Skip to main content

roder_core/
runtime.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::Context;
6use futures::StreamExt;
7use futures::future::{AbortHandle, Abortable, BoxFuture, try_join_all};
8use roder_api::catalog::{
9    EDIT_TOOL_EDIT, EDIT_TOOL_PATCH, PROVIDER_GEMINI, REASONING_NONE, built_in_model_profile,
10    built_in_model_profile_for_provider, lookup_model,
11};
12use roder_api::context::PolicyGate;
13use roder_api::events::*;
14use roder_api::extension::ExtensionRegistry;
15use roder_api::inference::{
16    AgentInferenceRequest, HostedWebSearchConfig, HostedWebSearchMode, InferenceEngine,
17    InferenceEvent, InferenceTurnContext, InstructionBundle, ModelHarnessProfile,
18    ModelSchemaPolicy, ModelSelection, OutputConfig, ReasoningConfig, RuntimeHints, RuntimeProfile,
19    TokenUsage, ToolCallCompleted, ToolSearchConfig, ToolSearchConfigOverlay,
20    finish_reason_from_stop_reason,
21};
22use roder_api::inference_routing::{InferenceRoutingOutcome, ModelSelectionMode};
23use roder_api::policy_mode::{PolicyDecision, PolicyMode};
24use roder_api::reliability::{
25    ReliabilityContext, ReliabilityDetails, ReliabilityErrorClass, ReliabilityLimitRecorded,
26    ReliabilityRequestPolicy, ReliabilityRetryDecision, ReliabilityRetryRecorded,
27    provider_retry_delay_ms,
28};
29use roder_api::remote_runner::{
30    RemoteRunnerSession, RemoteWorkspace, RunnerDestination, ThreadRunnerBinding,
31};
32use roder_api::subagents::SubagentDefinition;
33use roder_api::teams::TeamMemberStatus;
34use roder_api::thread::{
35    ThreadItemEvent, ThreadItemEventKind, ThreadMetadata, ThreadSnapshot, ThreadStore,
36    ThreadUsageMetadata, is_synthetic_event_thread_id, validate_thread_workspace,
37};
38use roder_api::tools::{ToolCall, ToolChoice, ToolExecutionContext, ToolRegistry, ToolResult};
39use roder_api::transcript::{
40    AssistantMessage, ErrorRecord, InputImage, ReasoningSummary, ToolCallRecord, ToolResultRecord,
41    TranscriptItem, UserMessage,
42};
43use roder_sandbox::ScopedFilesystem;
44use roder_sandbox::process::LocalProcessRunner;
45use roder_skills::{SkillRegistry, SkillRegistryOptions};
46use time::{Duration, OffsetDateTime};
47use tokio::sync::{Mutex, RwLock, oneshot};
48
49use crate::artifacts::{
50    ContextArtifactStore as FilesystemContextArtifactStore, default_context_artifact_dir,
51};
52use crate::bus::EventBus;
53use crate::dynamic_workflows::{
54    DynamicWorkflowEffortProfile, RuntimeDynamicWorkflowConfig, WorkflowTriggerDecision,
55    classify_workflow_trigger, ultracode_reasoning_level_for_model,
56};
57use crate::fake_provider::FakeInferenceEngine;
58use crate::goals::RuntimeGoalController;
59use crate::inference_routing::{
60    InferenceRoutingRequest, RuntimeInferenceRouterConfig, collect_inference_routing_candidates,
61    route_inference_selection, transcript_failure_count_since,
62};
63use crate::instructions::{
64    apply_model_instruction_overlay, apply_plan_mode, apply_runtime_profile,
65    apply_task_ledger_required, apply_thread_developer_instructions, apply_turn_developer_context,
66};
67use crate::policy_gate::DefaultPolicyGate;
68use crate::reliability::{
69    ReliabilityLimitHit, RuntimeReliabilityConfig, TurnReliabilityState,
70    provider_stream_retry_cause,
71};
72pub use crate::speed_policy::RuntimeSpeedPolicyConfig;
73use crate::speed_policy::{SpeedPolicyState, reasoning_from_decision};
74use crate::subagent_traces::RuntimeSubagentTraceSink;
75use crate::teams::{TeamManager, TeamMemberStartRequest, TeamStartRequest, TeamState};
76use crate::thread_item_cache::{ThreadItemCache, ThreadItemCacheEntry};
77use crate::verification_gate::VerificationGateState;
78
79const MAX_TOOL_ROUNDS_PER_TURN: usize = 1024;
80const FINAL_ANSWER_PHASE: &str = "final_answer";
81pub(crate) const TASK_LEDGER_TOOL_NAME: &str = "task_ledger.update";
82const TASK_LEDGER_COMPLETION_REMINDER_LIMIT: u8 = 2;
83const TASK_LEDGER_SCOREABLE_CHECKPOINT_SECONDS: u64 = 180;
84const TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT: u8 = 1;
85pub(crate) const MIN_CHILD_DEADLINE_SECONDS: u64 = 2;
86const MODEL_PROFILE_TRACE_KIND: &str = "model_profile_segment";
87const MODEL_SWITCH_SUMMARY_PREFIX: &str = "Model switch summary:";
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90enum InferenceTimeoutAction {
91    ScoreableCheckpoint,
92    Finalization,
93}
94
95#[derive(Debug, Clone)]
96pub struct RuntimeConfig {
97    pub default_provider: String,
98    pub default_model: String,
99    pub reasoning: Option<String>,
100    pub auto_compact_token_limit: Option<u32>,
101    pub file_backed_dynamic_context: bool,
102    pub hosted_web_search: HostedWebSearchConfig,
103    pub tool_search: ToolSearchConfig,
104    pub provider_tool_search: HashMap<String, ToolSearchConfigOverlay>,
105    pub model_tool_search: HashMap<String, ToolSearchConfigOverlay>,
106    pub model_edit_tools: HashMap<String, String>,
107    pub model_parallel_tool_calls: HashMap<String, bool>,
108    pub model_profiles: HashMap<String, ModelHarnessProfile>,
109    pub tool_allowlist: Vec<String>,
110    /// Seconds a host-executed external tool call may stay unresolved before it fails with a timeout error.
111    pub external_tool_timeout_seconds: u64,
112    pub command_shell: String,
113    pub workspace: Option<String>,
114    pub policy_mode: PolicyMode,
115    pub runtime_profile: RuntimeProfile,
116    pub inference_router: RuntimeInferenceRouterConfig,
117    pub speed_policy: RuntimeSpeedPolicyConfig,
118    pub dynamic_workflows: RuntimeDynamicWorkflowConfig,
119    pub reliability: RuntimeReliabilityConfig,
120    pub turn_deadline_seconds: Option<u64>,
121    pub remote_runner_destination: Option<RunnerDestination>,
122    pub team_data_dir: Option<PathBuf>,
123    pub roadmap_data_dir: Option<PathBuf>,
124    pub media_generation: crate::media_generation::RuntimeMediaGenerationConfig,
125}
126
127impl Default for RuntimeConfig {
128    fn default() -> Self {
129        Self {
130            default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
131            default_model: "mock".to_string(),
132            reasoning: None,
133            auto_compact_token_limit: None,
134            file_backed_dynamic_context: true,
135            hosted_web_search: HostedWebSearchConfig::cached(),
136            tool_search: ToolSearchConfig::default(),
137            provider_tool_search: HashMap::new(),
138            model_tool_search: HashMap::new(),
139            model_edit_tools: HashMap::new(),
140            model_parallel_tool_calls: HashMap::new(),
141            model_profiles: HashMap::new(),
142            tool_allowlist: Vec::new(),
143            external_tool_timeout_seconds: DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECONDS,
144            command_shell: roder_api::command_shell::default_command_shell(),
145            workspace: None,
146            policy_mode: PolicyMode::Default,
147            runtime_profile: RuntimeProfile::Interactive,
148            inference_router: RuntimeInferenceRouterConfig::default(),
149            speed_policy: RuntimeSpeedPolicyConfig::default(),
150            dynamic_workflows: RuntimeDynamicWorkflowConfig::default(),
151            reliability: RuntimeReliabilityConfig::default(),
152            turn_deadline_seconds: None,
153            remote_runner_destination: None,
154            team_data_dir: None,
155            roadmap_data_dir: None,
156            media_generation: crate::media_generation::RuntimeMediaGenerationConfig::default(),
157        }
158    }
159}
160
161#[derive(Debug, Clone)]
162pub struct StartTurnRequest {
163    pub thread_id: ThreadId,
164    pub message: String,
165    pub images: Vec<InputImage>,
166    pub provider_override: Option<String>,
167    pub model_override: Option<String>,
168    pub reasoning_override: Option<String>,
169    pub workspace: String,
170    pub instructions: InstructionBundle,
171    /**
172     * Per-turn developer-authority context for this turn's InstructionBundle.
173     * Applies to every inference round of the turn, is never written to
174     * thread state, and does not carry over to later turns.
175     */
176    pub developer_context: Option<String>,
177    pub task_ledger_required: bool,
178}
179
180#[derive(Debug, Clone)]
181pub struct CreateThreadRequest {
182    pub title: Option<String>,
183    pub workspace: String,
184    pub workspace_id: Option<String>,
185    pub root_id: Option<String>,
186    pub provider: Option<String>,
187    pub model: Option<String>,
188    pub selection_mode: Option<ModelSelectionMode>,
189    /// Per-thread tool filter applied on top of the runtime allowlist. Empty = no filtering.
190    pub tool_allowlist: Vec<String>,
191    /// Host-supplied instructions added to the developer slot of every turn's inference request.
192    pub developer_instructions: Option<String>,
193    /// Host-executed tool specs advertised to the model on every turn of this thread.
194    pub external_tools: Vec<roder_api::tools::ToolSpec>,
195    /// Explicit remote-runner binding for the thread's native coding tools.
196    pub runner: Option<ThreadRunnerSelection>,
197}
198
199/**
200 * Thread-level remote-runner selection. The destination config is persisted
201 * with the thread, so secrets must reach the provider through its
202 * environment, not this config.
203 */
204#[derive(Debug, Clone)]
205pub struct ThreadRunnerSelection {
206    pub provider_id: String,
207    pub config: serde_json::Value,
208    /// Absolute path on the runner used as the thread's coding-tool workspace root.
209    pub workspace: String,
210    /**
211     * Extra absolute runner paths file reads may resolve under, beyond
212     * `workspace`. Writes and the working directory stay confined to
213     * `workspace`.
214     */
215    pub read_roots: Vec<String>,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct PendingPlanExit {
220    pub thread_id: ThreadId,
221    pub turn_id: TurnId,
222    pub request_id: String,
223    pub target_mode: PolicyMode,
224    pub plan_summary: Option<String>,
225    pub next_steps: Vec<String>,
226    pub requested_at: OffsetDateTime,
227    pub expires_at: Option<OffsetDateTime>,
228}
229
230pub(crate) struct PendingToolApproval {
231    pub(crate) thread_id: ThreadId,
232    pub(crate) turn_id: TurnId,
233    pub(crate) tool_id: String,
234    pub(crate) tool_name: String,
235    pub(crate) call: roder_api::tools::ToolCall,
236    pub(crate) tx: oneshot::Sender<bool>,
237}
238
239pub(crate) struct PendingUserInput {
240    pub(crate) thread_id: ThreadId,
241    pub(crate) turn_id: TurnId,
242    pub(crate) tx: oneshot::Sender<serde_json::Value>,
243}
244
245/// Host answer to an external tool call delivered via `tools/resolve`.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct ExternalToolResolution {
248    pub output: String,
249    pub is_error: bool,
250}
251
252pub(crate) struct PendingExternalToolCall {
253    pub(crate) thread_id: ThreadId,
254    pub(crate) turn_id: TurnId,
255    pub(crate) tool_id: String,
256    pub(crate) tool_name: String,
257    pub(crate) tx: oneshot::Sender<ExternalToolResolution>,
258}
259
260#[derive(Clone)]
261struct ActiveTurnHandle {
262    thread_id: ThreadId,
263    abort: AbortHandle,
264    steers: Arc<Mutex<Vec<UserMessage>>>,
265}
266
267#[derive(Debug, Clone, Default, PartialEq, Eq)]
268pub struct ThreadActivity {
269    pub active_turn_id: Option<TurnId>,
270    pub active_flags: Vec<String>,
271}
272
273/// Per-thread settings persisted at thread creation and applied to every turn.
274#[derive(Debug, Clone, Default)]
275pub(crate) struct ThreadTurnOverrides {
276    pub(crate) tool_allowlist: Vec<String>,
277    pub(crate) developer_instructions: Option<String>,
278    pub(crate) external_tools: Vec<roder_api::tools::ToolSpec>,
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub(crate) enum TurnRunOutcome {
283    Completed,
284    Stopped,
285}
286
287impl PendingPlanExit {
288    pub fn new(
289        thread_id: ThreadId,
290        turn_id: TurnId,
291        request_id: String,
292        target_mode: PolicyMode,
293        plan_summary: Option<String>,
294        next_steps: Vec<String>,
295    ) -> Self {
296        let requested_at = OffsetDateTime::now_utc();
297        Self {
298            thread_id,
299            turn_id,
300            request_id,
301            target_mode,
302            plan_summary,
303            next_steps,
304            requested_at,
305            expires_at: Some(requested_at + default_plan_exit_timeout()),
306        }
307    }
308
309    pub fn is_expired(&self, now: OffsetDateTime) -> bool {
310        self.expires_at.is_some_and(|expires_at| now >= expires_at)
311    }
312}
313
314pub fn default_plan_exit_timeout() -> Duration {
315    Duration::minutes(10)
316}
317
318pub const DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECONDS: u64 = 300;
319
320pub struct Runtime {
321    pub bus: EventBus,
322    pub registry: ExtensionRegistry,
323    config: RwLock<RuntimeConfig>,
324    pending_plan_exit: RwLock<Option<PendingPlanExit>>,
325    pub(crate) pending_tool_approvals: Mutex<HashMap<String, PendingToolApproval>>,
326    pub(crate) pending_user_inputs: Mutex<HashMap<String, PendingUserInput>>,
327    pub(crate) pending_external_tool_calls: Mutex<HashMap<String, PendingExternalToolCall>>,
328    active_turns: RwLock<HashMap<TurnId, ActiveTurnHandle>>,
329    workspace: PathBuf,
330    teams: TeamManager,
331    pub(crate) roadmaps: Mutex<roder_roadmap::RoadmapRuntime>,
332    pub(crate) goals: Arc<RuntimeGoalController>,
333    context_artifacts: roder_api::artifacts::ContextArtifactStore,
334    pub(crate) thread_store: Option<Arc<dyn ThreadStore>>,
335    thread_item_cache: Mutex<ThreadItemCache>,
336    pub(crate) tool_registry: ToolRegistry,
337    media_generation: Arc<crate::media_generation::MediaGenerationService>,
338    pub(crate) skills: RwLock<SkillRegistry>,
339    /// Lazily-started bounded dispatch of emitted events to registry
340    /// `EventSink`s (process extensions etc.); see `event_sink_dispatch`.
341    event_sink_dispatcher: tokio::sync::OnceCell<crate::event_sink_dispatch::EventSinkDispatcher>,
342}
343
344impl Runtime {
345    pub fn new(registry: ExtensionRegistry, config: RuntimeConfig) -> anyhow::Result<Self> {
346        if registry.inference_engines.is_empty() {
347            anyhow::bail!("at least one inference engine must be registered");
348        }
349        validate_runtime_config_reasoning(&config)?;
350        validate_runtime_inference_router_config(&registry, &config)?;
351
352        let bus = EventBus::new(1024);
353        let thread_store = registry
354            .thread_stores
355            .first()
356            .map(|factory| factory.create());
357        let mut tool_registry = ToolRegistry::default();
358        for contributor in &registry.tools {
359            contributor
360                .contribute(&mut tool_registry)
361                .with_context(|| format!("tool contributor {} failed", contributor.id()))?;
362        }
363        crate::agent_control_tools::contribute_agent_control_tools(&mut tool_registry)?;
364
365        let media_generation = Arc::new(crate::media_generation::MediaGenerationService::new(
366            registry.media_generator_providers.clone(),
367            config.media_generation.clone(),
368        ));
369        tool_registry.replace(Arc::new(
370            crate::media_generation::MediaGenerateImageTool::new(media_generation.clone()),
371        ));
372
373        let team_data_dir = config.team_data_dir.clone();
374        let workspace = config
375            .workspace
376            .clone()
377            .map(PathBuf::from)
378            .unwrap_or(std::env::current_dir()?);
379        let roadmap_data_dir = config
380            .roadmap_data_dir
381            .clone()
382            .unwrap_or_else(|| workspace.join(".roder"));
383        let context_artifacts = thread_store
384            .as_ref()
385            .and_then(|store| store.context_artifact_store())
386            .or_else(|| {
387                thread_store
388                    .as_ref()
389                    .and_then(|store| store.local_thread_root())
390                    .map(FilesystemContextArtifactStore::shared_thread_scoped)
391            })
392            .unwrap_or_else(|| {
393                FilesystemContextArtifactStore::shared_legacy(default_context_artifact_dir())
394            });
395        let goals = Arc::new(RuntimeGoalController::new(
396            bus.clone(),
397            thread_store.clone(),
398        ));
399        let runtime = Self {
400            bus,
401            registry,
402            config: RwLock::new(config),
403            pending_plan_exit: RwLock::new(None),
404            pending_tool_approvals: Mutex::new(HashMap::new()),
405            pending_user_inputs: Mutex::new(HashMap::new()),
406            pending_external_tool_calls: Mutex::new(HashMap::new()),
407            active_turns: RwLock::new(HashMap::new()),
408            workspace: workspace.clone(),
409            teams: TeamManager::new(
410                team_data_dir.unwrap_or_else(crate::teams::default_team_data_dir),
411            ),
412            roadmaps: Mutex::new(roder_roadmap::RoadmapRuntime::new(
413                workspace,
414                roadmap_data_dir,
415            )),
416            goals,
417            context_artifacts,
418            thread_store,
419            thread_item_cache: Mutex::new(ThreadItemCache::default()),
420            tool_registry,
421            media_generation,
422            skills: RwLock::new(SkillRegistry::load(SkillRegistryOptions::new(
423                PathBuf::new(),
424            ))),
425            event_sink_dispatcher: tokio::sync::OnceCell::new(),
426        };
427        runtime.bus.emit(RoderEvent::RuntimeStarted(RuntimeStarted {
428            timestamp: OffsetDateTime::now_utc(),
429        }));
430        for manifest in &runtime.registry.manifests {
431            runtime
432                .bus
433                .emit(RoderEvent::ExtensionRegistered(ExtensionRegistered {
434                    extension_id: manifest.id.clone(),
435                    timestamp: OffsetDateTime::now_utc(),
436                }));
437        }
438        Ok(runtime)
439    }
440
441    pub fn from_engine(engine: Arc<dyn InferenceEngine>) -> anyhow::Result<Self> {
442        let mut builder = roder_api::extension::ExtensionRegistryBuilder::new();
443        builder.inference_engine(engine);
444        Self::new(builder.build()?, RuntimeConfig::default())
445    }
446
447    pub fn fake() -> anyhow::Result<Self> {
448        Self::from_engine(Arc::new(FakeInferenceEngine))
449    }
450
451    pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<EventEnvelope> {
452        self.bus.subscribe()
453    }
454
455    pub fn registry(&self) -> &ExtensionRegistry {
456        &self.registry
457    }
458
459    pub fn media_generation(&self) -> Arc<crate::media_generation::MediaGenerationService> {
460        self.media_generation.clone()
461    }
462
463    pub fn context_artifacts(&self) -> roder_api::artifacts::ContextArtifactStore {
464        self.context_artifacts.clone()
465    }
466
467    pub async fn execute_workflow_tool(
468        &self,
469        thread_id: ThreadId,
470        tool_name: &str,
471        arguments: serde_json::Value,
472    ) -> anyhow::Result<ToolResult> {
473        let Some(executor) = self.tool_registry.get(tool_name) else {
474            anyhow::bail!("tool not found: {tool_name}");
475        };
476        let tool_call = ToolCall {
477            id: format!("slash-{tool_name}"),
478            name: tool_name.to_string(),
479            raw_arguments: serde_json::to_string(&arguments)?,
480            arguments,
481            thread_id: thread_id.clone(),
482            turn_id: "slash-command".to_string(),
483        };
484        let runtime_config = self.status().await;
485        let ctx = self.tool_execution_context(
486            thread_id,
487            "slash-command".to_string(),
488            runtime_config.policy_mode,
489            runtime_config.workspace.as_deref(),
490            Some(&runtime_config.command_shell),
491        );
492        executor.execute(ctx, tool_call).await
493    }
494
495    pub(crate) fn tool_execution_context(
496        &self,
497        thread_id: ThreadId,
498        turn_id: TurnId,
499        mode: PolicyMode,
500        workspace: Option<&str>,
501        command_shell: Option<&str>,
502    ) -> ToolExecutionContext {
503        let mut ctx = ToolExecutionContext::new(thread_id, turn_id, mode)
504            .with_command_shell(command_shell.unwrap_or_default())
505            .with_process_runner(Arc::new(LocalProcessRunner))
506            .with_context_artifacts(self.context_artifacts.backend())
507            .with_goal_controller(self.goals.clone())
508            .with_subagent_trace_sink(Arc::new(RuntimeSubagentTraceSink::new(
509                self.bus.clone(),
510                self.thread_store.clone(),
511            )));
512        if let Some(workspace) = workspace {
513            ctx = ctx.with_workspace_handle(Arc::new(ScopedFilesystem::new(workspace)));
514        }
515        ctx
516    }
517
518    pub async fn status(&self) -> RuntimeConfig {
519        self.config.read().await.clone()
520    }
521
522    pub async fn set_skills(&self, skills: SkillRegistry) {
523        *self.skills.write().await = skills;
524    }
525
526    pub async fn skills_snapshot(&self) -> SkillRegistry {
527        self.skills.read().await.clone()
528    }
529
530    pub fn workspace(&self) -> PathBuf {
531        self.workspace.clone()
532    }
533
534    pub async fn set_remote_runner_destination(&self, destination: Option<RunnerDestination>) {
535        let lifecycle = destination.as_ref().map(|destination| RunnerLifecycle {
536            destination_id: destination.id.clone(),
537            provider_id: destination.provider_id.clone(),
538            state: "configured".to_string(),
539            session_id: None,
540            timestamp: OffsetDateTime::now_utc(),
541        });
542        self.config.write().await.remote_runner_destination = destination;
543        if let Some(lifecycle) = lifecycle {
544            self.emit(RoderEvent::RunnerLifecycle(lifecycle)).await;
545        } else {
546            self.emit(RoderEvent::RunnerLifecycle(RunnerLifecycle {
547                destination_id: "local".to_string(),
548                provider_id: "local".to_string(),
549                state: "local_fallback".to_string(),
550                session_id: None,
551                timestamp: OffsetDateTime::now_utc(),
552            }))
553            .await;
554        }
555    }
556
557    pub async fn set_file_backed_dynamic_context(&self, enabled: bool) -> RuntimeConfig {
558        let mut cfg = self.config.write().await;
559        cfg.file_backed_dynamic_context = enabled;
560        cfg.clone()
561    }
562
563    pub async fn set_command_shell(&self, shell: String) -> RuntimeConfig {
564        let mut cfg = self.config.write().await;
565        cfg.command_shell = shell;
566        cfg.clone()
567    }
568
569    pub async fn pending_plan_exit(&self) -> Option<PendingPlanExit> {
570        let mut pending = self.pending_plan_exit.write().await;
571        let current = pending.clone()?;
572        if !current.is_expired(OffsetDateTime::now_utc()) {
573            return Some(current);
574        }
575        *pending = None;
576        drop(pending);
577        self.emit_plan_exit_resolved(&current, false, self.status().await.policy_mode)
578            .await;
579        None
580    }
581
582    pub async fn set_policy_mode(
583        &self,
584        mode: PolicyMode,
585        reason: Option<String>,
586    ) -> anyhow::Result<RuntimeConfig> {
587        let mut cfg = self.config.write().await;
588        let previous_mode = cfg.policy_mode;
589        cfg.policy_mode = mode;
590        let next = cfg.clone();
591        drop(cfg);
592        self.emit(RoderEvent::PolicyModeChanged(PolicyModeChanged {
593            thread_id: "runtime".to_string(),
594            turn_id: None,
595            previous_mode,
596            new_mode: mode,
597            reason,
598            timestamp: OffsetDateTime::now_utc(),
599        }))
600        .await;
601        self.auto_resolve_pending_tool_approvals_for_mode(mode)
602            .await;
603        Ok(next)
604    }
605
606    pub async fn set_hosted_web_search(
607        &self,
608        mode: HostedWebSearchMode,
609    ) -> anyhow::Result<RuntimeConfig> {
610        let mut cfg = self.config.write().await;
611        cfg.hosted_web_search = HostedWebSearchConfig { mode };
612        Ok(cfg.clone())
613    }
614
615    async fn auto_resolve_pending_tool_approvals_for_mode(&self, mode: PolicyMode) {
616        let gate = DefaultPolicyGate::new();
617        let mut pending = self.pending_tool_approvals.lock().await;
618        let approval_ids = pending
619            .iter()
620            .filter_map(|(approval_id, approval)| {
621                let ctx = ToolExecutionContext::new(
622                    approval.thread_id.clone(),
623                    approval.turn_id.clone(),
624                    mode,
625                );
626                matches!(
627                    gate.decide(&approval.call, mode, &ctx),
628                    PolicyDecision::AutoApproved { .. }
629                )
630                .then_some(approval_id.clone())
631            })
632            .collect::<Vec<_>>();
633        let approvals = approval_ids
634            .into_iter()
635            .filter_map(|approval_id| {
636                pending
637                    .remove(&approval_id)
638                    .map(|approval| (approval_id, approval))
639            })
640            .collect::<Vec<_>>();
641        drop(pending);
642
643        for (approval_id, approval) in approvals {
644            let ctx = ToolExecutionContext::new(
645                approval.thread_id.clone(),
646                approval.turn_id.clone(),
647                mode,
648            );
649            let decision = gate.decide(&approval.call, mode, &ctx);
650            self.emit(RoderEvent::PolicyDecisionRecorded(PolicyDecisionRecorded {
651                thread_id: approval.thread_id.clone(),
652                turn_id: approval.turn_id.clone(),
653                tool_id: approval.tool_id.clone(),
654                tool_name: approval.tool_name.clone(),
655                mode,
656                decision,
657                timestamp: OffsetDateTime::now_utc(),
658            }))
659            .await;
660            if mode == PolicyMode::Bypass {
661                self.emit(RoderEvent::PolicyBypassActive(PolicyBypassActive {
662                    thread_id: approval.thread_id.clone(),
663                    turn_id: approval.turn_id.clone(),
664                    tool_id: approval.tool_id.clone(),
665                    tool_name: approval.tool_name.clone(),
666                    timestamp: OffsetDateTime::now_utc(),
667                }))
668                .await;
669            }
670            self.emit(RoderEvent::ApprovalResolved(ApprovalResolved {
671                thread_id: approval.thread_id,
672                turn_id: approval.turn_id,
673                approval_id,
674                tool_id: approval.tool_id,
675                tool_name: approval.tool_name,
676                approved: true,
677                timestamp: OffsetDateTime::now_utc(),
678            }))
679            .await;
680            let _ = approval.tx.send(true);
681        }
682    }
683
684    pub async fn record_pending_plan_exit(&self, pending: PendingPlanExit) {
685        *self.pending_plan_exit.write().await = Some(pending.clone());
686        self.emit(RoderEvent::PolicyExitPlanRequested(
687            PolicyExitPlanRequested {
688                thread_id: pending.thread_id,
689                turn_id: pending.turn_id,
690                request_id: pending.request_id,
691                target_mode: pending.target_mode,
692                plan_summary: pending.plan_summary,
693                next_steps: pending.next_steps,
694                timestamp: OffsetDateTime::now_utc(),
695            },
696        ))
697        .await;
698    }
699
700    pub async fn resolve_pending_plan_exit(
701        &self,
702        request_id: &str,
703        approved: bool,
704    ) -> anyhow::Result<Option<PendingPlanExit>> {
705        let mut pending = self.pending_plan_exit.write().await;
706        let Some(current) = pending.clone() else {
707            return Ok(None);
708        };
709        if current.request_id != request_id {
710            anyhow::bail!("pending plan exit request {request_id:?} was not found");
711        }
712        *pending = None;
713        drop(pending);
714
715        let approved = approved && !current.is_expired(OffsetDateTime::now_utc());
716        let resolved_mode = if approved {
717            let mut cfg = self.config.write().await;
718            let previous_mode = cfg.policy_mode;
719            cfg.policy_mode = current.target_mode;
720            drop(cfg);
721            self.emit(RoderEvent::PolicyModeChanged(PolicyModeChanged {
722                thread_id: current.thread_id.clone(),
723                turn_id: Some(current.turn_id.clone()),
724                previous_mode,
725                new_mode: current.target_mode,
726                reason: Some("approved plan exit".to_string()),
727                timestamp: OffsetDateTime::now_utc(),
728            }))
729            .await;
730            self.auto_resolve_pending_tool_approvals_for_mode(current.target_mode)
731                .await;
732            current.target_mode
733        } else {
734            self.status().await.policy_mode
735        };
736        self.emit_plan_exit_resolved(&current, approved, resolved_mode)
737            .await;
738        Ok(Some(current))
739    }
740
741    pub async fn resolve_tool_approval(
742        &self,
743        approval_id: &str,
744        approved: bool,
745    ) -> anyhow::Result<bool> {
746        let pending = self.pending_tool_approvals.lock().await.remove(approval_id);
747        let Some(pending) = pending else {
748            return Ok(false);
749        };
750        self.emit(RoderEvent::ApprovalResolved(ApprovalResolved {
751            thread_id: pending.thread_id,
752            turn_id: pending.turn_id,
753            approval_id: approval_id.to_string(),
754            tool_id: pending.tool_id,
755            tool_name: pending.tool_name,
756            approved,
757            timestamp: OffsetDateTime::now_utc(),
758        }))
759        .await;
760        let _ = pending.tx.send(approved);
761        Ok(true)
762    }
763
764    pub async fn request_app_server_tool_approval(
765        &self,
766        call: ToolCall,
767        reason: Option<String>,
768    ) -> anyhow::Result<bool> {
769        let approval_id = call.id.clone();
770        let (tx, rx) = oneshot::channel();
771        self.pending_tool_approvals.lock().await.insert(
772            approval_id.clone(),
773            PendingToolApproval {
774                thread_id: call.thread_id.clone(),
775                turn_id: call.turn_id.clone(),
776                tool_id: call.id.clone(),
777                tool_name: call.name.clone(),
778                call: call.clone(),
779                tx,
780            },
781        );
782        self.emit(RoderEvent::ApprovalRequested(ApprovalRequested {
783            thread_id: call.thread_id.clone(),
784            turn_id: call.turn_id.clone(),
785            approval_id,
786            tool_id: call.id.clone(),
787            tool_name: call.name.clone(),
788            reason,
789            timestamp: OffsetDateTime::now_utc(),
790        }))
791        .await;
792        Ok(rx.await.unwrap_or(false))
793    }
794
795    /// Completes a pending host-executed tool call (`tools/resolve`). Returns false when the
796    /// request id is unknown, already resolved, timed out, or cancelled by a turn interrupt.
797    pub async fn resolve_external_tool_call(
798        &self,
799        request_id: &str,
800        resolution: ExternalToolResolution,
801    ) -> anyhow::Result<bool> {
802        let pending = self
803            .pending_external_tool_calls
804            .lock()
805            .await
806            .remove(request_id);
807        let Some(pending) = pending else {
808            return Ok(false);
809        };
810        self.emit(RoderEvent::ExternalToolCallResolved(
811            ExternalToolCallResolved {
812                thread_id: pending.thread_id,
813                turn_id: pending.turn_id,
814                request_id: request_id.to_string(),
815                tool_id: pending.tool_id,
816                tool_name: pending.tool_name,
817                outcome: ExternalToolCallOutcome::Resolved,
818                is_error: resolution.is_error,
819                timestamp: OffsetDateTime::now_utc(),
820            },
821        ))
822        .await;
823        let _ = pending.tx.send(resolution);
824        Ok(true)
825    }
826
827    /// Drops every pending external tool call for the turn and reports them as cancelled so an
828    /// interrupt does not leak requests waiting on `tools/resolve`.
829    async fn cancel_pending_external_tool_calls_for_turn(&self, turn_id: &TurnId) {
830        let cancelled = {
831            let mut pending = self.pending_external_tool_calls.lock().await;
832            let request_ids = pending
833                .iter()
834                .filter(|(_, call)| &call.turn_id == turn_id)
835                .map(|(request_id, _)| request_id.clone())
836                .collect::<Vec<_>>();
837            request_ids
838                .into_iter()
839                .filter_map(|request_id| pending.remove(&request_id).map(|call| (request_id, call)))
840                .collect::<Vec<_>>()
841        };
842        for (request_id, call) in cancelled {
843            self.emit(RoderEvent::ExternalToolCallResolved(
844                ExternalToolCallResolved {
845                    thread_id: call.thread_id,
846                    turn_id: call.turn_id,
847                    request_id,
848                    tool_id: call.tool_id,
849                    tool_name: call.tool_name,
850                    outcome: ExternalToolCallOutcome::Cancelled,
851                    is_error: true,
852                    timestamp: OffsetDateTime::now_utc(),
853                },
854            ))
855            .await;
856        }
857    }
858
859    pub async fn resolve_user_input(
860        &self,
861        request_id: &str,
862        answers: serde_json::Value,
863    ) -> anyhow::Result<bool> {
864        let pending = self.pending_user_inputs.lock().await.remove(request_id);
865        let Some(pending) = pending else {
866            return Ok(false);
867        };
868        self.emit(RoderEvent::UserInputResolved(UserInputResolved {
869            thread_id: pending.thread_id,
870            turn_id: pending.turn_id,
871            request_id: request_id.to_string(),
872            answers: answers.clone(),
873            timestamp: OffsetDateTime::now_utc(),
874        }))
875        .await;
876        let _ = pending.tx.send(answers);
877        Ok(true)
878    }
879
880    async fn emit_plan_exit_resolved(
881        &self,
882        current: &PendingPlanExit,
883        approved: bool,
884        resolved_mode: PolicyMode,
885    ) {
886        self.emit(RoderEvent::PolicyExitPlanResolved(PolicyExitPlanResolved {
887            thread_id: current.thread_id.clone(),
888            turn_id: current.turn_id.clone(),
889            request_id: current.request_id.clone(),
890            approved,
891            target_mode: current.target_mode,
892            resolved_mode,
893            timestamp: OffsetDateTime::now_utc(),
894        }))
895        .await;
896    }
897
898    pub async fn select_provider(
899        &self,
900        provider: String,
901        model: Option<String>,
902        reasoning: Option<String>,
903    ) -> anyhow::Result<RuntimeConfig> {
904        let next = self
905            .preview_provider_selection(provider, model, reasoning)
906            .await?;
907        let mut cfg = self.config.write().await;
908        *cfg = next;
909        Ok(cfg.clone())
910    }
911
912    pub async fn preview_provider_selection(
913        &self,
914        provider: String,
915        model: Option<String>,
916        reasoning: Option<String>,
917    ) -> anyhow::Result<RuntimeConfig> {
918        self.engine_for(&provider)?;
919        let mut cfg = self.config.read().await.clone();
920        cfg.default_provider = provider;
921        if let Some(model) = model {
922            cfg.default_model = model;
923        }
924        if let Some(reasoning) = reasoning {
925            if reasoning == REASONING_NONE
926                && !model_supports_reasoning(&cfg.default_model, &reasoning)
927            {
928                return Ok(cfg.clone());
929            }
930            validate_reasoning_effort(&cfg.default_model, &reasoning)?;
931            cfg.reasoning = Some(reasoning);
932        }
933        Ok(cfg)
934    }
935
936    pub async fn effective_reasoning(&self) -> String {
937        let cfg = self.config.read().await;
938        effective_reasoning_for_model(&cfg, &cfg.default_model)
939    }
940
941    pub fn effective_reasoning_for_config(cfg: &RuntimeConfig) -> String {
942        effective_reasoning_for_model(cfg, &cfg.default_model)
943    }
944
945    pub async fn set_dynamic_workflow_effort(
946        &self,
947        effort_profile: DynamicWorkflowEffortProfile,
948    ) -> RuntimeConfig {
949        let mut cfg = self.config.write().await;
950        cfg.dynamic_workflows.effort_profile = effort_profile;
951        cfg.clone()
952    }
953
954    pub async fn dynamic_workflow_trigger_decision(
955        &self,
956        message: &str,
957    ) -> WorkflowTriggerDecision {
958        let cfg = self.config.read().await;
959        classify_workflow_trigger(message, &cfg.dynamic_workflows)
960    }
961
962    pub async fn create_thread(&self, title: Option<String>) -> anyhow::Result<ThreadMetadata> {
963        self.create_thread_with(CreateThreadRequest {
964            title,
965            workspace: self.workspace.display().to_string(),
966            workspace_id: None,
967            root_id: None,
968            provider: None,
969            model: None,
970            selection_mode: None,
971            tool_allowlist: Vec::new(),
972            developer_instructions: None,
973            external_tools: Vec::new(),
974            runner: None,
975        })
976        .await
977    }
978
979    /**
980     * Resolves an explicit thread-runner selection into a persisted binding.
981     * Fails fast at thread creation when the provider is missing or rejects
982     * the destination, instead of surfacing the error on the first tool call.
983     */
984    async fn resolve_thread_runner_binding(
985        &self,
986        thread_id: &str,
987        selection: ThreadRunnerSelection,
988    ) -> anyhow::Result<ThreadRunnerBinding> {
989        let provider = self
990            .registry
991            .remote_runner_providers
992            .iter()
993            .find(|provider| provider.id() == selection.provider_id)
994            .cloned()
995            .ok_or_else(|| {
996                anyhow::anyhow!(
997                    "remote runner provider {:?} is not installed",
998                    selection.provider_id
999                )
1000            })?;
1001        let workspace = selection.workspace.trim();
1002        anyhow::ensure!(
1003            std::path::Path::new(workspace).is_absolute(),
1004            "runner workspace must be an absolute path on the runner: {workspace:?}"
1005        );
1006        let mut read_roots = Vec::with_capacity(selection.read_roots.len());
1007        for read_root in &selection.read_roots {
1008            let trimmed = read_root.trim();
1009            anyhow::ensure!(
1010                std::path::Path::new(trimmed).is_absolute(),
1011                "runner read root must be an absolute path on the runner: {trimmed:?}"
1012            );
1013            read_roots.push(PathBuf::from(trimmed));
1014        }
1015        let destination = RunnerDestination {
1016            id: format!("thread-{thread_id}"),
1017            provider_id: selection.provider_id,
1018            config: selection.config,
1019            default_manifest: roder_api::remote_runner::RunnerManifest::default(),
1020        };
1021        provider.validate_destination(&destination).await?;
1022        Ok(ThreadRunnerBinding {
1023            destination,
1024            workspace: PathBuf::from(workspace),
1025            read_roots,
1026        })
1027    }
1028
1029    /// Validates a runner selection without creating a thread; the placeholder destination id only appears in error messages.
1030    pub async fn validate_thread_runner_selection(
1031        &self,
1032        selection: ThreadRunnerSelection,
1033    ) -> anyhow::Result<()> {
1034        self.resolve_thread_runner_binding("validate", selection)
1035            .await
1036            .map(|_| ())
1037    }
1038
1039    pub async fn create_thread_with(
1040        &self,
1041        req: CreateThreadRequest,
1042    ) -> anyhow::Result<ThreadMetadata> {
1043        let cfg = self.config.read().await.clone();
1044        let now = OffsetDateTime::now_utc();
1045        let workspace = validate_thread_workspace(&req.workspace)?;
1046        let provider = req.provider.unwrap_or(cfg.default_provider);
1047        let model = req.model.unwrap_or(cfg.default_model);
1048        let selection_mode = req
1049            .selection_mode
1050            .unwrap_or_else(|| ModelSelectionMode::manual(provider.clone(), model.clone(), None));
1051        let thread_id = uuid::Uuid::new_v4().to_string();
1052        let runner_binding = match req.runner {
1053            Some(selection) => Some(
1054                self.resolve_thread_runner_binding(&thread_id, selection)
1055                    .await?,
1056            ),
1057            None => None,
1058        };
1059        let runner_destination = runner_binding
1060            .as_ref()
1061            .map(|binding| binding.destination.clone())
1062            .or_else(|| cfg.remote_runner_destination.clone());
1063        let metadata = ThreadMetadata {
1064            thread_id,
1065            title: req.title,
1066            workspace,
1067            workspace_id: req.workspace_id,
1068            root_id: req.root_id,
1069            provider: Some(provider),
1070            model: Some(model),
1071            selection_mode: Some(selection_mode),
1072            tool_allowlist: req.tool_allowlist,
1073            developer_instructions: req.developer_instructions,
1074            external_tools: req.external_tools,
1075            runner_destination,
1076            runner_state: None,
1077            runner_binding,
1078            created_at: now,
1079            updated_at: now,
1080            message_count: 0,
1081            usage: None,
1082            parent_thread_id: None,
1083            forked_from_turn_id: None,
1084            workspace_fork: None,
1085        };
1086
1087        let metadata = if let Some(store) = &self.thread_store {
1088            store.create_thread(metadata).await?
1089        } else {
1090            metadata
1091        };
1092        self.emit(RoderEvent::ThreadCreated(ThreadCreated {
1093            thread_id: metadata.thread_id.clone(),
1094            timestamp: OffsetDateTime::now_utc(),
1095        }))
1096        .await;
1097        Ok(metadata)
1098    }
1099
1100    pub async fn list_threads(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
1101        if let Some(store) = &self.thread_store {
1102            return store.list_threads().await;
1103        }
1104        Ok(Vec::new())
1105    }
1106
1107    pub async fn list_threads_page(
1108        &self,
1109        options: roder_api::thread::ThreadListOptions,
1110    ) -> anyhow::Result<roder_api::thread::ThreadListPage> {
1111        if let Some(store) = &self.thread_store {
1112            return store.list_threads_page(options).await;
1113        }
1114        Ok(roder_api::thread::ThreadListPage::default())
1115    }
1116
1117    pub async fn load_thread_metadata(
1118        &self,
1119        thread_id: &str,
1120    ) -> anyhow::Result<Option<roder_api::thread::ThreadMetadata>> {
1121        if let Some(store) = &self.thread_store {
1122            return store.load_thread_metadata(&thread_id.to_string()).await;
1123        }
1124        Ok(None)
1125    }
1126
1127    pub async fn archive_thread(&self, thread_id: &str) -> anyhow::Result<bool> {
1128        let archived = if let Some(store) = &self.thread_store {
1129            store.archive_thread(&thread_id.to_string()).await?
1130        } else {
1131            false
1132        };
1133        if archived {
1134            self.thread_item_cache
1135                .lock()
1136                .await
1137                .remove_thread(&thread_id.to_string());
1138        }
1139        Ok(archived)
1140    }
1141
1142    pub async fn start_team(&self, req: TeamStartRequest) -> anyhow::Result<TeamState> {
1143        let cfg = self.config.read().await.clone();
1144        let workspace = self.workspace.display().to_string();
1145        let lead_thread_id = match req.lead_thread_id {
1146            Some(thread_id) => thread_id,
1147            None => {
1148                self.create_thread_with(CreateThreadRequest {
1149                    title: Some("Team lead".to_string()),
1150                    workspace: workspace.clone(),
1151                    workspace_id: None,
1152                    root_id: None,
1153                    provider: None,
1154                    model: None,
1155                    selection_mode: None,
1156                    tool_allowlist: Vec::new(),
1157                    developer_instructions: None,
1158                    external_tools: Vec::new(),
1159                    runner: None,
1160                })
1161                .await?
1162                .thread_id
1163            }
1164        };
1165        let team_id = uuid::Uuid::new_v4().to_string();
1166        let mut members = vec![crate::teams::lead_member(
1167            lead_thread_id.clone(),
1168            Some(cfg.default_provider.clone()),
1169            Some(cfg.default_model.clone()),
1170            cfg.policy_mode,
1171        )];
1172
1173        for (index, member) in req.members.into_iter().enumerate() {
1174            let thread = self
1175                .create_thread_with(CreateThreadRequest {
1176                    title: Some(member.name.clone()),
1177                    workspace: workspace.clone(),
1178                    workspace_id: None,
1179                    root_id: None,
1180                    provider: member.model_provider.clone(),
1181                    model: member.model.clone(),
1182                    selection_mode: None,
1183                    tool_allowlist: Vec::new(),
1184                    developer_instructions: None,
1185                    external_tools: Vec::new(),
1186                    runner: None,
1187                })
1188                .await?;
1189            let member_id = format!("member-{}", index + 1);
1190            let descriptor = crate::teams::teammate_member(
1191                member_id.clone(),
1192                member.name,
1193                thread.thread_id.clone(),
1194                member.model_provider.or(thread.provider),
1195                member.model.or(thread.model),
1196                cfg.policy_mode,
1197            );
1198            self.emit(RoderEvent::TeamMemberStarted(TeamMemberStarted {
1199                team_id: team_id.clone(),
1200                member_id,
1201                member_thread_id: thread.thread_id,
1202                role: descriptor.role,
1203                name: descriptor.name.clone(),
1204                timestamp: OffsetDateTime::now_utc(),
1205            }))
1206            .await;
1207            members.push(descriptor);
1208        }
1209
1210        let now = OffsetDateTime::now_utc();
1211        let team = self
1212            .teams
1213            .insert(TeamState {
1214                id: team_id.clone(),
1215                lead_thread_id: lead_thread_id.clone(),
1216                display_mode: req.display_mode,
1217                members,
1218                mailbox: Vec::new(),
1219                tasks: Vec::new(),
1220                created_at: now,
1221                updated_at: now,
1222            })
1223            .await?;
1224        self.emit(RoderEvent::TeamStarted(TeamStarted {
1225            team_id,
1226            lead_thread_id,
1227            display_mode: team.display_mode,
1228            timestamp: OffsetDateTime::now_utc(),
1229        }))
1230        .await;
1231        Ok(team)
1232    }
1233
1234    pub async fn list_teams(&self) -> Vec<TeamState> {
1235        self.teams.list().await
1236    }
1237
1238    pub async fn read_team(&self, team_id: &str) -> Option<TeamState> {
1239        self.teams.get(team_id).await
1240    }
1241
1242    pub async fn start_team_member(
1243        &self,
1244        team_id: &str,
1245        req: TeamMemberStartRequest,
1246    ) -> anyhow::Result<TeamState> {
1247        let cfg = self.config.read().await.clone();
1248        let thread = self
1249            .create_thread_with(CreateThreadRequest {
1250                title: Some(req.name.clone()),
1251                workspace: self.workspace.display().to_string(),
1252                workspace_id: None,
1253                root_id: None,
1254                provider: req.model_provider.clone(),
1255                model: req.model.clone(),
1256                selection_mode: None,
1257                tool_allowlist: Vec::new(),
1258                developer_instructions: None,
1259                external_tools: Vec::new(),
1260                runner: None,
1261            })
1262            .await?;
1263        let team = self
1264            .read_team(team_id)
1265            .await
1266            .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
1267        let member_id = format!("member-{}", team.members.len());
1268        let descriptor = crate::teams::teammate_member(
1269            member_id.clone(),
1270            req.name,
1271            thread.thread_id.clone(),
1272            req.model_provider.or(thread.provider),
1273            req.model.or(thread.model),
1274            cfg.policy_mode,
1275        );
1276        let mut next = team;
1277        next.members.push(descriptor.clone());
1278        next.updated_at = OffsetDateTime::now_utc();
1279        let next = self.teams.insert(next).await?;
1280        self.emit(RoderEvent::TeamMemberStarted(TeamMemberStarted {
1281            team_id: next.id.clone(),
1282            member_id,
1283            member_thread_id: descriptor.thread_id,
1284            role: descriptor.role,
1285            name: descriptor.name,
1286            timestamp: OffsetDateTime::now_utc(),
1287        }))
1288        .await;
1289        Ok(next)
1290    }
1291
1292    pub async fn message_team_member(
1293        self: &Arc<Self>,
1294        team_id: &str,
1295        member_id: &str,
1296        message: String,
1297    ) -> anyhow::Result<TurnId> {
1298        let team = self
1299            .read_team(team_id)
1300            .await
1301            .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
1302        let member = team
1303            .members
1304            .iter()
1305            .find(|member| member.id == member_id)
1306            .ok_or_else(|| anyhow::anyhow!("unknown team member {member_id:?}"))?
1307            .clone();
1308        if member.status == TeamMemberStatus::Closed {
1309            anyhow::bail!("subagent {} is closed", member.name);
1310        }
1311        self.teams
1312            .append_mailbox_message(team_id, None, member_id.to_string(), message.clone())
1313            .await?;
1314        let workspace = self.workspace.display().to_string();
1315        let turn_id = if member.status == TeamMemberStatus::Running {
1316            if let Some(turn_id) = member.current_turn_id.clone() {
1317                self.steer_turn(
1318                    member.thread_id.clone(),
1319                    turn_id.clone(),
1320                    message,
1321                    Vec::new(),
1322                )
1323                .await?;
1324                turn_id
1325            } else {
1326                self.start_turn(StartTurnRequest {
1327                    thread_id: member.thread_id.clone(),
1328                    message,
1329                    images: Vec::new(),
1330                    provider_override: member.model_provider.clone(),
1331                    model_override: member.model.clone(),
1332                    reasoning_override: None,
1333                    workspace: workspace.clone(),
1334                    instructions: crate::default_instructions(),
1335                    developer_context: None,
1336                    task_ledger_required: false,
1337                })
1338                .await?
1339            }
1340        } else {
1341            self.start_turn(StartTurnRequest {
1342                thread_id: member.thread_id.clone(),
1343                message,
1344                images: Vec::new(),
1345                provider_override: member.model_provider.clone(),
1346                model_override: member.model.clone(),
1347                reasoning_override: None,
1348                workspace,
1349                instructions: crate::default_instructions(),
1350                developer_context: None,
1351                task_ledger_required: false,
1352            })
1353            .await?
1354        };
1355        let is_active = self.active_turns.read().await.contains_key(&turn_id);
1356        self.teams
1357            .update_member(team_id, member_id, |member| {
1358                if is_active {
1359                    member.current_turn_id = Some(turn_id.clone());
1360                    member.status = TeamMemberStatus::Running;
1361                } else {
1362                    member.current_turn_id = None;
1363                    member.status = TeamMemberStatus::Completed;
1364                }
1365            })
1366            .await?;
1367        if is_active {
1368            self.emit(RoderEvent::TeamMemberStatusChanged(
1369                TeamMemberStatusChanged {
1370                    team_id: team_id.to_string(),
1371                    member_id: member_id.to_string(),
1372                    member_thread_id: member.thread_id,
1373                    status: TeamMemberStatus::Running,
1374                    timestamp: OffsetDateTime::now_utc(),
1375                },
1376            ))
1377            .await;
1378        } else {
1379            self.emit(RoderEvent::TeamMemberCompleted(TeamMemberCompleted {
1380                team_id: team_id.to_string(),
1381                member_id: member_id.to_string(),
1382                member_thread_id: member.thread_id,
1383                turn_id: Some(turn_id.clone()),
1384                status: TeamMemberStatus::Completed,
1385                timestamp: OffsetDateTime::now_utc(),
1386            }))
1387            .await;
1388        }
1389        Ok(turn_id)
1390    }
1391
1392    pub async fn set_team_member_policy_mode(
1393        &self,
1394        team_id: &str,
1395        member_id: &str,
1396        policy_mode: PolicyMode,
1397    ) -> anyhow::Result<TeamState> {
1398        self.teams
1399            .set_member_policy_mode(team_id, member_id, policy_mode)
1400            .await
1401    }
1402
1403    pub async fn interrupt_team_member(
1404        &self,
1405        team_id: &str,
1406        member_id: &str,
1407    ) -> anyhow::Result<Option<TurnId>> {
1408        let team = self
1409            .read_team(team_id)
1410            .await
1411            .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
1412        let member = team
1413            .members
1414            .iter()
1415            .find(|member| member.id == member_id)
1416            .ok_or_else(|| anyhow::anyhow!("unknown team member {member_id:?}"))?
1417            .clone();
1418        let Some(turn_id) = member.current_turn_id.clone() else {
1419            return Ok(None);
1420        };
1421        self.interrupt_turn(member.thread_id.clone(), turn_id.clone())
1422            .await?;
1423        self.teams
1424            .update_member(team_id, member_id, |member| {
1425                member.status = TeamMemberStatus::Interrupted;
1426                member.current_turn_id = None;
1427            })
1428            .await?;
1429        self.emit(RoderEvent::TeamMemberCompleted(TeamMemberCompleted {
1430            team_id: team_id.to_string(),
1431            member_id: member_id.to_string(),
1432            member_thread_id: member.thread_id,
1433            turn_id: Some(turn_id.clone()),
1434            status: TeamMemberStatus::Interrupted,
1435            timestamp: OffsetDateTime::now_utc(),
1436        }))
1437        .await;
1438        Ok(Some(turn_id))
1439    }
1440
1441    pub async fn close_team_member(
1442        &self,
1443        team_id: &str,
1444        member_id: &str,
1445    ) -> anyhow::Result<roder_api::teams::TeamMemberDescriptor> {
1446        let team = self
1447            .read_team(team_id)
1448            .await
1449            .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
1450        let member = team
1451            .members
1452            .iter()
1453            .find(|member| member.id == member_id)
1454            .ok_or_else(|| anyhow::anyhow!("unknown team member {member_id:?}"))?
1455            .clone();
1456        if member.role == roder_api::teams::TeamMemberRole::Lead {
1457            anyhow::bail!("team lead cannot be closed as a subagent");
1458        }
1459        let interrupted_turn_id = if member.status == TeamMemberStatus::Running {
1460            if let Some(turn_id) = member.current_turn_id.clone() {
1461                self.interrupt_turn(member.thread_id.clone(), turn_id.clone())
1462                    .await?;
1463                Some(turn_id)
1464            } else {
1465                None
1466            }
1467        } else {
1468            member.current_turn_id.clone()
1469        };
1470        let updated = self
1471            .teams
1472            .update_member(team_id, member_id, |member| {
1473                member.status = TeamMemberStatus::Closed;
1474                member.current_turn_id = None;
1475            })
1476            .await?;
1477        let closed = updated
1478            .members
1479            .iter()
1480            .find(|member| member.id == member_id)
1481            .cloned()
1482            .ok_or_else(|| anyhow::anyhow!("closed team member disappeared"))?;
1483        self.emit(crate::agent_control_tools::closed_member_event(
1484            team_id.to_string(),
1485            &closed,
1486            interrupted_turn_id,
1487        ))
1488        .await;
1489        Ok(closed)
1490    }
1491
1492    pub async fn cleanup_team(&self, team_id: &str, force: bool) -> anyhow::Result<bool> {
1493        let Some(team) = self.read_team(team_id).await else {
1494            return Ok(false);
1495        };
1496        if !force
1497            && team
1498                .members
1499                .iter()
1500                .any(|member| member.status == TeamMemberStatus::Running)
1501        {
1502            anyhow::bail!("team {team_id:?} has active teammates; use forced cleanup");
1503        }
1504        let removed = self.teams.remove(team_id).await?.is_some();
1505        if removed {
1506            self.emit(RoderEvent::TeamCleanupCompleted(TeamCleanupCompleted {
1507                team_id: team_id.to_string(),
1508                forced: force,
1509                timestamp: OffsetDateTime::now_utc(),
1510            }))
1511            .await;
1512        }
1513        Ok(removed)
1514    }
1515
1516    pub async fn effective_policy_mode_for_thread(&self, thread_id: &str) -> PolicyMode {
1517        if let Some(mode) = self.teams.policy_mode_for_thread(thread_id).await {
1518            return mode;
1519        }
1520        self.status().await.policy_mode
1521    }
1522
1523    async fn complete_team_member_turn(
1524        &self,
1525        thread_id: &ThreadId,
1526        turn_id: &TurnId,
1527        status: TeamMemberStatus,
1528    ) -> anyhow::Result<()> {
1529        let Some((team_id, member)) = self
1530            .teams
1531            .complete_member_turn(thread_id, turn_id, status)
1532            .await?
1533        else {
1534            return Ok(());
1535        };
1536        self.emit(RoderEvent::TeamMemberCompleted(TeamMemberCompleted {
1537            team_id,
1538            member_id: member.id,
1539            member_thread_id: member.thread_id,
1540            turn_id: Some(turn_id.clone()),
1541            status,
1542            timestamp: OffsetDateTime::now_utc(),
1543        }))
1544        .await;
1545        Ok(())
1546    }
1547
1548    pub async fn load_thread(
1549        &self,
1550        thread_id: &ThreadId,
1551    ) -> anyhow::Result<Option<ThreadSnapshot>> {
1552        let loaded = if let Some(store) = &self.thread_store {
1553            store.load_thread(thread_id).await?
1554        } else {
1555            None
1556        };
1557        if loaded.is_some() {
1558            self.emit(RoderEvent::ThreadLoaded(ThreadLoaded {
1559                thread_id: thread_id.clone(),
1560                timestamp: OffsetDateTime::now_utc(),
1561            }))
1562            .await;
1563        }
1564        Ok(loaded)
1565    }
1566
1567    pub async fn workspace_for_thread(&self, thread_id: &ThreadId) -> anyhow::Result<String> {
1568        if let Some(store) = &self.thread_store {
1569            let snapshot = store
1570                .load_thread(thread_id)
1571                .await?
1572                .ok_or_else(|| anyhow::anyhow!("thread not found: {thread_id}"));
1573            match snapshot {
1574                Ok(snapshot) => {
1575                    if let Some(metadata) = snapshot.metadata {
1576                        // Fork-backed threads fail closed before any write
1577                        // when their workspace was removed out-of-band.
1578                        if let Some(fork) = &metadata.workspace_fork
1579                            && fork.status == roder_api::forks::ForkStatus::Active
1580                            && !std::path::Path::new(&metadata.workspace).is_dir()
1581                        {
1582                            anyhow::bail!(
1583                                "workspace fork {} is missing its workspace at {}; restore it or \
1584                                 remove the fork before running turns in this thread",
1585                                fork.id,
1586                                metadata.workspace
1587                            );
1588                        }
1589                        return Ok(metadata.workspace);
1590                    }
1591                    eprintln!(
1592                        "thread metadata missing while resolving workspace for {thread_id}; falling back to runtime workspace"
1593                    );
1594                }
1595                Err(err) => {
1596                    eprintln!(
1597                        "thread missing while resolving workspace for {thread_id}: {err}; falling back to runtime workspace"
1598                    );
1599                }
1600            }
1601        }
1602        Ok(self.workspace.display().to_string())
1603    }
1604
1605    async fn selection_mode_for_thread(
1606        &self,
1607        thread_id: &ThreadId,
1608    ) -> anyhow::Result<Option<ModelSelectionMode>> {
1609        let Some(store) = &self.thread_store else {
1610            return Ok(None);
1611        };
1612        Ok(store
1613            .load_thread(thread_id)
1614            .await?
1615            .and_then(|snapshot| snapshot.metadata)
1616            .and_then(|metadata| {
1617                metadata
1618                    .selection_mode
1619                    .or_else(|| match (metadata.provider, metadata.model) {
1620                        (Some(provider), Some(model)) => {
1621                            Some(ModelSelectionMode::manual(provider, model, None))
1622                        }
1623                        _ => None,
1624                    })
1625            }))
1626    }
1627
1628    /// Per-thread tool allowlist, developer instructions, and external tools persisted at thread creation.
1629    pub(crate) async fn thread_turn_overrides(
1630        &self,
1631        thread_id: &ThreadId,
1632    ) -> anyhow::Result<ThreadTurnOverrides> {
1633        let Some(store) = &self.thread_store else {
1634            return Ok(ThreadTurnOverrides::default());
1635        };
1636        Ok(store
1637            .load_thread_metadata(thread_id)
1638            .await?
1639            .map(|metadata| ThreadTurnOverrides {
1640                tool_allowlist: metadata.tool_allowlist,
1641                developer_instructions: metadata.developer_instructions,
1642                external_tools: metadata.external_tools,
1643            })
1644            .unwrap_or_default())
1645    }
1646
1647    pub async fn set_thread_selection_mode(
1648        &self,
1649        thread_id: &ThreadId,
1650        selection_mode: ModelSelectionMode,
1651    ) -> anyhow::Result<()> {
1652        let Some(store) = &self.thread_store else {
1653            return Ok(());
1654        };
1655        let Some(snapshot) = store.load_thread(thread_id).await? else {
1656            anyhow::bail!("thread not found: {thread_id}");
1657        };
1658        let Some(mut metadata) = snapshot.metadata else {
1659            return Ok(());
1660        };
1661        let concrete = selection_mode.concrete_selection();
1662        metadata.provider = Some(concrete.provider);
1663        metadata.model = Some(concrete.model);
1664        metadata.selection_mode = Some(selection_mode);
1665        metadata.updated_at = OffsetDateTime::now_utc();
1666        store.update_thread_metadata(metadata).await?;
1667        Ok(())
1668    }
1669
1670    async fn runner_session_for_thread(
1671        &self,
1672        thread_id: &ThreadId,
1673    ) -> anyhow::Result<Option<(RunnerDestination, Arc<dyn RemoteRunnerSession>)>> {
1674        let metadata = if let Some(store) = &self.thread_store {
1675            store.load_thread_metadata(thread_id).await?
1676        } else {
1677            None
1678        };
1679        // An explicit per-thread binding wins over the runtime-level destination.
1680        let destination = metadata
1681            .as_ref()
1682            .and_then(|metadata| metadata.runner_binding.as_ref())
1683            .map(|binding| binding.destination.clone())
1684            .or(self.config.read().await.remote_runner_destination.clone());
1685        let Some(destination) = destination else {
1686            return Ok(None);
1687        };
1688        let provider = self
1689            .registry
1690            .remote_runner_providers
1691            .iter()
1692            .find(|provider| provider.id() == destination.provider_id)
1693            .cloned()
1694            .ok_or_else(|| {
1695                anyhow::anyhow!(
1696                    "remote runner provider {:?} is not installed",
1697                    destination.provider_id
1698                )
1699            })?;
1700        let persisted_state = metadata.and_then(|metadata| metadata.runner_state);
1701        let session = if let Some(state) = persisted_state
1702            && state.provider_id == destination.provider_id
1703            && state.destination_id == destination.id
1704        {
1705            match provider.resume_session(state).await {
1706                Ok(session) => session,
1707                Err(_) => provider.create_session(destination.clone()).await?,
1708            }
1709        } else {
1710            provider.create_session(destination.clone()).await?
1711        };
1712        Ok(Some((destination, session)))
1713    }
1714
1715    /**
1716     * Remote workspace for tool execution on a runner-bound thread. `None`
1717     * for threads without an explicit binding, including threads on a
1718     * runtime-level `runners/select` destination — those keep local tools.
1719     */
1720    pub(crate) async fn remote_workspace_for_thread(
1721        &self,
1722        thread_id: &ThreadId,
1723    ) -> anyhow::Result<Option<Arc<RemoteWorkspace>>> {
1724        let Some(store) = &self.thread_store else {
1725            return Ok(None);
1726        };
1727        let binding = store
1728            .load_thread_metadata(thread_id)
1729            .await?
1730            .and_then(|metadata| metadata.runner_binding);
1731        let Some(binding) = binding else {
1732            return Ok(None);
1733        };
1734        let session = self
1735            .runner_session_for_thread(thread_id)
1736            .await?
1737            .map(|(_, session)| session)
1738            .ok_or_else(|| {
1739                anyhow::anyhow!("runner-bound thread {thread_id} has no runner session")
1740            })?;
1741        Ok(Some(Arc::new(RemoteWorkspace {
1742            session,
1743            root: binding.workspace,
1744            read_roots: binding.read_roots,
1745        })))
1746    }
1747
1748    async fn persist_runner_state(
1749        &self,
1750        thread_id: &ThreadId,
1751        runner: Option<&(RunnerDestination, Arc<dyn RemoteRunnerSession>)>,
1752    ) -> anyhow::Result<()> {
1753        let Some((destination, session)) = runner else {
1754            return Ok(());
1755        };
1756        let Some(store) = &self.thread_store else {
1757            return Ok(());
1758        };
1759        let Some(snapshot) = store.load_thread(thread_id).await? else {
1760            return Ok(());
1761        };
1762        let Some(mut metadata) = snapshot.metadata else {
1763            return Ok(());
1764        };
1765        metadata.runner_destination = Some(destination.clone());
1766        metadata.runner_state = Some(session.state());
1767        metadata.updated_at = OffsetDateTime::now_utc();
1768        store.update_thread_metadata(metadata).await?;
1769        Ok(())
1770    }
1771
1772    async fn record_thread_usage_metadata(
1773        &self,
1774        thread_id: &ThreadId,
1775        usage: &TokenUsage,
1776    ) -> anyhow::Result<()> {
1777        if usage.is_empty() {
1778            return Ok(());
1779        }
1780        let Some(store) = &self.thread_store else {
1781            return Ok(());
1782        };
1783        let Some(snapshot) = store.load_thread(thread_id).await? else {
1784            return Ok(());
1785        };
1786        let Some(mut metadata) = snapshot.metadata else {
1787            return Ok(());
1788        };
1789        metadata
1790            .usage
1791            .get_or_insert_with(ThreadUsageMetadata::default)
1792            .add_token_usage(usage);
1793        metadata.updated_at = OffsetDateTime::now_utc();
1794        store.update_thread_metadata(metadata).await?;
1795        Ok(())
1796    }
1797
1798    pub fn start_turn(
1799        self: &Arc<Self>,
1800        mut req: StartTurnRequest,
1801    ) -> BoxFuture<'_, anyhow::Result<TurnId>> {
1802        Box::pin(async move {
1803            req.workspace = validate_thread_workspace(&req.workspace)?;
1804            let cfg = self.config.read().await.clone();
1805            let provider = req
1806                .provider_override
1807                .clone()
1808                .unwrap_or_else(|| cfg.default_provider.clone());
1809            self.engine_for(&provider)?;
1810            let turn_id = uuid::Uuid::new_v4().to_string();
1811            let (abort_handle, abort_registration) = AbortHandle::new_pair();
1812            let active = ActiveTurnHandle {
1813                thread_id: req.thread_id.clone(),
1814                abort: abort_handle,
1815                steers: Arc::new(Mutex::new(Vec::new())),
1816            };
1817            self.active_turns
1818                .write()
1819                .await
1820                .insert(turn_id.clone(), active);
1821            let runtime = Arc::clone(self);
1822            let turn_req = req;
1823            let thread_id_for_task = turn_req.thread_id.clone();
1824            let turn_id_for_task = turn_id.clone();
1825            tokio::spawn(async move {
1826                let result = Abortable::new(
1827                    runtime.run_turn(turn_req, turn_id_for_task.clone()),
1828                    abort_registration,
1829                )
1830                .await;
1831                /*
1832                 * A failed sibling in a parallel tool batch drops in-flight external tool
1833                 * futures (`try_join_all` in `route_tool_calls`), stranding their
1834                 * `pending_external_tool_calls` entries. Sweep before reporting the turn
1835                 * outcome so every `thread/toolExecutionRequested` gets a terminal
1836                 * resolution; on clean completion the map holds nothing for this turn.
1837                 */
1838                runtime
1839                    .cancel_pending_external_tool_calls_for_turn(&turn_id_for_task)
1840                    .await;
1841                let completed = matches!(&result, Ok(Ok(TurnRunOutcome::Completed)));
1842                if let Ok(Err(err)) = &result {
1843                    // run_turn emits failures after the stream starts; this covers setup/startup errors.
1844                    runtime
1845                        .emit(RoderEvent::TurnFailed(TurnFailed {
1846                            thread_id: thread_id_for_task.clone(),
1847                            turn_id: turn_id_for_task.clone(),
1848                            error: err.to_string(),
1849                            error_kind: None,
1850                            usage: None,
1851                            timestamp: OffsetDateTime::now_utc(),
1852                        }))
1853                        .await;
1854                }
1855                runtime.active_turns.write().await.remove(&turn_id_for_task);
1856                if completed {
1857                    let _ = runtime
1858                        .continue_active_goal_after_turn(thread_id_for_task)
1859                        .await;
1860                }
1861            });
1862            Ok(turn_id)
1863        })
1864    }
1865
1866    pub(crate) async fn has_active_turn_for_thread(&self, thread_id: &ThreadId) -> bool {
1867        self.active_turns
1868            .read()
1869            .await
1870            .values()
1871            .any(|handle| &handle.thread_id == thread_id)
1872    }
1873
1874    /// Number of currently running turns across all threads. Hosted runtime
1875    /// pools use this to avoid evicting tenants with active work.
1876    pub async fn active_turn_count(&self) -> usize {
1877        self.active_turns.read().await.len()
1878    }
1879
1880    pub async fn active_turn_for_thread(&self, thread_id: &ThreadId) -> Option<TurnId> {
1881        self.active_turns
1882            .read()
1883            .await
1884            .iter()
1885            .find_map(|(turn_id, handle)| (&handle.thread_id == thread_id).then(|| turn_id.clone()))
1886    }
1887
1888    pub async fn thread_activity(&self, thread_id: &ThreadId) -> ThreadActivity {
1889        let Some(active_turn_id) = self.active_turn_for_thread(thread_id).await else {
1890            return ThreadActivity::default();
1891        };
1892
1893        let mut active_flags = Vec::new();
1894        {
1895            let pending_approvals = self.pending_tool_approvals.lock().await;
1896            if pending_approvals
1897                .values()
1898                .any(|pending| &pending.thread_id == thread_id && pending.turn_id == active_turn_id)
1899            {
1900                active_flags.push("approvalRequired".to_string());
1901            }
1902        }
1903        {
1904            let pending_inputs = self.pending_user_inputs.lock().await;
1905            if pending_inputs
1906                .values()
1907                .any(|pending| &pending.thread_id == thread_id && pending.turn_id == active_turn_id)
1908            {
1909                active_flags.push("userInputRequired".to_string());
1910            }
1911        }
1912        {
1913            let pending_external = self.pending_external_tool_calls.lock().await;
1914            if pending_external
1915                .values()
1916                .any(|pending| &pending.thread_id == thread_id && pending.turn_id == active_turn_id)
1917            {
1918                active_flags.push("externalToolPending".to_string());
1919            }
1920        }
1921        if self.pending_plan_exit().await.is_some_and(|pending| {
1922            &pending.thread_id == thread_id && pending.turn_id == active_turn_id
1923        }) {
1924            active_flags.push("planExitRequired".to_string());
1925        }
1926
1927        ThreadActivity {
1928            active_turn_id: Some(active_turn_id),
1929            active_flags,
1930        }
1931    }
1932
1933    pub async fn interrupt_turn(&self, thread_id: ThreadId, turn_id: TurnId) -> anyhow::Result<()> {
1934        if let Some(handle) = self.active_turns.write().await.remove(&turn_id) {
1935            handle.abort.abort();
1936        }
1937        self.cancel_pending_external_tool_calls_for_turn(&turn_id)
1938            .await;
1939        self.emit(RoderEvent::TurnInterrupted(TurnInterrupted {
1940            thread_id,
1941            turn_id,
1942            timestamp: OffsetDateTime::now_utc(),
1943        }))
1944        .await;
1945        Ok(())
1946    }
1947
1948    pub async fn steer_turn(
1949        &self,
1950        thread_id: ThreadId,
1951        turn_id: TurnId,
1952        message: String,
1953        images: Vec<InputImage>,
1954    ) -> anyhow::Result<()> {
1955        let message = message.trim().to_string();
1956        if message.is_empty() && images.is_empty() {
1957            return Ok(());
1958        }
1959
1960        let Some(active) = self.active_turns.read().await.get(&turn_id).cloned() else {
1961            anyhow::bail!("no active turn to steer");
1962        };
1963        active
1964            .steers
1965            .lock()
1966            .await
1967            .push(UserMessage::with_images(message.clone(), images));
1968        self.emit(RoderEvent::TurnSteered(TurnSteered {
1969            thread_id,
1970            turn_id,
1971            message,
1972            timestamp: OffsetDateTime::now_utc(),
1973        }))
1974        .await;
1975        Ok(())
1976    }
1977
1978    pub async fn tool_specs(&self) -> Vec<roder_api::tools::ToolSpec> {
1979        let cfg = self.config.read().await;
1980        let model_profile =
1981            model_profile_for_provider_model(&cfg, &cfg.default_provider, &cfg.default_model);
1982        self.filtered_tool_specs(&cfg, &cfg.default_model, model_profile.as_ref(), &[], &[])
1983    }
1984
1985    pub fn subagent_definitions(&self) -> Vec<SubagentDefinition> {
1986        self.registry
1987            .subagent_dispatchers
1988            .iter()
1989            .flat_map(|dispatcher| dispatcher.definitions())
1990            .collect()
1991    }
1992
1993    async fn run_turn(
1994        self: &Arc<Self>,
1995        req: StartTurnRequest,
1996        turn_id: TurnId,
1997    ) -> anyhow::Result<TurnRunOutcome> {
1998        let turn_started_at = OffsetDateTime::now_utc();
1999        self.emit(RoderEvent::TurnStarted(TurnStarted {
2000            thread_id: req.thread_id.clone(),
2001            turn_id: turn_id.clone(),
2002            runtime_profile: self.config.read().await.runtime_profile,
2003            timestamp: turn_started_at,
2004        }))
2005        .await;
2006        self.persist_turn_item(
2007            &req.thread_id,
2008            &turn_id,
2009            &TranscriptItem::UserMessage(UserMessage::with_images(
2010                req.message.clone(),
2011                req.images.clone(),
2012            )),
2013        )
2014        .await?;
2015
2016        let mut cfg = self.config.read().await.clone();
2017        let runtime_profile = cfg.runtime_profile;
2018        let turn_deadline = turn_deadline_for_config(&cfg);
2019        let deadline_finalization_reserve =
2020            crate::deadline_policy::finalization_reserve_seconds(cfg.turn_deadline_seconds);
2021        let selection_mode = self.selection_mode_for_thread(&req.thread_id).await?;
2022        let concrete_selection = selection_mode
2023            .as_ref()
2024            .map(ModelSelectionMode::concrete_selection);
2025        let default_provider = req
2026            .provider_override
2027            .clone()
2028            .or_else(|| {
2029                concrete_selection
2030                    .as_ref()
2031                    .map(|selection| selection.provider.clone())
2032            })
2033            .unwrap_or(cfg.default_provider.clone());
2034        let default_model = req
2035            .model_override
2036            .clone()
2037            .or_else(|| {
2038                concrete_selection
2039                    .as_ref()
2040                    .map(|selection| selection.model.clone())
2041            })
2042            .unwrap_or(cfg.default_model.clone());
2043        if let Some(reasoning) = req.reasoning_override.as_deref().or_else(|| {
2044            selection_mode
2045                .as_ref()
2046                .and_then(ModelSelectionMode::reasoning)
2047        }) {
2048            validate_reasoning_effort(&default_model, reasoning)?;
2049            cfg.reasoning = Some(reasoning.to_string());
2050        }
2051        let turn_has_concrete_model_override =
2052            req.provider_override.is_some() || req.model_override.is_some();
2053        let (turn_inference_router, turn_inference_router_profile) = match &selection_mode {
2054            Some(ModelSelectionMode::Auto {
2055                router_id, profile, ..
2056            }) if !turn_has_concrete_model_override => (
2057                RuntimeInferenceRouterConfig {
2058                    enabled: true,
2059                    router_id: Some(router_id.clone()),
2060                },
2061                profile.clone(),
2062            ),
2063            _ => (RuntimeInferenceRouterConfig::disabled(), None),
2064        };
2065        let mut provider = default_provider.clone();
2066        let mut model = default_model.clone();
2067        let mut model_profile = model_profile_for_provider_model(&cfg, &provider, &model);
2068        let workspace = req.workspace.clone();
2069        let mut transcript = self.transcript_for_turn(&req, &turn_id, &model).await?;
2070        let runner_session = self.runner_session_for_thread(&req.thread_id).await?;
2071        let effective_policy_mode = self.effective_policy_mode_for_thread(&req.thread_id).await;
2072        let thread_overrides = self.thread_turn_overrides(&req.thread_id).await?;
2073        let mut final_assistant_text = String::new();
2074        let mut final_phase_messages = Vec::<AssistantMessage>::new();
2075        let mut final_reasoning_text = String::new();
2076        let mut final_provider_metadata = None;
2077        let mut exhausted_tool_rounds = true;
2078        let mut verification_gate =
2079            VerificationGateState::new(req.message.clone(), runtime_profile);
2080        let mut speed_policy = SpeedPolicyState::default();
2081        let mut reliability = TurnReliabilityState::default();
2082        let mut turn_usage = TokenUsage::default();
2083        // Overwritten on every inference step's Completed event so only the
2084        // terminal step's stop reason survives (mid-turn tool_use steps must
2085        // not leak onto turn/completed).
2086        let mut turn_finish_reason: Option<String> = None;
2087        let mut deadline_finalization_requested = false;
2088        let mut deadline_scoreable_completion_requested = false;
2089        let mut task_ledger_completion_reminders = 0_u8;
2090        let mut task_ledger_scoreable_checkpoints = 0_u8;
2091        let mut provider_stream_retry_attempts = 0_u32;
2092        let mut routing_candidates = None;
2093        let routing_transcript_start = transcript.len().saturating_sub(1);
2094        let mut routing_escalations = 0_u32;
2095        let mut model_switch_summary_selection = None::<ModelSelection>;
2096
2097        'tool_rounds: for round_index in 0..MAX_TOOL_ROUNDS_PER_TURN {
2098            if let Some(deadline) = turn_deadline
2099                && deadline_expired(deadline)
2100            {
2101                self.fail_turn_due_to_deadline(&req.thread_id, &turn_id, deadline, &transcript)
2102                    .await?;
2103                return Ok(TurnRunOutcome::Stopped);
2104            }
2105            let steers = self.drain_turn_steers(&turn_id).await;
2106            self.append_steers(&req, &turn_id, &mut transcript, steers)
2107                .await?;
2108            if runtime_profile == RuntimeProfile::Eval
2109                && let Some(remaining) = crate::deadline_policy::should_start_finalization(
2110                    turn_deadline,
2111                    deadline_finalization_reserve,
2112                    deadline_finalization_requested || deadline_scoreable_completion_requested,
2113                )
2114            {
2115                if req.task_ledger_required
2116                    && task_ledger_completion_reminders < TASK_LEDGER_COMPLETION_REMINDER_LIMIT
2117                    && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2118                {
2119                    task_ledger_completion_reminders += 1;
2120                    deadline_scoreable_completion_requested = true;
2121                    let item = TranscriptItem::UserMessage(UserMessage::text(
2122                        task_ledger_deadline_completion_prompt(
2123                            remaining,
2124                            deadline_finalization_reserve,
2125                            &prompt,
2126                        ),
2127                    ));
2128                    self.persist_turn_item(&req.thread_id, &turn_id, &item)
2129                        .await?;
2130                    transcript.push(item);
2131                    continue 'tool_rounds;
2132                } else {
2133                    self.start_deadline_finalization(
2134                        &req.thread_id,
2135                        &turn_id,
2136                        &mut transcript,
2137                        remaining,
2138                    )
2139                    .await?;
2140                    deadline_finalization_requested = true;
2141                }
2142            }
2143            if runtime_profile == RuntimeProfile::Eval
2144                && req.task_ledger_required
2145                && !deadline_finalization_requested
2146                && task_ledger_scoreable_checkpoints < TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT
2147                && let Some(remaining) = deadline_remaining_seconds(turn_deadline)
2148                && remaining <= TASK_LEDGER_SCOREABLE_CHECKPOINT_SECONDS
2149                && remaining > deadline_finalization_reserve
2150                && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2151            {
2152                task_ledger_scoreable_checkpoints += 1;
2153                let item = TranscriptItem::UserMessage(UserMessage::text(
2154                    task_ledger_scoreable_checkpoint_prompt(remaining, &prompt),
2155                ));
2156                self.persist_turn_item(&req.thread_id, &turn_id, &item)
2157                    .await?;
2158                transcript.push(item);
2159                continue 'tool_rounds;
2160            }
2161            if turn_inference_router.is_active() && routing_candidates.is_none() {
2162                routing_candidates =
2163                    Some(collect_inference_routing_candidates(&self.registry).await);
2164            }
2165            let routing_tools_model = model.clone();
2166            let routing_tools = self.filtered_tool_specs(
2167                &cfg,
2168                &model,
2169                model_profile.as_ref(),
2170                &thread_overrides.tool_allowlist,
2171                &thread_overrides.external_tools,
2172            );
2173            let prior_failures =
2174                transcript_failure_count_since(&transcript, routing_transcript_start)
2175                    .max(reliability.tool_failure_count())
2176                    .saturating_add(provider_stream_retry_attempts);
2177            let routing_selection = route_inference_selection(
2178                &self.registry,
2179                &turn_inference_router,
2180                InferenceRoutingRequest {
2181                    thread_id: &req.thread_id,
2182                    turn_id: &turn_id,
2183                    round_index: round_index as u32,
2184                    runtime_profile,
2185                    phase: speed_policy.phase(),
2186                    profile: turn_inference_router_profile.as_deref(),
2187                    default_selection: ModelSelection {
2188                        provider: default_provider.clone(),
2189                        model: default_model.clone(),
2190                    },
2191                    transcript: &transcript,
2192                    tools: &routing_tools,
2193                    candidates: routing_candidates.as_deref(),
2194                    prior_failures,
2195                    prior_escalations: routing_escalations,
2196                },
2197            )
2198            .await;
2199            if let Some(decision) = routing_selection.decision.clone() {
2200                if matches!(decision.outcome, InferenceRoutingOutcome::Escalated) {
2201                    routing_escalations = routing_escalations.saturating_add(1);
2202                }
2203                self.emit(RoderEvent::InferenceRoutingDecision(
2204                    InferenceRoutingDecisionEvent {
2205                        thread_id: req.thread_id.clone(),
2206                        turn_id: turn_id.clone(),
2207                        round_index: round_index as u32,
2208                        default_selection: ModelSelection {
2209                            provider: default_provider.clone(),
2210                            model: default_model.clone(),
2211                        },
2212                        selected_selection: routing_selection.selection.clone(),
2213                        decision,
2214                        timestamp: OffsetDateTime::now_utc(),
2215                    },
2216                ))
2217                .await;
2218            }
2219            provider = routing_selection.selection.provider.clone();
2220            model = routing_selection.selection.model.clone();
2221            let engine = self.engine_for(&provider)?;
2222            let capabilities = engine.capabilities();
2223            model_profile = model_profile_for_provider_model(&cfg, &provider, &model);
2224            let tools = if capabilities.tool_calls {
2225                if model == routing_tools_model {
2226                    routing_tools.clone()
2227                } else {
2228                    self.filtered_tool_specs(
2229                        &cfg,
2230                        &model,
2231                        model_profile.as_ref(),
2232                        &thread_overrides.tool_allowlist,
2233                        &thread_overrides.external_tools,
2234                    )
2235                }
2236            } else {
2237                Vec::new()
2238            };
2239            let parallel_tool_calls = parallel_tool_calls_for_model(&cfg, &model);
2240            let tool_choice = if tools.is_empty() {
2241                ToolChoice::None
2242            } else {
2243                ToolChoice::Auto
2244            };
2245            let summary_selection = ModelSelection {
2246                provider: provider.clone(),
2247                model: model.clone(),
2248            };
2249            if model_switch_summary_selection.as_ref() != Some(&summary_selection) {
2250                if let Some(summary) = model_switch_summary(
2251                    &transcript,
2252                    model_profile.as_ref(),
2253                    &provider,
2254                    &model,
2255                    &tools,
2256                ) {
2257                    let item = TranscriptItem::UserMessage(UserMessage::text(summary));
2258                    self.persist_turn_item(&req.thread_id, &turn_id, &item)
2259                        .await?;
2260                    transcript.push(item);
2261                }
2262                model_switch_summary_selection = Some(summary_selection);
2263            }
2264
2265            if !capabilities.image_input && transcript_has_images(&transcript) {
2266                self.fail_turn_with_error(
2267                    &req.thread_id,
2268                    &turn_id,
2269                    format!("provider {provider} does not support image input"),
2270                )
2271                .await?;
2272                return Ok(TurnRunOutcome::Stopped);
2273            }
2274            transcript = self
2275                .compact_transcript_if_needed(&req.thread_id, &turn_id, &model, transcript)
2276                .await?;
2277
2278            let speed_policy_decision =
2279                speed_policy.decision(runtime_profile, &model, &cfg.speed_policy);
2280            let request_reasoning = reasoning_from_decision(
2281                speed_policy_decision.as_ref(),
2282                routing_selection
2283                    .reasoning
2284                    .clone()
2285                    .unwrap_or_else(|| reasoning_for_model(&cfg, &model)),
2286            );
2287            if let Some(limit) = reliability.record_model_call(
2288                &cfg.reliability,
2289                runtime_profile == RuntimeProfile::Interactive,
2290            ) {
2291                self.fail_turn_due_to_reliability_limit(
2292                    &req.thread_id,
2293                    &turn_id,
2294                    &provider,
2295                    &model,
2296                    limit,
2297                    &transcript,
2298                )
2299                .await?;
2300                return Ok(TurnRunOutcome::Stopped);
2301            }
2302            self.emit(RoderEvent::InferenceStarted(InferenceStarted {
2303                thread_id: req.thread_id.clone(),
2304                turn_id: turn_id.clone(),
2305                engine_id: engine.id(),
2306                model: ModelSelection {
2307                    provider: provider.clone(),
2308                    model: model.clone(),
2309                },
2310                reasoning: request_reasoning.clone(),
2311                speed_policy: speed_policy_decision.clone(),
2312                deadline_remaining_seconds: deadline_remaining_seconds(turn_deadline),
2313                timestamp: OffsetDateTime::now_utc(),
2314            }))
2315            .await;
2316
2317            let mut instructions = req.instructions.clone();
2318            if let Some(extra) = &thread_overrides.developer_instructions {
2319                instructions = apply_thread_developer_instructions(instructions, extra);
2320            }
2321            if let Some(context) = req.developer_context.as_deref() {
2322                instructions = apply_turn_developer_context(instructions, context);
2323            }
2324            let mut instructions = apply_runtime_profile(instructions, runtime_profile);
2325            if let Some(profile) = &model_profile {
2326                instructions = apply_model_instruction_overlay(instructions, profile);
2327            }
2328            if req.task_ledger_required
2329                && runtime_profile == RuntimeProfile::Eval
2330                && !transcript_has_task_ledger(&transcript)
2331            {
2332                instructions = apply_task_ledger_required(instructions);
2333            }
2334            if effective_policy_mode == PolicyMode::Plan {
2335                instructions = apply_plan_mode(instructions);
2336            }
2337            instructions = self
2338                .goals
2339                .apply_goal_instructions(&req.thread_id, instructions)
2340                .await?;
2341            let mut request_metadata = serde_json::json!({});
2342            if let Some(decision) = &speed_policy_decision {
2343                request_metadata["speedPolicy"] = serde_json::json!(decision);
2344            }
2345            if let Some(decision) = routing_selection.decision.as_ref() {
2346                request_metadata["inferenceRouting"] = serde_json::json!(decision);
2347            }
2348            if let Some(remaining) = deadline_remaining_seconds(turn_deadline) {
2349                request_metadata["deadlineRemainingSeconds"] = serde_json::json!(remaining);
2350            }
2351            if let Some(profile) = &model_profile {
2352                request_metadata["modelProfile"] = serde_json::json!({
2353                    "model": profile.model,
2354                    "providerFamily": profile.provider_family,
2355                    "editTool": profile.edit_tool,
2356                    "schemaPolicy": profile.schema_policy,
2357                    "instructionOverlay": profile.instruction_overlay,
2358                    "parallelToolCalls": profile.parallel_tool_calls,
2359                    "autoCompactTokenLimit": profile.auto_compact_token_limit,
2360                });
2361            }
2362            let task_ledger_required_this_round = req.task_ledger_required
2363                && runtime_profile == RuntimeProfile::Eval
2364                && !deadline_finalization_requested
2365                && !transcript_has_task_ledger(&transcript);
2366            let task_ledger_tools = (capabilities.tool_calls && task_ledger_required_this_round)
2367                .then(|| self.task_ledger_tool_specs(model_profile.as_ref()))
2368                .filter(|tools| !tools.is_empty());
2369            let request_tools = if deadline_finalization_requested {
2370                Vec::new()
2371            } else if let Some(ledger_tools) = &task_ledger_tools {
2372                ledger_tools.clone()
2373            } else {
2374                tools.clone()
2375            };
2376            let request_tool_choice = if deadline_finalization_requested {
2377                ToolChoice::None
2378            } else if task_ledger_tools.is_some() {
2379                ToolChoice::Specific(TASK_LEDGER_TOOL_NAME.to_string())
2380            } else {
2381                tool_choice.clone()
2382            };
2383            if deadline_finalization_requested {
2384                request_metadata["deadlineFinalization"] = serde_json::json!({
2385                    "reserveSeconds": deadline_finalization_reserve,
2386                    "remainingSeconds": deadline_remaining_seconds(turn_deadline),
2387                });
2388            } else if deadline_scoreable_completion_requested {
2389                request_metadata["deadlineScoreableCompletion"] = serde_json::json!({
2390                    "reserveSeconds": deadline_finalization_reserve,
2391                    "remainingSeconds": deadline_remaining_seconds(turn_deadline),
2392                });
2393            }
2394            let request = AgentInferenceRequest {
2395                model: ModelSelection {
2396                    provider: provider.clone(),
2397                    model: model.clone(),
2398                },
2399                instructions,
2400                transcript: transcript.clone(),
2401                tools: request_tools,
2402                tool_choice: request_tool_choice,
2403                reasoning: request_reasoning,
2404                output: OutputConfig::default(),
2405                runtime: RuntimeHints {
2406                    auto_compact_token_limit: server_side_compaction_threshold(&cfg, &model),
2407                    profile: runtime_profile,
2408                    parallel_tool_calls: Some(parallel_tool_calls),
2409                    hosted_web_search: cfg.hosted_web_search.clone(),
2410                    tool_search: tool_search_for_provider_model(&cfg, &provider, &model),
2411                    speed_policy: speed_policy_decision,
2412                    reliability: Some(cfg.reliability.clone().into()),
2413                    deadline_remaining_seconds: deadline_remaining_seconds(turn_deadline),
2414                    ..RuntimeHints::default()
2415                },
2416                metadata: request_metadata,
2417            };
2418
2419            let ctx = InferenceTurnContext {
2420                thread_id: &req.thread_id,
2421                turn_id: &turn_id,
2422                tool_executor: Some(std::sync::Arc::new(
2423                    crate::tool_execution::RuntimeTurnToolExecutor {
2424                        runtime: Arc::clone(self),
2425                        thread_id: req.thread_id.clone(),
2426                        turn_id: turn_id.clone(),
2427                        workspace: Some(workspace.clone()),
2428                        deadline: turn_deadline,
2429                    },
2430                )),
2431            };
2432            let stream_future = engine.stream_turn(ctx, request);
2433            let mut stream = if let Some((deadline, timeout_action)) = inference_timeout_deadline(
2434                turn_deadline,
2435                runtime_profile,
2436                req.task_ledger_required,
2437                deadline_finalization_reserve,
2438                deadline_finalization_requested || deadline_scoreable_completion_requested,
2439                task_ledger_scoreable_checkpoints,
2440                &transcript,
2441            ) {
2442                match tokio::time::timeout_at(deadline_instant(deadline), stream_future).await {
2443                    Ok(stream) => stream?,
2444                    Err(_) => {
2445                        if runtime_profile == RuntimeProfile::Eval
2446                            && !deadline_finalization_requested
2447                        {
2448                            let remaining = deadline_remaining_seconds(turn_deadline).unwrap_or(0);
2449                            if timeout_action == InferenceTimeoutAction::ScoreableCheckpoint
2450                                && task_ledger_scoreable_checkpoints
2451                                    < TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT
2452                                && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2453                            {
2454                                task_ledger_scoreable_checkpoints += 1;
2455                                let item = TranscriptItem::UserMessage(UserMessage::text(
2456                                    task_ledger_scoreable_checkpoint_prompt(remaining, &prompt),
2457                                ));
2458                                self.persist_turn_item(&req.thread_id, &turn_id, &item)
2459                                    .await?;
2460                                transcript.push(item);
2461                                continue 'tool_rounds;
2462                            }
2463                            self.start_deadline_finalization(
2464                                &req.thread_id,
2465                                &turn_id,
2466                                &mut transcript,
2467                                remaining,
2468                            )
2469                            .await?;
2470                            deadline_finalization_requested = true;
2471                            continue 'tool_rounds;
2472                        }
2473                        self.fail_turn_due_to_deadline(
2474                            &req.thread_id,
2475                            &turn_id,
2476                            deadline,
2477                            &transcript,
2478                        )
2479                        .await?;
2480                        return Ok(TurnRunOutcome::Stopped);
2481                    }
2482                }
2483            } else {
2484                stream_future.await?
2485            };
2486            let mut assistant_text = String::new();
2487            let mut phase_messages = Vec::<AssistantMessage>::new();
2488            let mut reasoning_text = String::new();
2489            let mut tool_calls = Vec::new();
2490            let mut provider_metadata = None;
2491
2492            loop {
2493                let next = if let Some((deadline, timeout_action)) = inference_timeout_deadline(
2494                    turn_deadline,
2495                    runtime_profile,
2496                    req.task_ledger_required,
2497                    deadline_finalization_reserve,
2498                    deadline_finalization_requested || deadline_scoreable_completion_requested,
2499                    task_ledger_scoreable_checkpoints,
2500                    &transcript,
2501                ) {
2502                    match tokio::time::timeout_at(deadline_instant(deadline), stream.next()).await {
2503                        Ok(next) => next,
2504                        Err(_) => {
2505                            if runtime_profile == RuntimeProfile::Eval
2506                                && !deadline_finalization_requested
2507                            {
2508                                let remaining =
2509                                    deadline_remaining_seconds(turn_deadline).unwrap_or(0);
2510                                if timeout_action == InferenceTimeoutAction::ScoreableCheckpoint
2511                                    && task_ledger_scoreable_checkpoints
2512                                        < TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT
2513                                    && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2514                                {
2515                                    task_ledger_scoreable_checkpoints += 1;
2516                                    let item = TranscriptItem::UserMessage(UserMessage::text(
2517                                        task_ledger_scoreable_checkpoint_prompt(remaining, &prompt),
2518                                    ));
2519                                    self.persist_turn_item(&req.thread_id, &turn_id, &item)
2520                                        .await?;
2521                                    transcript.push(item);
2522                                    continue 'tool_rounds;
2523                                }
2524                                self.start_deadline_finalization(
2525                                    &req.thread_id,
2526                                    &turn_id,
2527                                    &mut transcript,
2528                                    remaining,
2529                                )
2530                                .await?;
2531                                deadline_finalization_requested = true;
2532                                continue 'tool_rounds;
2533                            }
2534                            self.fail_turn_due_to_deadline(
2535                                &req.thread_id,
2536                                &turn_id,
2537                                deadline,
2538                                &transcript,
2539                            )
2540                            .await?;
2541                            return Ok(TurnRunOutcome::Stopped);
2542                        }
2543                    }
2544                } else {
2545                    stream.next().await
2546                };
2547                let Some(res) = next else {
2548                    break;
2549                };
2550                let event = match res {
2551                    Ok(event) => event,
2552                    Err(err) => {
2553                        let error = err.to_string();
2554                        if runtime_profile == RuntimeProfile::Eval
2555                            && !deadline_finalization_requested
2556                            && let Some(cause) = provider_stream_retry_cause(&error)
2557                        {
2558                            let retry_attempt = provider_stream_retry_attempts.saturating_add(1);
2559                            let policy: ReliabilityRequestPolicy = cfg.reliability.clone().into();
2560                            if retry_attempt < policy.provider_retry_max_attempts {
2561                                provider_stream_retry_attempts = retry_attempt;
2562                                let delay_ms = provider_retry_delay_ms(&policy, retry_attempt);
2563                                self.emit(RoderEvent::ReliabilityRetryRecorded(
2564                                    ReliabilityRetryRecorded {
2565                                        context: ReliabilityContext {
2566                                            thread_id: req.thread_id.clone(),
2567                                            turn_id: turn_id.clone(),
2568                                            provider: Some(provider.clone()),
2569                                            model: Some(model.clone()),
2570                                            ..ReliabilityContext::default()
2571                                        },
2572                                        error_class: ReliabilityErrorClass::ProviderError,
2573                                        decision: ReliabilityRetryDecision::Retry,
2574                                        attempt: retry_attempt,
2575                                        max_attempts: policy.provider_retry_max_attempts,
2576                                        delay_ms: Some(delay_ms),
2577                                        details: ReliabilityDetails::redacted(format!(
2578                                            "{cause}: {error}"
2579                                        )),
2580                                        timestamp: OffsetDateTime::now_utc(),
2581                                    },
2582                                ))
2583                                .await;
2584                                if delay_ms > 0 {
2585                                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms))
2586                                        .await;
2587                                }
2588                                continue 'tool_rounds;
2589                            }
2590                        }
2591                        self.emit(RoderEvent::TurnFailed(TurnFailed {
2592                            thread_id: req.thread_id.clone(),
2593                            turn_id: turn_id.clone(),
2594                            error,
2595                            error_kind: None,
2596                            usage: None,
2597                            timestamp: OffsetDateTime::now_utc(),
2598                        }))
2599                        .await;
2600                        self.complete_team_member_turn(
2601                            &req.thread_id,
2602                            &turn_id,
2603                            TeamMemberStatus::Failed,
2604                        )
2605                        .await?;
2606                        return Err(err);
2607                    }
2608                };
2609
2610                let inference_timestamp = OffsetDateTime::now_utc();
2611                self.emit(RoderEvent::InferenceEventReceived(InferenceEventReceived {
2612                    thread_id: req.thread_id.clone(),
2613                    turn_id: turn_id.clone(),
2614                    event: event.clone(),
2615                    timestamp: inference_timestamp,
2616                }))
2617                .await;
2618
2619                match event {
2620                    InferenceEvent::MessageDelta(delta) => {
2621                        if let Some((team_id, member)) =
2622                            self.teams.member_for_thread(&req.thread_id).await
2623                        {
2624                            self.emit(RoderEvent::TeamMemberMessageDelta(TeamMemberMessageDelta {
2625                                team_id,
2626                                member_id: member.id,
2627                                member_thread_id: req.thread_id.clone(),
2628                                turn_id: turn_id.clone(),
2629                                delta: delta.text.clone(),
2630                                timestamp: OffsetDateTime::now_utc(),
2631                            }))
2632                            .await;
2633                        }
2634                        if is_final_answer_phase(delta.phase.as_deref()) {
2635                            assistant_text.push_str(&delta.text);
2636                        } else if let Some(last) = phase_messages.last_mut()
2637                            && last.phase == delta.phase
2638                        {
2639                            last.text.push_str(&delta.text);
2640                        } else {
2641                            phase_messages.push(AssistantMessage {
2642                                text: delta.text,
2643                                phase: delta.phase,
2644                            });
2645                        }
2646                    }
2647                    InferenceEvent::ReasoningDelta(delta) => reasoning_text.push_str(&delta.text),
2648                    InferenceEvent::ToolCallCompleted(call) => tool_calls.push(call),
2649                    InferenceEvent::Failed(failure) => {
2650                        speed_policy.record_failure();
2651                        self.persist_turn_item(
2652                            &req.thread_id,
2653                            &turn_id,
2654                            &TranscriptItem::Error(ErrorRecord {
2655                                message: failure.message.clone(),
2656                            }),
2657                        )
2658                        .await?;
2659                        self.emit(RoderEvent::TurnFailed(TurnFailed {
2660                            thread_id: req.thread_id.clone(),
2661                            turn_id: turn_id.clone(),
2662                            error: failure.message,
2663                            error_kind: None,
2664                            usage: None,
2665                            timestamp: OffsetDateTime::now_utc(),
2666                        }))
2667                        .await;
2668                        self.complete_team_member_turn(
2669                            &req.thread_id,
2670                            &turn_id,
2671                            TeamMemberStatus::Failed,
2672                        )
2673                        .await?;
2674                        return Ok(TurnRunOutcome::Stopped);
2675                    }
2676                    InferenceEvent::Usage(usage) => {
2677                        turn_usage.add_assign(&usage);
2678                    }
2679                    InferenceEvent::Completed(metadata) => {
2680                        turn_finish_reason = metadata
2681                            .stop_reason
2682                            .as_deref()
2683                            .map(finish_reason_from_stop_reason);
2684                    }
2685                    InferenceEvent::Compaction(_)
2686                    | InferenceEvent::HostedToolCallStarted(_)
2687                    | InferenceEvent::HostedToolCallCompleted(_)
2688                    | InferenceEvent::ToolCallStarted(_)
2689                    | InferenceEvent::ToolCallDelta(_) => {}
2690                    InferenceEvent::ProviderMetadata(metadata) => {
2691                        provider_metadata = Some(metadata);
2692                    }
2693                }
2694            }
2695
2696            speed_policy.record_model_output(
2697                !assistant_text.is_empty() || !phase_messages.is_empty(),
2698                tool_calls.len(),
2699            );
2700            if tool_calls.is_empty() {
2701                let steers = self.drain_turn_steers(&turn_id).await;
2702                if !steers.is_empty() {
2703                    for message in phase_messages {
2704                        let item = TranscriptItem::AssistantMessage(message);
2705                        self.persist_turn_item(&req.thread_id, &turn_id, &item)
2706                            .await?;
2707                        transcript.push(item);
2708                        self.persist_model_profile_segment(
2709                            &req.thread_id,
2710                            &turn_id,
2711                            model_profile.as_ref(),
2712                            &provider,
2713                            &model,
2714                            "assistant",
2715                        )
2716                        .await?;
2717                    }
2718                    if !assistant_text.is_empty() {
2719                        let assistant = TranscriptItem::AssistantMessage(AssistantMessage {
2720                            text: assistant_text,
2721                            phase: Some(FINAL_ANSWER_PHASE.to_string()),
2722                        });
2723                        self.persist_turn_item(&req.thread_id, &turn_id, &assistant)
2724                            .await?;
2725                        transcript.push(assistant);
2726                        self.persist_model_profile_segment(
2727                            &req.thread_id,
2728                            &turn_id,
2729                            model_profile.as_ref(),
2730                            &provider,
2731                            &model,
2732                            "assistant",
2733                        )
2734                        .await?;
2735                    }
2736                    if let Some(metadata) = provider_metadata {
2737                        let item = TranscriptItem::ProviderMetadata(metadata);
2738                        self.persist_turn_item(&req.thread_id, &turn_id, &item)
2739                            .await?;
2740                        transcript.push(item);
2741                    }
2742                    self.append_steers(&req, &turn_id, &mut transcript, steers)
2743                        .await?;
2744                    continue;
2745                }
2746                if !deadline_finalization_requested
2747                    && req.task_ledger_required
2748                    && runtime_profile == RuntimeProfile::Eval
2749                    && task_ledger_completion_reminders < TASK_LEDGER_COMPLETION_REMINDER_LIMIT
2750                    && (!assistant_text.trim().is_empty() || !phase_messages.is_empty())
2751                    && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2752                {
2753                    task_ledger_completion_reminders += 1;
2754                    let item = TranscriptItem::UserMessage(UserMessage::text(prompt));
2755                    self.persist_turn_item(&req.thread_id, &turn_id, &item)
2756                        .await?;
2757                    transcript.push(item);
2758                    continue;
2759                }
2760                if !deadline_finalization_requested
2761                    && let Some(prompt) = verification_gate.blocking_prompt()
2762                {
2763                    speed_policy.record_verification_required();
2764                    self.emit(RoderEvent::VerificationRequired(VerificationRequired {
2765                        thread_id: req.thread_id.clone(),
2766                        turn_id: turn_id.clone(),
2767                        reason: verification_gate.reason(),
2768                        changed_files: verification_gate.changed_files(),
2769                        tool_evidence: verification_gate.tool_evidence.clone(),
2770                        tests_run: verification_gate.tests_run.clone(),
2771                        open_gaps: verification_gate.open_gaps.clone(),
2772                        timestamp: OffsetDateTime::now_utc(),
2773                    }))
2774                    .await;
2775                    let item = TranscriptItem::UserMessage(UserMessage::text(prompt));
2776                    self.persist_turn_item(&req.thread_id, &turn_id, &item)
2777                        .await?;
2778                    transcript.push(item);
2779                    continue;
2780                }
2781                if deadline_finalization_requested
2782                    && assistant_text.trim().is_empty()
2783                    && phase_messages.is_empty()
2784                {
2785                    assistant_text = format!(
2786                        "Deadline finalization completed without model text. {}",
2787                        turn_partial_result(&transcript)
2788                    );
2789                }
2790                final_phase_messages = phase_messages;
2791                final_assistant_text = assistant_text;
2792                final_reasoning_text = reasoning_text;
2793                final_provider_metadata = provider_metadata;
2794                exhausted_tool_rounds = false;
2795                break;
2796            }
2797
2798            for message in phase_messages {
2799                let item = TranscriptItem::AssistantMessage(message);
2800                self.persist_turn_item(&req.thread_id, &turn_id, &item)
2801                    .await?;
2802                transcript.push(item);
2803                self.persist_model_profile_segment(
2804                    &req.thread_id,
2805                    &turn_id,
2806                    model_profile.as_ref(),
2807                    &provider,
2808                    &model,
2809                    "assistant",
2810                )
2811                .await?;
2812            }
2813            if !assistant_text.is_empty() {
2814                transcript.push(TranscriptItem::AssistantMessage(AssistantMessage {
2815                    text: assistant_text,
2816                    phase: Some(FINAL_ANSWER_PHASE.to_string()),
2817                }));
2818                self.persist_model_profile_segment(
2819                    &req.thread_id,
2820                    &turn_id,
2821                    model_profile.as_ref(),
2822                    &provider,
2823                    &model,
2824                    "assistant",
2825                )
2826                .await?;
2827            }
2828            if let Some(metadata) = provider_metadata {
2829                let item = TranscriptItem::ProviderMetadata(metadata);
2830                self.persist_turn_item(&req.thread_id, &turn_id, &item)
2831                    .await?;
2832                transcript.push(item);
2833            }
2834            for call in &tool_calls {
2835                let tool_item = TranscriptItem::ToolCall(ToolCallRecord {
2836                    id: call.id.clone(),
2837                    name: call.name.clone(),
2838                    arguments: call.arguments.clone(),
2839                });
2840                self.persist_turn_item(&req.thread_id, &turn_id, &tool_item)
2841                    .await?;
2842                transcript.push(tool_item);
2843                self.persist_model_profile_segment(
2844                    &req.thread_id,
2845                    &turn_id,
2846                    model_profile.as_ref(),
2847                    &provider,
2848                    &model,
2849                    "tool_call",
2850                )
2851                .await?;
2852            }
2853            if let Some(deadline) = turn_deadline
2854                && deadline_expired(deadline)
2855            {
2856                self.fail_turn_due_to_deadline(&req.thread_id, &turn_id, deadline, &transcript)
2857                    .await?;
2858                return Ok(TurnRunOutcome::Stopped);
2859            }
2860            let results = self
2861                .route_tool_calls(
2862                    &req.thread_id,
2863                    &turn_id,
2864                    tool_calls,
2865                    parallel_tool_calls,
2866                    Some(workspace.as_str()),
2867                    turn_deadline,
2868                )
2869                .await?;
2870            if let Some(limit) = reliability.record_tool_results(
2871                &cfg.reliability,
2872                &results,
2873                runtime_profile == RuntimeProfile::Interactive,
2874            ) {
2875                self.fail_turn_due_to_reliability_limit(
2876                    &req.thread_id,
2877                    &turn_id,
2878                    &provider,
2879                    &model,
2880                    limit,
2881                    &transcript,
2882                )
2883                .await?;
2884                return Ok(TurnRunOutcome::Stopped);
2885            }
2886            for result in results {
2887                verification_gate.record_tool_result(&result);
2888                transcript.push(TranscriptItem::ToolResult(result));
2889                self.persist_model_profile_segment(
2890                    &req.thread_id,
2891                    &turn_id,
2892                    model_profile.as_ref(),
2893                    &provider,
2894                    &model,
2895                    "tool_result",
2896                )
2897                .await?;
2898            }
2899            transcript = self
2900                .compact_transcript_if_needed(&req.thread_id, &turn_id, &model, transcript)
2901                .await?;
2902        }
2903
2904        if exhausted_tool_rounds {
2905            let message =
2906                format!("tool call limit reached after {MAX_TOOL_ROUNDS_PER_TURN} rounds");
2907            self.persist_turn_item(
2908                &req.thread_id,
2909                &turn_id,
2910                &TranscriptItem::Error(ErrorRecord {
2911                    message: message.clone(),
2912                }),
2913            )
2914            .await?;
2915            self.emit(RoderEvent::TurnFailed(TurnFailed {
2916                thread_id: req.thread_id.clone(),
2917                turn_id: turn_id.clone(),
2918                error: message,
2919                error_kind: None,
2920                usage: None,
2921                timestamp: OffsetDateTime::now_utc(),
2922            }))
2923            .await;
2924            self.complete_team_member_turn(&req.thread_id, &turn_id, TeamMemberStatus::Failed)
2925                .await?;
2926            return Ok(TurnRunOutcome::Stopped);
2927        }
2928
2929        if !final_reasoning_text.is_empty() {
2930            self.persist_turn_item(
2931                &req.thread_id,
2932                &turn_id,
2933                &TranscriptItem::ReasoningSummary(ReasoningSummary {
2934                    text: final_reasoning_text,
2935                }),
2936            )
2937            .await?;
2938        }
2939        for message in final_phase_messages {
2940            self.persist_turn_item(
2941                &req.thread_id,
2942                &turn_id,
2943                &TranscriptItem::AssistantMessage(message),
2944            )
2945            .await?;
2946            self.persist_model_profile_segment(
2947                &req.thread_id,
2948                &turn_id,
2949                model_profile.as_ref(),
2950                &provider,
2951                &model,
2952                "assistant",
2953            )
2954            .await?;
2955        }
2956        if !final_assistant_text.is_empty() {
2957            self.persist_turn_item(
2958                &req.thread_id,
2959                &turn_id,
2960                &TranscriptItem::AssistantMessage(AssistantMessage {
2961                    text: final_assistant_text,
2962                    phase: Some(FINAL_ANSWER_PHASE.to_string()),
2963                }),
2964            )
2965            .await?;
2966            self.persist_model_profile_segment(
2967                &req.thread_id,
2968                &turn_id,
2969                model_profile.as_ref(),
2970                &provider,
2971                &model,
2972                "assistant",
2973            )
2974            .await?;
2975        }
2976        if let Some(metadata) = final_provider_metadata {
2977            self.persist_turn_item(
2978                &req.thread_id,
2979                &turn_id,
2980                &TranscriptItem::ProviderMetadata(metadata),
2981            )
2982            .await?;
2983        }
2984
2985        let turn_usage_tokens = turn_usage.total_tokens as i64;
2986        let completed_usage = (!turn_usage.is_empty()).then_some(turn_usage.clone());
2987        self.record_thread_usage_metadata(&req.thread_id, &turn_usage)
2988            .await?;
2989        self.goals
2990            .account_turn_usage(
2991                &req.thread_id,
2992                turn_usage_tokens,
2993                OffsetDateTime::now_utc() - turn_started_at,
2994            )
2995            .await?;
2996        self.emit(RoderEvent::TurnCompleted(TurnCompleted {
2997            thread_id: req.thread_id.clone(),
2998            turn_id: turn_id.clone(),
2999            usage: completed_usage,
3000            finish_reason: turn_finish_reason,
3001            timestamp: OffsetDateTime::now_utc(),
3002        }))
3003        .await;
3004        self.complete_team_member_turn(&req.thread_id, &turn_id, TeamMemberStatus::Completed)
3005            .await?;
3006        self.persist_runner_state(&req.thread_id, runner_session.as_ref())
3007            .await?;
3008        Ok(TurnRunOutcome::Completed)
3009    }
3010
3011    async fn drain_turn_steers(&self, turn_id: &TurnId) -> Vec<UserMessage> {
3012        let Some(active) = self.active_turns.read().await.get(turn_id).cloned() else {
3013            return Vec::new();
3014        };
3015        let mut steers = active.steers.lock().await;
3016        std::mem::take(&mut *steers)
3017    }
3018
3019    async fn route_tool_calls(
3020        self: &Arc<Self>,
3021        thread_id: &ThreadId,
3022        turn_id: &TurnId,
3023        calls: Vec<ToolCallCompleted>,
3024        parallel: bool,
3025        workspace: Option<&str>,
3026        deadline: Option<OffsetDateTime>,
3027    ) -> anyhow::Result<Vec<ToolResultRecord>> {
3028        let force_sequential = calls
3029            .iter()
3030            .any(|call| crate::agent_control_tools::is_agent_control_tool(&call.name));
3031        if parallel && !force_sequential {
3032            try_join_all(
3033                calls.into_iter().map(|call| {
3034                    self.route_tool_call(thread_id, turn_id, call, workspace, deadline)
3035                }),
3036            )
3037            .await
3038        } else {
3039            let mut results = Vec::with_capacity(calls.len());
3040            for call in calls {
3041                results.push(
3042                    self.route_tool_call(thread_id, turn_id, call, workspace, deadline)
3043                        .await?,
3044                );
3045            }
3046            Ok(results)
3047        }
3048    }
3049
3050    async fn fail_turn_with_error(
3051        &self,
3052        thread_id: &ThreadId,
3053        turn_id: &TurnId,
3054        message: String,
3055    ) -> anyhow::Result<()> {
3056        self.persist_turn_item(
3057            thread_id,
3058            turn_id,
3059            &TranscriptItem::Error(ErrorRecord {
3060                message: message.clone(),
3061            }),
3062        )
3063        .await?;
3064        self.emit(RoderEvent::TurnFailed(TurnFailed {
3065            thread_id: thread_id.clone(),
3066            turn_id: turn_id.clone(),
3067            error: message,
3068            error_kind: None,
3069            usage: None,
3070            timestamp: OffsetDateTime::now_utc(),
3071        }))
3072        .await;
3073        self.complete_team_member_turn(thread_id, turn_id, TeamMemberStatus::Failed)
3074            .await?;
3075        Ok(())
3076    }
3077
3078    async fn fail_turn_due_to_deadline(
3079        &self,
3080        thread_id: &ThreadId,
3081        turn_id: &TurnId,
3082        deadline: OffsetDateTime,
3083        transcript: &[TranscriptItem],
3084    ) -> anyhow::Result<()> {
3085        let partial_result = turn_partial_result(transcript);
3086        self.emit(RoderEvent::TurnPartialResult(TurnPartialResult {
3087            thread_id: thread_id.clone(),
3088            turn_id: turn_id.clone(),
3089            summary: partial_result.clone(),
3090            timestamp: OffsetDateTime::now_utc(),
3091        }))
3092        .await;
3093        self.emit(RoderEvent::TurnDeadlineExceeded(TurnDeadlineExceeded {
3094            thread_id: thread_id.clone(),
3095            turn_id: turn_id.clone(),
3096            deadline,
3097            partial_result: partial_result.clone(),
3098            timestamp: OffsetDateTime::now_utc(),
3099        }))
3100        .await;
3101        let message = "turn deadline expired".to_string();
3102        self.persist_turn_item(
3103            thread_id,
3104            turn_id,
3105            &TranscriptItem::Error(ErrorRecord {
3106                message: format!("{message}: {partial_result}"),
3107            }),
3108        )
3109        .await?;
3110        self.emit(RoderEvent::TurnFailed(TurnFailed {
3111            thread_id: thread_id.clone(),
3112            turn_id: turn_id.clone(),
3113            error: message,
3114            error_kind: Some("deadline_timeout".to_string()),
3115            usage: None,
3116            timestamp: OffsetDateTime::now_utc(),
3117        }))
3118        .await;
3119        self.complete_team_member_turn(thread_id, turn_id, TeamMemberStatus::Failed)
3120            .await?;
3121        Ok(())
3122    }
3123
3124    async fn start_deadline_finalization(
3125        &self,
3126        thread_id: &ThreadId,
3127        turn_id: &TurnId,
3128        transcript: &mut Vec<TranscriptItem>,
3129        remaining_seconds: u64,
3130    ) -> anyhow::Result<()> {
3131        let item = TranscriptItem::UserMessage(crate::deadline_policy::finalization_message(
3132            remaining_seconds,
3133        ));
3134        self.persist_turn_item(thread_id, turn_id, &item).await?;
3135        transcript.push(item);
3136        self.emit(RoderEvent::TurnPartialResult(TurnPartialResult {
3137            thread_id: thread_id.clone(),
3138            turn_id: turn_id.clone(),
3139            summary: turn_partial_result(transcript),
3140            timestamp: OffsetDateTime::now_utc(),
3141        }))
3142        .await;
3143        Ok(())
3144    }
3145
3146    async fn fail_turn_due_to_reliability_limit(
3147        &self,
3148        thread_id: &ThreadId,
3149        turn_id: &TurnId,
3150        provider: &str,
3151        model: &str,
3152        limit: ReliabilityLimitHit,
3153        transcript: &[TranscriptItem],
3154    ) -> anyhow::Result<()> {
3155        self.emit(RoderEvent::ReliabilityLimitRecorded(
3156            ReliabilityLimitRecorded {
3157                context: ReliabilityContext {
3158                    thread_id: thread_id.clone(),
3159                    turn_id: turn_id.clone(),
3160                    tool_id: None,
3161                    tool_name: None,
3162                    provider: Some(provider.to_string()),
3163                    model: Some(model.to_string()),
3164                },
3165                error_class: limit.error_class,
3166                limit_kind: limit.limit_kind,
3167                decision: limit.decision,
3168                current: limit.current,
3169                limit: limit.limit,
3170                details: ReliabilityDetails::redacted(&limit.message),
3171                timestamp: OffsetDateTime::now_utc(),
3172            },
3173        ))
3174        .await;
3175        let partial_result = turn_partial_result(transcript);
3176        self.emit(RoderEvent::TurnPartialResult(TurnPartialResult {
3177            thread_id: thread_id.clone(),
3178            turn_id: turn_id.clone(),
3179            summary: partial_result.clone(),
3180            timestamp: OffsetDateTime::now_utc(),
3181        }))
3182        .await;
3183        let message = format!("reliability limit reached: {}", limit.message);
3184        self.persist_turn_item(
3185            thread_id,
3186            turn_id,
3187            &TranscriptItem::Error(ErrorRecord {
3188                message: format!("{message}: {partial_result}"),
3189            }),
3190        )
3191        .await?;
3192        self.emit(RoderEvent::TurnFailed(TurnFailed {
3193            thread_id: thread_id.clone(),
3194            turn_id: turn_id.clone(),
3195            error: message,
3196            error_kind: Some("reliability_limit".to_string()),
3197            usage: None,
3198            timestamp: OffsetDateTime::now_utc(),
3199        }))
3200        .await;
3201        self.complete_team_member_turn(thread_id, turn_id, TeamMemberStatus::Failed)
3202            .await?;
3203        Ok(())
3204    }
3205
3206    async fn append_steers(
3207        &self,
3208        req: &StartTurnRequest,
3209        turn_id: &TurnId,
3210        transcript: &mut Vec<TranscriptItem>,
3211        steers: Vec<UserMessage>,
3212    ) -> anyhow::Result<()> {
3213        for mut steer in steers {
3214            steer.text = steer.text.trim().to_string();
3215            if steer.text.is_empty() && steer.images.is_empty() {
3216                continue;
3217            }
3218            let item = TranscriptItem::UserMessage(steer);
3219            self.persist_turn_item(&req.thread_id, turn_id, &item)
3220                .await?;
3221            transcript.push(item);
3222        }
3223        Ok(())
3224    }
3225
3226    async fn persist_model_profile_segment(
3227        &self,
3228        thread_id: &ThreadId,
3229        turn_id: &TurnId,
3230        profile: Option<&ModelHarnessProfile>,
3231        provider: &str,
3232        model: &str,
3233        segment: &str,
3234    ) -> anyhow::Result<()> {
3235        let item = TranscriptItem::ProviderMetadata(model_profile_segment_metadata(
3236            profile, provider, model, segment,
3237        ));
3238        self.persist_turn_item(thread_id, turn_id, &item).await
3239    }
3240
3241    /**
3242     * Allowlists apply to built-in tools only; external tools are advertised with their
3243     * host-supplied schemas as given. An external tool shadows a built-in with the same name in
3244     * both advertisement and dispatch (see `route_tool_call`).
3245     */
3246    fn filtered_tool_specs(
3247        &self,
3248        cfg: &RuntimeConfig,
3249        model: &str,
3250        profile: Option<&ModelHarnessProfile>,
3251        thread_allowlist: &[String],
3252        external_tools: &[roder_api::tools::ToolSpec],
3253    ) -> Vec<roder_api::tools::ToolSpec> {
3254        let mut specs = self
3255            .tool_registry
3256            .specs_for_edit_tool_with_schema_policy(
3257                edit_tool_for_model(cfg, model),
3258                schema_policy_for_model(profile),
3259            )
3260            .into_iter()
3261            .filter(|spec| {
3262                allowlist_permits(&cfg.tool_allowlist, &spec.name)
3263                    && allowlist_permits(thread_allowlist, &spec.name)
3264                    && !external_tools.iter().any(|tool| tool.name == spec.name)
3265            })
3266            .collect::<Vec<_>>();
3267        specs.extend(external_tools.iter().cloned());
3268        specs
3269    }
3270
3271    fn task_ledger_tool_specs(
3272        &self,
3273        profile: Option<&ModelHarnessProfile>,
3274    ) -> Vec<roder_api::tools::ToolSpec> {
3275        self.tool_registry
3276            .get(TASK_LEDGER_TOOL_NAME)
3277            .map(|tool| {
3278                tool.spec()
3279                    .normalized_for_model_profile(schema_policy_for_model(profile))
3280            })
3281            .into_iter()
3282            .collect()
3283    }
3284
3285    fn engine_for(&self, provider: &str) -> anyhow::Result<Arc<dyn InferenceEngine>> {
3286        self.registry
3287            .inference_engine(provider)
3288            .or_else(|| {
3289                self.registry
3290                    .default_inference_engine()
3291                    .filter(|engine| provider.is_empty() || engine.id() == provider)
3292            })
3293            .ok_or_else(|| anyhow::anyhow!("inference provider {provider:?} is not registered"))
3294    }
3295
3296    pub async fn emit(&self, event: RoderEvent) -> EventEnvelope {
3297        let envelope = self.bus.emit(event);
3298        if let (Some(store), Some(thread_id)) = (&self.thread_store, envelope.thread_id.as_ref())
3299            && should_persist_thread_event(thread_id)
3300        {
3301            let _ = store.append_event(thread_id, &envelope).await;
3302        }
3303        // Registered event sinks (e.g. process extensions) receive the
3304        // persisted envelope through bounded per-sink queues; a slow sink
3305        // never blocks emit or turn progress.
3306        let dispatcher = self
3307            .event_sink_dispatcher
3308            .get_or_init(|| async {
3309                crate::event_sink_dispatch::EventSinkDispatcher::start(
3310                    &self.registry.event_sinks,
3311                    self.bus.clone(),
3312                )
3313            })
3314            .await;
3315        if !dispatcher.is_empty() {
3316            dispatcher.dispatch(&envelope, &self.bus);
3317        }
3318        envelope
3319    }
3320
3321    /// Records a projected thread item event and persists it through the configured thread store.
3322    ///
3323    /// App-server protocol notification bridges currently call this after translating runtime
3324    /// events into item events. Headless runtime consumers that subscribe to `RoderEvent` directly
3325    /// must make the same call if they need the derived item event stream persisted.
3326    pub async fn record_thread_item_event_kind(
3327        &self,
3328        thread_id: &ThreadId,
3329        turn_id: &TurnId,
3330        timestamp: OffsetDateTime,
3331        kind: ThreadItemEventKind,
3332    ) -> anyhow::Result<ThreadItemEvent> {
3333        let seq = self.next_thread_item_event_seq(thread_id).await?;
3334        let item_event = ThreadItemEvent {
3335            seq,
3336            event_id: format!("{turn_id}-item-event-{seq}"),
3337            thread_id: thread_id.clone(),
3338            turn_id: turn_id.clone(),
3339            timestamp,
3340            event: kind,
3341        };
3342        if let Some(store) = &self.thread_store {
3343            store.append_item_event(thread_id, &item_event).await?;
3344        }
3345        self.remember_thread_item_event(&item_event).await?;
3346        Ok(item_event)
3347    }
3348
3349    async fn next_thread_item_event_seq(&self, thread_id: &ThreadId) -> anyhow::Result<u64> {
3350        self.ensure_thread_item_cache(thread_id).await?;
3351        Ok(self
3352            .thread_item_cache
3353            .lock()
3354            .await
3355            .next_item_event_seq(thread_id))
3356    }
3357
3358    pub async fn thread_item_exists(
3359        &self,
3360        thread_id: &ThreadId,
3361        turn_id: &TurnId,
3362        item_id: &str,
3363    ) -> anyhow::Result<bool> {
3364        self.ensure_thread_item_cache(thread_id).await?;
3365        Ok(self
3366            .thread_item_cache
3367            .lock()
3368            .await
3369            .thread_item_exists(thread_id, turn_id, item_id))
3370    }
3371
3372    pub async fn current_reasoning_item_id(
3373        &self,
3374        thread_id: &ThreadId,
3375        turn_id: &TurnId,
3376    ) -> anyhow::Result<Option<String>> {
3377        self.ensure_thread_item_cache(thread_id).await?;
3378        Ok(self
3379            .thread_item_cache
3380            .lock()
3381            .await
3382            .current_reasoning_item_id(thread_id, turn_id))
3383    }
3384
3385    async fn remember_thread_item_event(&self, item_event: &ThreadItemEvent) -> anyhow::Result<()> {
3386        self.ensure_thread_item_cache(&item_event.thread_id).await?;
3387        self.thread_item_cache
3388            .lock()
3389            .await
3390            .remember_item_event(item_event);
3391        Ok(())
3392    }
3393
3394    pub async fn latest_transcript_item_index(
3395        &self,
3396        thread_id: &ThreadId,
3397        turn_id: &TurnId,
3398    ) -> anyhow::Result<Option<usize>> {
3399        self.ensure_thread_item_cache(thread_id).await?;
3400        Ok(self
3401            .thread_item_cache
3402            .lock()
3403            .await
3404            .latest_transcript_item_index(thread_id, turn_id))
3405    }
3406
3407    async fn next_transcript_item_index(
3408        &self,
3409        thread_id: &ThreadId,
3410        turn_id: &TurnId,
3411    ) -> anyhow::Result<usize> {
3412        self.ensure_thread_item_cache(thread_id).await?;
3413        Ok(self
3414            .thread_item_cache
3415            .lock()
3416            .await
3417            .next_transcript_item_index(thread_id, turn_id))
3418    }
3419
3420    async fn remember_transcript_item_index(
3421        &self,
3422        thread_id: &ThreadId,
3423        turn_id: &TurnId,
3424        item_index: usize,
3425    ) -> anyhow::Result<()> {
3426        self.ensure_thread_item_cache(thread_id).await?;
3427        self.thread_item_cache
3428            .lock()
3429            .await
3430            .remember_transcript_item_index(thread_id, turn_id, item_index);
3431        Ok(())
3432    }
3433
3434    async fn ensure_thread_item_cache(&self, thread_id: &ThreadId) -> anyhow::Result<()> {
3435        if self
3436            .thread_item_cache
3437            .lock()
3438            .await
3439            .contains_thread(thread_id)
3440        {
3441            return Ok(());
3442        }
3443
3444        let snapshot = if let Some(store) = &self.thread_store {
3445            store.load_thread(thread_id).await?
3446        } else {
3447            None
3448        };
3449        self.thread_item_cache.lock().await.ensure_thread(
3450            thread_id,
3451            ThreadItemCacheEntry::from_snapshot(snapshot.as_ref()),
3452        );
3453        Ok(())
3454    }
3455
3456    pub(crate) async fn persist_turn_item(
3457        &self,
3458        thread_id: &ThreadId,
3459        turn_id: &TurnId,
3460        item: &TranscriptItem,
3461    ) -> anyhow::Result<()> {
3462        let item_index = self.next_transcript_item_index(thread_id, turn_id).await?;
3463        let timestamp = OffsetDateTime::now_utc();
3464        self.emit(RoderEvent::TranscriptItemAppended(TranscriptItemAppended {
3465            thread_id: thread_id.clone(),
3466            turn_id: turn_id.clone(),
3467            item_type: match item {
3468                TranscriptItem::UserMessage(_) => "user_message",
3469                TranscriptItem::AssistantMessage(_) => "assistant_message",
3470                TranscriptItem::ReasoningSummary(_) => "reasoning_summary",
3471                TranscriptItem::ToolCall(_) => "tool_call",
3472                TranscriptItem::ToolResult(_) => "tool_result",
3473                TranscriptItem::FileChange(_) => "file_change",
3474                TranscriptItem::ContextCompaction(_) => "context_compaction",
3475                TranscriptItem::Error(_) => "error",
3476                TranscriptItem::ProviderMetadata(_) => "provider_metadata",
3477            }
3478            .to_string(),
3479            item_index: Some(item_index),
3480            item: Some(item.clone()),
3481            timestamp,
3482        }))
3483        .await;
3484        self.remember_transcript_item_index(thread_id, turn_id, item_index)
3485            .await?;
3486        Ok(())
3487    }
3488}
3489
3490fn transcript_has_images(transcript: &[TranscriptItem]) -> bool {
3491    transcript.iter().any(|item| {
3492        matches!(
3493            item,
3494            TranscriptItem::UserMessage(message) if !message.images.is_empty()
3495        )
3496    })
3497}
3498
3499fn transcript_has_task_ledger(transcript: &[TranscriptItem]) -> bool {
3500    transcript.iter().any(|item| {
3501        matches!(
3502            item,
3503            TranscriptItem::ToolResult(result)
3504                if result.name.as_deref() == Some(TASK_LEDGER_TOOL_NAME) && !result.is_error
3505        )
3506    })
3507}
3508
3509fn task_ledger_completion_prompt(transcript: &[TranscriptItem]) -> Option<String> {
3510    let latest = transcript.iter().rev().find_map(|item| match item {
3511        TranscriptItem::ToolResult(result)
3512            if result.name.as_deref() == Some(TASK_LEDGER_TOOL_NAME) && !result.is_error =>
3513        {
3514            Some(result.result.as_str())
3515        }
3516        _ => None,
3517    })?;
3518    if !task_ledger_has_open_items(latest) {
3519        return None;
3520    }
3521
3522    let mut ledger = latest.chars().take(1500).collect::<String>();
3523    if latest.chars().nth(1500).is_some() {
3524        ledger.push_str("...");
3525    }
3526    Some(format!(
3527        "Task Ledger Completion Required: the latest task ledger still has pending or in-progress items. Do not provide a final answer yet. Use tools to complete the remaining scoreable work, create or update any required output files, then call `{TASK_LEDGER_TOOL_NAME}` with every task completed and evidence before finalizing.\n\nLatest ledger:\n{ledger}"
3528    ))
3529}
3530
3531fn task_ledger_deadline_completion_prompt(
3532    remaining_seconds: u64,
3533    reserve_seconds: u64,
3534    completion_prompt: &str,
3535) -> String {
3536    format!(
3537        "Eval deadline scoreable completion: {remaining_seconds} seconds remain in the {reserve_seconds}-second finalization reserve. Do not browse, search, or start slow work. Use the available tools now to create or update the required scoreable output files, run only a quick local check if needed, then update the task ledger to completed before finalizing.\n\n{completion_prompt}"
3538    )
3539}
3540
3541fn task_ledger_scoreable_checkpoint_prompt(
3542    remaining_seconds: u64,
3543    completion_prompt: &str,
3544) -> String {
3545    format!(
3546        "Scoreable Output Checkpoint: {remaining_seconds} seconds remain before the eval deadline. Before any further research, browsing, or long commands, use tools now to ensure the required output file(s) exist with the best evidence-backed answer, even if provisional. If a scoreable file already exists, read it and preserve that candidate unless you have stronger task-specific evidence for a replacement. Do not overwrite a plausible dated, historical, or local-evidence candidate with a current live-page, partial-coverage, or weaker guess merely to refresh the checkpoint. You may continue refining afterward, but do not apologize or finalize until the scoreable file exists and the task ledger is updated.\n\n{completion_prompt}"
3547    )
3548}
3549
3550fn task_ledger_has_open_items(ledger: &str) -> bool {
3551    ledger.lines().any(|line| {
3552        let line = line.trim_start();
3553        line.starts_with("- pending:") || line.starts_with("- in_progress:")
3554    })
3555}
3556
3557fn turn_deadline_for_config(cfg: &RuntimeConfig) -> Option<OffsetDateTime> {
3558    if !cfg.runtime_profile.is_non_interactive() {
3559        return None;
3560    }
3561    cfg.turn_deadline_seconds
3562        .filter(|seconds| *seconds > 0)
3563        .map(|seconds| OffsetDateTime::now_utc() + Duration::seconds(seconds as i64))
3564}
3565
3566fn deadline_expired(deadline: OffsetDateTime) -> bool {
3567    OffsetDateTime::now_utc() >= deadline
3568}
3569
3570pub(crate) fn deadline_remaining_seconds(deadline: Option<OffsetDateTime>) -> Option<u64> {
3571    let deadline = deadline?;
3572    if deadline <= OffsetDateTime::now_utc() {
3573        return Some(0);
3574    }
3575    Some(
3576        (deadline - OffsetDateTime::now_utc())
3577            .unsigned_abs()
3578            .as_secs()
3579            .max(1),
3580    )
3581}
3582
3583fn deadline_instant(deadline: OffsetDateTime) -> tokio::time::Instant {
3584    let now = OffsetDateTime::now_utc();
3585    if deadline <= now {
3586        return tokio::time::Instant::now();
3587    }
3588    tokio::time::Instant::now() + (deadline - now).unsigned_abs()
3589}
3590
3591fn inference_timeout_deadline(
3592    deadline: Option<OffsetDateTime>,
3593    runtime_profile: RuntimeProfile,
3594    task_ledger_required: bool,
3595    reserve_seconds: u64,
3596    finalization_requested: bool,
3597    task_ledger_scoreable_checkpoints: u8,
3598    transcript: &[TranscriptItem],
3599) -> Option<(OffsetDateTime, InferenceTimeoutAction)> {
3600    let deadline = deadline?;
3601    if runtime_profile == RuntimeProfile::Eval
3602        && task_ledger_required
3603        && !finalization_requested
3604        && task_ledger_scoreable_checkpoints < TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT
3605        && TASK_LEDGER_SCOREABLE_CHECKPOINT_SECONDS > reserve_seconds
3606        && task_ledger_completion_prompt(transcript).is_some()
3607    {
3608        let checkpoint_deadline =
3609            deadline - Duration::seconds(TASK_LEDGER_SCOREABLE_CHECKPOINT_SECONDS as i64);
3610        if checkpoint_deadline > OffsetDateTime::now_utc() {
3611            return Some((
3612                checkpoint_deadline,
3613                InferenceTimeoutAction::ScoreableCheckpoint,
3614            ));
3615        }
3616    }
3617    if runtime_profile == RuntimeProfile::Eval && !finalization_requested {
3618        return Some((
3619            deadline - Duration::seconds(reserve_seconds as i64),
3620            InferenceTimeoutAction::Finalization,
3621        ));
3622    }
3623    Some((deadline, InferenceTimeoutAction::Finalization))
3624}
3625
3626fn turn_partial_result(transcript: &[TranscriptItem]) -> String {
3627    let tool_results = transcript
3628        .iter()
3629        .filter(|item| matches!(item, TranscriptItem::ToolResult(_)))
3630        .count();
3631    let assistant_messages = transcript
3632        .iter()
3633        .filter(|item| matches!(item, TranscriptItem::AssistantMessage(_)))
3634        .count();
3635    format!(
3636        "partial turn state: {} transcript items, {assistant_messages} assistant messages, {tool_results} tool results",
3637        transcript.len()
3638    )
3639}
3640
3641fn reasoning_for_model(cfg: &RuntimeConfig, model: &str) -> ReasoningConfig {
3642    let level = effective_reasoning_for_model(cfg, model);
3643    match level.as_str() {
3644        "" | REASONING_NONE => ReasoningConfig::default(),
3645        level => ReasoningConfig {
3646            enabled: true,
3647            level: Some(level.to_string()),
3648        },
3649    }
3650}
3651
3652fn server_side_compaction_threshold(cfg: &RuntimeConfig, model: &str) -> Option<u32> {
3653    let entry = lookup_model(model)?;
3654    if !entry.supports_compaction {
3655        return None;
3656    }
3657    cfg.auto_compact_token_limit
3658        .or_else(|| {
3659            model_profile_for_model(cfg, model).and_then(|profile| profile.auto_compact_token_limit)
3660        })
3661        .or(Some(entry.auto_compact_token_limit))
3662        .filter(|threshold| *threshold > 0)
3663}
3664
3665pub(crate) fn tool_search_for_provider_model(
3666    cfg: &RuntimeConfig,
3667    provider: &str,
3668    model: &str,
3669) -> ToolSearchConfig {
3670    let mut resolved = cfg.tool_search.clone();
3671    if let Some(provider_config) = cfg.provider_tool_search.get(provider) {
3672        provider_config.apply_to(&mut resolved);
3673    }
3674    if let Some(model_config) = cfg.model_tool_search.get(model) {
3675        model_config.apply_to(&mut resolved);
3676    }
3677    resolved
3678}
3679
3680fn parallel_tool_calls_for_model(cfg: &RuntimeConfig, model: &str) -> bool {
3681    cfg.model_parallel_tool_calls
3682        .get(model)
3683        .copied()
3684        .or_else(|| {
3685            model_profile_for_model(cfg, model).and_then(|profile| profile.parallel_tool_calls)
3686        })
3687        .unwrap_or(true)
3688}
3689
3690fn effective_reasoning_for_model(cfg: &RuntimeConfig, model: &str) -> String {
3691    let base_reasoning = default_effective_reasoning_for_model(cfg, model);
3692    if cfg.dynamic_workflows.effort_profile == DynamicWorkflowEffortProfile::Ultracode {
3693        return ultracode_reasoning_level_for_model(
3694            model,
3695            &cfg.speed_policy.ultracode_reasoning,
3696            &base_reasoning,
3697        );
3698    }
3699    base_reasoning
3700}
3701
3702fn default_effective_reasoning_for_model(cfg: &RuntimeConfig, model: &str) -> String {
3703    let Some(entry) = lookup_model(model) else {
3704        return cfg
3705            .reasoning
3706            .clone()
3707            .unwrap_or_else(|| REASONING_NONE.to_string());
3708    };
3709    if entry.supported_reasoning.is_empty() {
3710        return REASONING_NONE.to_string();
3711    }
3712    cfg.reasoning
3713        .as_deref()
3714        .filter(|reasoning| {
3715            entry
3716                .supported_reasoning
3717                .iter()
3718                .any(|option| option.effort == *reasoning)
3719        })
3720        .map(str::to_string)
3721        .or_else(|| {
3722            model_profile_for_model(cfg, model)
3723                .and_then(|profile| profile.reasoning.orientation)
3724                .filter(|reasoning| {
3725                    entry
3726                        .supported_reasoning
3727                        .iter()
3728                        .any(|option| option.effort == reasoning)
3729                })
3730        })
3731        .unwrap_or_else(|| entry.default_reasoning.to_string())
3732}
3733
3734fn validate_reasoning_effort(model: &str, effort: &str) -> anyhow::Result<()> {
3735    if effort == REASONING_NONE && !model_supports_reasoning(model, effort) {
3736        return Ok(());
3737    }
3738    let Some(entry) = lookup_model(model) else {
3739        return Ok(());
3740    };
3741    if entry
3742        .supported_reasoning
3743        .iter()
3744        .any(|option| option.effort == effort)
3745    {
3746        Ok(())
3747    } else {
3748        anyhow::bail!("model {model} does not support reasoning effort {effort}")
3749    }
3750}
3751
3752fn validate_runtime_config_reasoning(cfg: &RuntimeConfig) -> anyhow::Result<()> {
3753    let Some(reasoning) = cfg.reasoning.as_deref() else {
3754        return Ok(());
3755    };
3756    let Some(entry) = lookup_model(&cfg.default_model) else {
3757        return Ok(());
3758    };
3759    if entry.provider != PROVIDER_GEMINI {
3760        return Ok(());
3761    }
3762    validate_reasoning_effort(&cfg.default_model, reasoning)
3763}
3764
3765fn validate_runtime_inference_router_config(
3766    registry: &ExtensionRegistry,
3767    cfg: &RuntimeConfig,
3768) -> anyhow::Result<()> {
3769    if !cfg.inference_router.enabled {
3770        return Ok(());
3771    }
3772    let Some(router_id) = cfg.inference_router.router_id.as_deref() else {
3773        anyhow::bail!("inference_router.enabled requires inference_router.router");
3774    };
3775    if registry.inference_router(router_id).is_some() {
3776        return Ok(());
3777    }
3778    let available = registry
3779        .inference_routers
3780        .iter()
3781        .map(|router| router.id())
3782        .collect::<Vec<_>>()
3783        .join(", ");
3784    if available.is_empty() {
3785        anyhow::bail!("inference router {router_id:?} is not registered");
3786    }
3787    anyhow::bail!(
3788        "inference router {router_id:?} is not registered; available routers: {available}"
3789    );
3790}
3791
3792fn model_supports_reasoning(model: &str, effort: &str) -> bool {
3793    lookup_model(model)
3794        .map(|entry| {
3795            entry
3796                .supported_reasoning
3797                .iter()
3798                .any(|option| option.effort == effort)
3799        })
3800        .unwrap_or(false)
3801}
3802
3803fn is_final_answer_phase(phase: Option<&str>) -> bool {
3804    phase.is_none_or(|phase| phase.is_empty() || phase == FINAL_ANSWER_PHASE)
3805}
3806
3807fn edit_tool_for_model<'a>(cfg: &'a RuntimeConfig, model: &'a str) -> Option<&'a str> {
3808    cfg.model_edit_tools
3809        .get(model)
3810        .map(String::as_str)
3811        .or_else(|| {
3812            cfg.model_profiles
3813                .get(model)
3814                .and_then(|profile| profile.edit_tool.as_deref())
3815        })
3816        .or_else(|| lookup_model(model).and_then(|entry| entry.edit_tool))
3817        .or(Some(EDIT_TOOL_EDIT))
3818}
3819
3820fn model_profile_for_model(cfg: &RuntimeConfig, model: &str) -> Option<ModelHarnessProfile> {
3821    cfg.model_profiles
3822        .get(model)
3823        .cloned()
3824        .or_else(|| built_in_model_profile(model))
3825}
3826
3827pub(crate) fn allowlist_permits(allowlist: &[String], tool_name: &str) -> bool {
3828    allowlist.is_empty() || allowlist.iter().any(|allowed| allowed == tool_name)
3829}
3830
3831/// Provider-aware profile resolution for the active turn.
3832///
3833/// Many model ids are shared across providers (for example Cursor proxies
3834/// `claude-opus-4-8`). Resolving the harness profile by id alone picks the
3835/// first catalog entry, which assigns cross-provider ids the wrong family and
3836/// instruction overlay (e.g. a `cursor/claude-opus-4-8` turn would otherwise
3837/// inherit the Anthropic overlay). Prefer the explicit `(provider, model)`
3838/// catalog entry, keeping user-configured profile overrides as the top
3839/// precedence.
3840fn model_profile_for_provider_model(
3841    cfg: &RuntimeConfig,
3842    provider: &str,
3843    model: &str,
3844) -> Option<ModelHarnessProfile> {
3845    cfg.model_profiles
3846        .get(model)
3847        .cloned()
3848        .or_else(|| built_in_model_profile_for_provider(provider, model))
3849}
3850
3851fn schema_policy_for_model(profile: Option<&ModelHarnessProfile>) -> ModelSchemaPolicy {
3852    profile
3853        .map(|profile| profile.schema_policy)
3854        .unwrap_or_default()
3855}
3856
3857fn model_profile_segment_metadata(
3858    profile: Option<&ModelHarnessProfile>,
3859    provider: &str,
3860    model: &str,
3861    segment: &str,
3862) -> serde_json::Value {
3863    serde_json::json!({
3864        "kind": MODEL_PROFILE_TRACE_KIND,
3865        "segment": segment,
3866        "provider": provider,
3867        "model": model,
3868        "profileModel": profile.map(|profile| profile.model.as_str()).unwrap_or(model),
3869        "providerFamily": profile.map(|profile| profile.provider_family),
3870        "editTool": profile.and_then(|profile| profile.edit_tool.as_deref()),
3871        "schemaPolicy": profile.map(|profile| profile.schema_policy),
3872        "instructionOverlay": profile.map(|profile| profile.instruction_overlay),
3873        "parallelToolCalls": profile.and_then(|profile| profile.parallel_tool_calls),
3874        "autoCompactTokenLimit": profile.and_then(|profile| profile.auto_compact_token_limit),
3875    })
3876}
3877
3878fn model_switch_summary(
3879    transcript: &[TranscriptItem],
3880    profile: Option<&ModelHarnessProfile>,
3881    provider: &str,
3882    model: &str,
3883    tools: &[roder_api::tools::ToolSpec],
3884) -> Option<String> {
3885    let previous = latest_model_profile_segment(transcript)?;
3886    let previous_model = previous
3887        .get("model")
3888        .and_then(serde_json::Value::as_str)
3889        .unwrap_or_default();
3890    let previous_provider = previous
3891        .get("provider")
3892        .and_then(serde_json::Value::as_str)
3893        .unwrap_or_default();
3894    if previous_model == model && previous_provider == provider {
3895        return None;
3896    }
3897
3898    let previous_profile = previous
3899        .get("profileModel")
3900        .and_then(serde_json::Value::as_str)
3901        .unwrap_or(previous_model);
3902    let current_profile = profile
3903        .map(|profile| profile.model.as_str())
3904        .unwrap_or(model);
3905    let previous_edit_tool = previous
3906        .get("editTool")
3907        .and_then(serde_json::Value::as_str)
3908        .unwrap_or("none");
3909    let current_edit_tool = profile
3910        .and_then(|profile| profile.edit_tool.as_deref())
3911        .unwrap_or("none");
3912    let tool_names = tools
3913        .iter()
3914        .map(|tool| tool.name.as_str())
3915        .take(12)
3916        .collect::<Vec<_>>()
3917        .join(", ");
3918    Some(format!(
3919        "{MODEL_SWITCH_SUMMARY_PREFIX} previous profile {previous_provider}/{previous_profile} used edit tool {previous_edit_tool}. Current profile {provider}/{current_profile} uses edit tool {current_edit_tool}. Available tools now: {}.",
3920        if tool_names.is_empty() {
3921            "none"
3922        } else {
3923            &tool_names
3924        }
3925    ))
3926}
3927
3928fn latest_model_profile_segment(transcript: &[TranscriptItem]) -> Option<&serde_json::Value> {
3929    transcript.iter().rev().find_map(|item| {
3930        let TranscriptItem::ProviderMetadata(value) = item else {
3931            return None;
3932        };
3933        (value.get("kind").and_then(serde_json::Value::as_str) == Some(MODEL_PROFILE_TRACE_KIND))
3934            .then_some(value)
3935    })
3936}
3937
3938pub fn validate_edit_tool(value: &str) -> anyhow::Result<()> {
3939    match value.trim() {
3940        EDIT_TOOL_PATCH | EDIT_TOOL_EDIT => Ok(()),
3941        _ => anyhow::bail!(
3942            "unsupported edit_tool {value:?}; allowed values: {EDIT_TOOL_PATCH}, {EDIT_TOOL_EDIT}"
3943        ),
3944    }
3945}
3946
3947fn should_persist_thread_event(thread_id: &str) -> bool {
3948    !is_synthetic_event_thread_id(thread_id)
3949}
3950
3951#[cfg(test)]
3952mod tests {
3953    use super::*;
3954    use futures::stream;
3955    use roder_api::catalog::{
3956        PROVIDER_MOCK, REASONING_HIGH, REASONING_LOW, REASONING_MEDIUM, REASONING_MINIMAL,
3957        REASONING_NONE, REASONING_XHIGH,
3958    };
3959    use roder_api::extension::ExtensionRegistryBuilder;
3960    use roder_api::inference::{
3961        CompletionMetadata, InferenceCapabilities, InferenceEngine, InferenceEventStream,
3962        InferenceProviderContext, InferenceTurnContext, MessageDelta, ModelDescriptor,
3963        ModelInstructionOverlay, ModelProfileReasoning, ModelSchemaPolicy, ProviderFamily,
3964        ReasoningEffortDescriptor,
3965    };
3966    use roder_api::inference_routing::{
3967        InferenceRouter, InferenceRoutingContext, InferenceRoutingDecision, InferenceRoutingOutcome,
3968    };
3969    use roder_api::thread::ThreadStoreFactory;
3970    use roder_api::tools::{ToolContributor, ToolExecutor, ToolSpec};
3971    use roder_ext_jsonl_thread_store::store::JsonlThreadStoreFactory;
3972    use std::sync::Mutex as StdMutex;
3973
3974    fn test_workspace() -> String {
3975        std::env::current_dir().unwrap().display().to_string()
3976    }
3977
3978    struct MetadataMissingStore;
3979
3980    #[async_trait::async_trait]
3981    impl ThreadStore for MetadataMissingStore {
3982        fn id(&self) -> roder_api::thread::ThreadStoreId {
3983            "metadata-missing-store".to_string()
3984        }
3985
3986        async fn create_thread(&self, metadata: ThreadMetadata) -> anyhow::Result<ThreadMetadata> {
3987            Ok(metadata)
3988        }
3989
3990        async fn list_threads(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
3991            Ok(Vec::new())
3992        }
3993
3994        async fn load_thread(
3995            &self,
3996            _thread_id: &ThreadId,
3997        ) -> anyhow::Result<Option<ThreadSnapshot>> {
3998            Ok(Some(ThreadSnapshot {
3999                metadata: None,
4000                ..ThreadSnapshot::default()
4001            }))
4002        }
4003
4004        async fn append_event(
4005            &self,
4006            _thread_id: &ThreadId,
4007            _envelope: &EventEnvelope,
4008        ) -> anyhow::Result<()> {
4009            Ok(())
4010        }
4011    }
4012
4013    struct MetadataMissingStoreFactory;
4014
4015    impl ThreadStoreFactory for MetadataMissingStoreFactory {
4016        fn id(&self) -> roder_api::thread::ThreadStoreId {
4017            "metadata-missing-store".to_string()
4018        }
4019
4020        fn create(&self) -> Arc<dyn ThreadStore> {
4021            Arc::new(MetadataMissingStore)
4022        }
4023    }
4024
4025    #[test]
4026    fn synthetic_app_server_events_are_not_thread_events() {
4027        for thread_id in ["app-server", "runtime", "thread-workflow"] {
4028            assert!(!should_persist_thread_event(thread_id));
4029        }
4030        assert!(should_persist_thread_event("thread-discovery"));
4031        assert!(should_persist_thread_event("thread-plan"));
4032        assert!(should_persist_thread_event("thread-process"));
4033        assert!(should_persist_thread_event("thread-1"));
4034    }
4035
4036    #[test]
4037    fn server_side_compaction_uses_catalog_ninety_percent_default() {
4038        assert_eq!(
4039            server_side_compaction_threshold(&RuntimeConfig::default(), "gpt-5.5"),
4040            Some(945_000)
4041        );
4042        assert_eq!(
4043            server_side_compaction_threshold(&RuntimeConfig::default(), "gpt-5.3-codex-spark"),
4044            Some(115_200)
4045        );
4046    }
4047
4048    #[test]
4049    fn server_side_compaction_respects_explicit_config_override() {
4050        let cfg = RuntimeConfig {
4051            auto_compact_token_limit: Some(123_456),
4052            ..RuntimeConfig::default()
4053        };
4054
4055        assert_eq!(
4056            server_side_compaction_threshold(&cfg, "gpt-5.5"),
4057            Some(123_456)
4058        );
4059    }
4060
4061    #[tokio::test]
4062    async fn pre_request_compaction_runs_when_server_side_model_is_at_context_window() {
4063        let captured = Arc::new(StdMutex::new(None));
4064        let mut builder = ExtensionRegistryBuilder::new();
4065        builder.inference_engine(Arc::new(CapturingEngine {
4066            request: captured.clone(),
4067        }));
4068        let thread_root = std::env::temp_dir().join(format!(
4069            "roder-pre-request-compaction-{}",
4070            uuid::Uuid::new_v4()
4071        ));
4072        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
4073            base_path: thread_root.clone(),
4074        }));
4075        let runtime = Arc::new(
4076            Runtime::new(
4077                builder.build().unwrap(),
4078                RuntimeConfig {
4079                    default_provider: PROVIDER_MOCK.to_string(),
4080                    default_model: "gpt-5.5".to_string(),
4081                    file_backed_dynamic_context: true,
4082                    ..RuntimeConfig::default()
4083                },
4084            )
4085            .unwrap(),
4086        );
4087        let thread_id = runtime
4088            .create_thread(Some("Pre-request compaction".to_string()))
4089            .await
4090            .unwrap()
4091            .thread_id;
4092        let old_turn = "old-turn".to_string();
4093        runtime
4094            .persist_turn_item(
4095                &thread_id,
4096                &old_turn,
4097                &TranscriptItem::UserMessage(UserMessage::text("old context ".repeat(4_300_000))),
4098            )
4099            .await
4100            .unwrap();
4101
4102        let mut events = runtime.subscribe_events();
4103        runtime
4104            .start_turn(StartTurnRequest {
4105                thread_id: thread_id.clone(),
4106                message: "continue".to_string(),
4107                images: Vec::new(),
4108                provider_override: None,
4109                model_override: None,
4110                reasoning_override: None,
4111                workspace: test_workspace(),
4112                instructions: InstructionBundle::default(),
4113                developer_context: None,
4114                task_ledger_required: false,
4115            })
4116            .await
4117            .unwrap();
4118        loop {
4119            let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
4120                .await
4121                .unwrap()
4122                .unwrap();
4123            if envelope.thread_id.as_deref() == Some(&thread_id)
4124                && matches!(envelope.event, RoderEvent::TurnCompleted(_))
4125            {
4126                break;
4127            }
4128        }
4129
4130        let request = captured.lock().unwrap().clone().unwrap();
4131        assert!(
4132            matches!(
4133                request.transcript.first(),
4134                Some(TranscriptItem::ContextCompaction(_))
4135            ),
4136            "provider request should start with a local emergency compaction item"
4137        );
4138        assert!(
4139            request.transcript.len() < 4,
4140            "provider request should not replay the full oversized prior transcript: {:?}",
4141            request.transcript
4142        );
4143
4144        let _ = std::fs::remove_dir_all(thread_root);
4145    }
4146
4147    #[tokio::test]
4148    async fn continue_after_context_window_failure_compacts_before_provider_request() {
4149        let captured = Arc::new(StdMutex::new(None));
4150        let mut builder = ExtensionRegistryBuilder::new();
4151        builder.inference_engine(Arc::new(CapturingEngine {
4152            request: captured.clone(),
4153        }));
4154        let thread_root = std::env::temp_dir().join(format!(
4155            "roder-context-failure-continue-{}",
4156            uuid::Uuid::new_v4()
4157        ));
4158        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
4159            base_path: thread_root.clone(),
4160        }));
4161        let runtime = Arc::new(
4162            Runtime::new(
4163                builder.build().unwrap(),
4164                RuntimeConfig {
4165                    default_provider: PROVIDER_MOCK.to_string(),
4166                    default_model: "gpt-5.5".to_string(),
4167                    file_backed_dynamic_context: true,
4168                    ..RuntimeConfig::default()
4169                },
4170            )
4171            .unwrap(),
4172        );
4173        let thread_id = runtime
4174            .create_thread(Some("Context failure continue".to_string()))
4175            .await
4176            .unwrap()
4177            .thread_id;
4178        let failed_turn = "failed-turn".to_string();
4179        runtime
4180            .persist_turn_item(
4181                &thread_id,
4182                &failed_turn,
4183                &TranscriptItem::UserMessage(UserMessage::text("old work ".repeat(10_000))),
4184            )
4185            .await
4186            .unwrap();
4187        runtime
4188            .persist_turn_item(
4189                &thread_id,
4190                &failed_turn,
4191                &TranscriptItem::Error(ErrorRecord {
4192                    message: "Your input exceeds the context window of this model. Please adjust your input and try again."
4193                        .to_string(),
4194                }),
4195            )
4196            .await
4197            .unwrap();
4198
4199        let mut events = runtime.subscribe_events();
4200        runtime
4201            .start_turn(StartTurnRequest {
4202                thread_id: thread_id.clone(),
4203                message: "continue".to_string(),
4204                images: Vec::new(),
4205                provider_override: None,
4206                model_override: None,
4207                reasoning_override: None,
4208                workspace: test_workspace(),
4209                instructions: InstructionBundle::default(),
4210                developer_context: None,
4211                task_ledger_required: false,
4212            })
4213            .await
4214            .unwrap();
4215        loop {
4216            let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
4217                .await
4218                .unwrap()
4219                .unwrap();
4220            if envelope.thread_id.as_deref() == Some(&thread_id)
4221                && matches!(envelope.event, RoderEvent::TurnCompleted(_))
4222            {
4223                break;
4224            }
4225        }
4226
4227        let request = captured.lock().unwrap().clone().unwrap();
4228        assert!(
4229            matches!(
4230                request.transcript.first(),
4231                Some(TranscriptItem::ContextCompaction(_))
4232            ),
4233            "provider request after context-window failure should start with local compaction"
4234        );
4235        assert!(
4236            request
4237                .transcript
4238                .iter()
4239                .any(|item| matches!(item, TranscriptItem::UserMessage(message) if message.text == "continue")),
4240            "current continue prompt must be preserved: {:?}",
4241            request.transcript
4242        );
4243        assert!(
4244            !request.transcript.iter().any(
4245                |item| matches!(item, TranscriptItem::Error(error) if error.message.contains("context window"))
4246            ),
4247            "raw prior context-window error should be summarized, not replayed: {:?}",
4248            request.transcript
4249        );
4250
4251        let _ = std::fs::remove_dir_all(thread_root);
4252    }
4253
4254    #[tokio::test]
4255    async fn workspace_for_thread_falls_back_when_metadata_is_missing() {
4256        let workspace = test_workspace();
4257        let mut builder = ExtensionRegistryBuilder::new();
4258        builder.inference_engine(Arc::new(FakeInferenceEngine));
4259        builder.thread_store_factory(Arc::new(MetadataMissingStoreFactory));
4260        let runtime = Runtime::new(
4261            builder.build().unwrap(),
4262            RuntimeConfig {
4263                workspace: Some(workspace.clone()),
4264                ..RuntimeConfig::default()
4265            },
4266        )
4267        .unwrap();
4268
4269        let resolved = runtime
4270            .workspace_for_thread(&ThreadId::from("thread-workflow"))
4271            .await
4272            .unwrap();
4273
4274        assert_eq!(resolved, workspace);
4275    }
4276
4277    #[tokio::test]
4278    async fn automations_can_create_project_thread_with_model_overrides() {
4279        let runtime = Runtime::fake().unwrap();
4280        let workspace = std::env::temp_dir().join("project");
4281        let metadata = runtime
4282            .create_thread_with(CreateThreadRequest {
4283                title: Some("Automation: nightly status".to_string()),
4284                workspace: workspace.display().to_string(),
4285                workspace_id: None,
4286                root_id: None,
4287                provider: Some("mock".to_string()),
4288                model: Some("mock".to_string()),
4289                selection_mode: None,
4290                tool_allowlist: Vec::new(),
4291                developer_instructions: None,
4292                external_tools: Vec::new(),
4293                runner: None,
4294            })
4295            .await
4296            .unwrap();
4297
4298        assert_eq!(
4299            metadata.title.as_deref(),
4300            Some("Automation: nightly status")
4301        );
4302        assert_eq!(metadata.workspace, workspace.display().to_string());
4303        assert_eq!(metadata.provider.as_deref(), Some("mock"));
4304        assert_eq!(metadata.model.as_deref(), Some("mock"));
4305    }
4306
4307    #[test]
4308    fn server_side_compaction_is_only_enabled_for_supported_models() {
4309        let cfg = RuntimeConfig {
4310            auto_compact_token_limit: Some(123_456),
4311            ..RuntimeConfig::default()
4312        };
4313
4314        assert_eq!(server_side_compaction_threshold(&cfg, "mock"), None);
4315        assert_eq!(
4316            server_side_compaction_threshold(&cfg, "codex-auto-review"),
4317            None
4318        );
4319    }
4320
4321    #[test]
4322    fn reasoning_is_disabled_for_models_without_reasoning_support() {
4323        let cfg = RuntimeConfig {
4324            reasoning: Some(REASONING_HIGH.to_string()),
4325            ..RuntimeConfig::default()
4326        };
4327
4328        assert_eq!(
4329            effective_reasoning_for_model(&cfg, "claude-haiku-4-5-20251001"),
4330            REASONING_NONE
4331        );
4332        assert_eq!(
4333            reasoning_for_model(&cfg, "claude-haiku-4-5-20251001"),
4334            ReasoningConfig::default()
4335        );
4336    }
4337
4338    #[test]
4339    fn unsupported_configured_reasoning_falls_back_to_model_default() {
4340        let cfg = RuntimeConfig {
4341            reasoning: Some(REASONING_MINIMAL.to_string()),
4342            ..RuntimeConfig::default()
4343        };
4344
4345        assert_eq!(
4346            effective_reasoning_for_model(&cfg, "gpt-5.5"),
4347            REASONING_MEDIUM
4348        );
4349    }
4350
4351    #[test]
4352    fn unsupported_configured_gemini_reasoning_is_rejected() {
4353        let mut builder = ExtensionRegistryBuilder::new();
4354        builder.inference_engine(std::sync::Arc::new(FakeInferenceEngine));
4355
4356        let err = match Runtime::new(
4357            builder.build().unwrap(),
4358            RuntimeConfig {
4359                default_model: "gemini-3.5-flash".to_string(),
4360                reasoning: Some(REASONING_XHIGH.to_string()),
4361                ..RuntimeConfig::default()
4362            },
4363        ) {
4364            Ok(_) => panic!("expected unsupported Gemini reasoning to be rejected"),
4365            Err(err) => err,
4366        };
4367
4368        assert!(
4369            err.to_string()
4370                .contains("model gemini-3.5-flash does not support reasoning effort xhigh")
4371        );
4372    }
4373
4374    #[tokio::test]
4375    async fn selecting_none_for_non_reasoning_model_preserves_stored_preference() {
4376        let runtime = Runtime::new(
4377            Runtime::fake().unwrap().registry,
4378            RuntimeConfig {
4379                reasoning: Some(REASONING_HIGH.to_string()),
4380                ..RuntimeConfig::default()
4381            },
4382        )
4383        .unwrap();
4384
4385        let cfg = runtime
4386            .select_provider(
4387                roder_api::catalog::PROVIDER_MOCK.to_string(),
4388                Some("claude-haiku-4-5-20251001".to_string()),
4389                Some(REASONING_NONE.to_string()),
4390            )
4391            .await
4392            .unwrap();
4393
4394        assert_eq!(cfg.reasoning.as_deref(), Some(REASONING_HIGH));
4395        assert_eq!(runtime.effective_reasoning().await, REASONING_NONE);
4396    }
4397
4398    #[tokio::test]
4399    async fn selecting_none_for_model_that_supports_none_updates_preference() {
4400        let runtime = Runtime::new(
4401            Runtime::fake().unwrap().registry,
4402            RuntimeConfig {
4403                reasoning: Some(REASONING_HIGH.to_string()),
4404                ..RuntimeConfig::default()
4405            },
4406        )
4407        .unwrap();
4408
4409        let cfg = runtime
4410            .select_provider(
4411                roder_api::catalog::PROVIDER_MOCK.to_string(),
4412                Some("mock".to_string()),
4413                Some(REASONING_NONE.to_string()),
4414            )
4415            .await
4416            .unwrap();
4417
4418        assert_eq!(cfg.reasoning.as_deref(), Some(REASONING_NONE));
4419    }
4420
4421    #[test]
4422    fn parallel_tool_calls_default_on_with_model_override() {
4423        assert!(parallel_tool_calls_for_model(
4424            &RuntimeConfig::default(),
4425            "custom-model"
4426        ));
4427
4428        let cfg = RuntimeConfig {
4429            model_parallel_tool_calls: std::collections::HashMap::from([(
4430                "custom-model".to_string(),
4431                false,
4432            )]),
4433            ..RuntimeConfig::default()
4434        };
4435
4436        assert!(!parallel_tool_calls_for_model(&cfg, "custom-model"));
4437        assert!(parallel_tool_calls_for_model(&cfg, "other-model"));
4438    }
4439
4440    #[test]
4441    fn profile_parallel_tool_calls_applies_between_config_and_default() {
4442        let cfg = RuntimeConfig {
4443            model_profiles: std::collections::HashMap::from([(
4444                "gpt-5.5".to_string(),
4445                test_model_profile("gpt-5.5"),
4446            )]),
4447            ..RuntimeConfig::default()
4448        };
4449
4450        assert!(!parallel_tool_calls_for_model(&cfg, "gpt-5.5"));
4451
4452        let cfg = RuntimeConfig {
4453            model_parallel_tool_calls: std::collections::HashMap::from([(
4454                "gpt-5.5".to_string(),
4455                true,
4456            )]),
4457            ..cfg
4458        };
4459
4460        assert!(parallel_tool_calls_for_model(&cfg, "gpt-5.5"));
4461    }
4462
4463    struct CapturingEngine {
4464        request: Arc<StdMutex<Option<AgentInferenceRequest>>>,
4465    }
4466
4467    #[async_trait::async_trait]
4468    impl InferenceEngine for CapturingEngine {
4469        fn id(&self) -> String {
4470            roder_api::catalog::PROVIDER_MOCK.to_string()
4471        }
4472
4473        fn capabilities(&self) -> InferenceCapabilities {
4474            InferenceCapabilities::coding_agent_default()
4475        }
4476
4477        async fn list_models(
4478            &self,
4479            _ctx: InferenceProviderContext<'_>,
4480        ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
4481            Ok(roder_api::catalog::models_for_provider(
4482                roder_api::catalog::PROVIDER_MOCK,
4483                true,
4484            ))
4485        }
4486
4487        async fn stream_turn(
4488            &self,
4489            _ctx: InferenceTurnContext<'_>,
4490            request: AgentInferenceRequest,
4491        ) -> anyhow::Result<InferenceEventStream> {
4492            *self.request.lock().unwrap() = Some(request);
4493            Ok(Box::pin(stream::iter(vec![
4494                Ok(InferenceEvent::MessageDelta(MessageDelta {
4495                    text: "done".to_string(),
4496                    phase: None,
4497                })),
4498                Ok(InferenceEvent::Completed(CompletionMetadata {
4499                    stop_reason: Some("stop".to_string()),
4500                    provider_response_id: None,
4501                })),
4502            ])))
4503        }
4504    }
4505
4506    struct RoutingCaptureEngine {
4507        id: &'static str,
4508        models: Vec<ModelDescriptor>,
4509        requests: Arc<StdMutex<Vec<AgentInferenceRequest>>>,
4510    }
4511
4512    #[async_trait::async_trait]
4513    impl InferenceEngine for RoutingCaptureEngine {
4514        fn id(&self) -> String {
4515            self.id.to_string()
4516        }
4517
4518        fn capabilities(&self) -> InferenceCapabilities {
4519            InferenceCapabilities::coding_agent_default()
4520        }
4521
4522        async fn list_models(
4523            &self,
4524            _ctx: InferenceProviderContext<'_>,
4525        ) -> anyhow::Result<Vec<ModelDescriptor>> {
4526            Ok(self.models.clone())
4527        }
4528
4529        async fn stream_turn(
4530            &self,
4531            _ctx: InferenceTurnContext<'_>,
4532            request: AgentInferenceRequest,
4533        ) -> anyhow::Result<InferenceEventStream> {
4534            self.requests.lock().unwrap().push(request);
4535            Ok(Box::pin(stream::iter(vec![
4536                Ok(InferenceEvent::MessageDelta(MessageDelta {
4537                    text: "routed".to_string(),
4538                    phase: None,
4539                })),
4540                Ok(InferenceEvent::Completed(CompletionMetadata {
4541                    stop_reason: Some("stop".to_string()),
4542                    provider_response_id: None,
4543                })),
4544            ])))
4545        }
4546    }
4547
4548    struct StaticRouter {
4549        id: &'static str,
4550        decision: InferenceRoutingDecision,
4551        contexts: Arc<StdMutex<Vec<InferenceRoutingContext>>>,
4552    }
4553
4554    #[async_trait::async_trait]
4555    impl InferenceRouter for StaticRouter {
4556        fn id(&self) -> String {
4557            self.id.to_string()
4558        }
4559
4560        async fn route(
4561            &self,
4562            context: InferenceRoutingContext,
4563        ) -> anyhow::Result<InferenceRoutingDecision> {
4564            self.contexts.lock().unwrap().push(context);
4565            Ok(self.decision.clone())
4566        }
4567    }
4568
4569    fn routing_test_model(id: &str, supported_reasoning: &[&str]) -> ModelDescriptor {
4570        ModelDescriptor {
4571            id: id.to_string(),
4572            name: id.to_string(),
4573            context_window: Some(128_000),
4574            default_reasoning: supported_reasoning
4575                .first()
4576                .map(|effort| (*effort).to_string()),
4577            supported_reasoning: supported_reasoning
4578                .iter()
4579                .map(|effort| ReasoningEffortDescriptor {
4580                    effort: (*effort).to_string(),
4581                    description: format!("{effort} reasoning"),
4582                })
4583                .collect(),
4584        }
4585    }
4586
4587    struct TaskLedgerCompletionGateEngine {
4588        calls: StdMutex<u32>,
4589        requests: Arc<StdMutex<Vec<AgentInferenceRequest>>>,
4590    }
4591
4592    #[async_trait::async_trait]
4593    impl InferenceEngine for TaskLedgerCompletionGateEngine {
4594        fn id(&self) -> String {
4595            roder_api::catalog::PROVIDER_MOCK.to_string()
4596        }
4597
4598        fn capabilities(&self) -> InferenceCapabilities {
4599            InferenceCapabilities::coding_agent_default()
4600        }
4601
4602        async fn list_models(
4603            &self,
4604            _ctx: InferenceProviderContext<'_>,
4605        ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
4606            Ok(roder_api::catalog::models_for_provider(
4607                roder_api::catalog::PROVIDER_MOCK,
4608                true,
4609            ))
4610        }
4611
4612        async fn stream_turn(
4613            &self,
4614            _ctx: InferenceTurnContext<'_>,
4615            request: AgentInferenceRequest,
4616        ) -> anyhow::Result<InferenceEventStream> {
4617            self.requests.lock().unwrap().push(request);
4618            let mut calls = self.calls.lock().unwrap();
4619            *calls += 1;
4620            let events = match *calls {
4621                1 => vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
4622                    id: "ledger-open".to_string(),
4623                    name: TASK_LEDGER_TOOL_NAME.to_string(),
4624                    arguments: serde_json::json!({
4625                        "tasks": [
4626                            {
4627                                "id": "inspect",
4628                                "content": "Inspect local assets",
4629                                "status": "completed",
4630                                "evidence": "listed workspace"
4631                            },
4632                            {
4633                                "id": "write",
4634                                "content": "Write /app/result.txt",
4635                                "status": "pending"
4636                            }
4637                        ],
4638                        "requireCompletionEvidence": true
4639                    })
4640                    .to_string(),
4641                }))],
4642                3 => vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
4643                    id: "ledger-complete".to_string(),
4644                    name: TASK_LEDGER_TOOL_NAME.to_string(),
4645                    arguments: serde_json::json!({
4646                        "tasks": [
4647                            {
4648                                "id": "inspect",
4649                                "content": "Inspect local assets",
4650                                "status": "completed",
4651                                "evidence": "listed workspace"
4652                            },
4653                            {
4654                                "id": "write",
4655                                "content": "Write /app/result.txt",
4656                                "status": "completed",
4657                                "evidence": "wrote answer"
4658                            }
4659                        ],
4660                        "requireCompletionEvidence": true
4661                    })
4662                    .to_string(),
4663                }))],
4664                _ => vec![Ok(InferenceEvent::MessageDelta(MessageDelta {
4665                    text: "final".to_string(),
4666                    phase: None,
4667                }))],
4668            };
4669            Ok(Box::pin(stream::iter(events.into_iter().chain(
4670                std::iter::once(Ok(InferenceEvent::Completed(CompletionMetadata {
4671                    stop_reason: Some("stop".to_string()),
4672                    provider_response_id: None,
4673                }))),
4674            ))))
4675        }
4676    }
4677
4678    struct VerificationGateEngine {
4679        calls: StdMutex<u32>,
4680    }
4681
4682    #[async_trait::async_trait]
4683    impl InferenceEngine for VerificationGateEngine {
4684        fn id(&self) -> String {
4685            roder_api::catalog::PROVIDER_MOCK.to_string()
4686        }
4687
4688        fn capabilities(&self) -> InferenceCapabilities {
4689            InferenceCapabilities::coding_agent_default()
4690        }
4691
4692        async fn list_models(
4693            &self,
4694            _ctx: InferenceProviderContext<'_>,
4695        ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
4696            Ok(roder_api::catalog::models_for_provider(
4697                roder_api::catalog::PROVIDER_MOCK,
4698                true,
4699            ))
4700        }
4701
4702        async fn stream_turn(
4703            &self,
4704            _ctx: InferenceTurnContext<'_>,
4705            request: AgentInferenceRequest,
4706        ) -> anyhow::Result<InferenceEventStream> {
4707            let mut calls = self.calls.lock().unwrap();
4708            *calls += 1;
4709            let events = match *calls {
4710                1 => vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
4711                    id: "write-1".to_string(),
4712                    name: "write_file".to_string(),
4713                    arguments: serde_json::json!({
4714                        "path": "src/lib.rs",
4715                        "content": "pub fn answer() -> u8 { 42 }\n"
4716                    })
4717                    .to_string(),
4718                }))],
4719                2 => vec![Ok(InferenceEvent::MessageDelta(MessageDelta {
4720                    text: "done too early".to_string(),
4721                    phase: None,
4722                }))],
4723                3 if request.transcript.iter().any(|item| {
4724                    matches!(
4725                        item,
4726                        TranscriptItem::UserMessage(message)
4727                            if message.text.contains("Verification gate blocked final completion")
4728                    )
4729                }) =>
4730                {
4731                    vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
4732                        id: "verify-1".to_string(),
4733                        name: crate::verification_gate::VERIFICATION_TOOL_NAME.to_string(),
4734                        arguments: serde_json::json!({
4735                            "originalTask": "write code",
4736                            "changedFiles": ["src/lib.rs"],
4737                            "toolEvidence": ["write_file wrote src/lib.rs"],
4738                            "testsRun": ["cargo test -p roder-core verification_gate"],
4739                            "openGaps": [],
4740                            "status": "completed"
4741                        })
4742                        .to_string(),
4743                    }))]
4744                }
4745                _ => vec![Ok(InferenceEvent::MessageDelta(MessageDelta {
4746                    text: "verified final".to_string(),
4747                    phase: None,
4748                }))],
4749            };
4750            Ok(Box::pin(stream::iter(events.into_iter().chain(
4751                std::iter::once(Ok(InferenceEvent::Completed(CompletionMetadata {
4752                    stop_reason: Some("stop".to_string()),
4753                    provider_response_id: None,
4754                }))),
4755            ))))
4756        }
4757    }
4758
4759    struct SpeedPolicyEngine {
4760        calls: StdMutex<u32>,
4761        requests: Arc<StdMutex<Vec<AgentInferenceRequest>>>,
4762    }
4763
4764    #[async_trait::async_trait]
4765    impl InferenceEngine for SpeedPolicyEngine {
4766        fn id(&self) -> String {
4767            roder_api::catalog::PROVIDER_MOCK.to_string()
4768        }
4769
4770        fn capabilities(&self) -> InferenceCapabilities {
4771            InferenceCapabilities::coding_agent_default()
4772        }
4773
4774        async fn list_models(
4775            &self,
4776            _ctx: InferenceProviderContext<'_>,
4777        ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
4778            Ok(roder_api::catalog::models_for_provider(
4779                roder_api::catalog::PROVIDER_MOCK,
4780                true,
4781            ))
4782        }
4783
4784        async fn stream_turn(
4785            &self,
4786            _ctx: InferenceTurnContext<'_>,
4787            request: AgentInferenceRequest,
4788        ) -> anyhow::Result<InferenceEventStream> {
4789            self.requests.lock().unwrap().push(request.clone());
4790            let mut calls = self.calls.lock().unwrap();
4791            *calls += 1;
4792            let events = match *calls {
4793                1 => vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
4794                    id: "write-1".to_string(),
4795                    name: "write_file".to_string(),
4796                    arguments: serde_json::json!({
4797                        "path": "src/lib.rs",
4798                        "content": "pub fn answer() -> u8 { 42 }\n"
4799                    })
4800                    .to_string(),
4801                }))],
4802                3 if request.transcript.iter().any(|item| {
4803                    matches!(
4804                        item,
4805                        TranscriptItem::UserMessage(message)
4806                            if message.text.contains("Verification gate blocked final completion")
4807                    )
4808                }) =>
4809                {
4810                    vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
4811                        id: "verify-1".to_string(),
4812                        name: crate::verification_gate::VERIFICATION_TOOL_NAME.to_string(),
4813                        arguments: serde_json::json!({
4814                            "originalTask": "write code",
4815                            "changedFiles": ["src/lib.rs"],
4816                            "toolEvidence": ["write_file wrote src/lib.rs"],
4817                            "testsRun": ["cargo test -p roder-core speed_policy"],
4818                            "openGaps": [],
4819                            "status": "completed"
4820                        })
4821                        .to_string(),
4822                    }))]
4823                }
4824                _ => vec![Ok(InferenceEvent::MessageDelta(MessageDelta {
4825                    text: "done".to_string(),
4826                    phase: None,
4827                }))],
4828            };
4829            Ok(Box::pin(stream::iter(events.into_iter().chain(
4830                std::iter::once(Ok(InferenceEvent::Completed(CompletionMetadata {
4831                    stop_reason: Some("stop".to_string()),
4832                    provider_response_id: None,
4833                }))),
4834            ))))
4835        }
4836    }
4837
4838    struct SwitchCaptureEngine {
4839        requests: Arc<StdMutex<Vec<AgentInferenceRequest>>>,
4840    }
4841
4842    #[async_trait::async_trait]
4843    impl InferenceEngine for SwitchCaptureEngine {
4844        fn id(&self) -> String {
4845            roder_api::catalog::PROVIDER_MOCK.to_string()
4846        }
4847
4848        fn capabilities(&self) -> InferenceCapabilities {
4849            InferenceCapabilities::coding_agent_default()
4850        }
4851
4852        async fn list_models(
4853            &self,
4854            _ctx: InferenceProviderContext<'_>,
4855        ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
4856            Ok(roder_api::catalog::models_for_provider(
4857                roder_api::catalog::PROVIDER_MOCK,
4858                true,
4859            ))
4860        }
4861
4862        async fn stream_turn(
4863            &self,
4864            _ctx: InferenceTurnContext<'_>,
4865            request: AgentInferenceRequest,
4866        ) -> anyhow::Result<InferenceEventStream> {
4867            self.requests.lock().unwrap().push(request);
4868            Ok(Box::pin(stream::iter(vec![
4869                Ok(InferenceEvent::MessageDelta(MessageDelta {
4870                    text: "done".to_string(),
4871                    phase: None,
4872                })),
4873                Ok(InferenceEvent::Completed(CompletionMetadata {
4874                    stop_reason: Some("stop".to_string()),
4875                    provider_response_id: None,
4876                })),
4877            ])))
4878        }
4879    }
4880
4881    struct DeadlineEngine;
4882
4883    #[async_trait::async_trait]
4884    impl InferenceEngine for DeadlineEngine {
4885        fn id(&self) -> String {
4886            roder_api::catalog::PROVIDER_MOCK.to_string()
4887        }
4888
4889        fn capabilities(&self) -> InferenceCapabilities {
4890            InferenceCapabilities::coding_agent_default()
4891        }
4892
4893        async fn list_models(
4894            &self,
4895            _ctx: InferenceProviderContext<'_>,
4896        ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
4897            Ok(Vec::new())
4898        }
4899
4900        async fn stream_turn(
4901            &self,
4902            _ctx: InferenceTurnContext<'_>,
4903            _request: AgentInferenceRequest,
4904        ) -> anyhow::Result<InferenceEventStream> {
4905            Ok(Box::pin(stream::once(async {
4906                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4907                Ok(InferenceEvent::MessageDelta(MessageDelta {
4908                    text: "too late".to_string(),
4909                    phase: None,
4910                }))
4911            })))
4912        }
4913    }
4914
4915    struct WriteFileContributor;
4916
4917    impl ToolContributor for WriteFileContributor {
4918        fn id(&self) -> String {
4919            "test-write".to_string()
4920        }
4921
4922        fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
4923            registry.register(Arc::new(WriteFileTool))
4924        }
4925    }
4926
4927    struct WriteFileTool;
4928
4929    #[async_trait::async_trait]
4930    impl ToolExecutor for WriteFileTool {
4931        fn spec(&self) -> ToolSpec {
4932            ToolSpec {
4933                name: "write_file".to_string(),
4934                description: "Write a test file.".to_string(),
4935                parameters: serde_json::json!({
4936                    "type": "object",
4937                    "properties": {
4938                        "path": { "type": "string" },
4939                        "content": { "type": "string" }
4940                    },
4941                    "required": ["path", "content"],
4942                    "additionalProperties": false
4943                }),
4944            }
4945        }
4946
4947        async fn execute(
4948            &self,
4949            _ctx: ToolExecutionContext,
4950            call: ToolCall,
4951        ) -> anyhow::Result<ToolResult> {
4952            let path = call
4953                .arguments
4954                .get("path")
4955                .and_then(serde_json::Value::as_str)
4956                .unwrap_or("src/lib.rs");
4957            Ok(ToolResult {
4958                id: call.id,
4959                name: call.name,
4960                text: format!("wrote {path}"),
4961                data: serde_json::json!({ "path": path }),
4962                is_error: false,
4963            })
4964        }
4965    }
4966
4967    struct ProfileToolContributor;
4968
4969    impl ToolContributor for ProfileToolContributor {
4970        fn id(&self) -> String {
4971            "profile-tools".to_string()
4972        }
4973
4974        fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
4975            for name in ["apply_patch", "edit", "multi_edit", "write_file"] {
4976                registry.register(Arc::new(ProfileTool {
4977                    name: name.to_string(),
4978                }))?;
4979            }
4980            Ok(())
4981        }
4982    }
4983
4984    struct ProfileTool {
4985        name: String,
4986    }
4987
4988    #[async_trait::async_trait]
4989    impl ToolExecutor for ProfileTool {
4990        fn spec(&self) -> ToolSpec {
4991            ToolSpec {
4992                name: self.name.clone(),
4993                description: format!("{} test tool", self.name),
4994                parameters: serde_json::json!({
4995                    "type": "object",
4996                    "properties": {
4997                        "path": { "type": "string" },
4998                        "content": { "type": "string" }
4999                    },
5000                    "required": ["path", "content"],
5001                    "additionalProperties": false
5002                }),
5003            }
5004        }
5005
5006        async fn execute(
5007            &self,
5008            _ctx: ToolExecutionContext,
5009            call: ToolCall,
5010        ) -> anyhow::Result<ToolResult> {
5011            Ok(ToolResult {
5012                id: call.id,
5013                name: call.name,
5014                text: "ok".to_string(),
5015                data: serde_json::json!({}),
5016                is_error: false,
5017            })
5018        }
5019    }
5020
5021    fn test_model_profile(model: &str) -> ModelHarnessProfile {
5022        ModelHarnessProfile {
5023            model: model.to_string(),
5024            provider: roder_api::catalog::PROVIDER_OPENAI.to_string(),
5025            provider_family: ProviderFamily::OpenAi,
5026            edit_tool: Some(EDIT_TOOL_EDIT.to_string()),
5027            schema_policy: ModelSchemaPolicy::StandardRequiredFirst,
5028            instruction_overlay: ModelInstructionOverlay::IntuitiveContext,
5029            reasoning: ModelProfileReasoning {
5030                orientation: Some(REASONING_LOW.to_string()),
5031                execution: Some(REASONING_LOW.to_string()),
5032                verification: Some(REASONING_LOW.to_string()),
5033                recovery: Some(REASONING_LOW.to_string()),
5034            },
5035            parallel_tool_calls: Some(false),
5036            auto_compact_token_limit: Some(123_000),
5037        }
5038    }
5039
5040    async fn captured_profile_request(cfg: RuntimeConfig) -> AgentInferenceRequest {
5041        let captured = Arc::new(StdMutex::new(None));
5042        let mut builder = ExtensionRegistryBuilder::new();
5043        builder.inference_engine(Arc::new(CapturingEngine {
5044            request: captured.clone(),
5045        }));
5046        builder.tool_contributor(Arc::new(ProfileToolContributor));
5047        let runtime = Arc::new(Runtime::new(builder.build().unwrap(), cfg).unwrap());
5048        let mut rx = runtime.subscribe_events();
5049        let turn_id = runtime
5050            .start_turn(StartTurnRequest {
5051                thread_id: "thread-model-profile".to_string(),
5052                message: "use profile knobs".to_string(),
5053                images: Vec::new(),
5054                provider_override: None,
5055                model_override: None,
5056                reasoning_override: None,
5057                workspace: test_workspace(),
5058                instructions: InstructionBundle {
5059                    system: None,
5060                    developer: Some("base developer".to_string()),
5061                    developer_context: None,
5062                },
5063                developer_context: None,
5064                task_ledger_required: false,
5065            })
5066            .await
5067            .unwrap();
5068
5069        tokio::time::timeout(std::time::Duration::from_secs(5), async {
5070            loop {
5071                let envelope = rx.recv().await.unwrap();
5072                if envelope.turn_id.as_deref() != Some(&turn_id) {
5073                    continue;
5074                }
5075                match envelope.event {
5076                    RoderEvent::TurnCompleted(_) => break,
5077                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5078                    _ => {}
5079                }
5080            }
5081        })
5082        .await
5083        .unwrap();
5084
5085        captured.lock().unwrap().clone().unwrap()
5086    }
5087
5088    struct ToolThenStopEngine {
5089        calls: StdMutex<u32>,
5090    }
5091
5092    #[async_trait::async_trait]
5093    impl InferenceEngine for ToolThenStopEngine {
5094        fn id(&self) -> String {
5095            roder_api::catalog::PROVIDER_MOCK.to_string()
5096        }
5097
5098        fn capabilities(&self) -> InferenceCapabilities {
5099            InferenceCapabilities::coding_agent_default()
5100        }
5101
5102        async fn list_models(
5103            &self,
5104            _ctx: InferenceProviderContext<'_>,
5105        ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
5106            Ok(roder_api::catalog::models_for_provider(
5107                roder_api::catalog::PROVIDER_MOCK,
5108                true,
5109            ))
5110        }
5111
5112        async fn stream_turn(
5113            &self,
5114            _ctx: InferenceTurnContext<'_>,
5115            _request: AgentInferenceRequest,
5116        ) -> anyhow::Result<InferenceEventStream> {
5117            let mut calls = self.calls.lock().unwrap();
5118            *calls += 1;
5119            let events = match *calls {
5120                1 => vec![
5121                    Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
5122                        id: "write-1".to_string(),
5123                        name: "write_file".to_string(),
5124                        arguments: serde_json::json!({
5125                            "path": "src/lib.rs",
5126                            "content": "pub fn answer() -> u8 { 42 }\n"
5127                        })
5128                        .to_string(),
5129                    })),
5130                    Ok(InferenceEvent::Completed(CompletionMetadata {
5131                        stop_reason: Some("tool_use".to_string()),
5132                        provider_response_id: None,
5133                    })),
5134                ],
5135                _ => vec![
5136                    Ok(InferenceEvent::MessageDelta(MessageDelta {
5137                        text: "final".to_string(),
5138                        phase: None,
5139                    })),
5140                    Ok(InferenceEvent::Completed(CompletionMetadata {
5141                        stop_reason: Some("end_turn".to_string()),
5142                        provider_response_id: None,
5143                    })),
5144                ],
5145            };
5146            Ok(Box::pin(stream::iter(events)))
5147        }
5148    }
5149
5150    #[tokio::test]
5151    async fn turn_completed_reports_terminal_step_finish_reason() {
5152        let mut builder = ExtensionRegistryBuilder::new();
5153        builder.inference_engine(Arc::new(ToolThenStopEngine {
5154            calls: StdMutex::new(0),
5155        }));
5156        builder.tool_contributor(Arc::new(WriteFileContributor));
5157        let runtime = Arc::new(
5158            Runtime::new(
5159                builder.build().unwrap(),
5160                RuntimeConfig {
5161                    policy_mode: PolicyMode::Bypass,
5162                    ..RuntimeConfig::default()
5163                },
5164            )
5165            .unwrap(),
5166        );
5167        let mut rx = runtime.subscribe_events();
5168        let turn_id = runtime
5169            .start_turn(StartTurnRequest {
5170                thread_id: "thread-finish-reason".to_string(),
5171                message: "write then finish".to_string(),
5172                images: Vec::new(),
5173                provider_override: None,
5174                model_override: None,
5175                reasoning_override: None,
5176                workspace: test_workspace(),
5177                instructions: InstructionBundle {
5178                    system: None,
5179                    developer: None,
5180                    developer_context: None,
5181                },
5182                developer_context: None,
5183                task_ledger_required: false,
5184            })
5185            .await
5186            .unwrap();
5187
5188        let completed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
5189            loop {
5190                let envelope = rx.recv().await.unwrap();
5191                if envelope.turn_id.as_deref() != Some(&turn_id) {
5192                    continue;
5193                }
5194                match envelope.event {
5195                    RoderEvent::TurnCompleted(event) => break event,
5196                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5197                    _ => {}
5198                }
5199            }
5200        })
5201        .await
5202        .unwrap();
5203
5204        // The mid-turn tool_use stop reason must not leak; the terminal
5205        // end_turn step decides the turn's finish reason.
5206        assert_eq!(completed.finish_reason.as_deref(), Some("stop"));
5207    }
5208
5209    #[tokio::test]
5210    async fn inference_router_selection_changes_request_model_and_records_event() {
5211        let default_requests = Arc::new(StdMutex::new(Vec::<AgentInferenceRequest>::new()));
5212        let routed_requests = Arc::new(StdMutex::new(Vec::<AgentInferenceRequest>::new()));
5213        let contexts = Arc::new(StdMutex::new(Vec::<InferenceRoutingContext>::new()));
5214        let selected = ModelSelection {
5215            provider: "routed-provider".to_string(),
5216            model: "routed-model".to_string(),
5217        };
5218        let default = ModelSelection {
5219            provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5220            model: "mock".to_string(),
5221        };
5222        let decision = InferenceRoutingDecision {
5223            reasoning: Some(ReasoningConfig {
5224                enabled: true,
5225                level: Some(REASONING_LOW.to_string()),
5226            }),
5227            confidence: Some(0.91),
5228            baseline: Some(default.clone()),
5229            matched_signals: vec![roder_api::inference_routing::InferenceRoutingSignal::new(
5230                "intent", "routine",
5231            )],
5232            ..InferenceRoutingDecision::selected("test-router", selected.clone(), "routine request")
5233        };
5234
5235        let mut builder = ExtensionRegistryBuilder::new();
5236        builder.inference_engine(Arc::new(RoutingCaptureEngine {
5237            id: roder_api::catalog::PROVIDER_MOCK,
5238            models: vec![routing_test_model("mock", &[REASONING_LOW])],
5239            requests: default_requests.clone(),
5240        }));
5241        builder.inference_engine(Arc::new(RoutingCaptureEngine {
5242            id: "routed-provider",
5243            models: vec![routing_test_model(
5244                "routed-model",
5245                &[REASONING_LOW, REASONING_MEDIUM],
5246            )],
5247            requests: routed_requests.clone(),
5248        }));
5249        builder.inference_router(Arc::new(StaticRouter {
5250            id: "test-router",
5251            decision,
5252            contexts: contexts.clone(),
5253        }));
5254        let thread_root =
5255            std::env::temp_dir().join(format!("roder-routing-auto-{}", uuid::Uuid::new_v4()));
5256        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
5257            base_path: thread_root.clone(),
5258        }));
5259        let runtime = Arc::new(
5260            Runtime::new(
5261                builder.build().unwrap(),
5262                RuntimeConfig {
5263                    default_provider: default.provider.clone(),
5264                    default_model: default.model.clone(),
5265                    ..RuntimeConfig::default()
5266                },
5267            )
5268            .unwrap(),
5269        );
5270        let thread_id = runtime
5271            .create_thread_with(CreateThreadRequest {
5272                title: Some("Routing auto".to_string()),
5273                workspace: test_workspace(),
5274                workspace_id: None,
5275                root_id: None,
5276                provider: Some(default.provider.clone()),
5277                model: Some(default.model.clone()),
5278                tool_allowlist: Vec::new(),
5279                developer_instructions: None,
5280                external_tools: Vec::new(),
5281                selection_mode: Some(ModelSelectionMode::auto(
5282                    "test-router:coding",
5283                    "test-router",
5284                    "Auto: Coding",
5285                    default.clone(),
5286                    Some("coding".to_string()),
5287                    None,
5288                )),
5289                runner: None,
5290            })
5291            .await
5292            .unwrap()
5293            .thread_id;
5294        let mut rx = runtime.subscribe_events();
5295        let turn_id = runtime
5296            .start_turn(StartTurnRequest {
5297                thread_id: thread_id.clone(),
5298                message: "small cleanup".to_string(),
5299                images: Vec::new(),
5300                provider_override: None,
5301                model_override: None,
5302                reasoning_override: None,
5303                workspace: test_workspace(),
5304                instructions: InstructionBundle::default(),
5305                developer_context: None,
5306                task_ledger_required: false,
5307            })
5308            .await
5309            .unwrap();
5310
5311        let mut routing_event = None;
5312        let mut inference_started = None;
5313        tokio::time::timeout(std::time::Duration::from_secs(5), async {
5314            loop {
5315                let envelope = rx.recv().await.unwrap();
5316                if envelope.turn_id.as_deref() != Some(&turn_id) {
5317                    continue;
5318                }
5319                match envelope.event {
5320                    RoderEvent::InferenceRoutingDecision(event) => {
5321                        routing_event = Some(event);
5322                    }
5323                    RoderEvent::InferenceStarted(event) => {
5324                        inference_started = Some(event);
5325                    }
5326                    RoderEvent::TurnCompleted(_) => break,
5327                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5328                    _ => {}
5329                }
5330            }
5331        })
5332        .await
5333        .unwrap();
5334
5335        assert!(default_requests.lock().unwrap().is_empty());
5336        let routed_requests = routed_requests.lock().unwrap();
5337        assert_eq!(routed_requests.len(), 1);
5338        assert_eq!(routed_requests[0].model, selected);
5339        assert_eq!(
5340            routed_requests[0].reasoning.level.as_deref(),
5341            Some(REASONING_LOW)
5342        );
5343        assert_eq!(
5344            routed_requests[0].metadata["inferenceRouting"]["outcome"],
5345            "selected"
5346        );
5347
5348        let routing_event = routing_event.expect("routing decision event");
5349        assert_eq!(routing_event.default_selection, default);
5350        assert_eq!(routing_event.selected_selection, selected);
5351        assert_eq!(
5352            routing_event.decision.outcome,
5353            InferenceRoutingOutcome::Selected
5354        );
5355        assert_eq!(
5356            inference_started.expect("inference started event").model,
5357            selected
5358        );
5359
5360        let contexts = contexts.lock().unwrap();
5361        assert_eq!(contexts.len(), 1);
5362        assert_eq!(contexts[0].default_selection, default);
5363        assert_eq!(contexts[0].candidates.len(), 2);
5364        assert!(
5365            contexts[0]
5366                .signals
5367                .iter()
5368                .any(|signal| signal.key == "profile" && signal.value == "coding")
5369        );
5370        let _ = std::fs::remove_dir_all(thread_root);
5371    }
5372
5373    #[tokio::test]
5374    async fn inference_router_is_bypassed_for_explicit_selection() {
5375        let requests = Arc::new(StdMutex::new(Vec::<AgentInferenceRequest>::new()));
5376        let contexts = Arc::new(StdMutex::new(Vec::<InferenceRoutingContext>::new()));
5377        let selected = ModelSelection {
5378            provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5379            model: "mock".to_string(),
5380        };
5381
5382        let mut builder = ExtensionRegistryBuilder::new();
5383        builder.inference_engine(Arc::new(RoutingCaptureEngine {
5384            id: roder_api::catalog::PROVIDER_MOCK,
5385            models: vec![routing_test_model("mock", &[REASONING_LOW])],
5386            requests: requests.clone(),
5387        }));
5388        builder.inference_router(Arc::new(StaticRouter {
5389            id: "test-router",
5390            decision: InferenceRoutingDecision::selected(
5391                "test-router",
5392                ModelSelection {
5393                    provider: "missing".to_string(),
5394                    model: "missing".to_string(),
5395                },
5396                "would route if called",
5397            ),
5398            contexts: contexts.clone(),
5399        }));
5400        let thread_root =
5401            std::env::temp_dir().join(format!("roder-routing-explicit-{}", uuid::Uuid::new_v4()));
5402        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
5403            base_path: thread_root.clone(),
5404        }));
5405        let runtime = Arc::new(
5406            Runtime::new(
5407                builder.build().unwrap(),
5408                RuntimeConfig {
5409                    default_provider: selected.provider.clone(),
5410                    default_model: selected.model.clone(),
5411                    inference_router: RuntimeInferenceRouterConfig {
5412                        enabled: true,
5413                        router_id: Some("test-router".to_string()),
5414                    },
5415                    ..RuntimeConfig::default()
5416                },
5417            )
5418            .unwrap(),
5419        );
5420        let thread_id = runtime
5421            .create_thread_with(CreateThreadRequest {
5422                title: Some("Routing explicit".to_string()),
5423                workspace: test_workspace(),
5424                workspace_id: None,
5425                root_id: None,
5426                provider: Some(selected.provider.clone()),
5427                model: Some(selected.model.clone()),
5428                tool_allowlist: Vec::new(),
5429                developer_instructions: None,
5430                external_tools: Vec::new(),
5431                selection_mode: Some(ModelSelectionMode::auto(
5432                    "test-router:default",
5433                    "test-router",
5434                    "Auto",
5435                    selected.clone(),
5436                    None,
5437                    None,
5438                )),
5439                runner: None,
5440            })
5441            .await
5442            .unwrap()
5443            .thread_id;
5444        let mut rx = runtime.subscribe_events();
5445        let turn_id = runtime
5446            .start_turn(StartTurnRequest {
5447                thread_id,
5448                message: "use explicit selection".to_string(),
5449                images: Vec::new(),
5450                provider_override: Some(selected.provider.clone()),
5451                model_override: Some(selected.model.clone()),
5452                reasoning_override: None,
5453                workspace: test_workspace(),
5454                instructions: InstructionBundle::default(),
5455                developer_context: None,
5456                task_ledger_required: false,
5457            })
5458            .await
5459            .unwrap();
5460
5461        let mut saw_routing_event = false;
5462        tokio::time::timeout(std::time::Duration::from_secs(5), async {
5463            loop {
5464                let envelope = rx.recv().await.unwrap();
5465                if envelope.turn_id.as_deref() != Some(&turn_id) {
5466                    continue;
5467                }
5468                match envelope.event {
5469                    RoderEvent::InferenceRoutingDecision(_) => {
5470                        saw_routing_event = true;
5471                    }
5472                    RoderEvent::TurnCompleted(_) => break,
5473                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5474                    _ => {}
5475                }
5476            }
5477        })
5478        .await
5479        .unwrap();
5480
5481        assert!(!saw_routing_event);
5482        assert!(contexts.lock().unwrap().is_empty());
5483        let requests = requests.lock().unwrap();
5484        assert_eq!(requests.len(), 1);
5485        assert_eq!(requests[0].model, selected);
5486        let _ = std::fs::remove_dir_all(thread_root);
5487    }
5488
5489    #[tokio::test]
5490    async fn inference_router_is_bypassed_for_manual_selection_mode() {
5491        let requests = Arc::new(StdMutex::new(Vec::<AgentInferenceRequest>::new()));
5492        let contexts = Arc::new(StdMutex::new(Vec::<InferenceRoutingContext>::new()));
5493        let selected = ModelSelection {
5494            provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5495            model: "mock".to_string(),
5496        };
5497
5498        let mut builder = ExtensionRegistryBuilder::new();
5499        builder.inference_engine(Arc::new(RoutingCaptureEngine {
5500            id: roder_api::catalog::PROVIDER_MOCK,
5501            models: vec![routing_test_model("mock", &[REASONING_LOW])],
5502            requests: requests.clone(),
5503        }));
5504        builder.inference_router(Arc::new(StaticRouter {
5505            id: "test-router",
5506            decision: InferenceRoutingDecision::selected(
5507                "test-router",
5508                ModelSelection {
5509                    provider: "missing".to_string(),
5510                    model: "missing".to_string(),
5511                },
5512                "would route if called",
5513            ),
5514            contexts: contexts.clone(),
5515        }));
5516        let thread_root =
5517            std::env::temp_dir().join(format!("roder-routing-manual-{}", uuid::Uuid::new_v4()));
5518        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
5519            base_path: thread_root.clone(),
5520        }));
5521        let runtime = Arc::new(
5522            Runtime::new(
5523                builder.build().unwrap(),
5524                RuntimeConfig {
5525                    default_provider: selected.provider.clone(),
5526                    default_model: selected.model.clone(),
5527                    inference_router: RuntimeInferenceRouterConfig {
5528                        enabled: true,
5529                        router_id: Some("test-router".to_string()),
5530                    },
5531                    ..RuntimeConfig::default()
5532                },
5533            )
5534            .unwrap(),
5535        );
5536        let thread_id = runtime
5537            .create_thread_with(CreateThreadRequest {
5538                title: Some("Routing manual".to_string()),
5539                workspace: test_workspace(),
5540                workspace_id: None,
5541                root_id: None,
5542                provider: Some(selected.provider.clone()),
5543                model: Some(selected.model.clone()),
5544                tool_allowlist: Vec::new(),
5545                developer_instructions: None,
5546                external_tools: Vec::new(),
5547                selection_mode: Some(ModelSelectionMode::manual(
5548                    selected.provider.clone(),
5549                    selected.model.clone(),
5550                    None,
5551                )),
5552                runner: None,
5553            })
5554            .await
5555            .unwrap()
5556            .thread_id;
5557        let mut rx = runtime.subscribe_events();
5558        let turn_id = runtime
5559            .start_turn(StartTurnRequest {
5560                thread_id,
5561                message: "use selected manual model".to_string(),
5562                images: Vec::new(),
5563                provider_override: None,
5564                model_override: None,
5565                reasoning_override: None,
5566                workspace: test_workspace(),
5567                instructions: InstructionBundle::default(),
5568                developer_context: None,
5569                task_ledger_required: false,
5570            })
5571            .await
5572            .unwrap();
5573
5574        let mut saw_routing_event = false;
5575        tokio::time::timeout(std::time::Duration::from_secs(5), async {
5576            loop {
5577                let envelope = rx.recv().await.unwrap();
5578                if envelope.turn_id.as_deref() != Some(&turn_id) {
5579                    continue;
5580                }
5581                match envelope.event {
5582                    RoderEvent::InferenceRoutingDecision(_) => {
5583                        saw_routing_event = true;
5584                    }
5585                    RoderEvent::TurnCompleted(_) => break,
5586                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5587                    _ => {}
5588                }
5589            }
5590        })
5591        .await
5592        .unwrap();
5593
5594        assert!(!saw_routing_event);
5595        assert!(contexts.lock().unwrap().is_empty());
5596        let requests = requests.lock().unwrap();
5597        assert_eq!(requests.len(), 1);
5598        assert_eq!(requests[0].model, selected);
5599        let _ = std::fs::remove_dir_all(thread_root);
5600    }
5601
5602    #[test]
5603    fn enabled_inference_router_requires_registered_router() {
5604        let mut builder = ExtensionRegistryBuilder::new();
5605        builder.inference_engine(Arc::new(FakeInferenceEngine));
5606
5607        let err = match Runtime::new(
5608            builder.build().unwrap(),
5609            RuntimeConfig {
5610                inference_router: RuntimeInferenceRouterConfig {
5611                    enabled: true,
5612                    router_id: Some("missing-router".to_string()),
5613                },
5614                ..RuntimeConfig::default()
5615            },
5616        ) {
5617            Ok(_) => panic!("runtime should reject unknown inference router"),
5618            Err(err) => err,
5619        };
5620
5621        assert!(
5622            err.to_string()
5623                .contains("inference router \"missing-router\" is not registered")
5624        );
5625    }
5626
5627    #[tokio::test]
5628    async fn model_profile_routes_request_knobs_to_next_inference() {
5629        let request = captured_profile_request(RuntimeConfig {
5630            default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5631            default_model: "gpt-5.5".to_string(),
5632            model_profiles: std::collections::HashMap::from([(
5633                "gpt-5.5".to_string(),
5634                test_model_profile("gpt-5.5"),
5635            )]),
5636            ..RuntimeConfig::default()
5637        })
5638        .await;
5639
5640        let tool_names = request
5641            .tools
5642            .iter()
5643            .map(|tool| tool.name.as_str())
5644            .collect::<Vec<_>>();
5645        assert!(tool_names.contains(&"apply_patch"));
5646        assert!(tool_names.contains(&"edit"));
5647        assert!(tool_names.contains(&"multi_edit"));
5648        assert!(tool_names.contains(&"write_file"));
5649        assert_eq!(request.reasoning.level.as_deref(), Some(REASONING_LOW));
5650        assert_eq!(request.runtime.parallel_tool_calls, Some(false));
5651        assert_eq!(request.runtime.auto_compact_token_limit, Some(123_000));
5652        assert!(
5653            request
5654                .instructions
5655                .developer
5656                .as_deref()
5657                .unwrap_or_default()
5658                .contains("Use the provided context as the current working set")
5659        );
5660        assert_eq!(
5661            request
5662                .metadata
5663                .pointer("/modelProfile/schemaPolicy")
5664                .and_then(serde_json::Value::as_str),
5665            Some("standard_required_first")
5666        );
5667    }
5668
5669    #[tokio::test]
5670    async fn turn_developer_context_reaches_inference_and_does_not_persist() {
5671        let captured = Arc::new(StdMutex::new(None));
5672        let mut builder = ExtensionRegistryBuilder::new();
5673        builder.inference_engine(Arc::new(CapturingEngine {
5674            request: captured.clone(),
5675        }));
5676        let runtime = Arc::new(
5677            Runtime::new(
5678                builder.build().unwrap(),
5679                RuntimeConfig {
5680                    default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5681                    default_model: "gpt-5.5".to_string(),
5682                    ..RuntimeConfig::default()
5683                },
5684            )
5685            .unwrap(),
5686        );
5687
5688        async fn run_turn(runtime: &Arc<Runtime>, developer_context: Option<String>) {
5689            let mut rx = runtime.subscribe_events();
5690            let turn_id = runtime
5691                .start_turn(StartTurnRequest {
5692                    thread_id: "thread-turn-context".to_string(),
5693                    message: "hello".to_string(),
5694                    images: Vec::new(),
5695                    provider_override: None,
5696                    model_override: None,
5697                    reasoning_override: None,
5698                    workspace: test_workspace(),
5699                    instructions: InstructionBundle::default(),
5700                    developer_context,
5701                    task_ledger_required: false,
5702                })
5703                .await
5704                .unwrap();
5705            tokio::time::timeout(std::time::Duration::from_secs(5), async {
5706                loop {
5707                    let envelope = rx.recv().await.unwrap();
5708                    if envelope.turn_id.as_deref() != Some(&turn_id) {
5709                        continue;
5710                    }
5711                    match envelope.event {
5712                        RoderEvent::TurnCompleted(_) => break,
5713                        RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5714                        _ => {}
5715                    }
5716                }
5717            })
5718            .await
5719            .unwrap();
5720        }
5721
5722        run_turn(
5723            &runtime,
5724            Some("Connected accounts: example-service.".to_string()),
5725        )
5726        .await;
5727        let request = captured.lock().unwrap().clone().unwrap();
5728        assert_eq!(
5729            request.instructions.developer_context.as_deref(),
5730            Some("Connected accounts: example-service.")
5731        );
5732
5733        // The context is per-turn only: the next turn on the same thread
5734        // without a developerContext must not see the previous one.
5735        run_turn(&runtime, None).await;
5736        let request = captured.lock().unwrap().clone().unwrap();
5737        assert_eq!(request.instructions.developer_context, None);
5738    }
5739
5740    #[tokio::test]
5741    async fn tool_search_overrides_route_to_next_inference_request() {
5742        let request = captured_profile_request(RuntimeConfig {
5743            default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5744            default_model: "gpt-5.4".to_string(),
5745            tool_search: ToolSearchConfig {
5746                mode: roder_api::inference::ToolSearchMode::Auto,
5747                max_catalog_items: Some(100),
5748                ..ToolSearchConfig::default()
5749            },
5750            provider_tool_search: std::collections::HashMap::from([(
5751                roder_api::catalog::PROVIDER_MOCK.to_string(),
5752                roder_api::inference::ToolSearchConfigOverlay {
5753                    include_skills: Some(false),
5754                    provider_variant: Some(roder_api::inference::ToolSearchProviderVariant::Regex),
5755                    ..Default::default()
5756                },
5757            )]),
5758            model_tool_search: std::collections::HashMap::from([(
5759                "gpt-5.4".to_string(),
5760                roder_api::inference::ToolSearchConfigOverlay {
5761                    mode: Some(roder_api::inference::ToolSearchMode::ProviderNative),
5762                    max_catalog_items: Some(25),
5763                    provider_variant: Some(roder_api::inference::ToolSearchProviderVariant::Bm25),
5764                    ..Default::default()
5765                },
5766            )]),
5767            ..RuntimeConfig::default()
5768        })
5769        .await;
5770
5771        assert_eq!(
5772            request.runtime.tool_search.mode,
5773            roder_api::inference::ToolSearchMode::ProviderNative
5774        );
5775        assert_eq!(request.runtime.tool_search.max_catalog_items, Some(25));
5776        assert_eq!(
5777            request.runtime.tool_search.provider_variant,
5778            roder_api::inference::ToolSearchProviderVariant::Bm25
5779        );
5780    }
5781
5782    #[tokio::test]
5783    async fn context_entrypoint_hints_use_turn_workspace() {
5784        let process_workspace = runtime_test_workspace("entrypoint-process");
5785        let thread_workspace = runtime_test_workspace("entrypoint-thread");
5786        std::fs::create_dir_all(process_workspace.join("src")).unwrap();
5787        std::fs::create_dir_all(thread_workspace.join("src")).unwrap();
5788        std::fs::write(
5789            process_workspace.join("src/sidebar-thread-groups.ts"),
5790            "export const desktopLeak = true;\n",
5791        )
5792        .unwrap();
5793        std::fs::write(
5794            thread_workspace.join("src/voice-plan-feedback.ts"),
5795            "export const voicePlanFeedback = true;\n",
5796        )
5797        .unwrap();
5798
5799        let captured = Arc::new(StdMutex::new(None));
5800        let mut builder = ExtensionRegistryBuilder::new();
5801        builder.inference_engine(Arc::new(CapturingEngine {
5802            request: captured.clone(),
5803        }));
5804        builder.context_planner(Arc::new(roder_context::EntrypointContextPlanner::new(
5805            process_workspace.clone(),
5806        )));
5807        let runtime = Arc::new(
5808            Runtime::new(
5809                builder.build().unwrap(),
5810                RuntimeConfig {
5811                    workspace: Some(process_workspace.display().to_string()),
5812                    ..RuntimeConfig::default()
5813                },
5814            )
5815            .unwrap(),
5816        );
5817        let mut rx = runtime.subscribe_events();
5818
5819        let turn_id = runtime
5820            .start_turn(StartTurnRequest {
5821                thread_id: "thread-workspace-entrypoint".to_string(),
5822                message: "investigate voice plan feedback".to_string(),
5823                images: Vec::new(),
5824                provider_override: None,
5825                model_override: None,
5826                reasoning_override: None,
5827                workspace: thread_workspace.display().to_string(),
5828                instructions: crate::instructions::default_instructions(),
5829                developer_context: None,
5830                task_ledger_required: false,
5831            })
5832            .await
5833            .unwrap();
5834
5835        tokio::time::timeout(std::time::Duration::from_secs(5), async {
5836            loop {
5837                let envelope = rx.recv().await.unwrap();
5838                if envelope.turn_id.as_deref() != Some(&turn_id) {
5839                    continue;
5840                }
5841                match envelope.event {
5842                    RoderEvent::TurnCompleted(_) => break,
5843                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5844                    _ => {}
5845                }
5846            }
5847        })
5848        .await
5849        .unwrap();
5850
5851        let request = captured.lock().unwrap().clone().expect("captured request");
5852        let transcript_text = request
5853            .transcript
5854            .iter()
5855            .map(|item| match item {
5856                TranscriptItem::UserMessage(message) => message.text.as_str(),
5857                _ => "",
5858            })
5859            .collect::<Vec<_>>()
5860            .join("\n");
5861        assert!(transcript_text.contains("src/voice-plan-feedback.ts"));
5862        assert!(!transcript_text.contains("src/sidebar-thread-groups.ts"));
5863
5864        let _ = std::fs::remove_dir_all(process_workspace);
5865        let _ = std::fs::remove_dir_all(thread_workspace);
5866    }
5867
5868    fn runtime_test_workspace(name: &str) -> std::path::PathBuf {
5869        let path =
5870            std::env::temp_dir().join(format!("roder-runtime-{name}-{}", uuid::Uuid::new_v4()));
5871        let _ = std::fs::remove_dir_all(&path);
5872        std::fs::create_dir_all(&path).unwrap();
5873        path
5874    }
5875
5876    #[tokio::test]
5877    async fn model_profile_user_model_knobs_override_profile_defaults() {
5878        let request = captured_profile_request(RuntimeConfig {
5879            default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5880            default_model: "gpt-5.5".to_string(),
5881            reasoning: Some(REASONING_HIGH.to_string()),
5882            auto_compact_token_limit: Some(999),
5883            model_edit_tools: std::collections::HashMap::from([(
5884                "gpt-5.5".to_string(),
5885                EDIT_TOOL_PATCH.to_string(),
5886            )]),
5887            model_parallel_tool_calls: std::collections::HashMap::from([(
5888                "gpt-5.5".to_string(),
5889                true,
5890            )]),
5891            model_profiles: std::collections::HashMap::from([(
5892                "gpt-5.5".to_string(),
5893                test_model_profile("gpt-5.5"),
5894            )]),
5895            ..RuntimeConfig::default()
5896        })
5897        .await;
5898
5899        let tool_names = request
5900            .tools
5901            .iter()
5902            .map(|tool| tool.name.as_str())
5903            .collect::<Vec<_>>();
5904        assert!(tool_names.contains(&"apply_patch"));
5905        assert!(!tool_names.contains(&"edit"));
5906        assert_eq!(request.reasoning.level.as_deref(), Some(REASONING_HIGH));
5907        assert_eq!(request.runtime.parallel_tool_calls, Some(true));
5908        assert_eq!(request.runtime.auto_compact_token_limit, Some(999));
5909    }
5910
5911    #[tokio::test]
5912    async fn runtime_tool_allowlist_filters_advertised_tools() {
5913        let request = captured_profile_request(RuntimeConfig {
5914            default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5915            default_model: "gpt-5.5".to_string(),
5916            tool_allowlist: vec!["edit".to_string()],
5917            model_profiles: std::collections::HashMap::from([(
5918                "gpt-5.5".to_string(),
5919                test_model_profile("gpt-5.5"),
5920            )]),
5921            ..RuntimeConfig::default()
5922        })
5923        .await;
5924
5925        let tool_names = request
5926            .tools
5927            .iter()
5928            .map(|tool| tool.name.as_str())
5929            .collect::<Vec<_>>();
5930        assert_eq!(tool_names, vec!["edit"]);
5931    }
5932
5933    /// Builds a store-backed runtime turn for a thread created with the given per-thread
5934    /// overrides and returns the captured inference request.
5935    async fn captured_thread_override_request(
5936        runtime: &Arc<Runtime>,
5937        requests: &Arc<StdMutex<Vec<AgentInferenceRequest>>>,
5938        tool_allowlist: Vec<String>,
5939        developer_instructions: Option<String>,
5940        external_tools: Vec<ToolSpec>,
5941    ) -> AgentInferenceRequest {
5942        let thread_id = runtime
5943            .create_thread_with(CreateThreadRequest {
5944                title: Some("Thread overrides".to_string()),
5945                workspace: test_workspace(),
5946                workspace_id: None,
5947                root_id: None,
5948                provider: None,
5949                model: None,
5950                selection_mode: None,
5951                tool_allowlist,
5952                developer_instructions,
5953                external_tools,
5954                runner: None,
5955            })
5956            .await
5957            .unwrap()
5958            .thread_id;
5959        let mut rx = runtime.subscribe_events();
5960        let turn_id = runtime
5961            .start_turn(StartTurnRequest {
5962                thread_id,
5963                message: "hello".to_string(),
5964                images: Vec::new(),
5965                provider_override: None,
5966                model_override: None,
5967                reasoning_override: None,
5968                workspace: test_workspace(),
5969                instructions: crate::default_instructions(),
5970                developer_context: None,
5971                task_ledger_required: false,
5972            })
5973            .await
5974            .unwrap();
5975        tokio::time::timeout(std::time::Duration::from_secs(5), async {
5976            loop {
5977                let envelope = rx.recv().await.unwrap();
5978                if envelope.turn_id.as_deref() != Some(&turn_id) {
5979                    continue;
5980                }
5981                match envelope.event {
5982                    RoderEvent::TurnCompleted(_) => break,
5983                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5984                    _ => {}
5985                }
5986            }
5987        })
5988        .await
5989        .unwrap();
5990        requests.lock().unwrap().pop().expect("captured request")
5991    }
5992
5993    #[tokio::test]
5994    async fn thread_tool_allowlist_filters_only_that_thread() {
5995        let requests = Arc::new(StdMutex::new(Vec::new()));
5996        let thread_root =
5997            std::env::temp_dir().join(format!("roder-thread-allowlist-{}", uuid::Uuid::new_v4()));
5998        let mut builder = ExtensionRegistryBuilder::new();
5999        builder.inference_engine(Arc::new(SwitchCaptureEngine {
6000            requests: requests.clone(),
6001        }));
6002        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6003            base_path: thread_root.clone(),
6004        }));
6005        builder.tool_contributor(Arc::new(ProfileToolContributor));
6006        let runtime = Arc::new(
6007            Runtime::new(
6008                builder.build().unwrap(),
6009                RuntimeConfig {
6010                    default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6011                    default_model: "gpt-5.5".to_string(),
6012                    model_profiles: std::collections::HashMap::from([(
6013                        "gpt-5.5".to_string(),
6014                        test_model_profile("gpt-5.5"),
6015                    )]),
6016                    ..RuntimeConfig::default()
6017                },
6018            )
6019            .unwrap(),
6020        );
6021
6022        let allowlisted = captured_thread_override_request(
6023            &runtime,
6024            &requests,
6025            vec!["edit".to_string()],
6026            None,
6027            Vec::new(),
6028        )
6029        .await;
6030        let unrestricted =
6031            captured_thread_override_request(&runtime, &requests, Vec::new(), None, Vec::new())
6032                .await;
6033
6034        let allowlisted_names = allowlisted
6035            .tools
6036            .iter()
6037            .map(|tool| tool.name.as_str())
6038            .collect::<Vec<_>>();
6039        assert_eq!(allowlisted_names, vec!["edit"]);
6040        let unrestricted_names = unrestricted
6041            .tools
6042            .iter()
6043            .map(|tool| tool.name.as_str())
6044            .collect::<Vec<_>>();
6045        assert!(unrestricted_names.contains(&"edit"));
6046        assert!(unrestricted_names.len() > 1);
6047
6048        let _ = std::fs::remove_dir_all(thread_root);
6049    }
6050
6051    /// Builds a store-backed runtime whose `RuntimeConfig.tool_allowlist` is `["edit"]`.
6052    fn runtime_with_edit_allowlist(
6053        requests: &Arc<StdMutex<Vec<AgentInferenceRequest>>>,
6054        thread_root: &std::path::Path,
6055    ) -> Arc<Runtime> {
6056        let mut builder = ExtensionRegistryBuilder::new();
6057        builder.inference_engine(Arc::new(SwitchCaptureEngine {
6058            requests: requests.clone(),
6059        }));
6060        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6061            base_path: thread_root.to_path_buf(),
6062        }));
6063        builder.tool_contributor(Arc::new(ProfileToolContributor));
6064        Arc::new(
6065            Runtime::new(
6066                builder.build().unwrap(),
6067                RuntimeConfig {
6068                    default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6069                    default_model: "gpt-5.5".to_string(),
6070                    tool_allowlist: vec!["edit".to_string()],
6071                    model_profiles: std::collections::HashMap::from([(
6072                        "gpt-5.5".to_string(),
6073                        test_model_profile("gpt-5.5"),
6074                    )]),
6075                    ..RuntimeConfig::default()
6076                },
6077            )
6078            .unwrap(),
6079        )
6080    }
6081
6082    #[tokio::test]
6083    async fn runtime_and_thread_allowlists_intersect() {
6084        let requests = Arc::new(StdMutex::new(Vec::new()));
6085        let thread_root = std::env::temp_dir().join(format!(
6086            "roder-allowlist-intersect-{}",
6087            uuid::Uuid::new_v4()
6088        ));
6089        let runtime = runtime_with_edit_allowlist(&requests, &thread_root);
6090
6091        let request = captured_thread_override_request(
6092            &runtime,
6093            &requests,
6094            vec!["edit".to_string(), "write_file".to_string()],
6095            None,
6096            Vec::new(),
6097        )
6098        .await;
6099
6100        // The thread allowlist must not re-enable tools the runtime allowlist bans.
6101        let names = request
6102            .tools
6103            .iter()
6104            .map(|tool| tool.name.as_str())
6105            .collect::<Vec<_>>();
6106        assert_eq!(names, vec!["edit"]);
6107
6108        let _ = std::fs::remove_dir_all(thread_root);
6109    }
6110
6111    #[tokio::test]
6112    async fn route_tool_call_denies_tools_outside_allowlists() {
6113        let requests = Arc::new(StdMutex::new(Vec::new()));
6114        let thread_root =
6115            std::env::temp_dir().join(format!("roder-allowlist-dispatch-{}", uuid::Uuid::new_v4()));
6116        let runtime = runtime_with_edit_allowlist(&requests, &thread_root);
6117        let thread_id = runtime
6118            .create_thread_with(CreateThreadRequest {
6119                title: Some("Dispatch allowlist".to_string()),
6120                workspace: test_workspace(),
6121                workspace_id: None,
6122                root_id: None,
6123                provider: None,
6124                model: None,
6125                selection_mode: None,
6126                tool_allowlist: vec!["edit".to_string(), "write_file".to_string()],
6127                developer_instructions: None,
6128                external_tools: Vec::new(),
6129                runner: None,
6130            })
6131            .await
6132            .unwrap()
6133            .thread_id;
6134
6135        // write_file is registered and on the thread allowlist but banned by the runtime allowlist.
6136        let result = runtime
6137            .route_tool_call(
6138                &thread_id,
6139                &"turn-allowlist-dispatch".to_string(),
6140                roder_api::inference::ToolCallCompleted {
6141                    id: "call-1".to_string(),
6142                    name: "write_file".to_string(),
6143                    arguments: r#"{"path":"a.txt","content":"hi"}"#.to_string(),
6144                },
6145                None,
6146                None,
6147            )
6148            .await
6149            .unwrap();
6150
6151        assert!(result.is_error);
6152        assert!(
6153            result
6154                .result
6155                .contains("not permitted by the tool allowlist"),
6156            "unexpected result: {}",
6157            result.result
6158        );
6159
6160        let _ = std::fs::remove_dir_all(thread_root);
6161    }
6162
6163    /// Signals when inference starts, then waits for `proceed` and fails the stream.
6164    struct SignalledFailureEngine {
6165        started: tokio::sync::mpsc::UnboundedSender<()>,
6166        proceed: Arc<tokio::sync::Notify>,
6167    }
6168
6169    #[async_trait::async_trait]
6170    impl InferenceEngine for SignalledFailureEngine {
6171        fn id(&self) -> String {
6172            roder_api::catalog::PROVIDER_MOCK.to_string()
6173        }
6174
6175        fn capabilities(&self) -> InferenceCapabilities {
6176            InferenceCapabilities::coding_agent_default()
6177        }
6178
6179        async fn list_models(
6180            &self,
6181            _ctx: InferenceProviderContext<'_>,
6182        ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
6183            Ok(roder_api::catalog::models_for_provider(
6184                roder_api::catalog::PROVIDER_MOCK,
6185                true,
6186            ))
6187        }
6188
6189        async fn stream_turn(
6190            &self,
6191            _ctx: InferenceTurnContext<'_>,
6192            _request: AgentInferenceRequest,
6193        ) -> anyhow::Result<InferenceEventStream> {
6194            let _ = self.started.send(());
6195            self.proceed.notified().await;
6196            anyhow::bail!("engine failed mid-turn")
6197        }
6198    }
6199
6200    #[tokio::test]
6201    async fn failed_turn_sweeps_pending_external_tool_calls() {
6202        let (started_tx, mut started_rx) = tokio::sync::mpsc::unbounded_channel();
6203        let proceed = Arc::new(tokio::sync::Notify::new());
6204        let mut builder = ExtensionRegistryBuilder::new();
6205        builder.inference_engine(Arc::new(SignalledFailureEngine {
6206            started: started_tx,
6207            proceed: proceed.clone(),
6208        }));
6209        let runtime =
6210            Arc::new(Runtime::new(builder.build().unwrap(), RuntimeConfig::default()).unwrap());
6211        let mut rx = runtime.subscribe_events();
6212        let turn_id = runtime
6213            .start_turn(StartTurnRequest {
6214                thread_id: "thread-sweep".to_string(),
6215                message: "go".to_string(),
6216                images: Vec::new(),
6217                provider_override: None,
6218                model_override: None,
6219                reasoning_override: None,
6220                workspace: test_workspace(),
6221                instructions: InstructionBundle {
6222                    system: None,
6223                    developer: None,
6224                    developer_context: None,
6225                },
6226                developer_context: None,
6227                task_ledger_required: false,
6228            })
6229            .await
6230            .unwrap();
6231        tokio::time::timeout(std::time::Duration::from_secs(5), started_rx.recv())
6232            .await
6233            .unwrap()
6234            .unwrap();
6235
6236        let (tx, _pending_rx) = oneshot::channel();
6237        runtime.pending_external_tool_calls.lock().await.insert(
6238            "exttool-sweep-test".to_string(),
6239            PendingExternalToolCall {
6240                thread_id: "thread-sweep".to_string(),
6241                turn_id: turn_id.clone(),
6242                tool_id: "call-1".to_string(),
6243                tool_name: "acme_lookup".to_string(),
6244                tx,
6245            },
6246        );
6247        proceed.notify_one();
6248
6249        let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async {
6250            loop {
6251                let envelope = rx.recv().await.unwrap();
6252                if let RoderEvent::ExternalToolCallResolved(event) = envelope.event
6253                    && event.request_id == "exttool-sweep-test"
6254                {
6255                    break event.outcome;
6256                }
6257            }
6258        })
6259        .await
6260        .expect("turn failure must resolve pending external tool calls");
6261        assert_eq!(outcome, ExternalToolCallOutcome::Cancelled);
6262        assert!(runtime.pending_external_tool_calls.lock().await.is_empty());
6263    }
6264
6265    #[tokio::test]
6266    async fn thread_developer_instructions_layer_under_harness_prompt() {
6267        let requests = Arc::new(StdMutex::new(Vec::new()));
6268        let thread_root = std::env::temp_dir().join(format!(
6269            "roder-thread-instructions-{}",
6270            uuid::Uuid::new_v4()
6271        ));
6272        let mut builder = ExtensionRegistryBuilder::new();
6273        builder.inference_engine(Arc::new(SwitchCaptureEngine {
6274            requests: requests.clone(),
6275        }));
6276        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6277            base_path: thread_root.clone(),
6278        }));
6279        builder.tool_contributor(Arc::new(ProfileToolContributor));
6280        let runtime =
6281            Arc::new(Runtime::new(builder.build().unwrap(), RuntimeConfig::default()).unwrap());
6282
6283        let request = captured_thread_override_request(
6284            &runtime,
6285            &requests,
6286            Vec::new(),
6287            Some("You are embedded in a host app.".to_string()),
6288            Vec::new(),
6289        )
6290        .await;
6291
6292        let system = request.instructions.system.expect("system instructions");
6293        assert!(system.starts_with("You are Roder"));
6294        let developer = request
6295            .instructions
6296            .developer
6297            .expect("developer instructions");
6298        assert!(developer.starts_with("You are embedded in a host app."));
6299
6300        let plain =
6301            captured_thread_override_request(&runtime, &requests, Vec::new(), None, Vec::new())
6302                .await;
6303        assert_eq!(plain.instructions.developer, None);
6304
6305        let _ = std::fs::remove_dir_all(thread_root);
6306    }
6307
6308    #[tokio::test]
6309    async fn thread_external_tools_are_advertised_and_shadow_builtins() {
6310        let requests = Arc::new(StdMutex::new(Vec::new()));
6311        let thread_root = std::env::temp_dir().join(format!(
6312            "roder-thread-external-tools-{}",
6313            uuid::Uuid::new_v4()
6314        ));
6315        let mut builder = ExtensionRegistryBuilder::new();
6316        builder.inference_engine(Arc::new(SwitchCaptureEngine {
6317            requests: requests.clone(),
6318        }));
6319        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6320            base_path: thread_root.clone(),
6321        }));
6322        builder.tool_contributor(Arc::new(ProfileToolContributor));
6323        let runtime =
6324            Arc::new(Runtime::new(builder.build().unwrap(), RuntimeConfig::default()).unwrap());
6325
6326        let external_tools = vec![
6327            ToolSpec {
6328                name: "acme_lookup".to_string(),
6329                description: "Look up Acme workspace state.".to_string(),
6330                parameters: serde_json::json!({
6331                    "type": "object",
6332                    "properties": { "query": { "type": "string" } },
6333                    "required": ["query"]
6334                }),
6335            },
6336            ToolSpec {
6337                name: "edit".to_string(),
6338                description: "Host-managed edit.".to_string(),
6339                parameters: serde_json::json!({ "type": "object" }),
6340            },
6341        ];
6342        let request =
6343            captured_thread_override_request(&runtime, &requests, Vec::new(), None, external_tools)
6344                .await;
6345
6346        let acme = request
6347            .tools
6348            .iter()
6349            .find(|tool| tool.name == "acme_lookup")
6350            .expect("external tool advertised");
6351        assert_eq!(acme.description, "Look up Acme workspace state.");
6352        assert_eq!(acme.parameters["required"][0], "query");
6353        let edits = request
6354            .tools
6355            .iter()
6356            .filter(|tool| tool.name == "edit")
6357            .collect::<Vec<_>>();
6358        assert_eq!(edits.len(), 1, "external edit shadows the builtin");
6359        assert_eq!(edits[0].description, "Host-managed edit.");
6360
6361        let plain =
6362            captured_thread_override_request(&runtime, &requests, Vec::new(), None, Vec::new())
6363                .await;
6364        assert!(plain.tools.iter().all(|tool| tool.name != "acme_lookup"));
6365        let plain_edit = plain
6366            .tools
6367            .iter()
6368            .find(|tool| tool.name == "edit")
6369            .expect("builtin edit advertised on plain thread");
6370        assert_eq!(plain_edit.description, "edit test tool");
6371
6372        let _ = std::fs::remove_dir_all(thread_root);
6373    }
6374
6375    #[tokio::test]
6376    async fn model_switch_injects_summary_and_records_profile_segments() {
6377        let requests = Arc::new(StdMutex::new(Vec::new()));
6378        let thread_root = std::env::temp_dir().join(format!(
6379            "roder-model-switch-thread-{}",
6380            uuid::Uuid::new_v4()
6381        ));
6382        let mut builder = ExtensionRegistryBuilder::new();
6383        builder.inference_engine(Arc::new(SwitchCaptureEngine {
6384            requests: requests.clone(),
6385        }));
6386        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6387            base_path: thread_root.clone(),
6388        }));
6389        builder.tool_contributor(Arc::new(ProfileToolContributor));
6390        let mut claude_profile = test_model_profile("claude-haiku-4-5-20251001");
6391        claude_profile.provider_family = ProviderFamily::Anthropic;
6392        claude_profile.edit_tool = Some(EDIT_TOOL_EDIT.to_string());
6393        let runtime = Arc::new(
6394            Runtime::new(
6395                builder.build().unwrap(),
6396                RuntimeConfig {
6397                    default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6398                    default_model: "gpt-5.5".to_string(),
6399                    model_profiles: std::collections::HashMap::from([
6400                        ("gpt-5.5".to_string(), test_model_profile("gpt-5.5")),
6401                        ("claude-haiku-4-5-20251001".to_string(), claude_profile),
6402                    ]),
6403                    ..RuntimeConfig::default()
6404                },
6405            )
6406            .unwrap(),
6407        );
6408        let thread_id = runtime
6409            .create_thread_with(CreateThreadRequest {
6410                title: Some("Model switch".to_string()),
6411                workspace: test_workspace(),
6412                workspace_id: None,
6413                root_id: None,
6414                provider: None,
6415                model: None,
6416                selection_mode: None,
6417                tool_allowlist: Vec::new(),
6418                developer_instructions: None,
6419                external_tools: Vec::new(),
6420                runner: None,
6421            })
6422            .await
6423            .unwrap()
6424            .thread_id;
6425        let mut rx = runtime.subscribe_events();
6426        for (message, model_override) in [
6427            ("first turn", None),
6428            ("second turn", Some("claude-haiku-4-5-20251001".to_string())),
6429        ] {
6430            let turn_id = runtime
6431                .start_turn(StartTurnRequest {
6432                    thread_id: thread_id.clone(),
6433                    message: message.to_string(),
6434                    images: Vec::new(),
6435                    provider_override: None,
6436                    model_override,
6437                    reasoning_override: None,
6438                    workspace: test_workspace(),
6439                    instructions: InstructionBundle::default(),
6440                    developer_context: None,
6441                    task_ledger_required: false,
6442                })
6443                .await
6444                .unwrap();
6445            tokio::time::timeout(std::time::Duration::from_secs(5), async {
6446                loop {
6447                    let envelope = rx.recv().await.unwrap();
6448                    if envelope.turn_id.as_deref() != Some(&turn_id) {
6449                        continue;
6450                    }
6451                    match envelope.event {
6452                        RoderEvent::TurnCompleted(_) => break,
6453                        RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
6454                        _ => {}
6455                    }
6456                }
6457            })
6458            .await
6459            .unwrap();
6460        }
6461
6462        let captured = requests.lock().unwrap().clone();
6463        assert_eq!(captured.len(), 2);
6464        assert!(captured[1].transcript.iter().any(|item| {
6465            matches!(
6466                item,
6467                TranscriptItem::UserMessage(message)
6468                    if message.text.starts_with(MODEL_SWITCH_SUMMARY_PREFIX)
6469                        && message.text.contains("previous profile mock/gpt-5.5")
6470                        && message.text.contains("Current profile mock/claude-haiku-4-5-20251001")
6471                        && message.text.contains("Available tools now:")
6472            )
6473        }));
6474
6475        let snapshot = runtime
6476            .thread_store
6477            .as_ref()
6478            .unwrap()
6479            .load_thread(&thread_id)
6480            .await
6481            .unwrap()
6482            .unwrap();
6483        let trace_segments = snapshot
6484            .turns
6485            .iter()
6486            .flat_map(|turn| &turn.items)
6487            .filter(|item| {
6488                matches!(
6489                    item,
6490                    TranscriptItem::ProviderMetadata(value)
6491                        if value.get("kind").and_then(serde_json::Value::as_str)
6492                            == Some(MODEL_PROFILE_TRACE_KIND)
6493                            && value.get("segment").and_then(serde_json::Value::as_str)
6494                                == Some("assistant")
6495                )
6496            })
6497            .count();
6498        assert!(trace_segments >= 2);
6499        let _ = std::fs::remove_dir_all(thread_root);
6500    }
6501
6502    struct CountingTaskTool {
6503        calls: Arc<StdMutex<u32>>,
6504    }
6505
6506    #[async_trait::async_trait]
6507    impl ToolExecutor for CountingTaskTool {
6508        fn spec(&self) -> ToolSpec {
6509            ToolSpec {
6510                name: "task".to_string(),
6511                description: "Dispatch a test subagent.".to_string(),
6512                parameters: serde_json::json!({
6513                    "type": "object",
6514                    "properties": {
6515                        "description": { "type": "string" },
6516                        "prompt": { "type": "string" },
6517                        "parent_deadline_seconds": { "type": "integer" }
6518                    },
6519                    "required": ["description", "prompt"],
6520                    "additionalProperties": false
6521                }),
6522            }
6523        }
6524
6525        async fn execute(
6526            &self,
6527            _ctx: ToolExecutionContext,
6528            call: ToolCall,
6529        ) -> anyhow::Result<ToolResult> {
6530            *self.calls.lock().unwrap() += 1;
6531            Ok(ToolResult {
6532                id: call.id,
6533                name: call.name,
6534                text: "started child".to_string(),
6535                data: serde_json::json!({}),
6536                is_error: false,
6537            })
6538        }
6539    }
6540
6541    #[tokio::test]
6542    async fn runtime_profile_reaches_inference_request_and_turn_metadata() {
6543        let captured = Arc::new(StdMutex::new(None));
6544        let mut builder = ExtensionRegistryBuilder::new();
6545        builder.inference_engine(Arc::new(CapturingEngine {
6546            request: captured.clone(),
6547        }));
6548        let runtime = Arc::new(
6549            Runtime::new(
6550                builder.build().unwrap(),
6551                RuntimeConfig {
6552                    runtime_profile: RuntimeProfile::NonInteractive,
6553                    ..RuntimeConfig::default()
6554                },
6555            )
6556            .unwrap(),
6557        );
6558        let mut rx = runtime.subscribe_events();
6559        let turn_id = runtime
6560            .start_turn(StartTurnRequest {
6561                thread_id: "thread-profile".to_string(),
6562                message: "work unattended".to_string(),
6563                images: Vec::new(),
6564                provider_override: None,
6565                model_override: None,
6566                reasoning_override: None,
6567                workspace: test_workspace(),
6568                instructions: InstructionBundle {
6569                    system: None,
6570                    developer: Some("base developer".to_string()),
6571                    developer_context: None,
6572                },
6573                developer_context: None,
6574                task_ledger_required: false,
6575            })
6576            .await
6577            .unwrap();
6578
6579        let mut observed_profile = None;
6580        tokio::time::timeout(std::time::Duration::from_secs(5), async {
6581            loop {
6582                let envelope = rx.recv().await.unwrap();
6583                if envelope.turn_id.as_deref() != Some(&turn_id) {
6584                    continue;
6585                }
6586                match envelope.event {
6587                    RoderEvent::TurnStarted(event) => {
6588                        observed_profile = Some(event.runtime_profile);
6589                    }
6590                    RoderEvent::TurnCompleted(_) => break,
6591                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
6592                    _ => {}
6593                }
6594            }
6595        })
6596        .await
6597        .unwrap();
6598
6599        assert_eq!(observed_profile, Some(RuntimeProfile::NonInteractive));
6600        let request = captured.lock().unwrap().clone().unwrap();
6601        assert_eq!(request.runtime.profile, RuntimeProfile::NonInteractive);
6602        let developer = request.instructions.developer.unwrap();
6603        assert!(developer.contains("base developer"));
6604        assert!(developer.contains("non-interactive profile"));
6605    }
6606
6607    #[tokio::test]
6608    async fn global_policy_mode_changes_do_not_create_runtime_thread_directory() {
6609        let workspace = runtime_test_workspace("global-policy-mode");
6610        let thread_root = workspace.join("threads");
6611        let mut builder = ExtensionRegistryBuilder::new();
6612        builder.inference_engine(Arc::new(FakeInferenceEngine));
6613        builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6614            base_path: thread_root.clone(),
6615        }));
6616        let runtime = Runtime::new(
6617            builder.build().unwrap(),
6618            RuntimeConfig {
6619                workspace: Some(workspace.display().to_string()),
6620                ..Default::default()
6621            },
6622        )
6623        .unwrap();
6624
6625        runtime
6626            .set_policy_mode(PolicyMode::AcceptAll, Some("test".to_string()))
6627            .await
6628            .unwrap();
6629
6630        assert!(!thread_root.join("runtime").exists());
6631        let _ = std::fs::remove_dir_all(workspace);
6632    }
6633
6634    #[tokio::test]
6635    async fn task_ledger_enforcement_injects_eval_reminder_before_work() {
6636        let captured = Arc::new(StdMutex::new(None));
6637        let mut builder = ExtensionRegistryBuilder::new();
6638        builder.inference_engine(Arc::new(CapturingEngine {
6639            request: captured.clone(),
6640        }));
6641        builder.tool_contributor(Arc::new(
6642            roder_ext_task_ledger::TaskLedgerToolContributor::default(),
6643        ));
6644        let runtime = Arc::new(
6645            Runtime::new(
6646                builder.build().unwrap(),
6647                RuntimeConfig {
6648                    runtime_profile: RuntimeProfile::Eval,
6649                    policy_mode: PolicyMode::Bypass,
6650                    ..RuntimeConfig::default()
6651                },
6652            )
6653            .unwrap(),
6654        );
6655        let mut rx = runtime.subscribe_events();
6656        let turn_id = runtime
6657            .start_turn(StartTurnRequest {
6658                thread_id: "thread-ledger".to_string(),
6659                message: "decomposed work".to_string(),
6660                images: Vec::new(),
6661                provider_override: None,
6662                model_override: None,
6663                reasoning_override: None,
6664                workspace: test_workspace(),
6665                instructions: InstructionBundle::default(),
6666                developer_context: None,
6667                task_ledger_required: true,
6668            })
6669            .await
6670            .unwrap();
6671
6672        tokio::time::timeout(std::time::Duration::from_secs(5), async {
6673            loop {
6674                let envelope = rx.recv().await.unwrap();
6675                if envelope.turn_id.as_deref() == Some(&turn_id)
6676                    && matches!(envelope.event, RoderEvent::TurnCompleted(_))
6677                {
6678                    break;
6679                }
6680            }
6681        })
6682        .await
6683        .unwrap();
6684
6685        let request = captured.lock().unwrap().clone().unwrap();
6686        let developer = request.instructions.developer.unwrap();
6687        assert!(developer.contains("Task Ledger Required"));
6688        assert!(developer.contains("task_ledger.update"));
6689        let tool_names: Vec<_> = request
6690            .tools
6691            .iter()
6692            .map(|tool| tool.name.as_str())
6693            .collect();
6694        assert!(
6695            tool_names.contains(&TASK_LEDGER_TOOL_NAME),
6696            "tool names: {tool_names:?}"
6697        );
6698        assert_eq!(
6699            request.tool_choice,
6700            ToolChoice::Specific(TASK_LEDGER_TOOL_NAME.to_string())
6701        );
6702        assert_eq!(request.tools.len(), 1);
6703        assert_eq!(request.tools[0].name, TASK_LEDGER_TOOL_NAME);
6704    }
6705
6706    #[tokio::test]
6707    async fn eval_task_ledger_blocks_final_answer_until_open_items_are_completed() {
6708        let requests = Arc::new(StdMutex::new(Vec::new()));
6709        let mut builder = ExtensionRegistryBuilder::new();
6710        builder.inference_engine(Arc::new(TaskLedgerCompletionGateEngine {
6711            calls: StdMutex::new(0),
6712            requests: requests.clone(),
6713        }));
6714        builder.tool_contributor(Arc::new(
6715            roder_ext_task_ledger::TaskLedgerToolContributor::default(),
6716        ));
6717        let runtime = Arc::new(
6718            Runtime::new(
6719                builder.build().unwrap(),
6720                RuntimeConfig {
6721                    runtime_profile: RuntimeProfile::Eval,
6722                    policy_mode: PolicyMode::Bypass,
6723                    ..RuntimeConfig::default()
6724                },
6725            )
6726            .unwrap(),
6727        );
6728        let mut rx = runtime.subscribe_events();
6729        let turn_id = runtime
6730            .start_turn(StartTurnRequest {
6731                thread_id: "thread-ledger-completion".to_string(),
6732                message: "write the answer file".to_string(),
6733                images: Vec::new(),
6734                provider_override: None,
6735                model_override: None,
6736                reasoning_override: None,
6737                workspace: test_workspace(),
6738                instructions: InstructionBundle::default(),
6739                developer_context: None,
6740                task_ledger_required: true,
6741            })
6742            .await
6743            .unwrap();
6744
6745        tokio::time::timeout(std::time::Duration::from_secs(5), async {
6746            loop {
6747                let envelope = rx.recv().await.unwrap();
6748                if envelope.turn_id.as_deref() != Some(&turn_id) {
6749                    continue;
6750                }
6751                match envelope.event {
6752                    RoderEvent::TurnCompleted(_) => break,
6753                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
6754                    _ => {}
6755                }
6756            }
6757        })
6758        .await
6759        .unwrap();
6760
6761        let requests = requests.lock().unwrap().clone();
6762        assert_eq!(requests.len(), 4);
6763        assert!(requests[2].transcript.iter().any(|item| {
6764            matches!(
6765                item,
6766                TranscriptItem::UserMessage(message)
6767                    if message.text.contains("Task Ledger Completion Required")
6768                        && message.text.contains("Write /app/result.txt")
6769            )
6770        }));
6771        assert!(requests[3].transcript.iter().any(|item| {
6772            matches!(
6773                item,
6774                TranscriptItem::ToolResult(result)
6775                    if result.name.as_deref() == Some(TASK_LEDGER_TOOL_NAME)
6776                        && result.result.contains("Task ledger: 2/2 completed")
6777            )
6778        }));
6779    }
6780
6781    #[tokio::test]
6782    async fn eval_task_ledger_checkpoint_requests_scoreable_file_before_final_reserve() {
6783        let requests = Arc::new(StdMutex::new(Vec::new()));
6784        let mut builder = ExtensionRegistryBuilder::new();
6785        builder.inference_engine(Arc::new(TaskLedgerCompletionGateEngine {
6786            calls: StdMutex::new(0),
6787            requests: requests.clone(),
6788        }));
6789        builder.tool_contributor(Arc::new(
6790            roder_ext_task_ledger::TaskLedgerToolContributor::default(),
6791        ));
6792        let runtime = Arc::new(
6793            Runtime::new(
6794                builder.build().unwrap(),
6795                RuntimeConfig {
6796                    runtime_profile: RuntimeProfile::Eval,
6797                    policy_mode: PolicyMode::Bypass,
6798                    turn_deadline_seconds: Some(120),
6799                    ..RuntimeConfig::default()
6800                },
6801            )
6802            .unwrap(),
6803        );
6804        let mut rx = runtime.subscribe_events();
6805        let turn_id = runtime
6806            .start_turn(StartTurnRequest {
6807                thread_id: "thread-ledger-checkpoint".to_string(),
6808                message: "write the answer file".to_string(),
6809                images: Vec::new(),
6810                provider_override: None,
6811                model_override: None,
6812                reasoning_override: None,
6813                workspace: test_workspace(),
6814                instructions: InstructionBundle::default(),
6815                developer_context: None,
6816                task_ledger_required: true,
6817            })
6818            .await
6819            .unwrap();
6820
6821        tokio::time::timeout(std::time::Duration::from_secs(5), async {
6822            loop {
6823                let envelope = rx.recv().await.unwrap();
6824                if envelope.turn_id.as_deref() != Some(&turn_id) {
6825                    continue;
6826                }
6827                match envelope.event {
6828                    RoderEvent::TurnCompleted(_) => break,
6829                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
6830                    _ => {}
6831                }
6832            }
6833        })
6834        .await
6835        .unwrap();
6836
6837        let requests = requests.lock().unwrap().clone();
6838        assert!(requests.len() >= 2);
6839        assert!(requests[1].transcript.iter().any(|item| {
6840            matches!(
6841                item,
6842                TranscriptItem::UserMessage(message)
6843                    if message.text.contains("Scoreable Output Checkpoint")
6844                        && message.text.contains("ensure the required output file(s) exist")
6845                        && message.text.contains("Write /app/result.txt")
6846            )
6847        }));
6848    }
6849
6850    #[test]
6851    fn deadline_task_ledger_prompt_preserves_scoreable_work_instruction() {
6852        let prompt = task_ledger_deadline_completion_prompt(
6853            12,
6854            30,
6855            "Task Ledger Completion Required: write /app/result.txt, then call task_ledger.update",
6856        );
6857
6858        assert!(prompt.contains("12 seconds remain"));
6859        assert!(prompt.contains("create or update the required scoreable output files"));
6860        assert!(prompt.contains("write /app/result.txt"));
6861        assert!(prompt.contains(TASK_LEDGER_TOOL_NAME));
6862    }
6863
6864    #[test]
6865    fn scoreable_checkpoint_prompt_preserves_provisional_file_instruction() {
6866        let prompt = task_ledger_scoreable_checkpoint_prompt(
6867            120,
6868            "Task Ledger Completion Required: write /app/result.txt, then call task_ledger.update",
6869        );
6870
6871        assert!(prompt.contains("120 seconds remain"));
6872        assert!(prompt.contains("best evidence-backed answer"));
6873        assert!(prompt.contains("even if provisional"));
6874        assert!(prompt.contains("preserve that candidate"));
6875        assert!(prompt.contains("partial-coverage"));
6876        assert!(prompt.contains("write /app/result.txt"));
6877        assert!(prompt.contains(TASK_LEDGER_TOOL_NAME));
6878    }
6879
6880    #[test]
6881    fn open_task_ledger_moves_inference_timeout_to_scoreable_checkpoint() {
6882        let deadline = Some(OffsetDateTime::now_utc() + Duration::seconds(870));
6883        let transcript = vec![TranscriptItem::ToolResult(ToolResultRecord {
6884            id: "ledger-open".to_string(),
6885            name: Some(TASK_LEDGER_TOOL_NAME.to_string()),
6886            result: "Task ledger: 0/1 completed\n- pending: Write /app/result.txt [write]"
6887                .to_string(),
6888            display_payload: None,
6889            is_error: false,
6890        })];
6891
6892        let (_, action) = inference_timeout_deadline(
6893            deadline,
6894            RuntimeProfile::Eval,
6895            true,
6896            30,
6897            false,
6898            0,
6899            &transcript,
6900        )
6901        .unwrap();
6902
6903        assert_eq!(action, InferenceTimeoutAction::ScoreableCheckpoint);
6904    }
6905
6906    #[tokio::test]
6907    async fn verification_gate_forces_eval_code_changes_through_review() {
6908        let mut builder = ExtensionRegistryBuilder::new();
6909        builder.inference_engine(Arc::new(VerificationGateEngine {
6910            calls: StdMutex::new(0),
6911        }));
6912        builder.tool_contributor(Arc::new(WriteFileContributor));
6913        builder.tool_contributor(Arc::new(
6914            roder_ext_verification::VerificationToolContributor,
6915        ));
6916        let runtime = Arc::new(
6917            Runtime::new(
6918                builder.build().unwrap(),
6919                RuntimeConfig {
6920                    default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6921                    default_model: "mock".to_string(),
6922                    runtime_profile: RuntimeProfile::Eval,
6923                    policy_mode: PolicyMode::Bypass,
6924                    ..RuntimeConfig::default()
6925                },
6926            )
6927            .unwrap(),
6928        );
6929        let mut rx = runtime.subscribe_events();
6930        let turn_id = runtime
6931            .start_turn(StartTurnRequest {
6932                thread_id: "thread-verification".to_string(),
6933                message: "write code".to_string(),
6934                images: Vec::new(),
6935                provider_override: None,
6936                model_override: None,
6937                reasoning_override: None,
6938                workspace: test_workspace(),
6939                instructions: InstructionBundle::default(),
6940                developer_context: None,
6941                task_ledger_required: false,
6942            })
6943            .await
6944            .unwrap();
6945
6946        let mut saw_required = false;
6947        let mut saw_completed = false;
6948        let mut final_text = String::new();
6949        tokio::time::timeout(std::time::Duration::from_secs(5), async {
6950            loop {
6951                let envelope = rx.recv().await.unwrap();
6952                if envelope.turn_id.as_deref() != Some(&turn_id) {
6953                    continue;
6954                }
6955                match envelope.event {
6956                    RoderEvent::VerificationRequired(event) => {
6957                        saw_required = true;
6958                        assert_eq!(event.changed_files, vec!["src/lib.rs"]);
6959                    }
6960                    RoderEvent::VerificationCompleted(event) => {
6961                        saw_completed = true;
6962                        assert!(event.passed);
6963                    }
6964                    RoderEvent::InferenceEventReceived(event) => {
6965                        if let InferenceEvent::MessageDelta(delta) = event.event {
6966                            final_text.push_str(&delta.text);
6967                        }
6968                    }
6969                    RoderEvent::TurnCompleted(_) => break,
6970                    _ => {}
6971                }
6972            }
6973        })
6974        .await
6975        .unwrap();
6976
6977        assert!(saw_required);
6978        assert!(saw_completed);
6979        assert!(final_text.contains("verified final"));
6980    }
6981
6982    #[tokio::test]
6983    async fn speed_policy_changes_reasoning_across_eval_model_calls_without_model_switch() {
6984        let requests = Arc::new(StdMutex::new(Vec::new()));
6985        let mut builder = ExtensionRegistryBuilder::new();
6986        builder.inference_engine(Arc::new(SpeedPolicyEngine {
6987            calls: StdMutex::new(0),
6988            requests: requests.clone(),
6989        }));
6990        builder.tool_contributor(Arc::new(WriteFileContributor));
6991        builder.tool_contributor(Arc::new(
6992            roder_ext_verification::VerificationToolContributor,
6993        ));
6994        let runtime = Arc::new(
6995            Runtime::new(
6996                builder.build().unwrap(),
6997                RuntimeConfig {
6998                    default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6999                    default_model: "gpt-5.5".to_string(),
7000                    runtime_profile: RuntimeProfile::Eval,
7001                    policy_mode: PolicyMode::Bypass,
7002                    ..RuntimeConfig::default()
7003                },
7004            )
7005            .unwrap(),
7006        );
7007        let mut rx = runtime.subscribe_events();
7008        let turn_id = runtime
7009            .start_turn(StartTurnRequest {
7010                thread_id: "thread-speed-policy".to_string(),
7011                message: "write code".to_string(),
7012                images: Vec::new(),
7013                provider_override: None,
7014                model_override: None,
7015                reasoning_override: None,
7016                workspace: test_workspace(),
7017                instructions: InstructionBundle::default(),
7018                developer_context: None,
7019                task_ledger_required: false,
7020            })
7021            .await
7022            .unwrap();
7023
7024        let mut saw_speed_policy_event = false;
7025        tokio::time::timeout(std::time::Duration::from_secs(5), async {
7026            loop {
7027                let envelope = rx.recv().await.unwrap();
7028                if envelope.turn_id.as_deref() != Some(&turn_id) {
7029                    continue;
7030                }
7031                match envelope.event {
7032                    RoderEvent::InferenceStarted(event) => {
7033                        if event.speed_policy.is_some() {
7034                            saw_speed_policy_event = true;
7035                        }
7036                    }
7037                    RoderEvent::TurnCompleted(_) => break,
7038                    RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
7039                    _ => {}
7040                }
7041            }
7042        })
7043        .await
7044        .unwrap();
7045
7046        let requests = requests.lock().unwrap().clone();
7047        assert!(saw_speed_policy_event);
7048        assert!(requests.len() >= 4);
7049        assert!(requests.iter().all(|request| {
7050            request.model.provider == roder_api::catalog::PROVIDER_MOCK
7051                && request.model.model == "gpt-5.5"
7052        }));
7053        assert_eq!(
7054            requests[0].runtime.speed_policy.as_ref().map(|d| d.phase),
7055            Some(roder_api::inference::SpeedPolicyPhase::Orientation)
7056        );
7057        assert_eq!(requests[0].reasoning.level.as_deref(), Some(REASONING_HIGH));
7058        assert_eq!(
7059            requests[1].runtime.speed_policy.as_ref().map(|d| d.phase),
7060            Some(roder_api::inference::SpeedPolicyPhase::Execution)
7061        );
7062        assert_eq!(requests[1].reasoning.level.as_deref(), Some(REASONING_LOW));
7063        assert_eq!(
7064            requests[2].runtime.speed_policy.as_ref().map(|d| d.phase),
7065            Some(roder_api::inference::SpeedPolicyPhase::Verification)
7066        );
7067        assert_eq!(requests[2].reasoning.level.as_deref(), Some(REASONING_HIGH));
7068        assert_eq!(
7069            requests[2]
7070                .metadata
7071                .pointer("/speedPolicy/phase")
7072                .and_then(serde_json::Value::as_str),
7073            Some("verification")
7074        );
7075    }
7076
7077    #[tokio::test]
7078    async fn deadline_turn_timeout_emits_partial_result_and_clears_active_turn() {
7079        let mut builder = ExtensionRegistryBuilder::new();
7080        builder.inference_engine(Arc::new(DeadlineEngine));
7081        let runtime = Arc::new(
7082            Runtime::new(
7083                builder.build().unwrap(),
7084                RuntimeConfig {
7085                    default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
7086                    default_model: "mock".to_string(),
7087                    runtime_profile: RuntimeProfile::Eval,
7088                    turn_deadline_seconds: Some(1),
7089                    ..RuntimeConfig::default()
7090                },
7091            )
7092            .unwrap(),
7093        );
7094        let mut rx = runtime.subscribe_events();
7095        let turn_id = runtime
7096            .start_turn(StartTurnRequest {
7097                thread_id: "thread-deadline".to_string(),
7098                message: "slow work".to_string(),
7099                images: Vec::new(),
7100                provider_override: None,
7101                model_override: None,
7102                reasoning_override: None,
7103                workspace: test_workspace(),
7104                instructions: InstructionBundle::default(),
7105                developer_context: None,
7106                task_ledger_required: false,
7107            })
7108            .await
7109            .unwrap();
7110
7111        let mut saw_partial = false;
7112        let mut saw_deadline = false;
7113        let mut failed_kind = None;
7114        tokio::time::timeout(std::time::Duration::from_secs(5), async {
7115            loop {
7116                let envelope = rx.recv().await.unwrap();
7117                if envelope.turn_id.as_deref() != Some(&turn_id) {
7118                    continue;
7119                }
7120                match envelope.event {
7121                    RoderEvent::TurnPartialResult(event) => {
7122                        saw_partial = event.summary.contains("partial turn state");
7123                    }
7124                    RoderEvent::TurnDeadlineExceeded(event) => {
7125                        saw_deadline = event.partial_result.contains("transcript items");
7126                    }
7127                    RoderEvent::TurnFailed(event) => {
7128                        failed_kind = event.error_kind;
7129                        break;
7130                    }
7131                    _ => {}
7132                }
7133            }
7134        })
7135        .await
7136        .unwrap();
7137
7138        for _ in 0..20 {
7139            if !runtime.active_turns.read().await.contains_key(&turn_id) {
7140                break;
7141            }
7142            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7143        }
7144        assert!(saw_partial);
7145        assert!(saw_deadline);
7146        assert_eq!(failed_kind.as_deref(), Some("deadline_timeout"));
7147        assert!(!runtime.active_turns.read().await.contains_key(&turn_id));
7148    }
7149
7150    #[tokio::test]
7151    async fn deadline_skips_subagent_task_when_remaining_budget_is_too_low() {
7152        let calls = Arc::new(StdMutex::new(0));
7153        let mut builder = ExtensionRegistryBuilder::new();
7154        builder.inference_engine(Arc::new(CapturingEngine {
7155            request: Arc::new(StdMutex::new(None)),
7156        }));
7157        let task_tool = Arc::new(CountingTaskTool {
7158            calls: calls.clone(),
7159        });
7160        builder.tool_contributor(Arc::new(TestToolContributor { tool: task_tool }));
7161        let runtime = Arc::new(
7162            Runtime::new(
7163                builder.build().unwrap(),
7164                RuntimeConfig {
7165                    policy_mode: PolicyMode::Bypass,
7166                    ..RuntimeConfig::default()
7167                },
7168            )
7169            .unwrap(),
7170        );
7171
7172        let result = runtime
7173            .route_tool_call(
7174                &"thread-deadline-task".to_string(),
7175                &"turn-deadline-task".to_string(),
7176                ToolCallCompleted {
7177                    id: "task-1".to_string(),
7178                    name: "task".to_string(),
7179                    arguments: serde_json::json!({
7180                        "description": "inspect",
7181                        "prompt": "read"
7182                    })
7183                    .to_string(),
7184                },
7185                None,
7186                Some(OffsetDateTime::now_utc() + Duration::seconds(1)),
7187            )
7188            .await
7189            .unwrap();
7190
7191        assert!(result.is_error);
7192        assert!(result.result.contains("deadline policy skipped"));
7193        assert_eq!(*calls.lock().unwrap(), 0);
7194    }
7195
7196    struct TestToolContributor {
7197        tool: Arc<dyn ToolExecutor>,
7198    }
7199
7200    impl ToolContributor for TestToolContributor {
7201        fn id(&self) -> String {
7202            "test-tool".to_string()
7203        }
7204
7205        fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
7206            registry.register(self.tool.clone())
7207        }
7208    }
7209}