Skip to main content

opi_coding_agent/
harness.rs

1//! Interactive CLI harness (S8.4).
2//!
3//! Wires together config, tools, system prompt, hooks, and Agent into a
4//! single entry point for the interactive coding agent.
5
6use std::path::{Path, PathBuf};
7
8use opi_agent::Agent;
9use opi_agent::event::AgentEvent;
10use opi_agent::extension::ExtensionRegistry;
11use opi_agent::hooks::AgentHooks;
12use opi_agent::loop_types::{AgentError, AgentLoopConfig};
13use opi_agent::message::AgentMessage;
14use opi_agent::tool::Tool;
15use opi_ai::message::Message;
16use opi_ai::provider::{EventStream, ModelInfo, Provider, ThinkingConfig};
17
18use crate::config::OpiConfig;
19use crate::context_files;
20use crate::policy::{RunMode, ToolRuntimeConfig, ToolSelection};
21use crate::prompt::SystemPromptBuilder;
22use crate::resource::{ExplicitResourcePaths, ResourceDiscoveryLayers, standard_discovery_layers};
23use crate::session_coordinator::{SessionCoordinator, to_wire_result};
24use crate::tool::{BashTool, EditTool, FindTool, GlobTool, GrepTool, LsTool, ReadTool, WriteTool};
25
26/// Optional pre-existing session the harness can adopt instead of creating
27/// a new JSONL file. Produced by `--resume` flows.
28pub struct ResumeInfo {
29    pub path: PathBuf,
30    pub session_id: String,
31    pub entries: Vec<opi_agent::session::SessionEntry>,
32    /// The workspace cwd recorded in the session header. Used to restore the
33    /// correct workspace root when resuming from a different directory.
34    pub original_cwd: PathBuf,
35}
36
37/// Harness wiring config, tools, system prompt, hooks, and Agent.
38pub struct CodingHarness {
39    agent: Agent,
40    config: OpiConfig,
41    system_prompt: String,
42    resources: HarnessResources,
43    model_registry: opi_ai::ProviderRegistry,
44    session: Option<SessionCoordinator>,
45    /// Message count before the current turn — used to slice only new messages for persistence.
46    turn_offset: usize,
47    /// Images queued from --image CLI flag, injected into the first prompt.
48    pending_images: Vec<opi_ai::message::InputContent>,
49}
50
51pub struct RuntimeThinkingState {
52    pub level: String,
53    pub enabled: bool,
54    pub budget_tokens: Option<u64>,
55}
56
57/// Public metadata for resources discovered by the coding harness.
58#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
59pub struct DiscoveredResourceMetadata {
60    pub extensions: Vec<ResourceMetadataEntry>,
61    pub packages: Vec<ResourceMetadataEntry>,
62    pub skills: Vec<ResourceMetadataEntry>,
63    pub fragments: Vec<ResourceMetadataEntry>,
64    pub themes: Vec<ResourceMetadataEntry>,
65    pub diagnostics: Vec<String>,
66}
67
68/// One metadata entry exposed to prompts, RPC clients, and embedders.
69#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
70pub struct ResourceMetadataEntry {
71    pub name: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub description: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub version: Option<String>,
76}
77
78#[derive(Debug, Clone, Default)]
79struct HarnessResources {
80    metadata: DiscoveredResourceMetadata,
81    theme_resources: Vec<crate::theme_discovery::ThemeResource>,
82}
83
84struct MetadataProvider {
85    id: String,
86    models: Vec<ModelInfo>,
87}
88
89impl MetadataProvider {
90    fn from_provider(provider: &dyn Provider) -> Self {
91        Self {
92            id: provider.id().to_owned(),
93            models: provider.models().to_vec(),
94        }
95    }
96}
97
98impl Provider for MetadataProvider {
99    fn id(&self) -> &str {
100        &self.id
101    }
102
103    fn models(&self) -> &[ModelInfo] {
104        &self.models
105    }
106
107    fn stream(&self, _request: opi_ai::provider::Request) -> EventStream {
108        Box::pin(futures_util::stream::empty())
109    }
110}
111
112impl DiscoveredResourceMetadata {
113    fn format_for_system_prompt(&self) -> String {
114        let mut sections = Vec::new();
115        push_metadata_section(&mut sections, "Discovered packages", &self.packages);
116        push_metadata_section(&mut sections, "Discovered extensions", &self.extensions);
117        push_metadata_section(&mut sections, "Discovered skills", &self.skills);
118        push_metadata_section(
119            &mut sections,
120            "Discovered prompt fragments",
121            &self.fragments,
122        );
123        push_metadata_section(&mut sections, "Discovered themes", &self.themes);
124        if !self.diagnostics.is_empty() {
125            sections.push(format!(
126                "Resource discovery diagnostics:\n{}",
127                self.diagnostics
128                    .iter()
129                    .map(|diagnostic| format!("- {diagnostic}"))
130                    .collect::<Vec<_>>()
131                    .join("\n")
132            ));
133        }
134        sections.join("\n\n")
135    }
136
137    pub fn to_rpc_json(&self) -> serde_json::Value {
138        serde_json::json!({
139            "extensions": metadata_names(&self.extensions),
140            "packages": metadata_names(&self.packages),
141            "skills": metadata_names(&self.skills),
142            "fragments": metadata_names(&self.fragments),
143            "themes": metadata_names(&self.themes),
144            "diagnostics": self.diagnostics.clone(),
145        })
146    }
147
148    fn add_extension_name(&mut self, name: String) {
149        if self.extensions.iter().any(|entry| entry.name == name) {
150            return;
151        }
152        self.extensions.push(ResourceMetadataEntry {
153            name,
154            description: None,
155            version: None,
156        });
157        self.extensions.sort_by(|a, b| a.name.cmp(&b.name));
158    }
159}
160
161fn metadata_names(entries: &[ResourceMetadataEntry]) -> Vec<&str> {
162    entries.iter().map(|entry| entry.name.as_str()).collect()
163}
164
165fn push_metadata_section(
166    sections: &mut Vec<String>,
167    title: &str,
168    entries: &[ResourceMetadataEntry],
169) {
170    if entries.is_empty() {
171        return;
172    }
173    let lines = entries
174        .iter()
175        .map(|entry| {
176            let mut line = format!("- {}", entry.name);
177            if let Some(description) = &entry.description {
178                line.push_str(": ");
179                line.push_str(description);
180            }
181            if let Some(version) = &entry.version {
182                line.push_str(" v");
183                line.push_str(version);
184            }
185            line
186        })
187        .collect::<Vec<_>>()
188        .join("\n");
189    sections.push(format!("{title}:\n{lines}"));
190}
191
192fn filter_extension_tools(
193    tools: Vec<Box<dyn Tool>>,
194    selection: &ToolSelection,
195) -> Vec<Box<dyn Tool>> {
196    match selection {
197        ToolSelection::Default | ToolSelection::NoBuiltin => tools,
198        ToolSelection::Disabled => Vec::new(),
199        ToolSelection::Allowlist(names) => tools
200            .into_iter()
201            .filter(|tool| {
202                let name = tool.definition().name;
203                names.iter().any(|allowed| allowed == &name)
204            })
205            .collect(),
206    }
207}
208
209/// Builder for SDK embedders that need to inject extension registries or
210/// precomputed discovery metadata without dynamic loading.
211pub struct CodingHarnessBuilder {
212    provider: Box<dyn Provider>,
213    model: String,
214    config: OpiConfig,
215    workspace_root: PathBuf,
216    hooks: Option<Box<dyn AgentHooks>>,
217    user_system_prompt: Option<String>,
218    initial_messages: Vec<AgentMessage>,
219    resume: Option<ResumeInfo>,
220    tool_config: Option<ToolRuntimeConfig>,
221    tool_selection: ToolSelection,
222    global_config_dir: Option<PathBuf>,
223    extension_registry: Option<ExtensionRegistry>,
224    resource_layers: Option<ResourceDiscoveryLayers>,
225    resource_metadata: Option<DiscoveredResourceMetadata>,
226}
227
228impl CodingHarnessBuilder {
229    fn new(
230        provider: Box<dyn Provider>,
231        model: String,
232        config: OpiConfig,
233        workspace_root: PathBuf,
234    ) -> Self {
235        Self {
236            provider,
237            model,
238            config,
239            workspace_root,
240            hooks: None,
241            user_system_prompt: None,
242            initial_messages: Vec::new(),
243            resume: None,
244            tool_config: None,
245            tool_selection: ToolSelection::Default,
246            global_config_dir: None,
247            extension_registry: None,
248            resource_layers: None,
249            resource_metadata: None,
250        }
251    }
252
253    pub fn hooks(mut self, hooks: Box<dyn AgentHooks>) -> Self {
254        self.hooks = Some(hooks);
255        self
256    }
257
258    pub fn user_system_prompt(mut self, prompt: impl Into<String>) -> Self {
259        self.user_system_prompt = Some(prompt.into());
260        self
261    }
262
263    pub fn initial_messages(mut self, messages: Vec<AgentMessage>) -> Self {
264        self.initial_messages = messages;
265        self
266    }
267
268    pub fn resume(mut self, resume: ResumeInfo) -> Self {
269        self.resume = Some(resume);
270        self
271    }
272
273    pub fn tool_selection(mut self, selection: ToolSelection) -> Self {
274        self.tool_selection = selection;
275        self
276    }
277
278    pub fn tool_config(mut self, config: ToolRuntimeConfig) -> Self {
279        self.tool_config = Some(config);
280        self
281    }
282
283    pub fn global_config_dir(mut self, dir: PathBuf) -> Self {
284        self.global_config_dir = Some(dir);
285        self
286    }
287
288    pub fn extension_registry(mut self, registry: ExtensionRegistry) -> Self {
289        self.extension_registry = Some(registry);
290        self
291    }
292
293    pub fn resource_layers(mut self, layers: ResourceDiscoveryLayers) -> Self {
294        self.resource_layers = Some(layers);
295        self
296    }
297
298    pub fn resource_metadata(mut self, metadata: DiscoveredResourceMetadata) -> Self {
299        self.resource_metadata = Some(metadata);
300        self
301    }
302
303    pub fn build(self) -> CodingHarness {
304        let tool_selection = self.tool_selection;
305        let tool_config = self.tool_config.unwrap_or_else(|| {
306            ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection.clone())
307                .expect("interactive tool config should be valid")
308        });
309        CodingHarness::new_with_build_options(
310            self.provider,
311            self.model,
312            self.config,
313            self.workspace_root,
314            self.hooks.unwrap_or_else(|| Box::new(CodingAgentHooks)),
315            self.user_system_prompt,
316            self.initial_messages,
317            self.resume,
318            tool_config,
319            self.global_config_dir,
320            HarnessBuildOptions {
321                extension_registry: self.extension_registry,
322                resource_layers: self.resource_layers,
323                resource_metadata: self.resource_metadata,
324                tool_selection,
325            },
326        )
327    }
328}
329
330struct HarnessBuildOptions {
331    extension_registry: Option<ExtensionRegistry>,
332    resource_layers: Option<ResourceDiscoveryLayers>,
333    resource_metadata: Option<DiscoveredResourceMetadata>,
334    tool_selection: ToolSelection,
335}
336
337impl Default for HarnessBuildOptions {
338    fn default() -> Self {
339        Self {
340            extension_registry: None,
341            resource_layers: None,
342            resource_metadata: None,
343            tool_selection: ToolSelection::Default,
344        }
345    }
346}
347
348impl CodingHarness {
349    /// Start building a harness for SDK/embedder use.
350    pub fn builder(
351        provider: Box<dyn Provider>,
352        model: String,
353        config: OpiConfig,
354        workspace_root: PathBuf,
355    ) -> CodingHarnessBuilder {
356        CodingHarnessBuilder::new(provider, model, config, workspace_root)
357    }
358
359    /// Create a new harness with the given provider, model, config, and workspace root.
360    pub fn new(
361        provider: Box<dyn Provider>,
362        model: String,
363        config: OpiConfig,
364        workspace_root: PathBuf,
365    ) -> Self {
366        Self::new_with_hooks(
367            provider,
368            model,
369            config,
370            workspace_root,
371            Box::new(CodingAgentHooks),
372            None,
373            Vec::new(),
374            ToolSelection::Default,
375        )
376    }
377
378    /// Create a new harness with an explicit tool selection.
379    pub fn new_with_selection(
380        provider: Box<dyn Provider>,
381        model: String,
382        config: OpiConfig,
383        workspace_root: PathBuf,
384        tool_selection: ToolSelection,
385    ) -> Self {
386        Self::new_with_hooks(
387            provider,
388            model,
389            config,
390            workspace_root,
391            Box::new(CodingAgentHooks),
392            None,
393            Vec::new(),
394            tool_selection,
395        )
396    }
397
398    /// Create a new harness with already resolved tool runtime config.
399    pub fn new_with_tool_config(
400        provider: Box<dyn Provider>,
401        model: String,
402        config: OpiConfig,
403        workspace_root: PathBuf,
404        tool_config: ToolRuntimeConfig,
405    ) -> Self {
406        Self::new_with_hooks_and_resume_tool_config(
407            provider,
408            model,
409            config,
410            workspace_root,
411            Box::new(CodingAgentHooks),
412            None,
413            Vec::new(),
414            None,
415            tool_config,
416        )
417    }
418
419    /// Create a new harness with custom hooks.
420    #[allow(clippy::too_many_arguments)]
421    pub fn new_with_hooks(
422        provider: Box<dyn Provider>,
423        model: String,
424        config: OpiConfig,
425        workspace_root: PathBuf,
426        hooks: Box<dyn AgentHooks>,
427        user_system_prompt: Option<String>,
428        initial_messages: Vec<AgentMessage>,
429        tool_selection: ToolSelection,
430    ) -> Self {
431        Self::new_with_hooks_and_resume(
432            provider,
433            model,
434            config,
435            workspace_root,
436            hooks,
437            user_system_prompt,
438            initial_messages,
439            None,
440            tool_selection,
441        )
442    }
443
444    /// Create a new harness, optionally adopting an existing session (resume).
445    #[allow(clippy::too_many_arguments)]
446    pub fn new_with_hooks_and_resume(
447        provider: Box<dyn Provider>,
448        model: String,
449        config: OpiConfig,
450        workspace_root: PathBuf,
451        hooks: Box<dyn AgentHooks>,
452        user_system_prompt: Option<String>,
453        initial_messages: Vec<AgentMessage>,
454        resume: Option<ResumeInfo>,
455        tool_selection: ToolSelection,
456    ) -> Self {
457        let tool_config = ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection)
458            .expect("interactive tool config should be valid");
459        Self::new_with_hooks_and_resume_tool_config(
460            provider,
461            model,
462            config,
463            workspace_root,
464            hooks,
465            user_system_prompt,
466            initial_messages,
467            resume,
468            tool_config,
469        )
470    }
471
472    /// Create a new harness, optionally adopting an existing session (resume),
473    /// with already resolved tool runtime config.
474    #[allow(clippy::too_many_arguments)]
475    pub fn new_with_hooks_and_resume_tool_config(
476        provider: Box<dyn Provider>,
477        model: String,
478        config: OpiConfig,
479        workspace_root: PathBuf,
480        hooks: Box<dyn AgentHooks>,
481        user_system_prompt: Option<String>,
482        initial_messages: Vec<AgentMessage>,
483        resume: Option<ResumeInfo>,
484        tool_config: ToolRuntimeConfig,
485    ) -> Self {
486        Self::new_with_global_config_dir_tool_config(
487            provider,
488            model,
489            config,
490            workspace_root,
491            hooks,
492            user_system_prompt,
493            initial_messages,
494            resume,
495            tool_config,
496            None,
497        )
498    }
499
500    /// Create a new harness with an explicit global config directory override.
501    ///
502    /// When `global_config_dir` is `None`, uses the platform default from
503    /// [`crate::config::user_config_dir`]. Pass `Some(path)` in tests to
504    /// isolate global context file discovery from the real user config dir.
505    #[allow(clippy::too_many_arguments)]
506    pub fn new_with_global_config_dir(
507        provider: Box<dyn Provider>,
508        model: String,
509        config: OpiConfig,
510        workspace_root: PathBuf,
511        hooks: Box<dyn AgentHooks>,
512        user_system_prompt: Option<String>,
513        initial_messages: Vec<AgentMessage>,
514        resume: Option<ResumeInfo>,
515        tool_selection: ToolSelection,
516        global_config_dir: Option<PathBuf>,
517    ) -> Self {
518        let tool_config = ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection)
519            .expect("interactive tool config should be valid");
520        Self::new_with_global_config_dir_tool_config(
521            provider,
522            model,
523            config,
524            workspace_root,
525            hooks,
526            user_system_prompt,
527            initial_messages,
528            resume,
529            tool_config,
530            global_config_dir,
531        )
532    }
533
534    /// Create a new harness with an explicit global config directory override
535    /// and already resolved tool runtime config.
536    #[allow(clippy::too_many_arguments)]
537    pub fn new_with_global_config_dir_tool_config(
538        provider: Box<dyn Provider>,
539        model: String,
540        config: OpiConfig,
541        workspace_root: PathBuf,
542        hooks: Box<dyn AgentHooks>,
543        user_system_prompt: Option<String>,
544        initial_messages: Vec<AgentMessage>,
545        resume: Option<ResumeInfo>,
546        tool_config: ToolRuntimeConfig,
547        global_config_dir: Option<PathBuf>,
548    ) -> Self {
549        Self::new_with_build_options(
550            provider,
551            model,
552            config,
553            workspace_root,
554            hooks,
555            user_system_prompt,
556            initial_messages,
557            resume,
558            tool_config,
559            global_config_dir,
560            HarnessBuildOptions::default(),
561        )
562    }
563
564    #[allow(clippy::too_many_arguments)]
565    fn new_with_build_options(
566        provider: Box<dyn Provider>,
567        model: String,
568        config: OpiConfig,
569        workspace_root: PathBuf,
570        hooks: Box<dyn AgentHooks>,
571        user_system_prompt: Option<String>,
572        initial_messages: Vec<AgentMessage>,
573        resume: Option<ResumeInfo>,
574        tool_config: ToolRuntimeConfig,
575        global_config_dir: Option<PathBuf>,
576        build_options: HarnessBuildOptions,
577    ) -> Self {
578        let mut hooks = hooks;
579        let mut extension_tools = Vec::new();
580        let mut injected_extension_names = Vec::new();
581        let mut extension_event_registry = None;
582        let extension_registry = build_options.extension_registry;
583        let (model_registry, model_registry_diagnostics) =
584            Self::build_model_registry(provider.as_ref(), extension_registry.as_ref());
585        if let Some(registry) = extension_registry {
586            extension_event_registry = Some(registry.clone());
587            injected_extension_names = registry
588                .names()
589                .into_iter()
590                .map(str::to_owned)
591                .collect::<Vec<_>>();
592            extension_tools =
593                filter_extension_tools(registry.collect_tools(), &build_options.tool_selection);
594            hooks = registry.wrap_hooks(hooks);
595        }
596
597        let mut tools = Self::build_tools(&workspace_root, &tool_config);
598        tools.extend(extension_tools);
599        let tool_defs: Vec<_> = tools.iter().map(|t| t.definition()).collect();
600        let mut builder = SystemPromptBuilder::new().tools(tool_defs);
601        if let Some(content) = user_system_prompt {
602            builder = builder.user_system(content);
603        }
604        let resolved_global_dir = global_config_dir.unwrap_or_else(crate::config::user_config_dir);
605        let mut resources = match build_options.resource_metadata {
606            Some(metadata) => HarnessResources {
607                metadata,
608                theme_resources: Vec::new(),
609            },
610            None => Self::discover_resources(
611                &workspace_root,
612                &config,
613                Some(resolved_global_dir.as_path()),
614                build_options.resource_layers,
615            ),
616        };
617        resources
618            .metadata
619            .diagnostics
620            .extend(model_registry_diagnostics);
621        for name in injected_extension_names {
622            resources.metadata.add_extension_name(name);
623        }
624
625        let context = context_files::discover_context_files(
626            &workspace_root,
627            Some(resolved_global_dir.as_path()),
628        );
629        let resource_prompt = resources.metadata.format_for_system_prompt();
630        let mut context_content = context.content;
631        if !resource_prompt.is_empty() {
632            if !context_content.is_empty() {
633                context_content.push_str("\n\n");
634            }
635            context_content.push_str(&resource_prompt);
636        }
637        if !context_content.is_empty() {
638            builder = builder.context_files(context_content);
639        }
640        let system_prompt = builder.build();
641
642        let (thinking, max_tokens) =
643            initial_thinking_request_config(&model_registry, &model, &config);
644        let agent_config = AgentLoopConfig {
645            max_turns: config.defaults.max_iterations,
646            max_tokens,
647            retry: Some(config.retry.clone()),
648            thinking,
649            ..Default::default()
650        };
651
652        let mut agent = Agent::new(
653            provider,
654            tools,
655            model.clone(),
656            Some(system_prompt.clone()),
657            agent_config,
658            hooks,
659        );
660        if let Some(registry) = extension_event_registry {
661            agent.subscribe(Box::new(move |event| registry.dispatch_event(event)));
662        }
663
664        let initial_len = initial_messages.len();
665        if !initial_messages.is_empty() {
666            agent.set_initial_messages(initial_messages);
667        }
668
669        let cwd = if let Some(ref info) = resume {
670            // When resuming, use the workspace cwd from the session header so
671            // tools operate in the correct workspace even if the process was
672            // launched from a different directory.
673            info.original_cwd.to_string_lossy().into_owned()
674        } else {
675            std::env::current_dir()
676                .unwrap_or_default()
677                .to_string_lossy()
678                .into_owned()
679        };
680        let compaction_config = opi_agent::compaction::CompactionConfig {
681            enabled: config.compaction.enabled,
682            threshold_tokens: config.compaction.threshold_tokens,
683        };
684
685        let session = if let Some(info) = resume {
686            SessionCoordinator::open_existing(
687                info.path,
688                info.session_id,
689                &info.entries,
690                initial_len,
691                compaction_config,
692                model.clone(),
693            )
694            .ok()
695        } else {
696            let session_dir = crate::session_cli::session_dir();
697            SessionCoordinator::new(&session_dir, &cwd, compaction_config, model.clone()).ok()
698        };
699
700        Self {
701            agent,
702            config,
703            system_prompt,
704            resources,
705            model_registry,
706            session,
707            turn_offset: initial_len,
708            pending_images: Vec::new(),
709        }
710    }
711
712    /// Add an extra tool to the harness (for testing with mock tools).
713    pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
714        self.agent.add_tool(tool);
715    }
716
717    /// Queue images to be injected into the next prompt.
718    pub fn queue_images(&mut self, images: Vec<opi_ai::message::InputContent>) {
719        self.pending_images.extend(images);
720    }
721
722    /// Take and clear queued images.
723    pub fn take_pending_images(&mut self) -> Vec<opi_ai::message::InputContent> {
724        std::mem::take(&mut self.pending_images)
725    }
726
727    /// Return model picker items from the active provider.
728    pub fn model_picker_items(&self) -> Vec<opi_tui::SelectItem> {
729        let current_provider = self.agent.provider().id();
730        crate::picker::model_picker_items(&self.model_registry)
731            .into_iter()
732            .filter(|item| item.metadata == current_provider)
733            .collect()
734    }
735
736    /// Change the model used by subsequent prompts.
737    pub fn set_model(&mut self, model: String) {
738        self.agent.set_model(model);
739    }
740
741    /// Validate and change the model used by subsequent prompts.
742    pub fn set_model_validated(&mut self, model: String) -> Result<&str, String> {
743        let (requested_provider, requested_model) = parse_model_spec(&model)?;
744        let current_provider = self.agent.provider().id();
745        if requested_provider != current_provider {
746            return Err(format!(
747                "cannot switch provider from {current_provider} to {requested_provider} at runtime"
748            ));
749        }
750
751        let requested_model_info = self.model_info(requested_model);
752        let Some(requested_model_info) = requested_model_info else {
753            return Err(format!(
754                "unknown model '{requested_model}' for provider '{requested_provider}'"
755            ));
756        };
757
758        self.validate_current_thinking_for_model(&requested_model_info)?;
759
760        self.agent.set_model(model);
761        Ok(self.agent.model())
762    }
763
764    /// Change the thinking level used by subsequent provider requests.
765    pub fn set_thinking_level(&mut self, level: &str) -> Result<RuntimeThinkingState, String> {
766        let default_budget = self.config.thinking.budget_tokens as u64;
767        let budget_tokens = match level {
768            "off" => None,
769            "low" => Some(2_048),
770            "medium" => Some(default_budget),
771            "high" => Some(default_budget.max(20_000)),
772            _ => {
773                return Err(format!(
774                    "invalid thinking level '{level}': expected off, low, medium, or high"
775                ));
776            }
777        };
778
779        let (thinking, max_tokens) = match budget_tokens {
780            Some(budget_tokens) => {
781                let (thinking, max_tokens) = request_config_for_thinking_budget(budget_tokens)?;
782                if let Some(model) = self.active_model_info() {
783                    validate_thinking_budget_for_model(&model, budget_tokens, max_tokens)?;
784                }
785                (Some(thinking), Some(max_tokens))
786            }
787            None => (None, None),
788        };
789
790        self.agent.set_max_tokens(max_tokens);
791        self.agent.set_thinking_config(thinking);
792        let state = self.agent.thinking_config();
793        Ok(RuntimeThinkingState {
794            level: level.to_owned(),
795            enabled: state.enabled,
796            budget_tokens: state.budget_tokens,
797        })
798    }
799
800    fn active_model_info(&self) -> Option<ModelInfo> {
801        let Ok((provider_id, model_id)) = parse_model_spec(self.agent.model()) else {
802            return None;
803        };
804        if provider_id != self.agent.provider().id() {
805            return None;
806        }
807        self.model_info(model_id)
808    }
809
810    fn model_info(&self, model_id: &str) -> Option<ModelInfo> {
811        let spec = format!("{}:{model_id}", self.agent.provider().id());
812        self.model_registry
813            .resolve(&spec)
814            .ok()
815            .map(|(_, model)| model.clone())
816    }
817
818    fn validate_current_thinking_for_model(&self, model: &ModelInfo) -> Result<(), String> {
819        let thinking = self.agent.thinking_config();
820        if !thinking.enabled {
821            return Ok(());
822        }
823        let Some(budget_tokens) = thinking.budget_tokens else {
824            return Ok(());
825        };
826        let max_tokens = max_tokens_for_thinking_budget(budget_tokens)?;
827        validate_thinking_budget_for_model(model, budget_tokens, max_tokens)
828    }
829
830    /// Resume an existing session by ID into this harness.
831    pub fn resume_session_id(&mut self, session_id: &str) -> Result<usize, String> {
832        let dir = crate::session_cli::session_dir();
833        let session =
834            crate::session_cli::resume_session(&dir, session_id).map_err(|e| e.to_string())?;
835        let messages = crate::session_cli::reconstruct_context(&session.entries);
836        let message_count = messages.len();
837        self.agent.replace_messages(messages);
838
839        let compaction_config = opi_agent::compaction::CompactionConfig {
840            enabled: self.config.compaction.enabled,
841            threshold_tokens: self.config.compaction.threshold_tokens,
842        };
843        self.session = SessionCoordinator::open_existing(
844            session.path,
845            session.header.id,
846            &session.entries,
847            message_count,
848            compaction_config,
849            self.agent.model().to_string(),
850        )
851        .ok();
852        self.turn_offset = message_count;
853        Ok(message_count)
854    }
855
856    /// Return branch picker items for the currently active session.
857    pub fn branch_picker_items(&self) -> Result<Vec<opi_tui::SelectItem>, String> {
858        let session = self
859            .session
860            .as_ref()
861            .ok_or_else(|| "no active session".to_owned())?;
862        let (_, entries) = opi_agent::session::SessionReader::read_all(session.session_path())
863            .map_err(|e| format!("failed to read session: {e}"))?;
864        let tree = opi_agent::session_branch::SessionTree::from_entries(&entries);
865        Ok(crate::picker::branch_picker_items(&tree))
866    }
867
868    /// Switch the current session to the branch ending at `tip_id`.
869    pub fn resume_session_branch_tip(&mut self, tip_id: &str) -> Result<usize, String> {
870        let session = self
871            .session
872            .as_mut()
873            .ok_or_else(|| "no active session".to_owned())?;
874        let path = session.session_path().to_path_buf();
875        let session_id = session.session_id().to_owned();
876        let (_, entries) = opi_agent::session::SessionReader::read_all(&path)
877            .map_err(|e| format!("failed to read session: {e}"))?;
878        let tree = opi_agent::session_branch::SessionTree::from_entries(&entries);
879        if !tree.branches().iter().any(|branch| branch.tip_id == tip_id) {
880            return Err(format!("unknown branch tip: {tip_id}"));
881        }
882
883        session
884            .append_leaf(tip_id)
885            .map_err(|e| format!("failed to select branch: {e}"))?;
886        let (_, entries) = opi_agent::session::SessionReader::read_all(&path)
887            .map_err(|e| format!("failed to read selected branch: {e}"))?;
888        let messages = crate::session_cli::reconstruct_context(&entries);
889        let message_count = messages.len();
890        self.agent.replace_messages(messages);
891
892        let compaction_config = opi_agent::compaction::CompactionConfig {
893            enabled: self.config.compaction.enabled,
894            threshold_tokens: self.config.compaction.threshold_tokens,
895        };
896        self.session = Some(
897            SessionCoordinator::open_existing(
898                path,
899                session_id,
900                &entries,
901                message_count,
902                compaction_config,
903                self.agent.model().to_string(),
904            )
905            .map_err(|e| format!("failed to reopen selected branch: {e}"))?,
906        );
907        self.turn_offset = message_count;
908        Ok(message_count)
909    }
910
911    /// Send a user prompt and run the agent loop.
912    pub async fn prompt(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
913        let offset = self.turn_offset;
914        let messages = self.agent.prompt(text).await?;
915        let new = &messages[offset..];
916        self.persist_turn(new, offset);
917        let final_messages = self.current_messages();
918        self.turn_offset = final_messages.len();
919        Ok(final_messages)
920    }
921
922    /// Send a user message with arbitrary content (text + images) and run the
923    /// agent loop.
924    pub async fn prompt_with_content(
925        &mut self,
926        content: Vec<opi_ai::message::InputContent>,
927    ) -> Result<Vec<AgentMessage>, AgentError> {
928        let offset = self.turn_offset;
929        let messages = self.agent.prompt_with_content(content).await?;
930        let new = &messages[offset..];
931        self.persist_turn(new, offset);
932        let final_messages = self.current_messages();
933        self.turn_offset = final_messages.len();
934        Ok(final_messages)
935    }
936
937    /// Continue the conversation with an additional message.
938    pub async fn continue_(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
939        let offset = self.turn_offset;
940        let messages = self.agent.continue_(text).await?;
941        let new = &messages[offset..];
942        self.persist_turn(new, offset);
943        let final_messages = self.current_messages();
944        self.turn_offset = final_messages.len();
945        Ok(final_messages)
946    }
947
948    /// Sum usage across every assistant message produced during a turn.
949    ///
950    /// A single user prompt can drive multiple provider calls (e.g.
951    /// tool-call response followed by a final response). Each emitted
952    /// assistant message carries its own `usage`; the cumulative session
953    /// total must include all of them, not just the last one.
954    fn aggregate_turn_usage(messages: &[AgentMessage]) -> opi_ai::stream::Usage {
955        let mut total = opi_ai::stream::Usage::default();
956        for m in messages {
957            if let AgentMessage::Llm(Message::Assistant(a)) = m {
958                total.input_tokens = total.input_tokens.saturating_add(a.usage.input_tokens);
959                total.output_tokens = total.output_tokens.saturating_add(a.usage.output_tokens);
960                total.cache_read_tokens = total
961                    .cache_read_tokens
962                    .saturating_add(a.usage.cache_read_tokens);
963                total.cache_write_tokens = total
964                    .cache_write_tokens
965                    .saturating_add(a.usage.cache_write_tokens);
966            }
967        }
968        total
969    }
970
971    /// Aggregate usage across all assistant messages in a turn and persist.
972    ///
973    /// If compaction was triggered during persistence, this also rewrites
974    /// the Agent's message buffer to `[summary, ...kept]` so subsequent
975    /// provider calls no longer carry the compacted history. Emits
976    /// `CompactionStart`/`CompactionEnd` events for subscribers.
977    fn persist_turn(&mut self, messages: &[AgentMessage], turn_start_agent_index: usize) {
978        if let Some(session) = &mut self.session {
979            let usage = Self::aggregate_turn_usage(messages);
980            let compaction_reason =
981                match session.on_turn_end(messages, &usage, turn_start_agent_index) {
982                    Ok(reason) => reason,
983                    Err(e) => {
984                        self.agent.emit_event(AgentEvent::SessionPersistError {
985                            message: format!("session write failed: {e}"),
986                        });
987                        return;
988                    }
989                };
990
991            if let Some(reason) = compaction_reason {
992                self.agent
993                    .emit_event(AgentEvent::CompactionStart { reason });
994                match session.execute_compaction(reason) {
995                    Ok(Some(out)) => {
996                        let wire = to_wire_result(&out);
997                        self.agent.replace_messages(out.new_agent_messages);
998                        self.agent.emit_event(AgentEvent::CompactionEnd {
999                            reason,
1000                            result: Some(wire),
1001                            aborted: false,
1002                            error_message: None,
1003                        });
1004                    }
1005                    Ok(None) => {
1006                        self.agent.emit_event(AgentEvent::CompactionEnd {
1007                            reason,
1008                            result: None,
1009                            aborted: true,
1010                            error_message: Some("compaction produced no output".into()),
1011                        });
1012                    }
1013                    Err(e) => {
1014                        // Compaction marker failed to persist — leave in-memory
1015                        // state un-compacted (SessionCoordinator already skipped
1016                        // the mutation) and surface the error to subscribers.
1017                        self.agent.emit_event(AgentEvent::CompactionEnd {
1018                            reason,
1019                            result: None,
1020                            aborted: true,
1021                            error_message: Some(format!("compaction persist failed: {e}")),
1022                        });
1023                        self.agent.emit_event(AgentEvent::SessionPersistError {
1024                            message: format!("compaction write failed: {e}"),
1025                        });
1026                    }
1027                }
1028            }
1029        }
1030    }
1031
1032    /// Return the current message buffer (after any compaction).
1033    fn current_messages(&self) -> Vec<AgentMessage> {
1034        // The Agent's `set_initial_messages` / `replace_messages` API doesn't
1035        // expose a getter, so we re-derive the buffer from what was returned
1036        // by the loop plus any post-loop mutation. Simplest correct option:
1037        // ask the Agent via a new getter.
1038        self.agent.messages_snapshot()
1039    }
1040
1041    /// Return the current model name.
1042    pub fn model(&self) -> &str {
1043        self.agent.model()
1044    }
1045
1046    /// Queue a steering message for the next provider call.
1047    pub fn steer(&self, message: String) {
1048        self.agent.steer(message);
1049    }
1050
1051    /// Queue a follow-up message for when the agent would otherwise stop.
1052    pub fn follow_up(&self, message: String) {
1053        self.agent.follow_up(message);
1054    }
1055
1056    /// Register an event subscriber.
1057    pub fn subscribe(&mut self, callback: Box<dyn Fn(&AgentEvent) + Send + Sync>) {
1058        self.agent.subscribe(callback);
1059    }
1060
1061    /// Return the assembled system prompt (for testing).
1062    pub fn system_prompt(&self) -> &str {
1063        &self.system_prompt
1064    }
1065
1066    /// Return read-only discovered resource metadata.
1067    pub fn resource_metadata(&self) -> &DiscoveredResourceMetadata {
1068        &self.resources.metadata
1069    }
1070
1071    /// Return resource metadata in the compact RPC/session-info shape.
1072    pub fn resource_metadata_json(&self) -> serde_json::Value {
1073        self.resources.metadata.to_rpc_json()
1074    }
1075
1076    /// Resolve a theme using discovered themes first, then built-ins.
1077    pub fn resolve_theme(
1078        &self,
1079        name: &str,
1080    ) -> Result<opi_tui::Theme, crate::theme_discovery::ThemeDiscoveryError> {
1081        crate::theme_discovery::ThemeRegistry::from_resources(
1082            self.resources.theme_resources.clone(),
1083        )
1084        .resolve_theme(name)
1085    }
1086
1087    /// Return a reference to the config.
1088    pub fn config(&self) -> &OpiConfig {
1089        &self.config
1090    }
1091
1092    /// Cancel the running operation.
1093    pub fn cancel(&self) {
1094        self.agent.abort();
1095    }
1096
1097    /// Return a clonable cancellation token for external cancellation.
1098    pub fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1099        self.agent.cancel_token()
1100    }
1101
1102    /// Return a clonable control handle for an active agent turn.
1103    pub fn control_handle(&self) -> opi_agent::agent::AgentControl {
1104        self.agent.control_handle()
1105    }
1106
1107    /// Reset cancellation state before cloning a control handle for a new turn.
1108    pub fn reset_cancel_if_cancelled(&mut self) {
1109        self.agent.reset_cancel_if_cancelled();
1110    }
1111
1112    /// Return the session coordinator, if active.
1113    pub fn session(&self) -> Option<&SessionCoordinator> {
1114        self.session.as_ref()
1115    }
1116
1117    /// Execute manual compaction on the session, if one is active.
1118    /// Returns the compaction result, or None if compaction produced no output
1119    /// or no session exists.
1120    pub fn compact(
1121        &mut self,
1122        reason: opi_agent::session_event::CompactionReason,
1123    ) -> Result<Option<opi_agent::session_event::CompactionResult>, String> {
1124        let session = match &mut self.session {
1125            Some(s) => s,
1126            None => return Err("no active session".into()),
1127        };
1128        let result = session
1129            .execute_compaction(reason)
1130            .map_err(|e| format!("compaction failed: {e}"))?;
1131        if let Some(out) = &result {
1132            self.agent.replace_messages(out.new_agent_messages.clone());
1133        }
1134        Ok(result.map(|out| crate::session_coordinator::to_wire_result(&out)))
1135    }
1136
1137    fn build_tools(workspace_root: &Path, tool_config: &ToolRuntimeConfig) -> Vec<Box<dyn Tool>> {
1138        let read_policy = match tool_config.run_mode {
1139            RunMode::Interactive => crate::tool::PathPolicy::AllowOutsideWorkspace,
1140            RunMode::NonInteractive => crate::tool::PathPolicy::WorkspaceOnly,
1141        };
1142
1143        let mut tools: Vec<(&str, Box<dyn Tool>)> = vec![
1144            (
1145                "read",
1146                Box::new(ReadTool::new_with_policy(
1147                    workspace_root.to_path_buf(),
1148                    read_policy,
1149                )),
1150            ),
1151            (
1152                "write",
1153                Box::new(WriteTool::new(workspace_root.to_path_buf())),
1154            ),
1155            (
1156                "edit",
1157                Box::new(EditTool::new(workspace_root.to_path_buf())),
1158            ),
1159            (
1160                "bash",
1161                Box::new(BashTool::new(workspace_root.to_path_buf())),
1162            ),
1163            (
1164                "grep",
1165                Box::new(GrepTool::new(workspace_root.to_path_buf())),
1166            ),
1167            (
1168                "find",
1169                Box::new(FindTool::new(workspace_root.to_path_buf())),
1170            ),
1171            ("ls", Box::new(LsTool::new(workspace_root.to_path_buf()))),
1172            (
1173                "glob",
1174                Box::new(GlobTool::new(workspace_root.to_path_buf())),
1175            ),
1176        ];
1177
1178        tools
1179            .drain(..)
1180            .filter(|(name, _)| {
1181                tool_config
1182                    .active_tool_names
1183                    .iter()
1184                    .any(|active| active == name)
1185            })
1186            .map(|(_, tool)| tool)
1187            .collect()
1188    }
1189
1190    fn discover_resources(
1191        workspace_root: &Path,
1192        config: &OpiConfig,
1193        user_config_dir: Option<&Path>,
1194        resource_layers: Option<ResourceDiscoveryLayers>,
1195    ) -> HarnessResources {
1196        let explicit = ExplicitResourcePaths {
1197            extensions: config.extensions.paths.clone(),
1198            packages: config.packages.paths.clone(),
1199            ..Default::default()
1200        };
1201        let mut layers = resource_layers.unwrap_or_else(|| {
1202            standard_discovery_layers(workspace_root, user_config_dir, explicit)
1203        });
1204        let mut metadata = DiscoveredResourceMetadata::default();
1205
1206        let packages = match crate::package_discovery::discover_packages(&layers.packages) {
1207            Ok(packages) => packages,
1208            Err(e) => {
1209                metadata
1210                    .diagnostics
1211                    .push(format!("package discovery failed: {e}"));
1212                Vec::new()
1213            }
1214        };
1215        metadata.packages = packages
1216            .iter()
1217            .map(|package| ResourceMetadataEntry {
1218                name: package.manifest.name.clone(),
1219                description: Some(package.manifest.description.clone()),
1220                version: package.manifest.version.clone(),
1221            })
1222            .collect();
1223
1224        let package_layers = crate::package_discovery::package_composed_resource_layers(&packages);
1225        metadata.diagnostics.extend(package_layers.diagnostics);
1226        layers.extensions.extend(package_layers.extensions);
1227        layers.skills.extend(package_layers.skills);
1228        layers.fragments.extend(package_layers.fragments);
1229        layers.themes.extend(package_layers.themes);
1230
1231        match crate::resource::discover_extension_resources(&layers.extensions) {
1232            Ok(extensions) => {
1233                metadata.extensions = extensions
1234                    .iter()
1235                    .map(|extension| ResourceMetadataEntry {
1236                        name: extension.manifest.name.clone(),
1237                        description: extension.manifest.description.clone(),
1238                        version: extension.manifest.version.clone(),
1239                    })
1240                    .collect();
1241            }
1242            Err(e) => metadata
1243                .diagnostics
1244                .push(format!("extension discovery failed: {e}")),
1245        }
1246
1247        match crate::skill::discover_skills(&layers.skills) {
1248            Ok(skills) => {
1249                metadata.skills = skills
1250                    .iter()
1251                    .map(|skill| ResourceMetadataEntry {
1252                        name: skill.manifest.name.clone(),
1253                        description: Some(skill.manifest.description.clone()),
1254                        version: None,
1255                    })
1256                    .collect();
1257            }
1258            Err(e) => metadata
1259                .diagnostics
1260                .push(format!("skill discovery failed: {e}")),
1261        }
1262
1263        match crate::prompt_fragment::discover_fragments(&layers.fragments) {
1264            Ok(fragments) => {
1265                metadata.fragments = fragments
1266                    .iter()
1267                    .map(|fragment| ResourceMetadataEntry {
1268                        name: fragment.manifest.name.clone(),
1269                        description: Some(fragment.manifest.description.clone()),
1270                        version: None,
1271                    })
1272                    .collect();
1273            }
1274            Err(e) => metadata
1275                .diagnostics
1276                .push(format!("fragment discovery failed: {e}")),
1277        }
1278
1279        let theme_resources = match crate::theme_discovery::discover_themes(&layers.themes) {
1280            Ok(themes) => {
1281                metadata.themes = themes
1282                    .iter()
1283                    .map(|theme| ResourceMetadataEntry {
1284                        name: theme.manifest.name.clone(),
1285                        description: Some(theme.manifest.description.clone()),
1286                        version: None,
1287                    })
1288                    .collect();
1289                themes
1290            }
1291            Err(e) => {
1292                metadata
1293                    .diagnostics
1294                    .push(format!("theme discovery failed: {e}"));
1295                Vec::new()
1296            }
1297        };
1298
1299        HarnessResources {
1300            metadata,
1301            theme_resources,
1302        }
1303    }
1304
1305    fn build_model_registry(
1306        provider: &dyn Provider,
1307        extension_registry: Option<&ExtensionRegistry>,
1308    ) -> (opi_ai::ProviderRegistry, Vec<String>) {
1309        let mut registry = opi_ai::ProviderRegistry::new();
1310        let mut diagnostics = Vec::new();
1311
1312        if let Some(extension_registry) = extension_registry {
1313            for provider in extension_registry.collect_providers() {
1314                if let Err(e) = registry.register_provider(provider) {
1315                    diagnostics.push(format!("extension provider registration failed: {e}"));
1316                }
1317            }
1318        }
1319
1320        if let Err(e) =
1321            registry.register_provider(Box::new(MetadataProvider::from_provider(provider)))
1322        {
1323            diagnostics.push(format!("active provider metadata registration failed: {e}"));
1324        }
1325
1326        if let Some(extension_registry) = extension_registry {
1327            for (provider_id, model) in extension_registry.collect_model_overrides() {
1328                if let Err(e) = registry.register_model(&provider_id, model) {
1329                    diagnostics.push(format!("extension model override registration failed: {e}"));
1330                }
1331            }
1332        }
1333
1334        (registry, diagnostics)
1335    }
1336}
1337
1338fn parse_model_spec(spec: &str) -> Result<(&str, &str), String> {
1339    let Some((provider, model)) = spec.split_once(':') else {
1340        return Err("invalid model spec: expected provider:model".into());
1341    };
1342    if provider.is_empty() || model.is_empty() {
1343        return Err("invalid model spec: expected provider:model".into());
1344    }
1345    Ok((provider, model))
1346}
1347
1348fn initial_thinking_request_config(
1349    registry: &opi_ai::ProviderRegistry,
1350    model: &str,
1351    config: &OpiConfig,
1352) -> (Option<ThinkingConfig>, Option<u64>) {
1353    if !config.thinking.enabled {
1354        return (None, None);
1355    }
1356
1357    let budget_tokens = config.thinking.budget_tokens as u64;
1358    let Ok((mut thinking, mut max_tokens)) = request_config_for_thinking_budget(budget_tokens)
1359    else {
1360        return (None, None);
1361    };
1362
1363    if let Ok((_, model)) = registry.resolve(model) {
1364        if !model.supports_thinking {
1365            return (None, None);
1366        }
1367        if max_tokens > model.max_output_tokens {
1368            if model.max_output_tokens <= 1 {
1369                return (None, None);
1370            }
1371            let adjusted_budget = model.max_output_tokens - 1;
1372            let Ok((adjusted_thinking, adjusted_max_tokens)) =
1373                request_config_for_thinking_budget(adjusted_budget)
1374            else {
1375                return (None, None);
1376            };
1377            thinking = adjusted_thinking;
1378            max_tokens = adjusted_max_tokens;
1379        }
1380    }
1381
1382    (Some(thinking), Some(max_tokens))
1383}
1384
1385fn request_config_for_thinking_budget(budget_tokens: u64) -> Result<(ThinkingConfig, u64), String> {
1386    let max_tokens = max_tokens_for_thinking_budget(budget_tokens)?;
1387    Ok((
1388        ThinkingConfig {
1389            enabled: true,
1390            budget_tokens: Some(budget_tokens),
1391        },
1392        max_tokens,
1393    ))
1394}
1395
1396fn max_tokens_for_thinking_budget(budget_tokens: u64) -> Result<u64, String> {
1397    budget_tokens.checked_add(1).ok_or_else(|| {
1398        format!("thinking budget {budget_tokens} cannot fit a valid max_tokens value")
1399    })
1400}
1401
1402fn validate_thinking_budget_for_model(
1403    model: &ModelInfo,
1404    budget_tokens: u64,
1405    max_tokens: u64,
1406) -> Result<(), String> {
1407    if !model.supports_thinking {
1408        return Err(model_does_not_support_thinking(&model.id));
1409    }
1410    if max_tokens > model.max_output_tokens {
1411        return Err(thinking_budget_exceeds_model_limit(
1412            budget_tokens,
1413            max_tokens,
1414            model.max_output_tokens,
1415            &model.id,
1416        ));
1417    }
1418    Ok(())
1419}
1420
1421fn model_does_not_support_thinking(model_id: &str) -> String {
1422    format!("model '{model_id}' does not support thinking")
1423}
1424
1425fn thinking_budget_exceeds_model_limit(
1426    budget_tokens: u64,
1427    max_tokens: u64,
1428    max_output_tokens: u64,
1429    model_id: &str,
1430) -> String {
1431    format!(
1432        "thinking budget {budget_tokens} requires max_tokens {max_tokens}, exceeding max output tokens {max_output_tokens} for model '{model_id}'"
1433    )
1434}
1435
1436// ---------------------------------------------------------------------------
1437// Hooks
1438// ---------------------------------------------------------------------------
1439
1440/// Shared conversion of agent-level messages to the provider-facing Message
1441/// stream. Used by every hook in this crate so resume/compaction semantics
1442/// stay consistent between interactive and non-interactive paths.
1443///
1444/// - `AgentMessage::Llm` is forwarded directly.
1445/// - `AgentMessage::CompactionSummary` is rendered as a synthetic user
1446///   message so the provider sees a textual marker for context that was
1447///   compacted away.
1448/// - Other variants (`BranchSummary`, `Custom`) are dropped — they have no
1449///   provider-facing representation yet.
1450pub(crate) fn agent_messages_to_llm(messages: &[AgentMessage]) -> Vec<Message> {
1451    let mut result = Vec::with_capacity(messages.len());
1452    for msg in messages {
1453        match msg {
1454            AgentMessage::Llm(m) => result.push(m.clone()),
1455            AgentMessage::CompactionSummary(summary) => {
1456                result.push(Message::User(opi_ai::message::UserMessage {
1457                    content: vec![opi_ai::message::InputContent::Text {
1458                        text: format!(
1459                            "[Context was compacted. Summary of earlier conversation: {}]",
1460                            summary.summary
1461                        ),
1462                    }],
1463                    timestamp_ms: 0,
1464                }));
1465            }
1466            _ => {}
1467        }
1468    }
1469    result
1470}
1471
1472/// Default hooks for the coding agent -- pass-through message conversion.
1473pub struct CodingAgentHooks;
1474
1475impl AgentHooks for CodingAgentHooks {
1476    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
1477        Ok(agent_messages_to_llm(messages))
1478    }
1479}
1480
1481/// Interactive hooks for the coding agent.
1482///
1483/// Tool safety is controlled by active tool selection and extension hooks, not
1484/// by a core interactive permission popup.
1485pub struct InteractiveCodingHooks;
1486
1487impl InteractiveCodingHooks {
1488    pub fn new(_allow_mutating: bool) -> Self {
1489        Self
1490    }
1491}
1492
1493impl AgentHooks for InteractiveCodingHooks {
1494    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
1495        Ok(agent_messages_to_llm(messages))
1496    }
1497}