diff --git a/docs/phase2-milestone-1-summary.md b/docs/phase2-milestone-1-summary.md new file mode 100644 index 0000000..2f6e180 --- /dev/null +++ b/docs/phase2-milestone-1-summary.md @@ -0,0 +1,77 @@ +# Phase2 Milestone 1 Summary + +> **目标**:搭起 tools 抽象的最小闭环骨架,不接 UI、不替换 command-mode。 +> **结果**:骨架就位,`tool → runToolUse → Flow` 全链路打通,7/7 单测绿。 + +## 交付清单 + +### 新增代码(`src/main/kotlin/com/github/codeplangui/tools/`) + +| 文件 | 行数 | 职责 | +|---|---|---| +| `Tool.kt` | ~70 | `Tool` 接口定义,含 4 件必选(parse / call / mapResult / checkPermissions)+ 默认方法(isEnabled / isConcurrencySafe 等) | +| `ToolTypes.kt` | ~100 | `ValidationResult` / `PermissionResult` / `ToolResult` / `PreviewResult` / `Progress` / `ToolUpdate` / `ToolResultBlock` / `ToolUseBlock` | +| `ToolExecutionContext.kt` | ~40 | 执行上下文(`project`、`toolUseId`、`abortJob`、`permissionContext`)。Claude Code 那 30+ 字段的 ToolUseContext 砍到 4 个 | +| `ToolBuilder.kt` | ~110 | `tool { }` DSL builder,对应 Claude Code 的 `buildTool()` 工厂 | +| `ToolExecutor.kt` | ~85 | `runToolUse()` — Flow-based 流式执行器,对应 Claude Code 的 `AsyncGenerator` runToolUse | +| `bash/BashTool.kt` | ~170 | 第一个具体 tool,把现有 `CommandExecutionService` 包一层;含 destructive-command 启发式检测 | + +### 新增测试 + +| 文件 | 用例数 | 覆盖 | +|---|---|---| +| `ToolExecutorTest.kt` | 7 | happy path、progress 转发、validate 失败、permission deny、permission ask(MVP 自动批准)、execute 异常、parse 异常 | + +## 验收 + +- `./gradlew compileKotlin` ✅(Corretto 17,**不能用 Java 25**) +- `./gradlew test --tests ToolExecutorTest` ✅ **BUILD SUCCESSFUL**(7 tests) +- 无新增编译 warning + +## 锁定的设计选择 + +1. **流式执行层**:`runToolUse` 返回 `Flow`,对应 Claude Code 的 AsyncGenerator。不是 suspend fun、不是 callback。 +2. **权限三态**:`PermissionResult` = `Allow(updatedInput) | Ask(reason, preview?) | Deny(reason)`。`Ask` 可携带 `PreviewResult` 供 UI 展示 dry-run。 +3. **Tool 是 singleton**:`val BashTool: Tool<...> = tool { ... }`,不是 class 继承。对应 Claude Code 的 `buildTool()` 模式。 +4. **JSON Schema 是原始 JsonObject**:MVP 不引入 schema DSL,kotlinx.serialization 的 `buildJsonObject { }` 足够用。 +5. **Progress 是 sealed class**:`Stdout / Stderr / Status` 三态。Claude Code 按 tool 类型分化(BashProgress/MCPProgress 等),MVP 不分化。 +6. **ToolExecutionContext 字段极简**:只留真用到的 4 个字段。Claude Code 有 30+ 字段,按需再加。 + +## 与老代码的关系 + +**零侵入**。`execution/` 包下 command-mode 代码一行没动: +- `CommandExecutionService.kt`(现有,已被 `BashTool` 复用) +- `ExecutionResult.kt`(现有,已被 `BashTool.toBashOutput` 适配) +- `ShellPlatform.kt`(现有,通过 `CommandExecutionService` 间接复用) + +`tools/` 包是完全新建的并行体系,cutover 时才会删 command-mode(M5)。 + +## 未做(明确 M1 不在 scope) + +- ❌ BashTool 独立测试(需要 `mockkStatic(CommandExecutionService.Companion)`,留给 M2 开工时) +- ❌ UI 接线(ChatPanel、BridgeHandler 还没接入 tools 调度层) +- ❌ FileReadTool、FileEditTool、MCPProxyTool(M2-M4) +- ❌ `PermissionResult.Ask` 的真实用户询问(当前自动批准,M3 做) +- ❌ 并发 batch 调度(`runToolUseBatch`,M2) +- ❌ Tool 注册表 / `assembleToolPool`(现在 BashTool 是孤立 val,M2 引入装配层) + +## 对 M2 的交接 + +**M2 scope**:FileReadTool + 并发 batch 执行器 + Tool 注册表。 + +**入场清单**: +1. 读 `phase2-tools-design-notes.md §3.5.3` — 并发调度的 partition 逻辑 +2. 读 `phase2-mvp-tool-specs.md §2` — FileReadTool spec +3. 决定 `runToolUseBatch` 签名(建议 `Flow` 合流) +4. 决定 `ToolExecutionContext` 是否需要加 `FileStateCache`(FileRead 用于去重已读文件) + +**已知风险**: +- `ToolExecutionContext` 字段在 M2 必然需要扩(加 `FileStateCache` 或等价物)。这是预料中的——接口字段"第一版必错"的佐证 +- 现在 `BashTool.checkPermissions` 的白名单匹配只认 `"command *"` 简单前缀,M3 引入正式权限规则时要改 + +## 参考 + +- Brief:`docs/phase2-tools-refactor-brief.md` +- Design notes:`docs/phase2-tools-design-notes.md` +- MVP specs:`docs/phase2-mvp-tool-specs.md` +- Claude Code 源码:`~/SourceLib/fishNotExist/claude-code-source/`(`src/Tool.ts`、`src/tools.ts`、`src/services/tools/toolExecution.ts`) diff --git a/docs/phase2-mvp-tool-specs.md b/docs/phase2-mvp-tool-specs.md new file mode 100644 index 0000000..990b0a1 --- /dev/null +++ b/docs/phase2-mvp-tool-specs.md @@ -0,0 +1,299 @@ +# Phase2 MVP Tool Specs + +> **范围**:phase2 发版前必须就绪的 4 个 tool。覆盖 3 家族(shell / IDE API / MCP)+ dry-run 落地。 +> **非目标**:Claude Code 的 25+ tool 不追求 parity。FileWrite、Grep、Glob、WebFetch、WebSearch、TodoWrite、Agent、Skill、Task* 等**全部 post-MVP**。 + +--- + +## 0. 通用约定 + +### 0.1 命名与包结构 +- 包:`com.github.codeplangui.tools.{bash|ide|mcp}` +- Tool 声明:`val BashTool = tool { ... }`(DSL,不是 class 继承) +- Input/Output:每个 tool 一对 data class + kotlinx.serialization JSON Schema + +### 0.2 权限策略术语 +| 术语 | 含义 | +|---|---| +| `Allow` | 直接执行 | +| `Ask` | UI 弹窗问用户,可改 input 后再决定 | +| `Deny` | 拒绝,把拒绝原因回给 LLM | +| wildcard rule | 形如 `Bash(git *)` 的规则,matcher 由 tool 自己实现 `preparePermissionMatcher` | + +### 0.3 Dry-run 落点机制(架构决策) + +Tool 可选实现 `preview()` 方法: +```kotlin +suspend fun preview(input: Input, ctx: ToolExecutionContext): PreviewResult? +``` +- 返回 `null` = 该 tool 不支持 dry-run(例如 FileRead——读操作无副作用,不需要预览) +- 返回 `PreviewResult` = 结构化描述"将要发生什么" +- harness 在 `checkPermissions` 返回 `Ask` 时,把 preview 结果放进询问对话框 +- 用户确认后进入 `call()`;如果 preview 已展示全部要发生的事,`call()` 直接 apply + +**MVP 唯一强制实现 preview 的 tool 是 `FileEditTool`**(diff 预览)。BashTool 可以有简化版(打印"将执行:`<命令>`"),MCPTool 不实现(由远端 server 决定)。 + +--- + +## 1. BashTool + +### 1.1 Input +```kotlin +data class BashInput( + val command: String, + val description: String? = null, // 简短说明,给 LLM/用户看 + val timeoutSeconds: Int = 120, // 默认 120s,最大 600s + val runInBackground: Boolean = false // MVP 先不支持,留字段但忽略 +) +``` + +### 1.2 Output +```kotlin +data class BashOutput( + val stdout: String, + val stderr: String, + val exitCode: Int, + val durationMs: Long, + val truncated: Boolean, + val interrupted: Boolean = false +) +``` +直接复用现有 `ExecutionResult` 的字段即可;MVP 不做持久化到磁盘(Claude Code 那套"大输出转 FileRead 两跳"跳过)。 + +### 1.3 元数据谓词 +- `isConcurrencySafe = false`(shell 有副作用,保守串行化) +- `isReadOnly = false`(大部分命令都可能写,不做命令解析优化) +- `isDestructive`:根据 `command` 做轻量字符串检测(含 `rm `、`mv `、`DROP `、`> /` 等),为 true 则权限层强制 `Ask` +- `interruptBehavior = 'cancel'`(用户发新消息时中断当前 shell) + +### 1.4 Permission 策略 +1. **全局 deny**(`alwaysDenyRules` 里命中)→ Deny +2. **写规则命中 + 非 destructive** → Allow(用户已授权的命令前缀如 `Bash(npm test)`) +3. **destructive** → 强制 Ask(即使 allow rule 命中) +4. **默认** → Ask +5. `preparePermissionMatcher`:实现 wildcard 匹配(`Bash(git *)` → 匹配任何 `git` 开头的命令) + +**复用现有**:workspace path 检查走 `CommandExecutionService.hasPathsOutsideWorkspace`——已经存在,不重写。 + +### 1.5 call() 实现 +- 直接委托 `CommandExecutionService.executeAsyncWithStream(command, timeoutSeconds) { line, isError -> onProgress(...) }` +- 流式输出通过 `onProgress` 上报 +- 返回值转换成 `BashOutput` + +### 1.6 preview() 实现 +```kotlin +PreviewResult( + summary = "Run: $command", + details = "Working dir: ${project.basePath}\nTimeout: ${timeoutSeconds}s", + risk = if (isDestructive(input)) Risk.HIGH else Risk.MEDIUM +) +``` +简版,不真 dry-run(shell 本身没有 dry-run 概念)。 + +### 1.7 参考 +- Claude Code:`src/tools/BashTool/BashTool.tsx` (1143 行);**不抄 `runShellCommand`**(有 sandbox 决策,我们用不上) +- 现有代码:`src/main/kotlin/com/github/codeplangui/execution/CommandExecutionService.kt`(150 行,直接复用) + +--- + +## 2. FileReadTool + +### 2.1 Input +```kotlin +data class FileReadInput( + val path: String, // 绝对路径 or 相对项目根 + val offset: Int? = null, // 起始行(1-indexed) + val limit: Int? = null // 行数 +) +``` + +### 2.2 Output +```kotlin +data class FileReadOutput( + val content: String, // 带行号前缀,格式 " 1→line content" + val path: String, // 规范化后的绝对路径 + val totalLines: Int, + val returnedLines: Int, + val truncated: Boolean +) +``` + +### 2.3 元数据谓词 +- `isConcurrencySafe = true`(纯读,无副作用) +- `isReadOnly = true` +- `isDestructive = false` + +### 2.4 Permission 策略 +极简: +1. 路径在 `project.basePath` 或 `additionalWorkingDirectories` 之内 → Allow +2. 之外 → Deny(带消息"文件不在工作区") + +不涉及 wildcard、不需要 `preparePermissionMatcher`。 + +### 2.5 call() 实现 +- 用 `LocalFileSystem.getInstance().findFileByPath(path)` 打开 +- 用 VFS 读内容(不走 Files.readAllBytes——走 IntelliJ 的编码感知路径) +- 按行切片、加行号前缀 +- 最大读取限制:10000 行或 2MB(arbitrary,向 Claude Code 对齐) +- 大文件返回 `truncated = true` + 建议 offset/limit + +### 2.6 preview() +返回 `null` — 读操作无副作用,不需要 dry-run。 + +### 2.7 参考 +- Claude Code:`src/tools/FileReadTool/FileReadTool.ts` (1183 行)、`limits.ts`、`imageProcessor.ts` +- **MVP 不做**:PDF 页读取(`pages` 参数)、Jupyter 笔记本、图像识别 +- **关键学习点**:这是第一个**不走 shell 的 handler**,验证"IDE API 家族"的抽象能跑通 + +--- + +## 3. FileEditTool + +### 3.1 Input +```kotlin +data class FileEditInput( + val path: String, + val oldString: String, // 要替换的原文 + val newString: String, // 替换成什么 + val replaceAll: Boolean = false +) +``` + +### 3.2 Output +```kotlin +data class FileEditOutput( + val path: String, + val replacementCount: Int, + val linesChanged: Int, + val diff: String // unified diff 格式 +) +``` + +### 3.3 元数据谓词 +- `isConcurrencySafe = false`(并发写同文件会冲突) +- `isReadOnly = false` +- `isDestructive = true`(修改磁盘内容) + +### 3.4 Permission 策略 +1. 路径不在工作区 → Deny +2. Plan mode 下任何 Edit → Deny +3. `acceptEdits` mode → Allow(自动批准) +4. 默认 → **Ask + 附带 preview 的 diff** + +### 3.5 call() 实现流程 +1. 读文件(复用 `FileReadTool` 的 VFS 逻辑) +2. 验证 `oldString` 在文件中唯一出现(除非 `replaceAll = true`) +3. 生成 diff 用于 preview/output +4. 写回 VFS(`VfsUtil.saveText(...)`) +5. Refresh PSI(让 IntelliJ 感知变更) +6. 返回 `FileEditOutput` + +### 3.6 preview() 实现 — **MVP dry-run 唯一强制实现** +```kotlin +PreviewResult( + summary = "Edit $path: $replacementCount replacement(s)", + details = diffText, // unified diff + risk = Risk.HIGH +) +``` +harness 在 `Ask` 流程中展示 diff,用户可选 Accept / Reject / Accept-Always(Accept-Always 写入 `alwaysAllowRules`)。 + +### 3.7 参考 +- Claude Code:`src/tools/FileEditTool/FileEditTool.ts`、`utils.ts`、`types.ts` +- 相关辅助:`src/tools/BashTool/sedEditParser.ts`(sed-like 编辑逻辑,MVP 不做) +- **MVP 不做**:多块替换(Claude Code 有 MultiEdit 变体)、basé sur regex 的替换 + +--- + +## 4. MCPProxyTool(家族,不是单个 tool) + +### 4.1 实际形态 + +MCP 不是"一个 tool",而是**一个 Tool 工厂**:每个 MCP server 提供的远端 tool,在启动时被包装成一个 `Tool` 实例,name 形如 `mcp__{server}__{tool}`。 + +```kotlin +class MCPTool( + private val mcpClient: MCPClient, + private val serverName: String, + private val remoteToolName: String, + private val remoteSchema: JsonSchema, + private val remoteDescription: String, +) : Tool { + override val name = "mcp__${serverName}__${remoteToolName}" + override val inputSchema = remoteSchema + override suspend fun call(input, ctx, ...) = mcpClient.callTool(remoteToolName, input) + // ... 其余默认 +} +``` + +### 4.2 MVP 覆盖范围 + +**做**: +- **stdio 传输**:从配置(`~/.claude-code-gui/mcp.json` 或插件 settings)读 server 列表,每个 server = 一条 `command + args`,spawn 子进程 + stdin/stdout JSON-RPC +- **server 启动**:插件启动时连接所有配置的 server,拉取 `tools/list`,注册成 Tool +- **单次 tool 调用 roundtrip**:`tools/call` 请求 → 等结果 → 返回 +- **name 规范**:强制 `mcp__{server}__{tool}` 前缀 + +**不做**(post-MVP): +- HTTP / SSE 传输 +- OAuth / auth 头 +- server 热重连、生命周期 UI +- MCP `resources` / `prompts`(只支持 `tools`) +- server 分组 / 启用-禁用开关 UI + +### 4.3 Input / Output +透传:input 直接用 `JsonElement`(远端 schema 验证),output 也是 `JsonElement`。不做类型展开。 + +### 4.4 元数据谓词 +- `isConcurrencySafe = false`(默认保守,远端不可控) +- `isReadOnly = false`(不知道远端会做什么) +- `isDestructive = false`(同上,除非 server 通过 `_meta` 声明) + +### 4.5 Permission 策略 +1. Deny rule 命中 `mcp__server` 前缀 → 该 server 所有 tool 都 filter 掉(不进 prompt) +2. Deny rule 命中 `mcp__server__tool` 精确 → 只禁该 tool +3. 默认 → Ask(首次使用),用户可选 Accept-Always 加白名单 + +这和 Claude Code 的 `filterToolsByDenyRules` 语义一致。 + +### 4.6 preview() 实现 +返回 `null` — MCP tool 的 preview 应该由远端 server 负责(通过 `tools/call` with dry-run flag),MVP 阶段不做。 + +### 4.7 MVP 成功标准 +- 能配一个外部 stdio MCP server(建议用 `@modelcontextprotocol/server-everything` 做 smoke test) +- 插件启动时能连上、能拉 tools、能 list 出来 +- LLM 能调一个 mcp tool,结果能回对话流 + +### 4.8 参考 +- Claude Code:`src/tools/MCPTool/MCPTool.ts`、`src/services/mcp/`(MVP 开工前需另读) +- 标准:https://modelcontextprotocol.io/ +- **待定**:Kotlin 侧有没有 MCP SDK,还是自己写 stdio JSON-RPC + +--- + +## 5. Scope 边界总结 + +| Tool | 开发优先级 | 预估复杂度 | 阻塞项 | +|---|---|---|---| +| BashTool | P0 | 低(复用 CommandExecutionService) | 无 | +| FileReadTool | P0 | 低(VFS 调用 + 切片) | 无 | +| FileEditTool | P1 | 中(diff 生成 + preview UI) | 取决于 AskUserQuestion 流程怎么做 | +| MCPProxyTool | P1 | 中-高(stdio JSON-RPC + server 管理) | Kotlin 侧 MCP SDK 选型 | + +**先做 P0 两个 tool + Tool 接口骨架 + runToolUse 对应 Flow → 第一个 milestone PR**。 +P1 两个 tool 在骨架就绪后并行推。 + +## 6. 下一步 + +1. 写 Tool 接口骨架(`Tool.kt`、`ToolExecutionContext.kt`、`ToolResult.kt`、`PermissionResult.kt`、`ValidationResult.kt`、`PreviewResult.kt`) +2. 写 DSL Builder(`toolBuilder.kt` + `tool { }` 顶级函数) +3. 写 `runToolUse` 对应的 Flow-based 执行器 +4. 写第一个 tool:`BashTool`(复用 CommandExecutionService) +5. 跑通 "LLM → runToolUse → BashTool.call → Flow 流式结果" 的最小闭环 +6. **到此 milestone 1 结束**,合入 phase2 分支(不接线 UI、不替换 command-mode) + +后续 milestone: +- M2:FileReadTool + 正式的 `ToolExecutionContext` 字段敲定 +- M3:FileEditTool + preview 机制 + AskUserQuestion-like 询问流程 +- M4:MCPProxyTool + stdio 通道 +- M5:UI 接线 + 删除 command-mode(**cutover PR**) diff --git a/docs/phase2-tools-design-notes.md b/docs/phase2-tools-design-notes.md new file mode 100644 index 0000000..f448a6e --- /dev/null +++ b/docs/phase2-tools-design-notes.md @@ -0,0 +1,447 @@ +# Phase2 Tools 架构设计笔记 + +> **状态**:核心架构已摸清,可以动笔画 Kotlin 抽象。基于 Claude Code v2.1.88 反编译源码(`~/SourceLib/fishNotExist/claude-code-source/`)的一手精读 + agent 精读 + 交叉验证。 + +## 1. 信息来源与可信度 + +| 文件 | 读法 | 可信度 | +|---|---|---| +| `src/Tool.ts` (792 行) | 一手完整读 | 高 | +| `src/tools.ts` (389 行) | 一手完整读 | 高 | +| `src/tools/BashTool/BashTool.tsx` (1143 行) | agent 精读,主线程抽查关键行 | 中-高 | +| `src/tools/FileReadTool/FileReadTool.ts` (1183 行) | agent 精读 | 中 | +| `src/services/tools/toolExecution.ts` (1745 行) | agent 精读 + 主线程抽查 L337 `runToolUse` 签名 | 中-高 | +| `src/services/tools/toolOrchestration.ts` (188 行) | agent 精读 | 中 | + +**作废的二手结论**:之前一个 Explore agent 给的"7 必选 + 13 可选字段"的说法不准。真实接口约 50 个字段/方法。 + +--- + +## 2. Tool 接口真实面貌(基于 Tool.ts 一手读) + +### 2.1 核心类型:`Tool` (L362-695) + +按职责分 7 组: + +#### A. 身份与 schema +| 字段 | 文件位置 | Kotlin 决策 | +|---|---|---| +| `name: string` | L456 | 保留(必选) | +| `aliases?: string[]` | L371 | 保留(用于重命名兼容) | +| `searchHint?: string` | L378 | 保留(ToolSearch 需要) | +| `inputSchema: Input` (Zod) | L394 | 换成 **JSON Schema DSL**(Kotlin 侧用 kotlinx.serialization 或手写 builder) | +| `inputJSONSchema?` | L397 | 保留(MCP tool 直接声明 JSON Schema 时用) | +| `outputSchema?` | L400 | 可选保留 | + +#### B. 核心生命周期(**真正的必选四件套**) +| 方法 | 文件位置 | 作用 | +|---|---|---| +| `call(args, context, canUseTool, parentMessage, onProgress)` | L379-385 | 主执行器。返回 `Promise>` | +| `validateInput?(input, context)` | L489-492 | 权限检查前的句法/语义校验,返回 `ValidationResult` | +| `checkPermissions(input, context)` | L500-503 | tool 特定的权限决策,返回 `PermissionResult` | +| `mapToolResultToToolResultBlockParam(content, toolUseID)` | L557 | 把输出序列化成 Anthropic API 的 `tool_result` block | + +#### C. prompt / 描述(LLM 面向) +| 方法 | 文件位置 | 说明 | +|---|---|---| +| `description(input, options)` | L386 | 生成自然语言描述(给 LLM 的) | +| `prompt(options)` | L518 | 返回该 tool 的 system-prompt 段落 | +| `userFacingName(input)` | L524 | 用户看到的 tool 名 | +| `getActivityDescription?(input)` | L546 | spinner 显示用(如 "Reading src/foo.ts") | +| `getToolUseSummary?(input)` | L539 | 紧凑视图摘要 | + +#### D. 元数据谓词(大多可用默认值) +- `isEnabled()` L403 — default `true` +- `isConcurrencySafe(input)` L402 — default `false` +- `isReadOnly(input)` L404 — default `false` +- `isDestructive?(input)` L406 — default `false` +- `isOpenWorld?(input)` L434 +- `interruptBehavior?()` L416 — `'cancel' | 'block'`,默认 `'block'` +- `isSearchOrReadCommand?(input)` L429 — UI 折叠用 +- `requiresUserInteraction?()` L435 +- `isMcp?`, `isLsp?` L436-437 + +#### E. Hook / 权限辅助 +| 字段 | 文件位置 | Kotlin 决策 | +|---|---|---| +| `preparePermissionMatcher?(input)` | L514 | **保留**——为 hook 模式匹配(如 `"Bash(git *)"`)准备闭包 | +| `backfillObservableInput?(input)` | L481 | 保留——给 observer 看的 input 可与 API-bound input 不同 | +| `getPath?(input)` | L506 | 保留——文件路径 tool 通用接口 | +| `toAutoClassifierInput(input)` | L556 | **可砍**——ML 分类器是 Ant 内部特性,默认返回 `''` 等价于不进分类器 | + +#### F. 资源限制 +| 字段 | 文件位置 | 说明 | +|---|---|---| +| `maxResultSizeChars` | L466 | **必填**。超过这个就持久化到磁盘,返回预览。设 `Infinity` 则永不持久化(如 Read) | +| `shouldDefer?` | L442 | 是否走 ToolSearch 延迟加载 | +| `alwaysLoad?` | L449 | 与 shouldDefer 对立,保证首轮就进 prompt | +| `strict?` | L472 | API 侧严格模式 | + +#### G. UI 渲染(14 个方法,**全部砍掉**) +`renderToolUseMessage`、`renderToolResultMessage?`、`renderToolUseProgressMessage?`、`renderToolUseQueuedMessage?`、`renderToolUseRejectedMessage?`、`renderToolUseErrorMessage?`、`renderToolUseTag?`、`renderGroupedToolUse?`、`isResultTruncated?`、`isTransparentWrapper?`、`extractSearchText?`、`userFacingNameBackgroundColor?`、等等——全部是 React 输出。JetBrains 用 **IntelliJ UI DSL / Swing / JCEF webview** 替代。 + +### 2.2 关键支撑类型 + +#### `ToolUseContext` (L158-300) — 超级上下文对象 +- ~30 个字段:abort controller、file state、app state、MCP clients、hooks、agent id、message list、限额配置、progress 回调、session 生命周期钩子等 +- **Kotlin 对应物**:需要一个 `ToolExecutionContext` data class,但大部分字段不要照抄——挑本插件真正要用的(abort、file state cache、permission context、message list、app state 就够了) + +#### `ToolPermissionContext` (L123-138) +``` +mode: PermissionMode +additionalWorkingDirectories: Map +alwaysAllowRules / alwaysDenyRules / alwaysAskRules: ToolPermissionRulesBySource +isBypassPermissionsModeAvailable: boolean +shouldAvoidPermissionPrompts?: boolean +prePlanMode?: PermissionMode +``` +**Kotlin 几乎可以 1:1 移植**——这是权限的全局配置。 + +#### `ValidationResult` (L95-101) +``` +| { result: true } +| { result: false, message: string, errorCode: number } +``` +**Kotlin**:`sealed class ValidationResult { Ok; data class Failed(...) }` + +#### `ToolResult` (L321-336) +``` +data: T +newMessages?: Message[] // tool 可以注入新消息到对话 +contextModifier?: (ctx) => ctx // 修改上下文(非 concurrency-safe tool 专用) +mcpMeta?: { _meta?, structuredContent? } +``` +**Kotlin**:核心是 `data`,其他按需加。 + +#### `ToolCallProgress

` (L338-340) +`(progress: { toolUseID, data: P }) => void` — 流式回调。 + +### 2.3 `buildTool` 工厂模式 (L757-792) + +**关键设计模式**:每个 tool 不是 class 实例,而是 `buildTool({...})` 返回的对象。工厂为 7 个常省略的键填默认值: +- `isEnabled: () => true` +- `isConcurrencySafe: () => false` +- `isReadOnly: () => false` +- `isDestructive: () => false` +- `checkPermissions: () => { behavior: 'allow', updatedInput }` +- `toAutoClassifierInput: () => ''` +- `userFacingName: () => name` + +**Kotlin 对应物**(建议): +```kotlin +fun tool(block: ToolBuilder.() -> Unit): Tool = ToolBuilder().apply(block).build() + +// 使用: +val BashTool = tool { + name = "Bash" + inputSchema = bashInputSchema + call { args, ctx -> ... } + checkPermissions { input, ctx -> ... } + isDestructive = { it.command.containsDestructiveOp() } +} +``` +Kotlin DSL + `apply` 比 TS 的对象 spread 更自然。 + +--- + +## 3. Tool 装配与注册(tools.ts 一手读) + +### 3.1 关键函数 + +| 函数 | 文件位置 | 职责 | +|---|---|---| +| `getAllBaseTools()` | L193-251 | 返回硬编码的 tool 数组(40+ 个),feature flag 在此 gate | +| `filterToolsByDenyRules(tools, ctx)` | L262-269 | **预过滤**:deny rule 匹配的 tool 根本不进 prompt | +| `getTools(permissionContext)` | L271-327 | 单环境入口:simple mode / REPL mode / 普通 mode | +| `assembleToolPool(ctx, mcpTools)` | L345-367 | built-in + MCP 合并,按 name 排序(prompt-cache 稳定性),built-in 优先 | +| `getMergedTools(ctx, mcpTools)` | L383-389 | 简单合并,用于 token 估算 | + +### 3.2 关键设计选择 + +1. **Tool 是 singleton,不是 class**:`export const BashTool = buildTool({...})`。Kotlin 对应 `object BashTool : Tool`,或 `val BashTool = tool { ... }`。 + +2. **注册即硬编码数组**:没有装饰器、没有动态发现、没有插件系统(对基础 tool 而言)。MCP tool 是例外,走独立通道。 + +3. **Feature flag 在注册层 gate**:tool 本身总被 import,是否包含进 `getAllBaseTools()` 由 `feature('FLAG')` / `process.env` 决定。**我们插件可用类似模式:`plugin.xml` 或 settings 里的开关决定哪些 tool 注册进 pool**。 + +4. **deny-rule 预过滤是关键架构选择**:全局禁用的 tool **不进 prompt**,省 token + 防 LLM 乱试。这是值得抄的设计。 + +5. **MCP tool 与 built-in 平级**:`assembleToolPool` 把两者合并,built-in 名字冲突时胜出。Kotlin 侧 MCP 路由应该是独立模块,最后在装配层合流。 + +6. **排序保证 prompt-cache 稳定**:built-in 排完 + MCP 排完再 concat,`uniqBy` 保持插入顺序。**我们 MVP 可不做**——除非真的接 Anthropic prompt cache,否则只是工程洁癖。 + +--- + +## 3.5 执行入口层(toolExecution.ts + toolOrchestration.ts 一手抽查 + agent 精读) + +### 3.5.1 顶层入口:`runToolUse()` — 流式执行器 + +**签名**(`toolExecution.ts:337`,已抽查验证): +```typescript +export async function* runToolUse( + toolUse: ToolUseBlock, + assistantMessage: AssistantMessage, + canUseTool: CanUseToolFn, + toolUseContext: ToolUseContext, +): AsyncGenerator +``` + +**关键架构事实**:**这是 `AsyncGenerator`,不是普通 async 函数**。执行过程边跑边 yield 消息更新(进度、结果、错误)。**Kotlin 对应物是 `Flow`,不是 `suspend fun`**。这影响整个执行层的建模方式。 + +**路由逻辑**(L343-356): +1. 先在 `toolUseContext.options.tools` 查找(模型已知的 tool 列表) +2. 没找到 → 回退到 `getAllBaseTools()` 查 alias(向后兼容废弃别名) +3. 还找不到 → yield 错误 `tool_result` block(L396-408) + +### 3.5.2 权限/Hook 编排(最容易低估的复杂度) + +**流水线**(`toolExecution.ts`): +``` +用户输入 → validateInput() [Tool 方法] + ↓ +PreToolUse hooks (L800-820) + ↓ +canUseTool via resolveHookPermissionDecision (L921) + ├─ hooks 决策 + ├─ 交互式对话(如需) + └─ 自动分类器(Ant 特性) + ↓ +若 deny → PermissionDenied hooks (L1081) + ↓ +tool.call() (L1207) + ↓ +mapToolResultToToolResultBlockParam() (L1280+) + ↓ +yield tool_result block +``` + +**异步管道**:每一层都是 async for-await,中间可插入外部逻辑(如 `SedEditPermissionRequest` 改写 input)。不是一次性决策。 + +**Kotlin 设计影响**:权限链必须支持**中间修改 input**(`PermissionResult.Allow` 带 `updatedInput`)。不能假设 input 是不可变的。 + +### 3.5.3 并发 tool_use 处理 + +**一轮模型可能返回 ≥1 个 tool_use**。`toolOrchestration.ts` 处理这个: + +| 函数 | 位置 | 职责 | +|---|---|---| +| `partitionToolCalls()` | L91-116 | 按 `isConcurrencySafe(input)` 把 tool_use 列表切成"安全并发组"和"必须串行组" | +| `runToolsConcurrently()` | L152-177 | 并发执行安全组,最大并发数 `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(默认 10,L10) | +| 串行执行路径 | L118-150 | 非安全 tool 逐个跑,每跑完一个更新 context | + +**陷阱**:`isConcurrencySafe` 返回 `false` 的 tool(如 BashTool、FileEditTool)会**串行化整批 batch**。如果一轮里混了 safe 和 unsafe tool,partitioning 的顺序/聚合策略会影响用户体验。 + +**上下文隔离**:并发执行时,每个 tool 可能 mutate context(`ToolResult.contextModifier`)。`queuedContextModifiers` 缓冲保证串行 tool 看到的 context 是前面 tool 更新后的版本。 + +### 3.5.4 BashTool.call() 实现参考(BashTool.tsx:624-750) + +| 步骤 | 位置 | 做什么 | +|---|---|---| +| 1. 创建输出累加器 | L636 | `EndTruncatingAccumulator`(保留尾部) | +| 2. 调用执行器 | L646 | `runShellCommand()` 异步生成器,内部 spawn + stream | +| 3. 消费生成器 | L663 | 每个 yield 点调 `onProgress()` 上报流式进度 | +| 4. 收尾判断 | L683-720 | exit code、中断状态、stderr 处理 | +| 5. 大输出持久化 | L728-743 | 超阈值写 `tool-results/` 目录,只返回路径 | + +**Kotlin 移植难点**(不能直接抄的部分): +- `runShellCommand()` 封装了本地进程管理、timeout、sandbox 决策——**JetBrains 侧必须用 `GeneralCommandLine` + `ProcessHandler` 重写** +- 大输出磁盘持久化的"两跳读取"(Bash 写文件 → FileRead 读文件)在 IDE 里冗余,改成**内存缓冲 + 滚动截断**即可 + +### 3.5.5 FileReadTool 作为"最小 tool"样本(FileReadTool.ts) + +**实际用到的接口字段**(从 1183 行代码中可见): +- `name`、`inputSchema`、`outputSchema`、`validateInput`、`checkPermissions`、`call`、`prompt`、`description` +- `isConcurrencySafe` **硬编码 `true`**(L373,非函数)——Kotlin DSL 允许同样写法 +- `isReadOnly` **硬编码 `true`**(L376) +- 权限层只调一个辅助:`checkReadPermissionForTool()` (L400),**远比 BashTool 的 7 块权限辅助简单** + +**推论:Kotlin Tool 接口真实必选集(从实际 tool 样本反推,而非从接口声明反推)**: + +``` +必选(所有 tool 都填): +- name +- inputSchema (+ 可选 outputSchema) +- call +- checkPermissions +- description +- prompt +- mapToolResultToToolResultBlockParam + +可选但常填: +- validateInput (复杂 input 的 tool 必填) +- isReadOnly / isConcurrencySafe (并发决策依赖) +- isDestructive (权限提示决策用) + +几乎不用填(有默认): +- isEnabled / isOpenWorld / requiresUserInteraction / interruptBehavior +- 所有 render* 方法(Kotlin 侧全砍) +- toAutoClassifierInput(ML 分类器,不做) +``` + +这比之前二手摘要给的"7 必选 + 13 可选"更贴近实际。 + +--- + +## 4. Kotlin Tool 接口建议签名(修订版) + +**关键修订**:原本把 `call()` 写成 `suspend fun` 是错的——Claude Code 的 `runToolUse` 是 `AsyncGenerator`,执行过程持续 yield 消息。Kotlin 对应 `Flow`。 + +```kotlin +// Tool 本身的 call() 仍然是一次性 suspend(返回最终结果) +// 但调度层(runToolUse 对应物)是 Flow-producing +interface Tool { + val name: String + val aliases: List get() = emptyList() + val inputSchema: JsonSchema + val outputSchema: JsonSchema? get() = null + val maxResultSizeChars: Int get() = DEFAULT_MAX_RESULT_SIZE + + // 核心方法 + suspend fun call( + input: Input, + context: ToolExecutionContext, + canUseTool: CanUseToolFn, + onProgress: ((Progress) -> Unit)? = null, + ): ToolResult + + suspend fun validateInput(input: Input, ctx: ToolExecutionContext): ValidationResult = ValidationResult.Ok + + suspend fun checkPermissions(input: Input, ctx: ToolExecutionContext): PermissionResult = + PermissionResult.Allow(input) // 默认放行,委托给全局 permission 层 + + fun mapResultToApiBlock(output: Output, toolUseId: String): ToolResultBlock + + // prompt/描述(LLM 面向) + suspend fun description(input: Input, options: PromptOptions): String + suspend fun prompt(options: PromptOptions): String + + // 元数据谓词(默认值) + fun isEnabled(): Boolean = true + fun isConcurrencySafe(input: Input): Boolean = false // 默认保守:串行化 + fun isReadOnly(input: Input): Boolean = false + fun isDestructive(input: Input): Boolean = false + + // Hook 辅助 + fun getPath(input: Input): String? = null + suspend fun preparePermissionMatcher(input: Input): ((String) -> Boolean)? = null + + // 用户侧显示 + fun userFacingName(input: Input?): String = name + fun getActivityDescription(input: Input?): String? = null +} + +sealed class ValidationResult { + object Ok : ValidationResult() + data class Failed(val message: String, val errorCode: Int) : ValidationResult() +} + +sealed class PermissionResult { + data class Allow(val updatedInput: Any) : PermissionResult() + data class Deny(val message: String) : PermissionResult() + data class Ask(val message: String) : PermissionResult() +} + +data class ToolResult( + val data: T, + val newMessages: List = emptyList(), + val contextModifier: ((ToolExecutionContext) -> ToolExecutionContext)? = null, +) + +// 调度层入口(对应 runToolUse)——Flow 而非 suspend fun +fun runToolUse( + toolUse: ToolUseBlock, + tools: List>, + canUseTool: CanUseToolFn, + context: ToolExecutionContext, +): Flow // 流式发出 progress、permission-ask、result、error + +// 并发调度层(对应 partitionToolCalls + runToolsConcurrently) +suspend fun runToolUseBatch( + toolUses: List, + tools: List>, + canUseTool: CanUseToolFn, + context: ToolExecutionContext, + maxConcurrency: Int = 10, +): Flow +``` + +**Builder DSL(对应 `buildTool`)**: +```kotlin +inline fun tool( + block: ToolBuilder.() -> Unit, +): Tool = ToolBuilder().apply(block).build() + +// 使用: +val BashTool = tool { + name = "Bash" + inputSchema = buildJsonSchema { ... } + call { input, ctx -> ... } + checkPermissions { input, ctx -> ... } + isConcurrencySafe { false } + isDestructive { it.command.containsDestructiveOp() } + // 未指定的字段自动用默认 +} +``` + +**`ToolExecutionContext` 初版字段**(基于 Claude Code `ToolUseContext` L158-300 的精简子集): +```kotlin +data class ToolExecutionContext( + val abortSignal: Job, // 中断 + val permissionContext: ToolPermissionContext, // 全局权限 + val messages: List, // 对话历史 + val project: Project, // IntelliJ Project(替代 appState) + val fileStateCache: FileStateCache, // 文件状态缓存(Read/Edit 协作用) + val setToolJSX: ((ToolJSX?) -> Unit)? = null, // 可空,用于 AskUserQuestion 类 + val mcpClients: List = emptyList(), // MCP 接入 + val toolUseId: String, + val onProgress: ((Progress) -> Unit)? = null, + // Claude Code 里还有 ~25 个字段,MVP 不要 +) +``` + +--- + +## 5. 已经确定的架构决策 + +1. ✅ Tool = singleton object / 工厂构建,**不是 class 继承** +2. ✅ 注册 = 硬编码数组,feature flag 在装配层 gate,**不是装饰器** +3. ✅ 权限三态(Allow / Ask / Deny)+ deny-rule 预过滤进 prompt 前 + permission 可修改 input +4. ✅ Schema 用 JSON Schema(kotlinx.serialization 友好),不照抄 Zod +5. ✅ UI 层全部砍掉,用 IntelliJ UI DSL +6. ✅ ML 分类器(toAutoClassifierInput)暂不做,默认返回空 +7. ✅ `maxResultSizeChars` 必选;Claude Code 的磁盘持久化在插件里简化为**内存缓冲 + 滚动截断** +8. ✅ **执行层是 Flow-based(流式),不是 suspend fun**。`runToolUse` 对应 `Flow`。 +9. ✅ 并发调度:按 `isConcurrencySafe` partition,安全组并发(默认上限 10),不安全组串行(每跑完更新 context) +10. ✅ Hook 编排是 async 管道(PreToolUse → canUseTool → PermissionDenied),每一层可拦截和修改 input + +## 6. 还没想透的点 + +- [ ] `Progress` 类型怎么设计——Claude Code 按 tool 类型分了 `BashProgress` / `MCPProgress` / `REPLToolProgress` 等。Kotlin 侧走泛型 `P : Progress` 还是 sealed class? +- [ ] `ToolUpdate`(Flow 的元素)的具体形状:进度、权限询问、结果、错误都走同一个 Flow?还是拆成多个 channel? +- [ ] MCP 接入具体走什么库(Kotlin 侧有没有 MCP SDK,还是自己实现 stdio/HTTP+SSE?) +- [ ] JCEF webview vs 纯 Swing 做 tool 结果渲染——现有 CodePlanGUI 是 JCEF 路线,tool 结果是直接塞到 webview 的对话流里,还是 tool 层生成中立格式由 webview 渲染? +- [ ] `ToolResult.contextModifier` 在 Kotlin 下用什么类型?(纯函数还是 sealed 行为?) +- [ ] 一次性切换策略下,现有 command-mode 的 `CommandExecutionService` 是否可以拆分出底层 `ProcessRunner` 给新的 `BashTool` 复用 + +## 7. Milestone 进展 + +- **M1(骨架)** ✅ 已完成,详见 `docs/phase2-milestone-1-summary.md` + - Tool 接口 + ToolBuilder DSL + runToolUse Flow 执行器 + BashTool 骨架 + - 7/7 单测通过,编译绿,和 command-mode 零耦合 +- **M2(FileReadTool + 并发调度)** ⏸️ 待开工 + - 加 `runToolUseBatch`(按 `isConcurrencySafe` partition) + - 加 Tool 注册表(对应 Claude Code `getAllBaseTools` / `assembleToolPool`) + - 加 FileReadTool,验证"非 shell handler"家族可跑 + - `ToolExecutionContext` 可能需要扩 `FileStateCache` 字段 +- **M3(FileEditTool + 真询问流程)** ⏸️ 待开工 + - 补 preview() 真实落地(diff 渲染) + - 替换 `runToolUse` 里 Ask 自动批准的占位,接真 UI 回调 + - 正式权限规则系统(替代 BashTool 现在简单的前缀匹配) +- **M4(MCPProxyTool)** ⏸️ 待开工 +- **M5(cutover)** ⏸️ 待开工:UI 接线 + 删 command-mode,独立 PR + +## 8. 参考 + +- 源码根目录:`~/SourceLib/fishNotExist/claude-code-source/` +- 相关文件:`src/Tool.ts`、`src/tools.ts`、`src/tools/BashTool/`、`src/tools/FileReadTool/` +- 前置文档:`docs/phase2-tools-refactor-brief.md` diff --git a/docs/phase2-tools-refactor-brief.md b/docs/phase2-tools-refactor-brief.md new file mode 100644 index 0000000..e821ec1 --- /dev/null +++ b/docs/phase2-tools-refactor-brief.md @@ -0,0 +1,116 @@ +# Phase2 Tools 重构 Brief + +> **项目性质**:架构升级,非用户痛点驱动。 +> 对端用户无感;收益在工程可维护性和后续能力扩展。 + +## 升级动因 + +1. **统一操作抽象**:phase2 起,所有插件能力对外呈现为 tool,代码库消除"命令 vs 非命令"双轨。 +2. **为后续能力铺路**:IDE API 操作、MCP 外接、dry-run 预览——这些在 command-mode 下要么做不了、要么代价极高。 +3. **权限/安全解耦**:现有权限机制与 shell 命令文本耦合;tools 架构允许按"执行器家族"独立策略。 + +## 现架构局限 + +- **command-mode 本质是 shell 路径**:解析用户/LLM 输入的命令字符串 → spawn 进程。非 shell 操作(IDE action、MCP 代理)无法纳入。 +- **生命周期扁平**:validate / permission / execute / result 散落在命令处理链路中,没有可复用的分段抽象。 +- **扩展点缺失**:想加 preview、想接 MCP、想让某类操作走 IDE API,都得改 command-mode 主干,影响面大。 + +## 新架构不变量 + +1. **Tool 接口与执行器解耦**:handler 可以是 shell、IDE action、MCP 代理,接口层不关心。 +2. **权限正交于执行器**:每个 tool 自己声明权限策略(allow / ask / deny + 规则匹配),框架统一调度。 +3. **Dry-run 是 lifecycle 阶段**:不是独立 tool 家族,是任意 tool 可选的"预览再执行"两段式。 +4. **Tool 定义四字段最小集**:`{name, description, inputSchema, handler}` + 可选 `{validateInput, permission, preview, onProgress}`。 +5. **没有隐藏执行路径**:任何对工作区、文件、shell、IDE 的写操作都必须走 Tool 抽象。 + +## Scope:必须覆盖 + +### 执行器家族(≥3) +- **Shell executor tool**:替代 command-mode 现有能力 +- **IDE action tool**:至少一个(Find Usages / 文件跳转 / PSI 读取,任选一个做 MVP) +- **MCP proxy tool**:路由到用户配置的 MCP server,tool 名按 `mcp__{server}__{tool}` 命名 + +### 横切能力 +- Permission 三态(allow / ask / deny)+ 规则匹配 +- Dry-run / preview 钩子(至少一个 tool 真用上,不要只留接口) +- 进度回调(流式输出对应) + +## Non-goals + +- React/Ink UI 层(Claude Code 有,我们用 IntelliJ UI DSL) +- ML 安全分类器(手写规则即可) +- 跨进程 REPL、Worktree、Skill 加载器等 Claude Code 特色 tool +- 完整复制 Claude Code 25+ 内置 tool(按 phase2 需要加,不追求 parity) +- 向后兼容 command-mode 的用户配置/历史记录(一次性切换,老配置迁移策略另议) + +## 迁移策略:并行实现 + 原子切换 + +``` +phase2 分支生命周期 +├─ [开发期] command-mode 保持可用(不动) +│ tools/ 新包独立开发,每个 tool 独立可测 +├─ [cutover] 单一 PR 完成: +│ - UI 入口切到 tools +│ - 删除 command-mode 代码 +│ - 配置/文档更新 +└─ [合 master] phase2 → master → release +``` + +- 新代码落在 `com.github.codeplangui.tools` 包(或 phase2 决定的位置),与 command-mode 代码物理隔离 +- cutover 之前,任何时候 phase2 分支都能构建、能运行、command-mode 能用 +- cutover commit 独立成 PR,便于 review 和回滚 + +## 风险与对策 + +### 1. 接口第一版必错 +**Why**:基于 2-3 个设想的 tool 设计接口,往往在第 4-5 个 tool 落地时暴露缺字段(例如 undo 粒度、跨 tool 编排)。越晚改成本越高。 +**对策**: +- 开发前 2 周不要堆 tool 数量,先把 `BashTool` + 一个最简单的 IDE action tool 做出来,互相对拍接口 +- 接口字段未被至少 2 个 tool 真实使用前,不加入公共抽象层 + +### 2. Cutover 阶段爆雷集中 +**Why**:并行开发没暴露的问题(UI 绑定、消息协议、错误处理路径)会在接线瞬间全部冒出来。 +**对策**: +- cutover 前做一份 end-to-end 冒烟测试清单(每个家族至少 1 条真实用例) +- cutover commit 本身独立 PR,与功能开发解耦 + +### 3. YAGNI / 过度抽象 +**Why**:Claude Code 的 Tool 接口有 20+ 字段(7 必选 + 13 可选),完整抄会写很多用不到的代码。 +**对策**: +- 初版 Kotlin 接口只含必要字段,其余按需加 +- 参考清单记录在 `docs/phase2-tools-design-notes.md`(待开工前写),但实现时走最小化 + +### 4. 工期 +**Why**:一次性切换意味着 MVP tool 清单全部就绪才能发布;任何一个 tool 卡住都会阻塞整个 phase2 发版。 +**对策**: +- 锁定 MVP tool 清单(建议 3-5 个,覆盖 3 个家族即可),其余排到 phase2.1/phase2.2 增量发布 +- 每个 MVP tool 在 milestone review 前明确 DoD(definition of done) + +### 5. 未被问题驱动的抽象选型 +**Why**:这是架构升级项目,最大的风险是"造了框架没人用"。 +**对策**: +- 每个抽象决策(interface 新字段、新 lifecycle 钩子)必须能列举出 phase2 内至少一个落点 +- 不能列举的,进 "post-phase2 考虑" 清单,不进 phase2 scope + +## 成功标准(Definition of Done) + +- [ ] `command-mode` 代码从仓库中完全删除,没有第二条操作执行路径 +- [ ] 至少 3 个家族(shell / IDE action / MCP)各有一个可用 tool,覆盖真实使用场景 +- [ ] Permission 层对 3 个家族一视同仁,不是 shell 专属补丁的延伸 +- [ ] 至少一个 tool 真实使用了 dry-run / preview 能力(不是只挂接口) +- [ ] phase2 分支 CI 全绿;cutover PR 独立且 review 过 +- [ ] 用户侧从 command-mode 到 tools 的切换不需要手动迁移配置(或有明确迁移文档) + +## 下一步 + +1. 写 `docs/phase2-tools-design-notes.md`——把 Tool 接口的 Kotlin 签名、权限模型、生命周期钩子定下来 +2. 确定 MVP tool 清单(每个家族选一个) +3. 在 phase2 分支上开第一个 milestone PR(接口骨架 + BashTool) + +## 参考 + +- Claude Code v2.1.88 反编译源码:`~/SourceLib/fishNotExist/claude-code-source/` + - `src/Tool.ts` — 完整 Tool 接口(20+ 字段) + - `src/tools.ts` — 注册/装配/过滤 + - `src/tools/BashTool/` — 执行器 + 权限/安全参考 +- Claw-code 元数据镜像:`~/SourceLib/fishNotExist/claw-code/src/reference_data/tools_snapshot.json`(仅作清单参考,无执行逻辑) diff --git a/src/main/kotlin/com/github/codeplangui/tools/Tool.kt b/src/main/kotlin/com/github/codeplangui/tools/Tool.kt new file mode 100644 index 0000000..5d4b0c6 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/tools/Tool.kt @@ -0,0 +1,72 @@ +package com.github.codeplangui.tools + +import kotlinx.serialization.json.JsonElement + +/** + * The core Tool abstraction. A `Tool` is the unit the LLM can invoke via the + * Anthropic tool_use protocol, wrapping either a shell command, an IDE action, or + * an MCP proxy call. + * + * Modeled after Claude Code's `Tool` (Tool.ts:362-695), pared down + * to the fields an MVP actually needs. See docs/phase2-tools-design-notes.md §3 for + * the full mapping. + * + * Each concrete tool is a singleton (top-level `val` or `object`), constructed via + * the [tool] DSL in ToolBuilder.kt. + */ +interface Tool { + + val name: String + val description: String + val inputSchema: JsonSchema + val aliases: List get() = emptyList() + + /** Maximum characters before result is truncated or persisted. Tools that + * self-bound their output (e.g. FileRead with offset/limit) may return + * `Int.MAX_VALUE`. */ + val maxResultSizeChars: Int get() = DEFAULT_MAX_RESULT_SIZE + + /** Parse the raw JSON input the LLM emitted into this tool's typed input shape. */ + fun parseInput(raw: JsonElement): Input + + /** Pre-permission syntactic / semantic check. Default: no-op. */ + suspend fun validateInput(input: Input, context: ToolExecutionContext): ValidationResult = + ValidationResult.Ok + + /** Tool-specific permission decision. Default: Allow (defer to framework-wide rules). */ + suspend fun checkPermissions(input: Input, context: ToolExecutionContext): PermissionResult = + PermissionResult.Allow(updatedInput = emptyJsonObject()) + + /** Optional dry-run preview. Null ⇒ this tool doesn't support preview + * (e.g. read operations). See docs/phase2-mvp-tool-specs.md §0.3. */ + suspend fun preview(input: Input, context: ToolExecutionContext): PreviewResult? = null + + /** Main executor. Implementations emit progress via [onProgress] while running. */ + suspend fun call( + input: Input, + context: ToolExecutionContext, + onProgress: (Progress) -> Unit = {}, + ): ToolResult + + /** Serialize the typed output into the Anthropic tool_result shape. */ + fun mapResultToApiBlock(output: Output, toolUseId: String): ToolResultBlock + + // --- Metadata predicates (default values let tools omit these) --- + + fun isEnabled(): Boolean = true + fun isConcurrencySafe(input: Input): Boolean = false + fun isReadOnly(input: Input): Boolean = false + fun isDestructive(input: Input): Boolean = false + + // --- Display helpers --- + + fun userFacingName(input: Input?): String = name + fun getActivityDescription(input: Input?): String? = null + + companion object { + const val DEFAULT_MAX_RESULT_SIZE: Int = 40_000 + } +} + +private fun emptyJsonObject(): JsonElement = + kotlinx.serialization.json.JsonObject(emptyMap()) diff --git a/src/main/kotlin/com/github/codeplangui/tools/ToolBuilder.kt b/src/main/kotlin/com/github/codeplangui/tools/ToolBuilder.kt new file mode 100644 index 0000000..84488c6 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/tools/ToolBuilder.kt @@ -0,0 +1,106 @@ +package com.github.codeplangui.tools + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * DSL for declaring a tool without writing a full `Tool` subclass. Corresponds to + * Claude Code's `buildTool()` factory (Tool.ts:757-792), which fills sensible defaults + * for commonly-stubbed methods. + * + * Usage: + * ``` + * val MyTool: Tool = tool { + * name = "my_tool" + * description = "..." + * inputSchema = buildJsonObject { ... } + * parse { raw -> Json.decodeFromJsonElement(raw) } + * call { input, ctx, progress -> ToolResult(MyOutput(...)) } + * mapResult { output, id -> ToolResultBlock(id, content = "...") } + * } + * ``` + * + * Everything not set falls back to defaults: `isEnabled = true`, + * `isConcurrencySafe = false`, `checkPermissions = Allow`, etc. + */ +class ToolBuilder { + var name: String = "" + var description: String = "" + var aliases: List = emptyList() + var inputSchema: JsonSchema = JsonObject(emptyMap()) + var maxResultSizeChars: Int = Tool.DEFAULT_MAX_RESULT_SIZE + + private var parseFn: ((JsonElement) -> Input)? = null + private var validateFn: (suspend (Input, ToolExecutionContext) -> ValidationResult)? = null + private var permissionFn: (suspend (Input, ToolExecutionContext) -> PermissionResult)? = null + private var previewFn: (suspend (Input, ToolExecutionContext) -> PreviewResult?)? = null + private var callFn: (suspend (Input, ToolExecutionContext, (Progress) -> Unit) -> ToolResult)? = null + private var mapResultFn: ((Output, String) -> ToolResultBlock)? = null + + private var isEnabledFn: () -> Boolean = { true } + private var isConcurrencySafeFn: (Input) -> Boolean = { false } + private var isReadOnlyFn: (Input) -> Boolean = { false } + private var isDestructiveFn: (Input) -> Boolean = { false } + private var userFacingNameFn: ((Input?) -> String)? = null + private var activityDescriptionFn: (Input?) -> String? = { null } + + fun parse(block: (JsonElement) -> Input) { parseFn = block } + fun validate(block: suspend (Input, ToolExecutionContext) -> ValidationResult) { validateFn = block } + fun checkPermissions(block: suspend (Input, ToolExecutionContext) -> PermissionResult) { permissionFn = block } + fun preview(block: suspend (Input, ToolExecutionContext) -> PreviewResult?) { previewFn = block } + fun call(block: suspend (Input, ToolExecutionContext, (Progress) -> Unit) -> ToolResult) { callFn = block } + fun mapResult(block: (Output, String) -> ToolResultBlock) { mapResultFn = block } + + fun isEnabled(block: () -> Boolean) { isEnabledFn = block } + fun isConcurrencySafe(block: (Input) -> Boolean) { isConcurrencySafeFn = block } + fun isReadOnly(block: (Input) -> Boolean) { isReadOnlyFn = block } + fun isDestructive(block: (Input) -> Boolean) { isDestructiveFn = block } + fun userFacingName(block: (Input?) -> String) { userFacingNameFn = block } + fun activityDescription(block: (Input?) -> String?) { activityDescriptionFn = block } + + fun build(): Tool { + val finalName = name.ifEmpty { error("tool { } requires a non-empty name") } + val finalParse = parseFn ?: error("tool { } requires parse { } for '$finalName'") + val finalCall = callFn ?: error("tool { } requires call { } for '$finalName'") + val finalMapResult = mapResultFn ?: error("tool { } requires mapResult { } for '$finalName'") + val userFacing = userFacingNameFn ?: { _ -> finalName } + + return object : Tool { + override val name = finalName + override val description = this@ToolBuilder.description + override val aliases = this@ToolBuilder.aliases + override val inputSchema = this@ToolBuilder.inputSchema + override val maxResultSizeChars = this@ToolBuilder.maxResultSizeChars + + override fun parseInput(raw: JsonElement): Input = finalParse(raw) + + override suspend fun validateInput(input: Input, context: ToolExecutionContext): ValidationResult = + validateFn?.invoke(input, context) ?: ValidationResult.Ok + + override suspend fun checkPermissions(input: Input, context: ToolExecutionContext): PermissionResult = + permissionFn?.invoke(input, context) ?: PermissionResult.Allow(JsonObject(emptyMap())) + + override suspend fun preview(input: Input, context: ToolExecutionContext): PreviewResult? = + previewFn?.invoke(input, context) + + override suspend fun call( + input: Input, + context: ToolExecutionContext, + onProgress: (Progress) -> Unit, + ): ToolResult = finalCall(input, context, onProgress) + + override fun mapResultToApiBlock(output: Output, toolUseId: String): ToolResultBlock = + finalMapResult(output, toolUseId) + + override fun isEnabled(): Boolean = isEnabledFn() + override fun isConcurrencySafe(input: Input) = isConcurrencySafeFn(input) + override fun isReadOnly(input: Input) = isReadOnlyFn(input) + override fun isDestructive(input: Input) = isDestructiveFn(input) + override fun userFacingName(input: Input?): String = userFacing(input) + override fun getActivityDescription(input: Input?): String? = activityDescriptionFn(input) + } + } +} + +fun tool(block: ToolBuilder.() -> Unit): Tool = + ToolBuilder().apply(block).build() diff --git a/src/main/kotlin/com/github/codeplangui/tools/ToolExecutionContext.kt b/src/main/kotlin/com/github/codeplangui/tools/ToolExecutionContext.kt new file mode 100644 index 0000000..fa4f5fc --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/tools/ToolExecutionContext.kt @@ -0,0 +1,41 @@ +package com.github.codeplangui.tools + +import com.intellij.openapi.project.Project +import kotlinx.coroutines.Job + +/** + * Runtime context handed to every `Tool.call()`. Kept narrow for MVP — Claude Code's + * `ToolUseContext` (Tool.ts:158-300) carries ~30 fields for hooks, MCP clients, app state, + * notifications. Phase2 adds those as needs emerge. Fields not yet justified stay out. + */ +data class ToolExecutionContext( + val project: Project, + val toolUseId: String, + val abortJob: Job, + val permissionContext: ToolPermissionContext = ToolPermissionContext.default(), +) + +/** + * Global permission configuration. Mirrors Claude Code's `ToolPermissionContext` + * (Tool.ts:123-138) with the MVP-relevant fields. Rule matching logic lives in + * individual tools (via `preparePermissionMatcher`, M3). + */ +data class ToolPermissionContext( + val mode: Mode, + val alwaysAllow: Set, + val alwaysDeny: Set, + val alwaysAsk: Set, + val additionalWorkingDirectories: Set, +) { + enum class Mode { DEFAULT, ACCEPT_EDITS, PLAN, BYPASS } + + companion object { + fun default() = ToolPermissionContext( + mode = Mode.DEFAULT, + alwaysAllow = emptySet(), + alwaysDeny = emptySet(), + alwaysAsk = emptySet(), + additionalWorkingDirectories = emptySet(), + ) + } +} diff --git a/src/main/kotlin/com/github/codeplangui/tools/ToolExecutor.kt b/src/main/kotlin/com/github/codeplangui/tools/ToolExecutor.kt new file mode 100644 index 0000000..7f40c44 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/tools/ToolExecutor.kt @@ -0,0 +1,92 @@ +package com.github.codeplangui.tools + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch + +/** + * Single-tool execution flow. Mirrors Claude Code's `runToolUse()` + * (src/services/tools/toolExecution.ts:337), which is an `AsyncGenerator` yielding + * message updates. Kotlin's analog is a [Flow] emitting [ToolUpdate] events. + * + * Lifecycle (matches docs/phase2-tools-design-notes.md §3.5.2): + * 1. Look up tool by name → `ToolUpdate.Failed(LOOKUP)` on miss + * 2. Parse input JSON → `Failed(PARSE)` on throw + * 3. `validateInput` → `Failed(VALIDATE)` on Failed result + * 4. `checkPermissions` → on Deny emit `Failed(PERMISSION)`; on Ask emit + * `PermissionAsked` and (MVP) auto-approve + * 5. `call` with progress callback → `ProgressEmitted` for each progress event + * 6. `mapResultToApiBlock` → `Completed` + * + * On any exception from `call()` → `Failed(EXECUTE)`. + * + * MVP notes: + * - `Ask` is currently auto-approved (emit PermissionAsked then proceed). M3 will + * route this to a real user decision callback. + * - No parallel-batch execution; one tool_use at a time. M2 adds + * `runToolUseBatch` with the concurrency-safe partition logic. + */ +fun runToolUse( + tool: Tool, + toolUse: ToolUseBlock, + context: ToolExecutionContext, +): Flow = channelFlow { + val id = toolUse.toolUseId + send(ToolUpdate.Started(id, tool.name)) + + val input: Input = try { + tool.parseInput(toolUse.input) + } catch (t: Throwable) { + send(ToolUpdate.Failed(id, ToolUpdate.Failed.Stage.PARSE, t.message ?: "input parse error")) + return@channelFlow + } + + when (val v = tool.validateInput(input, context)) { + is ValidationResult.Ok -> Unit + is ValidationResult.Failed -> { + send(ToolUpdate.Failed(id, ToolUpdate.Failed.Stage.VALIDATE, v.message)) + return@channelFlow + } + } + + when (val p = tool.checkPermissions(input, context)) { + is PermissionResult.Allow -> Unit + is PermissionResult.Deny -> { + send(ToolUpdate.Failed(id, ToolUpdate.Failed.Stage.PERMISSION, p.reason)) + return@channelFlow + } + is PermissionResult.Ask -> { + send(ToolUpdate.PermissionAsked(id, tool.name, p.reason, p.preview)) + // MVP: auto-approve. M3 replaces this with a real user-decision callback. + } + } + + // Bridge the sync onProgress callback to the channelFlow. + val progressChannel = Channel(Channel.UNLIMITED) + val pump = launch { + for (progress in progressChannel) { + send(ToolUpdate.ProgressEmitted(id, progress)) + } + } + + val result: ToolResult = try { + tool.call(input, context) { progressChannel.trySend(it) } + } catch (t: Throwable) { + progressChannel.close() + pump.join() + send(ToolUpdate.Failed(id, ToolUpdate.Failed.Stage.EXECUTE, t.message ?: t.javaClass.simpleName)) + return@channelFlow + } + progressChannel.close() + pump.join() + + val block: ToolResultBlock = try { + tool.mapResultToApiBlock(result.data, id) + } catch (t: Throwable) { + send(ToolUpdate.Failed(id, ToolUpdate.Failed.Stage.SERIALIZE, t.message ?: "serialize error")) + return@channelFlow + } + + send(ToolUpdate.Completed(id, block)) +} diff --git a/src/main/kotlin/com/github/codeplangui/tools/ToolTypes.kt b/src/main/kotlin/com/github/codeplangui/tools/ToolTypes.kt new file mode 100644 index 0000000..8fbd505 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/tools/ToolTypes.kt @@ -0,0 +1,107 @@ +package com.github.codeplangui.tools + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * Outcome of `Tool.validateInput`. Validation runs before permission checks. + */ +sealed class ValidationResult { + object Ok : ValidationResult() + data class Failed(val message: String, val errorCode: Int = -1) : ValidationResult() +} + +/** + * Outcome of `Tool.checkPermissions`. Matches the three-state permission model from + * Claude Code's Tool.ts (PermissionResult). `Allow` carries an `updatedInput` so the + * permission layer can mutate input (e.g. normalize paths) before execution. + */ +sealed class PermissionResult { + data class Allow(val updatedInput: JsonElement) : PermissionResult() + data class Ask(val reason: String, val preview: PreviewResult? = null) : PermissionResult() + data class Deny(val reason: String) : PermissionResult() +} + +/** + * Structured preview of what `call()` would do. Used when `checkPermissions` returns `Ask` + * so the UI can show the user a dry-run summary before they approve. + */ +data class PreviewResult( + val summary: String, + val details: String? = null, + val risk: Risk = Risk.MEDIUM, +) { + enum class Risk { LOW, MEDIUM, HIGH } +} + +/** + * The value returned from `Tool.call()`. `data` is the typed output; optional fields let + * tools inject new messages or mutate context (rare — used by non-concurrency-safe tools). + */ +data class ToolResult( + val data: T, + val newMessages: List = emptyList(), + val contextModifier: ((ToolExecutionContext) -> ToolExecutionContext)? = null, +) + +/** + * Tool-facing progress payload. Tools emit these via the `onProgress` callback during `call()`. + * Kept as a sealed class so specific tools (Bash streaming, etc.) can carry typed data. + */ +sealed class Progress { + data class Stdout(val line: String) : Progress() + data class Stderr(val line: String) : Progress() + data class Status(val message: String) : Progress() +} + +/** + * Serializable shape sent back to the LLM as a `tool_result` block. + * Mirrors Anthropic's tool_result structure (simplified for MVP — only text content). + */ +@Serializable +data class ToolResultBlock( + val toolUseId: String, + val content: String, + val isError: Boolean = false, +) + +/** + * A tool_use request from the LLM. Comes from the Anthropic API response. + */ +@Serializable +data class ToolUseBlock( + val toolUseId: String, + val name: String, + val input: JsonElement, +) + +/** + * Events emitted by `runToolUse`. One `tool_use` becomes a Flow of these. + */ +sealed class ToolUpdate { + abstract val toolUseId: String + + data class Started(override val toolUseId: String, val toolName: String) : ToolUpdate() + data class ProgressEmitted(override val toolUseId: String, val progress: Progress) : ToolUpdate() + data class PermissionAsked( + override val toolUseId: String, + val toolName: String, + val reason: String, + val preview: PreviewResult?, + ) : ToolUpdate() + data class Completed(override val toolUseId: String, val block: ToolResultBlock) : ToolUpdate() + data class Failed( + override val toolUseId: String, + val stage: Stage, + val message: String, + ) : ToolUpdate() { + enum class Stage { LOOKUP, PARSE, VALIDATE, PERMISSION, EXECUTE, SERIALIZE } + } +} + +/** + * Convenience alias for the schema representation. Raw JSON Schema (draft-07 style) kept as a + * `JsonObject` — we don't introduce a schema DSL for MVP. + */ +typealias JsonSchema = JsonObject diff --git a/src/main/kotlin/com/github/codeplangui/tools/bash/BashTool.kt b/src/main/kotlin/com/github/codeplangui/tools/bash/BashTool.kt new file mode 100644 index 0000000..043c205 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/tools/bash/BashTool.kt @@ -0,0 +1,206 @@ +package com.github.codeplangui.tools.bash + +import com.github.codeplangui.execution.CommandExecutionService +import com.github.codeplangui.execution.ExecutionResult +import com.github.codeplangui.tools.PermissionResult +import com.github.codeplangui.tools.PreviewResult +import com.github.codeplangui.tools.Progress +import com.github.codeplangui.tools.Tool +import com.github.codeplangui.tools.ToolResult +import com.github.codeplangui.tools.ToolResultBlock +import com.github.codeplangui.tools.ValidationResult +import com.github.codeplangui.tools.tool +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +@Serializable +data class BashInput( + val command: String, + val description: String? = null, + val timeoutSeconds: Int = DEFAULT_TIMEOUT_SECONDS, +) { + companion object { + const val DEFAULT_TIMEOUT_SECONDS = 120 + const val MAX_TIMEOUT_SECONDS = 600 + } +} + +data class BashOutput( + val stdout: String, + val stderr: String, + val exitCode: Int, + val durationMs: Long, + val truncated: Boolean, + val timedOut: Boolean, +) + +private val json = Json { ignoreUnknownKeys = true } + +// Heuristic — matches commands that typically cause data loss or irreversible +// side effects. Permission layer forces Ask when this returns true even if the +// user has an allow rule for the command prefix. See +// docs/phase2-mvp-tool-specs.md §1.3. +private val DESTRUCTIVE_PATTERNS = listOf( + Regex("""\brm\s"""), + Regex("""\brmdir\s"""), + Regex("""\bmv\s"""), + Regex("""\bdd\s"""), + Regex("""\bDROP\s+TABLE\b""", RegexOption.IGNORE_CASE), + Regex("""\bDROP\s+DATABASE\b""", RegexOption.IGNORE_CASE), + Regex(""">\s*/"""), // redirection into absolute path + Regex("""\bgit\s+push\s+.*--force\b"""), + Regex("""\bgit\s+reset\s+--hard\b"""), +) + +private fun isDestructiveCommand(command: String): Boolean = + DESTRUCTIVE_PATTERNS.any { it.containsMatchIn(command) } + +val BashTool: Tool = tool { + name = "Bash" + description = """ + Execute a shell command in the project's working directory. Supports piping, + redirection, and subshells. Output over 20k chars is truncated. Use for git, + build tools, package managers, and general shell workflows. + """.trimIndent() + + inputSchema = buildJsonObject { + put("type", "object") + put("required", kotlinx.serialization.json.JsonArray(listOf(kotlinx.serialization.json.JsonPrimitive("command")))) + put("properties", buildJsonObject { + put("command", buildJsonObject { + put("type", "string") + put("description", "The shell command to execute.") + }) + put("description", buildJsonObject { + put("type", "string") + put("description", "Short description of what the command does (optional).") + }) + put("timeoutSeconds", buildJsonObject { + put("type", "integer") + put("description", "Timeout in seconds. Default 120, max 600.") + }) + }) + } + + parse { raw: JsonElement -> json.decodeFromJsonElement(BashInput.serializer(), raw) } + + validate { input, _ -> + when { + input.command.isBlank() -> + ValidationResult.Failed("command must not be blank", errorCode = 1) + input.timeoutSeconds !in 1..BashInput.MAX_TIMEOUT_SECONDS -> + ValidationResult.Failed( + "timeoutSeconds must be between 1 and ${BashInput.MAX_TIMEOUT_SECONDS}", + errorCode = 2, + ) + else -> ValidationResult.Ok + } + } + + checkPermissions { input, ctx -> + val basePath = ctx.project.basePath + when { + basePath == null -> + PermissionResult.Deny("Project path unavailable") + CommandExecutionService.hasPathsOutsideWorkspace(input.command, basePath) -> + PermissionResult.Deny("Command references paths outside the workspace") + isDestructiveCommand(input.command) -> + PermissionResult.Ask( + reason = "Potentially destructive command", + preview = previewFor(input, basePath), + ) + ctx.permissionContext.alwaysAllow.any { it == input.command || isPrefixMatch(it, input.command) } -> + PermissionResult.Allow(Json.encodeToJsonElement(BashInput.serializer(), input)) + else -> + PermissionResult.Ask( + reason = "New command — confirm before running", + preview = previewFor(input, basePath), + ) + } + } + + preview { input, ctx -> + previewFor(input, ctx.project.basePath ?: "") + } + + call { input, ctx, onProgress -> + val service = CommandExecutionService.getInstance(ctx.project) + val result = service.executeAsyncWithStream( + command = input.command, + timeoutSeconds = input.timeoutSeconds, + onOutput = { line, isError -> + onProgress(if (isError) Progress.Stderr(line) else Progress.Stdout(line)) + }, + ) + ToolResult(result.toBashOutput(input.command)) + } + + mapResult { output, toolUseId -> + val content = buildString { + if (output.timedOut) appendLine("[timed out]") + if (output.exitCode != 0) appendLine("[exit ${output.exitCode}]") + if (output.stdout.isNotEmpty()) appendLine(output.stdout) + if (output.stderr.isNotEmpty()) { + appendLine("--- stderr ---") + appendLine(output.stderr) + } + if (output.truncated) appendLine("[output truncated]") + }.trim() + ToolResultBlock( + toolUseId = toolUseId, + content = content.ifEmpty { "(no output)" }, + isError = output.exitCode != 0 || output.timedOut, + ) + } + + isConcurrencySafe { false } + isReadOnly { false } + isDestructive { isDestructiveCommand(it.command) } + + activityDescription { input -> input?.let { "Running: ${it.command.take(80)}" } } +} + +private fun previewFor(input: BashInput, basePath: String): PreviewResult = PreviewResult( + summary = "Run: ${input.command}", + details = buildString { + append("Working dir: ").appendLine(basePath) + append("Timeout: ").append(input.timeoutSeconds).appendLine("s") + input.description?.let { append("Intent: ").appendLine(it) } + }, + risk = if (isDestructiveCommand(input.command)) PreviewResult.Risk.HIGH else PreviewResult.Risk.MEDIUM, +) + +private fun isPrefixMatch(rule: String, command: String): Boolean = + rule.endsWith(" *") && command.startsWith(rule.removeSuffix(" *")) + +private fun ExecutionResult.toBashOutput(command: String): BashOutput = when (this) { + is ExecutionResult.Success -> BashOutput(stdout, stderr, exitCode, durationMs, truncated, timedOut = false) + is ExecutionResult.Failed -> BashOutput(stdout, stderr, exitCode, durationMs, truncated, timedOut = false) + is ExecutionResult.TimedOut -> BashOutput( + stdout = stdout, + stderr = "", + exitCode = -1, + durationMs = timeoutSeconds * 1000L, + truncated = false, + timedOut = true, + ) + is ExecutionResult.Blocked -> BashOutput( + stdout = "", + stderr = "Blocked: $reason (command: $command)", + exitCode = -1, + durationMs = 0, + truncated = false, + timedOut = false, + ) + is ExecutionResult.Denied -> BashOutput( + stdout = "", + stderr = "Denied: $reason (command: $command)", + exitCode = -1, + durationMs = 0, + truncated = false, + timedOut = false, + ) +} diff --git a/src/test/kotlin/com/github/codeplangui/tools/ToolExecutorTest.kt b/src/test/kotlin/com/github/codeplangui/tools/ToolExecutorTest.kt new file mode 100644 index 0000000..f9e7df4 --- /dev/null +++ b/src/test/kotlin/com/github/codeplangui/tools/ToolExecutorTest.kt @@ -0,0 +1,173 @@ +package com.github.codeplangui.tools + +import com.intellij.openapi.project.Project +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ToolExecutorTest { + + // --- Stub tool that cooperates with the test fixtures ---------------------- + + private data class StubInput(val text: String, val fail: String? = null) + private data class StubOutput(val echoed: String) + + private fun buildStubTool( + validate: (StubInput) -> ValidationResult = { ValidationResult.Ok }, + permission: (StubInput) -> PermissionResult = { PermissionResult.Allow(JsonObject(emptyMap())) }, + progressEmits: List = emptyList(), + throwOnCall: Boolean = false, + ): Tool = tool { + name = "stub" + description = "test-only tool" + parse { raw -> + val obj = raw as JsonObject + StubInput( + text = obj["text"]?.jsonPrimitive?.content ?: "", + fail = obj["fail"]?.jsonPrimitive?.content, + ) + } + validate { input, _ -> validate(input) } + checkPermissions { input, _ -> permission(input) } + call { input, _, onProgress -> + if (throwOnCall) error("boom: ${input.text}") + progressEmits.forEach(onProgress) + ToolResult(StubOutput(echoed = input.text)) + } + mapResult { out, id -> + ToolResultBlock(toolUseId = id, content = out.echoed) + } + } + + private fun fakeContext(): ToolExecutionContext = ToolExecutionContext( + project = mockk().also { every { it.basePath } returns "/tmp/fake" }, + toolUseId = "test-id", + abortJob = Job(), + ) + + private fun toolUse(text: String): ToolUseBlock = ToolUseBlock( + toolUseId = "test-id", + name = "stub", + input = buildJsonObject { put("text", JsonPrimitive(text)) }, + ) + + // --- Happy path ---------------------------------------------------------- + + @Test + fun `happy path emits Started then Completed`() = runBlocking { + val tool = buildStubTool() + val updates = runToolUse(tool, toolUse("hello"), fakeContext()).toList() + + assertEquals(2, updates.size) + assertTrue(updates[0] is ToolUpdate.Started) + assertTrue(updates[1] is ToolUpdate.Completed) + val completed = updates[1] as ToolUpdate.Completed + assertEquals("hello", completed.block.content) + assertFalse(completed.block.isError) + } + + @Test + fun `progress emits are forwarded as ProgressEmitted events`() = runBlocking { + val tool = buildStubTool( + progressEmits = listOf( + Progress.Stdout("line 1"), + Progress.Stdout("line 2"), + Progress.Stderr("warning"), + ), + ) + val updates = runToolUse(tool, toolUse("hi"), fakeContext()).toList() + val progress = updates.filterIsInstance() + + assertEquals(3, progress.size) + assertTrue(progress[0].progress is Progress.Stdout) + assertEquals("line 1", (progress[0].progress as Progress.Stdout).line) + assertTrue(progress[2].progress is Progress.Stderr) + } + + // --- Validation / permission / execute failure branches ------------------ + + @Test + fun `validation failure short-circuits to Failed(VALIDATE)`() = runBlocking { + val tool = buildStubTool( + validate = { ValidationResult.Failed(message = "bad", errorCode = 99) }, + ) + val updates = runToolUse(tool, toolUse("anything"), fakeContext()).toList() + + assertEquals(2, updates.size) // Started + Failed + val failed = updates[1] as ToolUpdate.Failed + assertEquals(ToolUpdate.Failed.Stage.VALIDATE, failed.stage) + assertEquals("bad", failed.message) + } + + @Test + fun `permission deny short-circuits to Failed(PERMISSION)`() = runBlocking { + val tool = buildStubTool( + permission = { PermissionResult.Deny("nope") }, + ) + val updates = runToolUse(tool, toolUse("x"), fakeContext()).toList() + + val failed = updates.last() as ToolUpdate.Failed + assertEquals(ToolUpdate.Failed.Stage.PERMISSION, failed.stage) + } + + @Test + fun `permission ask emits PermissionAsked and proceeds (MVP auto-approve)`() = runBlocking { + val tool = buildStubTool( + permission = { + PermissionResult.Ask( + reason = "please confirm", + preview = PreviewResult(summary = "would run", risk = PreviewResult.Risk.LOW), + ) + }, + ) + val updates = runToolUse(tool, toolUse("y"), fakeContext()).toList() + + val asked = updates.filterIsInstance() + assertEquals(1, asked.size) + assertEquals("please confirm", asked[0].reason) + assertEquals(PreviewResult.Risk.LOW, asked[0].preview?.risk) + assertTrue(updates.last() is ToolUpdate.Completed) + } + + @Test + fun `execution exception becomes Failed(EXECUTE) after Started`() = runBlocking { + val tool = buildStubTool(throwOnCall = true) + val updates = runToolUse(tool, toolUse("kaboom"), fakeContext()).toList() + + val failed = updates.last() as ToolUpdate.Failed + assertEquals(ToolUpdate.Failed.Stage.EXECUTE, failed.stage) + assertTrue(failed.message.contains("kaboom")) + } + + // --- Input parse errors --------------------------------------------------- + + @Test + fun `parse error becomes Failed(PARSE)`() = runBlocking { + val tool: Tool = tool { + name = "parse-broken" + description = "always fails parse" + parse { _ -> throw IllegalArgumentException("bad json") } + call { _, _, _ -> ToolResult(StubOutput("")) } + mapResult { _, id -> ToolResultBlock(id, "") } + } + val raw = Json.parseToJsonElement("""{"text":"x"}""") as JsonObject + val tu = ToolUseBlock(toolUseId = "t1", name = "parse-broken", input = raw) + + val updates = runToolUse(tool, tu, fakeContext()).toList() + + val failed = updates.last() as ToolUpdate.Failed + assertEquals(ToolUpdate.Failed.Stage.PARSE, failed.stage) + assertTrue(failed.message.contains("bad json")) + } +}