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