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