Skip to main content

sid_isnt_done/
lib.rs

1//! A UNIX-inspired coding agent for Anthropic-compatible APIs.
2//!
3//! `sid-isnt-done` combines an rc-conf configuration system with the Anthropic
4//! messages API to produce an interactive, tool-using coding agent.  Agents,
5//! tools, and skills are defined through plain configuration files rather than
6//! hard-coded tool lists, and the runtime manages sessions, sandboxing, and
7//! tool invocation lifecycle.
8//!
9//! The primary entry point is [`SidAgent`], which assembles a chat
10//! configuration, tool bindings, filesystem mounts, and sandbox policy into a
11//! single [`claudius::Agent`] implementation.
12
13#![deny(missing_docs)]
14
15/// Built-in tool implementations (editor, read-only viewer).
16pub mod builtin_tools;
17/// Workspace configuration loading and types.
18pub mod config;
19mod filesystem;
20mod retry;
21/// macOS Seatbelt sandbox integration.
22pub mod seatbelt;
23/// Session lifecycle: creation, resumption, journalling, and transcripts.
24pub mod session;
25/// Semantic diff rendering with syntax-aware annotations.
26pub mod sidiff;
27/// Skill-reference injection into user messages.
28pub mod skill_inject;
29#[cfg(test)]
30pub(crate) mod test_support;
31mod tool_protocol;
32mod tool_runtime;
33mod user_instructions;
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::ops::ControlFlow;
37use std::path::PathBuf;
38use std::sync::Arc;
39use std::sync::Mutex as StdMutex;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::time::Duration;
42
43use claudius::chat::{ChatAgent, ChatConfig};
44use claudius::{
45    Agent, AgentStreamContext, Anthropic, BashPtyConfig, BashPtyResult, BashPtySession, Budget,
46    Content, ContentBlock, Error, FileSystem, IntermediateToolResult, Message, MessageParam,
47    MessageParamContent, MessageRole, Metadata, Model, MountHierarchy, OperatorLine, Renderer,
48    StopReason, StreamContext, SystemPrompt, TextBlock, ThinkingConfig, TokenRates, Tool,
49    ToolBash20250124, ToolCallback, ToolChoice, ToolParam, ToolResult, ToolResultBlock,
50    ToolResultBlockContent, ToolTextEditor20250728, ToolUnionParam, ToolUseBlock, TurnOutcome,
51    Usage,
52};
53use handled::SError;
54use rc_conf::SwitchPosition;
55use tokio::sync::Mutex;
56use utf8path::Path;
57
58use crate::config::{
59    AGENTS_CONF_FILE, AgentConfig, Config, MEMORY_EXPERT_PROMPT_ID, SYSTEM_PROMPT_ID, SkillConfig,
60    TOOLS_CONF_FILE, ToolConfig, is_valid_anthropic_tool_name, resolve_canonical_tool_id,
61};
62use crate::filesystem::{build_agent_filesystem, build_default_filesystem, resolve_agent_skills};
63use crate::seatbelt::WritableRoots;
64use crate::session::{CompactionExpertConfig, CompactionProvenance, SidSession};
65use crate::tool_runtime::ToolRuntimeContext;
66use crate::user_instructions::{
67    UserInstructionRuntimeContext, UserInstructionSettings, append_agents_md_to_system_prompt,
68    append_user_instruction_block, build_user_instruction_block,
69    disabled_user_instruction_settings, resolve_user_instruction_settings,
70};
71
72const DEFAULT_AGENT_ID: &str = "sid";
73const DEFAULT_COMPACTOR_AGENT_ID: &str = "compact";
74const USER_CANCELLED_ACTION: &str = "user cancelled action";
75const ASK_AN_EXPERT_TOOL_NAME: &str = "ask_an_expert";
76const MAX_ASK_AN_EXPERT_DEPTH: usize = 8;
77const BASH_STATE_CAPTURE_COMMAND: &str = concat!(
78    "builtin printf 'builtin cd -- %q\\n' \"$PWD\"\n",
79    "builtin set +o\n",
80    "builtin shopt -p\n",
81    "builtin export -p\n",
82    "builtin alias -p\n",
83    "builtin declare -pf\n",
84);
85const DEFAULT_COMPACTOR_SYSTEM_PROMPT: &str = concat!(
86    "You are the sid compaction agent.\n",
87    "Turn the conversation history into a compact, high-signal handoff summary for a future session.\n",
88    "Preserve objectives, constraints, decisions, unfinished work, notable files, commands, errors, and next steps.\n",
89    "Prefer concrete facts over narration.\n",
90    "State uncertainty explicitly.\n",
91    "Do not ask follow-up questions.\n",
92);
93/// User-facing prompt sent to the compactor agent to trigger session compaction.
94pub const COMPACTION_REQUEST_PROMPT: &str = concat!(
95    "Compact this conversation into a standalone handoff summary for the next session.\n",
96    "Write only the summary.\n",
97);
98const MEMORY_EXPERT_PROMPT_ADDENDUM: &str = concat!(
99    "\n\n# Expert follow-up mode\n",
100    "You previously wrote the summary for a compacted sid session.\n",
101    "Answer the latest user question from contextual memory only.\n",
102    "If the answer is not supported by the conversation context you have, reply exactly: I don't know\n",
103    "You may use ask_an_expert only to consult the earlier summary writer when available.\n",
104);
105const COMPACTED_SESSION_CONTEXT_TEMPLATE: &str = concat!(
106    "This session is a compacted continuation of session {session_id}.\n",
107    "The next assistant message is the handoff summary written by the previous owner of the project.\n",
108    "Use that summary as working context. If you need details that are not in it, use ask_an_expert.\n",
109);
110
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112enum SidToolScope {
113    Normal,
114    MemoryOnly,
115}
116
117impl SidToolScope {
118    fn exposes_standard_tools(self) -> bool {
119        matches!(self, Self::Normal)
120    }
121}
122
123/// Top-level agent combining chat configuration, tool bindings, and sandbox policy.
124pub struct SidAgent {
125    id: String,
126    enabled: SwitchPosition,
127    config: ChatConfig,
128    named_prompts: BTreeMap<String, String>,
129    tools: Vec<Arc<dyn Tool<Self>>>,
130    builtin_bindings: BuiltinToolBindings,
131    skills: Vec<SkillConfig>,
132    config_root: Path<'static>,
133    workspace_root: Path<'static>,
134    user_instructions: UserInstructionSettings,
135    writable_roots: WritableRoots,
136    filesystem: MountHierarchy,
137    session: Option<Arc<SidSession>>,
138    memory_source: Option<CompactionProvenance>,
139    tool_scope: SidToolScope,
140    memory_depth: usize,
141    auto_compact_tokens: Option<u64>,
142    bash_session: Mutex<Option<BashPtySession>>,
143    tool_cancellation_pending: AtomicBool,
144    token_usage_totals: StdMutex<TokenUsageTotals>,
145}
146
147impl SidAgent {
148    /// Create a new agent with default settings rooted at `workspace_root`.
149    ///
150    /// The workspace root is used as both the configuration root and the
151    /// filesystem root.  No rc.conf files are loaded; the agent operates
152    /// with the supplied [`ChatConfig`] only.
153    pub fn new(config: ChatConfig, workspace_root: Path<'static>) -> Self {
154        Self::new_with_roots(config, workspace_root.clone(), workspace_root)
155    }
156
157    fn new_with_roots(
158        config: ChatConfig,
159        config_root: Path<'static>,
160        workspace_root: Path<'static>,
161    ) -> Self {
162        Self::new_custom(
163            DEFAULT_AGENT_ID.to_string(),
164            config,
165            config_root,
166            workspace_root,
167            UserInstructionSettings::default(),
168        )
169    }
170
171    fn new_custom(
172        id: String,
173        config: ChatConfig,
174        config_root: Path<'static>,
175        workspace_root: Path<'static>,
176        user_instructions: UserInstructionSettings,
177    ) -> Self {
178        let filesystem = build_default_filesystem(&workspace_root);
179        let writable_roots = default_writable_roots(&workspace_root);
180        Self::with_parts(
181            id,
182            SwitchPosition::Yes,
183            config,
184            BTreeMap::new(),
185            vec![],
186            BuiltinToolBindings::default(),
187            Vec::new(),
188            config_root,
189            workspace_root,
190            user_instructions,
191            writable_roots,
192            filesystem,
193            None,
194            None,
195            SidToolScope::Normal,
196            0,
197            None,
198        )
199    }
200
201    /// Load the default agent from a workspace's configuration files.
202    ///
203    /// When no configuration exists at `root`, the `fallback` chat config is
204    /// used with default settings.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error when the configuration exists but is malformed, or
209    /// when the default agent cannot be resolved.
210    pub fn from_workspace(root: &Path, fallback: ChatConfig) -> Result<Self, SError> {
211        Self::from_workspace_with_config_root(root, root, fallback)
212    }
213
214    /// Load the default agent using separate workspace and configuration roots.
215    ///
216    /// The `config_root` is the directory containing `agents.conf` and
217    /// `tools.conf`, while `workspace_root` is the directory exposed to tool
218    /// invocations and sandbox policies.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error when the configuration exists but is malformed, or
223    /// when the default agent cannot be resolved.
224    pub fn from_workspace_with_config_root(
225        workspace_root: &Path,
226        config_root: &Path,
227        fallback: ChatConfig,
228    ) -> Result<Self, SError> {
229        if !workspace_has_config(config_root) {
230            let mut agent = Self::new_with_roots(
231                fallback,
232                config_root.clone().into_owned(),
233                workspace_root.clone().into_owned(),
234            );
235            agent.append_agents_md_to_system_prompt()?;
236            return Ok(agent);
237        }
238        let config = Config::load(config_root)?;
239        let agent = default_agent_id(&config)?;
240        Self::from_loaded_config(
241            &config,
242            &agent,
243            Some(&fallback),
244            workspace_root.clone().into_owned(),
245        )
246    }
247
248    /// Load a named agent from a workspace's configuration files.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error when the agent does not exist in the configuration or
253    /// is disabled.
254    pub fn from_workspace_agent(
255        root: &Path,
256        agent: &str,
257        fallback: ChatConfig,
258    ) -> Result<Self, SError> {
259        Self::from_workspace_agent_with_config_root(root, root, agent, fallback)
260    }
261
262    /// Load a named agent using separate workspace and configuration roots.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error when the agent does not exist in the configuration or
267    /// is disabled.
268    pub fn from_workspace_agent_with_config_root(
269        workspace_root: &Path,
270        config_root: &Path,
271        agent: &str,
272        fallback: ChatConfig,
273    ) -> Result<Self, SError> {
274        if !workspace_has_config(config_root) {
275            return if agent == DEFAULT_AGENT_ID {
276                let mut agent = Self::new_with_roots(
277                    fallback,
278                    config_root.clone().into_owned(),
279                    workspace_root.clone().into_owned(),
280                );
281                agent.append_agents_md_to_system_prompt()?;
282                Ok(agent)
283            } else {
284                Err(missing_agent_error(agent))
285            };
286        }
287        let config = Config::load(config_root)?;
288        Self::from_loaded_config(
289            &config,
290            agent,
291            Some(&fallback),
292            workspace_root.clone().into_owned(),
293        )
294    }
295
296    /// Load the compaction agent using separate workspace and configuration roots.
297    ///
298    /// If a `compact` agent is declared in the configuration it is used;
299    /// otherwise a default compactor is built from `fallback` with the
300    /// built-in compaction system prompt.  The returned agent exposes
301    /// memory-only tools.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error when the configuration exists but is malformed.
306    pub fn from_workspace_compactor_with_config_root(
307        workspace_root: &Path,
308        config_root: &Path,
309        fallback: ChatConfig,
310    ) -> Result<Self, SError> {
311        if workspace_has_config(config_root) {
312            let config = Config::load(config_root)?;
313            if config.agents.contains_key(DEFAULT_COMPACTOR_AGENT_ID) {
314                return Self::from_loaded_config_inner(
315                    &config,
316                    DEFAULT_COMPACTOR_AGENT_ID,
317                    Some(&fallback),
318                    workspace_root.clone().into_owned(),
319                    true,
320                )
321                .map(|agent| agent.with_tool_scope(SidToolScope::MemoryOnly));
322            }
323        }
324
325        let mut config = fallback;
326        config.set_system_prompt(Some(DEFAULT_COMPACTOR_SYSTEM_PROMPT.to_string()));
327        Ok(Self::new_custom(
328            DEFAULT_COMPACTOR_AGENT_ID.to_string(),
329            config,
330            config_root.clone().into_owned(),
331            workspace_root.clone().into_owned(),
332            disabled_user_instruction_settings(),
333        )
334        .with_tool_scope(SidToolScope::MemoryOnly))
335    }
336
337    /// Build an agent directly from a pre-loaded [`Config`].
338    ///
339    /// # Errors
340    ///
341    /// Returns an error when the agent does not exist in the configuration or
342    /// is disabled.
343    pub fn from_config(
344        config: &Config,
345        agent: &str,
346        filesystem: Path<'static>,
347    ) -> Result<Self, SError> {
348        Self::from_loaded_config(config, agent, None, filesystem)
349    }
350
351    /// Return the agent's identifier.
352    pub fn id(&self) -> &str {
353        &self.id
354    }
355
356    /// Return `true` when the agent's enablement switch is [`SwitchPosition::Manual`].
357    pub fn requires_confirmation(&self) -> bool {
358        self.enabled == SwitchPosition::Manual
359    }
360
361    /// Return the auto-compact threshold in output tokens, if configured.
362    pub fn auto_compact_tokens(&self) -> Option<u64> {
363        self.auto_compact_tokens
364    }
365
366    fn append_agents_md_to_system_prompt(&mut self) -> Result<(), SError> {
367        append_agents_md_to_system_prompt(
368            &mut self.config,
369            &self.user_instructions,
370            &self.workspace_root,
371        )
372    }
373
374    fn from_loaded_config(
375        config: &Config,
376        agent: &str,
377        fallback: Option<&ChatConfig>,
378        workspace_root: Path<'static>,
379    ) -> Result<Self, SError> {
380        Self::from_loaded_config_inner(config, agent, fallback, workspace_root, false)
381    }
382
383    fn from_loaded_config_inner(
384        config: &Config,
385        agent: &str,
386        fallback: Option<&ChatConfig>,
387        workspace_root: Path<'static>,
388        allow_disabled: bool,
389    ) -> Result<Self, SError> {
390        let agent_config = config
391            .agents
392            .get(agent)
393            .ok_or_else(|| missing_agent_error(agent))?;
394        if !allow_disabled && !agent_config.enabled.can_be_started() {
395            return Err(disabled_agent_error(agent, agent_config.enabled));
396        }
397
398        let built_tools = build_tools(config, agent_config)?;
399        let mut chat_config = merged_chat_config(agent_config, fallback);
400        let named_prompts = agent_config
401            .prompts
402            .iter()
403            .map(|(id, prompt)| (id.clone(), prompt.markdown.clone()))
404            .collect();
405        let skills = resolve_agent_skills(config, agent_config)?;
406        let agent_skills = skills.iter().map(|skill| (*skill).clone()).collect();
407        let filesystem = build_agent_filesystem(&workspace_root, config, agent_config)?;
408        let user_instructions = resolve_user_instruction_settings(config, agent_config)?;
409        let writable_roots = default_writable_roots(&workspace_root);
410        append_system_description(&mut chat_config, &workspace_root);
411        if !skills.is_empty() {
412            append_skill_index_to_system_prompt(&mut chat_config, &skills);
413        }
414        append_agents_md_to_system_prompt(&mut chat_config, &user_instructions, &workspace_root)?;
415        Ok(Self::with_parts(
416            agent.to_string(),
417            agent_config.enabled,
418            chat_config,
419            named_prompts,
420            built_tools.tools,
421            built_tools.builtin_bindings,
422            agent_skills,
423            config.root.clone(),
424            workspace_root,
425            user_instructions,
426            writable_roots,
427            filesystem,
428            None,
429            None,
430            SidToolScope::Normal,
431            0,
432            agent_config.auto_compact_tokens,
433        ))
434    }
435
436    #[allow(clippy::too_many_arguments)]
437    fn with_parts(
438        id: String,
439        enabled: SwitchPosition,
440        config: ChatConfig,
441        named_prompts: BTreeMap<String, String>,
442        tools: Vec<Arc<dyn Tool<Self>>>,
443        builtin_bindings: BuiltinToolBindings,
444        skills: Vec<SkillConfig>,
445        config_root: Path<'static>,
446        workspace_root: Path<'static>,
447        user_instructions: UserInstructionSettings,
448        writable_roots: WritableRoots,
449        filesystem: MountHierarchy,
450        session: Option<Arc<SidSession>>,
451        memory_source: Option<CompactionProvenance>,
452        tool_scope: SidToolScope,
453        memory_depth: usize,
454        auto_compact_tokens: Option<u64>,
455    ) -> Self {
456        Self {
457            id,
458            enabled,
459            config,
460            named_prompts,
461            tools,
462            builtin_bindings,
463            skills,
464            config_root,
465            workspace_root,
466            user_instructions,
467            writable_roots,
468            filesystem,
469            session,
470            memory_source,
471            tool_scope,
472            memory_depth,
473            auto_compact_tokens,
474            bash_session: Mutex::new(None),
475            tool_cancellation_pending: AtomicBool::new(false),
476            token_usage_totals: StdMutex::new(TokenUsageTotals::default()),
477        }
478    }
479
480    /// Attach a session to this agent, making the session root writable.
481    pub fn with_session(mut self, session: Arc<SidSession>) -> Self {
482        append_writable_root(&mut self.writable_roots, session.root());
483        if self.memory_source.is_none() {
484            self.memory_source = session.compaction_provenance().cloned();
485        }
486        self.session = Some(session);
487        self
488    }
489
490    /// Override the compaction provenance used for ask-an-expert memory chains.
491    pub fn with_memory_source(mut self, memory_source: Option<CompactionProvenance>) -> Self {
492        self.memory_source = memory_source;
493        self
494    }
495
496    fn with_tool_scope(mut self, tool_scope: SidToolScope) -> Self {
497        self.tool_scope = tool_scope;
498        if !tool_scope.exposes_standard_tools() {
499            self.tools.clear();
500            self.builtin_bindings = BuiltinToolBindings::default();
501        }
502        self
503    }
504
505    fn with_memory_depth(mut self, memory_depth: usize) -> Self {
506        self.memory_depth = memory_depth;
507        self
508    }
509
510    /// Snapshot the agent's identity and model for use as a compaction expert.
511    pub fn compaction_snapshot(&self) -> CompactionExpertConfig {
512        CompactionExpertConfig {
513            agent_id: Some(self.id.clone()),
514            model: self.config.model().to_string(),
515            system_prompt: self.config.system_prompt_text().map(str::to_string),
516            memory_expert_prompt: Some(
517                self.named_prompt_markdown(MEMORY_EXPERT_PROMPT_ID)
518                    .unwrap_or(MEMORY_EXPERT_PROMPT_ADDENDUM)
519                    .to_string(),
520            ),
521        }
522    }
523
524    async fn run_bash_command(
525        &self,
526        command: &str,
527        restart: bool,
528    ) -> Result<String, std::io::Error> {
529        self.run_bash_command_with_renderer(command, restart, None)
530            .await
531    }
532
533    /// Execute a bash command in the agent's PTY session, streaming output to `renderer`.
534    ///
535    /// When `restart` is `true` the existing PTY is torn down and a fresh
536    /// session is started before executing `command`.
537    ///
538    /// # Errors
539    ///
540    /// Returns an I/O error when the PTY cannot be created or the command
541    /// fails to execute.
542    pub async fn bash_with_renderer(
543        &self,
544        command: &str,
545        restart: bool,
546        renderer: &mut dyn Renderer,
547    ) -> Result<String, std::io::Error> {
548        self.run_bash_command_with_renderer(command, restart, Some(renderer))
549            .await
550    }
551
552    async fn run_bash_command_with_renderer(
553        &self,
554        command: &str,
555        restart: bool,
556        renderer: Option<&mut dyn Renderer>,
557    ) -> Result<String, std::io::Error> {
558        let Some(binding) = self.builtin_bindings.bash.as_ref() else {
559            return Err(std::io::Error::new(
560                std::io::ErrorKind::Unsupported,
561                "bash is not supported",
562            ));
563        };
564
565        match binding.enabled {
566            SwitchPosition::Yes => {}
567            SwitchPosition::No => {
568                return Err(std::io::Error::new(
569                    std::io::ErrorKind::PermissionDenied,
570                    "bash is disabled",
571                ));
572            }
573            SwitchPosition::Manual => {
574                let input_value = serde_json::json!({
575                    "command": command,
576                    "restart": restart,
577                    "cwd": self.workspace_root.as_str(),
578                    "writable_roots": self.writable_roots.as_slice(),
579                });
580                match confirm_manual_tool_call("bash", &input_value, None, renderer) {
581                    Ok(ManualToolConfirmation::Allow) => {}
582                    Ok(ManualToolConfirmation::Deny) => {
583                        return Err(std::io::Error::new(
584                            std::io::ErrorKind::PermissionDenied,
585                            "bash call denied by operator",
586                        ));
587                    }
588                    Ok(ManualToolConfirmation::Cancel) => {
589                        return Err(std::io::Error::new(
590                            std::io::ErrorKind::PermissionDenied,
591                            USER_CANCELLED_ACTION,
592                        ));
593                    }
594                    Err(err) => {
595                        return Err(std::io::Error::other(err));
596                    }
597                }
598            }
599        }
600
601        if restart {
602            self.clear_persisted_bash_state()?;
603        }
604
605        let mut session = self.bash_session.lock().await;
606        if session.is_none() {
607            *session = Some(self.spawn_bash_session(!restart).await?);
608        }
609
610        let result = {
611            let session = session
612                .as_mut()
613                .expect("bash PTY session should be initialized");
614            session.run(command, restart).await
615        };
616        match result {
617            Ok(result) => {
618                let bash_session = session
619                    .as_mut()
620                    .expect("bash PTY session should still be initialized");
621                self.persist_bash_state(bash_session).await?;
622                render_bash_pty_result(result)
623            }
624            Err(err) => {
625                let _ = self.clear_persisted_bash_state();
626                *session = None;
627                Err(err)
628            }
629        }
630    }
631
632    async fn spawn_bash_session(
633        &self,
634        restore_state: bool,
635    ) -> Result<BashPtySession, std::io::Error> {
636        let mut session = BashPtySession::new(self.bash_pty_config()).await?;
637        if restore_state {
638            self.restore_bash_state(&mut session).await?;
639        }
640        Ok(session)
641    }
642
643    fn bash_pty_config(&self) -> BashPtyConfig {
644        let mut env: BTreeMap<String, String> = std::env::vars().collect();
645        env.insert(
646            "SID_WORKSPACE_ROOT".to_string(),
647            self.workspace_root.as_str().to_string(),
648        );
649        if let Some(session) = self.session.as_ref() {
650            env.insert(
651                session::SID_SESSION_ID_ENV.to_string(),
652                session.id().to_string(),
653            );
654            env.insert(
655                session::SID_SESSION_DIR_ENV.to_string(),
656                session.root().to_string_lossy().into_owned(),
657            );
658            env.insert(
659                session::SID_SESSIONS_ENV.to_string(),
660                session.sessions_root().to_string_lossy().into_owned(),
661            );
662            env.insert(
663                "TMPDIR".to_string(),
664                session.bash_tmp_dir().to_string_lossy().into_owned(),
665            );
666        }
667        env.insert("PAGER".to_string(), "cat".to_string());
668        env.insert("HISTFILE".to_string(), "/dev/null".to_string());
669        env.insert("INPUTRC".to_string(), "/dev/null".to_string());
670        BashPtyConfig {
671            cwd: PathBuf::from(self.workspace_root.as_str()),
672            env,
673            shell_wrapper: seatbelt::shell_wrapper(&self.writable_roots),
674            ..BashPtyConfig::default()
675        }
676    }
677
678    fn clear_persisted_bash_state(&self) -> Result<(), std::io::Error> {
679        if let Some(session) = self.session.as_ref() {
680            session
681                .clear_bash_state()
682                .map_err(|err| std::io::Error::other(err.to_string()))?;
683        }
684        Ok(())
685    }
686
687    async fn restore_bash_state(
688        &self,
689        bash_session: &mut BashPtySession,
690    ) -> Result<(), std::io::Error> {
691        let Some(sid_session) = self.session.as_ref() else {
692            return Ok(());
693        };
694        let Some(_) = sid_session
695            .read_bash_state()
696            .map_err(|err| std::io::Error::other(err.to_string()))?
697        else {
698            return Ok(());
699        };
700
701        let restore_command = format!(
702            "builtin source {}",
703            shvar::quote_string(sid_session.bash_state_path().to_string_lossy().as_ref())
704        );
705        let result = bash_session.run(&restore_command, false).await?;
706        if result.status.success() {
707            Ok(())
708        } else {
709            Err(bash_state_io_error("failed to restore bash state", &result))
710        }
711    }
712
713    async fn persist_bash_state(
714        &self,
715        bash_session: &mut BashPtySession,
716    ) -> Result<(), std::io::Error> {
717        let Some(sid_session) = self.session.as_ref() else {
718            return Ok(());
719        };
720        if !bash_session.is_alive()? {
721            sid_session
722                .clear_bash_state()
723                .map_err(|err| std::io::Error::other(err.to_string()))?;
724            return Ok(());
725        }
726
727        let snapshot = bash_session.run(BASH_STATE_CAPTURE_COMMAND, false).await?;
728        if !snapshot.status.success() {
729            return Err(bash_state_io_error(
730                "failed to capture bash state",
731                &snapshot,
732            ));
733        }
734        sid_session
735            .write_bash_state(&snapshot.output)
736            .map_err(|err| std::io::Error::other(err.to_string()))
737    }
738
739    fn memory_source(&self) -> Option<&CompactionProvenance> {
740        self.memory_source.as_ref()
741    }
742
743    async fn ask_an_expert(
744        &self,
745        client: &Anthropic,
746        question: &str,
747    ) -> Result<String, std::io::Error> {
748        self.ask_an_expert_with_renderer(client, question, None)
749            .await
750    }
751
752    async fn ask_an_expert_with_renderer(
753        &self,
754        client: &Anthropic,
755        question: &str,
756        renderer: Option<&mut dyn Renderer>,
757    ) -> Result<String, std::io::Error> {
758        let Some(source) = self.memory_source() else {
759            return Err(std::io::Error::new(
760                std::io::ErrorKind::Unsupported,
761                "ask_an_expert is not available in this session",
762            ));
763        };
764        if self.memory_depth >= MAX_ASK_AN_EXPERT_DEPTH {
765            return Ok("I don't know".to_string());
766        }
767
768        let parent_root = std::path::Path::new(&source.session_dir);
769        let parent_messages = load_transcript_messages(
770            session::transcript_path_for_session_dir(parent_root).as_path(),
771        )
772        .map_err(|err| std::io::Error::other(err.to_string()))?;
773        let parent_memory_source = session::read_compaction_provenance_from_dir(parent_root)
774            .map_err(|err| std::io::Error::other(err.to_string()))?;
775
776        let expert = Self::memory_expert_from_snapshot(
777            &source.expert,
778            &self.config_root,
779            &self.workspace_root,
780            parent_memory_source,
781            self.memory_depth + 1,
782        );
783        let mut chat = claudius::chat::ChatSession::with_agent(client.clone(), expert);
784        chat.replace_messages(parent_messages);
785
786        match renderer {
787            Some(renderer) => chat
788                .send_message(MessageParam::user(question), renderer)
789                .await
790                .map_err(|err| std::io::Error::other(err.to_string()))?,
791            None => {
792                let mut renderer = NullRenderer;
793                chat.send_message(MessageParam::user(question), &mut renderer)
794                    .await
795                    .map_err(|err| std::io::Error::other(err.to_string()))?;
796            }
797        }
798
799        extract_last_assistant_text(&chat.clone_messages()).ok_or_else(|| {
800            std::io::Error::other("expert conversation produced no assistant response")
801        })
802    }
803
804    fn memory_expert_from_snapshot(
805        snapshot: &CompactionExpertConfig,
806        config_root: &Path,
807        workspace_root: &Path,
808        memory_source: Option<CompactionProvenance>,
809        memory_depth: usize,
810    ) -> Self {
811        let mut config = ChatConfig::new();
812        let model = snapshot
813            .model
814            .parse()
815            .unwrap_or_else(|_| Model::Custom(snapshot.model.clone()));
816        config.set_model(model);
817        let memory_expert_prompt = snapshot
818            .memory_expert_prompt
819            .as_deref()
820            .unwrap_or(MEMORY_EXPERT_PROMPT_ADDENDUM);
821        config.set_system_prompt(Some(format!(
822            "{}{}",
823            snapshot.system_prompt.as_deref().unwrap_or_default(),
824            memory_expert_prompt
825        )));
826
827        Self::new_custom(
828            snapshot
829                .agent_id
830                .clone()
831                .unwrap_or_else(|| DEFAULT_COMPACTOR_AGENT_ID.to_string()),
832            config,
833            config_root.clone().into_owned(),
834            workspace_root.clone().into_owned(),
835            disabled_user_instruction_settings(),
836        )
837        .with_tool_scope(SidToolScope::MemoryOnly)
838        .with_memory_source(memory_source)
839        .with_memory_depth(memory_depth)
840    }
841
842    fn tool_runtime_context(&self) -> ToolRuntimeContext<'_> {
843        ToolRuntimeContext {
844            agent_id: &self.id,
845            config_root: &self.config_root,
846            workspace_root: &self.workspace_root,
847            writable_roots: &self.writable_roots,
848            session: self.session.as_deref(),
849        }
850    }
851
852    async fn inject_user_instructions_for_turn(
853        &self,
854        messages: &mut [MessageParam],
855    ) -> Result<(), Error> {
856        let latest_user_message = latest_user_message_text(messages);
857        let context = UserInstructionRuntimeContext {
858            agent_id: &self.id,
859            config_root: &self.config_root,
860            workspace_root: &self.workspace_root,
861            session: self.session.as_deref(),
862            latest_user_message: latest_user_message.as_deref(),
863            skills: &self.skills,
864        };
865        let Some(instructions) = build_user_instruction_block(&self.user_instructions, &context)
866            .await
867            .map_err(|err| Error::unknown(format!("failed to build user instructions: {err}")))?
868        else {
869            return Ok(());
870        };
871        append_user_instruction_block(messages, instructions);
872        Ok(())
873    }
874
875    fn prepare_rc_tool(
876        &self,
877        display_name: &str,
878        binding: &RcToolBinding,
879        tool_use_id: &str,
880        input: serde_json::Map<String, serde_json::Value>,
881    ) -> Result<tool_runtime::PreparedRcToolInvocation, String> {
882        let context = self.tool_runtime_context();
883        tool_runtime::prepare_rc_tool_invocation(
884            display_name,
885            &binding.service_name,
886            &binding.canonical_id,
887            &binding.executable_path,
888            &context,
889            tool_use_id,
890            input,
891            binding.timeout,
892        )
893    }
894
895    /// Return the markdown for a resolved named prompt.
896    pub fn named_prompt_markdown(&self, prompt_id: &str) -> Option<&str> {
897        if prompt_id == SYSTEM_PROMPT_ID {
898            return self.config.system_prompt_text();
899        }
900        self.named_prompts.get(prompt_id).map(String::as_str)
901    }
902
903    async fn invoke_rc_tool(
904        &self,
905        binding: &RcToolBinding,
906        tool_use_id: &str,
907        input: serde_json::Map<String, serde_json::Value>,
908    ) -> Result<String, std::io::Error> {
909        let context = self.tool_runtime_context();
910        tool_runtime::invoke_rc_tool_text(
911            &binding.service_name,
912            &binding.service_name,
913            &binding.canonical_id,
914            &binding.executable_path,
915            &context,
916            tool_use_id,
917            input,
918            binding.timeout,
919        )
920        .await
921        .map_err(std::io::Error::other)
922    }
923
924    async fn run_text_editor_tool(
925        &self,
926        tool_use: ToolUseBlock,
927        renderer: Option<&mut dyn Renderer>,
928    ) -> Result<String, std::io::Error> {
929        #[derive(serde::Deserialize)]
930        struct Command {
931            command: String,
932        }
933        let cmd: Command = serde_json::from_value(tool_use.input.clone())?;
934        let Some(binding) = self.builtin_bindings.edit.as_ref() else {
935            return self
936                .default_text_editor_command(cmd.command.as_str(), tool_use)
937                .await;
938        };
939        match binding.enabled {
940            SwitchPosition::Yes => {
941                let input = tool_use.input.as_object().cloned().ok_or_else(|| {
942                    std::io::Error::new(
943                        std::io::ErrorKind::InvalidInput,
944                        "text editor input must be a JSON object",
945                    )
946                })?;
947                return self.invoke_rc_tool(binding, &tool_use.id, input).await;
948            }
949            SwitchPosition::No => {
950                return Err(std::io::Error::new(
951                    std::io::ErrorKind::PermissionDenied,
952                    "edit is disabled",
953                ));
954            }
955            SwitchPosition::Manual => {}
956        }
957        let input = tool_use.input.as_object().cloned().ok_or_else(|| {
958            std::io::Error::new(
959                std::io::ErrorKind::InvalidInput,
960                "text editor input must be a JSON object",
961            )
962        })?;
963        let prepared = self
964            .prepare_rc_tool("edit", binding, &tool_use.id, input)
965            .map_err(std::io::Error::other)?;
966        match confirm_manual_prepared_tool_call(
967            "edit",
968            &tool_use.input,
969            binding.confirm_preview,
970            &prepared,
971            self.session.as_deref(),
972            renderer,
973        )
974        .await
975        {
976            Ok(ManualToolConfirmation::Allow) => {}
977            Ok(ManualToolConfirmation::Deny) => {
978                let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, false);
979                return Err(std::io::Error::new(
980                    std::io::ErrorKind::PermissionDenied,
981                    "edit call denied by operator",
982                ));
983            }
984            Ok(ManualToolConfirmation::Cancel) => {
985                let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, false);
986                return Err(std::io::Error::new(
987                    std::io::ErrorKind::PermissionDenied,
988                    USER_CANCELLED_ACTION,
989                ));
990            }
991            Err(err) => {
992                let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, true);
993                return Err(std::io::Error::other(err));
994            }
995        }
996        tool_runtime::run_prepared_rc_tool_text(
997            &prepared,
998            &self.writable_roots,
999            self.session.as_deref(),
1000        )
1001        .await
1002        .map_err(std::io::Error::other)
1003    }
1004
1005    async fn default_text_editor_command(
1006        &self,
1007        command: &str,
1008        tool_use: ToolUseBlock,
1009    ) -> Result<String, std::io::Error> {
1010        match command {
1011            "view" => {
1012                #[derive(serde::Deserialize)]
1013                struct ViewTool {
1014                    path: String,
1015                    view_range: Option<(u32, u32)>,
1016                }
1017                let args: ViewTool = serde_json::from_value(tool_use.input)?;
1018                self.view(&args.path, args.view_range).await
1019            }
1020            "str_replace" => {
1021                #[derive(serde::Deserialize)]
1022                struct StrReplaceTool {
1023                    path: String,
1024                    old_str: String,
1025                    new_str: Option<String>,
1026                }
1027                let args: StrReplaceTool = serde_json::from_value(tool_use.input)?;
1028                let new_str = args.new_str.as_deref().unwrap_or("");
1029                self.str_replace(&args.path, &args.old_str, new_str).await
1030            }
1031            "insert" => {
1032                #[derive(serde::Deserialize)]
1033                struct InsertTool {
1034                    path: String,
1035                    insert_line: u32,
1036                    insert_text: Option<String>,
1037                    new_str: Option<String>,
1038                }
1039                let args: InsertTool = serde_json::from_value(tool_use.input)?;
1040                let text = args.insert_text.or(args.new_str).ok_or_else(|| {
1041                    std::io::Error::new(
1042                        std::io::ErrorKind::InvalidInput,
1043                        "missing insert_text field",
1044                    )
1045                })?;
1046                self.insert(&args.path, args.insert_line, &text).await
1047            }
1048            "create" => {
1049                #[derive(serde::Deserialize)]
1050                struct CreateTool {
1051                    path: String,
1052                    file_text: String,
1053                }
1054                let args: CreateTool = serde_json::from_value(tool_use.input)?;
1055                self.create(&args.path, &args.file_text).await
1056            }
1057            _ => Err(std::io::Error::new(
1058                std::io::ErrorKind::Unsupported,
1059                format!("{} is not a supported tool", tool_use.name),
1060            )),
1061        }
1062    }
1063}
1064
1065#[async_trait::async_trait]
1066impl Agent for SidAgent {
1067    fn stream_label(&self) -> String {
1068        self.id.clone()
1069    }
1070
1071    async fn take_turn(
1072        &mut self,
1073        client: &Anthropic,
1074        messages: &mut Vec<MessageParam>,
1075        budget: &Arc<Budget>,
1076    ) -> Result<TurnOutcome, Error> {
1077        self.tool_cancellation_pending
1078            .store(false, Ordering::Relaxed);
1079        top_up_cancelled_tool_results(messages);
1080        self.inject_user_instructions_for_turn(messages).await?;
1081        let Some(mut tokens_rem) = budget.allocate(self.max_tokens().await) else {
1082            let stop_reason = self.handle_max_tokens().await?;
1083            top_up_cancelled_tool_results(messages);
1084            return Ok(TurnOutcome {
1085                stop_reason,
1086                usage: Usage::new(0, 0),
1087                request_count: 0,
1088            });
1089        };
1090
1091        let mut usage_total = Usage::new(0, 0);
1092        let mut request_count: u64 = 0;
1093        while tokens_rem.remaining_tokens()
1094            > self
1095                .thinking()
1096                .await
1097                .map(|thinking| thinking.num_tokens())
1098                .unwrap_or(0)
1099        {
1100            let retry_policy = retry::ApiRetryPolicy::default();
1101            let retry_backoff = retry_policy.backoff();
1102            let mut retry_count = 0usize;
1103            let step = loop {
1104                let step = self
1105                    .step_default_turn(client, messages, &mut tokens_rem)
1106                    .await;
1107                let Some(delay) = (match &step {
1108                    ControlFlow::Break(Err(err)) => {
1109                        retry_policy.retry_delay(&retry_backoff, retry_count, err)
1110                    }
1111                    _ => None,
1112                }) else {
1113                    break step;
1114                };
1115                retry_count += 1;
1116                tokio::time::sleep(delay).await;
1117            };
1118            match step {
1119                ControlFlow::Continue(step) => {
1120                    usage_total = usage_total + step.usage;
1121                    request_count = request_count.saturating_add(step.request_count);
1122                    if self
1123                        .tool_cancellation_pending
1124                        .swap(false, Ordering::Relaxed)
1125                    {
1126                        top_up_cancelled_tool_results(messages);
1127                        return Ok(TurnOutcome {
1128                            stop_reason: StopReason::EndTurn,
1129                            usage: usage_total,
1130                            request_count,
1131                        });
1132                    }
1133                }
1134                ControlFlow::Break(res) => {
1135                    return match res {
1136                        Ok(mut outcome) => {
1137                            outcome.usage = outcome.usage + usage_total;
1138                            outcome.request_count =
1139                                outcome.request_count.saturating_add(request_count);
1140                            top_up_cancelled_tool_results(messages);
1141                            Ok(outcome)
1142                        }
1143                        Err(err) => {
1144                            eprintln!("API error (conversation preserved): {err}");
1145                            top_up_cancelled_tool_results(messages);
1146                            Ok(TurnOutcome {
1147                                stop_reason: StopReason::EndTurn,
1148                                usage: usage_total,
1149                                request_count,
1150                            })
1151                        }
1152                    };
1153                }
1154            }
1155        }
1156
1157        let stop_reason = self.handle_max_tokens().await?;
1158        top_up_cancelled_tool_results(messages);
1159        Ok(TurnOutcome {
1160            stop_reason,
1161            usage: usage_total,
1162            request_count,
1163        })
1164    }
1165
1166    async fn take_turn_streaming(
1167        &mut self,
1168        client: &Anthropic,
1169        messages: &mut Vec<MessageParam>,
1170        budget: &Arc<Budget>,
1171        renderer: &mut dyn Renderer,
1172        context: AgentStreamContext,
1173    ) -> Result<TurnOutcome, Error> {
1174        self.tool_cancellation_pending
1175            .store(false, Ordering::Relaxed);
1176        top_up_cancelled_tool_results(messages);
1177        self.inject_user_instructions_for_turn(messages).await?;
1178        renderer.start_agent(&context);
1179        let Some(mut tokens_rem) = budget.allocate(self.max_tokens().await) else {
1180            let stop_reason = self.handle_max_tokens().await?;
1181            top_up_cancelled_tool_results(messages);
1182            renderer.finish_agent(&context, Some(&stop_reason));
1183            return Ok(TurnOutcome {
1184                stop_reason,
1185                usage: Usage::new(0, 0),
1186                request_count: 0,
1187            });
1188        };
1189
1190        let mut usage_total = Usage::new(0, 0);
1191        let mut request_count: u64 = 0;
1192        while tokens_rem.remaining_tokens()
1193            > self
1194                .thinking()
1195                .await
1196                .map(|thinking| thinking.num_tokens())
1197                .unwrap_or(0)
1198        {
1199            let retry_policy = retry::ApiRetryPolicy::default();
1200            let retry_backoff = retry_policy.backoff();
1201            let mut retry_count = 0usize;
1202            let step = loop {
1203                let step = self
1204                    .step_default_turn_streaming(
1205                        client,
1206                        messages,
1207                        &mut tokens_rem,
1208                        renderer,
1209                        &context,
1210                    )
1211                    .await;
1212                let Some(delay) = (match &step {
1213                    ControlFlow::Break(Err(err)) => {
1214                        retry_policy.retry_delay(&retry_backoff, retry_count, err)
1215                    }
1216                    _ => None,
1217                }) else {
1218                    break step;
1219                };
1220                retry_count += 1;
1221                renderer.print_info(
1222                    &context,
1223                    &format!(
1224                        "Transient API failure; retrying in {} (retry {}/{})",
1225                        retry::format_delay(delay),
1226                        retry_count,
1227                        retry_policy.max_retries(),
1228                    ),
1229                );
1230                tokio::time::sleep(delay).await;
1231            };
1232            match step {
1233                ControlFlow::Continue(step) => {
1234                    usage_total = usage_total + step.usage;
1235                    request_count = request_count.saturating_add(step.request_count);
1236                    if self
1237                        .tool_cancellation_pending
1238                        .swap(false, Ordering::Relaxed)
1239                    {
1240                        let stop_reason = StopReason::EndTurn;
1241                        top_up_cancelled_tool_results(messages);
1242                        renderer.finish_agent(&context, Some(&stop_reason));
1243                        return Ok(TurnOutcome {
1244                            stop_reason,
1245                            usage: usage_total,
1246                            request_count,
1247                        });
1248                    }
1249                }
1250                ControlFlow::Break(res) => match res {
1251                    Ok(mut outcome) => {
1252                        outcome.usage = outcome.usage + usage_total;
1253                        outcome.request_count = outcome.request_count.saturating_add(request_count);
1254                        top_up_cancelled_tool_results(messages);
1255                        renderer.finish_agent(&context, Some(&outcome.stop_reason));
1256                        return Ok(outcome);
1257                    }
1258                    Err(err) => {
1259                        renderer.print_error(
1260                            &context,
1261                            &format!("API error (conversation preserved): {err}"),
1262                        );
1263                        top_up_cancelled_tool_results(messages);
1264                        let stop_reason = StopReason::EndTurn;
1265                        renderer.finish_agent(&context, Some(&stop_reason));
1266                        return Ok(TurnOutcome {
1267                            stop_reason,
1268                            usage: usage_total,
1269                            request_count,
1270                        });
1271                    }
1272                },
1273            }
1274        }
1275
1276        let stop_reason = self.handle_max_tokens().await?;
1277        top_up_cancelled_tool_results(messages);
1278        renderer.finish_agent(&context, Some(&stop_reason));
1279        Ok(TurnOutcome {
1280            stop_reason,
1281            usage: usage_total,
1282            request_count,
1283        })
1284    }
1285
1286    async fn max_tokens(&self) -> u32 {
1287        self.config.max_tokens()
1288    }
1289
1290    async fn model(&self) -> Model {
1291        self.config.model()
1292    }
1293
1294    async fn metadata(&self) -> Option<Metadata> {
1295        self.config.template.metadata.clone()
1296    }
1297
1298    async fn stop_sequences(&self) -> Option<Vec<String>> {
1299        let sequences = self.config.stop_sequences();
1300        if sequences.is_empty() {
1301            None
1302        } else {
1303            Some(sequences.to_vec())
1304        }
1305    }
1306
1307    async fn system(&self) -> Option<SystemPrompt> {
1308        let prompt = self.config.template.system.as_ref()?;
1309        Some(prompt.clone())
1310    }
1311
1312    fn caching_enabled(&self) -> bool {
1313        self.config.caching_enabled
1314    }
1315
1316    async fn temperature(&self) -> Option<f32> {
1317        self.config.template.temperature
1318    }
1319
1320    async fn thinking(&self) -> Option<ThinkingConfig> {
1321        self.config.template.thinking
1322    }
1323
1324    async fn tool_choice(&self) -> Option<ToolChoice> {
1325        self.config.template.tool_choice.clone()
1326    }
1327
1328    async fn tools(&self) -> Vec<Arc<dyn Tool<Self>>> {
1329        let mut tools = if self.tool_scope.exposes_standard_tools() {
1330            self.tools.clone()
1331        } else {
1332            Vec::new()
1333        };
1334        if self.memory_source().is_some() {
1335            tools.push(Arc::new(AskAnExpertTool) as Arc<dyn Tool<Self>>);
1336        }
1337        tools
1338    }
1339
1340    async fn top_k(&self) -> Option<u32> {
1341        self.config.template.top_k
1342    }
1343
1344    async fn top_p(&self) -> Option<f32> {
1345        self.config.template.top_p
1346    }
1347
1348    async fn filesystem(&self) -> Option<&dyn FileSystem> {
1349        Some(&self.filesystem)
1350    }
1351
1352    async fn hook_message(&self, resp: &Message) -> Result<(), Error> {
1353        if let Some(session) = self.session.as_ref() {
1354            session
1355                .log_api_response(resp)
1356                .map_err(|err| Error::unknown(format!("failed to log API response: {err}")))?;
1357        }
1358        let totals = self.record_token_usage(resp.usage);
1359        let rates = self.token_rates();
1360        let cost_suffix = match rates {
1361            Some(rates) => {
1362                let turn_cost = compute_cost_micro_cents(resp.usage, rates);
1363                format!(
1364                    " turn={} total={}",
1365                    format_cost(turn_cost),
1366                    format_cost(totals.cost_micro_cents)
1367                )
1368            }
1369            None => String::new(),
1370        };
1371        println!(
1372            "[tokens: input={} cached_input={} output={}{}]",
1373            totals.input, totals.cached_input, totals.output, cost_suffix
1374        );
1375        println!("[usage: {:?}]", resp.usage);
1376        Ok(())
1377    }
1378
1379    async fn hook_message_create_params(
1380        &self,
1381        req: &claudius::MessageCreateParams,
1382    ) -> Result<(), Error> {
1383        if let Some(session) = self.session.as_ref() {
1384            session
1385                .log_api_request(req)
1386                .map_err(|err| Error::unknown(format!("failed to log API request: {err}")))?;
1387        }
1388        Ok(())
1389    }
1390
1391    async fn text_editor(&self, tool_use: ToolUseBlock) -> Result<String, std::io::Error> {
1392        self.run_text_editor_tool(tool_use, None).await
1393    }
1394
1395    async fn bash(&self, command: &str, restart: bool) -> Result<String, std::io::Error> {
1396        self.run_bash_command(command, restart).await
1397    }
1398
1399    async fn handle_tool_use(
1400        &mut self,
1401        client: &Anthropic,
1402        resp: &Message,
1403    ) -> ControlFlow<Result<StopReason, Error>, Vec<ContentBlock>> {
1404        let requested_tools = collect_requested_tool_calls(self, resp).await;
1405        let mut tool_results = Vec::new();
1406        for (index, requested) in requested_tools.iter().enumerate() {
1407            let result = match requested.tool.as_ref() {
1408                Some(tool) => {
1409                    let callback = tool.callback();
1410                    let tool_use = requested.tool_use.clone();
1411                    let this = &*self;
1412                    let intermediate = callback.compute_tool_result(client, this, &tool_use).await;
1413                    match callback
1414                        .apply_tool_result(client, self, &tool_use, intermediate)
1415                        .await
1416                    {
1417                        ControlFlow::Continue(result) => result,
1418                        ControlFlow::Break(err) => return ControlFlow::Break(Err(err)),
1419                    }
1420                }
1421                None => missing_tool_result(&requested.tool_use),
1422            };
1423            let cancelled = tool_result_is_user_cancelled(&result);
1424            push_tool_result(&mut tool_results, None, result);
1425            if cancelled {
1426                self.tool_cancellation_pending
1427                    .store(true, Ordering::Relaxed);
1428                cancel_remaining_tool_calls(
1429                    &mut tool_results,
1430                    requested_tools.iter().skip(index + 1),
1431                );
1432                break;
1433            }
1434        }
1435        ControlFlow::Continue(tool_results)
1436    }
1437
1438    async fn handle_tool_use_streaming(
1439        &mut self,
1440        client: &Anthropic,
1441        resp: &Message,
1442        renderer: &mut dyn Renderer,
1443        context: &AgentStreamContext,
1444    ) -> ControlFlow<Result<StopReason, Error>, Vec<ContentBlock>> {
1445        let requested_tools = collect_requested_tool_calls(self, resp).await;
1446        let mut tool_results = Vec::new();
1447        for (index, requested) in requested_tools.iter().enumerate() {
1448            let tool_context = context.child(format!("tool:{}", requested.tool_use.name));
1449            let result = match requested.tool.as_ref() {
1450                Some(tool) => {
1451                    let callback = tool.callback();
1452                    let this = &*self;
1453                    let intermediate = callback
1454                        .compute_tool_result_streaming(
1455                            client,
1456                            this,
1457                            &requested.tool_use,
1458                            renderer,
1459                            &tool_context,
1460                        )
1461                        .await;
1462                    let render_result = should_render_tool_result(intermediate.as_ref());
1463                    match callback
1464                        .apply_tool_result(client, self, &requested.tool_use, intermediate)
1465                        .await
1466                    {
1467                        ControlFlow::Continue(result) => {
1468                            let cancelled = tool_result_is_user_cancelled(&result);
1469                            if render_result {
1470                                push_tool_result(
1471                                    &mut tool_results,
1472                                    Some((renderer, &tool_context as &dyn StreamContext)),
1473                                    result,
1474                                );
1475                            } else {
1476                                push_tool_result(&mut tool_results, None, result);
1477                            }
1478                            if cancelled {
1479                                self.tool_cancellation_pending
1480                                    .store(true, Ordering::Relaxed);
1481                                cancel_remaining_tool_calls_streaming(
1482                                    &mut tool_results,
1483                                    renderer,
1484                                    context,
1485                                    requested_tools.iter().skip(index + 1),
1486                                );
1487                                break;
1488                            }
1489                            continue;
1490                        }
1491                        ControlFlow::Break(err) => return ControlFlow::Break(Err(err)),
1492                    }
1493                }
1494                None => missing_tool_result(&requested.tool_use),
1495            };
1496            let cancelled = tool_result_is_user_cancelled(&result);
1497            push_tool_result(&mut tool_results, Some((renderer, &tool_context)), result);
1498            if cancelled {
1499                self.tool_cancellation_pending
1500                    .store(true, Ordering::Relaxed);
1501                cancel_remaining_tool_calls_streaming(
1502                    &mut tool_results,
1503                    renderer,
1504                    context,
1505                    requested_tools.iter().skip(index + 1),
1506                );
1507                break;
1508            }
1509        }
1510        ControlFlow::Continue(tool_results)
1511    }
1512}
1513
1514impl ChatAgent for SidAgent {
1515    fn config(&self) -> &ChatConfig {
1516        &self.config
1517    }
1518
1519    fn config_mut(&mut self) -> &mut ChatConfig {
1520        &mut self.config
1521    }
1522}
1523
1524impl SidAgent {
1525    fn record_token_usage(&self, usage: Usage) -> TokenUsageTotals {
1526        let rates = self.token_rates();
1527        let mut totals = self
1528            .token_usage_totals
1529            .lock()
1530            .expect("token usage totals lock poisoned");
1531        totals.add(usage, rates);
1532        *totals
1533    }
1534
1535    fn token_rates(&self) -> Option<TokenRates> {
1536        match self.config.model() {
1537            Model::Known(km) => Some(km.token_rates()),
1538            Model::Custom(_) => None,
1539        }
1540    }
1541}
1542
1543struct RequestedToolCall {
1544    tool_use: ToolUseBlock,
1545    tool: Option<Arc<dyn Tool<SidAgent>>>,
1546}
1547
1548async fn collect_requested_tool_calls(agent: &SidAgent, resp: &Message) -> Vec<RequestedToolCall> {
1549    let tools = agent.tools().await;
1550    resp.content
1551        .iter()
1552        .filter_map(|block| {
1553            let ContentBlock::ToolUse(tool_use) = block else {
1554                return None;
1555            };
1556            let tool = tools
1557                .iter()
1558                .find(|tool| tool.name() == tool_use.name)
1559                .cloned();
1560            Some(RequestedToolCall {
1561                tool_use: tool_use.clone(),
1562                tool,
1563            })
1564        })
1565        .collect()
1566}
1567
1568fn top_up_cancelled_tool_results(messages: &mut Vec<MessageParam>) -> usize {
1569    let mut cancelled = 0;
1570    let mut index = 0;
1571    while index < messages.len() {
1572        let tool_use_ids = assistant_tool_use_ids(&messages[index]);
1573        if tool_use_ids.is_empty() {
1574            index += 1;
1575            continue;
1576        }
1577
1578        let next_index = index + 1;
1579        if next_index < messages.len()
1580            && messages[next_index].role == MessageRole::User
1581            && user_message_has_required_tool_result(&messages[next_index], &tool_use_ids)
1582        {
1583            cancelled +=
1584                top_up_user_tool_results(&mut messages[next_index], tool_use_ids.as_slice());
1585        } else {
1586            let blocks = tool_use_ids
1587                .iter()
1588                .map(|tool_use_id| user_cancelled_tool_result_block(tool_use_id).into())
1589                .collect::<Vec<ContentBlock>>();
1590            cancelled += blocks.len();
1591            messages.insert(
1592                next_index,
1593                MessageParam::new(MessageParamContent::Array(blocks), MessageRole::User),
1594            );
1595        }
1596        index += 2;
1597    }
1598    cancelled
1599}
1600
1601fn assistant_tool_use_ids(message: &MessageParam) -> Vec<String> {
1602    if message.role != MessageRole::Assistant {
1603        return Vec::new();
1604    }
1605    let MessageParamContent::Array(blocks) = &message.content else {
1606        return Vec::new();
1607    };
1608
1609    let mut seen = BTreeSet::new();
1610    let mut ids = Vec::new();
1611    for block in blocks {
1612        let Some(tool_use) = block.as_tool_use() else {
1613            continue;
1614        };
1615        if seen.insert(tool_use.id.clone()) {
1616            ids.push(tool_use.id.clone());
1617        }
1618    }
1619    ids
1620}
1621
1622fn user_message_has_required_tool_result(message: &MessageParam, tool_use_ids: &[String]) -> bool {
1623    let required = tool_use_ids.iter().collect::<BTreeSet<_>>();
1624    let MessageParamContent::Array(blocks) = &message.content else {
1625        return false;
1626    };
1627    blocks.iter().any(|block| {
1628        block
1629            .as_tool_result()
1630            .is_some_and(|tool_result| required.contains(&tool_result.tool_use_id))
1631    })
1632}
1633
1634fn top_up_user_tool_results(message: &mut MessageParam, tool_use_ids: &[String]) -> usize {
1635    let required = tool_use_ids.iter().cloned().collect::<BTreeSet<_>>();
1636    let content = std::mem::replace(&mut message.content, MessageParamContent::Array(Vec::new()));
1637    let mut existing_results = BTreeMap::new();
1638    let mut tail = Vec::new();
1639
1640    match content {
1641        MessageParamContent::String(text) => {
1642            tail.push(ContentBlock::Text(TextBlock::new(text)));
1643        }
1644        MessageParamContent::Array(blocks) => {
1645            for block in blocks {
1646                if let ContentBlock::ToolResult(tool_result) = &block
1647                    && required.contains(&tool_result.tool_use_id)
1648                    && !existing_results.contains_key(&tool_result.tool_use_id)
1649                {
1650                    existing_results.insert(tool_result.tool_use_id.clone(), block);
1651                    continue;
1652                }
1653                tail.push(block);
1654            }
1655        }
1656    }
1657
1658    let mut cancelled = 0;
1659    let mut blocks = Vec::with_capacity(tool_use_ids.len() + tail.len());
1660    for tool_use_id in tool_use_ids {
1661        if let Some(block) = existing_results.remove(tool_use_id) {
1662            blocks.push(block);
1663        } else {
1664            blocks.push(user_cancelled_tool_result_block(tool_use_id).into());
1665            cancelled += 1;
1666        }
1667    }
1668    blocks.extend(tail);
1669    message.content = MessageParamContent::Array(blocks);
1670    cancelled
1671}
1672
1673fn missing_tool_result(tool_use: &ToolUseBlock) -> Result<ToolResultBlock, ToolResultBlock> {
1674    Err(ToolResultBlock::new(tool_use.id.clone())
1675        .with_string_content(format!("{} not found", tool_use.name))
1676        .with_error(true))
1677}
1678
1679fn cancel_remaining_tool_calls<'a>(
1680    tool_results: &mut Vec<ContentBlock>,
1681    remaining: impl Iterator<Item = &'a RequestedToolCall>,
1682) {
1683    for requested in remaining {
1684        let result = user_cancelled_tool_result(&requested.tool_use.id);
1685        push_tool_result(tool_results, None, result);
1686    }
1687}
1688
1689fn cancel_remaining_tool_calls_streaming<'a>(
1690    tool_results: &mut Vec<ContentBlock>,
1691    renderer: &mut dyn Renderer,
1692    context: &AgentStreamContext,
1693    remaining: impl Iterator<Item = &'a RequestedToolCall>,
1694) {
1695    for requested in remaining {
1696        let result = user_cancelled_tool_result(&requested.tool_use.id);
1697        let tool_context = context.child(format!("tool:{}", requested.tool_use.name));
1698        push_tool_result(tool_results, Some((renderer, &tool_context)), result);
1699    }
1700}
1701
1702fn user_cancelled_tool_result(tool_use_id: &str) -> Result<ToolResultBlock, ToolResultBlock> {
1703    Err(user_cancelled_tool_result_block(tool_use_id))
1704}
1705
1706fn user_cancelled_tool_result_block(tool_use_id: &str) -> ToolResultBlock {
1707    ToolResultBlock::new(tool_use_id.to_string())
1708        .with_string_content(USER_CANCELLED_ACTION.to_string())
1709        .with_error(true)
1710}
1711
1712fn tool_result_is_user_cancelled(result: &Result<ToolResultBlock, ToolResultBlock>) -> bool {
1713    let block = match result {
1714        Ok(block) | Err(block) => block,
1715    };
1716    block.is_error.unwrap_or(false)
1717        && matches!(
1718            block.content.as_ref(),
1719            Some(ToolResultBlockContent::String(text)) if text == USER_CANCELLED_ACTION
1720        )
1721}
1722
1723fn push_tool_result(
1724    tool_results: &mut Vec<ContentBlock>,
1725    renderer: Option<(&mut dyn Renderer, &dyn StreamContext)>,
1726    result: Result<ToolResultBlock, ToolResultBlock>,
1727) {
1728    let block = match result {
1729        Ok(block) => block,
1730        Err(block) => block.with_error(true),
1731    };
1732    if let Some((renderer, context)) = renderer {
1733        render_tool_result_block(renderer, context, &block);
1734    }
1735    tool_results.push(block.into());
1736}
1737
1738fn render_tool_result_block(
1739    renderer: &mut dyn Renderer,
1740    context: &dyn StreamContext,
1741    block: &ToolResultBlock,
1742) {
1743    renderer.start_tool_result(context, &block.tool_use_id, block.is_error.unwrap_or(false));
1744    if let Some(content) = &block.content {
1745        render_tool_result_content(renderer, context, content);
1746    }
1747    renderer.finish_tool_result(context);
1748}
1749
1750fn render_tool_result_content(
1751    renderer: &mut dyn Renderer,
1752    context: &dyn StreamContext,
1753    content: &ToolResultBlockContent,
1754) {
1755    match content {
1756        ToolResultBlockContent::String(text) => renderer.print_tool_result_text(context, text),
1757        ToolResultBlockContent::Array(items) => {
1758            for (idx, item) in items.iter().enumerate() {
1759                if idx > 0 {
1760                    renderer.print_tool_result_text(context, "\n");
1761                }
1762                match item {
1763                    Content::Text(text) => renderer.print_tool_result_text(context, &text.text),
1764                    Content::Image(_) => renderer.print_tool_result_text(context, "[image]"),
1765                }
1766            }
1767        }
1768    }
1769}
1770
1771#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1772struct TokenUsageTotals {
1773    input: u64,
1774    cached_input: u64,
1775    output: u64,
1776    cost_micro_cents: u64,
1777}
1778
1779impl TokenUsageTotals {
1780    fn add(&mut self, usage: Usage, rates: Option<TokenRates>) {
1781        self.input = self.input.saturating_add(tokens_to_u64(usage.input_tokens));
1782        self.cached_input = self
1783            .cached_input
1784            .saturating_add(optional_tokens_to_u64(usage.cache_read_input_tokens));
1785        self.output = self
1786            .output
1787            .saturating_add(tokens_to_u64(usage.output_tokens));
1788        if let Some(rates) = rates {
1789            self.cost_micro_cents = self
1790                .cost_micro_cents
1791                .saturating_add(compute_cost_micro_cents(usage, rates));
1792        }
1793    }
1794}
1795
1796/// Compute the cost in micro-cents for a single Usage at the given TokenRates.
1797fn compute_cost_micro_cents(usage: Usage, rates: TokenRates) -> u64 {
1798    let input = tokens_to_u64(usage.input_tokens).saturating_mul(rates.input);
1799    let output = tokens_to_u64(usage.output_tokens).saturating_mul(rates.output);
1800    let cache_creation = optional_tokens_to_u64(usage.cache_creation_input_tokens)
1801        .saturating_mul(rates.cache_creation);
1802    let cache_read =
1803        optional_tokens_to_u64(usage.cache_read_input_tokens).saturating_mul(rates.cache_read);
1804    input
1805        .saturating_add(output)
1806        .saturating_add(cache_creation)
1807        .saturating_add(cache_read)
1808}
1809
1810/// Format a cost in micro-cents as a human-readable dollar string.
1811///
1812/// Micro-cents are 1/1,000,000 of a cent, so 100,000,000 micro-cents = $1.00.
1813fn format_cost(micro_cents: u64) -> String {
1814    // Convert to cents (integer division, then show fractional cents).
1815    // micro_cents / 100_000_000 = dollars
1816    let dollars = micro_cents / 100_000_000;
1817    let remainder = micro_cents % 100_000_000;
1818    // Convert remainder to fractional dollars with 4 decimal places.
1819    // remainder / 10_000 gives ten-thousandths of a dollar.
1820    let frac = remainder / 10_000;
1821    format!("${}.{:04}", dollars, frac)
1822}
1823
1824fn tokens_to_u64(tokens: i32) -> u64 {
1825    tokens.max(0) as u64
1826}
1827
1828fn optional_tokens_to_u64(tokens: Option<i32>) -> u64 {
1829    tokens.map(tokens_to_u64).unwrap_or(0)
1830}
1831
1832#[derive(Clone, Debug, Default)]
1833struct BuiltinToolBindings {
1834    bash: Option<BuiltinBashBinding>,
1835    edit: Option<RcToolBinding>,
1836}
1837
1838#[derive(Clone, Debug)]
1839struct BuiltinBashBinding {
1840    enabled: SwitchPosition,
1841}
1842
1843#[derive(Clone, Debug)]
1844struct RcToolBinding {
1845    service_name: String,
1846    canonical_id: String,
1847    enabled: SwitchPosition,
1848    confirm_preview: bool,
1849    executable_path: Path<'static>,
1850    timeout: Option<Duration>,
1851}
1852
1853struct BuiltTools {
1854    tools: Vec<Arc<dyn Tool<SidAgent>>>,
1855    builtin_bindings: BuiltinToolBindings,
1856}
1857
1858#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1859enum BuiltinToolKind {
1860    Bash,
1861    Edit,
1862}
1863
1864impl BuiltinToolKind {
1865    fn from_canonical_id(canonical_id: &str) -> Option<Self> {
1866        match canonical_id {
1867            "bash" => Some(Self::Bash),
1868            "edit" => Some(Self::Edit),
1869            _ => None,
1870        }
1871    }
1872
1873    fn tool(self) -> Arc<dyn Tool<SidAgent>> {
1874        match self {
1875            Self::Bash => Arc::new(SidBashTool::new()) as Arc<dyn Tool<SidAgent>>,
1876            Self::Edit => Arc::new(SidTextEditorTool::new()) as Arc<dyn Tool<SidAgent>>,
1877        }
1878    }
1879}
1880
1881#[derive(Clone, Debug)]
1882struct SidBashTool {
1883    param: ToolBash20250124,
1884}
1885
1886impl SidBashTool {
1887    fn new() -> Self {
1888        Self {
1889            param: ToolBash20250124::new(),
1890        }
1891    }
1892}
1893
1894impl Tool<SidAgent> for SidBashTool {
1895    fn name(&self) -> String {
1896        self.param.name.clone()
1897    }
1898
1899    fn callback(&self) -> Box<dyn ToolCallback<SidAgent> + '_> {
1900        Box::new(SidBashCallback)
1901    }
1902
1903    fn to_param(&self) -> ToolUnionParam {
1904        ToolUnionParam::Bash20250124(self.param.clone())
1905    }
1906}
1907
1908struct SidBashCallback;
1909
1910impl SidBashCallback {
1911    async fn compute(
1912        agent: &SidAgent,
1913        tool_use: &ToolUseBlock,
1914        renderer: Option<&mut dyn Renderer>,
1915    ) -> ToolResult {
1916        #[derive(serde::Deserialize)]
1917        struct BashInput {
1918            command: String,
1919            #[serde(default)]
1920            restart: bool,
1921        }
1922
1923        let bash: BashInput = match serde_json::from_value(tool_use.input.clone()) {
1924            Ok(input) => input,
1925            Err(err) => return tool_error_result(&tool_use.id, err.to_string()),
1926        };
1927
1928        match agent
1929            .run_bash_command_with_renderer(&bash.command, bash.restart, renderer)
1930            .await
1931        {
1932            Ok(output) => tool_success_result(&tool_use.id, output),
1933            Err(err) => tool_error_result(&tool_use.id, err.to_string()),
1934        }
1935    }
1936}
1937
1938#[async_trait::async_trait]
1939impl ToolCallback<SidAgent> for SidBashCallback {
1940    async fn compute_tool_result(
1941        &self,
1942        _client: &Anthropic,
1943        agent: &SidAgent,
1944        tool_use: &ToolUseBlock,
1945    ) -> Box<dyn IntermediateToolResult> {
1946        Box::new(Self::compute(agent, tool_use, None).await)
1947    }
1948
1949    async fn compute_tool_result_streaming(
1950        &self,
1951        _client: &Anthropic,
1952        agent: &SidAgent,
1953        tool_use: &ToolUseBlock,
1954        renderer: &mut dyn Renderer,
1955        _context: &AgentStreamContext,
1956    ) -> Box<dyn IntermediateToolResult> {
1957        Box::new(Self::compute(agent, tool_use, Some(renderer)).await)
1958    }
1959
1960    async fn apply_tool_result(
1961        &self,
1962        _client: &Anthropic,
1963        _agent: &mut SidAgent,
1964        _tool_use: &ToolUseBlock,
1965        intermediate: Box<dyn IntermediateToolResult>,
1966    ) -> ToolResult {
1967        apply_computed_tool_result(intermediate)
1968    }
1969}
1970
1971#[derive(Clone, Debug)]
1972struct SidTextEditorTool {
1973    param: ToolTextEditor20250728,
1974}
1975
1976impl SidTextEditorTool {
1977    fn new() -> Self {
1978        Self {
1979            param: ToolTextEditor20250728::new(),
1980        }
1981    }
1982}
1983
1984impl Tool<SidAgent> for SidTextEditorTool {
1985    fn name(&self) -> String {
1986        self.param.name.clone()
1987    }
1988
1989    fn callback(&self) -> Box<dyn ToolCallback<SidAgent> + '_> {
1990        Box::new(SidTextEditorCallback)
1991    }
1992
1993    fn to_param(&self) -> ToolUnionParam {
1994        ToolUnionParam::TextEditor20250728(self.param.clone())
1995    }
1996}
1997
1998struct SidTextEditorCallback;
1999
2000impl SidTextEditorCallback {
2001    async fn compute(
2002        agent: &SidAgent,
2003        tool_use: &ToolUseBlock,
2004        renderer: Option<&mut dyn Renderer>,
2005    ) -> ToolResult {
2006        match agent.run_text_editor_tool(tool_use.clone(), renderer).await {
2007            Ok(output) => tool_success_result(&tool_use.id, output),
2008            Err(err) => tool_error_result(&tool_use.id, err.to_string()),
2009        }
2010    }
2011}
2012
2013#[async_trait::async_trait]
2014impl ToolCallback<SidAgent> for SidTextEditorCallback {
2015    async fn compute_tool_result(
2016        &self,
2017        _client: &Anthropic,
2018        agent: &SidAgent,
2019        tool_use: &ToolUseBlock,
2020    ) -> Box<dyn IntermediateToolResult> {
2021        Box::new(Self::compute(agent, tool_use, None).await)
2022    }
2023
2024    async fn compute_tool_result_streaming(
2025        &self,
2026        _client: &Anthropic,
2027        agent: &SidAgent,
2028        tool_use: &ToolUseBlock,
2029        renderer: &mut dyn Renderer,
2030        _context: &AgentStreamContext,
2031    ) -> Box<dyn IntermediateToolResult> {
2032        Box::new(Self::compute(agent, tool_use, Some(renderer)).await)
2033    }
2034
2035    async fn apply_tool_result(
2036        &self,
2037        _client: &Anthropic,
2038        _agent: &mut SidAgent,
2039        _tool_use: &ToolUseBlock,
2040        intermediate: Box<dyn IntermediateToolResult>,
2041    ) -> ToolResult {
2042        apply_computed_tool_result(intermediate)
2043    }
2044}
2045
2046fn apply_computed_tool_result(intermediate: Box<dyn IntermediateToolResult>) -> ToolResult {
2047    if let Some(intermediate) = intermediate.as_any().downcast_ref::<ToolResult>() {
2048        return intermediate.clone();
2049    }
2050    if let Some(intermediate) = intermediate.as_any().downcast_ref::<ComputedToolResult>() {
2051        return intermediate.result.clone();
2052    }
2053    ControlFlow::Break(Error::unknown(
2054        "intermediate tool result fails to deserialize",
2055    ))
2056}
2057
2058#[derive(Clone, Debug)]
2059struct ComputedToolResult {
2060    result: ToolResult,
2061    render_result: bool,
2062}
2063
2064impl ComputedToolResult {
2065    fn new(result: ToolResult) -> Self {
2066        Self {
2067            result,
2068            render_result: true,
2069        }
2070    }
2071
2072    fn with_render_result(result: ToolResult, render_result: bool) -> Self {
2073        Self {
2074            result,
2075            render_result,
2076        }
2077    }
2078}
2079
2080impl IntermediateToolResult for ComputedToolResult {
2081    fn as_any(&self) -> &dyn std::any::Any {
2082        self
2083    }
2084}
2085
2086fn should_render_tool_result(intermediate: &dyn IntermediateToolResult) -> bool {
2087    intermediate
2088        .as_any()
2089        .downcast_ref::<ComputedToolResult>()
2090        .map(|intermediate| intermediate.render_result)
2091        .unwrap_or(true)
2092}
2093
2094#[derive(Clone, Debug)]
2095struct AskAnExpertTool;
2096
2097impl Tool<SidAgent> for AskAnExpertTool {
2098    fn name(&self) -> String {
2099        ASK_AN_EXPERT_TOOL_NAME.to_string()
2100    }
2101
2102    fn callback(&self) -> Box<dyn ToolCallback<SidAgent> + '_> {
2103        Box::new(AskAnExpertCallback)
2104    }
2105
2106    fn to_param(&self) -> ToolUnionParam {
2107        ToolUnionParam::CustomTool(
2108            ToolParam::new(
2109                ASK_AN_EXPERT_TOOL_NAME.to_string(),
2110                serde_json::json!({
2111                    "type": "object",
2112                    "properties": {
2113                        "question": {
2114                            "type": "string",
2115                            "description": "The direct question to ask the previous summary writer."
2116                        }
2117                    },
2118                    "required": ["question"],
2119                    "additionalProperties": false
2120                }),
2121            )
2122            .with_description("Ask a direct question of the person who wrote the summary for this compacted session. They are the previous owner of the project and only available for consultation by contextual memory.".to_string()),
2123        )
2124    }
2125}
2126
2127struct AskAnExpertCallback;
2128
2129#[async_trait::async_trait]
2130impl ToolCallback<SidAgent> for AskAnExpertCallback {
2131    async fn compute_tool_result(
2132        &self,
2133        client: &Anthropic,
2134        agent: &SidAgent,
2135        tool_use: &ToolUseBlock,
2136    ) -> Box<dyn IntermediateToolResult> {
2137        Box::new(ask_an_expert_result(client, agent, tool_use).await)
2138    }
2139
2140    async fn compute_tool_result_streaming(
2141        &self,
2142        client: &Anthropic,
2143        agent: &SidAgent,
2144        tool_use: &ToolUseBlock,
2145        renderer: &mut dyn Renderer,
2146        context: &AgentStreamContext,
2147    ) -> Box<dyn IntermediateToolResult> {
2148        Box::new(ask_an_expert_result_streaming(client, agent, tool_use, renderer, context).await)
2149    }
2150
2151    async fn apply_tool_result(
2152        &self,
2153        _client: &Anthropic,
2154        _agent: &mut SidAgent,
2155        _tool_use: &ToolUseBlock,
2156        intermediate: Box<dyn IntermediateToolResult>,
2157    ) -> ToolResult {
2158        apply_computed_tool_result(intermediate)
2159    }
2160}
2161
2162async fn ask_an_expert_result(
2163    client: &Anthropic,
2164    agent: &SidAgent,
2165    tool_use: &ToolUseBlock,
2166) -> ToolResult {
2167    let question = match ask_an_expert_question(tool_use) {
2168        Ok(question) => question,
2169        Err(result) => return result,
2170    };
2171
2172    match agent.ask_an_expert(client, &question).await {
2173        Ok(output) => tool_success_result(&tool_use.id, output),
2174        Err(err) => tool_error_result(&tool_use.id, err.to_string()),
2175    }
2176}
2177
2178fn ask_an_expert_question(tool_use: &ToolUseBlock) -> Result<String, ToolResult> {
2179    #[derive(serde::Deserialize)]
2180    struct AskAnExpertInput {
2181        question: String,
2182    }
2183
2184    let input: AskAnExpertInput = match serde_json::from_value(tool_use.input.clone()) {
2185        Ok(input) => input,
2186        Err(err) => return Err(tool_error_result(&tool_use.id, err.to_string())),
2187    };
2188    let question = input.question.trim();
2189    if question.is_empty() {
2190        return Err(tool_error_result(
2191            &tool_use.id,
2192            "question must not be empty".to_string(),
2193        ));
2194    }
2195
2196    Ok(question.to_string())
2197}
2198
2199async fn ask_an_expert_result_streaming(
2200    client: &Anthropic,
2201    agent: &SidAgent,
2202    tool_use: &ToolUseBlock,
2203    renderer: &mut dyn Renderer,
2204    context: &AgentStreamContext,
2205) -> ComputedToolResult {
2206    let question = match ask_an_expert_question(tool_use) {
2207        Ok(question) => question,
2208        Err(result) => return ComputedToolResult::new(result),
2209    };
2210    let mut renderer = AskAnExpertStreamRenderer::new(renderer, context, &question);
2211    let result = match agent
2212        .ask_an_expert_with_renderer(client, &question, Some(&mut renderer))
2213        .await
2214    {
2215        Ok(output) => ComputedToolResult::with_render_result(
2216            tool_success_result(&tool_use.id, output),
2217            !renderer.started(),
2218        ),
2219        Err(err) => ComputedToolResult::new(tool_error_result(&tool_use.id, err.to_string())),
2220    };
2221    renderer.finish_if_open(None);
2222    result
2223}
2224
2225struct AskAnExpertStreamRenderer<'a> {
2226    parent: &'a mut dyn Renderer,
2227    context: AgentStreamContext,
2228    question: &'a str,
2229    started: bool,
2230    finished: bool,
2231}
2232
2233impl<'a> AskAnExpertStreamRenderer<'a> {
2234    fn new(
2235        parent: &'a mut dyn Renderer,
2236        parent_context: &AgentStreamContext,
2237        question: &'a str,
2238    ) -> Self {
2239        Self {
2240            parent,
2241            context: parent_context.child("expert"),
2242            question,
2243            started: false,
2244            finished: false,
2245        }
2246    }
2247
2248    fn ensure_started(&mut self) {
2249        if self.started {
2250            return;
2251        }
2252        self.parent.start_agent(&self.context);
2253        self.parent.print_info(&self.context, "question:");
2254        self.parent.print_text(&self.context, self.question);
2255        if !self.question.ends_with('\n') {
2256            self.parent.print_text(&self.context, "\n");
2257        }
2258        self.parent.print_text(&self.context, "\n");
2259        self.started = true;
2260    }
2261
2262    fn finish_if_open(&mut self, stop_reason: Option<&StopReason>) {
2263        if self.started && !self.finished {
2264            self.parent.finish_agent(&self.context, stop_reason);
2265            self.finished = true;
2266        }
2267    }
2268
2269    fn started(&self) -> bool {
2270        self.started
2271    }
2272}
2273
2274impl Renderer for AskAnExpertStreamRenderer<'_> {
2275    fn start_agent(&mut self, _context: &dyn StreamContext) {
2276        self.ensure_started();
2277    }
2278
2279    fn finish_agent(&mut self, _context: &dyn StreamContext, stop_reason: Option<&StopReason>) {
2280        self.finish_if_open(stop_reason);
2281    }
2282
2283    fn print_text(&mut self, _context: &dyn StreamContext, text: &str) {
2284        self.ensure_started();
2285        self.parent.print_text(&self.context, text);
2286    }
2287
2288    fn print_thinking(&mut self, _context: &dyn StreamContext, text: &str) {
2289        self.ensure_started();
2290        self.parent.print_thinking(&self.context, text);
2291    }
2292
2293    fn print_error(&mut self, _context: &dyn StreamContext, error: &str) {
2294        self.ensure_started();
2295        self.parent.print_error(&self.context, error);
2296    }
2297
2298    fn print_info(&mut self, _context: &dyn StreamContext, info: &str) {
2299        self.ensure_started();
2300        self.parent.print_info(&self.context, info);
2301    }
2302
2303    fn start_tool_use(&mut self, _context: &dyn StreamContext, name: &str, id: &str) {
2304        self.ensure_started();
2305        self.parent.start_tool_use(&self.context, name, id);
2306    }
2307
2308    fn print_tool_input(&mut self, _context: &dyn StreamContext, partial_json: &str) {
2309        self.ensure_started();
2310        self.parent.print_tool_input(&self.context, partial_json);
2311    }
2312
2313    fn finish_tool_use(&mut self, _context: &dyn StreamContext) {
2314        self.ensure_started();
2315        self.parent.finish_tool_use(&self.context);
2316    }
2317
2318    fn start_tool_result(
2319        &mut self,
2320        _context: &dyn StreamContext,
2321        tool_use_id: &str,
2322        is_error: bool,
2323    ) {
2324        self.ensure_started();
2325        self.parent
2326            .start_tool_result(&self.context, tool_use_id, is_error);
2327    }
2328
2329    fn print_tool_result_text(&mut self, _context: &dyn StreamContext, text: &str) {
2330        self.ensure_started();
2331        self.parent.print_tool_result_text(&self.context, text);
2332    }
2333
2334    fn finish_tool_result(&mut self, _context: &dyn StreamContext) {
2335        self.ensure_started();
2336        self.parent.finish_tool_result(&self.context);
2337    }
2338
2339    fn finish_response(&mut self, _context: &dyn StreamContext) {
2340        self.ensure_started();
2341        self.parent.finish_response(&self.context);
2342    }
2343
2344    fn print_interrupted(&mut self, _context: &dyn StreamContext) {
2345        self.ensure_started();
2346        self.parent.print_interrupted(&self.context);
2347    }
2348
2349    fn should_interrupt(&self) -> bool {
2350        self.parent.should_interrupt()
2351    }
2352
2353    fn read_operator_line(&mut self, prompt: &str) -> std::io::Result<Option<OperatorLine>> {
2354        self.parent.read_operator_line(prompt)
2355    }
2356}
2357
2358#[derive(Clone, Debug)]
2359struct ExternalTool {
2360    name: String,
2361    canonical_id: String,
2362    enabled: SwitchPosition,
2363    confirm_preview: bool,
2364    executable_path: Path<'static>,
2365    description: String,
2366    input_schema: serde_json::Value,
2367    timeout: Option<Duration>,
2368}
2369
2370impl ExternalTool {
2371    fn from_config(name: String, canonical_id: String, tool: &ToolConfig) -> Self {
2372        let manifest = tool
2373            .manifest
2374            .as_ref()
2375            .expect("external tools require a manifest");
2376        let executable_path = tool
2377            .executable_path
2378            .clone()
2379            .expect("external tools require an executable path");
2380        Self {
2381            name,
2382            canonical_id,
2383            enabled: tool.enabled,
2384            confirm_preview: tool.confirm_preview,
2385            executable_path,
2386            description: manifest.description.clone(),
2387            input_schema: manifest.input_schema.clone(),
2388            timeout: tool.timeout,
2389        }
2390    }
2391}
2392
2393impl Tool<SidAgent> for ExternalTool {
2394    fn name(&self) -> String {
2395        self.name.clone()
2396    }
2397
2398    fn callback(&self) -> Box<dyn ToolCallback<SidAgent> + '_> {
2399        Box::new(ExternalToolCallback { tool: self.clone() })
2400    }
2401
2402    fn to_param(&self) -> ToolUnionParam {
2403        ToolUnionParam::CustomTool(
2404            ToolParam::new(self.name.clone(), self.input_schema.clone())
2405                .with_description(self.description.clone()),
2406        )
2407    }
2408}
2409
2410#[derive(Clone, Debug)]
2411struct ExternalToolCallback {
2412    tool: ExternalTool,
2413}
2414
2415#[async_trait::async_trait]
2416impl ToolCallback<SidAgent> for ExternalToolCallback {
2417    async fn compute_tool_result(
2418        &self,
2419        _client: &Anthropic,
2420        agent: &SidAgent,
2421        tool_use: &ToolUseBlock,
2422    ) -> Box<dyn IntermediateToolResult> {
2423        Box::new(invoke_external_tool(&self.tool, agent, tool_use, None).await)
2424    }
2425
2426    async fn compute_tool_result_streaming(
2427        &self,
2428        _client: &Anthropic,
2429        agent: &SidAgent,
2430        tool_use: &ToolUseBlock,
2431        renderer: &mut dyn Renderer,
2432        _context: &AgentStreamContext,
2433    ) -> Box<dyn IntermediateToolResult> {
2434        Box::new(invoke_external_tool(&self.tool, agent, tool_use, Some(renderer)).await)
2435    }
2436
2437    async fn apply_tool_result(
2438        &self,
2439        _client: &Anthropic,
2440        _agent: &mut SidAgent,
2441        _tool_use: &ToolUseBlock,
2442        intermediate: Box<dyn IntermediateToolResult>,
2443    ) -> ToolResult {
2444        apply_computed_tool_result(intermediate)
2445    }
2446}
2447
2448async fn invoke_external_tool(
2449    tool: &ExternalTool,
2450    agent: &SidAgent,
2451    tool_use: &ToolUseBlock,
2452    renderer: Option<&mut dyn Renderer>,
2453) -> ToolResult {
2454    if tool.enabled == SwitchPosition::No {
2455        return tool_error_result(&tool_use.id, format!("tool '{}' is disabled", tool.name));
2456    }
2457
2458    let input = match tool_use.input.as_object() {
2459        Some(input) => input.clone(),
2460        None => {
2461            return tool_error_result(
2462                &tool_use.id,
2463                format!(
2464                    "tool '{}' protocol error: tool input must be a JSON object",
2465                    tool.name
2466                ),
2467            );
2468        }
2469    };
2470
2471    match tool.enabled {
2472        SwitchPosition::Yes => {}
2473        SwitchPosition::No => unreachable!("disabled tools return before input validation"),
2474        SwitchPosition::Manual => {
2475            let context = agent.tool_runtime_context();
2476            let prepared = match tool_runtime::prepare_rc_tool_invocation(
2477                &tool.name,
2478                &tool.name,
2479                &tool.canonical_id,
2480                &tool.executable_path,
2481                &context,
2482                &tool_use.id,
2483                input,
2484                tool.timeout,
2485            ) {
2486                Ok(prepared) => prepared,
2487                Err(message) => return tool_error_result(&tool_use.id, message),
2488            };
2489            match confirm_manual_prepared_tool_call(
2490                &tool.name,
2491                &tool_use.input,
2492                tool.confirm_preview,
2493                &prepared,
2494                agent.session.as_deref(),
2495                renderer,
2496            )
2497            .await
2498            {
2499                Ok(ManualToolConfirmation::Allow) => {}
2500                Ok(ManualToolConfirmation::Deny) => {
2501                    let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, false);
2502                    return tool_error_result(
2503                        &tool_use.id,
2504                        format!("tool '{}' call denied by operator", tool.name),
2505                    );
2506                }
2507                Ok(ManualToolConfirmation::Cancel) => {
2508                    let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, false);
2509                    return tool_user_cancelled_result(&tool_use.id);
2510                }
2511                Err(err) => {
2512                    let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, true);
2513                    return tool_error_result(&tool_use.id, err);
2514                }
2515            }
2516            return match tool_runtime::run_prepared_rc_tool_text(
2517                &prepared,
2518                &agent.writable_roots,
2519                agent.session.as_deref(),
2520            )
2521            .await
2522            {
2523                Ok(text) => tool_success_result(&tool_use.id, text),
2524                Err(message) => tool_error_result(&tool_use.id, message),
2525            };
2526        }
2527    }
2528
2529    let context = ToolRuntimeContext {
2530        agent_id: &agent.id,
2531        config_root: &agent.config_root,
2532        workspace_root: &agent.workspace_root,
2533        writable_roots: &agent.writable_roots,
2534        session: agent.session.as_deref(),
2535    };
2536    match tool_runtime::invoke_rc_tool_text(
2537        &tool.name,
2538        &tool.name,
2539        &tool.canonical_id,
2540        &tool.executable_path,
2541        &context,
2542        &tool_use.id,
2543        input,
2544        tool.timeout,
2545    )
2546    .await
2547    {
2548        Ok(text) => tool_success_result(&tool_use.id, text),
2549        Err(message) => tool_error_result(&tool_use.id, message),
2550    }
2551}
2552
2553#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2554enum ManualToolConfirmation {
2555    Allow,
2556    Deny,
2557    Cancel,
2558}
2559
2560#[derive(Debug, Eq, PartialEq)]
2561enum ManualToolInput {
2562    Line(String),
2563    Cancel,
2564}
2565
2566/// Prompt the operator to confirm a manual tool call via stdin/stdout.
2567///
2568/// Returns `Allow` when the operator approves, `Deny` when the operator answers
2569/// no, `Cancel` when the prompt is interrupted or reaches EOF, and `Err` on I/O
2570/// failure.
2571async fn confirm_manual_prepared_tool_call(
2572    tool_name: &str,
2573    input: &serde_json::Value,
2574    confirm_preview: bool,
2575    prepared: &tool_runtime::PreparedRcToolInvocation,
2576    session: Option<&SidSession>,
2577    renderer: Option<&mut dyn Renderer>,
2578) -> Result<ManualToolConfirmation, String> {
2579    let preview = if confirm_preview {
2580        tool_runtime::render_rc_tool_confirmation_preview(prepared, session)
2581            .await
2582            .ok()
2583    } else {
2584        None
2585    };
2586    confirm_manual_tool_call(tool_name, input, preview.as_deref(), renderer)
2587}
2588
2589fn confirm_manual_tool_call(
2590    tool_name: &str,
2591    input: &serde_json::Value,
2592    preview: Option<&str>,
2593    mut renderer: Option<&mut dyn Renderer>,
2594) -> Result<ManualToolConfirmation, String> {
2595    let input_display = preview.map(str::to_string).unwrap_or_else(|| {
2596        serde_json::to_string_pretty(input).unwrap_or_else(|_| format!("{input:?}"))
2597    });
2598
2599    loop {
2600        let prompt =
2601            format!("Tool '{tool_name}' is MANUAL.\n{input_display}\nAllow this call? [yes/no]: ");
2602        let input = read_operator_line(&mut renderer, &prompt)?;
2603        let ManualToolInput::Line(buf) = input else {
2604            return Ok(ManualToolConfirmation::Cancel);
2605        };
2606
2607        match parse_tool_confirmation(&buf) {
2608            Some(true) => return Ok(ManualToolConfirmation::Allow),
2609            Some(false) => return Ok(ManualToolConfirmation::Deny),
2610            None => println!("Please answer yes or no."),
2611        }
2612    }
2613}
2614
2615fn read_operator_line(
2616    renderer: &mut Option<&mut dyn Renderer>,
2617    prompt: &str,
2618) -> Result<ManualToolInput, String> {
2619    use std::io::{self, Write};
2620
2621    if let Some(renderer) = renderer.as_mut()
2622        && let Some(line) = (*renderer)
2623            .read_operator_line(prompt)
2624            .map_err(|err| format!("failed to read manual-tool confirmation input: {err}"))?
2625    {
2626        return match line {
2627            OperatorLine::Line(line) => Ok(ManualToolInput::Line(line)),
2628            OperatorLine::Eof | OperatorLine::Interrupted => {
2629                println!();
2630                Ok(ManualToolInput::Cancel)
2631            }
2632        };
2633    }
2634
2635    print!("{prompt}");
2636    io::stdout()
2637        .flush()
2638        .map_err(|err| format!("failed to flush manual-tool confirmation prompt: {err}"))?;
2639
2640    let mut buf = String::new();
2641    match io::stdin().read_line(&mut buf) {
2642        Ok(0) => {
2643            println!();
2644            Ok(ManualToolInput::Cancel)
2645        }
2646        Ok(_) => Ok(ManualToolInput::Line(buf)),
2647        Err(err) if err.kind() == io::ErrorKind::Interrupted => {
2648            println!();
2649            Ok(ManualToolInput::Cancel)
2650        }
2651        Err(err) => Err(format!(
2652            "failed to read manual-tool confirmation input: {err}"
2653        )),
2654    }
2655}
2656
2657fn parse_tool_confirmation(input: &str) -> Option<bool> {
2658    match input.trim().to_ascii_lowercase().as_str() {
2659        "y" | "yes" => Some(true),
2660        "n" | "no" => Some(false),
2661        _ => None,
2662    }
2663}
2664
2665fn render_bash_pty_result(result: BashPtyResult) -> Result<String, std::io::Error> {
2666    let mut rendered = strip_ansi_escapes(&result.output);
2667    if result.status.success() {
2668        if rendered.is_empty() {
2669            rendered.push_str("success\n");
2670        }
2671        Ok(rendered)
2672    } else {
2673        if !rendered.is_empty() && !rendered.ends_with('\n') {
2674            rendered.push('\n');
2675        }
2676        rendered.push_str(&format!("{}\n", result.status));
2677        Err(std::io::Error::other(rendered))
2678    }
2679}
2680
2681fn bash_state_io_error(context: &str, result: &BashPtyResult) -> std::io::Error {
2682    let mut rendered = strip_ansi_escapes(&result.output);
2683    if !rendered.is_empty() && !rendered.ends_with('\n') {
2684        rendered.push('\n');
2685    }
2686    rendered.push_str(&format!("{}", result.status));
2687    std::io::Error::other(format!("{context}: {rendered}"))
2688}
2689
2690/// Strip ANSI escape sequences from terminal output.
2691///
2692/// Removes CSI sequences (`ESC[…X`), OSC sequences (`ESC]…ST`), and simple
2693/// two-character escape pairs (`ESC X`).  This ensures the model never sees
2694/// raw color or cursor-control codes in bash tool output.
2695fn strip_ansi_escapes(input: &str) -> String {
2696    let mut out = String::with_capacity(input.len());
2697    let mut chars = input.chars();
2698    while let Some(ch) = chars.next() {
2699        if ch != '\x1b' {
2700            out.push(ch);
2701            continue;
2702        }
2703        // We saw ESC.  Peek at the next character to decide the sequence type.
2704        match chars.next() {
2705            // CSI sequence: ESC [ <params> <intermediate> <final byte>
2706            Some('[') => {
2707                for c in chars.by_ref() {
2708                    if c.is_ascii_alphabetic() || c == '@' || c == '~' {
2709                        break;
2710                    }
2711                }
2712            }
2713            // OSC sequence: ESC ] … terminated by BEL or ST (ESC \)
2714            Some(']') => {
2715                let mut prev = '\0';
2716                for c in chars.by_ref() {
2717                    if c == '\x07' {
2718                        break;
2719                    }
2720                    if prev == '\x1b' && c == '\\' {
2721                        break;
2722                    }
2723                    prev = c;
2724                }
2725            }
2726            // Two-character escape (e.g. ESC M, ESC 7, ESC 8) — consume and
2727            // discard the second byte.
2728            Some(_) => {}
2729            // Trailing bare ESC at end of input — discard it.
2730            None => {}
2731        }
2732    }
2733    out
2734}
2735
2736#[derive(serde::Deserialize)]
2737struct TranscriptSnapshot {
2738    version: u8,
2739    messages: Vec<MessageParam>,
2740}
2741
2742struct NullRenderer;
2743
2744impl Renderer for NullRenderer {
2745    fn print_text(&mut self, _context: &dyn StreamContext, _text: &str) {}
2746
2747    fn print_thinking(&mut self, _context: &dyn StreamContext, _text: &str) {}
2748
2749    fn print_error(&mut self, _context: &dyn StreamContext, _error: &str) {}
2750
2751    fn print_info(&mut self, _context: &dyn StreamContext, _info: &str) {}
2752
2753    fn start_tool_use(&mut self, _context: &dyn StreamContext, _name: &str, _id: &str) {}
2754
2755    fn print_tool_input(&mut self, _context: &dyn StreamContext, _partial_json: &str) {}
2756
2757    fn finish_tool_use(&mut self, _context: &dyn StreamContext) {}
2758
2759    fn start_tool_result(
2760        &mut self,
2761        _context: &dyn StreamContext,
2762        _tool_use_id: &str,
2763        _is_error: bool,
2764    ) {
2765    }
2766
2767    fn print_tool_result_text(&mut self, _context: &dyn StreamContext, _text: &str) {}
2768
2769    fn finish_tool_result(&mut self, _context: &dyn StreamContext) {}
2770
2771    fn finish_response(&mut self, _context: &dyn StreamContext) {}
2772}
2773
2774/// Deserialize a transcript file into a sequence of message parameters.
2775///
2776/// The file must be a version-1 transcript snapshot written by the session
2777/// runtime.
2778///
2779/// # Errors
2780///
2781/// Returns an error when the file cannot be read, is not valid JSON, or has
2782/// an unsupported transcript version.
2783pub fn load_transcript_messages(path: &std::path::Path) -> Result<Vec<MessageParam>, SError> {
2784    let payload = std::fs::read(path).map_err(|err| {
2785        SError::new("sid-transcript")
2786            .with_code("transcript_read_failed")
2787            .with_message("failed to read transcript")
2788            .with_string_field("path", path.to_string_lossy().as_ref())
2789            .with_string_field("cause", &err.to_string())
2790    })?;
2791    let snapshot: TranscriptSnapshot = serde_json::from_slice(&payload).map_err(|err| {
2792        SError::new("sid-transcript")
2793            .with_code("transcript_parse_failed")
2794            .with_message("failed to parse transcript")
2795            .with_string_field("path", path.to_string_lossy().as_ref())
2796            .with_string_field("cause", &err.to_string())
2797    })?;
2798    if snapshot.version != 1 {
2799        return Err(SError::new("sid-transcript")
2800            .with_code("unsupported_transcript_version")
2801            .with_message("unsupported transcript version")
2802            .with_string_field("path", path.to_string_lossy().as_ref())
2803            .with_string_field("version", &snapshot.version.to_string()));
2804    }
2805    Ok(snapshot.messages)
2806}
2807
2808/// Build a two-message transcript representing a compacted session.
2809///
2810/// The first message is a user message containing the compaction context
2811/// template, and the second is an assistant message carrying the handoff
2812/// `summary`.
2813pub fn compacted_transcript(parent_session_id: &str, summary: &str) -> Vec<MessageParam> {
2814    vec![
2815        MessageParam::user(
2816            COMPACTED_SESSION_CONTEXT_TEMPLATE.replace("{session_id}", parent_session_id),
2817        ),
2818        MessageParam::assistant(summary),
2819    ]
2820}
2821
2822fn tool_success_result(tool_use_id: &str, message: String) -> ToolResult {
2823    ControlFlow::Continue(Ok(
2824        ToolResultBlock::new(tool_use_id.to_string()).with_string_content(message)
2825    ))
2826}
2827
2828fn tool_error_result(tool_use_id: &str, message: String) -> ToolResult {
2829    ControlFlow::Continue(Err(ToolResultBlock::new(tool_use_id.to_string())
2830        .with_string_content(message)
2831        .with_error(true)))
2832}
2833
2834fn tool_user_cancelled_result(tool_use_id: &str) -> ToolResult {
2835    ControlFlow::Continue(user_cancelled_tool_result(tool_use_id))
2836}
2837
2838/// Build the default writable roots for a workspace.
2839///
2840/// Includes the workspace root itself and the system temp directory.  Paths are
2841/// canonicalized so that the sandbox policy matches the kernel-resolved paths
2842/// (e.g. `/var` -> `/private/var` on macOS).
2843fn default_writable_roots(workspace_root: &Path) -> WritableRoots {
2844    let mut roots = WritableRoots::default();
2845    if let Ok(canonical) = std::fs::canonicalize(workspace_root.as_str()) {
2846        if let Some(s) = canonical.to_str() {
2847            roots.push(s.to_string());
2848        }
2849    } else {
2850        roots.push(workspace_root.as_str().to_string());
2851    }
2852    let temp_dir = std::env::temp_dir();
2853    if let Ok(canonical) = std::fs::canonicalize(&temp_dir) {
2854        if let Some(s) = canonical.to_str() {
2855            roots.push(s.to_string());
2856        }
2857    } else if let Some(s) = temp_dir.to_str() {
2858        roots.push(s.to_string());
2859    }
2860    roots
2861}
2862
2863fn append_writable_root(roots: &mut WritableRoots, root: &std::path::Path) {
2864    if let Ok(canonical) = std::fs::canonicalize(root)
2865        && let Some(s) = canonical.to_str()
2866    {
2867        roots.push(s.to_string());
2868        return;
2869    }
2870    if let Some(s) = root.to_str() {
2871        roots.push(s.to_string());
2872    }
2873}
2874
2875fn workspace_has_config(root: &Path) -> bool {
2876    root.join(AGENTS_CONF_FILE).is_file() || root.join(TOOLS_CONF_FILE).is_file()
2877}
2878
2879/// Determine the default agent to use when none is explicitly requested.
2880///
2881/// Prefers the explicit `DEFAULT_AGENT` setting from agents.conf when present.
2882/// Falls back to the first enabled agent, then the first manual agent.
2883fn default_agent_id(config: &Config) -> Result<String, SError> {
2884    if let Some(default) = config.default_agent.as_ref() {
2885        let agent_config = config.agents.get(default).ok_or_else(|| {
2886            SError::new("sid-agent")
2887                .with_code("invalid_default_agent")
2888                .with_message("DEFAULT_AGENT names an undefined agent")
2889                .with_string_field("default_agent", default)
2890        })?;
2891        if !agent_config.enabled.can_be_started() {
2892            return Err(SError::new("sid-agent")
2893                .with_code("disabled_default_agent")
2894                .with_message("DEFAULT_AGENT names a disabled agent")
2895                .with_string_field("default_agent", default)
2896                .with_string_field("enabled", &format!("{:?}", agent_config.enabled)));
2897        }
2898        return Ok(default.clone());
2899    }
2900
2901    if let Some(agent) = config
2902        .agents
2903        .values()
2904        .find(|agent| agent.enabled == SwitchPosition::Yes)
2905    {
2906        return Ok(agent.id.clone());
2907    }
2908    if let Some(agent) = config
2909        .agents
2910        .values()
2911        .find(|agent| agent.enabled == SwitchPosition::Manual)
2912    {
2913        return Ok(agent.id.clone());
2914    }
2915
2916    Err(SError::new("sid-agent")
2917        .with_code("no_startable_agents")
2918        .with_message("workspace config defines no runnable agents"))
2919}
2920
2921fn build_tools(config: &Config, agent_config: &AgentConfig) -> Result<BuiltTools, SError> {
2922    let mut tools = Vec::new();
2923    let mut seen = BTreeSet::new();
2924    let mut builtin_bindings = BuiltinToolBindings::default();
2925    for tool_name in &agent_config.tools {
2926        let tool_config = config.tools.get(tool_name).ok_or_else(|| {
2927            SError::new("sid-agent")
2928                .with_code("unknown_tool")
2929                .with_message("agent references an undefined tool")
2930                .with_string_field("agent", &agent_config.id)
2931                .with_string_field("tool", tool_name)
2932        })?;
2933        if !tool_config.enabled.can_be_started() {
2934            return Err(disabled_tool_error(
2935                &agent_config.id,
2936                tool_name,
2937                tool_config.enabled,
2938            ));
2939        }
2940        let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, tool_name)?;
2941        let Some(builtin_kind) = BuiltinToolKind::from_canonical_id(&canonical_id) else {
2942            let tool = ExternalTool::from_config(
2943                exposed_tool_name(&agent_config.id, tool_name)?,
2944                canonical_id,
2945                tool_config,
2946            );
2947            if seen.insert(tool.name.clone()) {
2948                tools.push(Arc::new(tool) as Arc<dyn Tool<SidAgent>>);
2949            }
2950            continue;
2951        };
2952        let tool = builtin_kind.tool();
2953        let exposed_name = tool.name();
2954        if seen.insert(exposed_name) {
2955            match builtin_kind {
2956                BuiltinToolKind::Bash if builtin_bindings.bash.is_none() => {
2957                    builtin_bindings.bash = Some(BuiltinBashBinding {
2958                        enabled: tool_config.enabled,
2959                    });
2960                }
2961                BuiltinToolKind::Edit if builtin_bindings.edit.is_none() => {
2962                    builtin_bindings.edit = Some(RcToolBinding {
2963                        service_name: tool_name.clone(),
2964                        canonical_id,
2965                        enabled: tool_config.enabled,
2966                        confirm_preview: tool_config.confirm_preview,
2967                        executable_path: tool_config
2968                            .executable_path
2969                            .clone()
2970                            .expect("built-in edit tool requires an executable path"),
2971                        timeout: tool_config.timeout,
2972                    });
2973                }
2974                _ => {}
2975            }
2976            tools.push(tool);
2977        }
2978    }
2979    Ok(BuiltTools {
2980        tools,
2981        builtin_bindings,
2982    })
2983}
2984
2985fn exposed_tool_name(agent: &str, tool_name: &str) -> Result<String, SError> {
2986    if is_valid_anthropic_tool_name(tool_name) {
2987        Ok(tool_name.to_string())
2988    } else {
2989        Err(SError::new("sid-agent")
2990            .with_code("invalid_tool_name")
2991            .with_message("tool name exposed to the model is not legal")
2992            .with_string_field("agent", agent)
2993            .with_string_field("tool", tool_name)
2994            .with_string_field("name", tool_name))
2995    }
2996}
2997
2998fn merged_chat_config(agent_config: &AgentConfig, fallback: Option<&ChatConfig>) -> ChatConfig {
2999    let mut merged = fallback.cloned().unwrap_or_else(ChatConfig::new);
3000    let defaults = ChatConfig::new();
3001    let agent = &agent_config.chat_config;
3002
3003    if agent.template.model != defaults.template.model {
3004        merged.template.model = agent.template.model.clone();
3005    }
3006    if agent.template.system.is_some() {
3007        merged.template.system = agent.template.system.clone();
3008    }
3009    if agent.template.max_tokens != defaults.template.max_tokens {
3010        merged.template.max_tokens = agent.template.max_tokens;
3011    }
3012    if agent.template.temperature != defaults.template.temperature {
3013        merged.template.temperature = agent.template.temperature;
3014    }
3015    if agent.template.top_p != defaults.template.top_p {
3016        merged.template.top_p = agent.template.top_p;
3017    }
3018    if agent.template.top_k != defaults.template.top_k {
3019        merged.template.top_k = agent.template.top_k;
3020    }
3021    if agent.template.stop_sequences != defaults.template.stop_sequences {
3022        merged.template.stop_sequences = agent.template.stop_sequences.clone();
3023    }
3024    if agent.template.thinking != defaults.template.thinking {
3025        merged.template.thinking = agent.template.thinking;
3026    }
3027    if agent.use_color != defaults.use_color {
3028        merged.use_color = agent.use_color;
3029    }
3030    if agent.session_spend.is_some() {
3031        merged.session_spend = agent.session_spend.clone();
3032    }
3033    if agent.caching_enabled != defaults.caching_enabled {
3034        merged.caching_enabled = agent.caching_enabled;
3035    }
3036
3037    merged
3038}
3039
3040fn append_system_description(chat_config: &mut ChatConfig, workspace_root: &Path) {
3041    let existing = chat_config.system_prompt_text().unwrap_or("").to_string();
3042    let addendum = format!(
3043        r#"# Environment
3044
3045You are operating in the sid-isn't-done environment.  Tools:
3046- edit: The Anthropic Text Editor tool:
3047    - Uses sid's virtual filesystem.
3048    - The virtual filesystem maps / to the workspace root.
3049    - It is not an operating-system chroot.
3050    - Absolute editor paths are workspace-rooted; /foo means {workspace_root}/foo.
3051- bash: A genuine bash shell:
3052    - Connected via PTY.
3053    - Without support for cursor positioning.
3054    - With state persistence between invocations.
3055    - With PS0, PS1, PS2 and PROMPT_COMMAND set to readonly.
3056    - `restart: true` throws the session away and starts fresh.
3057    - Initial CWD is {workspace_root}.
3058    - Runs in the host filesystem namespace, not a chroot.
3059    - Host / remains visible subject to OS permissions and sandbox policy.
3060    - Bash cannot see sid's virtual /skills mount.
3061    - Use the index to browse skills if you need specialized knowledge.
3062
3063CRITICAL — the edit tool and bash tool use different path namespaces:
3064- The edit tool's / is the workspace root ({workspace_root}).
3065  To edit a file at the workspace root, use /filename (e.g., /src/lib.rs).
3066  NEVER pass a full host path to the edit tool.
3067- Bash sees the real host filesystem.  `pwd` prints {workspace_root}, not /.
3068  To convert a bash path to an edit path, strip the {workspace_root} prefix.
3069  To convert an edit path to a bash path, prepend {workspace_root}.
3070  If bash `pwd` is a subdirectory, a relative path like ./foo.rs in bash
3071  corresponds to stripping the workspace prefix from the absolute bash path.
3072- Example: the bash path {workspace_root}/src/lib.rs is the edit path /src/lib.rs.
3073"#,
3074    );
3075
3076    chat_config.set_system_prompt(Some(format!("{existing}{addendum}")));
3077}
3078
3079/// Append a skill index to the system prompt so the model knows what skills are
3080/// available and where they are mounted.
3081fn append_skill_index_to_system_prompt(chat_config: &mut ChatConfig, skills: &[&SkillConfig]) {
3082    let mut index = String::from("\n\n# Available skills (mounted read-only under /skills/):\n");
3083    for skill in skills {
3084        index.push_str(&format!("  - /skills/{}/SKILL.md\n", skill.id));
3085    }
3086    let existing = chat_config.system_prompt_text().unwrap_or("").to_string();
3087    chat_config.set_system_prompt(Some(format!("{existing}{index}")));
3088}
3089
3090fn latest_user_message_text(messages: &[MessageParam]) -> Option<String> {
3091    let message = messages.last()?;
3092    if message.role != MessageRole::User {
3093        return None;
3094    }
3095    match &message.content {
3096        claudius::MessageParamContent::String(text) => Some(text.clone()),
3097        claudius::MessageParamContent::Array(blocks) => {
3098            if blocks.iter().any(ContentBlock::is_tool_result) {
3099                return None;
3100            }
3101            let text = blocks
3102                .iter()
3103                .filter_map(ContentBlock::as_text)
3104                .map(|block| block.text.as_str())
3105                .collect::<Vec<_>>()
3106                .join("\n\n");
3107            Some(text)
3108        }
3109    }
3110}
3111
3112/// Extract the concatenated text blocks from the last assistant message.
3113///
3114/// Returns `None` when the slice contains no assistant messages or the last
3115/// assistant message has no text content.
3116pub fn extract_last_assistant_text(messages: &[MessageParam]) -> Option<String> {
3117    let message = messages
3118        .iter()
3119        .rev()
3120        .find(|message| message.role == MessageRole::Assistant)?;
3121    assistant_message_text(message)
3122}
3123
3124fn assistant_message_text(message: &MessageParam) -> Option<String> {
3125    if message.role != MessageRole::Assistant {
3126        return None;
3127    }
3128    match &message.content {
3129        MessageParamContent::String(text) => Some(text.clone()),
3130        MessageParamContent::Array(blocks) => {
3131            let text = blocks
3132                .iter()
3133                .filter_map(ContentBlock::as_text)
3134                .map(|block| block.text.as_str())
3135                .collect::<Vec<_>>()
3136                .join("\n\n");
3137            if text.is_empty() { None } else { Some(text) }
3138        }
3139    }
3140}
3141
3142fn missing_agent_error(agent: &str) -> SError {
3143    SError::new("sid-agent")
3144        .with_code("unknown_agent")
3145        .with_message("requested agent is not defined")
3146        .with_string_field("agent", agent)
3147}
3148
3149fn disabled_agent_error(agent: &str, enabled: SwitchPosition) -> SError {
3150    SError::new("sid-agent")
3151        .with_code("disabled_agent")
3152        .with_message("requested agent is disabled")
3153        .with_string_field("agent", agent)
3154        .with_string_field("enabled", &format!("{enabled:?}"))
3155}
3156
3157fn disabled_tool_error(agent: &str, tool: &str, enabled: SwitchPosition) -> SError {
3158    SError::new("sid-agent")
3159        .with_code("disabled_tool")
3160        .with_message("agent references a disabled tool")
3161        .with_string_field("agent", agent)
3162        .with_string_field("tool", tool)
3163        .with_string_field("enabled", &format!("{enabled:?}"))
3164}
3165
3166#[cfg(test)]
3167mod tests {
3168    use std::collections::VecDeque;
3169    use std::fs;
3170
3171    use claudius::{
3172        KnownModel, MessageParamContent, ToolBash20250124, ToolTextEditor20250728, ToolUnionParam,
3173    };
3174    use serde_json::json;
3175
3176    use super::*;
3177    use crate::config::{TOOL_PROTOCOL_VERSION, TOOLS_DIR};
3178    use crate::test_support::{
3179        make_executable, temp_config_root, unique_temp_dir, write_default_tool_manifest,
3180    };
3181
3182    struct ScriptedRenderer {
3183        lines: VecDeque<OperatorLine>,
3184        prompts: Vec<String>,
3185    }
3186
3187    impl ScriptedRenderer {
3188        fn new(lines: impl IntoIterator<Item = OperatorLine>) -> Self {
3189            Self {
3190                lines: lines.into_iter().collect(),
3191                prompts: Vec::new(),
3192            }
3193        }
3194    }
3195
3196    impl Renderer for ScriptedRenderer {
3197        fn print_text(&mut self, _context: &dyn claudius::StreamContext, _text: &str) {}
3198
3199        fn print_thinking(&mut self, _context: &dyn claudius::StreamContext, _text: &str) {}
3200
3201        fn print_error(&mut self, _context: &dyn claudius::StreamContext, _error: &str) {}
3202
3203        fn print_info(&mut self, _context: &dyn claudius::StreamContext, _info: &str) {}
3204
3205        fn start_tool_use(
3206            &mut self,
3207            _context: &dyn claudius::StreamContext,
3208            _name: &str,
3209            _id: &str,
3210        ) {
3211        }
3212
3213        fn print_tool_input(
3214            &mut self,
3215            _context: &dyn claudius::StreamContext,
3216            _partial_json: &str,
3217        ) {
3218        }
3219
3220        fn finish_tool_use(&mut self, _context: &dyn claudius::StreamContext) {}
3221
3222        fn start_tool_result(
3223            &mut self,
3224            _context: &dyn claudius::StreamContext,
3225            _tool_use_id: &str,
3226            _is_error: bool,
3227        ) {
3228        }
3229
3230        fn print_tool_result_text(&mut self, _context: &dyn claudius::StreamContext, _text: &str) {}
3231
3232        fn finish_tool_result(&mut self, _context: &dyn claudius::StreamContext) {}
3233
3234        fn finish_response(&mut self, _context: &dyn claudius::StreamContext) {}
3235
3236        fn read_operator_line(&mut self, prompt: &str) -> std::io::Result<Option<OperatorLine>> {
3237            self.prompts.push(prompt.to_string());
3238            Ok(self.lines.pop_front())
3239        }
3240    }
3241
3242    #[derive(Default)]
3243    struct RecordingRenderer {
3244        output: String,
3245    }
3246
3247    impl Renderer for RecordingRenderer {
3248        fn start_agent(&mut self, context: &dyn claudius::StreamContext) {
3249            self.output.push_str(&format!(
3250                "[start:{}:{}]\n",
3251                context.label().unwrap_or_default(),
3252                context.depth()
3253            ));
3254        }
3255
3256        fn finish_agent(
3257            &mut self,
3258            context: &dyn claudius::StreamContext,
3259            stop_reason: Option<&StopReason>,
3260        ) {
3261            self.output.push_str(&format!(
3262                "[finish:{}:{}:{stop_reason:?}]\n",
3263                context.label().unwrap_or_default(),
3264                context.depth(),
3265            ));
3266        }
3267
3268        fn print_text(&mut self, _context: &dyn claudius::StreamContext, text: &str) {
3269            self.output.push_str(text);
3270        }
3271
3272        fn print_thinking(&mut self, _context: &dyn claudius::StreamContext, text: &str) {
3273            self.output.push_str(text);
3274        }
3275
3276        fn print_error(&mut self, _context: &dyn claudius::StreamContext, error: &str) {
3277            self.output.push_str(error);
3278            self.output.push('\n');
3279        }
3280
3281        fn print_info(&mut self, _context: &dyn claudius::StreamContext, info: &str) {
3282            self.output.push_str(info);
3283            self.output.push('\n');
3284        }
3285
3286        fn start_tool_use(
3287            &mut self,
3288            _context: &dyn claudius::StreamContext,
3289            _name: &str,
3290            _id: &str,
3291        ) {
3292        }
3293
3294        fn print_tool_input(&mut self, _context: &dyn claudius::StreamContext, partial_json: &str) {
3295            self.output.push_str(partial_json);
3296        }
3297
3298        fn finish_tool_use(&mut self, _context: &dyn claudius::StreamContext) {}
3299
3300        fn start_tool_result(
3301            &mut self,
3302            _context: &dyn claudius::StreamContext,
3303            _tool_use_id: &str,
3304            _is_error: bool,
3305        ) {
3306        }
3307
3308        fn print_tool_result_text(&mut self, _context: &dyn claudius::StreamContext, text: &str) {
3309            self.output.push_str(text);
3310        }
3311
3312        fn finish_tool_result(&mut self, _context: &dyn claudius::StreamContext) {}
3313
3314        fn finish_response(&mut self, _context: &dyn claudius::StreamContext) {}
3315    }
3316
3317    #[test]
3318    fn ask_an_expert_stream_renderer_streams_question_and_answer() {
3319        let parent_context = AgentStreamContext::root("build").child("tool:ask_an_expert");
3320        let mut parent = RecordingRenderer::default();
3321        {
3322            let mut renderer =
3323                AskAnExpertStreamRenderer::new(&mut parent, &parent_context, "Where is it?");
3324            renderer.start_agent(&());
3325            renderer.print_text(&(), "In src/lib.rs.");
3326            renderer.finish_agent(&(), Some(&StopReason::EndTurn));
3327        }
3328
3329        assert!(parent.output.contains("[start:expert:2]"));
3330        assert!(parent.output.contains("question:\nWhere is it?\n\n"));
3331        assert!(parent.output.contains("In src/lib.rs."));
3332        assert!(parent.output.contains("[finish:expert:2:Some(EndTurn)]"));
3333    }
3334
3335    #[test]
3336    fn computed_tool_result_can_suppress_terminal_rendering() {
3337        let result = ComputedToolResult::with_render_result(
3338            tool_success_result("toolu_test", "ok".to_string()),
3339            false,
3340        );
3341
3342        assert!(!should_render_tool_result(&result));
3343        assert_eq!(
3344            unwrap_success_text(apply_computed_tool_result(Box::new(result))),
3345            "ok"
3346        );
3347    }
3348
3349    #[test]
3350    fn from_config_uses_agent_prompt_and_tools() {
3351        let root = temp_config_root("agent");
3352        write_sample_config(&root);
3353
3354        let config = Config::load(&root).unwrap();
3355        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3356
3357        assert_eq!(agent.id(), "build");
3358        assert_eq!(agent.stream_label(), "build".to_string());
3359        assert_eq!(
3360            agent.config.system_prompt_text(),
3361            Some(expected_build_system_prompt(&root).as_str())
3362        );
3363
3364        let runtime = tokio::runtime::Runtime::new().unwrap();
3365        let tool_names = runtime
3366            .block_on(agent.tools())
3367            .iter()
3368            .map(|tool| tool.name())
3369            .collect::<Vec<_>>();
3370        assert_eq!(tool_names, vec!["format".to_string(), "shell".to_string()]);
3371        assert!(!agent.requires_confirmation());
3372
3373        fs::remove_dir_all(root.as_str()).unwrap();
3374    }
3375
3376    #[test]
3377    fn from_config_exposes_named_agent_prompts_and_snapshot_memory_prompt() {
3378        let root = temp_config_root("agent-prompts");
3379        fs::create_dir_all(root.join("agents").as_str()).unwrap();
3380        fs::write(
3381            root.join("agents.conf").as_str(),
3382            r#"
3383build_ENABLED="YES"
3384build_PROMPT_COMPACTION='agents/compact-request.md'
3385build_PROMPT_MEMORY_EXPERT='agents/memory-expert.md'
3386"#,
3387        )
3388        .unwrap();
3389        fs::write(root.join("tools.conf").as_str(), "").unwrap();
3390        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
3391        fs::write(
3392            root.join("agents/compact-request.md").as_str(),
3393            "Write the summary as bullets.\n",
3394        )
3395        .unwrap();
3396        fs::write(
3397            root.join("agents/memory-expert.md").as_str(),
3398            "\n\n# Memory mode\n\nAnswer from the saved summary only.\n",
3399        )
3400        .unwrap();
3401
3402        let config = Config::load(&root).unwrap();
3403        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3404
3405        assert_eq!(
3406            agent.named_prompt_markdown("COMPACTION"),
3407            Some("Write the summary as bullets.\n")
3408        );
3409        assert_eq!(
3410            agent.named_prompt_markdown("MEMORY_EXPERT"),
3411            Some("\n\n# Memory mode\n\nAnswer from the saved summary only.\n")
3412        );
3413        assert_eq!(agent.named_prompt_markdown("MISSING"), None);
3414
3415        let snapshot = agent.compaction_snapshot();
3416        assert_eq!(
3417            snapshot.memory_expert_prompt.as_deref(),
3418            Some("\n\n# Memory mode\n\nAnswer from the saved summary only.\n")
3419        );
3420
3421        fs::remove_dir_all(root.as_str()).unwrap();
3422    }
3423
3424    #[test]
3425    fn from_workspace_prefers_workspace_agent_and_fills_from_fallback() {
3426        let root = temp_config_root("agent");
3427        write_sample_config(&root);
3428
3429        let fallback = ChatConfig::new()
3430            .with_model(Model::Known(KnownModel::ClaudeHaiku45))
3431            .with_system_prompt("fallback system".to_string())
3432            .with_max_tokens(2048);
3433        let agent = SidAgent::from_workspace(&root, fallback).unwrap();
3434
3435        assert_eq!(agent.id(), "build");
3436        assert_eq!(
3437            agent.config.system_prompt_text(),
3438            Some(expected_build_system_prompt(&root).as_str())
3439        );
3440        assert_eq!(
3441            agent.config.model(),
3442            Model::Known(KnownModel::ClaudeHaiku45)
3443        );
3444        assert_eq!(agent.config.max_tokens(), 2048);
3445
3446        let runtime = tokio::runtime::Runtime::new().unwrap();
3447        let tool_names = runtime
3448            .block_on(agent.tools())
3449            .iter()
3450            .map(|tool| tool.name())
3451            .collect::<Vec<_>>();
3452        assert_eq!(tool_names, vec!["format".to_string(), "shell".to_string()]);
3453        assert!(!agent.requires_confirmation());
3454
3455        fs::remove_dir_all(root.as_str()).unwrap();
3456    }
3457
3458    #[test]
3459    fn from_workspace_with_config_root_loads_config_from_sid_home() {
3460        let config_root = temp_config_root("config-root");
3461        write_sample_config_with_fmt_script(
3462            &config_root,
3463            &capturing_tool_script(
3464                "format via sid_home",
3465                "sid-home-request-capture.json",
3466                "sid-home-env-capture.json",
3467            ),
3468        );
3469        let workspace_root = unique_temp_dir("workspace-root");
3470        fs::create_dir_all(workspace_root.as_str()).unwrap();
3471
3472        let fallback = ChatConfig::new()
3473            .with_system_prompt("fallback system".to_string())
3474            .with_max_tokens(2048);
3475        let agent =
3476            SidAgent::from_workspace_with_config_root(&workspace_root, &config_root, fallback)
3477                .unwrap();
3478        assert_eq!(agent.id(), "build");
3479        assert_eq!(
3480            agent.config.system_prompt_text(),
3481            Some(expected_build_system_prompt(&workspace_root).as_str())
3482        );
3483        assert_eq!(agent.config.max_tokens(), 2048);
3484
3485        let config = Config::load(&config_root).unwrap();
3486        let tool_config = config.tools.get("format").unwrap();
3487        let exposed_name = exposed_tool_name("build", "format").unwrap();
3488        let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "format").unwrap();
3489        let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
3490        let tool_use = ToolUseBlock::new(
3491            "toolu_sid_home_123",
3492            exposed_name,
3493            json!({ "paths": ["src/lib.rs"] }),
3494        );
3495        let runtime = tokio::runtime::Runtime::new().unwrap();
3496        let result = runtime.block_on(invoke_external_tool(&tool, &agent, &tool_use, None));
3497        assert_eq!(unwrap_success_text(result), "format via sid_home");
3498
3499        let request: serde_json::Value = serde_json::from_str(
3500            &fs::read_to_string(
3501                workspace_root
3502                    .join("sid-home-request-capture.json")
3503                    .as_str(),
3504            )
3505            .unwrap(),
3506        )
3507        .unwrap();
3508        assert_eq!(request["workspace"]["root"], json!(workspace_root.as_str()));
3509        assert_eq!(request["workspace"]["cwd"], json!(workspace_root.as_str()));
3510
3511        let env: serde_json::Value = serde_json::from_str(
3512            &fs::read_to_string(workspace_root.join("sid-home-env-capture.json").as_str()).unwrap(),
3513        )
3514        .unwrap();
3515        assert_eq!(env["workspace_root"], json!(workspace_root.as_str()));
3516        assert_eq!(env["rc_d_path"], json!(config_root.join("tools").as_str()));
3517        assert!(
3518            env["rc_conf_path"]
3519                .as_str()
3520                .unwrap()
3521                .starts_with(config_root.join("tools.conf").as_str())
3522        );
3523
3524        fs::remove_dir_all(config_root.as_str()).unwrap();
3525        fs::remove_dir_all(workspace_root.as_str()).unwrap();
3526    }
3527
3528    #[test]
3529    fn from_workspace_with_home_config_root_runs_external_tools() {
3530        if !seatbelt::sandbox_available() {
3531            return;
3532        }
3533
3534        let home = match std::env::var("HOME") {
3535            Ok(home) if !home.is_empty() => home,
3536            _ => return,
3537        };
3538        let config_leaf = std::path::Path::new(unique_temp_dir("home-tool-config").as_str())
3539            .file_name()
3540            .and_then(|name| name.to_str())
3541            .expect("temp dir should have a utf-8 leaf")
3542            .to_string();
3543        let config_root = Path::new(&home)
3544            .join(".sid-isnt-done-tests")
3545            .join(config_leaf)
3546            .into_owned();
3547        write_sample_config_with_fmt_script(
3548            &config_root,
3549            &capturing_tool_script(
3550                "format via home sid_root",
3551                "home-sid-root-request-capture.json",
3552                "home-sid-root-env-capture.json",
3553            ),
3554        );
3555        let workspace_root = unique_temp_dir("workspace-root");
3556        fs::create_dir_all(workspace_root.as_str()).unwrap();
3557
3558        let fallback = ChatConfig::new()
3559            .with_system_prompt("fallback system".to_string())
3560            .with_max_tokens(2048);
3561        let agent =
3562            SidAgent::from_workspace_with_config_root(&workspace_root, &config_root, fallback)
3563                .unwrap();
3564
3565        let config = Config::load(&config_root).unwrap();
3566        let tool_config = config.tools.get("format").unwrap();
3567        let exposed_name = exposed_tool_name("build", "format").unwrap();
3568        let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "format").unwrap();
3569        let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
3570        let tool_use = ToolUseBlock::new(
3571            "toolu_home_sid_root_123",
3572            exposed_name,
3573            json!({ "paths": ["src/lib.rs"] }),
3574        );
3575        let runtime = tokio::runtime::Runtime::new().unwrap();
3576        let result = runtime.block_on(invoke_external_tool(&tool, &agent, &tool_use, None));
3577        assert_eq!(unwrap_success_text(result), "format via home sid_root");
3578
3579        fs::remove_dir_all(config_root.as_str()).unwrap();
3580        fs::remove_dir_all(workspace_root.as_str()).unwrap();
3581    }
3582
3583    #[test]
3584    fn from_config_exposes_builtin_bash_and_edit_tools() {
3585        let root = temp_config_root("agent");
3586        write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
3587
3588        let config = Config::load(&root).unwrap();
3589        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3590
3591        let runtime = tokio::runtime::Runtime::new().unwrap();
3592        let tool_names = runtime
3593            .block_on(agent.tools())
3594            .iter()
3595            .map(|tool| tool.name())
3596            .collect::<Vec<_>>();
3597        assert_eq!(
3598            tool_names,
3599            vec![
3600                "bash".to_string(),
3601                "str_replace_based_edit_tool".to_string(),
3602            ]
3603        );
3604
3605        fs::remove_dir_all(root.as_str()).unwrap();
3606    }
3607
3608    #[test]
3609    fn builtin_tools_without_manifests_use_claudius_union_params() {
3610        let root = temp_config_root("agent");
3611        write_builtin_config_without_manifests(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
3612
3613        let config = Config::load(&root).unwrap();
3614        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3615
3616        let runtime = tokio::runtime::Runtime::new().unwrap();
3617        let tools = runtime.block_on(agent.tools());
3618        let params = tools
3619            .iter()
3620            .map(|tool| (tool.name(), tool.to_param()))
3621            .collect::<Vec<_>>();
3622        assert_eq!(
3623            params,
3624            vec![
3625                (
3626                    "bash".to_string(),
3627                    ToolUnionParam::Bash20250124(ToolBash20250124::new()),
3628                ),
3629                (
3630                    "str_replace_based_edit_tool".to_string(),
3631                    ToolUnionParam::TextEditor20250728(ToolTextEditor20250728::new()),
3632                ),
3633            ]
3634        );
3635
3636        fs::remove_dir_all(root.as_str()).unwrap();
3637    }
3638
3639    #[test]
3640    fn from_workspace_falls_back_without_config_files() {
3641        let root = unique_temp_dir("agent");
3642        fs::create_dir_all(root.as_str()).unwrap();
3643
3644        let fallback = ChatConfig::new()
3645            .with_system_prompt("fallback system".to_string())
3646            .with_max_tokens(2048);
3647        let agent = SidAgent::from_workspace(&root, fallback).unwrap();
3648
3649        assert_eq!(agent.id(), DEFAULT_AGENT_ID);
3650        assert_eq!(agent.config.system_prompt_text(), Some("fallback system"));
3651        assert_eq!(agent.config.max_tokens(), 2048);
3652        let runtime = tokio::runtime::Runtime::new().unwrap();
3653        assert!(runtime.block_on(agent.tools()).is_empty());
3654        assert!(!agent.requires_confirmation());
3655
3656        fs::remove_dir_all(root.as_str()).unwrap();
3657    }
3658
3659    #[test]
3660    fn from_workspace_compactor_falls_back_to_builtin_prompt() {
3661        let root = unique_temp_dir("compactor");
3662        fs::create_dir_all(root.as_str()).unwrap();
3663
3664        let agent = SidAgent::from_workspace_compactor_with_config_root(
3665            &root,
3666            &root,
3667            ChatConfig::new().with_system_prompt("ignored".to_string()),
3668        )
3669        .unwrap();
3670
3671        assert_eq!(agent.id(), DEFAULT_COMPACTOR_AGENT_ID);
3672        assert_eq!(
3673            agent.config.system_prompt_text(),
3674            Some(DEFAULT_COMPACTOR_SYSTEM_PROMPT)
3675        );
3676        let runtime = tokio::runtime::Runtime::new().unwrap();
3677        assert!(runtime.block_on(agent.tools()).is_empty());
3678
3679        fs::remove_dir_all(root.as_str()).unwrap();
3680    }
3681
3682    #[test]
3683    fn configured_compactor_uses_reserved_agent_even_if_disabled() {
3684        let root = temp_config_root("compact-agent");
3685        fs::create_dir_all(root.join("agents").as_str()).unwrap();
3686        fs::write(
3687            root.join("agents.conf").as_str(),
3688            r#"
3689build_ENABLED="YES"
3690build_TOOLS='bash'
3691compact_ENABLED="NO"
3692compact_TOOLS='bash'
3693"#,
3694        )
3695        .unwrap();
3696        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
3697        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
3698        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
3699        fs::write(
3700            root.join("agents/compact.md").as_str(),
3701            "# Compact\n\nCustom compact prompt.\n",
3702        )
3703        .unwrap();
3704
3705        let agent = SidAgent::from_workspace_compactor_with_config_root(
3706            &root,
3707            &root,
3708            ChatConfig::new().with_system_prompt("ignored".to_string()),
3709        )
3710        .unwrap();
3711        assert_eq!(agent.id(), DEFAULT_COMPACTOR_AGENT_ID);
3712        assert!(
3713            agent
3714                .config
3715                .system_prompt_text()
3716                .unwrap()
3717                .contains("Custom compact prompt.")
3718        );
3719        let runtime = tokio::runtime::Runtime::new().unwrap();
3720        assert!(runtime.block_on(agent.tools()).is_empty());
3721
3722        fs::remove_dir_all(root.as_str()).unwrap();
3723    }
3724
3725    #[test]
3726    fn compacted_session_exposes_ask_an_expert_tool() {
3727        let root = temp_config_root("compact-memory");
3728        write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
3729        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
3730        let parent = SidSession::create_in(sessions_root.clone()).unwrap();
3731        let child = SidSession::create_compacted_in(
3732            sessions_root.clone(),
3733            CompactionProvenance {
3734                session_id: parent.id().to_string(),
3735                session_dir: parent.root().to_string_lossy().into_owned(),
3736                expert: CompactionExpertConfig {
3737                    agent_id: Some(DEFAULT_COMPACTOR_AGENT_ID.to_string()),
3738                    model: "claude-sonnet-4-5".to_string(),
3739                    system_prompt: Some("Summarize carefully.".to_string()),
3740                    memory_expert_prompt: Some("Answer from memory only.".to_string()),
3741                },
3742            },
3743        )
3744        .unwrap();
3745
3746        let config = Config::load(&root).unwrap();
3747        let agent = SidAgent::from_config(&config, "build", root.clone())
3748            .unwrap()
3749            .with_session(Arc::new(child));
3750
3751        let runtime = tokio::runtime::Runtime::new().unwrap();
3752        let tool_names = runtime
3753            .block_on(agent.tools())
3754            .iter()
3755            .map(|tool| tool.name())
3756            .collect::<Vec<_>>();
3757        assert_eq!(
3758            tool_names,
3759            vec![
3760                "bash".to_string(),
3761                "str_replace_based_edit_tool".to_string(),
3762                ASK_AN_EXPERT_TOOL_NAME.to_string(),
3763            ]
3764        );
3765
3766        fs::remove_dir_all(root.as_str()).unwrap();
3767        fs::remove_dir_all(sessions_root).unwrap();
3768    }
3769
3770    #[test]
3771    fn from_workspace_without_config_appends_agents_md_to_system_prompt() {
3772        let root = unique_temp_dir("agent");
3773        fs::create_dir_all(root.as_str()).unwrap();
3774        fs::write(root.join("AGENTS.md").as_str(), "Use workspace rules.\n").unwrap();
3775
3776        let fallback = ChatConfig::new()
3777            .with_system_prompt("fallback system".to_string())
3778            .with_max_tokens(2048);
3779        let agent = SidAgent::from_workspace(&root, fallback).unwrap();
3780        let prompt = agent.config.system_prompt_text().unwrap();
3781
3782        assert!(prompt.starts_with("fallback system"));
3783        assert!(prompt.contains("# User instructions from AGENTS.md"));
3784        assert!(prompt.contains("Use workspace rules."));
3785
3786        fs::remove_dir_all(root.as_str()).unwrap();
3787    }
3788
3789    #[test]
3790    fn agent_create_request_forwards_chat_config_request_fields() {
3791        let root = unique_temp_dir("agent-request");
3792        fs::create_dir_all(root.as_str()).unwrap();
3793
3794        let mut config = ChatConfig::new();
3795        config.template.metadata = Some(Metadata::with_user_id("opaque-user"));
3796        config.template.tool_choice = Some(ToolChoice::none());
3797        let agent = SidAgent::new(config, root.clone());
3798
3799        let runtime = tokio::runtime::Runtime::new().unwrap();
3800        let req = runtime.block_on(agent.create_request(123, vec![], false));
3801
3802        assert!(req.cache_control.is_some());
3803        assert_eq!(req.metadata, Some(Metadata::with_user_id("opaque-user")));
3804        assert_eq!(req.tool_choice, Some(ToolChoice::none()));
3805
3806        fs::remove_dir_all(root.as_str()).unwrap();
3807    }
3808
3809    #[test]
3810    fn manual_agents_require_confirmation() {
3811        let root = temp_config_root("agent");
3812        write_sample_config(&root);
3813
3814        let config = Config::load(&root).unwrap();
3815        let agent = SidAgent::from_config(&config, "plan", root.clone()).unwrap();
3816
3817        assert_eq!(agent.id(), "plan");
3818        assert!(agent.requires_confirmation());
3819
3820        fs::remove_dir_all(root.as_str()).unwrap();
3821    }
3822
3823    #[test]
3824    fn parse_tool_confirmation_accepts_yes() {
3825        assert_eq!(parse_tool_confirmation("yes"), Some(true));
3826        assert_eq!(parse_tool_confirmation("YES"), Some(true));
3827        assert_eq!(parse_tool_confirmation("y"), Some(true));
3828        assert_eq!(parse_tool_confirmation("Y"), Some(true));
3829        assert_eq!(parse_tool_confirmation("  yes  \n"), Some(true));
3830    }
3831
3832    #[test]
3833    fn parse_tool_confirmation_accepts_no() {
3834        assert_eq!(parse_tool_confirmation("no"), Some(false));
3835        assert_eq!(parse_tool_confirmation("NO"), Some(false));
3836        assert_eq!(parse_tool_confirmation("n"), Some(false));
3837        assert_eq!(parse_tool_confirmation("N"), Some(false));
3838        assert_eq!(parse_tool_confirmation("  no  \n"), Some(false));
3839    }
3840
3841    #[test]
3842    fn parse_tool_confirmation_rejects_other() {
3843        assert_eq!(parse_tool_confirmation(""), None);
3844        assert_eq!(parse_tool_confirmation("maybe"), None);
3845        assert_eq!(parse_tool_confirmation("yep"), None);
3846        assert_eq!(parse_tool_confirmation("nope"), None);
3847    }
3848
3849    #[test]
3850    fn manual_tool_confirmation_reads_from_renderer() {
3851        let input = json!({ "command": "pwd" });
3852        let mut renderer = ScriptedRenderer::new([
3853            OperatorLine::Line("maybe".to_string()),
3854            OperatorLine::Line("yes".to_string()),
3855        ]);
3856
3857        assert_eq!(
3858            confirm_manual_tool_call("bash", &input, None, Some(&mut renderer)).unwrap(),
3859            ManualToolConfirmation::Allow
3860        );
3861        assert_eq!(renderer.prompts.len(), 2);
3862        assert!(renderer.prompts[0].contains("Tool 'bash' is MANUAL."));
3863    }
3864
3865    #[test]
3866    fn manual_tool_confirmation_interrupted_renderer_cancels() {
3867        let input = json!({ "command": "pwd" });
3868        let mut renderer = ScriptedRenderer::new([OperatorLine::Interrupted]);
3869
3870        assert_eq!(
3871            confirm_manual_tool_call("bash", &input, None, Some(&mut renderer)).unwrap(),
3872            ManualToolConfirmation::Cancel
3873        );
3874        assert_eq!(renderer.prompts.len(), 1);
3875    }
3876
3877    #[test]
3878    fn interrupted_manual_tool_cancels_remaining_tool_calls() {
3879        let root = temp_config_root("agent");
3880        fs::create_dir_all(root.join("agents").as_str()).unwrap();
3881        fs::write(
3882            root.join("agents.conf").as_str(),
3883            "build_ENABLED=YES\nbuild_TOOLS='bash edit'\n",
3884        )
3885        .unwrap();
3886        fs::write(
3887            root.join("tools.conf").as_str(),
3888            "bash_ENABLED=MANUAL\nedit_ENABLED=MANUAL\n",
3889        )
3890        .unwrap();
3891        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
3892        write_tool_runtime(&root, "edit", "#!/bin/sh\nexit 0\n");
3893        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
3894
3895        let config = Config::load(&root).unwrap();
3896        let mut agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3897        let client = Anthropic::new(Some("test-api-key".to_string())).unwrap();
3898        let response = Message::new(
3899            "msg_test".to_string(),
3900            vec![
3901                ToolUseBlock::new(
3902                    "toolu_bash",
3903                    "bash",
3904                    json!({
3905                        "command": "pwd"
3906                    }),
3907                )
3908                .into(),
3909                ToolUseBlock::new(
3910                    "toolu_edit",
3911                    "str_replace_based_edit_tool",
3912                    json!({
3913                        "command": "view",
3914                        "path": "/src/lib.rs"
3915                    }),
3916                )
3917                .into(),
3918            ],
3919            Model::Known(KnownModel::ClaudeHaiku45),
3920            Usage::new(0, 0),
3921        )
3922        .with_stop_reason(StopReason::ToolUse);
3923        let runtime = tokio::runtime::Runtime::new().unwrap();
3924        let mut renderer = ScriptedRenderer::new([OperatorLine::Interrupted]);
3925        let context = AgentStreamContext::root("build");
3926
3927        let result = runtime.block_on(agent.handle_tool_use_streaming(
3928            &client,
3929            &response,
3930            &mut renderer,
3931            &context,
3932        ));
3933        let ControlFlow::Continue(blocks) = result else {
3934            panic!("expected tool result blocks, got {result:?}");
3935        };
3936
3937        assert_eq!(renderer.prompts.len(), 1);
3938        assert_eq!(blocks.len(), 2);
3939        let first = blocks[0].as_tool_result().unwrap().clone();
3940        let second = blocks[1].as_tool_result().unwrap().clone();
3941        assert_eq!(first.tool_use_id, "toolu_bash");
3942        assert_eq!(second.tool_use_id, "toolu_edit");
3943        assert_eq!(first.is_error, Some(true));
3944        assert_eq!(second.is_error, Some(true));
3945        assert_eq!(tool_block_text(first), USER_CANCELLED_ACTION);
3946        assert_eq!(tool_block_text(second), USER_CANCELLED_ACTION);
3947
3948        fs::remove_dir_all(root.as_str()).unwrap();
3949    }
3950
3951    #[test]
3952    fn top_up_cancelled_tool_results_appends_after_partial_assistant_tool_use() {
3953        let mut messages = vec![
3954            MessageParam::user("Do the work."),
3955            MessageParam::new(
3956                MessageParamContent::Array(vec![
3957                    ContentBlock::Text(TextBlock::new("I will check.".to_string())),
3958                    ToolUseBlock::new("toolu_partial", "bash", json!({"command": "pwd"})).into(),
3959                ]),
3960                MessageRole::Assistant,
3961            ),
3962        ];
3963
3964        assert_eq!(top_up_cancelled_tool_results(&mut messages), 1);
3965
3966        assert_eq!(messages.len(), 3);
3967        assert_eq!(messages[2].role, MessageRole::User);
3968        let MessageParamContent::Array(blocks) = &messages[2].content else {
3969            panic!("expected synthesized tool result blocks");
3970        };
3971        assert_eq!(blocks.len(), 1);
3972        let result = blocks[0].as_tool_result().unwrap().clone();
3973        assert_eq!(result.tool_use_id, "toolu_partial");
3974        assert_eq!(result.is_error, Some(true));
3975        assert_eq!(tool_block_text(result), USER_CANCELLED_ACTION);
3976    }
3977
3978    #[test]
3979    fn top_up_cancelled_tool_results_inserts_before_stale_user_message() {
3980        let mut messages = vec![
3981            MessageParam::new(
3982                MessageParamContent::Array(vec![
3983                    ToolUseBlock::new("toolu_stale", "bash", json!({"command": "pwd"})).into(),
3984                ]),
3985                MessageRole::Assistant,
3986            ),
3987            MessageParam::user("Try again."),
3988        ];
3989
3990        assert_eq!(top_up_cancelled_tool_results(&mut messages), 1);
3991
3992        assert_eq!(messages.len(), 3);
3993        assert_eq!(messages[1].role, MessageRole::User);
3994        assert_eq!(messages[2], MessageParam::user("Try again."));
3995        let MessageParamContent::Array(blocks) = &messages[1].content else {
3996            panic!("expected synthesized tool result blocks");
3997        };
3998        let result = blocks[0].as_tool_result().unwrap().clone();
3999        assert_eq!(result.tool_use_id, "toolu_stale");
4000        assert_eq!(result.is_error, Some(true));
4001        assert_eq!(tool_block_text(result), USER_CANCELLED_ACTION);
4002    }
4003
4004    #[test]
4005    fn top_up_cancelled_tool_results_preserves_existing_results_and_cancels_missing() {
4006        let existing =
4007            ToolResultBlock::new("toolu_done".to_string()).with_string_content("done".to_string());
4008        let mut messages = vec![
4009            MessageParam::new(
4010                MessageParamContent::Array(vec![
4011                    ToolUseBlock::new("toolu_missing", "bash", json!({"command": "pwd"})).into(),
4012                    ToolUseBlock::new("toolu_done", "bash", json!({"command": "date"})).into(),
4013                ]),
4014                MessageRole::Assistant,
4015            ),
4016            MessageParam::new(
4017                MessageParamContent::Array(vec![
4018                    ContentBlock::Text(TextBlock::new("next request".to_string())),
4019                    existing.into(),
4020                ]),
4021                MessageRole::User,
4022            ),
4023        ];
4024
4025        assert_eq!(top_up_cancelled_tool_results(&mut messages), 1);
4026
4027        assert_eq!(messages.len(), 2);
4028        let MessageParamContent::Array(blocks) = &messages[1].content else {
4029            panic!("expected reordered user content blocks");
4030        };
4031        assert_eq!(blocks.len(), 3);
4032        let first = blocks[0].as_tool_result().unwrap().clone();
4033        let second = blocks[1].as_tool_result().unwrap().clone();
4034        assert_eq!(first.tool_use_id, "toolu_missing");
4035        assert_eq!(first.is_error, Some(true));
4036        assert_eq!(tool_block_text(first), USER_CANCELLED_ACTION);
4037        assert_eq!(second.tool_use_id, "toolu_done");
4038        assert_eq!(tool_block_text(second), "done");
4039        assert_eq!(
4040            blocks[2].as_text().map(|block| block.text.as_str()),
4041            Some("next request")
4042        );
4043    }
4044
4045    #[test]
4046    fn token_usage_totals_accumulate_input_cached_input_and_output() {
4047        let mut totals = TokenUsageTotals::default();
4048        totals.add(Usage::new(10, 4).with_cache_read_input_tokens(6), None);
4049        totals.add(Usage::new(20, 8).with_cache_read_input_tokens(7), None);
4050
4051        assert_eq!(
4052            totals,
4053            TokenUsageTotals {
4054                input: 30,
4055                cached_input: 13,
4056                output: 12,
4057                cost_micro_cents: 0,
4058            }
4059        );
4060    }
4061
4062    #[test]
4063    fn token_usage_totals_treat_negative_counts_as_zero() {
4064        let mut totals = TokenUsageTotals::default();
4065        totals.add(Usage::new(-10, -4).with_cache_read_input_tokens(-6), None);
4066
4067        assert_eq!(totals, TokenUsageTotals::default());
4068    }
4069
4070    #[test]
4071    fn token_usage_totals_accumulate_cost_with_rates() {
4072        let rates = TokenRates {
4073            input: 300,
4074            output: 1500,
4075            cache_creation: 375,
4076            cache_read: 30,
4077        };
4078        let mut totals = TokenUsageTotals::default();
4079        totals.add(Usage::new(100, 50), Some(rates));
4080
4081        // 100 input * 300 + 50 output * 1500 = 30_000 + 75_000 = 105_000
4082        assert_eq!(totals.cost_micro_cents, 105_000);
4083    }
4084
4085    #[test]
4086    fn token_usage_totals_no_cost_without_rates() {
4087        let mut totals = TokenUsageTotals::default();
4088        totals.add(Usage::new(100, 50), None);
4089
4090        assert_eq!(totals.cost_micro_cents, 0);
4091    }
4092
4093    #[test]
4094    fn format_cost_renders_dollars_and_fractional_cents() {
4095        assert_eq!(format_cost(0), "$0.0000");
4096        assert_eq!(format_cost(100_000_000), "$1.0000");
4097        assert_eq!(format_cost(50_000_000), "$0.5000");
4098        assert_eq!(format_cost(1_230_000), "$0.0123");
4099        assert_eq!(format_cost(250_000_000), "$2.5000");
4100    }
4101
4102    #[test]
4103    fn agents_md_instructions_are_appended_to_system_prompt() {
4104        let root = temp_config_root("agent");
4105        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4106        fs::write(
4107            root.join("agents.conf").as_str(),
4108            "build_ENABLED=YES\nbuild_TOOLS='bash'\n",
4109        )
4110        .unwrap();
4111        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4112        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4113        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4114        fs::write(root.join("AGENTS.md").as_str(), "Use local rules.\n").unwrap();
4115
4116        let config = Config::load(&root).unwrap();
4117        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4118        let mut messages = vec![MessageParam::user("Do the work.")];
4119        let runtime = tokio::runtime::Runtime::new().unwrap();
4120        runtime
4121            .block_on(agent.inject_user_instructions_for_turn(&mut messages))
4122            .unwrap();
4123
4124        assert_eq!(messages[0], MessageParam::user("Do the work."));
4125        let prompt = agent.config.system_prompt_text().unwrap();
4126        assert!(prompt.contains("# User instructions from AGENTS.md"));
4127        assert!(prompt.contains("Use local rules."));
4128
4129        fs::remove_dir_all(root.as_str()).unwrap();
4130    }
4131
4132    #[test]
4133    fn agents_md_path_concatenates_existing_files_in_order() {
4134        let root = temp_config_root("agent");
4135        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4136        fs::create_dir_all(root.join("local").as_str()).unwrap();
4137        fs::write(
4138            root.join("agents.conf").as_str(),
4139            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_AGENTS_MD_PATH='global.md:missing.md:local/AGENTS.md'\n",
4140        )
4141        .unwrap();
4142        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4143        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4144        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4145        fs::write(root.join("global.md").as_str(), "Global rules.\n").unwrap();
4146        fs::write(root.join("local/AGENTS.md").as_str(), "Local rules.\n").unwrap();
4147
4148        let config = Config::load(&root).unwrap();
4149        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4150        let prompt = agent.config.system_prompt_text().unwrap();
4151        let global = prompt.find("Global rules.").unwrap();
4152        let local = prompt.find("Local rules.").unwrap();
4153        assert!(global < local);
4154        assert!(!prompt.contains("missing.md"));
4155
4156        fs::remove_dir_all(root.as_str()).unwrap();
4157    }
4158
4159    #[test]
4160    fn agents_md_injection_can_be_disabled() {
4161        let root = temp_config_root("agent");
4162        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4163        fs::write(
4164            root.join("agents.conf").as_str(),
4165            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_AGENTS_MD=NO\n",
4166        )
4167        .unwrap();
4168        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4169        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4170        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4171        fs::write(root.join("AGENTS.md").as_str(), "Use local rules.\n").unwrap();
4172
4173        let config = Config::load(&root).unwrap();
4174        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4175        let mut messages = vec![MessageParam::user("Do the work.")];
4176        let runtime = tokio::runtime::Runtime::new().unwrap();
4177        runtime
4178            .block_on(agent.inject_user_instructions_for_turn(&mut messages))
4179            .unwrap();
4180
4181        assert_eq!(messages[0], MessageParam::user("Do the work."));
4182        assert!(
4183            !agent
4184                .config
4185                .system_prompt_text()
4186                .unwrap()
4187                .contains("Use local rules.")
4188        );
4189
4190        fs::remove_dir_all(root.as_str()).unwrap();
4191    }
4192
4193    #[test]
4194    fn user_instruction_hook_runs_with_rc_overlay() {
4195        let root = temp_config_root("agent");
4196        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4197        fs::write(
4198            root.join("agents.conf").as_str(),
4199            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_USER_INSTRUCTIONS_HOOK=context\n",
4200        )
4201        .unwrap();
4202        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4203        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4204        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4205        fs::write(root.join("AGENTS.md").as_str(), "Use local rules.\n").unwrap();
4206        write_agent_hook(
4207            &root,
4208            "context",
4209            r#"#!/bin/sh
4210set -eu
4211PREFIX=${RCVAR_ARGV0:?missing RCVAR_ARGV0}
4212case "${1:-}" in
4213rcvar)
4214    printf '%s\n' \
4215        "${PREFIX}_WORKSPACE_ROOT" \
4216        "${PREFIX}_CONFIG_ROOT" \
4217        "${PREFIX}_AGENT_ID" \
4218        "${PREFIX}_HOOK_NAME" \
4219        "${PREFIX}_AGENTS_MD_PATH" \
4220        "${PREFIX}_SCRATCH_DIR" \
4221        "${PREFIX}_TEMP_DIR" \
4222        "${PREFIX}_TMPDIR" \
4223        "${PREFIX}_RC_CONF_PATH" \
4224        "${PREFIX}_RC_D_PATH"
4225    ;;
4226run)
4227    printf 'workspace=%s\n' "$(printenv "${PREFIX}_WORKSPACE_ROOT")"
4228    printf 'config=%s\n' "$(printenv "${PREFIX}_CONFIG_ROOT")"
4229    printf 'agent=%s\n' "$(printenv "${PREFIX}_AGENT_ID")"
4230    printf 'hook=%s\n' "$(printenv "${PREFIX}_HOOK_NAME")"
4231    printf 'agents_md=%s\n' "$(printenv "${PREFIX}_AGENTS_MD_PATH")"
4232    printf 'scratch=%s\n' "$(printenv "${PREFIX}_SCRATCH_DIR")"
4233    printf 'temp=%s\n' "$(printenv "${PREFIX}_TEMP_DIR")"
4234    printf 'tmpdir=%s\n' "$(printenv "${PREFIX}_TMPDIR")"
4235    printf 'rc=%s\n' "$(printenv "${PREFIX}_RC_CONF_PATH")"
4236    printf 'rcd=%s\n' "$(printenv "${PREFIX}_RC_D_PATH")"
4237    ;;
4238*)
4239    exit 129
4240    ;;
4241esac
4242"#,
4243        );
4244
4245        let config = Config::load(&root).unwrap();
4246        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4247        let mut messages = vec![MessageParam::user("Do the work.")];
4248        let runtime = tokio::runtime::Runtime::new().unwrap();
4249        runtime
4250            .block_on(agent.inject_user_instructions_for_turn(&mut messages))
4251            .unwrap();
4252        let injected = injected_user_instruction_text(&messages[0]);
4253
4254        assert!(injected.contains("# User instructions from hook context"));
4255        assert!(!injected.contains("Use local rules."));
4256        assert!(injected.contains(&format!("workspace={}", root.as_str())));
4257        assert!(injected.contains(&format!("config={}", root.as_str())));
4258        assert!(injected.contains("agent=build"));
4259        assert!(injected.contains("hook=context"));
4260        assert!(injected.contains(&format!("agents_md={}", root.join("AGENTS.md").as_str())));
4261        assert!(injected.contains("scratch=/"));
4262        assert!(injected.contains("temp=/"));
4263        assert!(injected.contains("tmpdir=/"));
4264        assert!(injected.contains(root.join("agents.conf").as_str()));
4265        assert!(injected.contains(&format!("rcd={}", root.join("agents").as_str())));
4266        assert!(
4267            agent
4268                .config
4269                .system_prompt_text()
4270                .unwrap()
4271                .contains("Use local rules.")
4272        );
4273
4274        fs::remove_dir_all(root.as_str()).unwrap();
4275    }
4276
4277    #[test]
4278    fn user_instruction_hook_runs_from_home_config_root() {
4279        if !seatbelt::sandbox_available() {
4280            return;
4281        }
4282
4283        let home = match std::env::var("HOME") {
4284            Ok(home) if !home.is_empty() => home,
4285            _ => return,
4286        };
4287        let config_leaf = std::path::Path::new(unique_temp_dir("home-config-root").as_str())
4288            .file_name()
4289            .and_then(|name| name.to_str())
4290            .expect("temp dir should have a utf-8 leaf")
4291            .to_string();
4292        let config_root = Path::new(&home)
4293            .join(".sid-isnt-done-tests")
4294            .join(config_leaf)
4295            .into_owned();
4296        let workspace_root = unique_temp_dir("workspace-root");
4297
4298        fs::create_dir_all(config_root.join("agents").as_str()).unwrap();
4299        fs::write(
4300            config_root.join("agents.conf").as_str(),
4301            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_USER_INSTRUCTIONS_HOOK=context\n",
4302        )
4303        .unwrap();
4304        fs::write(
4305            config_root.join("tools.conf").as_str(),
4306            "bash_ENABLED=YES\n",
4307        )
4308        .unwrap();
4309        write_tool_runtime(&config_root, "bash", "#!/bin/sh\nexit 0\n");
4310        fs::write(config_root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4311        fs::write(
4312            config_root.join("agents/context").as_str(),
4313            "#!/bin/sh\nexec \"$(dirname \"$0\")/context.impl\" \"$@\"\n",
4314        )
4315        .unwrap();
4316        make_executable(&config_root.join("agents/context"));
4317        fs::write(
4318            config_root.join("agents/context.impl").as_str(),
4319            r#"#!/bin/sh
4320case "${1:-}" in
4321rcvar)
4322    ;;
4323run)
4324    printf 'home hook ok\n'
4325    ;;
4326*)
4327    exit 129
4328    ;;
4329esac
4330"#,
4331        )
4332        .unwrap();
4333        make_executable(&config_root.join("agents/context.impl"));
4334        fs::create_dir_all(workspace_root.as_str()).unwrap();
4335
4336        let config = Config::load(&config_root).unwrap();
4337        let agent = SidAgent::from_config(&config, "build", workspace_root.clone()).unwrap();
4338        let mut messages = vec![MessageParam::user("Do the work.")];
4339        let runtime = tokio::runtime::Runtime::new().unwrap();
4340        runtime
4341            .block_on(agent.inject_user_instructions_for_turn(&mut messages))
4342            .unwrap();
4343
4344        let injected = injected_user_instruction_text(&messages[0]);
4345        assert!(injected.contains("# User instructions from hook context"));
4346        assert!(injected.contains("home hook ok"));
4347
4348        fs::remove_dir_all(config_root.as_str()).unwrap();
4349        fs::remove_dir_all(workspace_root.as_str()).unwrap();
4350    }
4351
4352    #[test]
4353    fn user_instruction_hook_receives_user_message_and_skill_manifest() {
4354        let root = temp_config_root("agent");
4355        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4356        fs::write(
4357            root.join("agents.conf").as_str(),
4358            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='rust python PATH'\nbuild_USER_INSTRUCTIONS_HOOK='context'\n",
4359        )
4360        .unwrap();
4361        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4362        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4363        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4364        write_agent_hook(
4365            &root,
4366            "context",
4367            r#"#!/bin/sh
4368set -eu
4369PREFIX=${RCVAR_ARGV0:?missing RCVAR_ARGV0}
4370case "${1:-}" in
4371rcvar)
4372    printf '%s\n' \
4373        "${PREFIX}_USER_MESSAGE_FILE" \
4374        "${PREFIX}_SKILLS_MANIFEST_FILE"
4375    ;;
4376run)
4377    USER_MESSAGE_FILE=$(printenv "${PREFIX}_USER_MESSAGE_FILE")
4378    SKILLS_MANIFEST_FILE=$(printenv "${PREFIX}_SKILLS_MANIFEST_FILE")
4379    printf 'message=%s\n' "$(cat "$USER_MESSAGE_FILE")"
4380    printf 'manifest='
4381    cat "$SKILLS_MANIFEST_FILE"
4382    ;;
4383*)
4384    exit 129
4385    ;;
4386esac
4387"#,
4388        );
4389        write_skill(&root, "rust", "# Rust Skill\n\nUse Rust idioms.\n");
4390        write_skill(&root, "python", "# Python Skill\n\nUse Python idioms.\n");
4391        write_skill(&root, "PATH", "# Path Skill\n\nShould not be injected.\n");
4392
4393        let config = Config::load(&root).unwrap();
4394        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4395        let original = "Use $rust and $rust; ignore $python-extra and $PATH.";
4396        let mut messages = vec![MessageParam::user(original)];
4397        let runtime = tokio::runtime::Runtime::new().unwrap();
4398        runtime
4399            .block_on(agent.inject_user_instructions_for_turn(&mut messages))
4400            .unwrap();
4401
4402        let injected = injected_user_instruction_text(&messages[0]);
4403        assert!(injected.contains("message=Use $rust and $rust; ignore $python-extra and $PATH."));
4404        assert!(injected.contains("rust\t/skills/rust/SKILL.md\t"));
4405        assert!(injected.contains("python\t/skills/python/SKILL.md\t"));
4406        assert!(!injected.contains("PATH\t/skills/PATH/SKILL.md\t"));
4407
4408        fs::remove_dir_all(root.as_str()).unwrap();
4409    }
4410
4411    #[test]
4412    fn manual_external_tool_stores_enabled_state() {
4413        let root = temp_config_root("agent");
4414        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4415        fs::write(
4416            root.join("agents.conf").as_str(),
4417            "build_ENABLED=YES\nbuild_TOOLS='fmt'\n",
4418        )
4419        .unwrap();
4420        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=MANUAL\n").unwrap();
4421        write_tool_contract(&root, "fmt", "Format files.", "#!/bin/sh\nexit 0\n");
4422        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4423
4424        let config = Config::load(&root).unwrap();
4425        let tool_config = config.tools.get("fmt").unwrap();
4426        assert_eq!(tool_config.enabled, SwitchPosition::Manual);
4427
4428        let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "fmt").unwrap();
4429        let tool = ExternalTool::from_config("fmt".to_string(), canonical_id, tool_config);
4430        assert_eq!(tool.enabled, SwitchPosition::Manual);
4431
4432        fs::remove_dir_all(root.as_str()).unwrap();
4433    }
4434
4435    #[test]
4436    fn manual_builtin_binding_stores_enabled_state() {
4437        let root = temp_config_root("agent");
4438        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4439        fs::write(
4440            root.join("agents.conf").as_str(),
4441            "build_ENABLED=YES\nbuild_TOOLS='bash edit'\n",
4442        )
4443        .unwrap();
4444        fs::write(
4445            root.join("tools.conf").as_str(),
4446            "bash_ENABLED=MANUAL\nedit_ENABLED=MANUAL\n",
4447        )
4448        .unwrap();
4449        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4450        write_tool_runtime(&root, "edit", "#!/bin/sh\nexit 0\n");
4451        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4452
4453        let config = Config::load(&root).unwrap();
4454        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4455        assert_eq!(
4456            agent.builtin_bindings.bash.as_ref().unwrap().enabled,
4457            SwitchPosition::Manual,
4458        );
4459        assert_eq!(
4460            agent.builtin_bindings.edit.as_ref().unwrap().enabled,
4461            SwitchPosition::Manual,
4462        );
4463
4464        fs::remove_dir_all(root.as_str()).unwrap();
4465    }
4466
4467    #[test]
4468    fn from_config_mounts_skills_as_read_only_files() {
4469        let root = temp_config_root("agent");
4470        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4471        fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
4472        fs::create_dir_all(root.join("skills/python").as_str()).unwrap();
4473
4474        fs::write(
4475            root.join("agents.conf").as_str(),
4476            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='rust python'\n",
4477        )
4478        .unwrap();
4479        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4480        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4481        fs::write(
4482            root.join("agents/build.md").as_str(),
4483            "# Build\n\nYou are a builder.\n",
4484        )
4485        .unwrap();
4486        fs::write(
4487            root.join("skills/rust/SKILL.md").as_str(),
4488            "# Rust Skill\n\nWrite safe Rust.\n",
4489        )
4490        .unwrap();
4491        fs::write(
4492            root.join("skills/python/SKILL.md").as_str(),
4493            "# Python Skill\n\nWrite clean Python.\n",
4494        )
4495        .unwrap();
4496
4497        let config = Config::load(&root).unwrap();
4498        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4499
4500        let runtime = tokio::runtime::Runtime::new().unwrap();
4501        let rust_content = runtime
4502            .block_on(agent.filesystem.view("/skills/rust/SKILL.md", None))
4503            .unwrap();
4504        assert!(
4505            rust_content.contains("Write safe Rust."),
4506            "skill content should be viewable: {rust_content}"
4507        );
4508        let python_content = runtime
4509            .block_on(agent.filesystem.view("/skills/python/SKILL.md", None))
4510            .unwrap();
4511        assert!(
4512            python_content.contains("Write clean Python."),
4513            "skill content should be viewable: {python_content}"
4514        );
4515
4516        let replace_err = runtime
4517            .block_on(agent.filesystem.str_replace(
4518                "/skills/rust/SKILL.md",
4519                "Write safe Rust.",
4520                "Replaced.",
4521            ))
4522            .unwrap_err();
4523        assert_eq!(
4524            replace_err.kind(),
4525            std::io::ErrorKind::PermissionDenied,
4526            "skill files should be read-only: {replace_err}"
4527        );
4528
4529        fs::remove_dir_all(root.as_str()).unwrap();
4530    }
4531
4532    #[test]
4533    fn from_config_mounts_all_skills_with_wildcard() {
4534        let root = temp_config_root("agent");
4535        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4536        fs::create_dir_all(root.join("skills/docs").as_str()).unwrap();
4537
4538        fs::write(
4539            root.join("agents.conf").as_str(),
4540            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='*'\n",
4541        )
4542        .unwrap();
4543        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4544        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4545        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4546        fs::write(root.join("skills/docs/SKILL.md").as_str(), "# Docs Skill\n").unwrap();
4547
4548        let config = Config::load(&root).unwrap();
4549        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4550
4551        let runtime = tokio::runtime::Runtime::new().unwrap();
4552        let docs_content = runtime
4553            .block_on(agent.filesystem.view("/skills/docs/SKILL.md", None))
4554            .unwrap();
4555        assert!(
4556            docs_content.contains("# Docs Skill"),
4557            "wildcard should mount all skills: {docs_content}"
4558        );
4559
4560        fs::remove_dir_all(root.as_str()).unwrap();
4561    }
4562
4563    #[test]
4564    fn from_config_no_skills_mounts_workspace_only() {
4565        let root = temp_config_root("agent");
4566        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4567
4568        fs::write(
4569            root.join("agents.conf").as_str(),
4570            "build_ENABLED=YES\nbuild_TOOLS='bash'\n",
4571        )
4572        .unwrap();
4573        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4574        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4575        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4576
4577        let config = Config::load(&root).unwrap();
4578        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4579
4580        let runtime = tokio::runtime::Runtime::new().unwrap();
4581        let content = runtime
4582            .block_on(agent.filesystem.view("/agents/build.md", None))
4583            .unwrap();
4584        assert!(
4585            content.contains("# Build"),
4586            "workspace files should be viewable: {content}"
4587        );
4588
4589        fs::remove_dir_all(root.as_str()).unwrap();
4590    }
4591
4592    #[test]
4593    fn from_config_rejects_unknown_skill_references() {
4594        let root = temp_config_root("agent");
4595        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4596        fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
4597
4598        fs::write(
4599            root.join("agents.conf").as_str(),
4600            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='rust missing'\n",
4601        )
4602        .unwrap();
4603        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4604        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4605        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4606        fs::write(root.join("skills/rust/SKILL.md").as_str(), "# Rust Skill\n").unwrap();
4607
4608        let config = Config::load(&root).unwrap();
4609        let err = SidAgent::from_config(&config, "build", root.clone())
4610            .err()
4611            .expect("unknown skills should fail")
4612            .to_string();
4613        assert!(err.contains("unknown_skill"), "error: {err}");
4614        assert!(err.contains("missing"), "error: {err}");
4615
4616        fs::remove_dir_all(root.as_str()).unwrap();
4617    }
4618
4619    #[test]
4620    fn explicit_default_agent_is_preferred() {
4621        let root = temp_config_root("agent");
4622        write_sample_config(&root);
4623        fs::write(
4624            root.join("agents.conf").as_str(),
4625            concat!(
4626                "DEFAULT_AGENT=plan\n",
4627                "ROLE='principal engineer'\n",
4628                "build_ENABLED=\"YES\"\n",
4629                "plan_ENABLED=\"MANUAL\"\n",
4630                "evil_ENABLED=\"NO\"\n",
4631                "build_TOOLS='format shell'\n",
4632                "plan_MODEL=claude-sonnet-4-5\n",
4633                "plan_SYSTEM=\"You are ${ROLE}\"\n",
4634                "plan_MAX_TOKENS=8192\n",
4635            ),
4636        )
4637        .unwrap();
4638
4639        let config = Config::load(&root).unwrap();
4640        let agent_id = default_agent_id(&config).unwrap();
4641        assert_eq!(agent_id, "plan");
4642
4643        fs::remove_dir_all(root.as_str()).unwrap();
4644    }
4645
4646    #[test]
4647    fn invalid_default_agent_is_rejected_at_config_load() {
4648        let root = temp_config_root("agent");
4649        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4650        fs::write(
4651            root.join("agents.conf").as_str(),
4652            "DEFAULT_AGENT=nonexistent\nbuild_ENABLED=YES\n",
4653        )
4654        .unwrap();
4655        fs::write(root.join("tools.conf").as_str(), "").unwrap();
4656        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4657
4658        let err = Config::load(&root)
4659            .expect_err("invalid DEFAULT_AGENT should fail")
4660            .to_string();
4661        assert!(err.contains("invalid_default_agent"), "error: {err}");
4662        assert!(err.contains("nonexistent"), "error: {err}");
4663
4664        fs::remove_dir_all(root.as_str()).unwrap();
4665    }
4666
4667    #[test]
4668    fn skills_are_advertised_in_system_prompt() {
4669        let root = temp_config_root("agent");
4670        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4671        fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
4672        fs::create_dir_all(root.join("skills/python").as_str()).unwrap();
4673
4674        fs::write(
4675            root.join("agents.conf").as_str(),
4676            "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='rust python'\n",
4677        )
4678        .unwrap();
4679        fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4680        write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4681        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4682        fs::write(root.join("skills/rust/SKILL.md").as_str(), "# Rust Skill\n").unwrap();
4683        fs::write(
4684            root.join("skills/python/SKILL.md").as_str(),
4685            "# Python Skill\n",
4686        )
4687        .unwrap();
4688
4689        let config = Config::load(&root).unwrap();
4690        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4691
4692        let prompt = agent.config.system_prompt_text().unwrap();
4693        assert!(
4694            prompt.contains("/skills/rust/SKILL.md"),
4695            "system prompt should list rust skill: {prompt}"
4696        );
4697        assert!(
4698            prompt.contains("/skills/python/SKILL.md"),
4699            "system prompt should list python skill: {prompt}"
4700        );
4701
4702        fs::remove_dir_all(root.as_str()).unwrap();
4703    }
4704
4705    #[test]
4706    fn from_config_rejects_unknown_tools() {
4707        let root = unique_temp_dir("agent");
4708        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4709        fs::write(
4710            root.join("agents.conf").as_str(),
4711            "build_ENABLED=YES\nbuild_TOOLS='missing'\n",
4712        )
4713        .unwrap();
4714        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
4715        write_tool_contract(
4716            &root,
4717            "fmt",
4718            "Format files in the workspace.",
4719            "#!/bin/sh\nexit 0\n",
4720        );
4721        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4722
4723        let config = Config::load(&root).unwrap();
4724        let err = SidAgent::from_config(&config, "build", root.clone())
4725            .err()
4726            .expect("unknown tools should fail")
4727            .to_string();
4728        assert!(err.contains("unknown_tool"));
4729        assert!(err.contains("missing"));
4730
4731        fs::remove_dir_all(root.as_str()).unwrap();
4732    }
4733
4734    #[test]
4735    fn from_config_rejects_invalid_tool_names() {
4736        let root = unique_temp_dir("agent");
4737        let invalid_name = "a".repeat(65);
4738        fs::create_dir_all(root.join("agents").as_str()).unwrap();
4739        fs::write(
4740            root.join("agents.conf").as_str(),
4741            format!("build_ENABLED=YES\nbuild_TOOLS='{invalid_name}'\n"),
4742        )
4743        .unwrap();
4744        fs::write(
4745            root.join("tools.conf").as_str(),
4746            format!("fmt_ENABLED=YES\n{invalid_name}_ALIASES=fmt\n"),
4747        )
4748        .unwrap();
4749        write_tool_contract(
4750            &root,
4751            "fmt",
4752            "Format files in the workspace.",
4753            "#!/bin/sh\nexit 0\n",
4754        );
4755        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4756
4757        let config = Config::load(&root).unwrap();
4758        let err = SidAgent::from_config(&config, "build", root.clone())
4759            .err()
4760            .expect("invalid tool names should fail")
4761            .to_string();
4762        assert!(err.contains("invalid_tool_name"));
4763        assert!(err.contains(&invalid_name));
4764
4765        fs::remove_dir_all(root.as_str()).unwrap();
4766    }
4767
4768    #[test]
4769    fn builtin_bash_persists_shell_state_across_calls() {
4770        let root = temp_config_root("agent");
4771        write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4772        let canonical_agents = Path::try_from(
4773            fs::canonicalize(root.join("agents").as_str())
4774                .expect("canonicalize agents directory should succeed"),
4775        )
4776        .expect("canonical agents path should be valid UTF-8")
4777        .into_owned();
4778
4779        let config = Config::load(&root).unwrap();
4780        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4781        let runtime = tokio::runtime::Runtime::new().unwrap();
4782        runtime
4783            .block_on(agent.bash("export FOO=bar\nf() { printf hi; }\ncd agents", true))
4784            .unwrap();
4785        let result = runtime
4786            .block_on(agent.bash("printf '%s:%s:%s' \"$FOO\" \"$(f)\" \"$PWD\"", false))
4787            .unwrap();
4788        assert_eq!(
4789            result.trim_end(),
4790            format!("bar:hi:{}", canonical_agents.as_str()),
4791        );
4792
4793        fs::remove_dir_all(root.as_str()).unwrap();
4794    }
4795
4796    #[test]
4797    fn builtin_bash_restart_resets_shell_state() {
4798        let root = temp_config_root("agent");
4799        write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4800        let canonical_root = Path::try_from(
4801            fs::canonicalize(root.as_str()).expect("canonicalize root directory should succeed"),
4802        )
4803        .expect("canonical root path should be valid UTF-8")
4804        .into_owned();
4805        let canonical_agents = Path::try_from(
4806            fs::canonicalize(root.join("agents").as_str())
4807                .expect("canonicalize agents directory should succeed"),
4808        )
4809        .expect("canonical agents path should be valid UTF-8")
4810        .into_owned();
4811
4812        let config = Config::load(&root).unwrap();
4813        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4814        let runtime = tokio::runtime::Runtime::new().unwrap();
4815        runtime
4816            .block_on(agent.bash("export FOO=bar\ncd agents", true))
4817            .unwrap();
4818        let persisted = runtime
4819            .block_on(agent.bash("printf '%s:%s' \"$FOO\" \"$PWD\"", false))
4820            .unwrap();
4821        assert_eq!(
4822            persisted.trim_end(),
4823            format!("bar:{}", canonical_agents.as_str()),
4824        );
4825
4826        let restarted = runtime
4827            .block_on(agent.bash("printf '%s:%s' \"${FOO-unset}\" \"$PWD\"", true))
4828            .unwrap();
4829        assert_eq!(
4830            restarted.trim_end(),
4831            format!("unset:{}", canonical_root.as_str())
4832        );
4833
4834        fs::remove_dir_all(root.as_str()).unwrap();
4835    }
4836
4837    #[test]
4838    fn builtin_bash_tool_restart_resets_shell_state() {
4839        let root = temp_config_root("agent");
4840        write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4841        let canonical_root = Path::try_from(
4842            fs::canonicalize(root.as_str()).expect("canonicalize root directory should succeed"),
4843        )
4844        .expect("canonical root path should be valid UTF-8")
4845        .into_owned();
4846        let canonical_agents = Path::try_from(
4847            fs::canonicalize(root.join("agents").as_str())
4848                .expect("canonicalize agents directory should succeed"),
4849        )
4850        .expect("canonical agents path should be valid UTF-8")
4851        .into_owned();
4852
4853        let config = Config::load(&root).unwrap();
4854        let mut agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4855        let client = Anthropic::new(Some("test-api-key".to_string())).unwrap();
4856        let runtime = tokio::runtime::Runtime::new().unwrap();
4857
4858        let initialized = invoke_bash_tool(
4859            &runtime,
4860            &client,
4861            &mut agent,
4862            "toolu_bash_1",
4863            json!({
4864                "command": "export FOO=bar\ncd agents",
4865                "restart": true
4866            }),
4867        );
4868        assert_eq!(
4869            unwrap_success_block(initialized),
4870            ToolResultBlock {
4871                tool_use_id: "toolu_bash_1".to_string(),
4872                cache_control: None,
4873                content: Some(ToolResultBlockContent::String("success\n".to_string())),
4874                is_error: None,
4875            }
4876        );
4877
4878        let persisted = invoke_bash_tool(
4879            &runtime,
4880            &client,
4881            &mut agent,
4882            "toolu_bash_2",
4883            json!({
4884                "command": "printf '%s:%s' \"$FOO\" \"$PWD\"",
4885                "restart": false
4886            }),
4887        );
4888        assert_eq!(
4889            unwrap_success_block(persisted),
4890            ToolResultBlock {
4891                tool_use_id: "toolu_bash_2".to_string(),
4892                cache_control: None,
4893                content: Some(ToolResultBlockContent::String(format!(
4894                    "bar:{}",
4895                    canonical_agents.as_str()
4896                ))),
4897                is_error: None,
4898            }
4899        );
4900
4901        let restarted = invoke_bash_tool(
4902            &runtime,
4903            &client,
4904            &mut agent,
4905            "toolu_bash_3",
4906            json!({
4907                "command": "printf '%s:%s' \"${FOO-unset}\" \"$PWD\"",
4908                "restart": true
4909            }),
4910        );
4911        assert_eq!(
4912            unwrap_success_block(restarted),
4913            ToolResultBlock {
4914                tool_use_id: "toolu_bash_3".to_string(),
4915                cache_control: None,
4916                content: Some(ToolResultBlockContent::String(format!(
4917                    "unset:{}",
4918                    canonical_root.as_str()
4919                ))),
4920                is_error: None,
4921            }
4922        );
4923
4924        fs::remove_dir_all(root.as_str()).unwrap();
4925    }
4926
4927    #[test]
4928    fn resumed_session_restores_bash_state_snapshot() {
4929        let root = temp_config_root("agent");
4930        write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4931        let canonical_agents = Path::try_from(
4932            fs::canonicalize(root.join("agents").as_str())
4933                .expect("canonicalize agents directory should succeed"),
4934        )
4935        .expect("canonical agents path should be valid UTF-8")
4936        .into_owned();
4937        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
4938
4939        let config = Config::load(&root).unwrap();
4940        let sid_session = Arc::new(SidSession::create_in(sessions_root.clone()).unwrap());
4941        let agent = SidAgent::from_config(&config, "build", root.clone())
4942            .unwrap()
4943            .with_session(sid_session.clone());
4944        let runtime = tokio::runtime::Runtime::new().unwrap();
4945
4946        runtime
4947            .block_on(agent.bash(
4948                "export FOO=bar\nf() { printf hi; }\nalias ll='printf alias'\nset -o nounset\ncd agents",
4949                true,
4950            ))
4951            .unwrap();
4952
4953        let resumed =
4954            Arc::new(SidSession::resume_in(sessions_root.clone(), sid_session.id()).unwrap());
4955        let resumed_agent = SidAgent::from_config(&config, "build", root.clone())
4956            .unwrap()
4957            .with_session(resumed);
4958        let restored = runtime
4959            .block_on(resumed_agent.bash(
4960                "if shopt -qo nounset; then nounset=on; else nounset=off; fi\nprintf '%s:%s:%s:%s:%s' \"$FOO\" \"$(f)\" \"$(ll)\" \"$PWD\" \"$nounset\"",
4961                false,
4962            ))
4963            .unwrap();
4964
4965        assert_eq!(
4966            restored.trim_end(),
4967            format!("bar:hi:alias:{}:on", canonical_agents.as_str()),
4968        );
4969
4970        fs::remove_dir_all(root.as_str()).unwrap();
4971        fs::remove_dir_all(sessions_root).unwrap();
4972    }
4973
4974    #[test]
4975    fn resumed_session_restart_discards_saved_bash_state() {
4976        let root = temp_config_root("agent");
4977        write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4978        let canonical_root = Path::try_from(
4979            fs::canonicalize(root.as_str()).expect("canonicalize root directory should succeed"),
4980        )
4981        .expect("canonical root path should be valid UTF-8")
4982        .into_owned();
4983        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
4984
4985        let config = Config::load(&root).unwrap();
4986        let sid_session = Arc::new(SidSession::create_in(sessions_root.clone()).unwrap());
4987        let agent = SidAgent::from_config(&config, "build", root.clone())
4988            .unwrap()
4989            .with_session(sid_session.clone());
4990        let runtime = tokio::runtime::Runtime::new().unwrap();
4991
4992        runtime
4993            .block_on(agent.bash("export FOO=bar\ncd agents", true))
4994            .unwrap();
4995
4996        let resumed =
4997            Arc::new(SidSession::resume_in(sessions_root.clone(), sid_session.id()).unwrap());
4998        let resumed_agent = SidAgent::from_config(&config, "build", root.clone())
4999            .unwrap()
5000            .with_session(resumed.clone());
5001        let restarted = runtime
5002            .block_on(resumed_agent.bash("printf '%s:%s' \"${FOO-unset}\" \"$PWD\"", true))
5003            .unwrap();
5004        assert_eq!(
5005            restarted.trim_end(),
5006            format!("unset:{}", canonical_root.as_str())
5007        );
5008
5009        let resumed_again =
5010            Arc::new(SidSession::resume_in(sessions_root.clone(), sid_session.id()).unwrap());
5011        let resumed_again_agent = SidAgent::from_config(&config, "build", root.clone())
5012            .unwrap()
5013            .with_session(resumed_again);
5014        let restored = runtime
5015            .block_on(resumed_again_agent.bash("printf '%s:%s' \"${FOO-unset}\" \"$PWD\"", false))
5016            .unwrap();
5017        assert_eq!(
5018            restored.trim_end(),
5019            format!("unset:{}", canonical_root.as_str())
5020        );
5021
5022        fs::remove_dir_all(root.as_str()).unwrap();
5023        fs::remove_dir_all(sessions_root).unwrap();
5024    }
5025
5026    #[test]
5027    fn two_agents_with_different_workspace_roots_have_no_crosstalk() {
5028        let root_a = temp_config_root("agent-a");
5029        let root_b = temp_config_root("agent-b");
5030        write_builtin_config(&root_a, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
5031        write_builtin_config(&root_b, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
5032        let canonical_a = Path::try_from(
5033            fs::canonicalize(root_a.as_str()).expect("canonicalize root_a should succeed"),
5034        )
5035        .expect("canonical root_a path should be valid UTF-8")
5036        .into_owned();
5037        let canonical_b = Path::try_from(
5038            fs::canonicalize(root_b.as_str()).expect("canonicalize root_b should succeed"),
5039        )
5040        .expect("canonical root_b path should be valid UTF-8")
5041        .into_owned();
5042
5043        let config_a = Config::load(&root_a).unwrap();
5044        let agent_a = SidAgent::from_config(&config_a, "build", root_a.clone()).unwrap();
5045        let config_b = Config::load(&root_b).unwrap();
5046        let agent_b = SidAgent::from_config(&config_b, "build", root_b.clone()).unwrap();
5047
5048        let runtime = tokio::runtime::Runtime::new().unwrap();
5049        let pwd_a = runtime.block_on(agent_a.bash("pwd", true)).unwrap();
5050        let pwd_b = runtime.block_on(agent_b.bash("pwd", true)).unwrap();
5051
5052        assert_eq!(pwd_a.trim_end(), canonical_a.as_str());
5053        assert_eq!(pwd_b.trim_end(), canonical_b.as_str());
5054        assert_ne!(
5055            canonical_a.as_str(),
5056            canonical_b.as_str(),
5057            "the two workspace roots must be distinct"
5058        );
5059
5060        fs::remove_dir_all(root_a.as_str()).unwrap();
5061        fs::remove_dir_all(root_b.as_str()).unwrap();
5062    }
5063
5064    #[test]
5065    fn builtin_text_editor_uses_rc_tool_runtime() {
5066        let root = temp_config_root("agent");
5067        write_builtin_config(
5068            &root,
5069            "#!/bin/sh\nexit 0\n",
5070            &capturing_tool_script(
5071                "edit via rc",
5072                "edit-request-capture.json",
5073                "edit-env-capture.json",
5074            ),
5075        );
5076
5077        let config = Config::load(&root).unwrap();
5078        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
5079        let runtime = tokio::runtime::Runtime::new().unwrap();
5080        let tool_use = ToolUseBlock::new(
5081            "toolu_edit_123",
5082            "str_replace_based_edit_tool",
5083            json!({
5084                "command": "str_replace",
5085                "path": "src/lib.rs",
5086                "old_str": "old",
5087                "new_str": "new"
5088            }),
5089        );
5090        let result = runtime.block_on(agent.text_editor(tool_use)).unwrap();
5091        assert_eq!(result, "edit via rc");
5092
5093        let request: serde_json::Value = serde_json::from_str(
5094            &fs::read_to_string(root.join("edit-request-capture.json").as_str()).unwrap(),
5095        )
5096        .unwrap();
5097        assert_eq!(request["tool"]["id"], json!("edit"));
5098        assert_eq!(
5099            request["invocation"]["tool_use_id"],
5100            json!("toolu_edit_123")
5101        );
5102        assert_eq!(
5103            request["invocation"]["input"],
5104            json!({
5105                "command": "str_replace",
5106                "path": "src/lib.rs",
5107                "old_str": "old",
5108                "new_str": "new"
5109            })
5110        );
5111
5112        let env: serde_json::Value = serde_json::from_str(
5113            &fs::read_to_string(root.join("edit-env-capture.json").as_str()).unwrap(),
5114        )
5115        .unwrap();
5116        assert_eq!(env["tool_id"], json!("edit"));
5117        assert_eq!(env["tool_name"], json!("edit"));
5118        assert_eq!(env["workspace_root"], json!(root.as_str()));
5119
5120        fs::remove_dir_all(root.as_str()).unwrap();
5121    }
5122
5123    #[test]
5124    fn tool_invocation_writes_request_and_returns_success() {
5125        let root = temp_config_root("agent");
5126        write_sample_config_with_fmt_script(
5127            &root,
5128            &success_tool_script("Formatted 3 files.", true),
5129        );
5130
5131        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5132        assert_eq!(unwrap_success_text(result), "Formatted 3 files.");
5133
5134        let request: serde_json::Value = serde_json::from_str(
5135            &fs::read_to_string(root.join("request-capture.json").as_str()).unwrap(),
5136        )
5137        .unwrap();
5138        assert_eq!(request["protocol_version"], json!(TOOL_PROTOCOL_VERSION));
5139        assert_eq!(request["tool"]["id"], json!("fmt"));
5140        assert_eq!(request["invocation"]["tool_use_id"], json!("toolu_123"));
5141        assert_eq!(
5142            request["invocation"]["input"],
5143            json!({ "paths": ["src/lib.rs"] })
5144        );
5145        assert_eq!(request["agent"]["id"], json!("build"));
5146        assert_eq!(request["workspace"]["root"], json!(root.as_str()));
5147        assert_eq!(request["workspace"]["cwd"], json!(root.as_str()));
5148        let scratch_dir = request["files"]["scratch_dir"].as_str().unwrap();
5149        let result_file = request["files"]["result_file"].as_str().unwrap();
5150        assert!(scratch_dir.starts_with('/'));
5151        assert!(result_file.starts_with(scratch_dir));
5152        assert!(result_file.ends_with("/result.json"));
5153
5154        fs::remove_dir_all(root.as_str()).unwrap();
5155    }
5156
5157    #[test]
5158    fn tool_invocation_exposes_execution_env_and_ignores_terminal_output() {
5159        let root = temp_config_root("agent");
5160        write_sample_config_with_fmt_script(
5161            &root,
5162            &environment_capturing_tool_script("tool result only"),
5163        );
5164
5165        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5166        assert_eq!(unwrap_success_text(result), "tool result only");
5167
5168        let env: serde_json::Value = serde_json::from_str(
5169            &fs::read_to_string(root.join("env-capture.json").as_str()).unwrap(),
5170        )
5171        .unwrap();
5172        assert_eq!(env["protocol"], json!(TOOL_PROTOCOL_VERSION.to_string()));
5173        assert_eq!(env["workspace_root"], json!(root.as_str()));
5174        assert_eq!(env["agent_id"], json!("build"));
5175        assert_eq!(env["tool_id"], json!("fmt"));
5176        assert_eq!(env["tool_name"], json!("format"));
5177        assert_eq!(env["rc_d_path"], json!(root.join("tools").as_str()));
5178        let scratch_dir = env["scratch_dir"].as_str().unwrap();
5179        assert!(scratch_dir.starts_with('/'));
5180        assert!(
5181            !PathBuf::from(scratch_dir).exists(),
5182            "tool scratch should be cleaned by default"
5183        );
5184        assert!(
5185            env["request_file"]
5186                .as_str()
5187                .unwrap()
5188                .ends_with("/request.json")
5189        );
5190        assert!(
5191            env["result_file"]
5192                .as_str()
5193                .unwrap()
5194                .ends_with("/result.json")
5195        );
5196
5197        fs::remove_dir_all(root.as_str()).unwrap();
5198    }
5199
5200    #[test]
5201    fn tool_invocation_uses_ordered_session_tmp_and_journals() {
5202        let root = temp_config_root("agent");
5203        write_sample_config_with_fmt_script(
5204            &root,
5205            &capturing_tool_script(
5206                "tool result only",
5207                "request-capture.json",
5208                "env-capture.json",
5209            ),
5210        );
5211        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
5212        let sid_session = Arc::new(SidSession::create_in(sessions_root.clone()).unwrap());
5213
5214        let config = Config::load(&root).unwrap();
5215        let agent = SidAgent::from_config(&config, "build", root.clone())
5216            .unwrap()
5217            .with_session(sid_session.clone());
5218        let tool_config = config.tools.get("format").unwrap();
5219        let exposed_name = exposed_tool_name("build", "format").unwrap();
5220        let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "format").unwrap();
5221        let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
5222        let tool_use = ToolUseBlock::new(
5223            "toolu_123",
5224            exposed_name,
5225            json!({ "paths": ["src/lib.rs"] }),
5226        );
5227        let runtime = tokio::runtime::Runtime::new().unwrap();
5228
5229        let result = runtime.block_on(invoke_external_tool(&tool, &agent, &tool_use, None));
5230        assert_eq!(unwrap_success_text(result), "tool result only");
5231
5232        let request: serde_json::Value = serde_json::from_str(
5233            &fs::read_to_string(root.join("request-capture.json").as_str()).unwrap(),
5234        )
5235        .unwrap();
5236        let env: serde_json::Value = serde_json::from_str(
5237            &fs::read_to_string(root.join("env-capture.json").as_str()).unwrap(),
5238        )
5239        .unwrap();
5240        let scratch_dir = PathBuf::from(request["files"]["scratch_dir"].as_str().unwrap());
5241        let temp_dir = PathBuf::from(request["files"]["temp_dir"].as_str().unwrap());
5242        let tool_root = sid_session.root().join("tmp").join("tool-000001");
5243
5244        assert_eq!(scratch_dir, tool_root);
5245        assert_eq!(temp_dir, tool_root.join("tmp"));
5246        assert_eq!(
5247            request["files"]["result_file"],
5248            json!(scratch_dir.join("result.json").to_string_lossy())
5249        );
5250        assert_eq!(env["scratch_dir"], json!(scratch_dir.to_string_lossy()));
5251        assert_eq!(env["temp_dir"], json!(temp_dir.to_string_lossy()));
5252        assert_eq!(env["tmpdir"], json!(temp_dir.to_string_lossy()));
5253        assert_eq!(env["session_id"], json!(sid_session.id()));
5254        assert_eq!(
5255            env["session_dir"],
5256            json!(sid_session.root().to_string_lossy())
5257        );
5258        assert!(
5259            !tool_root.exists(),
5260            "session tool scratch should be cleaned by default"
5261        );
5262
5263        let events = fs::read_to_string(sid_session.root().join("events.jsonl")).unwrap();
5264        let events = events
5265            .lines()
5266            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
5267            .collect::<Vec<_>>();
5268        assert_eq!(events[0]["kind"], json!("session_start"));
5269        assert_eq!(events[1]["kind"], json!("tool_start"));
5270        assert_eq!(events[1]["tool_seq"], json!(1));
5271        assert_eq!(events[1]["request_id"], request["request_id"]);
5272        assert_eq!(events[2]["kind"], json!("tool_finish"));
5273        assert_eq!(events[2]["tool_seq"], json!(1));
5274        assert_eq!(events[2]["success"], json!(true));
5275        assert_eq!(events[2]["scratch_preserved"], json!(false));
5276
5277        fs::remove_dir_all(root.as_str()).unwrap();
5278        fs::remove_dir_all(sessions_root).unwrap();
5279    }
5280
5281    #[test]
5282    fn tool_confirmation_preview_uses_prepared_invocation_env() {
5283        if sandbox_exec_refuses_children() {
5284            return;
5285        }
5286
5287        let root = temp_config_root("agent");
5288        write_sample_config_with_fmt_script(&root, &confirmation_preview_tool_script());
5289
5290        let config = Config::load(&root).unwrap();
5291        let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
5292        let tool_config = config.tools.get("format").unwrap();
5293        let exposed_name = exposed_tool_name("build", "format").unwrap();
5294        let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "format").unwrap();
5295        let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
5296        let context = agent.tool_runtime_context();
5297        let prepared = tool_runtime::prepare_rc_tool_invocation(
5298            &tool.name,
5299            &tool.name,
5300            &tool.canonical_id,
5301            &tool.executable_path,
5302            &context,
5303            "toolu_confirm_123",
5304            serde_json::Map::from_iter([("paths".to_string(), json!(["src/lib.rs"]))]),
5305            tool.timeout,
5306        )
5307        .unwrap();
5308
5309        let runtime = tokio::runtime::Runtime::new().unwrap();
5310        let preview = runtime
5311            .block_on(tool_runtime::render_rc_tool_confirmation_preview(
5312                &prepared, None,
5313            ))
5314            .unwrap();
5315        assert!(preview.contains("mode=confirm"));
5316        assert!(preview.contains("tool=format"));
5317        assert!(preview.contains("id=fmt"));
5318        assert!(preview.contains("request=request.json"));
5319
5320        let text = runtime
5321            .block_on(tool_runtime::run_prepared_rc_tool_text(
5322                &prepared,
5323                &agent.writable_roots,
5324                None,
5325            ))
5326            .unwrap();
5327        assert_eq!(text, "format ran");
5328
5329        fs::remove_dir_all(root.as_str()).unwrap();
5330    }
5331
5332    #[test]
5333    fn tool_invocation_returns_handled_model_visible_error() {
5334        let root = temp_config_root("agent");
5335        write_sample_config_with_fmt_script(
5336            &root,
5337            &handled_error_tool_script("paths must not be empty"),
5338        );
5339
5340        let result = invoke_configured_tool(&root, "format", json!({ "paths": [] }));
5341        assert_eq!(unwrap_error_text(result), "paths must not be empty");
5342
5343        fs::remove_dir_all(root.as_str()).unwrap();
5344    }
5345
5346    #[test]
5347    fn tool_invocation_returns_process_error_when_tool_exits_nonzero() {
5348        let root = temp_config_root("agent");
5349        write_sample_config_with_fmt_script(&root, &nonzero_exit_tool_script());
5350
5351        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5352        let error = unwrap_error_text(result);
5353        assert!(error.contains("exited with status"));
5354
5355        fs::remove_dir_all(root.as_str()).unwrap();
5356    }
5357
5358    #[test]
5359    fn tool_invocation_returns_protocol_error_when_result_protocol_version_is_unsupported() {
5360        let root = temp_config_root("agent");
5361        write_sample_config_with_fmt_script(&root, &unsupported_protocol_version_tool_script());
5362
5363        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5364        let error = unwrap_error_text(result);
5365        assert!(error.contains("unsupported result protocol version"));
5366
5367        fs::remove_dir_all(root.as_str()).unwrap();
5368    }
5369
5370    #[test]
5371    fn tool_invocation_returns_protocol_error_when_output_kind_is_unsupported() {
5372        let root = temp_config_root("agent");
5373        write_sample_config_with_fmt_script(&root, &unsupported_output_kind_tool_script());
5374
5375        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5376        let error = unwrap_error_text(result);
5377        assert!(error.contains("unsupported output kind"));
5378
5379        fs::remove_dir_all(root.as_str()).unwrap();
5380    }
5381
5382    #[test]
5383    fn tool_invocation_returns_protocol_error_when_success_output_is_missing() {
5384        let root = temp_config_root("agent");
5385        write_sample_config_with_fmt_script(&root, &missing_success_output_tool_script());
5386
5387        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5388        let error = unwrap_error_text(result);
5389        assert!(error.contains("missing success output"));
5390
5391        fs::remove_dir_all(root.as_str()).unwrap();
5392    }
5393
5394    #[test]
5395    fn tool_invocation_returns_protocol_error_when_output_text_is_missing() {
5396        let root = temp_config_root("agent");
5397        write_sample_config_with_fmt_script(&root, &missing_output_text_tool_script());
5398
5399        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5400        let error = unwrap_error_text(result);
5401        assert!(error.contains("missing output.text"));
5402
5403        fs::remove_dir_all(root.as_str()).unwrap();
5404    }
5405
5406    #[test]
5407    fn tool_invocation_returns_protocol_error_when_result_is_missing() {
5408        let root = temp_config_root("agent");
5409        write_sample_config_with_fmt_script(&root, "#!/bin/sh\nexit 0\n");
5410
5411        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5412        let error = unwrap_error_text(result);
5413        assert!(error.contains("protocol error"));
5414        assert!(error.contains("failed to read tool result file"));
5415
5416        fs::remove_dir_all(root.as_str()).unwrap();
5417    }
5418
5419    #[test]
5420    fn tool_invocation_returns_protocol_error_when_result_is_malformed() {
5421        let root = temp_config_root("agent");
5422        write_sample_config_with_fmt_script(
5423            &root,
5424            "#!/bin/sh\nprintf 'not json' >\"$RESULT_FILE\"\n",
5425        );
5426
5427        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5428        let error = unwrap_error_text(result);
5429        assert!(error.contains("protocol error"));
5430        assert!(error.contains("failed to parse tool result file"));
5431
5432        fs::remove_dir_all(root.as_str()).unwrap();
5433    }
5434
5435    #[test]
5436    fn tool_invocation_returns_protocol_error_when_error_object_is_missing() {
5437        let root = temp_config_root("agent");
5438        write_sample_config_with_fmt_script(&root, &missing_error_object_tool_script());
5439
5440        let result = invoke_configured_tool(&root, "format", json!({ "paths": [] }));
5441        let error = unwrap_error_text(result);
5442        assert!(error.contains("missing error object"));
5443
5444        fs::remove_dir_all(root.as_str()).unwrap();
5445    }
5446
5447    #[test]
5448    fn tool_invocation_returns_protocol_error_when_error_message_is_missing() {
5449        let root = temp_config_root("agent");
5450        write_sample_config_with_fmt_script(&root, &missing_error_message_tool_script());
5451
5452        let result = invoke_configured_tool(&root, "format", json!({ "paths": [] }));
5453        let error = unwrap_error_text(result);
5454        assert!(error.contains("missing error.message"));
5455
5456        fs::remove_dir_all(root.as_str()).unwrap();
5457    }
5458
5459    #[test]
5460    fn tool_invocation_returns_protocol_error_when_request_id_mismatches() {
5461        let root = temp_config_root("agent");
5462        write_sample_config_with_fmt_script(&root, &mismatched_request_id_tool_script());
5463
5464        let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5465        let error = unwrap_error_text(result);
5466        assert!(error.contains("request_id mismatch"));
5467
5468        fs::remove_dir_all(root.as_str()).unwrap();
5469    }
5470
5471    #[test]
5472    fn tool_invocation_returns_protocol_error_when_input_is_not_an_object() {
5473        let root = temp_config_root("agent");
5474        write_sample_config_with_fmt_script(
5475            &root,
5476            &success_tool_script("Formatted 3 files.", false),
5477        );
5478
5479        let result = invoke_configured_tool(&root, "format", json!(["src/lib.rs"]));
5480        let error = unwrap_error_text(result);
5481        assert!(error.contains("tool input must be a JSON object"));
5482
5483        fs::remove_dir_all(root.as_str()).unwrap();
5484    }
5485
5486    fn expected_build_system_prompt(workspace_root: &Path) -> String {
5487        format!(
5488            r#"# Build
5489
5490You are an expert builder.
5491# Environment
5492
5493You are operating in the sid-isn't-done environment.  Tools:
5494- edit: The Anthropic Text Editor tool:
5495    - Uses sid's virtual filesystem.
5496    - The virtual filesystem maps / to the workspace root.
5497    - It is not an operating-system chroot.
5498    - Absolute editor paths are workspace-rooted; /foo means {}/foo.
5499- bash: A genuine bash shell:
5500    - Connected via PTY.
5501    - Without support for cursor positioning.
5502    - With state persistence between invocations.
5503    - With PS0, PS1, PS2 and PROMPT_COMMAND set to readonly.
5504    - `restart: true` throws the session away and starts fresh.
5505    - Initial CWD is {}.
5506    - Runs in the host filesystem namespace, not a chroot.
5507    - Host / remains visible subject to OS permissions and sandbox policy.
5508    - Bash cannot see sid's virtual /skills mount.
5509    - Use the index to browse skills if you need specialized knowledge.
5510
5511CRITICAL — the edit tool and bash tool use different path namespaces:
5512- The edit tool's / is the workspace root ({}).
5513  To edit a file at the workspace root, use /filename (e.g., /src/lib.rs).
5514  NEVER pass a full host path to the edit tool.
5515- Bash sees the real host filesystem.  `pwd` prints {}, not /.
5516  To convert a bash path to an edit path, strip the {} prefix.
5517  To convert an edit path to a bash path, prepend {}.
5518  If bash `pwd` is a subdirectory, a relative path like ./foo.rs in bash
5519  corresponds to stripping the workspace prefix from the absolute bash path.
5520- Example: the bash path {}/src/lib.rs is the edit path /src/lib.rs.
5521"#,
5522            workspace_root,
5523            workspace_root,
5524            workspace_root,
5525            workspace_root,
5526            workspace_root,
5527            workspace_root,
5528            workspace_root
5529        )
5530    }
5531
5532    fn write_sample_config(root: &Path) {
5533        write_sample_config_with_fmt_script(root, "#!/bin/sh\nexit 0\n");
5534    }
5535
5536    fn write_builtin_config(root: &Path, bash_script: &str, edit_script: &str) {
5537        fs::create_dir_all(root.join("agents").as_str()).unwrap();
5538        fs::write(
5539            root.join("agents.conf").as_str(),
5540            r#"
5541build_ENABLED="YES"
5542build_TOOLS='bash edit'
5543"#,
5544        )
5545        .unwrap();
5546        fs::write(
5547            root.join("tools.conf").as_str(),
5548            r#"
5549bash_ENABLED="YES"
5550edit_ENABLED="YES"
5551"#,
5552        )
5553        .unwrap();
5554        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
5555        write_tool_contract(root, "bash", "Run a shell command.", bash_script);
5556        write_tool_contract(root, "edit", "Edit workspace files.", edit_script);
5557    }
5558
5559    fn write_builtin_config_without_manifests(root: &Path, bash_script: &str, edit_script: &str) {
5560        fs::create_dir_all(root.join("agents").as_str()).unwrap();
5561        fs::write(
5562            root.join("agents.conf").as_str(),
5563            r#"
5564build_ENABLED="YES"
5565build_TOOLS='bash edit'
5566"#,
5567        )
5568        .unwrap();
5569        fs::write(
5570            root.join("tools.conf").as_str(),
5571            r#"
5572bash_ENABLED="YES"
5573edit_ENABLED="YES"
5574"#,
5575        )
5576        .unwrap();
5577        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
5578        write_tool_runtime(root, "bash", bash_script);
5579        write_tool_runtime(root, "edit", edit_script);
5580    }
5581
5582    fn write_sample_config_with_fmt_script(root: &Path, fmt_script: &str) {
5583        fs::create_dir_all(root.join("agents").as_str()).unwrap();
5584        fs::write(
5585            root.join("agents.conf").as_str(),
5586            r#"
5587ROLE='principal engineer'
5588build_ENABLED="YES"
5589plan_ENABLED="MANUAL"
5590evil_ENABLED="NO"
5591
5592build_NAME="Let's go ${ROLE}"
5593build_DESC="buildit"
5594build_TOOLS='format shell'
5595
5596plan_MODEL=claude-sonnet-4-5
5597plan_SYSTEM="You are ${ROLE}"
5598plan_MAX_TOKENS=8192
5599"#,
5600        )
5601        .unwrap();
5602        fs::write(
5603            root.join("tools.conf").as_str(),
5604            r#"
5605fmt_ENABLED="YES"
5606shell_ENABLED="YES"
5607
5608format_INHERIT="YES"
5609format_ALIASES="fmt"
5610"#,
5611        )
5612        .unwrap();
5613        fs::write(
5614            root.join("agents/build.md").as_str(),
5615            "# Build\n\nYou are an expert builder.\n",
5616        )
5617        .unwrap();
5618        write_tool_contract(root, "fmt", "Format files in the workspace.", fmt_script);
5619        write_tool_contract(root, "shell", "Run a shell command.", "#!/bin/sh\nexit 0\n");
5620    }
5621
5622    fn invoke_configured_tool(
5623        root: &Path,
5624        tool_name: &str,
5625        input: serde_json::Value,
5626    ) -> ToolResult {
5627        let config = Config::load(root).unwrap();
5628        let agent = SidAgent::from_config(&config, "build", root.clone().into_owned()).unwrap();
5629        let tool_config = config.tools.get(tool_name).unwrap();
5630        let exposed_name = exposed_tool_name("build", tool_name).unwrap();
5631        let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, tool_name).unwrap();
5632        let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
5633        let tool_use = ToolUseBlock::new("toolu_123", exposed_name, input);
5634        let runtime = tokio::runtime::Runtime::new().unwrap();
5635        runtime.block_on(invoke_external_tool(&tool, &agent, &tool_use, None))
5636    }
5637
5638    fn invoke_bash_tool(
5639        runtime: &tokio::runtime::Runtime,
5640        client: &Anthropic,
5641        agent: &mut SidAgent,
5642        tool_use_id: &str,
5643        input: serde_json::Value,
5644    ) -> ToolResult {
5645        let tool = runtime
5646            .block_on(agent.tools())
5647            .into_iter()
5648            .find(|tool| tool.name() == "bash")
5649            .expect("agent should expose the builtin bash tool");
5650        let tool_use = ToolUseBlock::new(tool_use_id, "bash", input);
5651        let callback = tool.callback();
5652        let intermediate = runtime.block_on(callback.compute_tool_result(client, agent, &tool_use));
5653        runtime.block_on(callback.apply_tool_result(client, agent, &tool_use, intermediate))
5654    }
5655
5656    fn unwrap_success_text(result: ToolResult) -> String {
5657        match result {
5658            ControlFlow::Continue(Ok(block)) => tool_block_text(block),
5659            other => panic!("expected successful tool result, got {other:?}"),
5660        }
5661    }
5662
5663    fn unwrap_success_block(result: ToolResult) -> ToolResultBlock {
5664        match result {
5665            ControlFlow::Continue(Ok(block)) => block,
5666            other => panic!("expected successful tool result, got {other:?}"),
5667        }
5668    }
5669
5670    fn unwrap_error_text(result: ToolResult) -> String {
5671        match result {
5672            ControlFlow::Continue(Err(block)) => {
5673                assert_eq!(block.is_error, Some(true));
5674                tool_block_text(block)
5675            }
5676            other => panic!("expected errored tool result, got {other:?}"),
5677        }
5678    }
5679
5680    fn tool_block_text(block: ToolResultBlock) -> String {
5681        match block.content.unwrap() {
5682            ToolResultBlockContent::String(text) => text,
5683            other => panic!("expected string tool result content, got {other:?}"),
5684        }
5685    }
5686
5687    fn injected_user_instruction_text(message: &MessageParam) -> &str {
5688        let MessageParamContent::Array(blocks) = &message.content else {
5689            panic!("expected content blocks, got {:?}", message.content);
5690        };
5691        blocks
5692            .last()
5693            .and_then(ContentBlock::as_text)
5694            .map(|block| block.text.as_str())
5695            .expect("last block should be injected text")
5696    }
5697
5698    fn success_tool_script(text: &str, capture_request: bool) -> String {
5699        let capture = if capture_request {
5700            "cp \"$REQUEST_FILE\" \"$WORKSPACE_ROOT/request-capture.json\"\n"
5701        } else {
5702            ""
5703        };
5704        format!(
5705            "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\n{capture}cat >\"$RESULT_FILE\" <<EOF\n{{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{{\"kind\":\"text\",\"text\":\"{text}\"}}}}\nEOF\n"
5706        )
5707    }
5708
5709    fn handled_error_tool_script(message: &str) -> String {
5710        format!(
5711            "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":false,\"error\":{{\"code\":\"invalid_input\",\"message\":\"{message}\"}}}}\nEOF\n"
5712        )
5713    }
5714
5715    fn environment_capturing_tool_script(text: &str) -> String {
5716        format!(
5717            "#!/bin/sh\nprintf 'stdout from tool\\n'\nprintf 'stderr from tool\\n' >&2\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$WORKSPACE_ROOT/env-capture.json\" <<EOF\n{{\"protocol\":\"$TOOL_PROTOCOL\",\"request_file\":\"$REQUEST_FILE\",\"result_file\":\"$RESULT_FILE\",\"scratch_dir\":\"$SCRATCH_DIR\",\"workspace_root\":\"$WORKSPACE_ROOT\",\"agent_id\":\"$AGENT_ID\",\"tool_id\":\"$TOOL_ID\",\"tool_name\":\"$TOOL_NAME\",\"rc_conf_path\":\"$RC_CONF_PATH\",\"rc_d_path\":\"$RC_D_PATH\"}}\nEOF\ncat >\"$RESULT_FILE\" <<EOF\n{{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{{\"kind\":\"text\",\"text\":\"{text}\"}}}}\nEOF\n"
5718        )
5719    }
5720
5721    fn capturing_tool_script(text: &str, request_capture: &str, env_capture: &str) -> String {
5722        format!(
5723            "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncp \"$REQUEST_FILE\" \"$WORKSPACE_ROOT/{request_capture}\"\ncat >\"$WORKSPACE_ROOT/{env_capture}\" <<EOF\n{{\"protocol\":\"$TOOL_PROTOCOL\",\"request_file\":\"$REQUEST_FILE\",\"result_file\":\"$RESULT_FILE\",\"scratch_dir\":\"$SCRATCH_DIR\",\"temp_dir\":\"$TEMP_DIR\",\"tmpdir\":\"$TMPDIR\",\"workspace_root\":\"$WORKSPACE_ROOT\",\"agent_id\":\"$AGENT_ID\",\"session_id\":\"$SESSION_ID\",\"session_dir\":\"$SESSION_DIR\",\"tool_id\":\"$TOOL_ID\",\"tool_name\":\"$TOOL_NAME\",\"rc_conf_path\":\"$RC_CONF_PATH\",\"rc_d_path\":\"$RC_D_PATH\"}}\nEOF\ncat >\"$RESULT_FILE\" <<EOF\n{{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{{\"kind\":\"text\",\"text\":\"{text}\"}}}}\nEOF\n"
5724        )
5725    }
5726
5727    fn confirmation_preview_tool_script() -> String {
5728        "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\nif [ \"$TOOL_MODE\" = confirm ]; then\n    printf 'mode=%s tool=%s id=%s request=%s\\n' \"$TOOL_MODE\" \"$TOOL_NAME\" \"$TOOL_ID\" \"$(basename \"$REQUEST_FILE\")\"\n    exit 0\nfi\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"text\",\"text\":\"format ran\"}}\nEOF\n".to_string()
5729    }
5730
5731    fn sandbox_exec_refuses_children() -> bool {
5732        if !seatbelt::sandbox_available() {
5733            return false;
5734        }
5735        match std::process::Command::new("/usr/bin/sandbox-exec")
5736            .arg("-p")
5737            .arg("(version 1)\n(allow default)\n")
5738            .arg("/usr/bin/true")
5739            .stdin(std::process::Stdio::null())
5740            .stdout(std::process::Stdio::null())
5741            .stderr(std::process::Stdio::null())
5742            .status()
5743        {
5744            Ok(status) => !status.success(),
5745            Err(_) => true,
5746        }
5747    }
5748
5749    fn nonzero_exit_tool_script() -> String {
5750        "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"text\",\"text\":\"ignored\"}}\nEOF\nexit 9\n".to_string()
5751    }
5752
5753    fn mismatched_request_id_tool_script() -> String {
5754        "#!/bin/sh\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"wrong-request\",\"ok\":true,\"output\":{\"kind\":\"text\",\"text\":\"ignored\"}}\nEOF\n".to_string()
5755    }
5756
5757    fn unsupported_protocol_version_tool_script() -> String {
5758        "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":2,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"text\",\"text\":\"ignored\"}}\nEOF\n".to_string()
5759    }
5760
5761    fn unsupported_output_kind_tool_script() -> String {
5762        "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"json\",\"text\":\"ignored\"}}\nEOF\n".to_string()
5763    }
5764
5765    fn missing_success_output_tool_script() -> String {
5766        "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true}\nEOF\n".to_string()
5767    }
5768
5769    fn missing_output_text_tool_script() -> String {
5770        "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"text\"}}\nEOF\n".to_string()
5771    }
5772
5773    fn missing_error_object_tool_script() -> String {
5774        "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":false}\nEOF\n".to_string()
5775    }
5776
5777    fn missing_error_message_tool_script() -> String {
5778        "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":false,\"error\":{\"code\":\"invalid_input\"}}\nEOF\n".to_string()
5779    }
5780
5781    fn write_tool_contract(root: &Path, tool: &str, description: &str, body: &str) {
5782        write_tool_runtime(root, tool, body);
5783        write_default_tool_manifest(root, tool, description);
5784    }
5785
5786    fn write_tool_runtime(root: &Path, tool: &str, body: &str) {
5787        fs::create_dir_all(root.join(TOOLS_DIR).as_str()).unwrap();
5788        let executable = root.join(format!("{TOOLS_DIR}/{tool}")).into_owned();
5789        let implementation = root.join(format!("{TOOLS_DIR}/{tool}.impl")).into_owned();
5790        fs::write(implementation.as_str(), body).unwrap();
5791        make_executable(&implementation);
5792        fs::write(
5793            executable.as_str(),
5794            format!(
5795                "#!/bin/sh\nset -eu\n\nlookup() {{\n    printenv \"$1\"\n}}\n\nPREFIX=${{RCVAR_ARGV0:?missing RCVAR_ARGV0}}\n\ncase \"${{1:-}}\" in\nrcvar)\n    printf '%s\\n' \\\n        \"${{PREFIX}}_REQUEST_FILE\" \\\n        \"${{PREFIX}}_RESULT_FILE\" \\\n        \"${{PREFIX}}_SCRATCH_DIR\" \\\n        \"${{PREFIX}}_TEMP_DIR\" \\\n        \"${{PREFIX}}_TMPDIR\" \\\n        \"${{PREFIX}}_WORKSPACE_ROOT\" \\\n        \"${{PREFIX}}_SESSION_ID\" \\\n        \"${{PREFIX}}_SESSION_DIR\" \\\n        \"${{PREFIX}}_AGENT_ID\" \\\n        \"${{PREFIX}}_TOOL_ID\" \\\n        \"${{PREFIX}}_TOOL_NAME\" \\\n        \"${{PREFIX}}_TOOL_PROTOCOL\" \\\n        \"${{PREFIX}}_RC_CONF_PATH\" \\\n        \"${{PREFIX}}_RC_D_PATH\"\n    ;;\nconfirm|run)\n    export TOOL_MODE=\"$1\"\n    shift\n    export REQUEST_FILE=\"$(lookup \"${{PREFIX}}_REQUEST_FILE\")\"\n    export RESULT_FILE=\"$(lookup \"${{PREFIX}}_RESULT_FILE\")\"\n    export SCRATCH_DIR=\"$(lookup \"${{PREFIX}}_SCRATCH_DIR\")\"\n    export TEMP_DIR=\"$(lookup \"${{PREFIX}}_TEMP_DIR\")\"\n    export TMPDIR=\"$(lookup \"${{PREFIX}}_TMPDIR\")\"\n    export WORKSPACE_ROOT=\"$(lookup \"${{PREFIX}}_WORKSPACE_ROOT\")\"\n    export SESSION_ID=\"$(lookup \"${{PREFIX}}_SESSION_ID\")\"\n    export SESSION_DIR=\"$(lookup \"${{PREFIX}}_SESSION_DIR\")\"\n    export AGENT_ID=\"$(lookup \"${{PREFIX}}_AGENT_ID\")\"\n    export TOOL_ID=\"$(lookup \"${{PREFIX}}_TOOL_ID\")\"\n    export TOOL_NAME=\"$(lookup \"${{PREFIX}}_TOOL_NAME\")\"\n    export TOOL_PROTOCOL=\"$(lookup \"${{PREFIX}}_TOOL_PROTOCOL\")\"\n    export RC_CONF_PATH=\"$(lookup \"${{PREFIX}}_RC_CONF_PATH\")\"\n    export RC_D_PATH=\"$(lookup \"${{PREFIX}}_RC_D_PATH\")\"\n    exec {} \"$@\"\n    ;;\n*)\n    echo \"usage: $0 [rcvar|confirm|run]\" >&2\n    exit 129\n    ;;\nesac\n",
5796                shvar::quote_string(implementation.as_str())
5797            ),
5798        )
5799        .unwrap();
5800        make_executable(&executable);
5801    }
5802
5803    fn write_agent_hook(root: &Path, hook: &str, body: &str) {
5804        fs::create_dir_all(root.join("agents").as_str()).unwrap();
5805        let executable = root.join(format!("agents/{hook}")).into_owned();
5806        fs::write(executable.as_str(), body).unwrap();
5807        make_executable(&executable);
5808    }
5809
5810    fn write_skill(root: &Path, skill: &str, body: &str) {
5811        let skill_dir = root.join(format!("skills/{skill}")).into_owned();
5812        fs::create_dir_all(skill_dir.as_str()).unwrap();
5813        fs::write(skill_dir.join("SKILL.md").as_str(), body).unwrap();
5814    }
5815
5816    #[test]
5817    fn strip_ansi_escapes_plain_text() {
5818        assert_eq!(strip_ansi_escapes("hello world"), "hello world");
5819    }
5820
5821    #[test]
5822    fn strip_ansi_escapes_empty() {
5823        assert_eq!(strip_ansi_escapes(""), "");
5824    }
5825
5826    #[test]
5827    fn strip_ansi_escapes_sgr_color() {
5828        // Bold red "error" then reset.
5829        assert_eq!(strip_ansi_escapes("\x1b[1;31merror\x1b[0m"), "error");
5830    }
5831
5832    #[test]
5833    fn strip_ansi_escapes_multiple_csi() {
5834        assert_eq!(
5835            strip_ansi_escapes("\x1b[32mok\x1b[0m \x1b[33mwarn\x1b[0m"),
5836            "ok warn"
5837        );
5838    }
5839
5840    #[test]
5841    fn strip_ansi_escapes_256_color() {
5842        assert_eq!(strip_ansi_escapes("\x1b[38;5;196mred\x1b[0m"), "red");
5843    }
5844
5845    #[test]
5846    fn strip_ansi_escapes_truecolor() {
5847        assert_eq!(strip_ansi_escapes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
5848    }
5849
5850    #[test]
5851    fn strip_ansi_escapes_osc_bel() {
5852        // OSC title-set terminated by BEL.
5853        assert_eq!(strip_ansi_escapes("\x1b]0;my title\x07rest"), "rest");
5854    }
5855
5856    #[test]
5857    fn strip_ansi_escapes_osc_st() {
5858        // OSC terminated by ST (ESC \).
5859        assert_eq!(strip_ansi_escapes("\x1b]0;my title\x1b\\rest"), "rest");
5860    }
5861
5862    #[test]
5863    fn strip_ansi_escapes_two_char_escape() {
5864        // ESC M (reverse index) should be stripped.
5865        assert_eq!(strip_ansi_escapes("a\x1bMb"), "ab");
5866    }
5867
5868    #[test]
5869    fn strip_ansi_escapes_trailing_esc() {
5870        assert_eq!(strip_ansi_escapes("text\x1b"), "text");
5871    }
5872
5873    #[test]
5874    fn strip_ansi_escapes_cursor_csi() {
5875        // CSI sequences ending with ~ (e.g., key codes) or @ (insert).
5876        assert_eq!(strip_ansi_escapes("\x1b[2~x\x1b[1@y"), "xy");
5877    }
5878
5879    #[test]
5880    fn strip_ansi_escapes_preserves_newlines() {
5881        assert_eq!(
5882            strip_ansi_escapes("\x1b[32mline1\x1b[0m\nline2\n"),
5883            "line1\nline2\n"
5884        );
5885    }
5886}