1 Commits

Author SHA1 Message Date
Yeachan-Heo
0e722fa013 auto: save WIP progress from rcc session 2026-04-01 04:01:37 +00:00
5 changed files with 219 additions and 923 deletions

0
.codex
View File

343
PARITY.md
View File

@@ -1,214 +1,253 @@
# PARITY GAP ANALYSIS
# PARITY Gap Analysis
Scope: read-only comparison between the original TypeScript source at `/home/bellman/Workspace/claude-code/src/` and the Rust port under `rust/crates/`.
Date: 2026-04-01
Method: compared feature surfaces, registries, entrypoints, and runtime plumbing only. No TypeScript source was copied.
Scope compared:
- Upstream TypeScript: `/home/bellman/Workspace/claude-code/src/`
- Rust port: `rust/crates/`
Method:
- Read-only comparison only.
- No upstream source was copied into this repo.
- This is a focused feature-gap report for `tools`, `hooks`, `plugins`, `skills`, `cli`, `assistant`, and `services`.
## Executive summary
The Rust port has a good foundation for:
- Anthropic API/OAuth basics
- local conversation/session state
- a core tool loop
- MCP stdio/bootstrap support
The Rust port has a solid core for:
- basic prompt/REPL flow
- session/runtime state
- Anthropic API/OAuth plumbing
- a compact MVP tool registry
- CLAUDE.md discovery
- a small but usable built-in tool set
- MCP config parsing/bootstrap primitives
It is **not feature-parity** with the TypeScript CLI.
But it is still materially behind the TypeScript implementation in six major areas:
1. **Tools surface area** is much smaller.
2. **Hook execution** is largely missing; Rust mostly loads hook config but does not run a TS-style PreToolUse/PostToolUse pipeline.
3. **Plugins** are effectively absent in Rust.
4. **Skills** are only partially implemented in Rust via direct `SKILL.md` loading; there is no comparable skills command/discovery/registration surface.
5. **CLI** breadth is much narrower in Rust.
6. **Assistant/tool orchestration** lacks the richer streaming concurrency, hook integration, and orchestration behavior present in TS.
7. **Services** in Rust cover API/auth/runtime basics, but many higher-level TS services are missing.
Largest gaps:
- **plugins** are effectively absent in Rust
- **hooks** are parsed but not executed in Rust
- **CLI breadth** is much narrower in Rust
- **skills** are local-file only in Rust, without the TS registry/bundled pipeline
- **assistant orchestration** lacks TS hook-aware orchestration and remote/structured transports
- **services** beyond core API/OAuth/MCP are mostly missing in Rust
## Critical bug status on this branch
Targeted critical items requested by the user:
- **Prompt mode tools enabled**: fixed in `rust/crates/rusty-claude-cli/src/main.rs:75-82`
- **Default permission mode = danger-full-access**: fixed in `rust/crates/rusty-claude-cli/src/args.rs:12-16`, `rust/crates/rusty-claude-cli/src/main.rs:348-353`, and starter config `rust/crates/rusty-claude-cli/src/init.rs:4-9`
- **Tool input `{}` prefix bug**: fixed/guarded in streaming vs non-stream paths at `rust/crates/rusty-claude-cli/src/main.rs:2211-2256`
- **Unlimited max_iterations**: already present at `rust/crates/runtime/src/conversation.rs:143-148` with `usize::MAX` initialization at `rust/crates/runtime/src/conversation.rs:119`
Build/test/manual verification is tracked separately below and must pass before the branch is considered done.
---
## tools/
## 1) tools/
### TS exists
Evidence:
- `src/tools/` contains broad tool families including `AgentTool`, `AskUserQuestionTool`, `BashTool`, `ConfigTool`, `FileReadTool`, `FileWriteTool`, `GlobTool`, `GrepTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `SkillTool`, `Task*`, `Team*`, `TodoWriteTool`, `ToolSearchTool`, `WebFetchTool`, `WebSearchTool`.
- Tool execution/orchestration is split across `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolHooks.ts`, and `src/services/tools/toolOrchestration.ts`.
### Upstream TS has
- Large per-tool module surface under `src/tools/`, including agent/task tools, AskUserQuestion, MCP tools, plan/worktree tools, REPL, schedule/task tools, synthetic output, brief/upload, and more.
- Evidence:
- `src/tools/AgentTool/AgentTool.tsx`
- `src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx`
- `src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts`
- `src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts`
- `src/tools/EnterPlanModeTool/EnterPlanModeTool.ts`
- `src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts`
- `src/tools/EnterWorktreeTool/EnterWorktreeTool.ts`
- `src/tools/ExitWorktreeTool/ExitWorktreeTool.ts`
- `src/tools/RemoteTriggerTool/RemoteTriggerTool.ts`
- `src/tools/ScheduleCronTool/*`
- `src/tools/TaskCreateTool/*`, `TaskGetTool/*`, `TaskListTool/*`, `TaskOutputTool/*`
### Rust exists
Evidence:
- Tool registry is centralized in `rust/crates/tools/src/lib.rs` via `mvp_tool_specs()`.
- Current built-ins include shell/file/search/web/todo/skill/agent/config/notebook/repl/powershell primitives.
- Runtime execution is wired through `rust/crates/tools/src/lib.rs` and `rust/crates/runtime/src/conversation.rs`.
### Rust currently has
- A single MVP registry in `rust/crates/tools/src/lib.rs:53-371`.
- Implemented tools include `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`, `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `StructuredOutput`, `REPL`, `PowerShell`.
### Missing or broken in Rust
- No Rust equivalents for major TS tools such as `AskUserQuestionTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `Task*`, `Team*`, and several workflow/system tools.
- Rust tool surface is still explicitly an MVP registry, not a parity registry.
- Rust lacks TSs layered tool orchestration split.
**Status:** partial core only.
- **Missing large chunks of the upstream tool catalog**: I did not find Rust equivalents for AskUserQuestion, MCP resource listing/reading tools, plan/worktree entry/exit tools, task management tools, remote trigger, synthetic output, or schedule/cron tools.
- **Tool decomposition is much coarser**: TS isolates tool-specific validation/security/UI behavior per tool module; Rust centralizes almost everything in one file (`rust/crates/tools/src/lib.rs`).
- **Likely parity impact**: lower fidelity tool prompting, weaker per-tool behavior specialization, and fewer native tool choices exposed to the model.
---
## hooks/
## 2) hooks/
### TS exists
Evidence:
- Hook command surface under `src/commands/hooks/`.
- Runtime hook machinery in `src/services/tools/toolHooks.ts` and `src/services/tools/toolExecution.ts`.
- TS supports `PreToolUse`, `PostToolUse`, and broader hook-driven behaviors configured through settings and documented in `src/skills/bundled/updateConfig.ts`.
### Upstream TS has
- A full permission and tool-hook system with **PermissionRequest**, **PreToolUse**, **PostToolUse**, and failure/cancellation handling.
- Evidence:
- `src/hooks/toolPermission/PermissionContext.ts:25,222`
- `src/hooks/toolPermission/handlers/coordinatorHandler.ts:32-38`
- `src/hooks/toolPermission/handlers/interactiveHandler.ts:412-429`
- `src/services/tools/toolHooks.ts:39,435`
- `src/services/tools/toolExecution.ts:800,1074,1483`
- `src/commands/hooks/index.ts:5-8`
### Rust exists
Evidence:
- Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`.
- Hook config can be inspected via Rust config reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
- Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`.
### Rust currently has
- Hook data is **loaded/merged from config** and visible in reports:
- `rust/crates/runtime/src/config.rs:786-797,829-838`
- `rust/crates/rusty-claude-cli/src/main.rs:1665-1669`
- The system prompt acknowledges user-configured hooks:
- `rust/crates/runtime/src/prompt.rs:452-459`
### Missing or broken in Rust
- No actual hook execution pipeline in `rust/crates/runtime/src/conversation.rs`.
- No PreToolUse/PostToolUse mutation/deny/rewrite/result-hook behavior.
- No Rust `/hooks` parity command.
**Status:** config-only; runtime behavior missing.
- **No comparable hook execution pipeline found** in the Rust runtime conversation/tool execution path.
- `rust/crates/runtime/src/conversation.rs:151-208` goes straight from assistant tool_use -> permission check -> tool execute -> tool_result, without TS-style PreToolUse/PostToolUse processing.
- I did **not** find Rust counterparts to TS files like `toolHooks.ts` or `PermissionContext.ts` that execute hook callbacks and alter/block tool behavior.
- Result: Rust appears to support **hook configuration visibility**, but not full **hook behavior parity**.
---
## plugins/
## 3) plugins/
### TS exists
Evidence:
- Built-in plugin scaffolding in `src/plugins/builtinPlugins.ts` and `src/plugins/bundled/index.ts`.
- Plugin lifecycle/services in `src/services/plugins/PluginInstallationManager.ts` and `src/services/plugins/pluginOperations.ts`.
- CLI/plugin command surface under `src/commands/plugin/` and `src/commands/reload-plugins/`.
### Upstream TS has
- Built-in and bundled plugin registration plus CLI/service support for validate/list/install/uninstall/enable/disable/update flows.
- Evidence:
- `src/plugins/builtinPlugins.ts:7-17,149-150`
- `src/plugins/bundled/index.ts:7-22`
- `src/cli/handlers/plugins.ts:51,101,157,668`
- `src/services/plugins/pluginOperations.ts:16,54,306,435,713`
- `src/services/plugins/pluginCliCommands.ts:7,36`
### Rust exists
Evidence:
- No dedicated plugin subsystem appears under `rust/crates/`.
- Repo-wide Rust references to plugins are effectively absent beyond text/help mentions.
### Rust currently has
- I did **not** find a dedicated plugin crate/module/handler under `rust/crates/`.
- The Rust crate layout is only `api`, `commands`, `compat-harness`, `runtime`, `rusty-claude-cli`, and `tools`.
### Missing or broken in Rust
- No plugin loader.
- No marketplace install/update/enable/disable flow.
- No `/plugin` or `/reload-plugins` parity.
- No plugin-provided hook/tool/command/MCP extension path.
**Status:** missing.
- **Plugin loading/install/update/validation is missing.**
- **No plugin CLI surface found** comparable to `claude plugin ...`.
- **No plugin runtime refresh/reconciliation layer found**.
- This is one of the largest parity gaps.
---
## skills/ and CLAUDE.md discovery
## 4) skills/
### TS exists
Evidence:
- Skill loading/registry pipeline in `src/skills/loadSkillsDir.ts`, `src/skills/bundledSkills.ts`, and `src/skills/mcpSkillBuilders.ts`.
- Bundled skills under `src/skills/bundled/`.
- Skills command surface under `src/commands/skills/`.
### Upstream TS has
- Bundled skills registry and loader integration, plus a `skills` command.
- Evidence:
- `src/commands/skills/index.ts:6`
- `src/skills/bundledSkills.ts:44,99,107,114`
- `src/skills/loadSkillsDir.ts:65`
- `src/skills/mcpSkillBuilders.ts:4-21,40`
### Rust exists
Evidence:
- `Skill` tool in `rust/crates/tools/src/lib.rs` resolves and reads local `SKILL.md` files.
- CLAUDE.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`.
- Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
### Rust currently has
- A `Skill` tool that loads local `SKILL.md` files directly:
- `rust/crates/tools/src/lib.rs:1244-1255`
- `rust/crates/tools/src/lib.rs:1288-1323`
- CLAUDE.md / instruction discovery exists in runtime prompt loading:
- `rust/crates/runtime/src/prompt.rs:203-208`
### Missing or broken in Rust
- No bundled skill registry equivalent.
- No `/skills` command.
- No MCP skill-builder pipeline.
- No TS-style live skill discovery/reload/change handling.
- No comparable session-memory / team-memory integration around skills.
**Status:** basic local skill loading only.
- **No Rust `/skills` slash command** in `rust/crates/commands/src/lib.rs:41-166`.
- **No visible bundled-skill registry equivalent** to TS `bundledSkills.ts` / `loadSkillsDir.ts` / `mcpSkillBuilders.ts`.
- Current Rust skill support is closer to **direct file loading** than full upstream **skill discovery/registration/command integration**.
---
## cli/
## 5) cli/
### TS exists
Evidence:
- Large command surface under `src/commands/` including `agents`, `hooks`, `mcp`, `memory`, `model`, `permissions`, `plan`, `plugin`, `resume`, `review`, `skills`, `tasks`, and many more.
- Structured/remote transport stack in `src/cli/structuredIO.ts`, `src/cli/remoteIO.ts`, and `src/cli/transports/*`.
- CLI handler split in `src/cli/handlers/*`.
### Upstream TS has
- Broad CLI handler and transport surface.
- Evidence:
- `src/cli/handlers/agents.ts:2-32`
- `src/cli/handlers/auth.ts`
- `src/cli/handlers/autoMode.ts:24,35,73`
- `src/cli/handlers/plugins.ts:2-3,101,157,668`
- `src/cli/remoteIO.ts:25-35,118-127`
- `src/cli/transports/SSETransport.ts`
- `src/cli/transports/WebSocketTransport.ts`
- `src/cli/transports/HybridTransport.ts`
- `src/cli/transports/SerialBatchEventUploader.ts`
- `src/cli/transports/WorkerStateUploader.ts`
### Rust exists
Evidence:
- Shared slash command registry in `rust/crates/commands/src/lib.rs`.
- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`.
- Main CLI/repl/prompt handling lives in `rust/crates/rusty-claude-cli/src/main.rs`.
### Rust currently has
- Minimal top-level subcommands in `rust/crates/rusty-claude-cli/src/args.rs:29-39` and `rust/crates/rusty-claude-cli/src/main.rs:67-90,242-261`.
- Slash command surface is 15 commands total in `rust/crates/commands/src/lib.rs:41-166,389`.
### Missing or broken in Rust
- Missing major TS command families: `/agents`, `/hooks`, `/mcp`, `/plugin`, `/skills`, `/plan`, `/review`, `/tasks`, and many others.
- No Rust equivalent to TS structured IO / remote transport layers.
- No TS-style handler decomposition for auth/plugins/MCP/agents.
- JSON prompt mode is improved on this branch, but still not clean transport parity: empirical verification shows tool-capable JSON output can emit human-readable tool-result lines before the final JSON object.
**Status:** functional local CLI core, much narrower than TS.
- **Missing major CLI subcommand families**: agents, plugins, mcp management, auto-mode tooling, and many other TS commands.
- **Missing remote/transport stack parity**: I did not find Rust equivalents to TS remote structured IO / SSE / websocket / CCR transport layers.
- **Slash command breadth is much narrower** than TS command inventory under `src/commands/`.
- **Prompt-mode parity bug** was present and is now fixed for this branchs prompt path.
---
## assistant/ (agentic loop, streaming, tool calling)
## 6) assistant/
### TS exists
Evidence:
- Assistant/session surface at `src/assistant/sessionHistory.ts`.
- Tool orchestration in `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolOrchestration.ts`.
- Remote/structured streaming layers in `src/cli/structuredIO.ts` and `src/cli/remoteIO.ts`.
### Upstream TS has
- Rich tool orchestration and streaming execution behavior, including concurrency/cancellation/fallback logic.
- Evidence:
- `src/services/tools/StreamingToolExecutor.ts:35-214`
- `src/services/tools/toolExecution.ts:455-569,800-918,1483`
- `src/services/tools/toolOrchestration.ts:134-167`
- `src/assistant/sessionHistory.ts`
### Rust exists
Evidence:
- Core loop in `rust/crates/runtime/src/conversation.rs`.
- Stream/tool event translation in `rust/crates/rusty-claude-cli/src/main.rs`.
- Session persistence in `rust/crates/runtime/src/session.rs`.
### Rust currently has
- A straightforward agentic loop in `rust/crates/runtime/src/conversation.rs:130-214`.
- Streaming API adaptation in `rust/crates/rusty-claude-cli/src/main.rs:1998-2058`.
- Tool-use block assembly and non-stream fallback handling in `rust/crates/rusty-claude-cli/src/main.rs:2211-2256`.
### Missing or broken in Rust
- No TS-style hook-aware orchestration layer.
- No TS structured/remote assistant transport stack.
- No richer TS assistant/session-history/background-task integration.
- JSON output path is no longer single-turn only on this branch, but output cleanliness still lags TS transport expectations.
**Status:** strong core loop, missing orchestration layers.
- **No TS-style streaming tool executor** with sibling cancellation / fallback discard semantics.
- **No integrated PreToolUse/PostToolUse hook participation** in assistant execution.
- **No comparable orchestration layer for richer tool event semantics** found.
- Historically broken parity items in prompt mode were:
- prompt tool enablement (`main.rs:75-82`) — now fixed on this branch
- streamed `{}` tool-input prefix behavior (`main.rs:2211-2256`) — now fixed/guarded on this branch
---
## services/ (API client, auth, models, MCP)
## 7) services/
### TS exists
Evidence:
- API services under `src/services/api/*`.
- OAuth services under `src/services/oauth/*`.
- MCP services under `src/services/mcp/*`.
- Additional service layers for analytics, prompt suggestion, session memory, plugin operations, settings sync, policy limits, team memory sync, notifier, voice, and more under `src/services/*`.
### Upstream TS has
- Very broad service layer, including API, analytics, compact/session memory, prompt suggestions, plugin services, MCP service helpers, LSP management, policy limits, team memory sync, notifier/tips, etc.
- Evidence:
- `src/services/api/client.ts`, `src/services/api/claude.ts`, `src/services/api/withRetry.ts`
- `src/services/oauth/client.ts`, `src/services/oauth/index.ts`
- `src/services/mcp/*`
- `src/services/plugins/*`
- `src/services/lsp/*`
- `src/services/compact/*`
- `src/services/SessionMemory/*`
- `src/services/PromptSuggestion/*`
- `src/services/analytics/*`
- `src/services/teamMemorySync/*`
### Rust exists
Evidence:
- Core Anthropic API client in `rust/crates/api/src/{client,error,sse,types}.rs`.
- OAuth support in `rust/crates/runtime/src/oauth.rs`.
- MCP config/bootstrap/client support in `rust/crates/runtime/src/{config,mcp,mcp_client,mcp_stdio}.rs`.
- Usage accounting in `rust/crates/runtime/src/usage.rs`.
- Remote upstream-proxy support in `rust/crates/runtime/src/remote.rs`.
### Rust currently has
- Core service equivalents for:
- API client + SSE: `rust/crates/api/src/client.rs`, `rust/crates/api/src/sse.rs`, `rust/crates/api/src/types.rs`
- OAuth: `rust/crates/runtime/src/oauth.rs`
- MCP config/bootstrap primitives: `rust/crates/runtime/src/mcp.rs`, `rust/crates/runtime/src/mcp_client.rs`, `rust/crates/runtime/src/mcp_stdio.rs`, `rust/crates/runtime/src/config.rs`
- prompt/context loading: `rust/crates/runtime/src/prompt.rs`
- session compaction/runtime usage: `rust/crates/runtime/src/compact.rs`, `rust/crates/runtime/src/usage.rs`
### Missing or broken in Rust
- Most TS service ecosystem beyond core messaging/auth/MCP is absent.
- No TS-equivalent plugin service layer.
- No TS-equivalent analytics/settings-sync/policy-limit/team-memory subsystems.
- No TS-style MCP connection-manager/UI layer.
- Model/provider ergonomics remain thinner than TS.
**Status:** core foundation exists; broader service ecosystem missing.
- **Missing many higher-level services**: analytics, plugin services, prompt suggestion, team memory sync, richer LSP service management, notifier/tips ecosystem, and much of the surrounding product/service scaffolding.
- Rust is closer to a **runtime/API core** than a full parity implementation of the TS service layer.
---
## Critical bug status in this worktree
## Highest-priority parity gaps after the critical bug fixes
### Fixed
- **Prompt mode tools enabled**
- `rust/crates/rusty-claude-cli/src/main.rs` now constructs prompt mode with `LiveCli::new(model, true, ...)`.
- **Default permission mode = DangerFullAccess**
- Runtime default now resolves to `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/main.rs`.
- Clap default also uses `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/args.rs`.
- Init template writes `dontAsk` in `rust/crates/rusty-claude-cli/src/init.rs`.
- **Streaming `{}` tool-input prefix bug**
- `rust/crates/rusty-claude-cli/src/main.rs` now strips the initial empty object only for streaming tool input, while preserving legitimate `{}` in non-stream responses.
- **Unlimited max_iterations**
- Verified at `rust/crates/runtime/src/conversation.rs` with `usize::MAX`.
1. **Hook execution parity**
- Config exists, execution does not appear to.
- This affects permissions, tool interception, and continuation behavior.
### Remaining notable parity issue
- **JSON prompt output cleanliness**
- Tool-capable JSON mode now loops, but empirical verification still shows pre-JSON human-readable tool-result output when tools fire.
2. **Plugin system parity**
- Entire install/load/manage surface appears missing.
3. **CLI breadth parity**
- Missing many upstream command families and remote transports.
4. **Tool surface parity**
- MVP tool registry exists, but a large number of upstream tool types are absent.
5. **Assistant orchestration parity**
- Core loop exists, but advanced streaming/execution behaviors from TS are missing.
## Recommended next work after current critical fixes
1. Finish build/test/manual verification of the critical bug patch.
2. Implement **hook execution** before broadening the tool surface further.
3. Decide whether **plugins** are in-scope for parity; if yes, this likely needs dedicated design work, not a small patch.
4. Expand the CLI/tool matrix deliberately rather than adding one-off commands without shared orchestration support.

2
rust/Cargo.lock generated
View File

@@ -1545,12 +1545,10 @@ dependencies = [
name = "tools"
version = "0.1.0"
dependencies = [
"api",
"reqwest",
"runtime",
"serde",
"serde_json",
"tokio",
]
[[package]]

View File

@@ -6,12 +6,10 @@ license.workspace = true
publish.workspace = true
[dependencies]
api = { path = "../api" }
runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread"] }
[lints]
workspace = true

View File

@@ -3,17 +3,10 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use api::{
read_base_url, AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage,
MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
ToolDefinition, ToolResultContentBlock,
};
use reqwest::blocking::Client;
use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
GrepSearchInput, PermissionMode,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -709,7 +702,7 @@ struct SkillOutput {
prompt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
struct AgentOutput {
#[serde(rename = "agentId")]
agent_id: String,
@@ -725,20 +718,6 @@ struct AgentOutput {
manifest_file: String,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")]
started_at: Option<String>,
#[serde(rename = "completedAt", skip_serializing_if = "Option::is_none")]
completed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Clone)]
struct AgentJob {
manifest: AgentOutput,
prompt: String,
system_prompt: Vec<String>,
allowed_tools: BTreeSet<String>,
}
#[derive(Debug, Serialize)]
@@ -1344,18 +1323,7 @@ fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
Err(format!("unknown skill: {requested}"))
}
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
execute_agent_with_spawn(input, spawn_agent_job)
}
fn execute_agent_with_spawn<F>(input: AgentInput, spawn_fn: F) -> Result<AgentOutput, String>
where
F: FnOnce(AgentJob) -> Result<(), String>,
{
if input.description.trim().is_empty() {
return Err(String::from("description must not be empty"));
}
@@ -1369,7 +1337,6 @@ where
let output_file = output_dir.join(format!("{agent_id}.md"));
let manifest_file = output_dir.join(format!("{agent_id}.json"));
let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
let model = resolve_agent_model(input.model.as_deref());
let agent_name = input
.name
.as_deref()
@@ -1377,8 +1344,6 @@ where
.filter(|name| !name.is_empty())
.unwrap_or_else(|| slugify_agent_name(&input.description));
let created_at = iso8601_now();
let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?;
let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
let output_contents = format!(
"# Agent Task
@@ -1402,512 +1367,19 @@ where
name: agent_name,
description: input.description,
subagent_type: Some(normalized_subagent_type),
model: Some(model),
status: String::from("running"),
model: input.model,
status: String::from("queued"),
output_file: output_file.display().to_string(),
manifest_file: manifest_file.display().to_string(),
created_at: created_at.clone(),
started_at: Some(created_at),
completed_at: None,
error: None,
created_at,
};
write_agent_manifest(&manifest)?;
let manifest_for_spawn = manifest.clone();
let job = AgentJob {
manifest: manifest_for_spawn,
prompt: input.prompt,
system_prompt,
allowed_tools,
};
if let Err(error) = spawn_fn(job) {
let error = format!("failed to spawn sub-agent: {error}");
persist_agent_terminal_state(&manifest, "failed", None, Some(error.clone()))?;
return Err(error);
}
Ok(manifest)
}
fn spawn_agent_job(job: AgentJob) -> Result<(), String> {
let thread_name = format!("clawd-agent-{}", job.manifest.agent_id);
std::thread::Builder::new()
.name(thread_name)
.spawn(move || {
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_agent_job(&job)));
match result {
Ok(Ok(())) => {}
Ok(Err(error)) => {
let _ =
persist_agent_terminal_state(&job.manifest, "failed", None, Some(error));
}
Err(_) => {
let _ = persist_agent_terminal_state(
&job.manifest,
"failed",
None,
Some(String::from("sub-agent thread panicked")),
);
}
}
})
.map(|_| ())
.map_err(|error| error.to_string())
}
fn run_agent_job(job: &AgentJob) -> Result<(), String> {
let mut runtime = build_agent_runtime(job)?.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS);
let summary = runtime
.run_turn(job.prompt.clone(), None)
.map_err(|error| error.to_string())?;
let final_text = final_assistant_text(&summary);
persist_agent_terminal_state(&job.manifest, "completed", Some(final_text.as_str()), None)
}
fn build_agent_runtime(
job: &AgentJob,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, SubagentToolExecutor>, String> {
let model = job
.manifest
.model
.clone()
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
let allowed_tools = job.allowed_tools.clone();
let api_client = AnthropicRuntimeClient::new(model, allowed_tools.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools);
Ok(ConversationRuntime::new(
Session::new(),
api_client,
tool_executor,
agent_permission_policy(),
job.system_prompt.clone(),
))
}
fn build_agent_system_prompt(subagent_type: &str) -> Result<Vec<String>, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let mut prompt = load_system_prompt(
cwd,
DEFAULT_AGENT_SYSTEM_DATE.to_string(),
std::env::consts::OS,
"unknown",
std::fs::write(
&manifest_file,
serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
prompt.push(format!(
"You are a background sub-agent of type `{subagent_type}`. Work only on the delegated task, use only the tools available to you, do not ask the user questions, and finish with a concise result."
));
Ok(prompt)
}
fn resolve_agent_model(model: Option<&str>) -> String {
model
.map(str::trim)
.filter(|model| !model.is_empty())
.unwrap_or(DEFAULT_AGENT_MODEL)
.to_string()
}
fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
let tools = match subagent_type {
"Explore" => vec![
"read_file",
"glob_search",
"grep_search",
"WebFetch",
"WebSearch",
"ToolSearch",
"Skill",
"StructuredOutput",
],
"Plan" => vec![
"read_file",
"glob_search",
"grep_search",
"WebFetch",
"WebSearch",
"ToolSearch",
"Skill",
"TodoWrite",
"StructuredOutput",
"SendUserMessage",
],
"Verification" => vec![
"bash",
"read_file",
"glob_search",
"grep_search",
"WebFetch",
"WebSearch",
"ToolSearch",
"TodoWrite",
"StructuredOutput",
"SendUserMessage",
"PowerShell",
],
"claude-code-guide" => vec![
"read_file",
"glob_search",
"grep_search",
"WebFetch",
"WebSearch",
"ToolSearch",
"Skill",
"StructuredOutput",
"SendUserMessage",
],
"statusline-setup" => vec![
"bash",
"read_file",
"write_file",
"edit_file",
"glob_search",
"grep_search",
"ToolSearch",
],
_ => vec![
"bash",
"read_file",
"write_file",
"edit_file",
"glob_search",
"grep_search",
"WebFetch",
"WebSearch",
"TodoWrite",
"Skill",
"ToolSearch",
"NotebookEdit",
"Sleep",
"SendUserMessage",
"Config",
"StructuredOutput",
"REPL",
"PowerShell",
],
};
tools.into_iter().map(str::to_string).collect()
}
fn agent_permission_policy() -> PermissionPolicy {
mvp_tool_specs().into_iter().fold(
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
)
}
fn write_agent_manifest(manifest: &AgentOutput) -> Result<(), String> {
std::fs::write(
&manifest.manifest_file,
serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())
}
fn persist_agent_terminal_state(
manifest: &AgentOutput,
status: &str,
result: Option<&str>,
error: Option<String>,
) -> Result<(), String> {
append_agent_output(
&manifest.output_file,
&format_agent_terminal_output(status, result, error.as_deref()),
)?;
let mut next_manifest = manifest.clone();
next_manifest.status = status.to_string();
next_manifest.completed_at = Some(iso8601_now());
next_manifest.error = error;
write_agent_manifest(&next_manifest)
}
fn append_agent_output(path: &str, suffix: &str) -> Result<(), String> {
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(path)
.map_err(|error| error.to_string())?;
file.write_all(suffix.as_bytes())
.map_err(|error| error.to_string())
}
fn format_agent_terminal_output(status: &str, result: Option<&str>, error: Option<&str>) -> String {
let mut sections = vec![format!("\n## Result\n\n- status: {status}\n")];
if let Some(result) = result.filter(|value| !value.trim().is_empty()) {
sections.push(format!("\n### Final response\n\n{}\n", result.trim()));
}
if let Some(error) = error.filter(|value| !value.trim().is_empty()) {
sections.push(format!("\n### Error\n\n{}\n", error.trim()));
}
sections.join("")
}
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: AnthropicClient,
model: String,
allowed_tools: BTreeSet<String>,
}
impl AnthropicRuntimeClient {
fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
let client = AnthropicClient::from_env()
.map_err(|error| error.to_string())?
.with_base_url(read_base_url());
Ok(Self {
runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
client,
model,
allowed_tools,
})
}
}
impl ApiClient for AnthropicRuntimeClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
.into_iter()
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
})
.collect::<Vec<_>>();
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: 32_000,
messages: convert_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
tools: (!tools.is_empty()).then_some(tools),
tool_choice: (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto),
stream: true,
};
self.runtime.block_on(async {
let mut stream = self
.client
.stream_message(&message_request)
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None;
let mut saw_stop = false;
while let Some(event) = stream
.next_event()
.await
.map_err(|error| RuntimeError::new(error.to_string()))?
{
match event {
ApiStreamEvent::MessageStart(start) => {
for block in start.message.content {
push_output_block(block, &mut events, &mut pending_tool, true);
}
}
ApiStreamEvent::ContentBlockStart(start) => {
push_output_block(
start.content_block,
&mut events,
&mut pending_tool,
true,
);
}
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
ContentBlockDelta::TextDelta { text } => {
if !text.is_empty() {
events.push(AssistantEvent::TextDelta(text));
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = &mut pending_tool {
input.push_str(&partial_json);
}
}
},
ApiStreamEvent::ContentBlockStop(_) => {
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: delta.usage.input_tokens,
output_tokens: delta.usage.output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}));
}
ApiStreamEvent::MessageStop(_) => {
saw_stop = true;
events.push(AssistantEvent::MessageStop);
}
}
}
if !saw_stop
&& events.iter().any(|event| {
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|| matches!(event, AssistantEvent::ToolUse { .. })
})
{
events.push(AssistantEvent::MessageStop);
}
if events
.iter()
.any(|event| matches!(event, AssistantEvent::MessageStop))
{
return Ok(events);
}
let response = self
.client
.send_message(&MessageRequest {
stream: false,
..message_request.clone()
})
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
Ok(response_to_events(response))
})
}
}
struct SubagentToolExecutor {
allowed_tools: BTreeSet<String>,
}
impl SubagentToolExecutor {
fn new(allowed_tools: BTreeSet<String>) -> Self {
Self { allowed_tools }
}
}
impl ToolExecutor for SubagentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
if !self.allowed_tools.contains(tool_name) {
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled for this sub-agent"
)));
}
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
execute_tool(tool_name, &value).map_err(ToolError::new)
}
}
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.collect()
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
messages
.iter()
.filter_map(|message| {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content = message
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
content,
})
})
.collect()
}
fn push_output_block(
block: OutputContentBlock,
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
streaming_tool_input: bool,
) {
match block {
OutputContentBlock::Text { text } => {
if !text.is_empty() {
events.push(AssistantEvent::TextDelta(text));
}
}
OutputContentBlock::ToolUse { id, name, input } => {
let initial_input = if streaming_tool_input
&& input.is_object()
&& input.as_object().is_some_and(serde_json::Map::is_empty)
{
String::new()
} else {
input.to_string()
};
*pending_tool = Some((id, name, initial_input));
}
}
}
fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
let mut events = Vec::new();
let mut pending_tool = None;
for block in response.content {
push_output_block(block, &mut events, &mut pending_tool, false);
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
}));
events.push(AssistantEvent::MessageStop);
events
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
summary
.assistant_messages
.last()
.map(|message| {
message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default()
Ok(manifest)
}
#[allow(clippy::needless_pass_by_value)]
@@ -2901,7 +2373,6 @@ fn parse_skill_description(contents: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener};
@@ -2910,12 +2381,7 @@ mod tests {
use std::thread;
use std::time::Duration;
use super::{
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
AgentInput, AgentJob, SubagentToolExecutor,
};
use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
use super::{execute_tool, mvp_tool_specs};
use serde_json::json;
fn env_lock() -> &'static Mutex<()> {
@@ -3307,48 +2773,32 @@ mod tests {
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = temp_path("agent-store");
std::env::set_var("CLAWD_AGENT_STORE", &dir);
let captured = Arc::new(Mutex::new(None::<AgentJob>));
let captured_for_spawn = Arc::clone(&captured);
let manifest = execute_agent_with_spawn(
AgentInput {
description: "Audit the branch".to_string(),
prompt: "Check tests and outstanding work.".to_string(),
subagent_type: Some("Explore".to_string()),
name: Some("ship-audit".to_string()),
model: None,
},
move |job| {
*captured_for_spawn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(job);
Ok(())
},
let result = execute_tool(
"Agent",
&json!({
"description": "Audit the branch",
"prompt": "Check tests and outstanding work.",
"subagent_type": "Explore",
"name": "ship-audit"
}),
)
.expect("Agent should succeed");
std::env::remove_var("CLAWD_AGENT_STORE");
assert_eq!(manifest.name, "ship-audit");
assert_eq!(manifest.subagent_type.as_deref(), Some("Explore"));
assert_eq!(manifest.status, "running");
assert!(!manifest.created_at.is_empty());
assert!(manifest.started_at.is_some());
assert!(manifest.completed_at.is_none());
let contents = std::fs::read_to_string(&manifest.output_file).expect("agent file exists");
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
assert_eq!(output["name"], "ship-audit");
assert_eq!(output["subagentType"], "Explore");
assert_eq!(output["status"], "queued");
assert!(output["createdAt"].as_str().is_some());
let manifest_file = output["manifestFile"].as_str().expect("manifest file");
let output_file = output["outputFile"].as_str().expect("output file");
let contents = std::fs::read_to_string(output_file).expect("agent file exists");
let manifest_contents =
std::fs::read_to_string(&manifest.manifest_file).expect("manifest file exists");
std::fs::read_to_string(manifest_file).expect("manifest file exists");
assert!(contents.contains("Audit the branch"));
assert!(contents.contains("Check tests and outstanding work."));
assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
assert!(manifest_contents.contains("\"status\": \"running\""));
let captured_job = captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.expect("spawn job should be captured");
assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
assert!(captured_job.allowed_tools.contains("read_file"));
assert!(!captured_job.allowed_tools.contains("Agent"));
let normalized = execute_tool(
"Agent",
@@ -3377,195 +2827,6 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn agent_fake_runner_can_persist_completion_and_failure() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = temp_path("agent-runner");
std::env::set_var("CLAWD_AGENT_STORE", &dir);
let completed = execute_agent_with_spawn(
AgentInput {
description: "Complete the task".to_string(),
prompt: "Do the work".to_string(),
subagent_type: Some("Explore".to_string()),
name: Some("complete-task".to_string()),
model: Some("claude-sonnet-4-6".to_string()),
},
|job| {
persist_agent_terminal_state(
&job.manifest,
"completed",
Some("Finished successfully"),
None,
)
},
)
.expect("completed agent should succeed");
let completed_manifest = std::fs::read_to_string(&completed.manifest_file)
.expect("completed manifest should exist");
let completed_output =
std::fs::read_to_string(&completed.output_file).expect("completed output should exist");
assert!(completed_manifest.contains("\"status\": \"completed\""));
assert!(completed_output.contains("Finished successfully"));
let failed = execute_agent_with_spawn(
AgentInput {
description: "Fail the task".to_string(),
prompt: "Do the failing work".to_string(),
subagent_type: Some("Verification".to_string()),
name: Some("fail-task".to_string()),
model: None,
},
|job| {
persist_agent_terminal_state(
&job.manifest,
"failed",
None,
Some(String::from("simulated failure")),
)
},
)
.expect("failed agent should still spawn");
let failed_manifest =
std::fs::read_to_string(&failed.manifest_file).expect("failed manifest should exist");
let failed_output =
std::fs::read_to_string(&failed.output_file).expect("failed output should exist");
assert!(failed_manifest.contains("\"status\": \"failed\""));
assert!(failed_manifest.contains("simulated failure"));
assert!(failed_output.contains("simulated failure"));
let spawn_error = execute_agent_with_spawn(
AgentInput {
description: "Spawn error task".to_string(),
prompt: "Never starts".to_string(),
subagent_type: None,
name: Some("spawn-error".to_string()),
model: None,
},
|_| Err(String::from("thread creation failed")),
)
.expect_err("spawn errors should surface");
assert!(spawn_error.contains("failed to spawn sub-agent"));
let spawn_error_manifest = std::fs::read_dir(&dir)
.expect("agent dir should exist")
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
.find_map(|path| {
let contents = std::fs::read_to_string(&path).ok()?;
contents
.contains("\"name\": \"spawn-error\"")
.then_some(contents)
})
.expect("failed manifest should still be written");
assert!(spawn_error_manifest.contains("\"status\": \"failed\""));
assert!(spawn_error_manifest.contains("thread creation failed"));
std::env::remove_var("CLAWD_AGENT_STORE");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn agent_tool_subset_mapping_is_expected() {
let general = allowed_tools_for_subagent("general-purpose");
assert!(general.contains("bash"));
assert!(general.contains("write_file"));
assert!(!general.contains("Agent"));
let explore = allowed_tools_for_subagent("Explore");
assert!(explore.contains("read_file"));
assert!(explore.contains("grep_search"));
assert!(!explore.contains("bash"));
let plan = allowed_tools_for_subagent("Plan");
assert!(plan.contains("TodoWrite"));
assert!(plan.contains("StructuredOutput"));
assert!(!plan.contains("Agent"));
let verification = allowed_tools_for_subagent("Verification");
assert!(verification.contains("bash"));
assert!(verification.contains("PowerShell"));
assert!(!verification.contains("write_file"));
}
#[derive(Debug)]
struct MockSubagentApiClient {
calls: usize,
input_path: String,
}
impl runtime::ApiClient for MockSubagentApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
self.calls += 1;
match self.calls {
1 => {
assert_eq!(request.messages.len(), 1);
Ok(vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({ "path": self.input_path }).to_string(),
},
AssistantEvent::MessageStop,
])
}
2 => {
assert!(request.messages.len() >= 3);
Ok(vec![
AssistantEvent::TextDelta("Scope: completed mock review".to_string()),
AssistantEvent::MessageStop,
])
}
_ => panic!("unexpected mock stream call"),
}
}
}
#[test]
fn subagent_runtime_executes_tool_loop_with_isolated_session() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let path = temp_path("subagent-input.txt");
std::fs::write(&path, "hello from child").expect("write input file");
let mut runtime = ConversationRuntime::new(
Session::new(),
MockSubagentApiClient {
calls: 0,
input_path: path.display().to_string(),
},
SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
agent_permission_policy(),
vec![String::from("system prompt")],
);
let summary = runtime
.run_turn("Inspect the delegated file", None)
.expect("subagent loop should succeed");
assert_eq!(
final_assistant_text(&summary),
"Scope: completed mock review"
);
assert!(runtime
.session()
.messages
.iter()
.flat_map(|message| message.blocks.iter())
.any(|block| matches!(
block,
runtime::ContentBlock::ToolResult { output, .. }
if output.contains("hello from child")
)));
let _ = std::fs::remove_file(path);
}
#[test]
fn agent_rejects_blank_required_fields() {
let missing_description = execute_tool(