mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be08f53bd | ||
|
|
02dd796706 | ||
|
|
8ba51edec1 | ||
|
|
73e54d4bbc | ||
|
|
2fdfb844cb | ||
|
|
4230f0fff1 | ||
|
|
7fe448d9e9 | ||
|
|
aa06cea904 | ||
|
|
c43efecbab | ||
|
|
cb4a6e76cf | ||
|
|
f7f69b759c | ||
|
|
771e3dbcf0 | ||
|
|
e3c0699f5b | ||
|
|
e8759f3402 | ||
|
|
958ac3a0d5 | ||
|
|
5895362178 | ||
|
|
8cfe9b6dc3 | ||
|
|
12f5aedf99 | ||
|
|
c7efac6b8d | ||
|
|
2f150d3ecd | ||
|
|
68c7ebb242 | ||
|
|
9e299a7208 | ||
|
|
941bcbd240 | ||
|
|
fd66ddc45f | ||
|
|
5c107e5f8c | ||
|
|
c4e9efb7a8 | ||
|
|
26ddbda849 | ||
|
|
872ee280e3 | ||
|
|
f5c9880d7d | ||
|
|
3f1c8468bf | ||
|
|
100e9d2da0 | ||
|
|
0ad6349434 | ||
|
|
1ac18aec0d | ||
|
|
fcbc882232 | ||
|
|
a1108870e3 | ||
|
|
87b96199f9 | ||
|
|
18d6656a6a | ||
|
|
d0915fc880 | ||
|
|
cf2bf29dcd | ||
|
|
75952bde9c | ||
|
|
e7220c530f | ||
|
|
6ff839d625 | ||
|
|
88057b10d4 | ||
|
|
4d0048a60a | ||
|
|
8a5ef8c9cb | ||
|
|
f8a289b868 | ||
|
|
45c892fc18 | ||
|
|
5b333e2246 | ||
|
|
5e215bb061 | ||
|
|
b28de717dd | ||
|
|
5c1be19511 | ||
|
|
5dc4d8f8a2 | ||
|
|
2545dcabfd |
@@ -1 +1 @@
|
||||
bunx lint-staged
|
||||
npx lint-staged
|
||||
|
||||
@@ -123,6 +123,7 @@ bun run docs:dev
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/constants/tools.ts`** — `CORE_TOOLS` 白名单常量(约 29 个核心工具名),用于 `isDeferredTool` 白名单制判定。
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
@@ -132,6 +133,7 @@ bun run docs:dev
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
- **`src/services/toolSearch/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`ToolSearchTool.mapToolResultToToolResultBlockParam` 新增可选第三个参数 `context?: { mainLoopModel?: string }`,用于判断当前模型是否支持 `tool_reference`。不支持时回退到文本输出,引导模型使用 ExecuteTool。调用方(`src/services/api/claude.ts` 的 tool_result 处理逻辑)需传入 context 参数。`prefetch.ts` 的 `extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
|
||||
9
build.ts
9
build.ts
@@ -21,7 +21,14 @@ const result = await Bun.build({
|
||||
outdir,
|
||||
target: 'bun',
|
||||
splitting: true,
|
||||
define: getMacroDefines(),
|
||||
sourcemap: 'linked',
|
||||
define: {
|
||||
...getMacroDefines(),
|
||||
// React production mode — eliminates _debugStack Error objects
|
||||
// (6,889 objects × ~1.7KB = 12MB in development builds) and removes
|
||||
// prop-type / key warnings not useful in a production CLI tool.
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
features,
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 2.2 MiB |
File diff suppressed because it is too large
Load Diff
225
docs/features/background-agent-selector.md
Normal file
225
docs/features/background-agent-selector.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Background Agent Selector — 底部统一后台 Agent 切换器
|
||||
|
||||
> Feature Flag: 无(直接启用)
|
||||
> 实现状态:完整可用
|
||||
> 依赖:`viewingAgentTaskId` / `enterTeammateView` / `exitTeammateView` 已有机制
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
Background Agent Selector 是渲染在 PromptInput 下方的常驻状态条,列出当前所有 **backgrounded 的 local_agent 任务**(包括 `/fork` 派生的 fork agent 和 Task/AgentTool 调用 `run_in_background: true` 派生的子 agent)。用户可以用 ↑/↓ 方向键在 `main` 和各 agent 之间切换焦点,按 Enter 把 REPL 主视图替换为所选 agent 的实时 transcript,再按 Enter 选中 `main` 即可回到主对话。
|
||||
|
||||
整个机制完全复用官方已有的 teammate transcript 查看基础设施,不引入新的视图层 / 数据流,仅新增一条 footer pill 类型。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **统一入口**:`/fork`、Task 派生的 subagent、所有 `run_in_background: true` 的 agent 都在同一栏显示
|
||||
- **就地切换**:prompt 为空时按 ↓ 溢出进入底部 selector,↑↓ 选中某行,Enter 即切主视图
|
||||
- **实时状态**:每行显示 agent 类型 + 描述 + 运行时长 + 已消耗 token;running 时圆点为绿色
|
||||
- **Keep-alive 视图**:agent 完成后在 `evictAfter` grace 窗口内保留一段时间,用户可回看
|
||||
- **零界面侵入**:tasks 数为 0 时 selector 完全不渲染,不占屏幕高度
|
||||
- **与旧 Dialog 共存**:Shift+↓ 打开的 `BackgroundTasksDialog` 原有行为保留,selector 只作为展示 + 快捷切换
|
||||
|
||||
## 二、用户交互
|
||||
|
||||
### 触发方式
|
||||
|
||||
有任何 background agent 时,selector 自动出现在 `bypass permissions on` 行下方:
|
||||
|
||||
```
|
||||
claude-code | Opus 4.7 (1M context) | ctx:4%
|
||||
▶▶ bypass permissions on (shift+tab to cycle)
|
||||
|
||||
○ main ↑/↓ to select · Enter to view
|
||||
● Explore Research src/hooks 23s · ↓ 10.9k tokens
|
||||
○ Explore Research src/components 22s · ↓ 9.5k tokens
|
||||
○ Explore Research src/utils 21s · ↓ 13.6k tokens
|
||||
```
|
||||
|
||||
### 键盘路由
|
||||
|
||||
| 位置 / 状态 | 按键 | 行为 |
|
||||
|---|---|---|
|
||||
| PromptInput 非空 | ↑↓ | 光标移动 / 翻历史(不变) |
|
||||
| PromptInput 空 + 历史底部 | ↓ | 焦点下放到 selector,高亮到 `● main` |
|
||||
| Selector 聚焦(`footerSelection === 'bg_agent'`) | ↓ | 高亮下移,-1 → 0 → ... → N-1 |
|
||||
| Selector 聚焦 | ↑ | 高亮上移;在 `main` 再 ↑ → 焦点回 PromptInput |
|
||||
| Selector 聚焦 | Enter | `-1` → `exitTeammateView`;`>=0` → `enterTeammateView(agentId)`。焦点保留在 pill |
|
||||
| Selector 聚焦 | Esc | `footer:clearSelection`,焦点回 PromptInput |
|
||||
|
||||
### 视觉规则
|
||||
|
||||
- `● main` / `● <agent>`:当前被**查看**(viewingAgentTaskId 指向)或被**光标聚焦**(pill focused 时以光标为准)的一行
|
||||
- running 状态的 agent:圆点渲染为 `success` 色(绿色),与 `BackgroundTasksDialog` 状态语义对齐
|
||||
- 右上角 hint 随状态变化:
|
||||
- pill 聚焦:`↑/↓ to select · Enter to view`
|
||||
- 已选中 running agent:`shift+↓ to manage · x to stop`
|
||||
- 已选中 terminal agent:`shift+↓ to manage · x to clear`
|
||||
- 未选中任何 agent:`shift+↓ to manage background agents`
|
||||
|
||||
## 三、实现架构
|
||||
|
||||
### 3.1 数据层:`useBackgroundAgentTasks`
|
||||
|
||||
文件:`src/hooks/useBackgroundAgentTasks.ts`
|
||||
|
||||
封装对 `useAppState(s => s.tasks)` 的过滤:
|
||||
|
||||
```ts
|
||||
export function useBackgroundAgentTasks(): LocalAgentTaskState[] {
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
return useMemo(() => {
|
||||
const now = Date.now()
|
||||
return Object.values(tasks)
|
||||
.filter(isLocalAgentTask)
|
||||
.filter(t => t.agentType !== 'main-session')
|
||||
.filter(t => t.isBackgrounded !== false)
|
||||
.filter(t => t.evictAfter === undefined || t.evictAfter > now)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
}, [tasks])
|
||||
}
|
||||
```
|
||||
|
||||
`/fork` 和 `AgentTool` 的 `run_in_background: true` 底层都走 `registerAsyncAgent → runAsyncAgentLifecycle`,最终写入同一个 `appState.tasks` Map;此 hook 是唯一数据源,Selector 和 PromptInput 的 `bgAgentList` 都消费它。
|
||||
|
||||
### 3.2 状态层:新增两个字段
|
||||
|
||||
文件:`src/state/AppStateStore.ts`
|
||||
|
||||
```ts
|
||||
export type FooterItem =
|
||||
| 'tasks' | 'tmux' | 'bagel' | 'teams' | 'bridge' | 'companion'
|
||||
| 'bg_agent' // ← 新增
|
||||
|
||||
export type AppState = DeepImmutable<{
|
||||
// ...
|
||||
selectedBgAgentIndex: number // -1 = main, 0..N-1 = 选中的 agent
|
||||
}>
|
||||
```
|
||||
|
||||
- `'bg_agent'` 作为 `FooterItem` 加入 footer pill 体系,享受既有的 `footer:up` / `footer:down` / `footer:openSelected` keybinding 路由
|
||||
- `selectedBgAgentIndex` 记录 selector 的光标位置,与 `viewingAgentTaskId`("正在看什么")独立;它不可从 `viewingAgentTaskId` 派生——Enter 后光标留在 pill 继续导航,查看目标才变
|
||||
|
||||
### 3.3 键盘路由:PromptInput footer pill 分支
|
||||
|
||||
文件:`src/components/PromptInput/PromptInput.tsx`
|
||||
|
||||
1. **`bg_agent` 进入 footerItems[0]**:保证 prompt ↓ 溢出时(`handleHistoryDown` → `selectFooterItem(footerItems[0])`)直接进入 selector,而不是 `tasks` 等其他 pill
|
||||
2. **`footer:up` 分支**:`bgAgentSelected` 时 `selectedBgAgentIndex > -1` 则递减;在 -1 → `selectFooterItem(null)` 退出 pill
|
||||
3. **`footer:down` 分支**:`selectedBgAgentIndex < bgAgentList.length - 1` 则递增,到底 clamp
|
||||
4. **`footer:openSelected` 分支**:index === -1 → `exitTeammateView`;否则 `enterTeammateView(bgAgentList[i].agentId)`。**不清理 pill 焦点**,光标留在 selector 上继续导航
|
||||
5. **`selectFooterItem('bg_agent')`**:入 pill 时重置 `selectedBgAgentIndex = -1`(光标落到 `main`)
|
||||
|
||||
### 3.4 渲染层:`BackgroundAgentSelector`
|
||||
|
||||
文件:`src/components/tasks/BackgroundAgentSelector.tsx`
|
||||
|
||||
纯展示组件,不订阅键盘:
|
||||
|
||||
```tsx
|
||||
const tasks = useBackgroundAgentTasks()
|
||||
const viewingId = useAppState(s => s.viewingAgentTaskId)
|
||||
const footerSelection = useAppState(s => s.footerSelection)
|
||||
const selectedBgIndex = useAppState(s => s.selectedBgAgentIndex)
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const pillFocused = footerSelection === 'bg_agent'
|
||||
const highlightedId = pillFocused
|
||||
? (selectedBgIndex === -1 ? null : tasks[selectedBgIndex]?.agentId ?? null)
|
||||
: (viewingId ?? null)
|
||||
```
|
||||
|
||||
**高亮派生规则**:pill 聚焦 → 跟 `selectedBgAgentIndex`;未聚焦 → 镜像 `viewingAgentTaskId`。这样当用户通过 Shift+↓ Dialog 或 `enterTeammateView` 其它途径切换视图时,selector 也会正确反映。
|
||||
|
||||
### 3.5 主视图切换:复用 `viewingAgentTaskId`
|
||||
|
||||
REPL.tsx 主体仍复用原有查看逻辑:
|
||||
|
||||
```ts
|
||||
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined
|
||||
const viewedAgentTask = ... (isLocalAgentTask(viewedTask) ? viewedTask : undefined)
|
||||
const displayedMessages = viewedAgentTask ? displayedAgentMessages : messages
|
||||
```
|
||||
|
||||
当 `enterTeammateView(agentId)` 把 `viewingAgentTaskId` 设成某个 local_agent 的 id:
|
||||
|
||||
- `viewedAgentTask` 解析成该 agent
|
||||
- `displayedMessages` 切换到 agent 的 messages
|
||||
- 消息列表、spinner、unseen divider 等一整套组件自动用 agent transcript 重渲染
|
||||
- 主对话流被"暂停"(并非销毁,回到 `main` 时仍在原处)
|
||||
|
||||
`enterTeammateView` 同步负责:设 `retain: true` 阻止 eviction、清 `evictAfter`、触发 disk bootstrap 从 `agent-<id>.jsonl` 加载完整 transcript 到 `task.messages`。
|
||||
|
||||
#### Fork agent prompt 归一化
|
||||
|
||||
`/fork` agent 的 transcript 和普通 subagent 不同:它继承 main agent 的上下文,真实初始消息形态是:
|
||||
|
||||
```text
|
||||
...parent messages
|
||||
assistant([...tool_use])
|
||||
user([tool_result..., text("<fork-boilerplate>...Your directive: <prompt>")])
|
||||
...fork live messages
|
||||
```
|
||||
|
||||
这里的 prompt 文本混在 `[tool_result..., text]` 多 block user message 里。消息渲染管线会优先把这条 user message 当作 tool-result plumbing 来处理,导致 `<fork-boilerplate>` 里的用户 prompt 不稳定可见。为保证切换到 fork agent 时总能看到用户发起的 fork prompt,REPL.tsx 对 fork 视图做一次展示层归一化:
|
||||
|
||||
1. 仅当 `viewedAgentTask.agentType === 'fork'` 时启用,不影响普通 Explore / Task subagent。
|
||||
2. 从原始 messages 中识别包含 `<fork-boilerplate>` 的 carrier message。
|
||||
3. 剥离 carrier message 里的 boilerplate text block,但保留 `tool_result` blocks,避免破坏父 assistant `tool_use` 的承接关系。
|
||||
4. 强制插入一条独立 `createUserMessage({ content: viewedAgentTask.prompt })` 作为可见用户 prompt。
|
||||
5. 插入位置优先为 boilerplate carrier 后;如果 sidechain bootstrap 还没读到 carrier,则插到最后一条 inherited `assistant tool_use` 后面,确保 prompt 接在 main 上下文之后,而不是跑到视图顶部。
|
||||
|
||||
这个归一化只影响 UI 展示用的 `displayedAgentMessages`,不回写 `task.messages`,也不改变发送给模型的 fork transcript。
|
||||
|
||||
### 3.6 生命周期
|
||||
|
||||
完全复用官方既有机制:
|
||||
|
||||
- **运行中**:`isBackgroundTask()` 谓词为真,selector 列出
|
||||
- **完成 / 失败 / 中止**:`completeAgentTask` / `failAgentTask` / `killAsyncAgent` 设 `status` 为 terminal
|
||||
- **回访后退出**:`exitTeammateView` 调 `release(task)`——清 `retain`、清 `messages`、terminal 状态下设 `evictAfter = now + PANEL_GRACE_MS (30s)`
|
||||
- **evictAfter 过期**:`useBackgroundAgentTasks` 过滤时自然剔除,selector 行消失
|
||||
- **手动清除**:`stopOrDismissAgent(taskId)` 设 `evictAfter = 0`,立即消失
|
||||
|
||||
## 四、设计决策
|
||||
|
||||
1. **数据源单一**:`useBackgroundAgentTasks` 是唯一过滤点,PromptInput 也复用,避免过滤条件散落
|
||||
2. **pill 聚焦保留**:Enter 切视图后不松焦,让 ↑↓ 连续导航,贴近官方体验
|
||||
3. **`bg_agent` 放 footerItems[0]**:确保 ↓ 溢出直接进入 selector 而非其它 pill
|
||||
4. **selector 不订阅键盘**:所有按键路由集中在 PromptInput 的 `footer:*` 分支,避免 selector 组件和 PromptInput 双重 `useInput` 的冲突
|
||||
5. **`selectedBgAgentIndex` 存 AppState 而非局部 state**:selector 和 PromptInput 分别在两棵不同子树,需要全局字段协调;该值不能从 `viewingAgentTaskId` 派生
|
||||
6. **与 `BackgroundTasksDialog` 共存**:Shift+↓ 行为完全不变,selector 是补充快捷入口;Dialog 仍管 shell / workflow / monitor_mcp 等 selector 不显示的 task 类型
|
||||
7. **fork prompt 展示层兜底**:fork prompt 不依赖 boilerplate 自身渲染,统一在 `displayedAgentMessages` 中合成独立用户消息;普通 subagent 不走该分支,避免 prompt 重复
|
||||
|
||||
## 五、关键 API 复用
|
||||
|
||||
| 官方已有能力 | selector 如何使用 |
|
||||
|---|---|
|
||||
| `AppState.tasks` | 单一数据源,无需 file watcher / output JSONL 订阅 |
|
||||
| `registerAsyncAgent` | `/fork` 和 AgentTool 共用,selector 不区分来源 |
|
||||
| `enterTeammateView(id)` | Enter 时调用,负责 retain + disk bootstrap |
|
||||
| `exitTeammateView` | Enter 选中 `main` 时调用 |
|
||||
| `release(task)` + `PANEL_GRACE_MS` | 30s keep-alive,selector 自动生效 |
|
||||
| `useElapsedTime` | 每行时长显示,非 running 自动停 interval |
|
||||
| `formatTokens` (`utils/format.ts`) | token 数 1k 缩写 |
|
||||
| `footer:up` / `footer:down` / `footer:openSelected` keybinding | 键盘路由复用 Footer context |
|
||||
|
||||
## 六、文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/hooks/useBackgroundAgentTasks.ts` | 数据过滤 hook(backgrounded local_agent + evictAfter 过滤 + startTime 排序) |
|
||||
| `src/components/tasks/BackgroundAgentSelector.tsx` | 底部 selector UI,纯展示 |
|
||||
| `src/components/PromptInput/PromptInput.tsx` | 新增 `'bg_agent'` footer pill + 对应的 `footer:up/down/openSelected` 分支 |
|
||||
| `src/state/AppStateStore.ts` | `FooterItem` 加 `'bg_agent'`;新增 `selectedBgAgentIndex` 字段 |
|
||||
| `src/main.tsx` | `getDefaultAppState` 同步初始化 `selectedBgAgentIndex: -1` |
|
||||
| `src/screens/REPL.tsx` | 在 PromptInput + SessionBackgroundHint 之后挂载 `<BackgroundAgentSelector />`;切换 agent 主视图;对 fork transcript 做 prompt 归一化 |
|
||||
| `src/components/messages/AssistantToolUseMessage.tsx` | 新增 `defaultCollapsed?: boolean` prop,为后续详情视图默认折叠工具块预留 |
|
||||
| `src/components/messages/UserTextMessage.tsx` | 识别 `<fork-boilerplate>`,交给 fork 专用 renderer 处理 |
|
||||
| `src/components/messages/UserForkBoilerplateMessage.tsx` | 将 fork boilerplate text 折叠为纯用户 prompt;作为 transcript 中原位渲染的兼容路径 |
|
||||
|
||||
## 七、已知限制
|
||||
|
||||
- `Date.now()` 在 `useBackgroundAgentTasks` 的 useMemo 里冻结于 `[tasks]` 触发时:若长时间没有新 task 变更事件,某个 terminal agent 的 grace 期过期后不会立即从 selector 消失,要等下一次 tasks 变化才刷新。在典型使用(主对话一直在产生消息)下感知不到,暂不额外加 interval。
|
||||
- Selector 当前不处理 Shell Task / Workflow / Monitor MCP 等类型——这些仍走 `BackgroundTasksDialog`(Shift+↓)管理。
|
||||
- `AssistantToolUseMessage` 的 `defaultCollapsed` prop 目前无调用方传值,保留作为后续"agent 详情视图内工具块默认折叠"扩展点。
|
||||
275
docs/features/status-line.mdx
Normal file
275
docs/features/status-line.mdx
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
title: "StatusLine 底部状态栏 - 自定义 shell 渲染管线"
|
||||
description: "从源码角度解析 Claude Code 底部状态栏:自定义 shell 脚本 + JSON stdin 协议、三种触发源(event / settings / time)、debounce + abort、信任与 hook 开关、以及本仓库 refreshInterval 缺失修复。"
|
||||
keywords: ["statusLine", "状态栏", "自定义提示符", "refreshInterval", "Hooks"]
|
||||
---
|
||||
|
||||
{/* 本章目标:完整讲清 StatusLine 的渲染管线、触发模型、协议契约与安全网关,并记录本仓库相对官方版本的已知缺口与修复 */}
|
||||
|
||||
## 概述
|
||||
|
||||
StatusLine 是 Claude Code REPL 底部显示的一行自定义文本,由**用户提供的 shell 命令**渲染。主进程把运行时状态(模型、工作目录、token、限流、会话元数据等)打包成 JSON 通过 stdin 喂给脚本,脚本在 stdout 输出一行字符串,Ink 侧以 ANSI 转义渲染到 footer。
|
||||
|
||||
核心设计哲学:**语言无关 + 进程隔离 + Unix 管道**。用户可用 bash / python / node / 任意语言写脚本;脚本崩溃不影响主进程;输入输出都是纯文本,可以离线测试(`echo '{...}' | ./script.sh`)。
|
||||
|
||||
## 配置
|
||||
|
||||
`~/.claude/settings.json` 里添加 `statusLine` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bash ~/.claude/statusline-command.sh",
|
||||
"refreshInterval": 1,
|
||||
"padding": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 作用 |
|
||||
|------|------|------|
|
||||
| `type` | `"command"` | 目前仅支持 command 型 |
|
||||
| `command` | `string` | shell 命令字符串;主进程用系统 shell 解释执行 |
|
||||
| `refreshInterval` | `number` (秒) | 定时刷新周期;缺省/0 表示不定时刷新 |
|
||||
| `padding` | `number` | 左右 padding,单位为 Ink cell |
|
||||
|
||||
Schema 定义在 `src/utils/settings/types.ts:550`(`statusLine` Zod object)。
|
||||
|
||||
## 渲染管线(整体图)
|
||||
|
||||
```
|
||||
┌─────────────────────── Ink 侧 ───────────────────────┐ ┌──────── 用户侧 ────────┐
|
||||
│ │ │ │
|
||||
│ buildStatusLineCommandInput() ──┐ │ │ ~/.claude/ │
|
||||
│ 收集运行时状态 │ │ │ statusline-*.sh │
|
||||
│ ▼ │ │ │
|
||||
│ executeStatusLineCommand() ─── JSON via stdin ────────────► jq '.model...' │
|
||||
│ execCommandHook() 拉起 shell │ │ 计算、格式化 │
|
||||
│ ▲ │ │ │
|
||||
│ stdout ◄──────────────────── 一行文本 ──────────────── printf '...' │
|
||||
│ │ │ │ │
|
||||
│ setAppState({ statusLineText }) ─┘ │ └────────────────────────┘
|
||||
│ zustand 存字段,组件 memo 订阅 │
|
||||
│ │
|
||||
│ <StatusLine /> → <Text><Ansi>{text}</Ansi></Text> │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Input 协议:主进程 → 脚本
|
||||
|
||||
`buildStatusLineCommandInput`(`src/components/StatusLine.tsx:53`)构造的 JSON 对象字段如下,**这是脚本可以 `jq` 读取的全部内容**:
|
||||
|
||||
| 字段 | 来源 | 备注 |
|
||||
|------|------|------|
|
||||
| `session_id` | `getSessionId()` | UUID,用于脚本侧 per-session 状态隔离 |
|
||||
| `session_name` | `getCurrentSessionTitle(sessionId)` | 用户命名的会话标题(可选) |
|
||||
| `model.id` / `model.display_name` | `getRuntimeMainLoopModel()` | 运行时真实模型(经 permission mode 降级/200k 升级) |
|
||||
| `workspace.current_dir` / `project_dir` / `added_dirs` | `getCwd()` / `getOriginalCwd()` / permission context | current_dir 随 `cd` 变化 |
|
||||
| `version` | `MACRO.VERSION` | 构建注入,如 `2.1.888` |
|
||||
| `output_style.name` | `settings.outputStyle` | 缺省 `DEFAULT_OUTPUT_STYLE_NAME` |
|
||||
| `cost.total_cost_usd` / `total_duration_ms` / `total_api_duration_ms` / `total_lines_added` / `total_lines_removed` | `cost-tracker.js` 聚合 | 会话累计 |
|
||||
| `context_window.total_input_tokens` / `total_output_tokens` | 同上 | 累计 token |
|
||||
| `context_window.context_window_size` | `getContextWindowForModel()` | 模型上下文上限 |
|
||||
| `context_window.current_usage` | `getCurrentUsage(messages)` | **最新一次 assistant message 的 usage**;含 `input_tokens` / `cache_creation_input_tokens` / `cache_read_input_tokens` / `output_tokens` |
|
||||
| `context_window.used_percentage` / `remaining_percentage` | `calculateContextPercentages()` | 0-100 浮点 |
|
||||
| `exceeds_200k_tokens` | 检查最近 assistant message | 用于 1M 上下文模型的展示 |
|
||||
| `rate_limits.five_hour` / `seven_day` | `getRawUtilization()` | `{ used_percentage, resets_at }`,来自 Claude.ai 限流 API |
|
||||
| `vim.mode` | 启用 vim 模式时 | `INSERT` / `NORMAL` / ... |
|
||||
| `agent.name` | 主线程 agent 类型 | 子 agent fork 时非空 |
|
||||
| `remote.session_id` | Bridge / Remote Control 模式 | 远程会话 |
|
||||
| `worktree` | 当前 worktree 元信息 | `name` / `path` / `branch` / `original_cwd` / `original_branch` |
|
||||
|
||||
类型签名目前在 `src/types/statusLine.ts` 是 `any` 的 stub(反编译残留),实际字段以上表为准。
|
||||
|
||||
## Output 协议:脚本 → 主进程
|
||||
|
||||
`executeStatusLineCommand`(`src/utils/hooks.ts:4752`)对脚本 stdout 做如下处理:
|
||||
|
||||
1. `trim()` 首尾空白
|
||||
2. 按 `\n` 拆行,每行再 `trim()`
|
||||
3. 空行丢弃,剩余用 `\n` 重新拼接
|
||||
|
||||
多行输出会被**保留为多行**(Ink 渲染时 `<Text>` 允许换行),但设计推荐**单行**——多行会挤占 REPL 高度,fullscreen 模式下可能挤掉 ScrollBox 行。
|
||||
|
||||
状态码约定:
|
||||
- `exit 0` + 有 stdout → 显示
|
||||
- `exit 0` + 空 stdout → 清空 statusLine(显示为空)
|
||||
- 非 0 → 忽略,保留上次内容;`logResult=true` 时 warn 级日志
|
||||
- 超时(默认 5000ms) → 忽略
|
||||
- 被 AbortController 取消 → 忽略
|
||||
|
||||
ANSI 颜色可用,Ink 通过 `<Ansi>{text}</Ansi>` 组件解析 SGR 序列。
|
||||
|
||||
## 三种触发源
|
||||
|
||||
StatusLine 的重算由**三类事件**驱动,全部经同一个 debounce 队列:
|
||||
|
||||
### 1. Event-driven(`src/components/StatusLine.tsx:275`)
|
||||
|
||||
监听这些状态变化,触发 `scheduleUpdate()`:
|
||||
|
||||
- `lastAssistantMessageId` — 新助手回复出现
|
||||
- `permissionMode` — `/mode` 切换权限模式
|
||||
- `vimMode` — vim insert/normal 切换
|
||||
- `mainLoopModel` — `/model` 切换
|
||||
|
||||
### 2. Settings-driven(`src/components/StatusLine.tsx:294`)
|
||||
|
||||
`settings.statusLine.command` 字符串变化时(热重载 settings.json),标记下一次结果 log 并立即 `doUpdate()`。
|
||||
|
||||
### 3. Time-driven(`src/components/StatusLine.tsx:292`,本仓库补丁)
|
||||
|
||||
读取 `settings.statusLine.refreshInterval`(秒),`setInterval` 每到点走一次 `scheduleUpdate()`。配置为 0 或缺省时不启定时器(零开销)。
|
||||
|
||||
> **本仓库历史缺口**:反编译出的 `StatusLine.tsx` 最初没有 Time-driven 触发路径,`refreshInterval` 字段也不在 Zod schema 里。导致脚本里 TTL 倒计时、时钟类动态内容不会秒刷,只有助手回复出现时才重算。已在 2026-05-06 补齐,细节见下方"已知缺口与修复"。
|
||||
|
||||
## Debounce + Abort
|
||||
|
||||
三种触发源都走 `scheduleUpdate`(`src/components/StatusLine.tsx:259`):
|
||||
|
||||
```
|
||||
scheduleUpdate() → setTimeout(300ms) → doUpdate()
|
||||
│
|
||||
└─ 再次 schedule 会 clearTimeout 前次
|
||||
```
|
||||
|
||||
300ms debounce 合并抖动事件(例如短时间连续切 vim/permission)。
|
||||
|
||||
`doUpdate()` 里:
|
||||
|
||||
```
|
||||
abortControllerRef.current?.abort() // 取消上一次 in-flight shell
|
||||
controller = new AbortController()
|
||||
executeStatusLineCommand(..., controller.signal, ...)
|
||||
```
|
||||
|
||||
**单飞(single-flight)语义**:任何新触发都会 abort 上一次未完成的 shell 调用,保证同一时刻最多一个子进程。这对 `refreshInterval: 1` 尤其关键——若脚本执行 > 1 秒,新 tick 到来时老进程被 kill,不会堆积。
|
||||
|
||||
## 安全网关
|
||||
|
||||
`executeStatusLineCommand`(`src/utils/hooks.ts:4752`)在执行前有**三层拦截**:
|
||||
|
||||
1. `shouldDisableAllHooksIncludingManaged()` → managed settings 全局禁用 hooks 时直接返回
|
||||
2. `shouldSkipHookDueToTrust()` → **工作区未接受信任对话框时跳过**,避免打开未知仓库时执行任意 shell 命令(RCE 防护)
|
||||
3. `shouldAllowManagedHooksOnly()` → 非 managed settings 禁用 hooks 但 managed 未禁用时,只读取 policySettings 源的 statusLine
|
||||
|
||||
组件侧配合(`src/components/StatusLine.tsx:318`):未接受 trust 时在通知中心提示 `"statusline skipped · restart to fix"`。
|
||||
|
||||
另外,`statusLineShouldDisplay`(`src/components/StatusLine.tsx:46`)在 **Kairos assistant mode** 下直接返回 false——因为那时 statusline 字段反映的是 REPL/daemon 进程状态,不是 agent 子进程在跑的东西,显示出来会误导用户。
|
||||
|
||||
## 渲染细节
|
||||
|
||||
### memo 隔离
|
||||
|
||||
```tsx
|
||||
export const StatusLine = memo(StatusLineInner)
|
||||
```
|
||||
|
||||
父组件 `PromptInputFooter` 每次 `setMessages` 都 rerender,但 `StatusLine` 的 props 只有 `lastAssistantMessageId` 会变,`memo` 阻断了无意义的重渲染。此前(未 memo 版本)一个 session 内大约 18 次冗余渲染。
|
||||
|
||||
### 订阅粒度
|
||||
|
||||
```tsx
|
||||
const statusLineText = useAppState(s => s.statusLineText)
|
||||
```
|
||||
|
||||
`useAppState` 是选择器订阅,仅在 `statusLineText` 字段变化时触发 rerender;`doUpdate()` 里还做了幂等检查(`prev.statusLineText === text` 则直接返回原 state),**文本不变就不更新 zustand**,连一次 notify 都省掉。
|
||||
|
||||
### Fullscreen 占位
|
||||
|
||||
```tsx
|
||||
{statusLineText ? (
|
||||
<Text dimColor wrap="truncate"><Ansi>{statusLineText}</Ansi></Text>
|
||||
) : isFullscreenEnvEnabled() ? (
|
||||
<Text> </Text> // 占位一行
|
||||
) : null}
|
||||
```
|
||||
|
||||
Fullscreen 模式下 footer `flexShrink:0`,statusline 从 0 行变 1 行会挤掉 ScrollBox 一行内容导致抖动。首次脚本还没返回时,用空格文本占住一行高度,脚本返回后原位替换。
|
||||
|
||||
## 内置 `/statusline` slash command
|
||||
|
||||
`src/commands/statusline.tsx` 定义了一个 **prompt 型 command**,展开成自然语言指令喂给主 Agent:
|
||||
|
||||
```
|
||||
Create an AgentTool with subagent_type "statusline-setup" and the prompt "<user-args>"
|
||||
```
|
||||
|
||||
默认 prompt 是 `"Configure my statusLine from my shell PS1 configuration"`。主 Agent 收到后会调用内置子 agent `statusline-setup`。该子 agent 权限极小:
|
||||
|
||||
- **Tools**: 仅 `Read`、`Edit`
|
||||
- **Allowed paths**: `Read(~/**)`、`Edit(~/.claude/settings.json)`
|
||||
|
||||
也就是说它**不能 Write 新文件、不能跑 Bash**。典型工作是读用户的 shell 配置、读/改 `settings.json`、增量编辑已有的 statusline 脚本。
|
||||
|
||||
## 编写自定义脚本的要点
|
||||
|
||||
1. **脚本必须无状态** — 每次 tick 主进程 fork 一次新 shell,进程内变量不跨调用保留。需要跨 tick 的状态(上次时间戳、上次 token 数)用 `~/.claude/statusline-state/<hash>.state` 文件持久化。
|
||||
2. **按 `session_id` 哈希隔离状态文件** — 多会话同时开着时共享一个 state 文件会串。典型做法:`md5(session_id) | head -c 16` 作为文件名。
|
||||
3. **防御性读取** — state 文件可能损坏/被截断,按行 read + 字段校验(数字字段用 `case "$var" in ''|*[!0-9]*) invalid ;;`)。
|
||||
4. **`refreshInterval` 不等于"脚本秒级调用"** — tick 和事件触发(新消息、模式切换)都走同一 debounce 队列,脚本实际被调用的频率介于"每 N 秒"和"每 N+0.3 秒"之间;且 abort 机制下,上一次没跑完会被 kill。
|
||||
5. **执行时间预算** — 默认 5000ms 超时;为避免 `refreshInterval=1` 时频繁超时,脚本热路径应在 100ms 内完成。重计算(curl、git log 拉取)需缓存。
|
||||
6. **颜色用 ANSI 转义** — 不要依赖 TERM 环境变量;Ink 的 `<Ansi>` 组件独立解析 SGR。
|
||||
7. **不要输出多行** — 单行文本,否则挤占 REPL 布局。
|
||||
8. **处理 `current_usage` 为 null 的情况** — 首次响应之前 `context_window.current_usage` 可能为 null,脚本应有 fallback(如读 state 里上次命中率)。
|
||||
|
||||
### 示例:Cache 命中率 + TTL 倒计时
|
||||
|
||||
本仓库默认安装了一个示例脚本 `~/.claude/statusline-command.sh`(用户侧),输出格式 `<dir> | <model> | ctx:N% | Cache 97% 59:43`:
|
||||
|
||||
- **命中率** = `cache_read / (input + cache_creation + cache_read)`(取自 `current_usage`)
|
||||
- **TTL** 从上次响应倒数 60 分钟,**只在 token signature 变化时重置时间戳**,避免秒级 tick 把 TTL 一直锁在 60:00
|
||||
- **颜色分段** — 命中率 ≥50% 绿 / <50% 灰;TTL 0-20m 绿 / 20-40m 黄 / 40-55m 红 / 最后 5m 闪红 / 过期 `exp` 灰
|
||||
- **Per-session state** — `~/.claude/statusline-state/<md5(session_id)[:16]>.state` 三行(signature、timestamp、hit),读前做 numeric 校验
|
||||
- **Fallback** — `current_usage` 为 null 时读 state 显示上次命中率
|
||||
|
||||
> 该脚本配合 `refreshInterval: 1` 即可秒刷 TTL,前提是 `refreshInterval` 触发路径已实现(见下节)。
|
||||
|
||||
## 已知缺口与修复(本仓库)
|
||||
|
||||
反编译版的 `StatusLine.tsx` 存在一处功能缺口:
|
||||
|
||||
| 项 | 官方 Claude Code | 本仓库原始 | 本仓库现状 |
|
||||
|----|-----------------|-----------|-----------|
|
||||
| `refreshInterval` Zod 字段 | ✅ 有 | ❌ 无 | ✅ 已补 |
|
||||
| Time-driven `setInterval` 触发 | ✅ 有 | ❌ 无 | ✅ 已补 |
|
||||
| Event-driven 触发 | ✅ 有 | ✅ 有 | — |
|
||||
| Settings-driven 触发 | ✅ 有 | ✅ 有 | — |
|
||||
| Debounce + Abort | ✅ 有 | ✅ 有 | — |
|
||||
| Trust 网关 | ✅ 有 | ✅ 有 | — |
|
||||
|
||||
修复(2026-05-06):
|
||||
|
||||
**1. `src/utils/settings/types.ts:554`** — statusLine schema 新增 `refreshInterval: z.number().optional()`,让字段进入类型系统而非被当未知键忽略。
|
||||
|
||||
**2. `src/components/StatusLine.tsx:292`** — 新增 Time-driven useEffect:
|
||||
|
||||
```tsx
|
||||
const refreshIntervalMs = (settings?.statusLine?.refreshInterval ?? 0) * 1000;
|
||||
useEffect(() => {
|
||||
if (refreshIntervalMs <= 0) return;
|
||||
const id = setInterval(() => scheduleUpdate(), refreshIntervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshIntervalMs, scheduleUpdate]);
|
||||
```
|
||||
|
||||
关键点:
|
||||
- 走 `scheduleUpdate`(非 `doUpdate`)复用 300ms debounce,interval + event 双触发不会双跑
|
||||
- `refreshIntervalMs <= 0` 时不启定时器,对未启用该字段的用户零开销
|
||||
- 依赖数组含 `refreshIntervalMs`,settings 热重载会自动清理旧 interval 重建新的
|
||||
|
||||
**静默失效特征**:修复前 settings.json 写 `refreshInterval: 1` 无任何报错——JSON 解析通过,Zod schema 默认 strip 多余字段,官方文档又说支持这个字段,用户很容易以为生效了而没意识到 TTL/时钟类输出根本没秒刷。这是反编译版本的典型"文档与实现不一致"。
|
||||
|
||||
## 相关源码
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/components/StatusLine.tsx` | UI 组件、触发逻辑、buildStatusLineCommandInput |
|
||||
| `src/utils/hooks.ts:4752` | `executeStatusLineCommand`:shell 执行、输出处理、安全网关 |
|
||||
| `src/utils/settings/types.ts:550` | `statusLine` Zod schema |
|
||||
| `src/types/statusLine.ts` | `StatusLineCommandInput` 类型(当前为 stub) |
|
||||
| `src/commands/statusline.tsx` | `/statusline` slash command 定义 |
|
||||
| `src/state/AppStateStore.ts:95` | `statusLineText` 字段声明 |
|
||||
| `src/components/PromptInput/PromptInputFooter.tsx:159` | StatusLine 组件挂载点 |
|
||||
152
learn/LEARN.md
152
learn/LEARN.md
@@ -1,152 +0,0 @@
|
||||
# Claude Code 源码学习路线
|
||||
|
||||
> 基于反编译版 Claude Code CLI (v2.1.888) 的源码学习跟踪
|
||||
>
|
||||
> 各阶段详细笔记见同目录下的 `phase-*.md` 文件
|
||||
|
||||
## 第一阶段:启动流程(入口链路) ✅
|
||||
|
||||
详细笔记:[phase-1-startup-flow.md](phase-1-startup-flow.md)
|
||||
|
||||
理解程序从命令行启动到用户看到交互界面的完整路径。
|
||||
|
||||
- [x] `src/entrypoints/cli.tsx` — 真正入口,polyfill 注入 + 快速路径分发
|
||||
- [x] 全局 polyfill:`feature()` 永远返回 false、`MACRO` 全局对象、`BUILD_*` 常量
|
||||
- [x] 快速路径设计:按开销从低到高检查,能早返回就早返回
|
||||
- [x] 动态 import 模式:`await import()` 延迟加载,减少启动时间
|
||||
- [x] 最终出口:`import("../main.jsx")` → `cliMain()`
|
||||
- [x] `src/main.tsx` — Commander.js CLI 定义,重型初始化(4683 行)
|
||||
- [x] 三段式结构:辅助函数(1-584) → main()(585-856) → run()(884-4683)
|
||||
- [x] side-effect import:profileCheckpoint、startMdmRawRead、startKeychainPrefetch 并行预加载
|
||||
- [x] preAction 钩子:MDM 等待、init()、迁移、远程设置
|
||||
- [x] Commander 参数定义:40+ CLI 选项
|
||||
- [x] action handler(2800 行):参数解析 → 服务初始化 → showSetupScreens → launchRepl()
|
||||
- [x] --print 分支走 print.ts;交互分支走 launchRepl()(7 个场景分支)
|
||||
- [x] 子命令注册:mcp/auth/plugin/doctor/update/install 等
|
||||
- [x] `src/replLauncher.tsx` — 桥梁(22 行),组合 `<App>` + `<REPL>` 渲染到终端
|
||||
- [x] `src/screens/REPL.tsx` — 交互式 REPL 界面(5009 行)
|
||||
- [x] Props:commands、tools、messages、systemPrompt、thinkingConfig 等
|
||||
- [x] 50+ 状态:messages、inputValue、screen、streamingText、queryGuard 等
|
||||
- [x] 核心数据流:onSubmit → handlePromptSubmit → onQuery → onQueryImpl → query() → onQueryEvent
|
||||
- [x] QueryGuard 并发控制:idle → running → idle,防止重复查询
|
||||
- [x] 渲染:Transcript 模式(只读历史)/ Prompt 模式(Messages + PermissionRequest + PromptInput)
|
||||
|
||||
**数据流**:`bun run dev` → `package.json scripts.dev` → `bun run src/entrypoints/cli.tsx` → 快速路径检查 → `main.tsx:main()` → `launchRepl()` → `<App><REPL /></App>`
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:核心对话循环 ✅
|
||||
|
||||
详细笔记:[phase-2-conversation-loop.md](phase-2-conversation-loop.md)
|
||||
|
||||
理解用户发一句话后,如何变成 API 请求、如何处理流式响应和工具调用。
|
||||
|
||||
- [x] `src/query.ts` — 核心查询循环(1732 行)
|
||||
- [x] `query()` AsyncGenerator 入口,委托给 `queryLoop()`
|
||||
- [x] `queryLoop()` — while(true) 主循环,State 对象管理迭代状态
|
||||
- [x] 消息预处理(autocompact、compact boundary)
|
||||
- [x] `deps.callModel()` → 流式 API 调用
|
||||
- [x] StreamingToolExecutor — API 流式返回时并行执行工具
|
||||
- [x] 工具调用循环(tool use → 执行 → result → continue)
|
||||
- [x] 错误恢复(prompt-too-long、max_output_tokens 升级+多轮恢复)
|
||||
- [x] 模型降级(FallbackTriggeredError → 切换 fallbackModel)
|
||||
- [x] Withheld 消息模式(暂扣可恢复错误)
|
||||
- [x] `src/QueryEngine.ts` — 高层编排器(1320 行)
|
||||
- [x] QueryEngine 类 — 一个 conversation 一个实例
|
||||
- [x] `submitMessage()` — 处理用户输入 → 调用 `query()` → 消费事件流
|
||||
- [x] SDK/print 模式专用(REPL 直接调用 query())
|
||||
- [x] 会话持久化(recordTranscript)
|
||||
- [x] Usage 跟踪、权限拒绝记录
|
||||
- [x] `ask()` 便捷包装函数
|
||||
- [x] `src/services/api/claude.ts` — API 客户端(3420 行)
|
||||
- [x] `queryModelWithStreaming` / `queryModelWithoutStreaming` — 两个公开入口
|
||||
- [x] `queryModel()` — 核心私有函数(2400 行)
|
||||
- [x] 请求参数组装(system prompt、betas、tools、cache control)
|
||||
- [x] Anthropic SDK 流式调用(`anthropic.beta.messages.stream()`)
|
||||
- [x] `BetaRawMessageStreamEvent` 事件处理(message_start/content_block_*/message_delta/stop)
|
||||
- [x] withRetry 重试策略(429/500/529 + 模型降级)
|
||||
- [x] Prompt Caching 策略(ephemeral/1h TTL/global scope)
|
||||
- [x] 多 provider 支持(Anthropic / Bedrock / Vertex / Azure)
|
||||
|
||||
**数据流**:REPL.onSubmit → handlePromptSubmit → onQuery → onQueryImpl → `query()` AsyncGenerator → `queryLoop()` while(true) → `deps.callModel()` → `claude.ts queryModel()` → `anthropic.beta.messages.stream()` → 流式事件 → 收集 tool_use → 执行工具 → 结果追加到 messages → continue → 无工具调用时 return
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:工具系统
|
||||
|
||||
理解 Claude 如何定义、注册、调用工具。先读框架,再挑具体工具。
|
||||
|
||||
- [ ] `src/Tool.ts` — Tool 接口定义
|
||||
- [ ] `Tool` 类型结构(name、description、inputSchema、call)
|
||||
- [ ] `findToolByName`、`toolMatchesName` 工具函数
|
||||
- [ ] `src/tools.ts` — 工具注册表
|
||||
- [ ] 工具列表组装逻辑
|
||||
- [ ] 条件加载(feature flag、USER_TYPE)
|
||||
- [ ] 具体工具实现(挑选 2-3 个深入阅读):
|
||||
- [ ] `src/tools/BashTool/` — 执行 shell 命令,最常用的工具
|
||||
- [ ] `src/tools/FileReadTool/` — 读取文件,简单直观,适合理解工具模式
|
||||
- [ ] `src/tools/FileEditTool/` — 编辑文件,理解 diff/patch 机制
|
||||
- [ ] `src/tools/AgentTool/` — 子 Agent 机制,较复杂但核心
|
||||
|
||||
---
|
||||
|
||||
## 第四阶段:上下文与系统提示
|
||||
|
||||
理解 Claude 如何"知道"项目信息、用户偏好等上下文。
|
||||
|
||||
- [ ] `src/context.ts` — 系统/用户上下文构建
|
||||
- [ ] git 状态注入
|
||||
- [ ] CLAUDE.md 内容加载
|
||||
- [ ] 内存文件(memory)注入
|
||||
- [ ] 日期、平台等环境信息
|
||||
- [ ] `src/utils/claudemd.ts` — CLAUDE.md 发现与加载
|
||||
- [ ] 项目层级搜索逻辑
|
||||
- [ ] 多级 CLAUDE.md 合并
|
||||
|
||||
---
|
||||
|
||||
## 第五阶段:UI 层(按兴趣选读)
|
||||
|
||||
理解终端 UI 的渲染机制(React/Ink)。
|
||||
|
||||
- [ ] `src/components/App.tsx` — 根组件,Provider 注入
|
||||
- [ ] `src/state/AppState.tsx` — 全局状态类型与 Context
|
||||
- [ ] `src/components/permissions/` — 工具权限审批 UI
|
||||
- [ ] `src/components/messages/` — 消息渲染组件
|
||||
|
||||
---
|
||||
|
||||
## 第六阶段:外围系统(按需探索)
|
||||
|
||||
- [ ] `src/services/mcp/` — MCP 协议(Model Context Protocol)
|
||||
- [ ] `src/skills/` — 技能系统(/commit 等斜杠命令)
|
||||
- [ ] `src/commands/` — CLI 子命令
|
||||
- [ ] `src/tasks/` — 后台任务系统
|
||||
- [ ] `src/utils/model/providers.ts` — 多 provider 选择逻辑
|
||||
|
||||
---
|
||||
|
||||
## 学习笔记
|
||||
|
||||
### 关键设计模式
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 快速路径 | cli.tsx | 按开销从低到高逐级检查,减少不必要的模块加载 |
|
||||
| 动态 import | cli.tsx / main.tsx | `await import()` 延迟加载,优化启动时间 |
|
||||
| feature flag | 全局 | `feature()` 永远返回 false,所有内部功能禁用 |
|
||||
| React/Ink | UI 层 | 用 React 组件模型渲染终端 UI |
|
||||
| 工具循环 | query.ts | AI 返回工具调用 → 执行 → 结果回传 → 继续,直到无工具调用 |
|
||||
| AsyncGenerator 链 | query.ts → claude.ts | `yield*` 透传事件流,形成管道 |
|
||||
| State 对象 | query.ts queryLoop | 循环间通过不可变 State + transition 字段传递状态 |
|
||||
| StreamingToolExecutor | query.ts | API 流式返回时并行执行工具 |
|
||||
| Withheld 消息 | query.ts | 暂扣可恢复错误,恢复成功则吞掉 |
|
||||
| withRetry | claude.ts | 429/500/529 自动重试 + 模型降级 |
|
||||
| Prompt Caching | claude.ts | 缓存系统提示和历史消息,减少 token 消耗 |
|
||||
|
||||
### 需要忽略的内容
|
||||
|
||||
- `_c()` 调用 — React Compiler 反编译产物
|
||||
- `feature('...')` 后面的代码块 — 全部是死代码
|
||||
- tsc 类型错误 — 反编译导致,不影响 Bun 运行
|
||||
- `packages/@ant/` — stub 包,无实际实现
|
||||
@@ -1,273 +0,0 @@
|
||||
# 第一阶段 Q&A
|
||||
|
||||
## Q1:cli.tsx 的快速路径分发具体在做什么?
|
||||
|
||||
**核心思想**:根据用户输入的命令参数,尽早决定走哪条路,避免加载不需要的代码。cli.tsx 充当一个轻量级路由器,把简单请求就地处理,只有真正需要完整 CLI 时才加载 main.tsx。
|
||||
|
||||
### 场景对比
|
||||
|
||||
#### 场景 1:`claude --version`(命中快速路径)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── args = ["--version"]
|
||||
├── 命中第 64 行: args[0] === "--version" ✅
|
||||
├── console.log("2.1.888 (Claude Code)")
|
||||
└── return ← 立即退出,零 import,~10ms
|
||||
```
|
||||
|
||||
#### 场景 2:`claude --claude-in-chrome-mcp`(命中中间路径)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── 第 64 行: --version? ❌
|
||||
├── 第 75 行: 加载 profileCheckpoint(仅此一个 import)
|
||||
├── 第 81 行: feature("DUMP_SYSTEM_PROMPT") → false ❌
|
||||
├── 第 95 行: --claude-in-chrome-mcp? ✅ 命中
|
||||
├── await import("../utils/claudeInChrome/mcpServer.js") ← 只加载这一个模块
|
||||
└── return ← 没有加载 main.tsx 的 200+ import
|
||||
```
|
||||
|
||||
#### 场景 3:`claude`(无参数,最常见,全部未命中)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── --version? ❌
|
||||
├── profileCheckpoint 加载
|
||||
├── feature(DUMP)? ❌ (feature=false)
|
||||
├── --chrome-mcp? ❌
|
||||
├── --chrome-native? ❌
|
||||
├── feature(CHICAGO)? ❌ (feature=false)
|
||||
├── feature(DAEMON)? ❌ (feature=false)
|
||||
├── feature(BRIDGE)? ❌ (feature=false)
|
||||
├── ... 所有快速路径逐一检查,全部未命中
|
||||
│
|
||||
├── 走到第 310 行 ← 最终出口
|
||||
├── await import("../main.jsx") ← 加载完整 CLI(200+ import,~135ms)
|
||||
└── await cliMain() ← 进入 main.tsx 重型初始化
|
||||
```
|
||||
|
||||
### 性能对比
|
||||
|
||||
| 方式 | `claude --version` 耗时 |
|
||||
|------|------------------------|
|
||||
| 无快速路径(全部走 main.tsx) | ~200ms(加载 200+ import → 初始化 Commander → 解析参数 → 打印) |
|
||||
| 有快速路径(cli.tsx 拦截) | ~10ms(读 args → 打印 → 退出) |
|
||||
|
||||
### feature() 的加速作用
|
||||
|
||||
大量快速路径被 `feature()` 守护:
|
||||
|
||||
```ts
|
||||
if (feature("DAEMON") && args[0] === "daemon") { ... }
|
||||
```
|
||||
|
||||
`feature()` 返回 false → `&&` 短路求值 → 连 `args[0]` 都不检查,直接跳过。在反编译版本中这些路径等于不存在,进一步加速了"全部没命中 → 走默认路径"的过程。
|
||||
|
||||
---
|
||||
|
||||
## Q2:main.tsx 中不同命令的具体执行流程是怎样的?
|
||||
|
||||
所有命令都会经过 main() → run(),但在 run() 内部根据 Commander 路由到不同分支。
|
||||
|
||||
### 场景 1:`claude`(无参数 — 启动交互 REPL)
|
||||
|
||||
最常见的场景,走完整条主命令路径:
|
||||
|
||||
```
|
||||
main() (第 585 行)
|
||||
├── 信号处理注册(SIGINT、exit)
|
||||
├── feature flag 路径全部跳过
|
||||
├── isNonInteractive = false(有 TTY,没有 -p)
|
||||
├── clientType = 'cli'
|
||||
└── await run()
|
||||
│
|
||||
▼
|
||||
run() (第 884 行)
|
||||
├── Commander 初始化 + preAction 钩子 + 主命令选项注册
|
||||
├── isPrintMode = false → 注册所有子命令
|
||||
└── program.parseAsync(process.argv)
|
||||
│ Commander 匹配到主命令,先执行 preAction
|
||||
▼
|
||||
preAction (第 907 行)
|
||||
├── await ensureMdmSettingsLoaded() ← 等 side-effect import 的子进程完成
|
||||
├── await ensureKeychainPrefetchCompleted() ← 等 keychain 预读完成
|
||||
├── await init() ← 遥测、配置、信任
|
||||
├── initSinks() ← 分析日志
|
||||
├── runMigrations() ← 数据迁移
|
||||
└── loadRemoteManagedSettings() / loadPolicyLimits() ← 非阻塞
|
||||
│ 然后执行 action handler
|
||||
▼
|
||||
action(undefined, options) (第 1007 行) ← prompt = undefined
|
||||
├── [参数解析] permissionMode, model, thinkingConfig...
|
||||
├── [工具加载] tools = getTools(toolPermissionContext)
|
||||
├── [并行初始化]
|
||||
│ ├── setup() ← worktree、CWD
|
||||
│ ├── getCommands() ← 加载斜杠命令
|
||||
│ └── getAgentDefinitionsWithOverrides() ← 加载 agent 定义
|
||||
├── [MCP 连接] 连接配置的 MCP 服务器
|
||||
├── [构建初始状态] initialState = { tools, mcp, permissions, ... }
|
||||
│
|
||||
├── [UI 初始化](交互模式专属)
|
||||
│ ├── createRoot() ← 创建 Ink 渲染根节点
|
||||
│ └── showSetupScreens() ← 信任对话框 / OAuth / 引导
|
||||
│
|
||||
├── [后续初始化] LSP、插件版本、session 注册
|
||||
│
|
||||
└── 默认分支 (第 3760 行) ← 没有 --continue/--resume/--print
|
||||
└── await launchRepl(root, {
|
||||
initialState
|
||||
}, {
|
||||
...sessionConfig,
|
||||
initialMessages: undefined ← 全新对话,无历史消息
|
||||
}, renderAndRun)
|
||||
│
|
||||
▼
|
||||
REPL.tsx 渲染,用户看到空白对话界面
|
||||
```
|
||||
|
||||
### 场景 2:`echo "explain this" | claude -p`(管道/非交互模式)
|
||||
|
||||
```
|
||||
main() →
|
||||
├── isNonInteractive = true(-p 标志 + stdin 不是 TTY)
|
||||
├── clientType = 'sdk-cli'
|
||||
└── run()
|
||||
│
|
||||
▼
|
||||
run()
|
||||
├── Commander 初始化 + preAction + 主命令选项
|
||||
├── isPrintMode = true
|
||||
│ → ★ 跳过所有子命令注册(节省 ~65ms)
|
||||
└── program.parseAsync() ← 直接解析,Commander 路由到主命令 action
|
||||
│
|
||||
▼
|
||||
preAction → init、迁移等(同场景 1)
|
||||
│
|
||||
▼
|
||||
action("", { print: true, ... })
|
||||
├── inputPrompt = await getInputPrompt("")
|
||||
│ ├── stdin.isTTY = false → 从 stdin 读数据
|
||||
│ ├── 等待最多 3s 读入: "explain this"
|
||||
│ └── 返回 "explain this"
|
||||
├── tools = getTools()
|
||||
├── setup() + getCommands()(并行)
|
||||
│
|
||||
├── isNonInteractiveSession = true → 走 --print 分支(第 2584 行)
|
||||
│ ├── applyConfigEnvironmentVariables() ← -p 模式信任隐含
|
||||
│ ├── 构建 headlessInitialState(无 UI)
|
||||
│ ├── headlessStore = createStore(headlessInitialState)
|
||||
│ │
|
||||
│ ├── await import('src/cli/print.js')
|
||||
│ └── runHeadless(inputPrompt, ...) ★ 不走 REPL
|
||||
│ ├── 发送 API 请求
|
||||
│ ├── 流式输出到 stdout
|
||||
│ └── 完成后 process.exit()
|
||||
│
|
||||
└── ← 不走 createRoot()、showSetupScreens()、launchRepl()
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- 检测到 `-p` 后跳过子命令注册(节省 ~65ms)
|
||||
- 不创建 Ink UI,不调用 `showSetupScreens()`
|
||||
- 从 stdin 读取输入(`getInputPrompt` 第 857 行)
|
||||
- 走 `print.js` 路径直接执行查询输出到 stdout
|
||||
|
||||
### 场景 3:`claude -c`(继续最近对话)
|
||||
|
||||
```
|
||||
... main() → run() → preAction → action(前半部分同场景 1)
|
||||
│
|
||||
▼
|
||||
action(undefined, { continue: true, ... })
|
||||
├── [参数解析 + 工具加载 + 并行初始化 + UI 初始化](同场景 1)
|
||||
│
|
||||
├── options.continue = true → 命中第 3101 行
|
||||
│ ├── clearSessionCaches() ← 清除过期缓存
|
||||
│ ├── result = await loadConversationForResume()
|
||||
│ │ └── 从 ~/.claude/projects/<cwd>/ 读最近的会话 JSONL
|
||||
│ │
|
||||
│ ├── result 为 null? → exitWithError("No conversation found")
|
||||
│ │
|
||||
│ ├── loaded = await processResumedConversation(result)
|
||||
│ │ ├── 解析 JSONL → messages[]
|
||||
│ │ ├── 恢复文件历史快照
|
||||
│ │ └── 重建 initialState
|
||||
│ │
|
||||
│ └── await launchRepl(root, {
|
||||
│ initialState: loaded.initialState
|
||||
│ }, {
|
||||
│ ...sessionConfig,
|
||||
│ initialMessages: loaded.messages, ★ 带上历史消息
|
||||
│ initialFileHistorySnapshots: loaded.fileHistorySnapshots,
|
||||
│ initialAgentName: loaded.agentName
|
||||
│ }, renderAndRun)
|
||||
│ │
|
||||
│ ▼
|
||||
│ REPL.tsx 渲染,显示历史对话,用户继续聊天
|
||||
│
|
||||
└── ← 其他分支不执行
|
||||
```
|
||||
|
||||
**关键差异**:`initialMessages` 有值(历史消息),REPL 启动时会渲染之前的对话内容。
|
||||
|
||||
### 场景 4:`claude mcp list`(子命令)
|
||||
|
||||
```
|
||||
main() → run()
|
||||
│
|
||||
▼
|
||||
run()
|
||||
├── Commander 初始化 + preAction 钩子
|
||||
├── 注册主命令 .action(...)
|
||||
├── isPrintMode = false → 注册所有子命令
|
||||
│ ├── program.command('mcp') (第 3894 行)
|
||||
│ │ ├── mcp.command('serve').action(...)
|
||||
│ │ ├── mcp.command('add').action(...)
|
||||
│ │ ├── mcp.command('list').action(async () => { ★
|
||||
│ │ │ const { mcpListHandler } = await import('./cli/handlers/mcp.js');
|
||||
│ │ │ await mcpListHandler();
|
||||
│ │ │ })
|
||||
│ │ └── ...
|
||||
│ ├── program.command('auth')
|
||||
│ ├── program.command('doctor')
|
||||
│ └── ...
|
||||
│
|
||||
└── program.parseAsync(["node", "claude", "mcp", "list"])
|
||||
│ Commander 匹配到 mcp → list
|
||||
▼
|
||||
preAction (第 907 行) ← 子命令也触发 preAction
|
||||
├── await init()
|
||||
├── initSinks()
|
||||
├── runMigrations()
|
||||
└── ...
|
||||
│
|
||||
▼ 执行子命令自己的 action(不走主命令 action)
|
||||
mcp list action
|
||||
├── await import('./cli/handlers/mcp.js')
|
||||
└── await mcpListHandler()
|
||||
├── 读取 MCP 配置(user/project/local 三级)
|
||||
├── 连接每个服务器做健康检查
|
||||
├── 格式化输出到终端
|
||||
└── 退出
|
||||
|
||||
← 主命令的 action handler 完全不执行
|
||||
← 没有 REPL、没有 Ink UI、没有 showSetupScreens
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- Commander 路由到子命令,**主命令 action 完全跳过**
|
||||
- `preAction` 仍然执行(基础初始化所有命令都需要)
|
||||
- 子命令有自己独立的轻量 action
|
||||
|
||||
### 四种场景对比
|
||||
|
||||
| | `claude` | `claude -p` | `claude -c` | `claude mcp list` |
|
||||
|---|---------|------------|------------|-------------------|
|
||||
| preAction | 执行 | 执行 | 执行 | 执行 |
|
||||
| 主命令 action | 执行 | 执行 | 执行 | **跳过** |
|
||||
| 子命令注册 | 注册 | **跳过** | 注册 | 注册 |
|
||||
| showSetupScreens | 执行 | **跳过** | 执行 | **跳过** |
|
||||
| createRoot (Ink) | 执行 | **跳过** | 执行 | **跳过** |
|
||||
| 加载历史消息 | 否 | 否 | **是** | 否 |
|
||||
| 最终出口 | launchRepl | print.js | launchRepl | 子命令 action |
|
||||
@@ -1,597 +0,0 @@
|
||||
# 第一阶段:启动流程详解
|
||||
|
||||
> 从 `bun run dev` 到用户看到交互界面的完整路径
|
||||
|
||||
## 启动链路总览
|
||||
|
||||
```
|
||||
bun run dev
|
||||
→ package.json scripts.dev: "bun run src/entrypoints/cli.tsx"
|
||||
→ cli.tsx: polyfill 注入 + 快速路径检查
|
||||
→ import("../main.jsx") → cliMain()
|
||||
→ main.tsx: main() → run()
|
||||
→ Commander 参数解析 → preAction 钩子
|
||||
→ action handler: 服务初始化 → showSetupScreens
|
||||
→ launchRepl()
|
||||
→ replLauncher.tsx: <App><REPL /></App>
|
||||
→ REPL.tsx: 渲染交互界面,等待用户输入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. cli.tsx(321 行)— 入口与快速路径分发
|
||||
|
||||
**文件路径**: `src/entrypoints/cli.tsx`
|
||||
|
||||
### 1.1 全局 Polyfill(第 1-53 行)
|
||||
|
||||
模块加载时立即执行的 side-effect,在 `main()` 之前运行。
|
||||
|
||||
#### feature() 桩函数(第 3 行)
|
||||
|
||||
```ts
|
||||
const feature = (_name: string) => false;
|
||||
```
|
||||
|
||||
原版 Claude Code 构建时,Bun bundler 通过 `bun:bundle` 提供 `feature()` 函数,用于**编译时 feature flag**(类似 C 的 `#ifdef`)。反编译版没有构建流程,所以直接定义为永远返回 `false`。
|
||||
|
||||
**效果**:所有 Anthropic 内部功能分支全部禁用,包括:
|
||||
- `COORDINATOR_MODE` — 协调器模式
|
||||
- `KAIROS` — 助手模式
|
||||
- `DAEMON` — 后台守护进程
|
||||
- `BRIDGE_MODE` — 远程控制
|
||||
- `SSH_REMOTE` — SSH 远程
|
||||
- `BG_SESSIONS` — 后台会话
|
||||
- ... 等 20+ 个 flag
|
||||
|
||||
#### MACRO 全局对象(第 4-14 行)
|
||||
|
||||
```ts
|
||||
globalThis.MACRO = {
|
||||
VERSION: "2.1.888",
|
||||
BUILD_TIME: new Date().toISOString(),
|
||||
FEEDBACK_CHANNEL: "",
|
||||
ISSUES_EXPLAINER: "",
|
||||
NATIVE_PACKAGE_URL: "",
|
||||
PACKAGE_URL: "",
|
||||
VERSION_CHANGELOG: "",
|
||||
};
|
||||
```
|
||||
|
||||
原版构建时 Bun 会把这些值内联到代码里。这里模拟注入,让后续代码读 `MACRO.VERSION` 时能拿到值。
|
||||
|
||||
#### 构建常量(第 16-18 行)
|
||||
|
||||
```ts
|
||||
BUILD_TARGET = "external"; // 标记为"外部"构建(非 Anthropic 内部)
|
||||
BUILD_ENV = "production"; // 生产环境
|
||||
INTERFACE_TYPE = "stdio"; // 标准输入输出模式
|
||||
```
|
||||
|
||||
这三个全局变量在代码各处被读取,用来区分运行环境。`"external"` 意味着很多 `("external" as string) === 'ant'` 的检查会返回 false。
|
||||
|
||||
#### 环境修补(第 22-33 行)
|
||||
|
||||
- 禁用 corepack 自动 pin(防止污染 package.json)
|
||||
- 远程模式下设置 Node.js 堆内存上限 8GB
|
||||
|
||||
#### ABLATION_BASELINE(第 40-53 行)
|
||||
|
||||
```ts
|
||||
if (feature("ABLATION_BASELINE") && ...) { ... }
|
||||
```
|
||||
|
||||
`feature()` 返回 false,**永远不执行**。Anthropic 内部 A/B 测试代码。
|
||||
|
||||
### 1.2 main() 函数(第 60-317 行)
|
||||
|
||||
设计模式:**分层快速路径(fast path cascading)**——按开销从低到高逐级检查,命中即返回。
|
||||
|
||||
#### 快速路径列表
|
||||
|
||||
| 优先级 | 行号 | 检查条件 | 功能 | 开销 | 可执行 |
|
||||
|--------|------|---------|------|------|--------|
|
||||
| 1 | 64-72 | `--version` / `-v` | 打印版本号退出 | **零 import** | 是 |
|
||||
| 2 | 81-94 | `feature("DUMP_SYSTEM_PROMPT")` | 导出系统提示 | - | 否(flag) |
|
||||
| 3 | 95-99 | `--claude-in-chrome-mcp` | Chrome MCP 服务 | 动态 import | 是 |
|
||||
| 4 | 101-105 | `--chrome-native-host` | Chrome Native Host | 动态 import | 是 |
|
||||
| 5 | 108-116 | `feature("CHICAGO_MCP")` | Computer Use MCP | - | 否(flag) |
|
||||
| 6 | 123-127 | `feature("DAEMON")` | Daemon Worker | - | 否(flag) |
|
||||
| 7 | 133-178 | `feature("BRIDGE_MODE")` | 远程控制 | - | 否(flag) |
|
||||
| 8 | 181-190 | `feature("DAEMON")` | Daemon 主进程 | - | 否(flag) |
|
||||
| 9 | 195-225 | `feature("BG_SESSIONS")` | ps/logs/attach/kill | - | 否(flag) |
|
||||
| 10 | 228-240 | `feature("TEMPLATES")` | 模板任务 | - | 否(flag) |
|
||||
| 11 | 244-253 | `feature("BYOC_ENVIRONMENT_RUNNER")` | BYOC 运行器 | - | 否(flag) |
|
||||
| 12 | 258-264 | `feature("SELF_HOSTED_RUNNER")` | 自托管运行器 | - | 否(flag) |
|
||||
| 13 | 267-293 | `--tmux` + `--worktree` | tmux worktree | 动态 import | 是 |
|
||||
|
||||
#### 参数修正(第 296-307 行)
|
||||
|
||||
```ts
|
||||
// --update/--upgrade → 重写为 update 子命令
|
||||
if (args[0] === "--update") process.argv = [..., "update"];
|
||||
// --bare → 设置简单模式环境变量
|
||||
if (args.includes("--bare")) process.env.CLAUDE_CODE_SIMPLE = "1";
|
||||
```
|
||||
|
||||
#### 最终出口(第 310-316 行)
|
||||
|
||||
```ts
|
||||
const { startCapturingEarlyInput } = await import("../utils/earlyInput.js");
|
||||
startCapturingEarlyInput(); // 捕获用户提前输入的内容
|
||||
const { main: cliMain } = await import("../main.jsx");
|
||||
await cliMain(); // 进入 main.tsx 重型初始化
|
||||
```
|
||||
|
||||
所有快速路径都没命中时(99% 的情况),才走到这里。
|
||||
|
||||
### 1.3 启动(第 320 行)
|
||||
|
||||
```ts
|
||||
void main();
|
||||
```
|
||||
|
||||
`void` 表示不关心 Promise 返回值。
|
||||
|
||||
### 1.4 关键设计思想
|
||||
|
||||
- **快速路径**:`--version` 零开销返回,不加载任何模块
|
||||
- **动态 import**:`await import()` 替代静态 import,每条路径只加载自己需要的模块
|
||||
- **feature flag 过滤**:`feature()` 返回 false 使大量内部功能成为死代码
|
||||
|
||||
---
|
||||
|
||||
## 2. main.tsx(4683 行)— 重型初始化与 Commander CLI
|
||||
|
||||
**文件路径**: `src/main.tsx`
|
||||
|
||||
整个项目最大的单文件,但结构清晰:**辅助函数 → main() → run()**。
|
||||
|
||||
### 2.1 Import 区(第 1-215 行)
|
||||
|
||||
200+ 行 import,加载几乎所有子系统。关键的是前三个 **side-effect import**(import 即执行):
|
||||
|
||||
```ts
|
||||
// 第 9 行:记录时间戳
|
||||
profileCheckpoint('main_tsx_entry');
|
||||
|
||||
// 第 16 行:启动 MDM 子进程读取(macOS plutil)
|
||||
startMdmRawRead();
|
||||
|
||||
// 第 20 行:启动 keychain 预读取(OAuth token、API key)
|
||||
startKeychainPrefetch();
|
||||
```
|
||||
|
||||
这三个在 import 阶段就**并行启动子进程**,和后续 ~135ms 的模块加载同时进行——**用并行隐藏延迟**。
|
||||
|
||||
### 2.2 辅助函数(第 216-584 行)
|
||||
|
||||
| 函数 | 行号 | 作用 |
|
||||
|------|------|------|
|
||||
| `logManagedSettings()` | 216 | 记录企业托管设置到分析日志 |
|
||||
| `isBeingDebugged()` | 232 | 检测调试模式,**外部构建下直接 exit(1)**(第 266 行) |
|
||||
| `logSessionTelemetry()` | 279 | Session 遥测(技能、插件) |
|
||||
| `getCertEnvVarTelemetry()` | 291 | SSL 证书环境变量收集 |
|
||||
| `runMigrations()` | 326 | 数据迁移(模型重命名、设置格式升级等) |
|
||||
| `prefetchSystemContextIfSafe()` | 360 | 信任关系建立后安全预取系统上下文 |
|
||||
| `startDeferredPrefetches()` | 388 | REPL 首次渲染后的延迟预取 |
|
||||
| `eagerLoadSettings()` | 502 | 在 init() 之前提前加载 `--settings` 参数 |
|
||||
| `initializeEntrypoint()` | 517 | 根据运行模式设置 `CLAUDE_CODE_ENTRYPOINT` |
|
||||
|
||||
还有 `_pendingConnect`、`_pendingSSH`、`_pendingAssistantChat` 三个状态变量(第 542-583 行),用于暂存子命令参数。
|
||||
|
||||
### 2.3 main() 函数(第 585-856 行)
|
||||
|
||||
`main()` 本身不长,做完环境检测后调用 `run()`:
|
||||
|
||||
```
|
||||
main()
|
||||
├── 安全设置(NoDefaultCurrentDirectoryInExePath)
|
||||
├── 信号处理(SIGINT → exit, exit → 恢复光标)
|
||||
├── feature flag 保护的特殊路径(全部跳过)
|
||||
├── 检测 -p/--print / --init-only → 判断是否交互模式
|
||||
├── clientType 判断(cli / sdk-typescript / remote / github-action 等)
|
||||
├── eagerLoadSettings()
|
||||
└── await run() ← 进入真正的逻辑
|
||||
```
|
||||
|
||||
### 2.4 run() 函数(第 884-4683 行)
|
||||
|
||||
占 3800 行,是整个文件的核心。
|
||||
|
||||
#### Commander 初始化 + preAction 钩子(第 884-967 行)
|
||||
|
||||
```ts
|
||||
const program = new CommanderCommand()
|
||||
.configureHelp(createSortedHelpConfig())
|
||||
.enablePositionalOptions();
|
||||
```
|
||||
|
||||
**preAction 钩子**(所有命令执行前都会运行):
|
||||
|
||||
```
|
||||
preAction
|
||||
├── await ensureMdmSettingsLoaded() ← 等 MDM 子进程完成
|
||||
├── await ensureKeychainPrefetchCompleted() ← 等 keychain 预读完成
|
||||
├── await init() ← 一次性初始化
|
||||
├── initSinks() ← 分析日志接收器
|
||||
├── runMigrations() ← 数据迁移
|
||||
├── loadRemoteManagedSettings() ← 企业远程设置(非阻塞)
|
||||
└── loadPolicyLimits() ← 策略限制(非阻塞)
|
||||
```
|
||||
|
||||
#### 主命令 Option 定义(第 968-1006 行)
|
||||
|
||||
定义了 40+ CLI 参数,关键的包括:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|------|------|
|
||||
| `-p, --print` | 非交互模式,输出后退出 |
|
||||
| `--model <model>` | 指定模型(如 sonnet、opus) |
|
||||
| `--permission-mode <mode>` | 权限模式 |
|
||||
| `-c, --continue` | 继续最近对话 |
|
||||
| `-r, --resume` | 恢复指定对话 |
|
||||
| `--mcp-config` | MCP 服务器配置文件 |
|
||||
| `--allowedTools` | 允许的工具列表 |
|
||||
| `--system-prompt` | 自定义系统提示 |
|
||||
| `--dangerously-skip-permissions` | 跳过所有权限检查 |
|
||||
| `--output-format` | 输出格式(text/json/stream-json) |
|
||||
| `--effort <level>` | 推理努力级别(low/medium/high/max) |
|
||||
| `--bare` | 最小模式 |
|
||||
|
||||
#### action 处理器(第 1006-3808 行)
|
||||
|
||||
主命令的执行逻辑,内部按阶段和场景分支:
|
||||
|
||||
```
|
||||
action(async (prompt, options) => {
|
||||
│
|
||||
├── [1007-1600] 参数解析与预处理
|
||||
│ ├── --bare 模式
|
||||
│ ├── 解析 model / permission-mode / thinking / effort
|
||||
│ ├── 解析 MCP 配置、工具列表、系统提示
|
||||
│ └── 初始化工具权限上下文
|
||||
│
|
||||
├── [1600-2220] 服务初始化
|
||||
│ ├── MCP 客户端连接
|
||||
│ ├── 插件加载 + 技能初始化
|
||||
│ ├── 工具列表组装
|
||||
│ └── 初始 AppState 构建
|
||||
│
|
||||
├── [2220-2315] UI 初始化(交互模式)
|
||||
│ ├── createRoot() — 创建 Ink 渲染根节点
|
||||
│ ├── showSetupScreens() — 信任对话框、OAuth 登录、引导
|
||||
│ └── 登录后刷新各种服务
|
||||
│
|
||||
├── [2315-2582] 后续初始化
|
||||
│ ├── LSP 管理器、插件版本管理
|
||||
│ ├── session 注册、遥测日志
|
||||
│ └── 遥测上报
|
||||
│
|
||||
├── [2584-3050] --print 非交互模式分支
|
||||
│ ├── 构建 headless AppState + store
|
||||
│ └── 交给 print.ts 执行
|
||||
│
|
||||
└── [3050-3808] 交互模式:启动 REPL(7 个分支)
|
||||
├── --continue → 加载最近对话 → launchRepl()
|
||||
├── DIRECT_CONNECT → ❌ flag 关闭
|
||||
├── SSH_REMOTE → ❌ flag 关闭
|
||||
├── KAIROS assistant → ❌ flag 关闭
|
||||
├── --resume <id> → 恢复指定对话 → launchRepl()
|
||||
├── --resume 无 ID → 显示对话选择器
|
||||
└── 默认(无参数) → launchRepl() ★最常走的路径
|
||||
})
|
||||
```
|
||||
|
||||
#### 子命令注册(第 3808-4683 行)
|
||||
|
||||
| 子命令 | 行号 | 作用 |
|
||||
|--------|------|------|
|
||||
| `claude mcp` | 3892 | MCP 服务器管理(serve/add/remove/list/get) |
|
||||
| `claude server` | 3960 | Session 服务器(❌ flag 关闭) |
|
||||
| `claude auth` | 4098 | 认证管理(login/logout/status/token) |
|
||||
| `claude plugin` | 4148 | 插件管理(install/uninstall/list/update) |
|
||||
| `claude setup-token` | 4267 | 设置长期认证 token |
|
||||
| `claude agents` | 4278 | 列出已配置的 agents |
|
||||
| `claude doctor` | 4346 | 健康检查 |
|
||||
| `claude update` | 4362 | 检查更新 |
|
||||
| `claude install` | 4394 | 安装原生构建 |
|
||||
| `claude log` | 4411 | 查看对话日志(内部) |
|
||||
| `claude completion` | 4491 | Shell 自动补全 |
|
||||
|
||||
最后执行解析:
|
||||
|
||||
```ts
|
||||
await program.parseAsync(process.argv);
|
||||
```
|
||||
|
||||
### 2.5 main.tsx 学习建议
|
||||
|
||||
- **不要通读**。记住三段结构:辅助函数 → main() → run()
|
||||
- `feature()` 返回 false 的分支全部跳过,可忽略 50%+ 代码
|
||||
- `("external" as string) === 'ant'` 的分支也跳过(内部构建专用)
|
||||
- 需要深入某功能时,通过搜索定位对应代码段
|
||||
|
||||
---
|
||||
|
||||
## 3. replLauncher.tsx(22 行)— 胶水层
|
||||
|
||||
**文件路径**: `src/replLauncher.tsx`
|
||||
|
||||
极其简单,就做一件事:
|
||||
|
||||
```tsx
|
||||
export async function launchRepl(root, appProps, replProps, renderAndRun) {
|
||||
const { App } = await import('./components/App.js');
|
||||
const { REPL } = await import('./screens/REPL.js');
|
||||
await renderAndRun(root, <App {...appProps}><REPL {...replProps} /></App>);
|
||||
}
|
||||
```
|
||||
|
||||
- `App` — 全局 Provider(AppState、Stats、FpsMetrics)
|
||||
- `REPL` — 交互界面组件
|
||||
- `renderAndRun` — 把 React 元素渲染到 Ink 终端
|
||||
|
||||
动态 import 保持了按需加载的策略。
|
||||
|
||||
---
|
||||
|
||||
## 4. REPL.tsx(5009 行)— 交互界面
|
||||
|
||||
**文件路径**: `src/screens/REPL.tsx`
|
||||
|
||||
项目第二大文件,是用户直接交互的界面。一个巨型 React 函数组件。
|
||||
|
||||
### 4.1 文件结构
|
||||
|
||||
```
|
||||
REPL.tsx (5009 行)
|
||||
├── [1-310] Import 区(150+ import)
|
||||
├── [312-525] 辅助组件
|
||||
│ ├── median() — 数学工具函数
|
||||
│ ├── TranscriptModeFooter — 转录模式底栏
|
||||
│ ├── TranscriptSearchBar — 转录搜索栏
|
||||
│ └── AnimatedTerminalTitle — 终端标题动画
|
||||
├── [527-571] Props 类型定义
|
||||
└── [573-5009] REPL() 组件主体
|
||||
├── [600-900] 状态声明(50+ 个 useState/useRef/useAppState)
|
||||
├── [900-2750] 副作用与回调(useEffect/useCallback)
|
||||
├── [2750-2860] onQueryImpl — 核心:执行 API 查询
|
||||
├── [2860-3030] onQuery — 查询守卫与并发控制
|
||||
├── [3030-3145] 查询相关辅助回调
|
||||
├── [3146-3550] onSubmit — 用户提交处理
|
||||
├── [3550-4395] 更多副作用与状态管理
|
||||
└── [4396-5009] JSX 渲染
|
||||
```
|
||||
|
||||
### 4.2 Props
|
||||
|
||||
从 main.tsx 通过 launchRepl() 传入:
|
||||
|
||||
| Prop | 类型 | 含义 |
|
||||
|------|------|------|
|
||||
| `commands` | `Command[]` | 可用的斜杠命令 |
|
||||
| `debug` | `boolean` | 调试模式 |
|
||||
| `initialTools` | `Tool[]` | 初始工具集 |
|
||||
| `initialMessages` | `MessageType[]` | 初始消息(恢复对话时有值) |
|
||||
| `pendingHookMessages` | `Promise<...>` | 延迟加载的 hook 消息 |
|
||||
| `mcpClients` | `MCPServerConnection[]` | MCP 服务器连接 |
|
||||
| `systemPrompt` | `string` | 自定义系统提示 |
|
||||
| `appendSystemPrompt` | `string` | 追加系统提示 |
|
||||
| `onBeforeQuery` | `fn` | 查询前回调,返回 false 可阻止查询 |
|
||||
| `onTurnComplete` | `fn` | 轮次完成回调 |
|
||||
| `mainThreadAgentDefinition` | `AgentDefinition` | 主线程 Agent 定义 |
|
||||
| `thinkingConfig` | `ThinkingConfig` | 思考模式配置 |
|
||||
| `disabled` | `boolean` | 禁用输入 |
|
||||
|
||||
### 4.3 状态管理
|
||||
|
||||
分三层:
|
||||
|
||||
**全局 AppState(通过 useAppState 选择器读取):**
|
||||
|
||||
```ts
|
||||
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
|
||||
const verbose = useAppState(s => s.verbose);
|
||||
const mcp = useAppState(s => s.mcp);
|
||||
const plugins = useAppState(s => s.plugins);
|
||||
const agentDefinitions = useAppState(s => s.agentDefinitions);
|
||||
```
|
||||
|
||||
**本地状态(useState):**
|
||||
|
||||
```ts
|
||||
const [messages, setMessages] = useState(initialMessages ?? []);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [screen, setScreen] = useState<Screen>('prompt');
|
||||
const [streamingText, setStreamingText] = useState(null);
|
||||
const [streamingToolUses, setStreamingToolUses] = useState([]);
|
||||
// ... 50+ 个状态
|
||||
```
|
||||
|
||||
**关键 Ref:**
|
||||
|
||||
```ts
|
||||
const queryGuard = useRef(new QueryGuard()).current; // 查询并发控制
|
||||
const messagesRef = useRef(messages); // 消息的同步引用(避免闭包问题)
|
||||
const abortController = ...; // 取消请求控制器
|
||||
const responseLengthRef = useRef(0); // 响应长度追踪
|
||||
```
|
||||
|
||||
### 4.4 核心数据流:用户输入 → API 调用
|
||||
|
||||
```
|
||||
用户按回车
|
||||
│
|
||||
▼
|
||||
onSubmit (第 3146 行)
|
||||
├── 斜杠命令?→ immediate command 直接执行 或 handlePromptSubmit 路由
|
||||
├── 空输入?→ 忽略
|
||||
├── 空闲检测 → 可能弹出"是否开始新对话"对话框
|
||||
├── 加入历史记录
|
||||
│
|
||||
▼
|
||||
handlePromptSubmit (外部函数,src/utils/handlePromptSubmit.ts)
|
||||
├── 斜杠命令 → 路由到对应 Command handler
|
||||
├── 普通文本 → 构建 UserMessage,调用 onQuery()
|
||||
│
|
||||
▼
|
||||
onQuery (第 2860 行) — 并发守卫层
|
||||
├── queryGuard.tryStart() → 已有查询?排队等待
|
||||
├── setMessages([...old, ...newMessages]) — 追加用户消息
|
||||
├── onQueryImpl()
|
||||
│
|
||||
▼
|
||||
onQueryImpl (第 2750 行) — 真正执行 API 调用
|
||||
│
|
||||
├── 1. 并行加载上下文:
|
||||
│ await Promise.all([
|
||||
│ getSystemPrompt(), // 构建系统提示
|
||||
│ getUserContext(), // 用户上下文
|
||||
│ getSystemContext(), // 系统上下文(git、平台等)
|
||||
│ ])
|
||||
│
|
||||
├── 2. buildEffectiveSystemPrompt() — 合成最终系统提示
|
||||
│
|
||||
├── 3. for await (const event of query({...})) ★核心★
|
||||
│ │ 调用 src/query.ts 的 query() AsyncGenerator
|
||||
│ │ 流式产出事件
|
||||
│ │
|
||||
│ └── onQueryEvent(event) — 处理每个流式事件
|
||||
│ ├── 更新 streamingText(打字机效果)
|
||||
│ ├── 更新 messages(工具调用结果)
|
||||
│ └── 更新 inProgressToolUseIDs
|
||||
│
|
||||
└── 4. 收尾:resetLoadingState()、onTurnComplete()
|
||||
```
|
||||
|
||||
**核心代码(第 2797-2807 行)**:
|
||||
|
||||
```ts
|
||||
for await (const event of query({
|
||||
messages: messagesIncludingNewMessages,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
systemContext,
|
||||
canUseTool,
|
||||
toolUseContext,
|
||||
querySource: getQuerySourceForREPL()
|
||||
})) {
|
||||
onQueryEvent(event);
|
||||
}
|
||||
```
|
||||
|
||||
`query()` 来自 `src/query.ts`,是第二阶段要学的核心函数。
|
||||
|
||||
### 4.5 QueryGuard 并发控制
|
||||
|
||||
防止同时发起多个 API 请求的状态机:
|
||||
|
||||
```
|
||||
idle ──tryStart()──▶ running ──end()──▶ idle
|
||||
│
|
||||
└── tryStart() 返回 null(已在运行)
|
||||
→ 新消息排入队列
|
||||
```
|
||||
|
||||
- `tryStart()` — 原子操作,检查并转换 idle→running,返回 generation 号
|
||||
- `end(generation)` — 检查 generation 匹配后转换 running→idle
|
||||
- 防止 cancel+resubmit 竞态条件
|
||||
|
||||
### 4.6 JSX 渲染
|
||||
|
||||
两个互斥的渲染分支:
|
||||
|
||||
#### Transcript 模式(第 4396-4493 行)
|
||||
|
||||
按 `v` 键切换,只读浏览对话历史,支持搜索:
|
||||
|
||||
```tsx
|
||||
<KeybindingSetup>
|
||||
<AnimatedTerminalTitle />
|
||||
<GlobalKeybindingHandlers />
|
||||
<ScrollKeybindingHandler />
|
||||
<CancelRequestHandler />
|
||||
<FullscreenLayout
|
||||
scrollable={<Messages />}
|
||||
bottom={<TranscriptSearchBar /> 或 <TranscriptModeFooter />}
|
||||
/>
|
||||
</KeybindingSetup>
|
||||
```
|
||||
|
||||
#### Prompt 模式(第 4552-5009 行)
|
||||
|
||||
主交互界面,从上到下:
|
||||
|
||||
```tsx
|
||||
<KeybindingSetup>
|
||||
<AnimatedTerminalTitle /> // 终端 tab 标题
|
||||
<GlobalKeybindingHandlers /> // 全局快捷键
|
||||
<CommandKeybindingHandlers /> // 命令快捷键
|
||||
<ScrollKeybindingHandler /> // 滚动快捷键
|
||||
<CancelRequestHandler /> // Ctrl+C 取消
|
||||
<MCPConnectionManager> // MCP 连接管理
|
||||
<FullscreenLayout
|
||||
overlay={<PermissionRequest />} // 权限审批覆盖层
|
||||
scrollable={ // 可滚动区域
|
||||
<>
|
||||
<Messages /> // ★ 对话消息渲染
|
||||
<UserTextMessage /> // 用户输入占位
|
||||
{toolJSX} // 工具 UI
|
||||
<SpinnerWithVerb /> // 加载动画
|
||||
</>
|
||||
}
|
||||
bottom={ // 固定底部
|
||||
<>
|
||||
{/* 各种对话框 */}
|
||||
<SandboxPermissionRequest />
|
||||
<PromptDialog />
|
||||
<ElicitationDialog />
|
||||
<CostThresholdDialog />
|
||||
<FeedbackSurvey />
|
||||
|
||||
{/* ★ 用户输入框 */}
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
commands={commands}
|
||||
isLoading={isLoading}
|
||||
messages={messages}
|
||||
// ... 20+ props
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</MCPConnectionManager>
|
||||
</KeybindingSetup>
|
||||
```
|
||||
|
||||
### 4.7 REPL.tsx 学习建议
|
||||
|
||||
- 核心只有一条线:`onSubmit → onQuery → query() → onQueryEvent → 更新消息`
|
||||
- 其余 4000+ 行是 UI 细节:快捷键、对话框、动画、边界情况处理
|
||||
- `feature('...')` 保护的 JSX 全部跳过
|
||||
- `("external" as string) === 'ant'` 的分支也跳过
|
||||
|
||||
---
|
||||
|
||||
## 关键设计模式总结
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 快速路径 | cli.tsx | 按开销从低到高逐级检查,零开销处理简单请求 |
|
||||
| 动态 import | cli.tsx / main.tsx | `await import()` 延迟加载,每条路径只加载需要的模块 |
|
||||
| Side-effect import | main.tsx 顶部 | import 阶段就并行启动子进程,用并行隐藏延迟 |
|
||||
| feature flag | 全局 | `feature()` 永远返回 false,编译时消除死代码 |
|
||||
| preAction 钩子 | main.tsx run() | Commander.js 命令执行前统一初始化 |
|
||||
| QueryGuard | REPL.tsx | 状态机防止并发 API 请求,带 generation 计数防竞态 |
|
||||
| React/Ink | UI 层 | 用 React 组件模型渲染终端 UI,支持全屏和虚拟滚动 |
|
||||
|
||||
## 需要忽略的代码模式
|
||||
|
||||
| 模式 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `_c(N)` 调用 | React Compiler | 反编译产生的 memoization 样板代码 |
|
||||
| `feature('FLAG')` 后面的代码 | Bun bundler | 全部是死代码,在当前版本不会执行 |
|
||||
| `("external" as string) === 'ant'` | 构建目标检查 | 永远为 false(external !== ant) |
|
||||
| tsc 类型错误 | 反编译 | `unknown`/`never`/`{}` 类型,不影响 Bun 运行 |
|
||||
| `packages/@ant/` | stub 包 | 空实现,仅满足 import 依赖 |
|
||||
@@ -1,774 +0,0 @@
|
||||
# 第二阶段:核心对话循环详解
|
||||
|
||||
> 用户发一句话后,如何变成 API 请求、如何处理流式响应和工具调用
|
||||
|
||||
## 对话循环总览
|
||||
|
||||
```
|
||||
用户输入 "帮我读取 README.md"
|
||||
│
|
||||
▼
|
||||
REPL.tsx: onSubmit → onQuery → onQueryImpl
|
||||
│
|
||||
├── 1. 并行加载上下文:
|
||||
│ getSystemPrompt() + getUserContext() + getSystemContext()
|
||||
│
|
||||
├── 2. buildEffectiveSystemPrompt() — 合成最终系统提示
|
||||
│
|
||||
├── 3. for await (const event of query({...})) ★ 核心循环
|
||||
│ │
|
||||
│ │ query.ts: queryLoop()
|
||||
│ │ ├── while (true) {
|
||||
│ │ │ ├── autocompact / microcompact 处理
|
||||
│ │ │ ├── deps.callModel() → claude.ts 流式 API 调用
|
||||
│ │ │ │ └── for await (message of stream) { yield message }
|
||||
│ │ │ │
|
||||
│ │ │ ├── 收集 assistant 消息中的 tool_use 块
|
||||
│ │ │ │
|
||||
│ │ │ ├── needsFollowUp?
|
||||
│ │ │ │ ├── true → 执行工具 → 收集结果 → state = next → continue
|
||||
│ │ │ │ └── false → 检查错误恢复 → return { reason: 'completed' }
|
||||
│ │ │ }
|
||||
│ │
|
||||
│ └── onQueryEvent(event) — 更新 UI 状态
|
||||
│
|
||||
└── 4. 收尾: resetLoadingState(), onTurnComplete()
|
||||
```
|
||||
|
||||
### 两条数据路径
|
||||
|
||||
| 路径 | 调用方 | 说明 |
|
||||
|------|--------|------|
|
||||
| **交互式(REPL)** | REPL.tsx → `query()` | 直接调用 `query()` AsyncGenerator |
|
||||
| **非交互式(SDK/print)** | print.ts → `QueryEngine.submitMessage()` → `query()` | 通过 QueryEngine 包装,增加了会话持久化、usage 跟踪等 |
|
||||
|
||||
---
|
||||
|
||||
## 1. query.ts(1732 行)— 核心查询循环
|
||||
|
||||
**文件路径**: `src/query.ts`
|
||||
|
||||
### 1.1 文件结构
|
||||
|
||||
```
|
||||
query.ts (1732 行)
|
||||
├── [0-120] Import 区 + feature flag 条件模块加载
|
||||
├── [122-148] yieldMissingToolResultBlocks() — 为未配对的 tool_use 生成错误 tool_result
|
||||
├── [150-178] 常量与辅助函数 (MAX_OUTPUT_TOKENS_RECOVERY_LIMIT, isWithheldMaxOutputTokens)
|
||||
├── [180-198] QueryParams 类型定义
|
||||
├── [200-216] State 类型 — 循环迭代间的可变状态
|
||||
├── [218-238] query() — 导出的 AsyncGenerator,委托给 queryLoop()
|
||||
├── [240-1732] queryLoop() — 核心 while(true) 循环
|
||||
│ ├── [241-306] 初始化 State + 内存预取
|
||||
│ ├── [307-448] 循环开头:解构 state、消息预处理(snip/microcompact/context collapse)
|
||||
│ ├── [449-578] 系统提示构建(第449行) + autocompact(第453行) + StreamingToolExecutor 初始化(第562行)
|
||||
│ ├── [650-866] ★ deps.callModel()(第659行) + 流式响应处理 + tool_use 收集
|
||||
│ ├── [896-956] 错误处理(FallbackTriggeredError、通用错误)
|
||||
│ ├── [1002-1054] 中断处理(abortController.signal.aborted)
|
||||
│ ├── [1065-1360] 无 followUp 时的终止/恢复逻辑
|
||||
│ │ ├── prompt-too-long 恢复
|
||||
│ │ ├── max_output_tokens 恢复(升级 + 多轮)
|
||||
│ │ ├── stop hooks 执行
|
||||
│ │ └── return { reason: 'completed' }
|
||||
│ └── [1360-1732] 有 followUp 时的工具执行 + 下一轮准备
|
||||
│ ├── 工具执行(streaming 或 sequential)
|
||||
│ ├── attachment 注入(排队命令、内存预取、技能发现)
|
||||
│ ├── maxTurns 检查
|
||||
│ └── state = next → continue
|
||||
```
|
||||
|
||||
### 1.2 入口:query() 函数(第 219 行)
|
||||
|
||||
```ts
|
||||
export async function* query(params: QueryParams):
|
||||
AsyncGenerator<StreamEvent | Message | ..., Terminal> {
|
||||
const consumedCommandUuids: string[] = []
|
||||
const terminal = yield* queryLoop(params, consumedCommandUuids)
|
||||
// 通知所有消费的排队命令已完成
|
||||
for (const uuid of consumedCommandUuids) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
```
|
||||
|
||||
`query()` 本身很薄,只做两件事:
|
||||
1. 委托给 `queryLoop()` 执行实际逻辑
|
||||
2. 在正常返回后通知排队命令的生命周期
|
||||
|
||||
### 1.3 QueryParams(第 181 行)
|
||||
|
||||
```ts
|
||||
type QueryParams = {
|
||||
messages: Message[] // 当前对话消息
|
||||
systemPrompt: SystemPrompt // 系统提示
|
||||
userContext: { [k: string]: string } // 用户上下文(CLAUDE.md 等)
|
||||
systemContext: { [k: string]: string } // 系统上下文(git 状态等)
|
||||
canUseTool: CanUseToolFn // 工具权限检查函数
|
||||
toolUseContext: ToolUseContext // 工具执行上下文
|
||||
fallbackModel?: string // 备用模型
|
||||
querySource: QuerySource // 查询来源标识
|
||||
maxTurns?: number // 最大轮次限制
|
||||
taskBudget?: { total: number } // 令牌预算
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 State — 循环迭代间的可变状态(第 204 行)
|
||||
|
||||
```ts
|
||||
type State = {
|
||||
messages: Message[] // 累积的消息列表
|
||||
toolUseContext: ToolUseContext // 工具执行上下文
|
||||
autoCompactTracking: ... // 自动压缩跟踪
|
||||
maxOutputTokensRecoveryCount: number // 输出令牌恢复尝试次数
|
||||
hasAttemptedReactiveCompact: boolean // 是否已尝试响应式压缩
|
||||
maxOutputTokensOverride: number | undefined // 输出令牌覆盖
|
||||
pendingToolUseSummary: Promise<...> // 待处理的工具使用摘要
|
||||
stopHookActive: boolean | undefined // stop hook 是否活跃
|
||||
turnCount: number // 当前轮次
|
||||
transition: Continue | undefined // 上一次迭代为何 continue
|
||||
}
|
||||
```
|
||||
|
||||
**设计关键**:每次 `continue` 时通过 `state = { ... }` 一次性更新所有状态,而不是分散的 9 个赋值。`transition` 字段记录了为什么要继续循环(便于调试和测试)。
|
||||
|
||||
### 1.5 queryLoop() 核心流程(第 241 行)
|
||||
|
||||
`while (true)` 循环(第 307 行)的每次迭代代表一次 API 调用。循环直到:
|
||||
- 模型不需要工具调用 → `return { reason: 'completed' }`
|
||||
- 被用户中断 → `return { reason: 'aborted_*' }`
|
||||
- 达到最大轮次 → `return { reason: 'max_turns' }`
|
||||
- 遇到不可恢复的错误 → `return { reason: 'model_error' }`
|
||||
|
||||
#### 步骤 1:消息预处理
|
||||
|
||||
```
|
||||
每次迭代开头:
|
||||
├── 解构 state → messages, toolUseContext, tracking, ...
|
||||
├── getMessagesAfterCompactBoundary() — 只保留压缩边界后的消息
|
||||
├── snip 处理(feature flag,跳过)
|
||||
├── microcompact 处理(feature flag,跳过)
|
||||
└── autocompact 检查 — 消息过长时自动压缩
|
||||
```
|
||||
|
||||
#### 步骤 2:系统提示构建(第 449 行)
|
||||
|
||||
```ts
|
||||
const fullSystemPrompt = asSystemPrompt(
|
||||
appendSystemContext(systemPrompt, systemContext),
|
||||
)
|
||||
```
|
||||
|
||||
将系统上下文(git 状态、日期等)追加到系统提示。注意:用户上下文(CLAUDE.md 等)不在这里注入,而是在 `deps.callModel()` 调用时通过 `prependUserContext(messagesForQuery, userContext)` 注入到消息数组的最前面(第 660 行)。
|
||||
|
||||
#### 步骤 3:Autocompact(第 454-543 行)
|
||||
|
||||
当消息历史过长时自动压缩:
|
||||
|
||||
```
|
||||
autocompact 流程:
|
||||
├── 检查 token 数量是否超过阈值
|
||||
├── 超过 → 调用 compact API(用 Haiku 总结历史)
|
||||
│ ├── yield compactBoundaryMessage ← 标记压缩边界
|
||||
│ └── 更新 messages 为压缩后的版本
|
||||
└── 未超过 → 继续
|
||||
```
|
||||
|
||||
#### 步骤 4:调用 API(第 559-708 行)— 核心
|
||||
|
||||
StreamingToolExecutor 在第 562 行初始化,API 调用在第 659 行开始:
|
||||
|
||||
```ts
|
||||
// 第 562 行:初始化流式工具执行器
|
||||
let streamingToolExecutor = useStreamingToolExecution
|
||||
? new StreamingToolExecutor(
|
||||
toolUseContext.options.tools, canUseTool, toolUseContext,
|
||||
)
|
||||
: null
|
||||
|
||||
// 第 659 行:调用 API
|
||||
for await (const message of deps.callModel({
|
||||
messages: prependUserContext(messagesForQuery, userContext), // ← 用户上下文注入到消息最前面
|
||||
systemPrompt: fullSystemPrompt,
|
||||
thinkingConfig: toolUseContext.options.thinkingConfig,
|
||||
tools: toolUseContext.options.tools,
|
||||
signal: toolUseContext.abortController.signal,
|
||||
options: { model: currentModel, querySource, fallbackModel, ... }
|
||||
})) {
|
||||
// 处理每条流式消息(第 708-866 行)
|
||||
}
|
||||
```
|
||||
|
||||
`deps.callModel()` 最终调用 `claude.ts` 的 `queryModelWithStreaming()`。
|
||||
|
||||
#### 步骤 5:流式响应处理(第 708-866 行)
|
||||
|
||||
处理逻辑在 `for await` 循环体内(第 708 行的 `})` 之后到第 866 行):
|
||||
|
||||
```
|
||||
for await (const message of stream):
|
||||
├── message.type === 'assistant'?
|
||||
│ ├── 记录到 assistantMessages[]
|
||||
│ ├── 提取 tool_use 块 → toolUseBlocks[]
|
||||
│ ├── needsFollowUp = true(如果有 tool_use)
|
||||
│ └── streamingToolExecutor.addTool() ← 流式工具并行执行
|
||||
│
|
||||
├── withheld? (prompt-too-long / max_output_tokens)
|
||||
│ └── 暂扣不 yield,等后面恢复逻辑处理
|
||||
│
|
||||
└── yield message ← 正常 yield 给上层(REPL/QueryEngine)
|
||||
```
|
||||
|
||||
**StreamingToolExecutor**:在 API 流式返回的同时就开始执行工具(如读文件),不等流结束。通过 `addTool()` 添加待执行工具,`getCompletedResults()` 获取已完成的结果。
|
||||
|
||||
#### 步骤 6A:无 followUp — 终止/恢复(第 1065-1360 行)
|
||||
|
||||
当模型没有请求工具调用时(`needsFollowUp === false`):
|
||||
|
||||
```
|
||||
无 followUp:
|
||||
├── prompt-too-long 恢复?
|
||||
│ ├── context collapse drain(feature flag,跳过)
|
||||
│ ├── reactive compact → 压缩消息重试
|
||||
│ └── 都失败 → yield 错误 + return
|
||||
│
|
||||
├── max_output_tokens 恢复?
|
||||
│ ├── 第一次 → 升级到 64k token 限制,continue
|
||||
│ ├── 后续 → 注入恢复消息("继续,别道歉"),continue
|
||||
│ └── 超过 3 次 → yield 错误 + return
|
||||
│
|
||||
├── stop hooks 执行
|
||||
│ ├── preventContinuation? → return
|
||||
│ └── blockingErrors? → 将错误加入消息,continue
|
||||
│
|
||||
└── return { reason: 'completed' } ★ 正常结束
|
||||
```
|
||||
|
||||
**恢复消息内容(第 1229 行)**:
|
||||
```
|
||||
"Output token limit hit. Resume directly — no apology, no recap of what
|
||||
you were doing. Pick up mid-thought if that is where the cut happened.
|
||||
Break remaining work into smaller pieces."
|
||||
```
|
||||
|
||||
#### 步骤 6B:有 followUp — 工具执行 + 下一轮(第 1363-1731 行)
|
||||
|
||||
当模型请求了工具调用时(`needsFollowUp === true`):
|
||||
|
||||
```
|
||||
有 followUp:
|
||||
├── 工具执行(两种模式)
|
||||
│ ├── streamingToolExecutor? → getRemainingResults()(流式已启动)
|
||||
│ └── 否 → runTools()(传统顺序执行)
|
||||
│
|
||||
├── for await (const update of toolUpdates):
|
||||
│ ├── yield update.message ← 工具结果消息
|
||||
│ └── toolResults.push(...) ← 收集工具结果
|
||||
│
|
||||
├── 中断检查(abortController.signal.aborted)
|
||||
│ └── return { reason: 'aborted_tools' }
|
||||
│
|
||||
├── attachment 注入
|
||||
│ ├── 排队命令(其他线程提交的消息)
|
||||
│ ├── 内存预取(相关记忆文件)
|
||||
│ └── 技能发现预取
|
||||
│
|
||||
├── maxTurns 检查
|
||||
│ └── 超过 → yield max_turns_reached + return
|
||||
│
|
||||
└── state = { messages: [...old, ...assistant, ...toolResults], turnCount: +1 }
|
||||
→ continue ★ 回到循环顶部,发起下一次 API 调用
|
||||
```
|
||||
|
||||
### 1.6 错误处理与模型降级(第 897-956 行)
|
||||
|
||||
```
|
||||
API 调用出错:
|
||||
├── FallbackTriggeredError(529 过载)?
|
||||
│ ├── 切换到 fallbackModel
|
||||
│ ├── 清空本轮 assistant/tool 消息
|
||||
│ ├── yield 系统消息 "Switched to X due to high demand for Y"
|
||||
│ └── continue(重试整个请求)
|
||||
│
|
||||
└── 其他错误
|
||||
├── ImageSizeError/ImageResizeError → yield 友好错误 + return
|
||||
├── yieldMissingToolResultBlocks() — 补全未配对的 tool_result
|
||||
└── yield API 错误消息 + return
|
||||
```
|
||||
|
||||
### 1.7 关键设计思想
|
||||
|
||||
| 设计 | 说明 |
|
||||
|------|------|
|
||||
| **AsyncGenerator 模式** | `query()` 是 `async function*`,通过 `yield` 逐条产出事件,调用者用 `for await` 消费 |
|
||||
| **while(true) + state 对象** | 每次 `continue` 构建新 State 对象,避免分散的状态修改 |
|
||||
| **transition 字段** | 记录为什么要 continue(`next_turn`、`max_output_tokens_recovery`、`reactive_compact_retry`...),便于调试 |
|
||||
| **StreamingToolExecutor** | API 流式返回时就并行执行工具,不等流结束 |
|
||||
| **Withheld 消息** | 可恢复错误先暂扣,恢复成功则不 yield 错误,失败才 yield |
|
||||
|
||||
---
|
||||
|
||||
## 2. QueryEngine.ts(1320 行)— 高层编排器
|
||||
|
||||
**文件路径**: `src/QueryEngine.ts`
|
||||
|
||||
### 2.1 定位
|
||||
|
||||
QueryEngine 是 `query()` 的**上层包装**,主要用于:
|
||||
- **print 模式**(`claude -p`):通过 `ask()` → `QueryEngine.submitMessage()`
|
||||
- **SDK 模式**:外部程序通过 SDK 调用
|
||||
- **REPL 不用它**:REPL 直接调用 `query()`
|
||||
|
||||
### 2.2 文件结构
|
||||
|
||||
```
|
||||
QueryEngine.ts (1320 行)
|
||||
├── [0-130] Import 区 + feature flag 条件模块
|
||||
├── [131-174] QueryEngineConfig 类型定义
|
||||
├── [185-1202] QueryEngine 类
|
||||
│ ├── [185-208] 成员变量 + constructor
|
||||
│ ├── [210-1181] submitMessage() — 核心方法(~970 行)
|
||||
│ │ ├── [210-400] 参数解析 + processUserInputContext 构建
|
||||
│ │ ├── [400-465] 用户输入处理 + 会话持久化
|
||||
│ │ ├── [465-660] 斜杠命令处理 + 无需查询的快速返回
|
||||
│ │ ├── [660-690] 文件历史快照
|
||||
│ │ ├── [679-1074] ★ for await (const message of query({...})) — 消费 query()
|
||||
│ │ └── [1074-1181] 结果提取 + yield result
|
||||
│ ├── [1183-1202] interrupt() / getMessages() / setModel() 辅助方法
|
||||
├── [1210-1320] ask() — 便捷包装函数
|
||||
```
|
||||
|
||||
### 2.3 QueryEngineConfig
|
||||
|
||||
```ts
|
||||
type QueryEngineConfig = {
|
||||
cwd: string // 工作目录
|
||||
tools: Tools // 工具列表
|
||||
commands: Command[] // 斜杠命令
|
||||
mcpClients: MCPServerConnection[] // MCP 服务器连接
|
||||
agents: AgentDefinition[] // Agent 定义
|
||||
canUseTool: CanUseToolFn // 权限检查
|
||||
getAppState / setAppState // 全局状态存取
|
||||
initialMessages?: Message[] // 初始消息(恢复对话)
|
||||
readFileCache: FileStateCache // 文件读取缓存
|
||||
customSystemPrompt?: string // 自定义系统提示
|
||||
thinkingConfig?: ThinkingConfig // 思考模式配置
|
||||
maxTurns?: number // 最大轮次
|
||||
maxBudgetUsd?: number // USD 预算上限
|
||||
jsonSchema?: Record<...> // 结构化输出 schema
|
||||
// ... 更多配置
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 submitMessage() 核心流程
|
||||
|
||||
```
|
||||
submitMessage(prompt)
|
||||
│
|
||||
├── 1. 参数准备
|
||||
│ ├── 解构 config 获取 tools, commands, model, ...
|
||||
│ ├── 构建 wrappedCanUseTool(包装权限检查,跟踪拒绝)
|
||||
│ ├── fetchSystemPromptParts() — 获取系统提示各部分
|
||||
│ └── 构建 processUserInputContext
|
||||
│
|
||||
├── 2. 用户输入处理
|
||||
│ ├── processUserInput(prompt) — 解析斜杠命令 / 普通文本
|
||||
│ ├── mutableMessages.push(...messagesFromUserInput)
|
||||
│ └── recordTranscript(messages) — 持久化到 JSONL
|
||||
│
|
||||
├── 3. yield buildSystemInitMessage() — SDK 初始化消息
|
||||
│
|
||||
├── 4. shouldQuery === false?(斜杠命令的本地执行结果)
|
||||
│ ├── yield 命令输出
|
||||
│ ├── yield { type: 'result', subtype: 'success' }
|
||||
│ └── return
|
||||
│
|
||||
├── 5. ★ for await (const message of query({...}))
|
||||
│ │ 消费 query() 产出的每条消息
|
||||
│ │
|
||||
│ ├── message.type === 'assistant'
|
||||
│ │ ├── mutableMessages.push(msg)
|
||||
│ │ ├── recordTranscript() ← fire-and-forget
|
||||
│ │ ├── yield* normalizeMessage(msg) — 转换为 SDK 格式
|
||||
│ │ └── 捕获 stop_reason
|
||||
│ │
|
||||
│ ├── message.type === 'user'(工具结果)
|
||||
│ │ ├── mutableMessages.push(msg)
|
||||
│ │ ├── turnCount++
|
||||
│ │ └── yield* normalizeMessage(msg)
|
||||
│ │
|
||||
│ ├── message.type === 'stream_event'
|
||||
│ │ ├── 跟踪 usage(message_start/delta/stop)
|
||||
│ │ └── includePartialMessages? → yield 流事件
|
||||
│ │
|
||||
│ ├── message.type === 'system'
|
||||
│ │ ├── compact_boundary → GC 旧消息 + yield 给 SDK
|
||||
│ │ └── api_error → yield 重试信息
|
||||
│ │
|
||||
│ └── maxBudgetUsd 检查 → 超预算则 yield error + return
|
||||
│
|
||||
└── 6. yield { type: 'result', subtype: 'success', result: textResult }
|
||||
```
|
||||
|
||||
### 2.5 ask() 便捷函数(第 1211 行)
|
||||
|
||||
```ts
|
||||
export async function* ask({ prompt, tools, ... }) {
|
||||
const engine = new QueryEngine({ ... })
|
||||
try {
|
||||
yield* engine.submitMessage(prompt)
|
||||
} finally {
|
||||
setReadFileCache(engine.getReadFileState())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ask()` 是 `QueryEngine` 的一次性包装,创建 engine → 提交消息 → 清理。用于 `print.ts` 的 `--print` 模式。
|
||||
|
||||
### 2.6 QueryEngine vs REPL 直接调用 query()
|
||||
|
||||
| 特性 | QueryEngine (SDK/print) | REPL 直接调用 query() |
|
||||
|------|------------------------|---------------------|
|
||||
| 会话持久化 | 自动 recordTranscript | 由 useLogMessages 处理 |
|
||||
| Usage 跟踪 | 内部 totalUsage 累积 | 由外层 cost-tracker 处理 |
|
||||
| 权限拒绝跟踪 | 记录 permissionDenials[] | 直接 UI 交互 |
|
||||
| 结果格式 | yield SDKMessage 格式 | 原始 Message 格式 |
|
||||
| 消息 GC | compact_boundary 后释放旧消息 | UI 需要保留完整历史 |
|
||||
|
||||
---
|
||||
|
||||
## 3. claude.ts(3420 行)— API 客户端
|
||||
|
||||
**文件路径**: `src/services/api/claude.ts`
|
||||
|
||||
### 3.1 文件结构
|
||||
|
||||
```
|
||||
claude.ts (3420 行)
|
||||
├── [0-260] Import 区(大量 SDK 类型、工具函数)
|
||||
├── [272-331] getExtraBodyParams() — 构建额外请求体参数
|
||||
├── [333-502] 缓存相关(getPromptCachingEnabled, getCacheControl, should1hCacheTTL, configureEffortParams, configureTaskBudgetParams)
|
||||
├── [504-587] verifyApiKey() — API 密钥验证
|
||||
├── [589-675] 消息转换(userMessageToMessageParam, assistantMessageToMessageParam)
|
||||
├── [677-708] Options 类型定义
|
||||
├── [710-781] queryModelWithoutStreaming / queryModelWithStreaming — 公开的两个入口
|
||||
├── [783-813] 辅助函数(shouldDeferLspTool, getNonstreamingFallbackTimeoutMs)
|
||||
├── [819-918] executeNonStreamingRequest() — 非流式请求辅助
|
||||
├── [920-999] 更多辅助函数(getPreviousRequestIdFromMessages, stripExcessMediaItems)
|
||||
├── [1018-3420] ★ queryModel() — 核心私有函数(2400 行)
|
||||
│ ├── [1018-1370] 前置检查 + 工具 schema 构建 + 消息归一化 + 系统提示组装
|
||||
│ ├── [1539-1730] paramsFromContext() — 构建 API 请求参数
|
||||
│ ├── [1777-2100] withRetry + 流式 API 调用(anthropic.beta.messages.create + stream)
|
||||
│ ├── [1941-2300] 流式事件处理(for await of stream)
|
||||
│ └── [2300-3420] 非流式降级 + 日志、分析、清理
|
||||
```
|
||||
|
||||
### 3.2 两个公开入口
|
||||
|
||||
```ts
|
||||
// 入口 1:流式(主要路径)
|
||||
export async function* queryModelWithStreaming({
|
||||
messages, systemPrompt, thinkingConfig, tools, signal, options
|
||||
}) {
|
||||
yield* withStreamingVCR(messages, async function* () {
|
||||
yield* queryModel(messages, systemPrompt, thinkingConfig, tools, signal, options)
|
||||
})
|
||||
}
|
||||
|
||||
// 入口 2:非流式(compact 等内部用途)
|
||||
export async function queryModelWithoutStreaming({
|
||||
messages, systemPrompt, thinkingConfig, tools, signal, options
|
||||
}) {
|
||||
let assistantMessage
|
||||
for await (const message of ...) {
|
||||
if (message.type === 'assistant') assistantMessage = message
|
||||
}
|
||||
return assistantMessage
|
||||
}
|
||||
```
|
||||
|
||||
两者都委托给内部的 `queryModel()`。`withStreamingVCR` 是一个 VCR(录像/回放)包装器,用于调试。
|
||||
|
||||
### 3.3 Options 类型(第 677 行)
|
||||
|
||||
```ts
|
||||
type Options = {
|
||||
getToolPermissionContext: () => Promise<ToolPermissionContext>
|
||||
model: string // 模型名称
|
||||
toolChoice?: BetaToolChoiceTool // 强制使用特定工具
|
||||
isNonInteractiveSession: boolean // 是否非交互模式
|
||||
fallbackModel?: string // 备用模型
|
||||
querySource: QuerySource // 查询来源
|
||||
agents: AgentDefinition[] // Agent 定义
|
||||
enablePromptCaching?: boolean // 启用提示缓存
|
||||
effortValue?: EffortValue // 推理努力级别
|
||||
mcpTools: Tools // MCP 工具
|
||||
fastMode?: boolean // 快速模式
|
||||
taskBudget?: { total: number; remaining?: number } // 令牌预算
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 queryModel() 核心流程(第 1018 行)
|
||||
|
||||
这是整个 API 调用的核心,2400 行。关键步骤:
|
||||
|
||||
#### 阶段 1:前置准备(1018-1400 行)
|
||||
|
||||
```
|
||||
queryModel()
|
||||
├── off-switch 检查(Opus 过载时的全局关闭开关)
|
||||
├── beta headers 组装(getMergedBetas)
|
||||
│ ├── 基础 betas
|
||||
│ ├── advisor beta(如果启用)
|
||||
│ ├── tool search beta(如果启用)
|
||||
│ ├── cache scope beta
|
||||
│ └── effort / task budget betas
|
||||
│
|
||||
├── 工具过滤
|
||||
│ ├── tool search 启用 → 只包含已发现的 deferred tools
|
||||
│ └── tool search 未启用 → 过滤掉 ToolSearchTool
|
||||
│
|
||||
├── toolToAPISchema() — 每个工具转为 API 格式
|
||||
│
|
||||
├── normalizeMessagesForAPI() — 消息转换为 API 格式
|
||||
│ ├── UserMessage → { role: 'user', content: ... }
|
||||
│ ├── AssistantMessage → { role: 'assistant', content: ... }
|
||||
│ └── 跳过 system/attachment/progress 等内部消息类型
|
||||
│
|
||||
└── 系统提示最终组装
|
||||
├── getAttributionHeader(fingerprint)
|
||||
├── getCLISyspromptPrefix()
|
||||
├── ...systemPrompt
|
||||
└── advisor 指令(如果启用)
|
||||
```
|
||||
|
||||
#### 阶段 2:构建请求参数 — paramsFromContext()(第 1539-1730 行)
|
||||
|
||||
```ts
|
||||
const paramsFromContext = (retryContext: RetryContext) => {
|
||||
// ... 动态 beta headers、effort、task budget 配置 ...
|
||||
|
||||
// 思考模式配置(adaptive 或 enabled + budget)
|
||||
let thinking = undefined
|
||||
if (hasThinking && modelSupportsThinking(options.model)) {
|
||||
if (modelSupportsAdaptiveThinking(options.model)) {
|
||||
thinking = { type: 'adaptive' }
|
||||
} else {
|
||||
thinking = { type: 'enabled', budget_tokens: thinkingBudget }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
model: normalizeModelStringForAPI(options.model),
|
||||
messages: addCacheBreakpoints(messagesForAPI, ...), // 带缓存标记的消息
|
||||
system, // 系统提示块(已构建好)
|
||||
tools: allTools, // 工具 schema
|
||||
tool_choice: options.toolChoice,
|
||||
max_tokens: maxOutputTokens,
|
||||
thinking,
|
||||
...(temperature !== undefined && { temperature }),
|
||||
...(useBetas && { betas: betasParams }),
|
||||
metadata: getAPIMetadata(),
|
||||
...extraBodyParams,
|
||||
...(speed !== undefined && { speed }), // 快速模式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 阶段 3:流式 API 调用(第 1779-1858 行)
|
||||
|
||||
```ts
|
||||
// 使用 withRetry 包装,自动处理重试
|
||||
const generator = withRetry(
|
||||
() => getAnthropicClient({ maxRetries: 0, model, source: querySource }),
|
||||
async (anthropic, attempt, context) => {
|
||||
const params = paramsFromContext(context)
|
||||
|
||||
// ★ 核心 API 调用(第 1823 行)
|
||||
// 使用 .create() + stream: true(而非 .stream())
|
||||
// 避免 BetaMessageStream 的 O(n²) partial JSON 解析开销
|
||||
const result = await anthropic.beta.messages
|
||||
.create(
|
||||
{ ...params, stream: true },
|
||||
{ signal, ...(clientRequestId && { headers: { ... } }) },
|
||||
)
|
||||
.withResponse()
|
||||
|
||||
return result.data // Stream<BetaRawMessageStreamEvent>
|
||||
},
|
||||
{ model, fallbackModel, thinkingConfig, signal, querySource }
|
||||
)
|
||||
|
||||
// 消费 withRetry 的系统错误消息(重试通知等)
|
||||
let e
|
||||
do {
|
||||
e = await generator.next()
|
||||
if (!('controller' in e.value)) yield e.value // yield API 错误消息
|
||||
} while (!e.done)
|
||||
stream = e.value // 获取最终的 Stream 对象
|
||||
|
||||
// 处理流式事件(第 1941 行)
|
||||
for await (const part of stream) {
|
||||
switch (part.type) {
|
||||
case 'message_start': // 记录 request_id、usage
|
||||
case 'content_block_start': // 新的内容块开始(text/thinking/tool_use)
|
||||
case 'content_block_delta': // 增量内容 → yield stream_event 给 UI
|
||||
case 'content_block_stop': // 内容块完成 → yield AssistantMessage
|
||||
case 'message_delta': // stop_reason、usage 更新
|
||||
case 'message_stop': // 整条消息完成
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 阶段 4:withRetry 重试策略
|
||||
|
||||
```
|
||||
withRetry 逻辑:
|
||||
├── 429 (Rate Limit) → 等待 Retry-After 后重试
|
||||
├── 529 (Overloaded) → 切换到 fallbackModel,throw FallbackTriggeredError
|
||||
├── 500 (Server Error) → 指数退避重试
|
||||
├── 408 (Timeout) → 重试
|
||||
├── 其他错误 → 不重试,直接抛出
|
||||
└── 最大重试次数: 根据模型和错误类型动态计算
|
||||
```
|
||||
|
||||
#### 阶段 5:非流式降级
|
||||
|
||||
当流式请求中途失败时,可能降级为非流式请求:
|
||||
|
||||
```
|
||||
流式失败(部分响应已收到):
|
||||
├── 已接收的内容 → yield 给上层
|
||||
├── 剩余部分 → 降级为非流式请求(anthropic.beta.messages.create)
|
||||
└── 非流式结果 → 转换格式 yield
|
||||
```
|
||||
|
||||
### 3.5 消息转换函数
|
||||
|
||||
```ts
|
||||
// UserMessage → API 格式
|
||||
userMessageToMessageParam(message, addCache, enablePromptCaching, querySource)
|
||||
→ { role: 'user', content: [...] }
|
||||
// addCache=true 时最后一个 content block 添加 cache_control
|
||||
|
||||
// AssistantMessage → API 格式
|
||||
assistantMessageToMessageParam(message, addCache, enablePromptCaching, querySource)
|
||||
→ { role: 'assistant', content: [...] }
|
||||
// thinking/redacted_thinking 块不加 cache_control
|
||||
```
|
||||
|
||||
### 3.6 Prompt Caching 策略
|
||||
|
||||
```
|
||||
缓存策略:
|
||||
├── cache_control: { type: 'ephemeral' } — 默认,5 分钟 TTL
|
||||
├── cache_control: { type: 'ephemeral', ttl: '1h' } — 订阅用户/Ant,1 小时
|
||||
├── cache_control: { ..., scope: 'global' } — 跨会话共享(无 MCP 工具时)
|
||||
└── 禁用条件:
|
||||
├── DISABLE_PROMPT_CACHING 环境变量
|
||||
├── DISABLE_PROMPT_CACHING_HAIKU(仅 Haiku)
|
||||
└── DISABLE_PROMPT_CACHING_SONNET(仅 Sonnet)
|
||||
```
|
||||
|
||||
### 3.7 多 Provider 支持
|
||||
|
||||
`getAnthropicClient()` 根据配置返回不同的 SDK 客户端:
|
||||
|
||||
| Provider | 入口 | 说明 |
|
||||
|----------|------|------|
|
||||
| Anthropic | 直接 API | 默认,`api.anthropic.com` |
|
||||
| AWS Bedrock | 通过 Bedrock | 使用 `@anthropic-ai/bedrock-sdk` |
|
||||
| Google Vertex | 通过 Vertex | 使用 `@anthropic-ai/vertex-sdk` |
|
||||
| Azure | 通过 Azure | 类似 Bedrock 的包装 |
|
||||
|
||||
Provider 选择逻辑在 `src/utils/model/providers.ts` 的 `getAPIProvider()` 中。
|
||||
|
||||
---
|
||||
|
||||
## 完整数据流:一次工具调用的生命周期
|
||||
|
||||
以用户输入 "读取 README.md" 为例:
|
||||
|
||||
```
|
||||
1. REPL.tsx: 用户按回车
|
||||
onSubmit("读取 README.md")
|
||||
└── handlePromptSubmit()
|
||||
└── onQuery([userMessage])
|
||||
|
||||
2. REPL.tsx: onQueryImpl()
|
||||
├── getSystemPrompt() + getUserContext() + getSystemContext()
|
||||
└── for await (event of query({messages, systemPrompt, ...}))
|
||||
|
||||
3. query.ts: queryLoop() — 第 1 次迭代
|
||||
├── messagesForQuery = [...messages] // 包含用户消息
|
||||
├── deps.callModel({...})
|
||||
│ └── claude.ts: queryModel()
|
||||
│ ├── 构建 API 参数
|
||||
│ └── anthropic.beta.messages.create({ ...params, stream: true })
|
||||
│
|
||||
├── API 流式返回:
|
||||
│ content_block_start: { type: 'tool_use', name: 'Read', id: 'toolu_123' }
|
||||
│ content_block_delta: { input: '{"file_path": "/path/to/README.md"}' }
|
||||
│ content_block_stop
|
||||
│ message_delta: { stop_reason: 'tool_use' }
|
||||
│
|
||||
├── 收集: toolUseBlocks = [{ name: 'Read', id: 'toolu_123', input: {...} }]
|
||||
├── needsFollowUp = true
|
||||
│
|
||||
├── 工具执行:
|
||||
│ streamingToolExecutor.getRemainingResults()
|
||||
│ └── Read 工具执行 → 返回文件内容
|
||||
│ yield toolResultMessage ← 包含文件内容
|
||||
│
|
||||
└── state = { messages: [...old, assistantMsg, toolResultMsg], turnCount: 2 }
|
||||
→ continue
|
||||
|
||||
4. query.ts: queryLoop() — 第 2 次迭代
|
||||
├── messagesForQuery 现在包含:
|
||||
│ [userMsg, assistantMsg(tool_use), userMsg(tool_result)]
|
||||
│
|
||||
├── deps.callModel({...}) ← 再次调用 API
|
||||
│
|
||||
├── API 返回:
|
||||
│ content_block_start: { type: 'text' }
|
||||
│ content_block_delta: { text: "README.md 的内容是..." }
|
||||
│ content_block_stop
|
||||
│ message_delta: { stop_reason: 'end_turn' }
|
||||
│
|
||||
├── toolUseBlocks = [] ← 没有工具调用
|
||||
├── needsFollowUp = false
|
||||
│
|
||||
└── return { reason: 'completed' } ★ 循环结束
|
||||
|
||||
5. REPL.tsx: onQueryEvent(event)
|
||||
├── 更新 streamingText(打字机效果)
|
||||
├── 更新 messages 数组
|
||||
└── 重新渲染 UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计模式总结
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| AsyncGenerator 链式传递 | query.ts → claude.ts | `yield*` 将底层事件透传给上层,形成事件流管道 |
|
||||
| while(true) + State 对象 | query.ts queryLoop | 循环迭代间通过不可变 State 传递,transition 字段记录原因 |
|
||||
| StreamingToolExecutor | query.ts | API 流式返回时并行执行工具,不等流结束 |
|
||||
| Withheld 消息 | query.ts | 可恢复错误先暂扣不 yield,恢复成功则吞掉错误 |
|
||||
| withRetry 重试 | claude.ts | 429/500/529 自动重试,529 触发模型降级 |
|
||||
| Prompt Caching | claude.ts | 缓存系统提示和历史消息,减少 API token 消耗 |
|
||||
| 非流式降级 | claude.ts | 流式请求中途失败时降级为非流式完成剩余部分 |
|
||||
| QueryEngine 包装 | QueryEngine.ts | 为 SDK/print 提供会话管理、持久化、usage 跟踪 |
|
||||
|
||||
## 需要忽略的代码
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `feature('REACTIVE_COMPACT')` / `feature('CONTEXT_COLLAPSE')` 等 | 所有 feature flag 保护的代码 — 全部是死代码 |
|
||||
| `feature('CACHED_MICROCOMPACT')` | 缓存微压缩 — 死代码 |
|
||||
| `feature('HISTORY_SNIP')` / `snipModule` | 历史截断 — 死代码 |
|
||||
| `feature('TOKEN_BUDGET')` / `budgetTracker` | 令牌预算 — 死代码 |
|
||||
| `feature('BG_SESSIONS')` / `taskSummaryModule` | 后台会话 — 死代码 |
|
||||
| `process.env.USER_TYPE === 'ant'` | Anthropic 内部专用代码 |
|
||||
| VCR (withStreamingVCR/withVCR) | 调试录像/回放包装器,不影响正常流程 |
|
||||
@@ -1,372 +0,0 @@
|
||||
# 第二阶段 Q&A
|
||||
|
||||
## Q1:query.ts 的流式消息处理具体是怎样的?
|
||||
|
||||
**核心问题**:`deps.callModel()` yield 出的每一条消息,在 `queryLoop()` 的 `for await` 循环体(L659-866)中具体经历了什么处理?
|
||||
|
||||
### 场景
|
||||
|
||||
用户说:**"帮我看看 package.json 的内容"**
|
||||
|
||||
模型回复:一段文字 "我来读取文件。" + 一个 Read 工具调用。
|
||||
|
||||
### callModel yield 的完整消息序列
|
||||
|
||||
claude.ts 的 `queryModel()` 会 yield 两种类型的消息:
|
||||
|
||||
| 类型标记 | 含义 | 产出时机 |
|
||||
|---------|------|---------|
|
||||
| `stream_event` | 原始 SSE 事件包装 | 每个 SSE 事件都产出一条 |
|
||||
| `assistant` | 完整的 AssistantMessage | 仅在 `content_block_stop` 时产出 |
|
||||
|
||||
本例中 callModel 依次 yield **共 13 条消息**:
|
||||
|
||||
```
|
||||
#1 { type: 'stream_event', event: { type: 'message_start', ... }, ttftMs: 342 }
|
||||
#2 { type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } } }
|
||||
#3 { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '我来' } } }
|
||||
#4 { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '读取文件。' } } }
|
||||
#5 { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }
|
||||
#6 { type: 'assistant', uuid: 'uuid-1', message: { content: [{ type: 'text', text: '我来读取文件。' }], stop_reason: null } }
|
||||
#7 { type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_001', name: 'Read' } } }
|
||||
#8 { type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"file_path":' } } }
|
||||
#9 { type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"/path/package.json"}' } } }
|
||||
#10 { type: 'stream_event', event: { type: 'content_block_stop', index: 1 } }
|
||||
#11 { type: 'assistant', uuid: 'uuid-2', message: { content: [{ type: 'tool_use', id: 'toolu_001', name: 'Read', input: { file_path: '/path/package.json' } }], stop_reason: null } }
|
||||
#12 { type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 87 } } }
|
||||
#13 { type: 'stream_event', event: { type: 'message_stop' } }
|
||||
```
|
||||
|
||||
注意 `#6` 和 `#11` 是 **assistant 类型**(content_block_stop 时由 claude.ts 组装),其余全是 **stream_event 类型**。
|
||||
|
||||
### 循环体结构
|
||||
|
||||
循环体在 L708-866,结构如下:
|
||||
|
||||
```
|
||||
for await (const message of deps.callModel({...})) { // L659
|
||||
// A. 降级检查 (L712)
|
||||
// B. backfill (L747-789)
|
||||
// C. withheld 检查 (L801-824)
|
||||
// D. yield (L825-827)
|
||||
// E. assistant 收集 + addTool (L828-848)
|
||||
// F. getCompletedResults (L850-865)
|
||||
}
|
||||
```
|
||||
|
||||
### 逐条走循环体
|
||||
|
||||
#### #1 stream_event (message_start)
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'?
|
||||
→ 'stream_event' !== 'assistant' → 跳过整个 backfill 块
|
||||
|
||||
C. L801-824: withheld 检查
|
||||
→ 不是 assistant 类型,各项检查均为 false → withheld = false
|
||||
|
||||
D. L825: yield message ✅ → 透传给 REPL(REPL 记录 ttftMs)
|
||||
|
||||
E. L828: message.type === 'assistant'? → 否 → 跳过
|
||||
|
||||
F. L850-854: streamingToolExecutor.getCompletedResults()
|
||||
→ tools 数组为空 → 无结果
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #2 stream_event (content_block_start, type: text)
|
||||
|
||||
```
|
||||
A-C. 同 #1
|
||||
D. yield message ✅ → REPL 设置 spinner 为 "Responding..."
|
||||
E-F. 同 #1
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #3 stream_event (text_delta: "我来")
|
||||
|
||||
```
|
||||
A-C. 同 #1
|
||||
D. yield message ✅ → REPL 追加 streamingText += "我来"(打字机效果)
|
||||
E-F. 同 #1
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #4 stream_event (text_delta: "读取文件。")
|
||||
|
||||
```
|
||||
同 #3
|
||||
D. yield message ✅ → REPL streamingText += "读取文件。"
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #5 stream_event (content_block_stop, index:0)
|
||||
|
||||
```
|
||||
同 #2
|
||||
D. yield message ✅ → REPL 无特殊操作(真正的 AssistantMessage 在下一条 #6)
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #6 assistant (text block 完整消息) ★
|
||||
|
||||
第一条 `type: 'assistant'` 的消息,走**完全不同的路径**:
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'? → ✅ 进入 backfill
|
||||
L750: contentArr = [{ type: 'text', text: '我来读取文件。' }]
|
||||
L752: for i=0: block.type === 'text'
|
||||
L754: block.type === 'tool_use'? → 否 → 跳过
|
||||
L783: clonedContent 为 undefined → yieldMessage = message(原样不变)
|
||||
|
||||
C. L801: let withheld = false
|
||||
L802: feature('CONTEXT_COLLAPSE') → false → 跳过
|
||||
L813: reactiveCompact?.isWithheldPromptTooLong(message) → 否 → false
|
||||
L822: isWithheldMaxOutputTokens(message)
|
||||
→ message.message.stop_reason === null → false
|
||||
→ withheld = false
|
||||
|
||||
D. L825: yield message ✅ → REPL 清除 streamingText,添加完整 text 消息到列表
|
||||
|
||||
E. L828: message.type === 'assistant'? → ✅
|
||||
L830: assistantMessages.push(message)
|
||||
→ assistantMessages = [uuid-1(text)]
|
||||
|
||||
L832-834: msgToolUseBlocks = content.filter(type === 'tool_use')
|
||||
→ [](这是 text block,没有 tool_use)
|
||||
|
||||
L835: length > 0? → 否 → 不设 needsFollowUp
|
||||
L844: msgToolUseBlocks 为空 → 不调用 addTool
|
||||
|
||||
F. L854: getCompletedResults() → 空
|
||||
```
|
||||
|
||||
**净效果**:`yield` 消息 + `assistantMessages` 增加一条。`needsFollowUp` 仍为 `false`。
|
||||
|
||||
---
|
||||
|
||||
#### #7 stream_event (content_block_start, tool_use: Read)
|
||||
|
||||
```
|
||||
A-C. 同 stream_event 通用路径
|
||||
D. yield message ✅ → REPL 设置 spinner 为 "tool-input",添加 streamingToolUse
|
||||
E. 不是 assistant → 跳过
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
|
||||
|
||||
```
|
||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #9 stream_event (input_json_delta: '"/path/package.json"}')
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #10 stream_event (content_block_stop, index:1)
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #11 assistant (tool_use block 完整消息) ★★
|
||||
|
||||
这条是**最关键的**——触发工具执行:
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'? → ✅ 进入 backfill
|
||||
L750: contentArr = [{ type: 'tool_use', id: 'toolu_001', name: 'Read',
|
||||
input: { file_path: '/path/package.json' } }]
|
||||
L752: for i=0:
|
||||
L754: block.type === 'tool_use'? → ✅
|
||||
L756: typeof block.input === 'object' && !== null? → ✅
|
||||
L759: tool = findToolByName(tools, 'Read') → Read 工具定义
|
||||
L763: tool.backfillObservableInput 存在? → 假设存在
|
||||
L764-766: inputCopy = { file_path: '/path/package.json' }
|
||||
tool.backfillObservableInput(inputCopy)
|
||||
→ 可能添加 absolutePath 字段
|
||||
L773-776: addedFields? → 假设有新增字段
|
||||
clonedContent = [...contentArr]
|
||||
clonedContent[0] = { ...block, input: inputCopy }
|
||||
L783-788: yieldMessage = {
|
||||
...message, // uuid, type, timestamp 不变
|
||||
message: {
|
||||
...message.message, // stop_reason, usage 不变
|
||||
content: clonedContent // ★ 替换为带 absolutePath 的副本
|
||||
}
|
||||
}
|
||||
// ★ 原始 message 保持不变(回传 API 保证缓存一致)
|
||||
|
||||
C. L801-824: withheld 检查 → 全部 false → withheld = false
|
||||
|
||||
D. L825: yield yieldMessage ✅
|
||||
→ yield 的是克隆版(带 backfill 字段),给 REPL 和 SDK 用
|
||||
→ 原始 message 下面存进 assistantMessages,回传 API 保证缓存一致
|
||||
|
||||
E. L828: message.type === 'assistant'? → ✅
|
||||
L830: assistantMessages.push(message) // ★ push 原始 message,不是 yieldMessage
|
||||
→ assistantMessages = [uuid-1(text), uuid-2(tool_use)]
|
||||
|
||||
L832-834: msgToolUseBlocks = content.filter(type === 'tool_use')
|
||||
→ [{ type: 'tool_use', id: 'toolu_001', name: 'Read', input: {...} }]
|
||||
|
||||
L835: length > 0? → ✅
|
||||
L836: toolUseBlocks.push(...msgToolUseBlocks)
|
||||
→ toolUseBlocks = [Read_block]
|
||||
L837: needsFollowUp = true // ★★★ 决定 while(true) 不会终止
|
||||
|
||||
L840-842: streamingToolExecutor 存在 ✓ && !aborted ✓
|
||||
L844-846: for (const toolBlock of msgToolUseBlocks):
|
||||
streamingToolExecutor.addTool(Read_block, uuid-2消息)
|
||||
// ★★★ 工具开始执行!
|
||||
// → StreamingToolExecutor 内部:
|
||||
// isConcurrencySafe = true(Read 是安全的)
|
||||
// queued → processQueue() → canExecuteTool() → true
|
||||
// → executeTool() → runToolUse() → 后台异步读文件
|
||||
|
||||
F. L850-854: getCompletedResults()
|
||||
→ Read 刚开始执行,status = 'executing' → 无完成结果
|
||||
```
|
||||
|
||||
**净效果**:
|
||||
- `yield` 克隆消息(带 backfill 字段)
|
||||
- `assistantMessages` push 原始消息
|
||||
- `needsFollowUp = true`
|
||||
- **Read 工具在后台异步开始执行**
|
||||
|
||||
---
|
||||
|
||||
#### #12 stream_event (message_delta, stop_reason: 'tool_use')
|
||||
|
||||
```
|
||||
A-C. 同 stream_event 通用路径
|
||||
D. yield message ✅
|
||||
|
||||
E. 不是 assistant → 跳过
|
||||
|
||||
F. L854: getCompletedResults()
|
||||
→ ★ 此时 Read 可能已经完成了!(读文件通常 <1ms)
|
||||
→ 如果完成: status = 'completed', results 有值
|
||||
L428(StreamingToolExecutor): tool.status = 'yielded'
|
||||
L431-432: yield { message: UserMsg(tool_result) }
|
||||
→ 回到 query.ts:
|
||||
L855: result.message 存在
|
||||
L856: yield result.message ✅ → REPL 显示工具结果
|
||||
L857-862: toolResults.push(normalizeMessagesForAPI([result.message])...)
|
||||
→ toolResults = [Read 的 tool_result]
|
||||
```
|
||||
|
||||
**净效果**:`yield` stream_event + **可能 yield 工具结果**(如果工具已完成)。
|
||||
|
||||
---
|
||||
|
||||
#### #13 stream_event (message_stop)
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults()
|
||||
→ 如果 Read 在 #12 已被收割 → 空
|
||||
→ 如果 Read 此时才完成 → yield 工具结果(同 #12 的 F 逻辑)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### for await 循环退出后
|
||||
|
||||
```
|
||||
L1018: aborted? → false → 跳过
|
||||
|
||||
L1065: if (!needsFollowUp)
|
||||
→ needsFollowUp = true → 不进入 → 跳过终止逻辑
|
||||
|
||||
L1383: toolUpdates = streamingToolExecutor.getRemainingResults()
|
||||
→ 如果 Read 已在 #12/#13 被收割 → 立即返回空
|
||||
→ 如果 Read 还没完成 → 阻塞等待 → 完成后 yield 结果
|
||||
|
||||
L1387-1404: for await (const update of toolUpdates) {
|
||||
yield update.message → REPL 显示
|
||||
toolResults.push(...) → 收集
|
||||
}
|
||||
|
||||
L1718-1730: 构建 next State:
|
||||
state = {
|
||||
messages: [
|
||||
...messagesForQuery, // [UserMessage("帮我看看...")]
|
||||
...assistantMessages, // [AssistantMsg(text), AssistantMsg(tool_use)]
|
||||
...toolResults, // [UserMsg(tool_result)]
|
||||
],
|
||||
turnCount: 1,
|
||||
transition: { reason: 'next_turn' },
|
||||
}
|
||||
→ continue → while(true) 第 2 次迭代 → 带着工具结果再次调 API
|
||||
```
|
||||
|
||||
### 循环体判定树总结
|
||||
|
||||
```
|
||||
for await (const message of deps.callModel(...)) {
|
||||
│
|
||||
├─ message.type === 'stream_event'?
|
||||
│ │
|
||||
│ └─ YES → 几乎零操作
|
||||
│ ├─ yield message(透传给 REPL 做实时 UI)
|
||||
│ └─ getCompletedResults()(顺便检查有没有完成的工具)
|
||||
│
|
||||
└─ message.type === 'assistant'?
|
||||
│
|
||||
├─ B. backfill: 有 tool_use + backfillObservableInput?
|
||||
│ ├─ YES → 克隆消息,yield 克隆版(原始消息保留给 API)
|
||||
│ └─ NO → yield 原始消息
|
||||
│
|
||||
├─ C. withheld: prompt_too_long / max_output_tokens?
|
||||
│ ├─ YES → 不 yield(暂扣,等后面恢复逻辑处理)
|
||||
│ └─ NO → yield
|
||||
│
|
||||
├─ E. assistantMessages.push(原始 message)
|
||||
│
|
||||
├─ E. 有 tool_use block?
|
||||
│ ├─ YES → toolUseBlocks.push()
|
||||
│ │ + needsFollowUp = true
|
||||
│ │ + streamingToolExecutor.addTool() → ★ 立即开始执行工具
|
||||
│ └─ NO → 什么都不做
|
||||
│
|
||||
└─ F. getCompletedResults() → 收割已完成的工具结果
|
||||
}
|
||||
```
|
||||
|
||||
**一句话总结**:stream_event 透传不处理;assistant 消息才是"真正的货"——收集起来、判断要不要暂扣、有工具就立即开始执行、顺便收割已完成的工具结果。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.0.3",
|
||||
"version": "2.2.1",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -131,8 +131,13 @@ type Props = {
|
||||
const MULTI_CLICK_TIMEOUT_MS = 500;
|
||||
const MULTI_CLICK_DISTANCE = 1;
|
||||
|
||||
type ErrorInfo = {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
readonly error?: Error;
|
||||
readonly error?: ErrorInfo;
|
||||
};
|
||||
|
||||
// Root component for all Ink apps
|
||||
@@ -142,7 +147,7 @@ export default class App extends PureComponent<Props, State> {
|
||||
static displayName = 'InternalApp';
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
return { error: { message: error.message, stack: error.stack } };
|
||||
}
|
||||
|
||||
override state = {
|
||||
@@ -221,7 +226,7 @@ export default class App extends PureComponent<Props, State> {
|
||||
<TerminalFocusProvider>
|
||||
<ClockProvider>
|
||||
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
||||
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
|
||||
{this.state.error ? <ErrorOverview error={this.state.error} /> : this.props.children}
|
||||
</CursorDeclarationContext.Provider>
|
||||
</ClockProvider>
|
||||
</TerminalFocusProvider>
|
||||
|
||||
@@ -23,8 +23,13 @@ function getStackUtils(): StackUtils {
|
||||
|
||||
/* eslint-enable custom-rules/no-process-cwd */
|
||||
|
||||
type ErrorLike = {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
readonly error: Error;
|
||||
readonly error: ErrorLike;
|
||||
};
|
||||
|
||||
export default function ErrorOverview({ error }: Props) {
|
||||
|
||||
132
packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts
Normal file
132
packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { z } from 'zod/v4'
|
||||
import {
|
||||
buildTool,
|
||||
findToolByName,
|
||||
type Tool,
|
||||
type ToolDef,
|
||||
type ToolUseContext,
|
||||
type ToolResult,
|
||||
type Tools,
|
||||
} from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import { DESCRIPTION, getPrompt } from './prompt.js'
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
tool_name: z
|
||||
.string()
|
||||
.describe(
|
||||
'The exact name of the target tool to execute (e.g., "CronCreate", "mcp__server__action")',
|
||||
),
|
||||
params: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe('The parameters to pass to the target tool'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
export const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
result: z.unknown(),
|
||||
tool_name: z.string(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
export type Output = z.infer<OutputSchema>
|
||||
|
||||
export const ExecuteTool = buildTool({
|
||||
name: EXECUTE_TOOL_NAME,
|
||||
searchHint: 'execute run invoke call a deferred tool by name with parameters',
|
||||
maxResultSizeChars: 100_000,
|
||||
isConcurrencySafe() {
|
||||
return false
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return getPrompt()
|
||||
},
|
||||
async call(input, context, canUseTool, parentMessage, onProgress) {
|
||||
const tools: Tools = context.options.tools ?? []
|
||||
|
||||
const targetTool = findToolByName(tools, input.tool_name)
|
||||
if (!targetTool) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Tool "${input.tool_name}" not found. Use ToolSearch to discover available tools.`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions on the target tool
|
||||
const permResult = await targetTool.checkPermissions?.(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
)
|
||||
if (permResult && permResult.behavior === 'deny') {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Permission denied for tool "${input.tool_name}": ${permResult.message ?? 'Permission denied'}`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate execution to the target tool
|
||||
const targetResult: ToolResult<unknown> = await targetTool.call(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
canUseTool,
|
||||
parentMessage,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
return {
|
||||
...targetResult,
|
||||
data: {
|
||||
result: targetResult.data,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
}
|
||||
},
|
||||
async checkPermissions() {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'ExecuteTool delegates permission to the target tool.',
|
||||
}
|
||||
},
|
||||
renderToolUseMessage(input) {
|
||||
return `Executing ${input.tool_name}...`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'ExecuteTool'
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(content, toolUseID) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: JSON.stringify(content),
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { mock } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../../../../tests/mocks/debug'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// Mock all heavy dependencies before importing ExecuteTool
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||
getFeatureValue_DEPRECATED: async () => undefined,
|
||||
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||
hasGrowthBookEnvOverride: () => false,
|
||||
getAllGrowthBookFeatures: () => ({}),
|
||||
getGrowthBookConfigOverrides: () => ({}),
|
||||
setGrowthBookConfigOverride: () => {},
|
||||
clearGrowthBookConfigOverrides: () => {},
|
||||
getApiBaseUrlHost: () => undefined,
|
||||
onGrowthBookRefresh: () => {},
|
||||
initializeGrowthBook: async () => {},
|
||||
checkSecurityRestrictionGate: async () => false,
|
||||
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||
refreshGrowthBookAfterAuthChange: () => {},
|
||||
resetGrowthBook: () => {},
|
||||
refreshGrowthBookFeatures: async () => {},
|
||||
setupPeriodicGrowthBookRefresh: () => {},
|
||||
stopPeriodicGrowthBookRefresh: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/toolSearch.js', () => ({
|
||||
isToolSearchEnabledOptimistic: () => true,
|
||||
getAutoToolSearchCharThreshold: () => 100,
|
||||
getToolSearchMode: () => 'tst' as const,
|
||||
modelSupportsToolReference: () => true,
|
||||
isToolSearchToolAvailable: async () => true,
|
||||
isToolSearchEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
|
||||
mock.module('src/constants/tools.js', () => ({
|
||||
CORE_TOOLS: new Set(['ExecuteTool', 'ToolSearch']),
|
||||
}))
|
||||
|
||||
// Mock messages module
|
||||
mock.module('src/utils/messages.js', () => ({
|
||||
createUserMessage: ({ content }: { content: string }) => ({
|
||||
type: 'user' as const,
|
||||
content,
|
||||
uuid: 'test-uuid',
|
||||
}),
|
||||
}))
|
||||
|
||||
const { ExecuteTool } = await import('../ExecuteTool.js')
|
||||
const { EXECUTE_TOOL_NAME } = await import('../constants.js')
|
||||
|
||||
function makeContext(tools: unknown[] = []) {
|
||||
return {
|
||||
options: {
|
||||
tools,
|
||||
},
|
||||
cwd: '/tmp',
|
||||
sessionId: 'test',
|
||||
} as never
|
||||
}
|
||||
|
||||
function makeMockTool(name: string, callResult: unknown = 'ok') {
|
||||
return {
|
||||
name,
|
||||
call: async () => ({ data: callResult }),
|
||||
checkPermissions: async () => ({ behavior: 'allow' as const }),
|
||||
prompt: async () => `Description for ${name}`,
|
||||
description: async () => `Description for ${name}`,
|
||||
inputSchema: {},
|
||||
isEnabled: () => true,
|
||||
isConcurrencySafe: () => true,
|
||||
isReadOnly: () => false,
|
||||
isMcp: false,
|
||||
alwaysLoad: undefined,
|
||||
shouldDefer: undefined,
|
||||
searchHint: '',
|
||||
userFacingName: () => name,
|
||||
renderToolUseMessage: () => `Running ${name}`,
|
||||
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
|
||||
tool_use_id: id,
|
||||
type: 'tool_result',
|
||||
content,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ExecuteTool', () => {
|
||||
test('executes a target tool by name', async () => {
|
||||
const mockTarget = makeMockTool('TestTool', { result: 'success' })
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'TestTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: { result: 'success' },
|
||||
tool_name: 'TestTool',
|
||||
})
|
||||
})
|
||||
|
||||
test('returns error when tool not found', async () => {
|
||||
const ctx = makeContext([])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'NonexistentTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'NonexistentTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
expect(result.newMessages!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('returns permission denied when target denies', async () => {
|
||||
const mockTarget = makeMockTool('SecretTool', 'secret')
|
||||
mockTarget.checkPermissions = async () =>
|
||||
({
|
||||
behavior: 'deny' as const,
|
||||
message: 'Access denied',
|
||||
}) as never
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'SecretTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'SecretTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
})
|
||||
|
||||
test('has correct name', () => {
|
||||
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
|
||||
})
|
||||
|
||||
test('searchHint contains keywords', () => {
|
||||
expect(ExecuteTool.searchHint).toContain('execute')
|
||||
expect(ExecuteTool.searchHint).toContain('tool')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ExecuteTool.test.ts
|
||||
*
|
||||
* Thin subprocess wrapper that runs the actual tests in an isolated bun:test
|
||||
* process. This prevents mock.module() leaks from other test files
|
||||
* (e.g., agentToolUtils.test.ts mocking src/Tool.js) from affecting
|
||||
* ExecuteTool's tests.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { resolve, relative } from 'path'
|
||||
|
||||
const PROJECT_ROOT = resolve(__dirname, '..', '..', '..', '..', '..')
|
||||
const RUNNER_ABS = resolve(__dirname, 'ExecuteTool.runner.ts')
|
||||
const RUNNER_REL = './' + relative(PROJECT_ROOT, RUNNER_ABS).replace(/\\/g, '/')
|
||||
|
||||
describe('ExecuteTool', () => {
|
||||
test('runs all ExecuteTool tests in isolated subprocess', async () => {
|
||||
const proc = Bun.spawn(['bun', 'test', RUNNER_REL], {
|
||||
cwd: PROJECT_ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const output = (stderr + '\n' + stdout).slice(-3000)
|
||||
throw new Error(
|
||||
`ExecuteTool test subprocess failed (exit ${code}):\n${output}`,
|
||||
)
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export const EXECUTE_TOOL_NAME = 'ExecuteTool'
|
||||
16
packages/builtin-tools/src/tools/ExecuteTool/prompt.ts
Normal file
16
packages/builtin-tools/src/tools/ExecuteTool/prompt.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
|
||||
export const DESCRIPTION =
|
||||
'Execute a deferred tool by name with parameters. Use this after discovering a tool via ToolSearch.'
|
||||
|
||||
export function getPrompt(): string {
|
||||
return `Execute a deferred tool by name. This tool accepts a tool_name and params object, looks up the target tool in the global tool registry, and delegates execution to it.
|
||||
|
||||
Use this tool after discovering a deferred tool via ToolSearch. The tool_name must match the exact name returned by ToolSearch (e.g., "CronCreate", "mcp__server__action").
|
||||
|
||||
Inputs:
|
||||
- tool_name: The exact name of the target tool (string)
|
||||
- params: The parameters to pass to the target tool (object)
|
||||
|
||||
If the tool is not found, an error message will be returned suggesting to use ToolSearch to discover available tools.`
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import * as React from 'react';
|
||||
import { Suspense, use, useState } from 'react';
|
||||
import { FileEditToolUseRejectedMessage } from 'src/components/FileEditToolUseRejectedMessage.js';
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js';
|
||||
import { extractTag } from 'src/utils/messages.js';
|
||||
@@ -10,10 +12,15 @@ import { Text } from '@anthropic/ink';
|
||||
import { FilePathLink } from 'src/components/FilePathLink.js';
|
||||
import type { Tools } from 'src/Tool.js';
|
||||
import type { Message, ProgressMessage } from 'src/types/message.js';
|
||||
import { adjustHunkLineNumbers, CONTEXT_LINES } from 'src/utils/diff.js';
|
||||
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from 'src/utils/file.js';
|
||||
import { logError } from 'src/utils/log.js';
|
||||
import { getPlansDirectory } from 'src/utils/plans.js';
|
||||
import { readEditContext } from 'src/utils/readEditContext.js';
|
||||
import { firstLineOf } from 'src/utils/stringUtils.js';
|
||||
import type { ThemeName } from 'src/utils/theme.js';
|
||||
import type { FileEditOutput } from './types.js';
|
||||
import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js';
|
||||
|
||||
export function userFacingName(
|
||||
input:
|
||||
@@ -84,6 +91,8 @@ export function renderToolResultMessage(
|
||||
<FileEditToolUpdatedMessage
|
||||
filePath={filePath}
|
||||
structuredPatch={structuredPatch}
|
||||
firstLine={originalFile.split('\n')[0] ?? null}
|
||||
fileContent={originalFile}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
previewHint={isPlanFile ? '/plan to preview' : undefined}
|
||||
@@ -99,7 +108,7 @@ export function renderToolUseRejectedMessage(
|
||||
replace_all?: boolean;
|
||||
edits?: unknown[];
|
||||
},
|
||||
_options: {
|
||||
options: {
|
||||
columns: number;
|
||||
messages: Message[];
|
||||
progressMessagesForMessage: ProgressMessage[];
|
||||
@@ -109,14 +118,40 @@ export function renderToolUseRejectedMessage(
|
||||
verbose: boolean;
|
||||
},
|
||||
): React.ReactElement {
|
||||
const { style, verbose } = _options;
|
||||
const { style, verbose } = options;
|
||||
const filePath = input.file_path;
|
||||
const isNewFile = input.old_string === '';
|
||||
const oldString = input.old_string ?? '';
|
||||
const newString = input.new_string ?? '';
|
||||
const replaceAll = input.replace_all ?? false;
|
||||
|
||||
// Defensive: if input has an unexpected shape, show a simple rejection message
|
||||
if ('edits' in input && input.edits != null) {
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />
|
||||
);
|
||||
}
|
||||
|
||||
const isNewFile = oldString === '';
|
||||
|
||||
// For new file creation, show content preview instead of diff
|
||||
if (isNewFile) {
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation="write"
|
||||
content={newString}
|
||||
firstLine={firstLineOf(newString)}
|
||||
verbose={verbose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation={isNewFile ? 'write' : 'update'}
|
||||
<EditRejectionDiff
|
||||
filePath={filePath}
|
||||
oldString={oldString}
|
||||
newString={newString}
|
||||
replaceAll={replaceAll}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
/>
|
||||
@@ -149,3 +184,103 @@ export function renderToolUseErrorMessage(
|
||||
}
|
||||
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />;
|
||||
}
|
||||
|
||||
type RejectionDiffData = {
|
||||
patch: StructuredPatchHunk[];
|
||||
firstLine: string | null;
|
||||
fileContent: string | undefined;
|
||||
};
|
||||
|
||||
function EditRejectionDiff({
|
||||
filePath,
|
||||
oldString,
|
||||
newString,
|
||||
replaceAll,
|
||||
style,
|
||||
verbose,
|
||||
}: {
|
||||
filePath: string;
|
||||
oldString: string;
|
||||
newString: string;
|
||||
replaceAll: boolean;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
const [dataPromise] = useState(() => loadRejectionDiff(filePath, oldString, newString, replaceAll));
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />
|
||||
}
|
||||
>
|
||||
<EditRejectionBody promise={dataPromise} filePath={filePath} style={style} verbose={verbose} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function EditRejectionBody({
|
||||
promise,
|
||||
filePath,
|
||||
style,
|
||||
verbose,
|
||||
}: {
|
||||
promise: Promise<RejectionDiffData>;
|
||||
filePath: string;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
const { patch, firstLine, fileContent } = use(promise);
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation="update"
|
||||
patch={patch}
|
||||
firstLine={firstLine}
|
||||
fileContent={fileContent}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRejectionDiff(
|
||||
filePath: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
): Promise<RejectionDiffData> {
|
||||
try {
|
||||
// Chunked read — context window around the first occurrence. replaceAll
|
||||
// still shows matches *within* the window via getPatchForEdit; we accept
|
||||
// losing the all-occurrences view to keep the read bounded.
|
||||
const ctx = await readEditContext(filePath, oldString, CONTEXT_LINES);
|
||||
if (ctx === null || ctx.truncated || ctx.content === '') {
|
||||
// ENOENT / not found / truncated — diff just the tool inputs.
|
||||
const { patch } = getPatchForEdit({
|
||||
filePath,
|
||||
fileContents: oldString,
|
||||
oldString,
|
||||
newString,
|
||||
});
|
||||
return { patch, firstLine: null, fileContent: undefined };
|
||||
}
|
||||
const actualOld = findActualString(ctx.content, oldString) || oldString;
|
||||
const actualNew = preserveQuoteStyle(oldString, actualOld, newString);
|
||||
const { patch } = getPatchForEdit({
|
||||
filePath,
|
||||
fileContents: ctx.content,
|
||||
oldString: actualOld,
|
||||
newString: actualNew,
|
||||
replaceAll,
|
||||
});
|
||||
return {
|
||||
patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1),
|
||||
firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null,
|
||||
fileContent: ctx.content,
|
||||
};
|
||||
} catch (e) {
|
||||
// User may have manually applied the change while the diff was shown.
|
||||
logError(e as Error);
|
||||
return { patch: [], firstLine: null, fileContent: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||
import { relative } from 'path';
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import { isAbsolute, relative, resolve } from 'path';
|
||||
import * as React from 'react';
|
||||
import { Suspense, use, useState } from 'react';
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js';
|
||||
import { extractTag } from 'src/utils/messages.js';
|
||||
import { CtrlOToExpand } from 'src/components/CtrlOToExpand.js';
|
||||
@@ -15,8 +17,11 @@ import { FilePathLink } from 'src/components/FilePathLink.js';
|
||||
import type { ToolProgressData } from 'src/Tool.js';
|
||||
import type { ProgressMessage } from 'src/types/message.js';
|
||||
import { getCwd } from 'src/utils/cwd.js';
|
||||
import { getPatchForDisplay } from 'src/utils/diff.js';
|
||||
import { getDisplayPath } from 'src/utils/file.js';
|
||||
import { logError } from 'src/utils/log.js';
|
||||
import { getPlansDirectory } from 'src/utils/plans.js';
|
||||
import { openForScan, readCapped } from 'src/utils/readEditContext.js';
|
||||
import type { Output } from './FileWriteTool.js';
|
||||
|
||||
const MAX_LINES_TO_RENDER = 10;
|
||||
@@ -122,10 +127,115 @@ export function renderToolUseMessage(
|
||||
}
|
||||
|
||||
export function renderToolUseRejectedMessage(
|
||||
{ file_path }: { file_path: string; content: string },
|
||||
{ file_path, content }: { file_path: string; content: string },
|
||||
{ style, verbose }: { style?: 'condensed'; verbose: boolean },
|
||||
): React.ReactNode {
|
||||
return <FileEditToolUseRejectedMessage file_path={file_path} operation="write" style={style} verbose={verbose} />;
|
||||
return <WriteRejectionDiff filePath={file_path} content={content} style={style} verbose={verbose} />;
|
||||
}
|
||||
|
||||
type RejectionDiffData =
|
||||
| { type: 'create' }
|
||||
| { type: 'update'; patch: StructuredPatchHunk[]; oldContent: string }
|
||||
| { type: 'error' };
|
||||
|
||||
function WriteRejectionDiff({
|
||||
filePath,
|
||||
content,
|
||||
style,
|
||||
verbose,
|
||||
}: {
|
||||
filePath: string;
|
||||
content: string;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
const [dataPromise] = useState(() => loadRejectionDiff(filePath, content));
|
||||
const firstLine = content.split('\n')[0] ?? null;
|
||||
const createFallback = (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation="write"
|
||||
content={content}
|
||||
firstLine={firstLine}
|
||||
verbose={verbose}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={createFallback}>
|
||||
<WriteRejectionBody
|
||||
promise={dataPromise}
|
||||
filePath={filePath}
|
||||
firstLine={firstLine}
|
||||
createFallback={createFallback}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function WriteRejectionBody({
|
||||
promise,
|
||||
filePath,
|
||||
firstLine,
|
||||
createFallback,
|
||||
style,
|
||||
verbose,
|
||||
}: {
|
||||
promise: Promise<RejectionDiffData>;
|
||||
filePath: string;
|
||||
firstLine: string | null;
|
||||
createFallback: React.ReactNode;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
const data = use(promise);
|
||||
if (data.type === 'create') return createFallback;
|
||||
if (data.type === 'error') {
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Text>(No changes)</Text>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation="update"
|
||||
patch={data.patch}
|
||||
firstLine={firstLine}
|
||||
fileContent={data.oldContent}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRejectionDiff(filePath: string, content: string): Promise<RejectionDiffData> {
|
||||
try {
|
||||
const fullFilePath = isAbsolute(filePath) ? filePath : resolve(getCwd(), filePath);
|
||||
const handle = await openForScan(fullFilePath);
|
||||
if (handle === null) return { type: 'create' };
|
||||
let oldContent: string | null;
|
||||
try {
|
||||
oldContent = await readCapped(handle);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// File exceeds MAX_SCAN_BYTES — fall back to the create view rather than
|
||||
// OOMing on a diff of a multi-GB file.
|
||||
if (oldContent === null) return { type: 'create' };
|
||||
const patch = getPatchForDisplay({
|
||||
filePath,
|
||||
fileContents: oldContent,
|
||||
edits: [{ old_string: oldContent, new_string: content, replace_all: false }],
|
||||
});
|
||||
return { type: 'update', patch, oldContent };
|
||||
} catch (e) {
|
||||
// User may have manually applied the change while the diff was shown.
|
||||
logError(e as Error);
|
||||
return { type: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
export function renderToolUseErrorMessage(
|
||||
@@ -179,6 +289,8 @@ export function renderToolResultMessage(
|
||||
<FileEditToolUpdatedMessage
|
||||
filePath={filePath}
|
||||
structuredPatch={structuredPatch}
|
||||
firstLine={content.split('\n')[0] ?? null}
|
||||
fileContent={originalFile ?? undefined}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
previewHint={isPlanFile ? '/plan to preview' : undefined}
|
||||
|
||||
@@ -15,8 +15,16 @@ import {
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { escapeRegExp } from 'src/utils/stringUtils.js'
|
||||
import { isToolSearchEnabledOptimistic } from 'src/utils/toolSearch.js'
|
||||
import {
|
||||
isToolSearchEnabledOptimistic,
|
||||
modelSupportsToolReference,
|
||||
} from 'src/utils/toolSearch.js'
|
||||
import { getPrompt, isDeferredTool, TOOL_SEARCH_TOOL_NAME } from './prompt.js'
|
||||
import { getToolIndex, searchTools } from 'src/services/toolSearch/toolIndex.js'
|
||||
import type { ToolSearchResult } from 'src/services/toolSearch/toolIndex.js'
|
||||
|
||||
const KEYWORD_WEIGHT = Number(process.env.TOOL_SEARCH_WEIGHT_KEYWORD ?? '0.4')
|
||||
const TFIDF_WEIGHT = Number(process.env.TOOL_SEARCH_WEIGHT_TFIDF ?? '0.6')
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
@@ -405,13 +413,66 @@ export const ToolSearchTool = buildTool({
|
||||
return buildSearchResult(found, query, deferredTools.length)
|
||||
}
|
||||
|
||||
// Keyword search
|
||||
const matches = await searchToolsWithKeywords(
|
||||
query,
|
||||
deferredTools,
|
||||
tools,
|
||||
max_results,
|
||||
)
|
||||
// Check for discover: prefix — pure discovery search.
|
||||
// Returns tool info (name + description + schema) as text,
|
||||
// does NOT trigger deferred tool loading.
|
||||
const discoverMatch = query.match(/^discover:(.+)$/i)
|
||||
if (discoverMatch) {
|
||||
const discoverQuery = discoverMatch[1]!.trim()
|
||||
const index = await getToolIndex(deferredTools)
|
||||
const tfIdfResults = searchTools(discoverQuery, index, max_results)
|
||||
const textResults = tfIdfResults.map(r => {
|
||||
let line = `**${r.name}** (score: ${r.score.toFixed(2)})\n${r.description}`
|
||||
if (r.inputSchema) {
|
||||
line += `\nSchema: ${JSON.stringify(r.inputSchema)}`
|
||||
}
|
||||
return line
|
||||
})
|
||||
const text =
|
||||
textResults.length > 0
|
||||
? `Found ${textResults.length} tools:\n${textResults.join('\n\n')}`
|
||||
: 'No matching deferred tools found'
|
||||
logSearchOutcome(
|
||||
tfIdfResults.map(r => r.name),
|
||||
'keyword',
|
||||
)
|
||||
return buildSearchResult(
|
||||
tfIdfResults.map(r => r.name),
|
||||
query,
|
||||
deferredTools.length,
|
||||
)
|
||||
}
|
||||
|
||||
// Keyword search + TF-IDF search in parallel
|
||||
const [keywordMatches, index] = await Promise.all([
|
||||
searchToolsWithKeywords(query, deferredTools, tools, max_results),
|
||||
getToolIndex(deferredTools),
|
||||
])
|
||||
const tfIdfResults = searchTools(query, index, max_results)
|
||||
|
||||
// Merge results: keyword score * 0.4 + TF-IDF score * 0.6
|
||||
const mergedScores = new Map<string, number>()
|
||||
// Add keyword results (assign scores inversely proportional to rank)
|
||||
keywordMatches.forEach((name, rank) => {
|
||||
const score = (keywordMatches.length - rank) / keywordMatches.length
|
||||
mergedScores.set(
|
||||
name,
|
||||
(mergedScores.get(name) ?? 0) + score * KEYWORD_WEIGHT,
|
||||
)
|
||||
})
|
||||
// Add TF-IDF results
|
||||
tfIdfResults.forEach(result => {
|
||||
mergedScores.set(
|
||||
result.name,
|
||||
(mergedScores.get(result.name) ?? 0) + result.score * TFIDF_WEIGHT,
|
||||
)
|
||||
})
|
||||
|
||||
// Sort by merged score, take top-N
|
||||
const matches = [...mergedScores.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, max_results)
|
||||
.map(([name]) => name)
|
||||
|
||||
logForDebugging(
|
||||
`ToolSearchTool: keyword search for "${query}", found ${matches.length} matches`,
|
||||
@@ -444,6 +505,7 @@ export const ToolSearchTool = buildTool({
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: Output,
|
||||
toolUseID: string,
|
||||
context?: { mainLoopModel?: string },
|
||||
): ToolResultBlockParam {
|
||||
if (content.matches.length === 0) {
|
||||
let text = 'No matching deferred tools found'
|
||||
@@ -459,6 +521,19 @@ export const ToolSearchTool = buildTool({
|
||||
content: text,
|
||||
}
|
||||
}
|
||||
|
||||
const supportsToolRef = context?.mainLoopModel
|
||||
? modelSupportsToolReference(context.mainLoopModel)
|
||||
: true // default: assume supported (backwards compatible)
|
||||
if (!supportsToolRef) {
|
||||
// Text mode: return tool name list for non-Anthropic providers
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseID,
|
||||
content: `Found ${content.matches.length} tool(s): ${content.matches.join(', ')}. Use ExecuteTool with tool_name and params to invoke.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseID,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { mock } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../../../../tests/mocks/debug'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||
getFeatureValue_DEPRECATED: async () => undefined,
|
||||
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||
hasGrowthBookEnvOverride: () => false,
|
||||
getAllGrowthBookFeatures: () => ({}),
|
||||
getGrowthBookConfigOverrides: () => ({}),
|
||||
setGrowthBookConfigOverride: () => {},
|
||||
clearGrowthBookConfigOverrides: () => {},
|
||||
getApiBaseUrlHost: () => undefined,
|
||||
onGrowthBookRefresh: () => {},
|
||||
initializeGrowthBook: async () => {},
|
||||
checkSecurityRestrictionGate: async () => false,
|
||||
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||
refreshGrowthBookAfterAuthChange: () => {},
|
||||
resetGrowthBook: () => {},
|
||||
refreshGrowthBookFeatures: async () => {},
|
||||
setupPeriodicGrowthBookRefresh: () => {},
|
||||
stopPeriodicGrowthBookRefresh: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/toolSearch.js', () => ({
|
||||
isToolSearchEnabledOptimistic: () => true,
|
||||
getAutoToolSearchCharThreshold: () => 100,
|
||||
getToolSearchMode: () => 'tst' as const,
|
||||
modelSupportsToolReference: (model: string) => !model.includes('haiku'),
|
||||
isToolSearchToolAvailable: async () => true,
|
||||
isToolSearchEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
|
||||
mock.module('src/constants/tools.js', () => ({
|
||||
CORE_TOOLS: new Set(['Read', 'Edit', 'ToolSearch', 'ExecuteTool']),
|
||||
}))
|
||||
|
||||
// Mock toolIndex module
|
||||
type MockToolSearchResult = {
|
||||
name: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
score: number
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
}
|
||||
const mockSearchTools = mock(
|
||||
(
|
||||
_query: string,
|
||||
_index: unknown,
|
||||
_limit?: number,
|
||||
): MockToolSearchResult[] => [],
|
||||
)
|
||||
const mockGetToolIndex = mock(async (_tools: unknown) => [])
|
||||
|
||||
mock.module('src/services/toolSearch/toolIndex.js', () => ({
|
||||
getToolIndex: mockGetToolIndex,
|
||||
searchTools: mockSearchTools,
|
||||
}))
|
||||
|
||||
// Mock analytics
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
}))
|
||||
|
||||
const { ToolSearchTool } = await import('../ToolSearchTool.js')
|
||||
|
||||
function makeDeferredTool(name: string, desc: string = 'A tool') {
|
||||
return {
|
||||
name,
|
||||
isMcp: false,
|
||||
alwaysLoad: undefined,
|
||||
shouldDefer: undefined,
|
||||
searchHint: '',
|
||||
prompt: async () => desc,
|
||||
description: async () => desc,
|
||||
inputSchema: {},
|
||||
isEnabled: () => true,
|
||||
}
|
||||
}
|
||||
|
||||
function makeContext(tools: unknown[] = []) {
|
||||
return {
|
||||
options: { tools },
|
||||
cwd: '/tmp',
|
||||
sessionId: 'test',
|
||||
getAppState: () => ({
|
||||
mcp: { clients: [] },
|
||||
}),
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('ToolSearchTool search enhancements', () => {
|
||||
test('discover: prefix triggers TF-IDF search and returns matches', async () => {
|
||||
const mockTool = makeDeferredTool('CronCreate', 'Schedule cron jobs')
|
||||
mockGetToolIndex.mockResolvedValueOnce([])
|
||||
mockSearchTools.mockReturnValueOnce([
|
||||
{
|
||||
name: 'CronCreate',
|
||||
description: 'Schedule cron jobs',
|
||||
searchHint: undefined,
|
||||
score: 0.85,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
const result: { data: { matches: string[] } } = await (
|
||||
ToolSearchTool as any
|
||||
).call(
|
||||
{ query: 'discover:schedule cron job', max_results: 5 },
|
||||
makeContext([mockTool]),
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data.matches).toContain('CronCreate')
|
||||
})
|
||||
|
||||
test('keyword + TF-IDF parallel search merges results', async () => {
|
||||
const toolA = makeDeferredTool('ToolA', 'Tool A description')
|
||||
const toolB = makeDeferredTool('ToolB', 'Tool B description')
|
||||
const toolC = makeDeferredTool('ToolC', 'Tool C description')
|
||||
|
||||
// getToolIndex returns tools, searchTools returns different ranking
|
||||
mockGetToolIndex.mockResolvedValueOnce([])
|
||||
mockSearchTools.mockReturnValueOnce([
|
||||
{
|
||||
name: 'ToolB',
|
||||
description: 'Tool B',
|
||||
searchHint: undefined,
|
||||
score: 0.9,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
{
|
||||
name: 'ToolC',
|
||||
description: 'Tool C',
|
||||
searchHint: undefined,
|
||||
score: 0.8,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
const result: { data: { matches: string[] } } = await (
|
||||
ToolSearchTool as any
|
||||
).call(
|
||||
{ query: 'tool B', max_results: 5 },
|
||||
makeContext([toolA, toolB, toolC]),
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
// ToolB should be in results (matched by both keyword and TF-IDF)
|
||||
expect(result.data.matches).toContain('ToolB')
|
||||
})
|
||||
|
||||
test('text mode output for non-Anthropic models', async () => {
|
||||
const tool = makeDeferredTool('TestTool', 'A test tool')
|
||||
mockGetToolIndex.mockResolvedValueOnce([])
|
||||
mockSearchTools.mockReturnValueOnce([])
|
||||
|
||||
// First call: search returns matches
|
||||
mockSearchTools.mockReturnValueOnce([
|
||||
{
|
||||
name: 'TestTool',
|
||||
description: 'A test',
|
||||
searchHint: undefined,
|
||||
score: 0.9,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
// Use mapToolResultToToolResultBlockParam directly
|
||||
const blockParam = ToolSearchTool.mapToolResultToToolResultBlockParam(
|
||||
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
|
||||
'tool-use-123',
|
||||
{ mainLoopModel: 'claude-3-haiku-20240307' },
|
||||
)
|
||||
|
||||
expect(blockParam.content).toContain('ExecuteTool')
|
||||
})
|
||||
|
||||
test('tool_reference mode for Anthropic models', async () => {
|
||||
const blockParam = ToolSearchTool.mapToolResultToToolResultBlockParam(
|
||||
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
|
||||
'tool-use-123',
|
||||
{ mainLoopModel: 'claude-sonnet-4-20250514' },
|
||||
)
|
||||
|
||||
// Should contain tool_reference type
|
||||
const content = blockParam.content as Array<{ type: string }>
|
||||
expect(content[0]!.type).toBe('tool_reference')
|
||||
})
|
||||
|
||||
test('backwards compatible without context parameter', async () => {
|
||||
const blockParam = ToolSearchTool.mapToolResultToToolResultBlockParam(
|
||||
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
|
||||
'tool-use-123',
|
||||
)
|
||||
|
||||
// Should default to tool_reference mode
|
||||
const content = blockParam.content as Array<{ type: string }>
|
||||
expect(content[0]!.type).toBe('tool_reference')
|
||||
})
|
||||
|
||||
test('empty results return helpful message', async () => {
|
||||
const blockParam = ToolSearchTool.mapToolResultToToolResultBlockParam(
|
||||
{ matches: [], query: 'nonexistent', total_deferred_tools: 5 },
|
||||
'tool-use-123',
|
||||
)
|
||||
|
||||
expect(blockParam.content).toContain('No matching deferred tools found')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,6 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { isReplBridgeActive } from 'src/bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import type { Tool } from 'src/Tool.js'
|
||||
import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'
|
||||
|
||||
// Dead code elimination: Brief tool name only needed when KAIROS or KAIROS_BRIEF is on
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const BRIEF_TOOL_NAME: string | null =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? (
|
||||
require('../BriefTool/prompt.js') as typeof import('../BriefTool/prompt.js')
|
||||
).BRIEF_TOOL_NAME
|
||||
: null
|
||||
const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS')
|
||||
? (
|
||||
require('../SendUserFileTool/prompt.js') as typeof import('../SendUserFileTool/prompt.js')
|
||||
).SEND_USER_FILE_TOOL_NAME
|
||||
: null
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import { CORE_TOOLS } from 'src/constants/tools.js'
|
||||
|
||||
export { TOOL_SEARCH_TOOL_NAME } from './constants.js'
|
||||
|
||||
@@ -47,64 +29,26 @@ Result format: each matched tool appears as one <function>{"description": "...",
|
||||
|
||||
Query forms:
|
||||
- "select:Read,Edit,Grep" — fetch these exact tools by name
|
||||
- "discover:schedule cron job" — pure discovery, returns tool info (name, description, schema) without loading. Use when you want to understand available tools before deciding which to invoke.
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
|
||||
/**
|
||||
* Check if a tool should be deferred (requires ToolSearch to load).
|
||||
* A tool is deferred if:
|
||||
* - It's an MCP tool (always deferred - workflow-specific)
|
||||
* - It has shouldDefer: true
|
||||
*
|
||||
* A tool is NEVER deferred if it has alwaysLoad: true (MCP tools set this via
|
||||
* _meta['anthropic/alwaysLoad']). This check runs first, before any other rule.
|
||||
* A tool is deferred if it is NOT in CORE_TOOLS and does NOT have alwaysLoad: true.
|
||||
* Core tools are always loaded — never deferred.
|
||||
* All other tools (non-core built-in + all MCP tools) are deferred
|
||||
* and must be discovered via ToolSearchTool / ExecuteTool.
|
||||
*/
|
||||
export function isDeferredTool(tool: Tool): boolean {
|
||||
// Explicit opt-out via _meta['anthropic/alwaysLoad'] — tool appears in the
|
||||
// initial prompt with full schema. Checked first so MCP tools can opt out.
|
||||
// Explicit opt-out via _meta['anthropic/alwaysLoad']
|
||||
if (tool.alwaysLoad === true) return false
|
||||
|
||||
// MCP tools are always deferred (workflow-specific)
|
||||
if (tool.isMcp === true) return true
|
||||
// Core tools are always loaded — never deferred
|
||||
if (CORE_TOOLS.has(tool.name)) return false
|
||||
|
||||
// Never defer ToolSearch itself — the model needs it to load everything else
|
||||
if (tool.name === TOOL_SEARCH_TOOL_NAME) return false
|
||||
|
||||
// Fork-first experiment: Agent must be available turn 1, not behind ToolSearch.
|
||||
// Lazy require: static import of forkSubagent → coordinatorMode creates a cycle
|
||||
// through constants/tools.ts at module init.
|
||||
if (feature('FORK_SUBAGENT') && tool.name === AGENT_TOOL_NAME) {
|
||||
type ForkMod = typeof import('../AgentTool/forkSubagent.js')
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const m = require('../AgentTool/forkSubagent.js') as ForkMod
|
||||
if (m.isForkSubagentEnabled()) return false
|
||||
}
|
||||
|
||||
// Brief is the primary communication channel whenever the tool is present.
|
||||
// Its prompt contains the text-visibility contract, which the model must
|
||||
// see without a ToolSearch round-trip. No runtime gate needed here: this
|
||||
// tool's isEnabled() IS isBriefEnabled(), so being asked about its deferral
|
||||
// status implies the gate already passed.
|
||||
if (
|
||||
(feature('KAIROS') || feature('KAIROS_BRIEF')) &&
|
||||
BRIEF_TOOL_NAME &&
|
||||
tool.name === BRIEF_TOOL_NAME
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// SendUserFile is a file-delivery communication channel (sibling of Brief).
|
||||
// Must be immediately available without a ToolSearch round-trip.
|
||||
if (
|
||||
feature('KAIROS') &&
|
||||
SEND_USER_FILE_TOOL_NAME &&
|
||||
tool.name === SEND_USER_FILE_TOOL_NAME &&
|
||||
isReplBridgeActive()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return tool.shouldDefer === true
|
||||
// Everything else (non-core built-in + all MCP tools) is deferred
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
63
progress.md
63
progress.md
@@ -13,3 +13,66 @@
|
||||
- settings.ts 依赖链过深(MDM/远程管理/文件系统),63 个现有测试覆盖良好
|
||||
- installedPluginsManager.ts V1→V2 迁移逻辑清晰,内存/磁盘状态分离设计良好
|
||||
- teammateMailbox.ts 25 个现有测试覆盖纯函数,协议消息检测函数完整
|
||||
|
||||
## 2026-05-05 — 第一轮用户思维 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 CLI 交互体验:Onboarding 流程、Trust Dialog、错误消息、Help Menu。聚焦非代码层面的用户友好性问题。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **错误消息缺乏可操作提示**:budget 超限/max turns 用尽时仅告知"出错了",未指导用户如何继续
|
||||
2. **Onboarding 安全说明冰冷**:"Security notes"标题过于技术化,用户容易跳过
|
||||
3. **Trust Dialog 文案冗长**:安全检查对话框用语偏官方,核心信息被淹没
|
||||
|
||||
### 变更内容
|
||||
1. **`src/cli/print.ts`** — 为 3 种错误子类型(budget/turns/structured-output)添加 Tip 提示行,告知用户具体的解决方式
|
||||
2. **`src/QueryEngine.ts`** — 预算超限错误消息添加 `--max-budget-usd` 指引
|
||||
3. **`src/components/Onboarding.tsx`** — 安全步骤标题改为 "Before you start, keep in mind",条目文案更口语化
|
||||
4. **`src/components/TrustDialog/TrustDialog.tsx`** — 精简为两句核心信息,降低认知负荷
|
||||
5. **`src/cli/__tests__/userFacingErrorMessages.test.ts`** — 7 个测试验证消息内容包含关键引导信息
|
||||
|
||||
## 2026-05-05 — 第二轮权限与帮助系统 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视权限交互提示(Bash/File 权限对话框底部提示行)、Help 页面引导、权限选项标签长度。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **权限对话框底部提示语义模糊**:"Esc to cancel" 不如 "Esc to reject" 明确,"Tab to amend" 用户不知能做什么
|
||||
2. **Help General 页面缺乏新手引导**:只有一句话 + 全部快捷键,新用户不知从何开始
|
||||
3. **.claude/ 文件夹权限选项标签过长**(60+ 字符),窄终端截断
|
||||
|
||||
### 变更内容
|
||||
1. **`src/components/HelpV2/General.tsx`** — 添加 3 步"Getting started"引导,取代原来的单段描述
|
||||
2. **`src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx`** — 底部 "cancel"→"reject","amend"→"add feedback"
|
||||
3. **`src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx`** — 同步底部提示用词
|
||||
4. **`src/components/permissions/FilePermissionDialog/permissionOptions.tsx`** — .claude/ 选项标签从 60 字符缩至 49 字符
|
||||
5. **`src/components/HelpV2/__tests__/General.test.ts`** — 10 个测试覆盖权限提示文案和帮助页引导内容
|
||||
|
||||
## 2026-05-05 — 第三轮模型选择与会话恢复 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 ModelPicker 选择器、/resume 会话恢复命令的错误提示、cost 命令展示。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **ModelPicker 副标题信息过载**:一句话里混合了模型切换说明和 --model 参数提示,新用户容易困惑
|
||||
2. **Resume 错误提示缺乏操作指导**:"Session X was not found" 没告诉用户怎么列出所有会话
|
||||
|
||||
### 变更内容
|
||||
1. **`src/components/ModelPicker.tsx`** — 副标题从技术说明改为操作提示("← → 调整 effort,Space 切换 1M context"),控制在 120 字符内
|
||||
2. **`src/commands/resume/resume.tsx`** — 错误提示添加 "Run /resume to browse" 操作引导
|
||||
3. **`src/commands/resume/__tests__/resume.test.ts`** — 6 个测试覆盖模型选择器、会话恢复、cost 消息文案
|
||||
|
||||
## 2026-05-05 — 第四轮压缩与上下文管理 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 /compact 命令体验、自动压缩提示、上下文窗口耗尽错误、CompactSummary 组件展示。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **"Not enough messages to compact" 缺乏指导**:用户不知下一步该做什么
|
||||
2. **"Conversation too long" 提示的 "Press esc twice" 操作不直观**:esc twice 对用户来说是模糊的操作
|
||||
3. **"Compact summary" 标题对用户没有信息量**:自动压缩时用户不知道发生了什么
|
||||
|
||||
### 变更内容
|
||||
1. **`src/services/compact/compact.ts`** — "Not enough messages" 添加 "Send a few more messages first" 引导;"Conversation too long" 改为建议 `/compact` 或 `/clear`
|
||||
2. **`src/components/CompactSummary.tsx`** — 自动压缩标题从 "Compact summary" 改为 "Conversation summarized to free up context",快捷键提示从 "expand" 改为 "view summary"
|
||||
3. **`src/components/__tests__/compactMessages.test.ts`** — 7 个测试覆盖压缩错误消息和展示文案
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const pkgPath = resolve(__dirname, '..', 'package.json')
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
|
||||
/**
|
||||
* Shared MACRO define map used by both dev.ts (runtime -d flags)
|
||||
* and build.ts (Bun.build define option).
|
||||
*
|
||||
* Each value is a JSON-stringified expression that replaces the
|
||||
* corresponding MACRO.* identifier at transpile / bundle time.
|
||||
*
|
||||
* VERSION is read from package.json to avoid version drift.
|
||||
*/
|
||||
export function getMacroDefines(): Record<string, string> {
|
||||
return {
|
||||
'MACRO.VERSION': JSON.stringify('2.1.888'),
|
||||
'MACRO.VERSION': JSON.stringify(pkg.version),
|
||||
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
|
||||
'MACRO.FEEDBACK_CHANNEL': JSON.stringify(''),
|
||||
'MACRO.ISSUES_EXPLAINER': JSON.stringify(''),
|
||||
@@ -52,11 +62,11 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
|
||||
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
||||
// 'FORK_SUBAGENT', // 已禁用:通过 Agent tool 的特殊方式实现了等效功能,无需再开
|
||||
'KAIROS', // Kairos 定时任务系统核心
|
||||
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
||||
// 'LAN_PIPES', // 依赖 UDS_INBOX(已随 UDS_INBOX 恢复)
|
||||
'COORDINATOR_MODE', // 多 worker 编排模式(AgentSummary 泄露已在 52b61c2c 修复)
|
||||
// 'UDS_INBOX', // 进程间通信管道(inbox/pipe/peers 等命令)构建后 nodejs 环境卡住
|
||||
// 'LAN_PIPES', // 局域网管道,依赖 UDS_INBOX 构建后 nodejs 环境卡住
|
||||
'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill)
|
||||
'TEMPLATES', // 模板任务(new/list/reply 子命令)
|
||||
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
||||
@@ -74,7 +84,8 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
// this branch (see docs/agent/sur-skill-overflow-bugs.md) close the
|
||||
// overflow risk, but Haiku-on-first-Chinese-query and disk-side
|
||||
// observation accumulation remain operator-discretion concerns.
|
||||
// 'EXPERIMENTAL_SKILL_SEARCH',
|
||||
'EXPERIMENTAL_SKILL_SEARCH', // 技能搜索(bounded caches 已修复 overflow,内存问题已解决)
|
||||
'EXPERIMENTAL_TOOL_SEARCH', // 工具搜索预取管道(TF-IDF 索引 + inter-turn 异步预取)
|
||||
// 'SKILL_LEARNING',
|
||||
// P3: poor mode
|
||||
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
||||
|
||||
@@ -14,7 +14,13 @@ const __dirname = dirname(__filename)
|
||||
const projectRoot = join(__dirname, '..')
|
||||
const cliPath = join(projectRoot, 'src/entrypoints/cli.tsx')
|
||||
|
||||
const defines = getMacroDefines()
|
||||
const defines = {
|
||||
...getMacroDefines(),
|
||||
// React production mode — prevents 6,889+ _debugStack Error objects
|
||||
// (12MB) from accumulating during long-running sessions.
|
||||
// dev 模式使用 development 模式
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}
|
||||
|
||||
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
|
||||
'-d',
|
||||
|
||||
451
spec/feature_20260508_F001_tool-search/spec-design.md
Normal file
451
spec/feature_20260508_F001_tool-search/spec-design.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Feature: 20260508_F001 - tool-search
|
||||
|
||||
## 需求背景
|
||||
|
||||
当前 Claude Code 有 60+ 内置工具和无限 MCP 工具,Agent 在处理任务时缺乏"根据任务描述自动发现最匹配工具"的能力。现有 `ToolSearchTool` 仅处理延迟加载(按需加载 schema via `tool_reference`),不做语义发现。`tool_reference` 机制存在以下局限:
|
||||
|
||||
1. **仅 Anthropic 一方 API 支持** — OpenAI/Gemini/Grok 兼容层不支持 `tool_reference` beta 特性
|
||||
2. **破坏 prompt cache** — 动态注入工具 schema 导致缓存失效
|
||||
3. **工具列表固定** — 每次请求的工具集在请求开始时就确定了,临时添加工具触发缓存全部失效
|
||||
|
||||
用户也无法直观了解哪些工具适合当前任务,缺乏推荐机制。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 激进精简初始化工具注入,从 60+ 精简到 ~10 个核心工具 + 2 个入口工具(ToolSearch + ExecuteTool)
|
||||
2. 增强 `ToolSearchTool`,增加 TF-IDF 文本匹配的"工具发现"能力
|
||||
3. 新建 `ExecuteTool`,提供跨 API provider 的统一工具执行入口
|
||||
4. 支持用户输入提示词后自动预取推荐工具(类似 skill prefetch)
|
||||
5. 在 REPL 中展示工具推荐提示条(类似 skill search tips)
|
||||
6. 搜索范围覆盖:MCP 工具、自定义工具、所有延迟加载的内置工具
|
||||
7. 复用 `localSearch.ts` 的 tokenize/stem/cosineSimilarity 基础设施
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 整体架构
|
||||
|
||||
四层设计:初始化精简 + 搜索层 + 执行层 + UI 层。
|
||||
|
||||
```text
|
||||
初始化阶段(激进精简):
|
||||
核心工具(~10个,始终加载 schema) 延迟工具(其余全部,仅注入名称列表)
|
||||
Bash / Read / Edit / Write / Glob WebFetch / WebSearch / NotebookEdit
|
||||
Grep / Agent / AskUser / ToolSearch TodoWrite / CronTools / TeamCreate
|
||||
ExecuteTool SkillTool / PlanMode / ...(50+ 工具)
|
||||
↓ MCP 工具也延迟加载
|
||||
|
||||
运行时发现与执行:
|
||||
用户输入 → 预取管道(异步) → TF-IDF 搜索 → UI 推荐提示
|
||||
↓
|
||||
模型处理任务 → ToolSearchTool(TF-IDF搜索) → 返回工具信息文本
|
||||
↓
|
||||
模型构造参数 → ExecuteTool(tool_name + params) → 路由执行 → 返回结果
|
||||
```
|
||||
|
||||
### 1. 初始化精简(激进策略)
|
||||
|
||||
**核心思路**: 将初始化时注入的工具从 60+ 精简到 ~10 个核心工具 + 2 个入口工具(ToolSearch + ExecuteTool)。其余 50+ 工具全部延迟加载,仅注入名称列表到延迟工具清单。
|
||||
|
||||
**始终加载的核心工具**(31 个):
|
||||
|
||||
| 工具 | 始终加载的理由 |
|
||||
|------|----------------|
|
||||
| `BashTool` | 几乎所有任务都需要 shell 执行 |
|
||||
| `FileReadTool` | 读取文件是基础操作 |
|
||||
| `FileEditTool` | 编辑文件是核心能力 |
|
||||
| `FileWriteTool` | 写入文件是核心能力 |
|
||||
| `GlobTool` | 文件搜索是基础操作 |
|
||||
| `GrepTool` | 内容搜索是基础操作 |
|
||||
| `AgentTool` | 子 agent 调度是核心架构 |
|
||||
| `AskUserQuestionTool` | 用户交互是基础能力 |
|
||||
| `ToolSearchTool` | 工具发现入口 |
|
||||
| `ExecuteTool` | 延迟工具执行入口(新增) |
|
||||
| `TaskOutputTool` | 任务输出查询是高频操作 |
|
||||
| `TaskStopTool` | 任务停止是 agent 生命周期管理 |
|
||||
| `EnterPlanModeTool` | 进入计划模式是常见工作流 |
|
||||
| `ExitPlanModeV2Tool` | 退出计划模式是常见工作流 |
|
||||
| `VerifyPlanExecutionTool` | 计划执行验证与 ExitPlanMode 配套 |
|
||||
| `TaskCreateTool` | 任务创建(TodoV2)是高频操作 |
|
||||
| `TaskGetTool` | 任务查询(TodoV2)是高频操作 |
|
||||
| `TaskUpdateTool` | 任务更新(TodoV2)是高频操作 |
|
||||
| `TaskListTool` | 任务列表(TodoV2)是高频操作 |
|
||||
| `TodoWriteTool` | 待办写入是任务跟踪基础 |
|
||||
| `SendMessageTool` | 团队内 agent 通信 |
|
||||
| `TeamCreateTool` | 团队创建(swarm 模式核心) |
|
||||
| `TeamDeleteTool` | 团队删除(swarm 模式核心) |
|
||||
| `ListPeersTool` | 跨会话通信发现 |
|
||||
| `SkillTool` | 技能调用(/skill 命令) |
|
||||
| `WebFetchTool` | Web 内容获取是常见需求 |
|
||||
| `WebSearchTool` | Web 搜索是常见需求 |
|
||||
| `NotebookEditTool` | Notebook 编辑是数据分析基础 |
|
||||
| `LSPTool` | LSP 代码智能是开发基础 |
|
||||
| `MonitorTool` | 后台监控进程(日志/轮询) |
|
||||
| `SleepTool` | 等待时长(轮询 deploy 等场景) |
|
||||
|
||||
**延迟加载的工具**(约 26 个内置工具 + 全部 MCP 工具):
|
||||
|
||||
所有未在核心列表中的内置工具,包括:
|
||||
|
||||
| 工具 | 延迟加载的理由 |
|
||||
|------|----------------|
|
||||
| `ConfigTool` | 配置操作低频(ant only) |
|
||||
| `TungstenTool` | 专用工具低频(ant only) |
|
||||
| `SuggestBackgroundPRTool` | PR 建议低频 |
|
||||
| `WebBrowserTool` | 浏览器操作低频(feature-gated) |
|
||||
| `OverflowTestTool` | 测试专用(feature-gated) |
|
||||
| `CtxInspectTool` | 上下文检查低频(debug/feature-gated) |
|
||||
| `TerminalCaptureTool` | 终端捕获低频(feature-gated) |
|
||||
| `EnterWorktreeTool` | worktree 操作低频 |
|
||||
| `ExitWorktreeTool` | worktree 操作低频 |
|
||||
| `REPLTool` | REPL 模式低频(ant only) |
|
||||
| `WorkflowTool` | 工作流脚本低频(feature-gated) |
|
||||
| `CronCreateTool` | 调度创建低频 |
|
||||
| `CronDeleteTool` | 调度删除低频 |
|
||||
| `CronListTool` | 调度列表低频 |
|
||||
| `RemoteTriggerTool` | 远程触发低频 |
|
||||
| `BriefTool` | 通信通道低频(KAIROS) |
|
||||
| `SendUserFileTool` | 文件发送低频(KAIROS) |
|
||||
| `PushNotificationTool` | 推送通知低频(KAIROS) |
|
||||
| `SubscribePRTool` | PR 订阅低频 |
|
||||
| `ReviewArtifactTool` | 产物审查低频 |
|
||||
| `PowerShellTool` | PowerShell 低频(需显式启用) |
|
||||
| `SnipTool` | 上下文裁剪低频(HISTORY_SNIP) |
|
||||
| `DiscoverSkillsTool` | 技能发现低频(feature-gated) |
|
||||
| `ListMcpResourcesTool` | MCP 资源列表低频 |
|
||||
| `ReadMcpResourceTool` | MCP 资源读取低频 |
|
||||
| `TestingPermissionTool` | 仅测试环境 |
|
||||
| 全部 MCP 工具 | 按连接动态加载 |
|
||||
|
||||
**实现方式**:
|
||||
|
||||
1. **系统提示词增强**(`src/context.ts` 或 `src/constants/prompts.ts`):
|
||||
|
||||
在系统提示词中加入工具发现引导指令,确保模型始终知道如何获取延迟工具:
|
||||
|
||||
```text
|
||||
When you need a capability that isn't in your available tools, use ToolSearch
|
||||
to discover and load it. ToolSearch can find all deferred tools by keyword or
|
||||
task description. After discovering a tool, use ExecuteTool to invoke it with
|
||||
the appropriate parameters.
|
||||
|
||||
Common deferred tools include: CronTools (scheduling), WorktreeTools (git
|
||||
isolation), SnipTool (context management), DiscoverSkills (skill search),
|
||||
MCP resource tools, and many more. Always search first rather than assuming
|
||||
a capability is unavailable.
|
||||
```
|
||||
|
||||
2. **新增核心工具集合常量**(`src/constants/tools.ts`):
|
||||
|
||||
```typescript
|
||||
export const CORE_TOOLS = new Set([
|
||||
// 文件操作
|
||||
'Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep',
|
||||
// Agent 与交互
|
||||
'Agent', 'AskUserQuestion', 'SendMessage', 'ListPeers',
|
||||
// 团队(swarm)
|
||||
'TeamCreate', 'TeamDelete',
|
||||
// 任务管理
|
||||
'TaskOutput', 'TaskStop',
|
||||
'TaskCreate', 'TaskGet', 'TaskUpdate', 'TaskList',
|
||||
'TodoWrite',
|
||||
// 规划
|
||||
'EnterPlanMode', 'ExitPlanMode', 'VerifyPlanExecution',
|
||||
// Web
|
||||
'WebFetch', 'WebSearch',
|
||||
// 编辑器
|
||||
'NotebookEdit',
|
||||
// 代码智能
|
||||
'LSP',
|
||||
// 技能
|
||||
'Skill',
|
||||
// 调度与监控
|
||||
'Sleep', 'Monitor',
|
||||
// 工具发现与执行(新增)
|
||||
'ToolSearch', 'ExecuteTool',
|
||||
])
|
||||
```
|
||||
|
||||
2. **修改 `isDeferredTool` 判定逻辑**(`ToolSearchTool/prompt.ts`):
|
||||
|
||||
```typescript
|
||||
export function isDeferredTool(tool: Tool): boolean {
|
||||
if (tool.alwaysLoad === true) return false
|
||||
if (tool.name === TOOL_SEARCH_TOOL_NAME) return false
|
||||
if (tool.name === EXECUTE_TOOL_NAME) return false
|
||||
// 核心工具不延迟
|
||||
if (CORE_TOOLS.has(tool.name)) return false
|
||||
// MCP 工具和其余内置工具全部延迟
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
3. **修改 `getAllBaseTools()` 注册逻辑**(`src/tools.ts`):
|
||||
|
||||
核心工具直接注册(带完整 schema),延迟工具也注册到工具池(用于 ExecuteTool 查找),但标记为 deferred。
|
||||
|
||||
4. **延迟工具名称列表注入**(`src/services/api/claude.ts`):
|
||||
|
||||
构建 API 请求时,核心工具的 schema 正常注入。延迟工具仅注入名称列表到 `<available-deferred-tools>` 或 `system-reminder` 附件中,模型通过 ToolSearchTool 获取详情。
|
||||
|
||||
**收益**:
|
||||
- 初始 prompt 体积减少约 30-40%(26 个内置工具 schema → 名称列表,加上 MCP 工具全延迟)
|
||||
- Prompt cache 命中率提升(核心 31 工具列表稳定,延迟工具仅名称列表)
|
||||
- 支持无限工具扩展(不受 context window 限制)
|
||||
|
||||
**权衡**:
|
||||
- 非核心工具首次使用需要一轮 ToolSearch → ExecuteTool 的额外交互
|
||||
- 模型需要更积极地使用 ToolSearchTool 发现可用工具
|
||||
|
||||
### 2. 工具索引层
|
||||
|
||||
**新增文件**: `src/services/toolSearch/toolIndex.ts`
|
||||
|
||||
从 `src/services/skillSearch/localSearch.ts` 直接 import 复用 `tokenizeAndStem`、`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 算法,新建工具索引。不提取为独立共享模块——skill 和 tool 的索引结构不同(`SkillIndexEntry` vs `ToolIndexEntry`),强行抽象反而增加复杂度。
|
||||
|
||||
**索引条目结构**:
|
||||
|
||||
```typescript
|
||||
interface ToolIndexEntry {
|
||||
name: string // 工具名(如 "FileEditTool" 或 "mcp__server__action")
|
||||
normalizedName: string // 小写 + 连字符替换
|
||||
description: string // 工具描述文本
|
||||
searchHint: string | undefined // buildTool 中定义的 searchHint
|
||||
isMcp: boolean // 是否 MCP 工具
|
||||
isDeferred: boolean // 是否延迟加载工具
|
||||
inputSchema: object | undefined // 参数 schema(JSON Schema 格式,供 discover 模式返回)
|
||||
tokens: string[] // 分词后的 token 列表
|
||||
tfVector: Map<string, number> // TF-IDF 向量
|
||||
}
|
||||
```
|
||||
|
||||
**字段权重**:
|
||||
|
||||
| 字段 | 权重 | 说明 |
|
||||
|------|------|------|
|
||||
| name | 3.0 | 工具名称(CamelCase 拆分、MCP `__` 拆分) |
|
||||
| searchHint | 2.5 | 工具的 `searchHint` 字段(高信号) |
|
||||
| description | 1.0 | 工具描述文本 |
|
||||
|
||||
**索引生命周期**:
|
||||
- 按需构建,缓存在会话中
|
||||
- MCP 工具连接/断开时触发增量更新(复用 `DeferredToolsDelta` 机制)
|
||||
- 内置工具在首次构建时全量索引
|
||||
- 仅索引延迟工具(核心工具已在模型上下文中,无需发现)
|
||||
|
||||
**工具名解析**:
|
||||
- MCP 工具:`mcp__server__action` → 拆分为 `["mcp", "server", "action"]`
|
||||
- 内置工具:`FileEditTool` → CamelCase 拆分为 `["file", "edit", "tool"]`
|
||||
- 与现有 `ToolSearchTool.parseToolName` 逻辑对齐
|
||||
|
||||
### 3. 搜索层增强
|
||||
|
||||
**修改文件**: `packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
|
||||
在现有 `searchToolsWithKeywords` 基础上,新增 TF-IDF 搜索路径:
|
||||
|
||||
**增强的搜索流程**:
|
||||
|
||||
```
|
||||
query 输入
|
||||
│
|
||||
├── select: 前缀 → 直接选择(保留现有逻辑)
|
||||
│
|
||||
└── 关键词搜索 → 并行执行两路搜索
|
||||
│
|
||||
├── searchToolsWithKeywords(现有,关键词匹配 + 评分)
|
||||
│
|
||||
└── searchToolsWithTfIdf(新增,TF-IDF 余弦相似度)
|
||||
│
|
||||
└── 合并结果 → 加权求和 → 排序 → top-N
|
||||
```
|
||||
|
||||
**结果合并策略**:
|
||||
- 关键词匹配分数 × 0.4 + TF-IDF 相似度分数 × 0.6
|
||||
- 权重可通过环境变量 `TOOL_SEARCH_WEIGHT_KEYWORD` / `TOOL_SEARCH_WEIGHT_TFIDF` 调整
|
||||
- 去重:同一工具取两路中最高分
|
||||
|
||||
**输出格式变更**:
|
||||
|
||||
`mapToolResultToToolResultBlockParam` 增加文本模式返回(当 `tool_reference` 不可用时):
|
||||
|
||||
```typescript
|
||||
// 当 tool_reference 可用时(现有逻辑,保持不变)
|
||||
{ type: 'tool_reference', tool_name: "..." }
|
||||
|
||||
// 当 tool_reference 不可用时(新增)
|
||||
{ type: 'text', text: "Found 3 tools:\n1. **ToolName** (score: 0.85)\n Description...\n Schema: {...}" }
|
||||
```
|
||||
|
||||
判断条件:复用 `modelSupportsToolReference()` 或检测当前 provider 是否支持。
|
||||
|
||||
**新增 `discover` 查询模式**:
|
||||
|
||||
```
|
||||
discover:<任务描述> — 纯发现搜索,不触发延迟加载,只返回工具信息
|
||||
```
|
||||
|
||||
与现有 `select:` 模式互补。`discover:` 返回工具名 + 描述 + 参数 schema(文本形式),供 ExecuteTool 使用。
|
||||
|
||||
### 4. 执行层(ExecuteTool)
|
||||
|
||||
**新增文件**: `packages/builtin-tools/src/tools/ExecuteTool/`
|
||||
|
||||
**工具定义**:
|
||||
|
||||
```typescript
|
||||
const ExecuteTool = buildTool({
|
||||
name: 'ExecuteTool',
|
||||
searchHint: 'execute run invoke a tool by name with parameters',
|
||||
|
||||
inputSchema: z.object({
|
||||
tool_name: z.string().describe('Name of the tool to execute'),
|
||||
params: z.record(z.unknown()).describe('Parameters to pass to the tool'),
|
||||
}),
|
||||
|
||||
async call(input, context) {
|
||||
// 1. 在全局工具注册表中查找目标工具
|
||||
// 2. 验证 params 是否符合目标工具的 inputSchema
|
||||
// 3. 调用目标工具的 call 方法
|
||||
// 4. 返回执行结果
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**核心逻辑**:
|
||||
|
||||
1. **工具查找**: 通过 `findToolByName` 在完整工具池(built-in + MCP)中查找
|
||||
2. **参数验证**: 用目标工具的 `inputSchema` 验证传入参数
|
||||
3. **权限继承**: 复用目标工具的 `checkPermissions` 方法
|
||||
4. **执行委托**: 调用目标工具的 `call(input, context)` 方法
|
||||
5. **结果透传**: 直接返回目标工具的执行结果
|
||||
|
||||
**权限模型**:
|
||||
- ExecuteTool 本身不做额外权限检查
|
||||
- 权限检查委托给目标工具的 `checkPermissions`
|
||||
- 用户审批时显示实际工具名和操作内容(而非 "ExecuteTool")
|
||||
|
||||
**工具注册**:
|
||||
- 在 `src/tools.ts` 的 `getAllBaseTools()` 中注册
|
||||
- 与 ToolSearchTool 关联启用:当 `isToolSearchEnabledOptimistic()` 为 true 时注册
|
||||
|
||||
### 5. 预取管道
|
||||
|
||||
**新增文件**: `src/services/toolSearch/prefetch.ts`
|
||||
|
||||
**触发时机**: 用户提交输入后、发送 API 请求前
|
||||
|
||||
**流程**:
|
||||
|
||||
```
|
||||
用户输入提交
|
||||
│
|
||||
├── 异步启动预取(不阻塞主流程)
|
||||
│ │
|
||||
│ ├── 提取用户消息文本
|
||||
│ ├── 调用 toolIndex.search(message, limit: 3)
|
||||
│ └── 存储结果到模块级缓存
|
||||
│
|
||||
└── API 请求构建时
|
||||
│
|
||||
└── collectToolSearchPrefetch()
|
||||
│
|
||||
├── 有结果 → 注入 system-reminder 或 <available-tools-hint>
|
||||
└── 无结果 → 不做任何附加
|
||||
```
|
||||
|
||||
**Hook 集成点**: 在 `REPL.tsx` 的消息提交流程或 `QueryEngine` 的请求构建环节中集成。
|
||||
|
||||
**并发安全**: 预取为异步操作,不阻塞主请求流程。如果预取未完成则跳过推荐。
|
||||
|
||||
### 6. 用户推荐 UI
|
||||
|
||||
**新增文件**: `src/components/ToolSearchHint.tsx`
|
||||
|
||||
**展示形式**: 在 REPL 输入区域上方渲染推荐提示条(类似现有 skill search tips 的设计)。
|
||||
|
||||
**UI 规格**:
|
||||
- 显示匹配度最高的 2-3 个工具
|
||||
- 每个工具显示:工具名 + 简短描述(一行截断) + 匹配分数
|
||||
- 样式与现有 skill search tips 对齐(Ink 组件,使用 theme 色系)
|
||||
- 可通过键盘快捷键选择(Tab 切换、Enter 确认)
|
||||
- 选择后将工具信息追加到当前消息的上下文中
|
||||
|
||||
**条件显示**:
|
||||
- 仅当预取结果非空时显示
|
||||
- 匹配分数低于阈值(默认 0.15)时不显示
|
||||
- 用户可通过 `settings.json` 关闭推荐提示
|
||||
|
||||
### 7. 搜索范围控制
|
||||
|
||||
采用激进精简策略后,搜索范围逻辑简化为:
|
||||
|
||||
- **索引范围**: 所有延迟工具(即核心工具列表之外的全部工具),包括所有 MCP 工具和所有非核心内置工具
|
||||
- **排除范围**: 核心工具(`CORE_TOOLS` 集合中的工具)不索引
|
||||
- **动态更新**: MCP 工具连接/断开时增量更新索引
|
||||
|
||||
可通过环境变量 `TOOL_SEARCH_EXCLUDE` 追加排除项,`TOOL_SEARCH_INCLUDE_FORCE` 强制索引某些工具。
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 关键技术决策
|
||||
|
||||
1. **复用 vs 重写 TF-IDF 基础设施**: 直接 import `localSearch.ts` 的 `tokenizeAndStem`、`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 函数。不提取为独立模块,因为 skill 和 tool 的索引结构不同(SkillIndexEntry vs ToolIndexEntry),强行抽象会增加复杂度。
|
||||
|
||||
2. **ExecuteTool vs tool_reference**: ExecuteTool 是通用方案,兼容所有 API provider。当 provider 支持 `tool_reference` 时,优先使用 `tool_reference`(性能更好,模型认知负担更低)。当不支持时,回退到 ExecuteTool。
|
||||
|
||||
3. **索引更新策略**: MCP 工具连接/断开时,通过 `DeferredToolsDelta` 机制检测变化,增量更新索引而非全量重建。
|
||||
|
||||
4. **预取不阻塞主流程**: 预取为 fire-and-forget 异步操作。如果预取未完成,API 请求正常发送,不做任何等待。
|
||||
|
||||
### 难点
|
||||
|
||||
1. **权限透传**: ExecuteTool 调用目标工具时需要正确透传权限上下文,确保用户审批流程与直接调用目标工具一致。
|
||||
|
||||
2. **参数 schema 验证**: MCP 工具的 schema 可能非常复杂(嵌套对象、oneOf 等),ExecuteTool 需要优雅地处理 schema 验证失败的情况。
|
||||
|
||||
3. **缓存一致性**: 工具索引缓存需要在 MCP 连接变化时及时更新,避免搜索到已失效的工具。
|
||||
|
||||
### 依赖
|
||||
|
||||
- `src/services/skillSearch/localSearch.ts` — TF-IDF 算法复用
|
||||
- `packages/builtin-tools/src/tools/ToolSearchTool/` — 现有搜索逻辑基础
|
||||
- `src/utils/toolSearch.ts` — 工具搜索基础设施(模式判断、阈值计算)
|
||||
- `packages/builtin-tools/src/tools/MCPTool/MCPTool.ts` — MCP 工具执行参考
|
||||
|
||||
### 新增文件清单
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/services/toolSearch/toolIndex.ts` | TF-IDF 工具索引构建与查询 |
|
||||
| `src/services/toolSearch/prefetch.ts` | 用户输入预取管道 |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts` | 工具执行入口 |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/prompt.ts` | ExecuteTool prompt 定义 |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/constants.ts` | 常量定义 |
|
||||
| `src/components/ToolSearchHint.tsx` | 用户推荐 UI 组件 |
|
||||
|
||||
### 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` | 新增 TF-IDF 搜索路径、discover 模式 |
|
||||
| `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` | 更新 prompt 文档、修改 `isDeferredTool` 判定逻辑 |
|
||||
| `src/constants/tools.ts` | 新增 `CORE_TOOLS` 常量集合 |
|
||||
| `src/tools.ts` | 注册 ExecuteTool、调整 `getAllBaseTools()` 工具注册 |
|
||||
| `src/utils/toolSearch.ts` | 适配新的延迟判定逻辑 |
|
||||
| `src/constants/prompts.ts` | 添加 ToolSearch 引导指令到系统提示词 |
|
||||
| `src/services/api/claude.ts` | 集成预取管道、调整延迟工具注入方式 |
|
||||
| `src/screens/REPL.tsx` | 集成 ToolSearchHint 组件 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 初始化时仅加载 ~10 个核心工具 schema,其余工具延迟加载
|
||||
- [ ] 延迟工具名称列表正确注入到 API 请求中
|
||||
- [ ] ToolSearchTool 支持基于 TF-IDF 的工具发现搜索(`discover:` 模式)
|
||||
- [ ] ToolSearchTool 支持关键词 + TF-IDF 混合搜索
|
||||
- [ ] ExecuteTool 可通过 tool_name + params 执行任意已注册工具
|
||||
- [ ] ExecuteTool 在所有 API provider(Anthropic/OpenAI/Gemini/Grok)下均可工作
|
||||
- [ ] MCP 工具连接/断开时索引自动更新
|
||||
- [ ] 用户输入后预取管道异步工作,不阻塞主流程
|
||||
- [ ] REPL 中展示工具推荐提示条(可配置开关)
|
||||
- [ ] `bun run precheck` 零错误通过
|
||||
- [ ] 新增单元测试覆盖:初始化精简验证、工具索引构建、TF-IDF 搜索、结果合并、ExecuteTool 执行
|
||||
262
spec/feature_20260508_F001_tool-search/spec-human-verify.md
Normal file
262
spec/feature_20260508_F001_tool-search/spec-human-verify.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Tool Search 基础设施层 人工验收清单
|
||||
|
||||
**生成时间:** 2026-05-08
|
||||
**关联计划:** spec/feature_20260508_F001_tool-search/spec-plan-1.md
|
||||
**关联设计:** spec/feature_20260508_F001_tool-search/spec-design.md
|
||||
|
||||
> 所有验收项均可自动化验证,无需人类参与。仍将生成清单用于自动执行。
|
||||
|
||||
---
|
||||
|
||||
## 验收前准备
|
||||
|
||||
### 环境要求
|
||||
- [ ] [AUTO] 检查 Bun 运行时: `bun --version`
|
||||
- [ ] [AUTO] 检查 TypeScript 编译: `bunx tsc --noEmit --pretty 2>&1 | tail -5`
|
||||
|
||||
---
|
||||
|
||||
## 验收项目
|
||||
|
||||
### 场景 1:核心工具白名单与延迟判定
|
||||
|
||||
> 验证 `CORE_TOOLS` 常量正确定义,`isDeferredTool` 已重构为白名单制判定。
|
||||
|
||||
#### - [x] 1.1 CORE_TOOLS 常量已定义且被引用
|
||||
- **来源:** spec-plan-1.md Task 1 / spec-design.md §1
|
||||
- **目的:** 确认核心工具白名单已建立
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c "CORE_TOOLS" src/constants/tools.ts` → 期望包含: 数字 ≥ 2
|
||||
2. [A] `grep -rn "CORE_TOOLS" src/ packages/builtin-tools/src/ --include="*.ts" 2>/dev/null | wc -l` → 期望包含: 数字 ≥ 3
|
||||
|
||||
#### - [x] 1.2 isDeferredTool 函数体仅含白名单逻辑
|
||||
- **来源:** spec-plan-1.md Task 1
|
||||
- **目的:** 确认延迟判定从"排除例外"改为"包含准入"
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -A 8 "export function isDeferredTool" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望包含: `CORE_TOOLS.has`
|
||||
2. [A] `grep -A 8 "export function isDeferredTool" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望包含: `return true`
|
||||
|
||||
#### - [x] 1.3 isDeferredTool 不再依赖旧 feature flag 逻辑
|
||||
- **来源:** spec-plan-1.md Task 1
|
||||
- **目的:** 确认旧的分散特判规则已清理
|
||||
- **操作步骤:**
|
||||
1. [A] `grep "feature(" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望精确: ""
|
||||
2. [A] `grep "shouldDefer" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望精确: ""
|
||||
|
||||
#### - [x] 1.4 CORE_TOOLS 与 isDeferredTool 单元测试通过
|
||||
- **来源:** spec-plan-1.md Task 1
|
||||
- **目的:** 确认白名单制逻辑正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/constants/__tests__/tools.test.ts 2>&1 | tail -5` → 期望包含: `pass`
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:TF-IDF 工具索引
|
||||
|
||||
> 验证 `localSearch.ts` 算法函数已导出,`toolIndex.ts` 正确构建 TF-IDF 索引并支持搜索。
|
||||
|
||||
#### - [x] 2.1 localSearch.ts 三个 TF-IDF 核心函数已导出
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认算法复用基础已建立
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c "export function computeWeightedTf\|export function computeIdf\|export function cosineSimilarity" src/services/skillSearch/localSearch.ts` → 期望精确: "3"
|
||||
|
||||
#### - [x] 2.2 toolIndex.ts 导出正确的接口与函数
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认索引模块 API 完整
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c "export function\|export interface" src/services/toolSearch/toolIndex.ts` → 期望包含: 数字 ≥ 6
|
||||
|
||||
#### - [x] 2.3 toolIndex.ts TypeScript 编译无错误
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认类型安全
|
||||
- **操作步骤:**
|
||||
1. [A] `bunx tsc --noEmit src/services/toolSearch/toolIndex.ts 2>&1 | head -20` → 期望包含: 无 error 行(或为空输出)
|
||||
|
||||
#### - [x] 2.4 toolIndex.ts 单元测试通过
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认索引构建和搜索逻辑正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/services/toolSearch/__tests__/toolIndex.test.ts 2>&1 | tail -10` → 期望包含: `pass`
|
||||
|
||||
#### - [x] 2.5 localSearch.ts 原有测试未回归
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认导出变更未破坏现有功能
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/services/skillSearch/__tests__/localSearch.test.ts 2>&1 | tail -10` → 期望包含: `pass`
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:ExecuteTool 执行入口
|
||||
|
||||
> 验证 ExecuteTool 工具包文件齐全、实现符合 buildTool 规范、权限透传正确。
|
||||
|
||||
#### - [x] 3.1 ExecuteTool 常量文件正确
|
||||
- **来源:** spec-plan-1.md Task 3
|
||||
- **目的:** 确认工具名常量已定义
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'EXECUTE_TOOL_NAME' packages/builtin-tools/src/tools/ExecuteTool/constants.ts` → 期望包含: `export const EXECUTE_TOOL_NAME`
|
||||
|
||||
#### - [x] 3.2 ExecuteTool prompt 文件正确
|
||||
- **来源:** spec-plan-1.md Task 3
|
||||
- **目的:** 确认 prompt 与 description 已导出
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'export' packages/builtin-tools/src/tools/ExecuteTool/prompt.ts` → 期望包含: `DESCRIPTION`
|
||||
2. [A] `grep -n 'export' packages/builtin-tools/src/tools/ExecuteTool/prompt.ts` → 期望包含: `getPrompt`
|
||||
|
||||
#### - [x] 3.3 ExecuteTool 使用 buildTool 构建
|
||||
- **来源:** spec-plan-1.md Task 3 / spec-design.md §4
|
||||
- **目的:** 确认遵循工具框架规范
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'buildTool\|satisfies ToolDef' packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts` → 期望包含: `buildTool`
|
||||
2. [A] `grep -n 'buildTool\|satisfies ToolDef' packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts` → 期望包含: `satisfies ToolDef`
|
||||
|
||||
#### - [x] 3.4 isDeferredTool 正确排除 ExecuteTool
|
||||
- **来源:** spec-plan-1.md Task 3
|
||||
- **目的:** 确认执行入口不被延迟加载
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'EXECUTE_TOOL_NAME' packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望包含: `EXECUTE_TOOL_NAME`
|
||||
|
||||
#### - [x] 3.5 ExecuteTool 单元测试通过
|
||||
- **来源:** spec-plan-1.md Task 3
|
||||
- **目的:** 确认工具执行、权限透传、错误处理正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts 2>&1 | tail -5` → 期望包含: `pass`
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:ToolSearchTool 搜索增强
|
||||
|
||||
> 验证 TF-IDF 搜索路径、discover 模式、并行搜索合并、文本模式输出均已实现。
|
||||
|
||||
#### - [x] 4.1 TF-IDF 搜索依赖已正确导入
|
||||
- **来源:** spec-plan-1.md Task 4
|
||||
- **目的:** 确认搜索层依赖就位
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "getToolIndex\|searchTools\|modelSupportsToolReference" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `getToolIndex`
|
||||
2. [A] `grep -n "getToolIndex\|searchTools\|modelSupportsToolReference" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `searchTools`
|
||||
3. [A] `grep -n "getToolIndex\|searchTools\|modelSupportsToolReference" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `modelSupportsToolReference`
|
||||
|
||||
#### - [x] 4.2 discover: 查询模式已实现
|
||||
- **来源:** spec-plan-1.md Task 4 / spec-design.md §3
|
||||
- **目的:** 确认纯发现搜索路径可用
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "discoverMatch\|discover:" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `discoverMatch`
|
||||
|
||||
#### - [x] 4.3 关键词搜索与 TF-IDF 搜索并行执行
|
||||
- **来源:** spec-plan-1.md Task 4 / spec-design.md §3
|
||||
- **目的:** 确认两路搜索并行而非串行
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "Promise.all" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `Promise.all`
|
||||
2. [A] `grep -n "searchToolsWithKeywords\|getToolIndex" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts | grep -i promise` → 期望包含: `searchToolsWithKeywords`
|
||||
|
||||
#### - [x] 4.4 结果合并使用加权求和
|
||||
- **来源:** spec-plan-1.md Task 4 / spec-design.md §3
|
||||
- **目的:** 确认混合搜索结果正确排序
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "KEYWORD_WEIGHT\|TFIDF_WEIGHT" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `KEYWORD_WEIGHT`
|
||||
2. [A] `grep -n "KEYWORD_WEIGHT\|TFIDF_WEIGHT" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `TFIDF_WEIGHT`
|
||||
|
||||
#### - [x] 4.5 mapToolResultToToolResultBlockParam 支持文本模式回退
|
||||
- **来源:** spec-plan-1.md Task 4 / spec-design.md §3(跨 API provider 兼容)
|
||||
- **目的:** 确认非 Anthropic provider 下返回文本格式
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "supportsToolRef\|ExecuteTool" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `supportsToolRef`
|
||||
2. [A] `grep -n "supportsToolRef\|ExecuteTool" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `ExecuteTool`
|
||||
|
||||
#### - [x] 4.6 prompt.ts 包含 discover: 模式文档
|
||||
- **来源:** spec-plan-1.md Task 4
|
||||
- **目的:** 确认模型可知晓 discover 查询模式
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "discover:" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望包含: `discover:`
|
||||
|
||||
#### - [x] 4.7 ToolSearchTool 增强后 TypeScript 编译无新增错误
|
||||
- **来源:** spec-plan-1.md Task 4
|
||||
- **目的:** 确认类型安全
|
||||
- **操作步骤:**
|
||||
1. [A] `bunx tsc --noEmit --pretty 2>&1 | head -30` → 期望包含: 无新增 error 行
|
||||
|
||||
#### - [x] 4.8 ToolSearchTool 搜索增强单元测试通过
|
||||
- **来源:** spec-plan-1.md Task 4
|
||||
- **目的:** 确认 discover 模式、并行搜索、文本回退均正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts 2>&1 | tail -10` → 期望包含: `pass`
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:端到端集成验证
|
||||
|
||||
> 验证全量测试、类型检查、构建产物均无回归。
|
||||
|
||||
#### - [x] 5.1 全量测试套件通过
|
||||
- **来源:** spec-plan-1.md Task 5 / spec-design.md 验收标准
|
||||
- **目的:** 确认所有新增测试无回归
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/constants/__tests__/tools.test.ts src/services/toolSearch/__tests__/toolIndex.test.ts packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ 2>&1 | tail -10` → 期望包含: `pass`
|
||||
|
||||
#### - [x] 5.2 TypeScript 全量类型检查通过
|
||||
- **来源:** spec-plan-1.md Task 5 / spec-design.md 验收标准
|
||||
- **目的:** 确认无新增类型错误
|
||||
- **操作步骤:**
|
||||
1. [A] `bunx tsc --noEmit --pretty 2>&1 | grep -i "error" | head -20` → 期望包含: 无新增 error 行(或为空输出)
|
||||
|
||||
#### - [x] 5.3 CORE_TOOLS 在关键文件中被引用
|
||||
- **来源:** spec-plan-1.md Task 5
|
||||
- **目的:** 确认白名单常量已集成到延迟判定和工具索引
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -rn "CORE_TOOLS" src/ packages/builtin-tools/src/ --include="*.ts" 2>/dev/null` → 期望包含: `tools.ts`
|
||||
2. [A] `grep -rn "CORE_TOOLS" src/ packages/builtin-tools/src/ --include="*.ts" 2>/dev/null` → 期望包含: `prompt.ts`
|
||||
|
||||
#### - [x] 5.4 项目构建成功
|
||||
- **来源:** spec-plan-1.md Task 5 / spec-design.md 验收标准
|
||||
- **目的:** 确认构建产物可用
|
||||
- **操作步骤:**
|
||||
1. [A] `bun run build 2>&1 | tail -5` → 期望包含: `dist/cli.js`
|
||||
|
||||
#### - [x] 5.5 precheck 零错误通过
|
||||
- **来源:** spec-design.md 验收标准 / CLAUDE.md
|
||||
- **目的:** 确认 typecheck + lint fix + test 全通过
|
||||
- **操作步骤:**
|
||||
1. [A] `bun run precheck 2>&1 | tail -10` → 期望包含: 无 error 或 fail
|
||||
|
||||
---
|
||||
|
||||
## 验收后清理
|
||||
|
||||
本功能为纯库代码变更,无后台服务启动,无需清理。
|
||||
|
||||
---
|
||||
|
||||
## 验收结果汇总
|
||||
|
||||
| 场景 | 序号 | 验收项 | [A] | [H] | 结果 |
|
||||
|------|------|--------|-----|-----|------|
|
||||
| 场景 1 | 1.1 | CORE_TOOLS 常量已定义且被引用 | 2 | 0 | ✅ |
|
||||
| 场景 1 | 1.2 | isDeferredTool 函数体仅含白名单逻辑 | 2 | 0 | ✅ |
|
||||
| 场景 1 | 1.3 | isDeferredTool 不再依赖旧 feature flag 逻辑 | 2 | 0 | ✅ |
|
||||
| 场景 1 | 1.4 | CORE_TOOLS 与 isDeferredTool 单元测试通过 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.1 | localSearch.ts 三个 TF-IDF 核心函数已导出 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.2 | toolIndex.ts 导出正确的接口与函数 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.3 | toolIndex.ts TypeScript 编译无错误 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.4 | toolIndex.ts 单元测试通过 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.5 | localSearch.ts 原有测试未回归 | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.1 | ExecuteTool 常量文件正确 | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.2 | ExecuteTool prompt 文件正确 | 2 | 0 | ✅ |
|
||||
| 场景 3 | 3.3 | ExecuteTool 使用 buildTool 构建 | 2 | 0 | ✅ |
|
||||
| 场景 3 | 3.4 | isDeferredTool 正确排除 ExecuteTool | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.5 | ExecuteTool 单元测试通过 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.1 | TF-IDF 搜索依赖已正确导入 | 3 | 0 | ✅ |
|
||||
| 场景 4 | 4.2 | discover: 查询模式已实现 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.3 | 关键词搜索与 TF-IDF 搜索并行执行 | 2 | 0 | ✅ |
|
||||
| 场景 4 | 4.4 | 结果合并使用加权求和 | 2 | 0 | ✅ |
|
||||
| 场景 4 | 4.5 | 文本模式回退支持跨 API provider | 2 | 0 | ✅ |
|
||||
| 场景 4 | 4.6 | prompt.ts 包含 discover: 模式文档 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.7 | 搜索增强后 TypeScript 编译无新增错误 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.8 | ToolSearchTool 搜索增强单元测试通过 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.1 | 全量测试套件通过 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.2 | TypeScript 全量类型检查通过 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.3 | CORE_TOOLS 在关键文件中被引用 | 2 | 0 | ✅ |
|
||||
| 场景 5 | 5.4 | 项目构建成功 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.5 | precheck 零错误通过 | 1 | 0 | ✅ |
|
||||
|
||||
**验收结论:** ✅ 全部通过
|
||||
650
spec/feature_20260508_F001_tool-search/spec-plan-1.md
Normal file
650
spec/feature_20260508_F001_tool-search/spec-plan-1.md
Normal file
@@ -0,0 +1,650 @@
|
||||
# Tool Search 执行计划(一)— 基础设施层
|
||||
|
||||
**目标:** 建立 tool search 的基础能力——核心工具常量、TF-IDF 工具索引、ExecuteTool 执行工具、ToolSearchTool 搜索增强
|
||||
|
||||
**技术栈:** TypeScript, Bun, Zod, TF-IDF (复用 localSearch.ts), buildTool 框架
|
||||
|
||||
**设计文档:** spec/feature_20260508_F001_tool-search/spec-design.md
|
||||
|
||||
## 改动总览
|
||||
|
||||
- 新增 `CORE_TOOLS` 常量集合(31 个核心工具名)到 `src/constants/tools.ts`,重构 `isDeferredTool` 为白名单制;新建 TF-IDF 工具索引 `toolIndex.ts`(复用 `localSearch.ts` 算法);新建 `ExecuteTool` 工具包(3 个文件);增强 `ToolSearchTool` 搜索层(TF-IDF + discover 模式)
|
||||
- Task 1(CORE_TOOLS)是 Task 2/3/4 的共同前置依赖;Task 2(toolIndex)被 Task 4(搜索增强)依赖
|
||||
- 关键决策:`isDeferredTool` 从"排除例外"改为"包含准入"白名单制,所有非核心工具默认延迟;TF-IDF 算法直接 import `localSearch.ts` 的导出函数,不创建独立共享模块
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 环境准备
|
||||
|
||||
**背景:**
|
||||
确保构建和测试工具链在当前开发环境中可用,避免后续 Task 因环境问题阻塞。
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 验证 Bun 运行时可用
|
||||
- `bun --version`
|
||||
- 预期: 输出 Bun 版本号
|
||||
- [x] 验证 TypeScript 编译可用
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | tail -5`
|
||||
- 预期: 无新增类型错误(已有错误可忽略)
|
||||
- [x] 验证测试框架可用
|
||||
- `bun test --help 2>&1 | head -3`
|
||||
- 预期: 输出 bun test 帮助信息
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 构建命令执行成功
|
||||
- `bun run build 2>&1 | tail -10`
|
||||
- 预期: 构建成功,输出 dist/cli.js
|
||||
- [x] 现有测试可通过
|
||||
- `bun test src/constants/__tests__/ 2>&1 | tail -5 || echo "no existing tests in this dir"`
|
||||
- 预期: 测试框架可用,无配置错误
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 核心工具常量与延迟判定
|
||||
|
||||
**背景:**
|
||||
当前 `isDeferredTool` 使用一组分散的特判规则(`shouldDefer`、MCP 检测、feature flag 特判)来决定工具是否延迟加载,缺少统一的"核心工具"概念。设计文档要求引入 `CORE_TOOLS` 白名单常量,将始终加载的核心工具(31 个)显式列出,并将 `isDeferredTool` 改为白名单制判定:核心工具 + alwaysLoad 工具 + ToolSearchTool/ExecuteTool 不延迟,其余全部延迟。本 Task 的输出(`CORE_TOOLS` 常量和重构后的 `isDeferredTool`)被 Task 2(TF-IDF 工具索引)、Task 3(ExecuteTool)、Task 4(ToolSearchTool 搜索增强)直接依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/constants/tools.ts`
|
||||
- 修改: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 新建: `src/constants/__tests__/tools.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `src/constants/tools.ts` 中新增 `CORE_TOOLS` 常量集合
|
||||
- 位置: `src/constants/tools.ts` 文件末尾(`COORDINATOR_MODE_ALLOWED_TOOLS` 之后,~L113)
|
||||
- 新增以下 import(文件顶部 import 区域,与现有 import 风格一致):
|
||||
```typescript
|
||||
import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool/prompt.js'
|
||||
import { LSP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/LSPTool/prompt.js'
|
||||
import { VERIFY_PLAN_EXECUTION_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/VerifyPlanExecutionTool/constants.js'
|
||||
import { TEAM_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TeamCreateTool/constants.js'
|
||||
import { TEAM_DELETE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TeamDeleteTool/constants.js'
|
||||
```
|
||||
- 在文件末尾新增 `CORE_TOOLS` 导出常量:
|
||||
```typescript
|
||||
/**
|
||||
* Core tools that are always loaded with full schema at initialization.
|
||||
* These tools are never deferred — they appear in the initial prompt.
|
||||
* All other tools (non-core built-in + all MCP tools) are deferred
|
||||
* and must be discovered via ToolSearchTool / ExecuteTool.
|
||||
*/
|
||||
export const CORE_TOOLS = new Set([
|
||||
// File operations
|
||||
...SHELL_TOOL_NAMES, // 'Bash', 'Shell'
|
||||
FILE_READ_TOOL_NAME, // 'Read'
|
||||
FILE_EDIT_TOOL_NAME, // 'Edit'
|
||||
FILE_WRITE_TOOL_NAME, // 'Write'
|
||||
GLOB_TOOL_NAME, // 'Glob'
|
||||
GREP_TOOL_NAME, // 'Grep'
|
||||
NOTEBOOK_EDIT_TOOL_NAME,// 'NotebookEdit'
|
||||
// Agent & interaction
|
||||
AGENT_TOOL_NAME, // 'Agent'
|
||||
ASK_USER_QUESTION_TOOL_NAME, // 'AskUserQuestion'
|
||||
SEND_MESSAGE_TOOL_NAME, // 'SendMessage'
|
||||
// Team (swarm)
|
||||
TEAM_CREATE_TOOL_NAME, // 'TeamCreate'
|
||||
TEAM_DELETE_TOOL_NAME, // 'TeamDelete'
|
||||
// Task management
|
||||
TASK_OUTPUT_TOOL_NAME, // 'TaskOutput'
|
||||
TASK_STOP_TOOL_NAME, // 'TaskStop'
|
||||
TASK_CREATE_TOOL_NAME, // 'TaskCreate'
|
||||
TASK_GET_TOOL_NAME, // 'TaskGet'
|
||||
TASK_LIST_TOOL_NAME, // 'TaskList'
|
||||
TASK_UPDATE_TOOL_NAME, // 'TaskUpdate'
|
||||
TODO_WRITE_TOOL_NAME, // 'TodoWrite'
|
||||
// Planning
|
||||
ENTER_PLAN_MODE_TOOL_NAME, // 'EnterPlanMode'
|
||||
EXIT_PLAN_MODE_V2_TOOL_NAME, // 'ExitPlanMode'
|
||||
VERIFY_PLAN_EXECUTION_TOOL_NAME, // 'VerifyPlanExecution'
|
||||
// Web
|
||||
WEB_FETCH_TOOL_NAME, // 'WebFetch'
|
||||
WEB_SEARCH_TOOL_NAME, // 'WebSearch'
|
||||
// Code intelligence
|
||||
LSP_TOOL_NAME, // 'LSP'
|
||||
// Skills
|
||||
SKILL_TOOL_NAME, // 'Skill'
|
||||
// Scheduling & monitoring
|
||||
SLEEP_TOOL_NAME, // 'Sleep'
|
||||
// Tool discovery (always loaded)
|
||||
TOOL_SEARCH_TOOL_NAME, // 'ToolSearch'
|
||||
SYNTHETIC_OUTPUT_TOOL_NAME, // 'SyntheticOutput'
|
||||
]) as ReadonlySet<string>
|
||||
```
|
||||
- 说明: `ListPeers` 和 `Monitor` 工具名在各自工具文件内以局部常量定义(非 export),无法在 `tools.ts` 中 import。`ListPeers` 频率较低,`Monitor` 受 `MONITOR_TOOL` feature gate 控制,两者暂不纳入 CORE_TOOLS,待后续 Task 按需加入。
|
||||
- 原因: 建立统一的"核心工具"白名单,为后续 Task 的延迟判定、工具索引排除提供单一数据源
|
||||
|
||||
- [x] 重构 `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` 中的 `isDeferredTool` 函数
|
||||
- 位置: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` 的 `isDeferredTool` 函数体(L62-L108)
|
||||
- 新增 import(文件顶部):
|
||||
```typescript
|
||||
import { CORE_TOOLS } from 'src/constants/tools.js'
|
||||
```
|
||||
- 替换整个 `isDeferredTool` 函数体为白名单制逻辑:
|
||||
```typescript
|
||||
export function isDeferredTool(tool: Tool): boolean {
|
||||
// Explicit opt-out via _meta['anthropic/alwaysLoad']
|
||||
if (tool.alwaysLoad === true) return false
|
||||
|
||||
// Core tools are always loaded — never deferred
|
||||
if (CORE_TOOLS.has(tool.name)) return false
|
||||
|
||||
// Everything else (non-core built-in + all MCP tools) is deferred
|
||||
return true
|
||||
}
|
||||
```
|
||||
- 清理 isDeferredTool 不再需要的代码:
|
||||
- 文件顶部的 `import { feature } from 'bun:bundle'`(仅被 isDeferredTool 使用的 feature flag 逻辑)
|
||||
- 文件顶部的 `import { isReplBridgeActive } from 'src/bootstrap/state.js'`(仅被 KAIROS 逻辑使用)
|
||||
- 保留 `import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'`(仍被 `getToolLocationHint()` 使用,不删除)
|
||||
- 文件顶部的 `import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'`(不再被 isDeferredTool 使用)
|
||||
- L8-L21 的 `BRIEF_TOOL_NAME` 和 `SEND_USER_FILE_TOOL_NAME` 条件 import 块(`isDeferredTool` 不再需要 feature flag 特判)
|
||||
- 注意: 保留 `getToolLocationHint()` 函数及其对 `getFeatureValue_CACHED_MAY_BE_STALE` 的 import(仍被 `getPrompt()` 使用)
|
||||
- 原因: 白名单制替代分散的特判规则,逻辑从"排除例外"变为"包含准入",更易维护和扩展
|
||||
|
||||
- [x] 为 `CORE_TOOLS` 常量和 `isDeferredTool` 重构编写单元测试
|
||||
- 测试文件: `src/constants/__tests__/tools.test.ts`(新建)
|
||||
- 测试场景:
|
||||
- `CORE_TOOLS` 包含预期数量的工具(约 29 个: 7 SHELL_TOOL_NAMES + 22 独立工具名)
|
||||
- `CORE_TOOLS` 包含所有设计文档中列出的核心工具名(抽查: 'Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep', 'Agent', 'AskUserQuestion', 'ToolSearch', 'WebSearch', 'WebFetch', 'Sleep', 'LSP', 'Skill', 'TeamCreate', 'TeamDelete', 'TaskCreate', 'TaskGet', 'TaskUpdate', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite', 'EnterPlanMode', 'ExitPlanMode', 'VerifyPlanExecution', 'NotebookEdit', 'SyntheticOutput')
|
||||
- `CORE_TOOLS` 是 ReadonlySet,不可外部修改
|
||||
- `isDeferredTool` 对 `CORE_TOOLS` 中的工具名返回 `false`(构造 `{ name: 'Read', alwaysLoad: undefined, isMcp: false, shouldDefer: undefined }` 形式的 mock Tool)
|
||||
- `isDeferredTool` 对 `alwaysLoad: true` 的工具返回 `false`(即使工具名不在 CORE_TOOLS 中)
|
||||
- `isDeferredTool` 对非核心内置工具返回 `true`(工具名 'ConfigTool',无 alwaysLoad,无 isMcp)
|
||||
- `isDeferredTool` 对 MCP 工具返回 `true`(`isMcp: true`,即使 alwaysLoad 为 undefined)
|
||||
- `isDeferredTool` 对 `alwaysLoad: true` 的 MCP 工具返回 `false`(alwaysLoad 优先级最高)
|
||||
- 运行命令: `bun test src/constants/__tests__/tools.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `CORE_TOOLS` 常量已导出且包含预期工具
|
||||
- `grep -c "CORE_TOOLS" src/constants/tools.ts`
|
||||
- 预期: 至少 2 行(export 定义 + 注释)
|
||||
|
||||
- [x] 验证 `isDeferredTool` 函数已简化为白名单制
|
||||
- `grep -A 8 "export function isDeferredTool" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 函数体仅包含 `alwaysLoad`、`CORE_TOOLS.has`、`return true` 三个分支,不包含 `isMcp`、`feature(`、`shouldDefer` 等旧逻辑
|
||||
|
||||
- [x] 验证 `isDeferredTool` 不再依赖已删除的 import
|
||||
- `grep "feature(" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 无输出(feature flag 依赖已从 isDeferredTool 中移除)
|
||||
|
||||
- [x] 验证类型检查通过
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
- 预期: 无新增类型错误
|
||||
|
||||
- [x] 运行新增单元测试
|
||||
- `bun test src/constants/__tests__/tools.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
---
|
||||
|
||||
### Task 2: TF-IDF 工具索引
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 本 Task 构建工具索引模块,为 TF-IDF 搜索提供索引构建和查询能力。ToolSearchTool(Task 4)和预取管道依赖此索引来按任务描述发现延迟工具。
|
||||
[修改原因] — 当前项目只有 skill 搜索的 TF-IDF 实现(`localSearch.ts`),缺少工具维度的索引。`localSearch.ts` 中的 `computeWeightedTf`、`computeIdf`、`cosineSimilarity` 三个核心函数未导出,需要先导出才能复用。
|
||||
[上下游影响] — 本 Task 输出 `toolIndex.ts` 被 Task 4(ToolSearchTool 搜索增强)和 Task 3(ExecuteTool 工具查找)依赖。本 Task 依赖 Task 1(`CORE_TOOLS` 常量和 `isDeferredTool` 判定)。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/services/skillSearch/localSearch.ts`(导出三个私有函数)
|
||||
- 新建: `src/services/toolSearch/toolIndex.ts`
|
||||
- 新建: `src/services/toolSearch/__tests__/toolIndex.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 导出 `localSearch.ts` 中三个私有 TF-IDF 函数 — `toolIndex.ts` 需要复用这些算法函数
|
||||
- 位置: `src/services/skillSearch/localSearch.ts` L212, L230, L249
|
||||
- 在 `computeWeightedTf`、`computeIdf`、`cosineSimilarity` 三个函数声明前各加 `export` 关键字
|
||||
- 保持函数签名不变,仅增加导出修饰符
|
||||
- 原因: 这三个函数是 TF-IDF 核心算法,与索引结构无关,导出后 skill 和 tool 两个索引模块均可复用
|
||||
|
||||
- [x] 新建 `src/services/toolSearch/toolIndex.ts`,定义 `ToolIndexEntry` 接口和工具字段权重常量
|
||||
- 位置: 文件开头
|
||||
- 定义 `ToolIndexEntry` 接口,包含以下字段:
|
||||
```typescript
|
||||
export interface ToolIndexEntry {
|
||||
name: string
|
||||
normalizedName: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
tokens: string[]
|
||||
tfVector: Map<string, number>
|
||||
}
|
||||
```
|
||||
- 定义字段权重常量(参照 `localSearch.ts` 的 `FIELD_WEIGHT` 模式):
|
||||
```typescript
|
||||
const TOOL_FIELD_WEIGHT = {
|
||||
name: 3.0,
|
||||
searchHint: 2.5,
|
||||
description: 1.0,
|
||||
} as const
|
||||
```
|
||||
- 定义最小显示分数常量:`const TOOL_SEARCH_DISPLAY_MIN_SCORE = Number(process.env.TOOL_SEARCH_DISPLAY_MIN_SCORE ?? '0.10')`
|
||||
- 原因: 工具索引结构与 skill 索引不同(无 `whenToUse`/`allowedTools`,增加 `searchHint`/`isMcp`/`isDeferred`/`inputSchema`),需独立定义
|
||||
|
||||
- [x] 实现 `parseToolName` 工具名解析函数 — 将工具名拆分为可搜索的 token 列表
|
||||
- 位置: `src/services/toolSearch/toolIndex.ts`,在接口定义之后
|
||||
- 从 `ToolSearchTool.ts:132-161` 的 `parseToolName` 逻辑提取并适配为独立函数:
|
||||
```typescript
|
||||
export function parseToolName(name: string): { parts: string[]; full: string; isMcp: boolean }
|
||||
```
|
||||
- MCP 工具(`mcp__` 前缀): 去掉前缀后按 `__` 和 `_` 拆分,结果示例 `mcp__github__create_issue` → `["github", "create", "issue"]`
|
||||
- 内置工具: CamelCase 拆分 + 下划线拆分,结果示例 `NotebookEditTool` → `["notebook", "edit", "tool"]`
|
||||
- 原因: 工具名是搜索的高权重信号,需要拆分为有意义的关键词 token
|
||||
|
||||
- [x] 实现 `buildToolIndex` 索引构建函数 — 从 `Tool[]` 数组构建完整的 TF-IDF 索引
|
||||
- 位置: `src/services/toolSearch/toolIndex.ts`,在 `parseToolName` 之后
|
||||
- 函数签名:`export async function buildToolIndex(tools: Tool[]): Promise<ToolIndexEntry[]>`
|
||||
- 导入依赖:从 `localSearch.ts` 导入 `tokenizeAndStem`、`computeWeightedTf`、`computeIdf`、`cosineSimilarity`
|
||||
- 核心逻辑:
|
||||
1. 过滤出延迟工具(调用 `isDeferredTool`,从 `@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js` 导入)
|
||||
2. 对每个延迟工具,调用 `tool.prompt()` 获取描述文本(构造一个 mock 的 `getToolPermissionContext` 返回空权限上下文,`tools` 传原始工具列表,`agents` 传空数组)
|
||||
3. 调用 `parseToolName(tool.name)` 获取工具名 token
|
||||
4. 调用 `tokenizeAndStem` 对 `name parts`、`searchHint`、`description` 分别分词
|
||||
5. 调用 `computeWeightedTf` 按权重计算 TF 向量
|
||||
6. 读取 `tool.inputJSONSchema ?? (tool.inputSchema ? zodToJsonSchema(tool.inputSchema) : undefined)` 作为 `inputSchema`
|
||||
7. 组装 `ToolIndexEntry` 条目
|
||||
8. 对全部条目调用 `computeIdf` 计算 IDF,将 TF 向量乘以 IDF 得到最终 TF-IDF 向量
|
||||
- 返回构建好的索引数组
|
||||
- 原因: 索引构建是搜索的前提,需要从 Tool 对象提取文本并计算 TF-IDF 向量
|
||||
|
||||
- [x] 实现 `searchTools` 搜索函数 — 按任务描述查询最匹配的工具
|
||||
- 位置: `src/services/toolSearch/toolIndex.ts`,在 `buildToolIndex` 之后
|
||||
- 函数签名:`export function searchTools(query: string, index: ToolIndexEntry[], limit?: number): ToolSearchResult[]`
|
||||
- 定义返回类型:
|
||||
```typescript
|
||||
export interface ToolSearchResult {
|
||||
name: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
score: number
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
}
|
||||
```
|
||||
- 核心逻辑(参照 `localSearch.ts:searchSkills` L383-443 的模式):
|
||||
1. 对 query 调用 `tokenizeAndStem` 分词
|
||||
2. 计算 query 的 TF-IDF 向量(TF 归一化 + IDF 乘法)
|
||||
3. 对索引中每个条目计算 `cosineSimilarity(queryTfIdf, entry.tfVector)`
|
||||
4. CJK bigram 过滤:若 query 包含 CJK token 且匹配数 < 2 且无 ASCII 匹配,则分数置零(复用 `CJK_MIN_BIGRAM_MATCHES = 2` 常量)
|
||||
5. 工具名完全包含加分:若 query 小写化后包含工具的 `normalizedName`,分数取 `Math.max(score, 0.75)`
|
||||
6. 过滤 `score >= TOOL_SEARCH_DISPLAY_MIN_SCORE` 的结果
|
||||
7. 按分数降序排列,截取前 `limit` 条(默认 5)
|
||||
- 原因: 搜索函数是工具发现的核心入口,提供给 ToolSearchTool 和预取管道调用
|
||||
|
||||
- [x] 实现模块级索引缓存和增量更新 — 避免每次搜索都全量重建索引
|
||||
- 位置: `src/services/toolSearch/toolIndex.ts`,在 `searchTools` 之后
|
||||
- 定义模块级缓存变量:
|
||||
```typescript
|
||||
let cachedIndex: ToolIndexEntry[] | null = null
|
||||
let cachedToolNames: string | null = null
|
||||
```
|
||||
- 实现 `getToolIndex` 缓存包装函数:签名 `export async function getToolIndex(tools: Tool[]): Promise<ToolIndexEntry[]>`
|
||||
- 缓存 key 为延迟工具名排序后的字符串
|
||||
- 当工具名集合变化时(MCP 连接/断开),自动重建索引
|
||||
- 缓存未命中时调用 `buildToolIndex`
|
||||
- 实现 `clearToolIndexCache` 清除函数:签名 `export function clearToolIndexCache(): void`
|
||||
- 原因: 索引构建涉及异步 `tool.prompt()` 调用,缓存避免重复计算;增量更新通过比较工具名集合实现
|
||||
|
||||
- [x] 为 `toolIndex.ts` 核心逻辑编写单元测试
|
||||
- 测试文件: `src/services/toolSearch/__tests__/toolIndex.test.ts`
|
||||
- 测试框架: `bun:test`(与 `localSearch.test.ts` 一致)
|
||||
- 测试场景:
|
||||
- `parseToolName` — MCP 工具名 `mcp__github__create_issue` 拆分为 `["github", "create", "issue"]`,`isMcp: true`
|
||||
- `parseToolName` — 内置工具名 `NotebookEditTool` 拆分为 `["notebook", "edit", "tool"]`,`isMcp: false`
|
||||
- `buildToolIndex` — 传入包含延迟工具的 mock Tool 数组,返回正确数量的 `ToolIndexEntry`,每个条目的 `tokens` 非空、`tfVector` 非空
|
||||
- `searchTools` — 英文查询 `"schedule cron job"` 能匹配含 `searchHint: "schedule a recurring or one-shot prompt"` 的工具,返回分数 > 0 且排名第一
|
||||
- `searchTools` — CJK 查询能匹配含中文描述的工具(参照 `localSearch.test.ts` 的 CJK 测试模式)
|
||||
- `searchTools` — 空查询返回空数组
|
||||
- `searchTools` — 无匹配结果返回空数组
|
||||
- `getToolIndex` — 相同工具列表两次调用返回同一缓存引用
|
||||
- `clearToolIndexCache` — 调用后 `getToolIndex` 重新构建索引
|
||||
- Mock 构造: 创建 `Partial<Tool>` 类型的 mock 工具,设置 `name`、`searchHint`、`prompt()`(返回固定描述字符串)、`inputSchema`(mock Zod schema 或 undefined)、`isMcp`、`shouldDefer`、`alwaysLoad` 等字段
|
||||
- 运行命令: `bun test src/services/toolSearch/__tests__/toolIndex.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证 `localSearch.ts` 三个函数已导出
|
||||
- `grep -c "export function computeWeightedTf\|export function computeIdf\|export function cosineSimilarity" src/services/skillSearch/localSearch.ts`
|
||||
- 预期: 输出 3
|
||||
|
||||
- [x] 验证 `toolIndex.ts` 文件存在且导出正确
|
||||
- `grep -c "export function\|export interface\|export type" src/services/toolSearch/toolIndex.ts`
|
||||
- 预期: 至少 6(ToolIndexEntry, ToolSearchResult, parseToolName, buildToolIndex, searchTools, getToolIndex, clearToolIndexCache)
|
||||
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `bunx tsc --noEmit src/services/toolSearch/toolIndex.ts 2>&1 | head -20`
|
||||
- 预期: 无错误输出
|
||||
|
||||
- [x] 验证单元测试通过
|
||||
- `bun test src/services/toolSearch/__tests__/toolIndex.test.ts 2>&1 | tail -10`
|
||||
- 预期: 输出包含 "pass" 且无 "fail"
|
||||
|
||||
- [x] 验证 `localSearch.ts` 原有测试未回归
|
||||
- `bun test src/services/skillSearch/__tests__/localSearch.test.ts 2>&1 | tail -10`
|
||||
- 预期: 所有测试通过,无回归
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `src/services/skillSearch/localSearch.ts` 中的 `computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出,供 `toolIndex.ts` 复用。修改这些函数时需同步检查工具索引的测试
|
||||
---
|
||||
### Task 3: ExecuteTool 执行工具
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 新建 ExecuteTool 作为跨 API provider 的统一工具执行入口。当模型通过 ToolSearchTool 发现延迟工具后,使用 ExecuteTool 以 `tool_name` + `params` 的方式调用该工具,替代仅 Anthropic 支持的 `tool_reference` 机制。
|
||||
[修改原因] — 当前项目无 ExecuteTool,延迟工具无法在非 Anthropic provider(OpenAI/Gemini/Grok)下被模型调用。
|
||||
[上下游影响] — 本 Task 依赖 Task 1(`EXECUTE_TOOL_NAME` 常量、`CORE_TOOLS` 集合、`isDeferredTool` 判定)。本 Task 的输出(ExecuteTool 工具实例)被 Task 4(ToolSearchTool 搜索增强)和 `src/tools.ts`(工具注册)依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 新建: `packages/builtin-tools/src/tools/ExecuteTool/constants.ts`
|
||||
- 新建: `packages/builtin-tools/src/tools/ExecuteTool/prompt.ts`
|
||||
- 新建: `packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts`
|
||||
- 修改: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`(导入 `EXECUTE_TOOL_NAME`,在 `isDeferredTool` 中排除 ExecuteTool)
|
||||
- 新建: `packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 创建 ExecuteTool 常量文件
|
||||
- 位置: 新建 `packages/builtin-tools/src/tools/ExecuteTool/constants.ts`
|
||||
- 内容:
|
||||
```typescript
|
||||
export const EXECUTE_TOOL_NAME = 'ExecuteTool'
|
||||
```
|
||||
- 原因: 与 `ToolSearchTool/constants.ts` 中的 `TOOL_SEARCH_TOOL_NAME` 保持一致的模式,供 `isDeferredTool`、工具注册等处引用
|
||||
|
||||
- [x] 创建 ExecuteTool prompt 文件
|
||||
- 位置: 新建 `packages/builtin-tools/src/tools/ExecuteTool/prompt.ts`
|
||||
- 从 `./constants.js` 导入 `EXECUTE_TOOL_NAME`
|
||||
- 导出 `DESCRIPTION` 常量(一句话描述)和 `getPrompt()` 函数
|
||||
- `getPrompt()` 返回完整 prompt 文本,包含:
|
||||
- 功能说明:接受 `tool_name` + `params`,在全局工具注册表中查找目标工具并委托执行
|
||||
- 使用场景:当通过 ToolSearch 发现延迟工具后,使用此工具调用该工具
|
||||
- 输入说明:`tool_name` 是目标工具名称(如 "CronCreate"、"mcp__server__action"),`params` 是传递给目标工具的参数对象
|
||||
- 错误处理:工具不存在或参数无效时返回清晰的错误信息
|
||||
- 原因: 与 `ToolSearchTool/prompt.ts` 的 `getPrompt()` 模式保持一致,将 prompt 逻辑与工具实现分离
|
||||
|
||||
- [x] 创建 ExecuteTool 主实现文件
|
||||
- 位置: 新建 `packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts`
|
||||
- 依赖导入:
|
||||
- `z` from `zod/v4`
|
||||
- `buildTool`, `findToolByName`, `type Tool`, `type ToolDef`, `type ToolUseContext`, `type ToolResult` from `src/Tool.js`
|
||||
- `lazySchema` from `src/utils/lazySchema.js`
|
||||
- `DESCRIPTION`, `getPrompt`, `EXECUTE_TOOL_NAME` from `./prompt.js`
|
||||
- `EXECUTE_TOOL_NAME` from `./constants.js`
|
||||
- `isToolSearchEnabledOptimistic` from `src/utils/toolSearch.js`
|
||||
- 定义 `inputSchema`: `z.object({ tool_name: z.string().describe('...'), params: z.record(z.unknown()).describe('...') })`
|
||||
- 定义 `outputSchema`: `z.object({ result: z.unknown(), tool_name: z.string() })`
|
||||
- 使用 `buildTool` 构建 `ExecuteTool`,`satisfies ToolDef<InputSchema, OutputSchema>`
|
||||
- 关键属性:
|
||||
- `name: EXECUTE_TOOL_NAME`
|
||||
- `searchHint: 'execute run invoke call a deferred tool by name with parameters'`
|
||||
- `isConcurrencySafe() { return false }`(委托执行的工具是否并发安全取决于目标工具,保守设为 false)
|
||||
- `maxResultSizeChars: 100_000`(与 ToolSearchTool 和 MCPTool 一致)
|
||||
- `description()` 返回 `DESCRIPTION`
|
||||
- `prompt()` 返回 `getPrompt()`
|
||||
- `call(input, context)` 核心逻辑:
|
||||
1. 从 `context.options.tools` 中通过 `findToolByName(tools, input.tool_name)` 查找目标工具
|
||||
2. 目标工具不存在时,返回 `{ data: { result: null, tool_name: input.tool_name }, newMessages: [错误提示 user message] }`,错误信息格式:`Tool "${input.tool_name}" not found. Use ToolSearch to discover available tools.`
|
||||
3. 目标工具存在时,调用 `targetTool.checkPermissions(input.params as any, context)` 获取权限结果
|
||||
4. 权限检查结果为 `behavior: 'deny'` 时,返回权限拒绝信息
|
||||
5. 权限检查通过后,调用 `targetTool.call(input.params as any, context, ...)` 委托执行,透传 context、canUseTool、parentMessage、onProgress 参数(`call` 签名为 `call(args, context, canUseTool, parentMessage, onProgress?)`,从 ExecuteTool 自身的 `call` 参数中获取后三个参数并传递给目标工具)
|
||||
6. 返回目标工具的执行结果,附加 `tool_name` 字段用于追踪
|
||||
- `checkPermissions()` 返回 `{ behavior: 'passthrough', message: 'ExecuteTool delegates permission to the target tool.' }`,与 MCPTool 的权限透传模式一致
|
||||
- `renderToolUseMessage(input)` 返回格式化字符串:`Executing ${input.tool_name}...`,用于 UI 展示
|
||||
- `userFacingName()` 返回 `'ExecuteTool'`
|
||||
- `mapToolResultToToolResultBlockParam(content, toolUseID)` 返回标准 tool_result 格式
|
||||
- `isEnabled()` 返回 `isToolSearchEnabledOptimistic()`,与 ToolSearchTool 联动启用
|
||||
- `isReadOnly()` 返回 `false`(执行的工具可能执行写操作)
|
||||
- 原因: 采用与 MCPTool 相同的 `buildTool` + `satisfies ToolDef` 模式,确保类型安全和框架一致性。权限透传采用 `passthrough` 策略,由目标工具自行决定权限逻辑
|
||||
|
||||
- [x] 在 `isDeferredTool` 中排除 ExecuteTool
|
||||
- 位置: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` 的 `isDeferredTool` 函数内,在 `if (tool.name === TOOL_SEARCH_TOOL_NAME) return false` 之后(~L71)
|
||||
- 新增导入: `import { EXECUTE_TOOL_NAME } from '../ExecuteTool/constants.js'`
|
||||
- 插入: `if (tool.name === EXECUTE_TOOL_NAME) return false`
|
||||
- 原因: ExecuteTool 是核心入口工具,必须在初始化时可用,不能被延迟加载
|
||||
|
||||
- [x] 为 ExecuteTool 编写单元测试
|
||||
- 测试文件: `packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts`
|
||||
- 测试场景:
|
||||
- 正常执行: 构造一个 mock 工具注册到 tools 列表中,调用 ExecuteTool 传入该工具名和合法参数,预期目标工具的 `call` 被调用且返回结果包含 `tool_name`
|
||||
- 工具不存在: 传入不存在的 `tool_name`,预期返回错误信息且 `result` 为 null
|
||||
- 权限拒绝: mock 目标工具的 `checkPermissions` 返回 `{ behavior: 'deny', message: 'denied' }`,预期 ExecuteTool 返回权限拒绝信息
|
||||
- isEnabled 联动: 验证 `ExecuteTool.isEnabled()` 依赖 `isToolSearchEnabledOptimistic()` 的返回值
|
||||
- searchHint 存在: 验证 `ExecuteTool.searchHint` 包含关键词 "execute" 和 "tool"
|
||||
- 运行命令: `bun test packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证常量文件正确导出 EXECUTE_TOOL_NAME
|
||||
- `grep -n 'EXECUTE_TOOL_NAME' packages/builtin-tools/src/tools/ExecuteTool/constants.ts`
|
||||
- 预期: 输出包含 `export const EXECUTE_TOOL_NAME = 'ExecuteTool'`
|
||||
- [x] 验证 prompt 文件正确导出 DESCRIPTION 和 getPrompt
|
||||
- `grep -n 'export' packages/builtin-tools/src/tools/ExecuteTool/prompt.ts`
|
||||
- 预期: 输出包含 `DESCRIPTION` 和 `getPrompt` 的导出
|
||||
- [x] 验证 ExecuteTool 主文件使用 buildTool 构建且 satisfies ToolDef
|
||||
- `grep -n 'buildTool\|satisfies ToolDef' packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts`
|
||||
- 预期: 输出同时包含 `buildTool` 和 `satisfies ToolDef`
|
||||
- [x] 验证 isDeferredTool 正确排除 ExecuteTool
|
||||
- `grep -n 'EXECUTE_TOOL_NAME' packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 输出包含 EXECUTE_TOOL_NAME 的导入和 `isDeferredTool` 中的排除逻辑
|
||||
- [x] 验证单元测试通过
|
||||
- `bun test packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts`
|
||||
- 预期: 所有测试用例通过,无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ToolSearchTool 搜索增强
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 本 Task 在现有 ToolSearchTool 上叠加 TF-IDF 搜索路径、`discover:` 查询模式和文本模式输出,使模型能通过自然语言描述发现延迟工具,并在 `tool_reference` 不可用时仍能获取工具信息。
|
||||
[修改原因] — 当前 ToolSearchTool 仅支持关键词搜索(`searchToolsWithKeywords`),缺少语义匹配能力;`mapToolResultToToolResultBlockParam` 仅返回 `tool_reference` 块,不支持非 Anthropic provider;缺少纯发现模式供模型了解工具能力。
|
||||
[上下游影响] — 本 Task 依赖 Task 1(`isDeferredTool` 白名单制判定)和 Task 2(`buildToolIndex`、`searchTools`、`getToolIndex`)。本 Task 的输出(增强后的 ToolSearchTool)被 Task 5(预取管道)和 Task 6(UI 推荐)间接依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 修改: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 新建: `packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `ToolSearchTool.ts` 中新增 TF-IDF 搜索相关 import — 为并行搜索和结果合并做准备
|
||||
- 位置: `packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` 文件顶部 import 区域(L18 之前,现有 import 块之后)
|
||||
- 新增 import:
|
||||
```typescript
|
||||
import { getToolIndex, searchTools } from 'src/services/toolSearch/toolIndex.js'
|
||||
import type { ToolSearchResult } from 'src/services/toolSearch/toolIndex.js'
|
||||
import { modelSupportsToolReference } from 'src/utils/toolSearch.js'
|
||||
```
|
||||
- 新增权重常量(import 区域之后、`inputSchema` 定义之前):
|
||||
```typescript
|
||||
const KEYWORD_WEIGHT = Number(process.env.TOOL_SEARCH_WEIGHT_KEYWORD ?? '0.4')
|
||||
const TFIDF_WEIGHT = Number(process.env.TOOL_SEARCH_WEIGHT_TFIDF ?? '0.6')
|
||||
```
|
||||
- 原因: TF-IDF 搜索函数和模型能力判断函数分别定义在 `src/` 下,需显式 import。权重常量支持环境变量调优。
|
||||
|
||||
- [x] 在 `ToolSearchTool.ts` 的 `call` 方法中增加 `discover:` 查询模式分支 — 纯发现搜索,不触发延迟加载
|
||||
- 位置: `ToolSearchTool.ts` 的 `call` 方法内,在 `selectMatch` 正则匹配之后(~L363)、关键词搜索之前(~L408)
|
||||
- 在 `selectMatch` 分支之后插入 `discover:` 分支:
|
||||
```typescript
|
||||
// Check for discover: prefix — pure discovery search.
|
||||
// Returns tool info (name + description + schema) as text,
|
||||
// does NOT trigger deferred tool loading.
|
||||
const discoverMatch = query.match(/^discover:(.+)$/i)
|
||||
if (discoverMatch) {
|
||||
const discoverQuery = discoverMatch[1]!.trim()
|
||||
const index = await getToolIndex(deferredTools)
|
||||
const tfIdfResults = searchTools(discoverQuery, index, max_results)
|
||||
// discover 模式返回文本格式的工具信息
|
||||
const textResults = tfIdfResults.map(r => {
|
||||
let line = `**${r.name}** (score: ${r.score.toFixed(2)})\n${r.description}`
|
||||
if (r.inputSchema) {
|
||||
line += `\nSchema: ${JSON.stringify(r.inputSchema)}`
|
||||
}
|
||||
return line
|
||||
})
|
||||
const text = textResults.length > 0
|
||||
? `Found ${textResults.length} tools:\n${textResults.join('\n\n')}`
|
||||
: 'No matching deferred tools found'
|
||||
logSearchOutcome(tfIdfResults.map(r => r.name), 'keyword')
|
||||
return buildSearchResult(tfIdfResults.map(r => r.name), query, deferredTools.length)
|
||||
}
|
||||
```
|
||||
- 更新 `logSearchOutcome` 的 `queryType` 参数: `discover` 模式使用 `'keyword'` 类型(与关键词搜索共用类型,避免修改分析事件的枚举)
|
||||
- 原因: `discover:` 模式让模型能了解延迟工具的能力(名称 + 描述 + schema),而不触发 schema 注入,适用于规划阶段或信息收集场景
|
||||
|
||||
- [x] 在 `ToolSearchTool.ts` 的 `call` 方法中实现关键词搜索与 TF-IDF 搜索的并行执行和结果合并
|
||||
- 位置: `ToolSearchTool.ts` 的 `call` 方法内,替换当前关键词搜索逻辑(L408-L433)
|
||||
- 替换原有关键词搜索段为并行搜索 + 合并逻辑:
|
||||
```typescript
|
||||
// Keyword search + TF-IDF search in parallel
|
||||
const [keywordMatches, index] = await Promise.all([
|
||||
searchToolsWithKeywords(query, deferredTools, tools, max_results),
|
||||
getToolIndex(deferredTools),
|
||||
])
|
||||
const tfIdfResults = searchTools(query, index, max_results)
|
||||
|
||||
// Merge results: keyword score * 0.4 + TF-IDF score * 0.6
|
||||
const mergedScores = new Map<string, number>()
|
||||
// Add keyword results (assign scores inversely proportional to rank)
|
||||
keywordMatches.forEach((name, rank) => {
|
||||
const score = (keywordMatches.length - rank) / keywordMatches.length
|
||||
mergedScores.set(name, (mergedScores.get(name) ?? 0) + score * KEYWORD_WEIGHT)
|
||||
})
|
||||
// Add TF-IDF results
|
||||
tfIdfResults.forEach(result => {
|
||||
mergedScores.set(result.name, (mergedScores.get(result.name) ?? 0) + result.score * TFIDF_WEIGHT)
|
||||
})
|
||||
|
||||
// Sort by merged score, take top-N
|
||||
const matches = [...mergedScores.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, max_results)
|
||||
.map(([name]) => name)
|
||||
```
|
||||
- 保留后续的 `logForDebugging`、`logSearchOutcome`、空结果 pending servers 逻辑和 `buildSearchResult` 调用不变
|
||||
- 原因: 并行执行避免串行延迟;加权合并综合关键词精确匹配和 TF-IDF 语义匹配的优势(TF-IDF 权重更高,因为其语义能力更强)
|
||||
|
||||
- [x] 修改 `mapToolResultToToolResultBlockParam` 方法,增加文本模式输出 — 当 `tool_reference` 不可用时返回文本格式工具信息
|
||||
- 位置: `ToolSearchTool.ts` 的 `mapToolResultToToolResultBlockParam` 方法(L444-L469)
|
||||
- 新增方法参数 `context` 用于获取当前模型信息: 将 `mapToolResultToToolResultBlockParam(content, toolUseID)` 签名改为 `mapToolResultToToolResultBlockParam(content, toolUseID, context?)`,其中 `context` 类型为 `{ mainLoopModel?: string } | undefined`
|
||||
- 在方法体中,`content.matches.length === 0` 分支保持不变
|
||||
- 在返回 `tool_reference` 块之前,插入 `tool_reference` 支持检查:
|
||||
```typescript
|
||||
const supportsToolRef = context?.mainLoopModel
|
||||
? modelSupportsToolReference(context.mainLoopModel)
|
||||
: true // 默认假设支持(向后兼容)
|
||||
if (!supportsToolRef) {
|
||||
// 文本模式: 返回工具名称列表
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseID,
|
||||
content: `Found ${content.matches.length} tool(s): ${content.matches.join(', ')}. Use ExecuteTool with tool_name and params to invoke.`,
|
||||
}
|
||||
}
|
||||
```
|
||||
- 保留原有 `tool_reference` 返回逻辑作为默认路径
|
||||
- 原因: 非 Anthropic provider(OpenAI/Gemini/Grok)不支持 `tool_reference` beta 特性,需要回退到文本模式输出,引导模型使用 ExecuteTool
|
||||
|
||||
- [x] 更新 `ToolSearchTool/prompt.ts` 的 PROMPT 文本,增加 `discover:` 模式和 TF-IDF 搜索说明
|
||||
- 位置: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` 的 `PROMPT_TAIL` 常量(L44-L51)
|
||||
- 在 `Query forms:` 部分追加 `discover:` 模式说明:
|
||||
```typescript
|
||||
const PROMPT_TAIL = ` ... (保留现有内容) ...
|
||||
|
||||
Query forms:
|
||||
- "select:Read,Edit,Grep" — fetch these exact tools by name
|
||||
- "discover:schedule cron job" — pure discovery, returns tool info (name, description, schema) without loading. Use when you want to understand available tools before deciding which to invoke.
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
- 原因: 模型需要知道 `discover:` 模式的存在和语义,才能正确使用该功能
|
||||
|
||||
- [x] 为 ToolSearchTool 搜索增强编写单元测试
|
||||
- 测试文件: `packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts`(新建)
|
||||
- 测试框架: `bun:test`(与 `DiscoverSkillsTool.test.ts` 一致)
|
||||
- 测试场景:
|
||||
- `discover:` 前缀解析: 传入 `query: "discover:send notification"` 调用 `ToolSearchTool.call()`,验证返回结果中 `matches` 非空且包含预期工具名(通过 mock `getToolIndex` 和 `searchTools`)
|
||||
- `select:` 前缀保持不变: 传入 `query: "select:SomeTool"` 调用 `ToolSearchTool.call()`,验证返回结果中 `matches` 包含 `"SomeTool"`(mock `findToolByName` 返回对应工具)
|
||||
- 关键词搜索 + TF-IDF 合并: mock `searchToolsWithKeywords` 返回 `["ToolA", "ToolB"]`,mock `searchTools` 返回 `[{name: "ToolB", score: 0.9}, {name: "ToolC", score: 0.8}]`,验证合并后 `matches` 包含 `"ToolB"`(两路均有)、`"ToolA"`(仅关键词)、`"ToolC"`(仅 TF-IDF),且 `"ToolB"` 排名靠前
|
||||
- 文本模式输出: 调用 `mapToolResultToToolResultBlockParam` 传入 `context: { mainLoopModel: 'claude-3-haiku-20240307' }`,验证返回内容为文本格式(包含 "Found" 和 "ExecuteTool"),而非 `tool_reference` 块
|
||||
- tool_reference 模式输出: 调用 `mapToolResultToToolResultBlockParam` 传入 `context: { mainLoopModel: 'claude-sonnet-4-20250514' }`,验证返回内容包含 `type: 'tool_reference'` 块
|
||||
- 向后兼容: 调用 `mapToolResultToToolResultBlockParam` 不传 `context` 参数,验证默认返回 `tool_reference` 块(向后兼容)
|
||||
- 空结果处理: 传入不匹配的查询,验证返回结果中 `matches` 为空数组
|
||||
- Mock 策略: 使用 `bun:test` 的 `mock` 函数 mock `src/services/toolSearch/toolIndex.js` 的 `getToolIndex` 和 `searchTools`,mock `src/utils/toolSearch.js` 的 `modelSupportsToolReference`
|
||||
- 运行命令: `bun test packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 TF-IDF 搜索 import 已添加
|
||||
- `grep -n "getToolIndex\|searchTools\|modelSupportsToolReference" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含 `getToolIndex`、`searchTools`、`modelSupportsToolReference` 的 import 行
|
||||
|
||||
- [x] 验证 `discover:` 模式分支已添加到 `call` 方法
|
||||
- `grep -n "discoverMatch\|discover:" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含 `discoverMatch` 正则匹配和 `discover:` 分支逻辑
|
||||
|
||||
- [x] 验证关键词搜索与 TF-IDF 搜索并行执行
|
||||
- `grep -n "Promise.all" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含 `Promise.all` 调用,参数包含 `searchToolsWithKeywords` 和 `getToolIndex`
|
||||
|
||||
- [x] 验证结果合并逻辑使用加权求和
|
||||
- `grep -n "KEYWORD_WEIGHT\|TFIDF_WEIGHT" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含权重常量定义和在合并逻辑中的使用
|
||||
|
||||
- [x] 验证 `mapToolResultToToolResultBlockParam` 增加了文本模式分支
|
||||
- `grep -n "supportsToolRef\|ExecuteTool" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含 `modelSupportsToolReference` 调用和 "ExecuteTool" 文本回退
|
||||
|
||||
- [x] 验证 prompt.ts 包含 `discover:` 模式说明
|
||||
- `grep -n "discover:" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 输出包含 `discover:` 模式的文档说明
|
||||
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
- 预期: 无新增类型错误
|
||||
|
||||
- [x] 运行新增单元测试
|
||||
- `bun test packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `ToolSearchTool.mapToolResultToToolResultBlockParam` 新增可选第三个参数 `context?: { mainLoopModel?: string }`,用于判断当前模型是否支持 `tool_reference`。不支持时回退到文本输出,引导模型使用 ExecuteTool。调用方(`src/services/api/claude.ts` 的 tool_result 处理逻辑)需传入 context 参数。
|
||||
|
||||
### Task 5: 基础设施层验收
|
||||
|
||||
**前置条件:**
|
||||
- Task 1-4 全部完成
|
||||
- 构建环境: `bun run build` 可用
|
||||
|
||||
**端到端验证:**
|
||||
|
||||
1. ✅ 运行完整测试套件确保无回归
|
||||
- `bun test src/constants/__tests__/tools.test.ts src/services/toolSearch/__tests__/toolIndex.test.ts packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts packages/builtin-tools/src/tools/ToolSearchTool/__tests__/DiscoverSearch.test.ts 2>&1`
|
||||
- 预期: 全部测试通过
|
||||
- 失败排查: 检查各 Task 的测试步骤,确认 import 路径和 mock 配置正确
|
||||
|
||||
2. ✅ 验证 TypeScript 类型检查通过
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | grep -i "error" | head -20`
|
||||
- 预期: 无新增类型错误
|
||||
- 失败排查: 检查 Task 1-4 中新增/修改文件的 import 路径和类型签名
|
||||
|
||||
3. ✅ 验证 CORE_TOOLS 常量被正确使用
|
||||
- `grep -rn "CORE_TOOLS" src/ packages/builtin-tools/src/ --include="*.ts" 2>/dev/null`
|
||||
- 预期: 在 `tools.ts`、`prompt.ts`(isDeferredTool)、`toolIndex.ts` 中被引用
|
||||
- 失败排查: 检查 Task 1 和 Task 2 的 import 步骤
|
||||
|
||||
4. ✅ 验证 isDeferredTool 白名单制生效
|
||||
- `grep -A5 "export function isDeferredTool" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 函数体包含 `CORE_TOOLS.has(tool.name)`,不包含旧的 `shouldDefer`、`feature(` 逻辑
|
||||
- 失败排查: 检查 Task 1 的重构步骤
|
||||
|
||||
5. ✅ 验证构建产物正确
|
||||
- `bun run build 2>&1 | tail -5`
|
||||
- 预期: 构建成功,输出 dist/cli.js
|
||||
- 失败排查: 检查新增文件的 import 路径是否兼容 Bun.build splitting
|
||||
587
spec/feature_20260508_F001_tool-search/spec-plan-2.md
Normal file
587
spec/feature_20260508_F001_tool-search/spec-plan-2.md
Normal file
@@ -0,0 +1,587 @@
|
||||
# Tool Search 执行计划(二)— 集成层
|
||||
|
||||
**目标:** 将基础设施层的组件集成到系统中——系统提示词增强、工具注册、预取管道、用户推荐 UI
|
||||
|
||||
**技术栈:** TypeScript, React (Ink), Bun, Zod
|
||||
|
||||
**设计文档:** spec/feature_20260508_F001_tool-search/spec-design.md
|
||||
|
||||
**前置:** spec-plan-1.md(Task 1-4)已完成
|
||||
|
||||
## 改动总览
|
||||
|
||||
- 在系统提示词添加 ToolSearch + ExecuteTool 引导指令,tools.ts 注册 ExecuteTool,toolSearch.ts 更新过时注释;新建预取管道 prefetch.ts 集成到 attachments.ts 和 query.ts(复用 skill prefetch 模式);新建 ToolSearchHint.tsx Ink 组件集成到 REPL
|
||||
- Task 5(系统提示词与注册)是 Task 6/7 的前置;Task 6(预取管道)被 Task 7(UI)依赖
|
||||
- 关键决策:预取管道完全复用 skill prefetch 的触发/消费模式;UI 组件参考 PluginHintMenu 模式
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 环境准备(轻量)
|
||||
|
||||
**背景:**
|
||||
Plan 1 的环境验证已完成,此处仅需确认 Plan 1 的产出文件可用。
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 确认 Plan 1 产出文件存在
|
||||
- `ls src/constants/tools.ts src/services/toolSearch/toolIndex.ts packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts 2>&1`
|
||||
- 预期: 所有文件存在
|
||||
|
||||
**检查步骤:**
|
||||
- [x] Plan 1 核心常量可被引用
|
||||
- `grep "CORE_TOOLS" src/constants/tools.ts | head -3`
|
||||
- 预期: 输出包含 CORE_TOOLS 定义
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 系统提示词与工具注册
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 本 Task 将 Task 3 创建的 ExecuteTool 注册到系统工具池中,并在系统提示词中添加 ToolSearch + ExecuteTool 的使用引导,确保模型知道如何发现和调用延迟工具。
|
||||
[修改原因] — 当前系统提示词(L192)仅提到"延迟工具必须通过 ToolSearch 或 DiscoverSkills 加载",缺少 ExecuteTool 的引导。`src/tools.ts` 的 `getAllBaseTools()` 中未注册 ExecuteTool。`src/utils/toolSearch.ts` 的 `isToolSearchEnabled()` 和 `isToolSearchEnabledOptimistic()` 内部已通过 `isDeferredTool` 间接使用 `CORE_TOOLS`(Task 1 重构后),需确认无遗留的 `shouldDefer` 直接引用。
|
||||
[上下游影响] — 本 Task 依赖 Task 1(`CORE_TOOLS`、`isDeferredTool` 白名单制)和 Task 3(ExecuteTool 工具包创建完成)。本 Task 的输出被 Task 6(预取管道)和 Task 7(用户推荐 UI)依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/constants/prompts.ts`
|
||||
- 修改: `src/tools.ts`
|
||||
- 修改: `src/utils/toolSearch.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `src/constants/prompts.ts` 中添加 ToolSearch + ExecuteTool 引导指令到系统提示词
|
||||
- 位置: `src/constants/prompts.ts` 的 `getSimpleSystemSection()` 函数内,在 L192 的延迟工具说明条目之后
|
||||
- 当前 L192 内容为:
|
||||
```
|
||||
`Your visible tool list is partial by design — many tools (deferred tools, skills, MCP resources) must be loaded via ToolSearch or DiscoverSkills before you can call them. Before telling the user that a capability is unavailable, search for a tool or skill that covers it. Only state something is unavailable after the search returns no match.`,
|
||||
```
|
||||
- 在此条目之后(L193 之前)插入新条目:
|
||||
```typescript
|
||||
`When you need a capability that isn't in your available tools, use ToolSearch to discover and load it. ToolSearch can find all deferred tools by keyword or task description. After discovering a tool, use ExecuteTool to invoke it with the appropriate parameters. Common deferred tools include: CronTools (scheduling), WorktreeTools (git isolation), SnipTool (context management), DiscoverSkills (skill search), MCP resource tools, and many more. Always search first rather than assuming a capability is unavailable.`,
|
||||
```
|
||||
- 在文件顶部 import 区域新增:
|
||||
```typescript
|
||||
import { EXECUTE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExecuteTool/constants.js'
|
||||
```
|
||||
- 注意: `TOOL_SEARCH_TOOL_NAME` 已通过 `src/constants/tools.ts` 的 import 链路导入(L25 `import { TOOL_SEARCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'`),无需重复导入。但需在 `prompts.ts` 中新增 `EXECUTE_TOOL_NAME` 的 import(当前文件中无此 import,经 grep 确认)。
|
||||
- 原因: 模型需要明确知道 ExecuteTool 的存在和用法,否则发现延迟工具后不知道如何调用
|
||||
|
||||
- [x] 在 `src/tools.ts` 的 `getAllBaseTools()` 中注册 ExecuteTool
|
||||
- 位置: `src/tools.ts` 的 `getAllBaseTools()` 函数内,在 L272 `...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : [])` 之后
|
||||
- 在文件顶部 import 区域(L84 附近,ToolSearchTool import 之后)新增:
|
||||
```typescript
|
||||
import { ExecuteTool } from '@claude-code-best/builtin-tools/tools/ExecuteTool/ExecuteTool.js'
|
||||
```
|
||||
- 将 L272:
|
||||
```typescript
|
||||
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
|
||||
```
|
||||
- 修改为:
|
||||
```typescript
|
||||
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool, ExecuteTool] : []),
|
||||
```
|
||||
- 原因: ExecuteTool 与 ToolSearchTool 联动启用,在相同条件块中注册确保两者同时可用或同时不可用
|
||||
|
||||
- [x] 在 `src/utils/toolSearch.ts` 中更新模块文档注释,移除过时的 `shouldDefer` 引用
|
||||
- 位置: `src/utils/toolSearch.ts` 文件顶部模块文档注释(L1-L7)
|
||||
- 当前 L4 内容为:
|
||||
```
|
||||
`
|
||||
* When enabled, deferred tools (MCP and shouldDefer tools) are sent with
|
||||
* defer_loading: true and discovered via ToolSearchTool rather than being
|
||||
* loaded upfront.
|
||||
```
|
||||
- 修改为:
|
||||
```
|
||||
`
|
||||
* When enabled, deferred tools (all non-core tools) are sent with
|
||||
* defer_loading: true and discovered via ToolSearchTool rather than being
|
||||
* loaded upfront. Core tools are defined in CORE_TOOLS (src/constants/tools.ts).
|
||||
```
|
||||
- 位置: `src/utils/toolSearch.ts` 的 `ToolSearchMode` 类型文档注释(L155-L156)
|
||||
- 当前内容为:
|
||||
```
|
||||
`
|
||||
* Tool search mode. Determines how deferrable tools (MCP + shouldDefer) are
|
||||
* surfaced:
|
||||
```
|
||||
- 修改为:
|
||||
```
|
||||
`
|
||||
* Tool search mode. Determines how deferred tools (all non-core tools)
|
||||
* are surfaced:
|
||||
```
|
||||
- 位置: `src/utils/toolSearch.ts` 的 `getToolSearchMode()` 函数文档注释(L170)
|
||||
- 当前内容为:
|
||||
```
|
||||
`
|
||||
* (unset) tst (default: always defer MCP and shouldDefer tools)
|
||||
```
|
||||
- 修改为:
|
||||
```
|
||||
`
|
||||
* (unset) tst (default: always defer non-core tools)
|
||||
```
|
||||
- 位置: `src/utils/toolSearch.ts` 的 `getToolSearchMode()` 函数末尾 return 注释(L197)
|
||||
- 当前内容为:
|
||||
```typescript
|
||||
return 'tst' // default: always defer MCP and shouldDefer tools
|
||||
```
|
||||
- 修改为:
|
||||
```typescript
|
||||
return 'tst' // default: always defer non-core tools
|
||||
```
|
||||
- 注意: `shouldDefer` 在此文件中仅出现在注释中(L4, L155, L170, L197),无任何运行时引用。`isDeferredTool` 函数从 `@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js` 导入(L24),Task 1 已将其重构为白名单制,此处无需修改函数调用。
|
||||
- 原因: Task 1 将 `isDeferredTool` 重构为白名单制后,`shouldDefer` 概念已过时。更新注释保持文档与实现一致。
|
||||
|
||||
- [x] 为 Task 5 的三个修改点编写单元测试
|
||||
- 测试文件: `src/__tests__/toolSearchIntegration.test.ts`(新建)
|
||||
- 测试场景:
|
||||
- `getSystemPrompt` 包含 ExecuteTool 引导: 调用 `getSystemPrompt(mockTools, model)` 后,结果字符串中包含 "ExecuteTool" 和 "ToolSearch" 关键词
|
||||
- `getAllBaseTools` 包含 ExecuteTool 当 tool search 启用时: mock `isToolSearchEnabledOptimistic` 返回 `true`,验证 `getAllBaseTools()` 返回的工具列表中包含 `name: 'ExecuteTool'` 的工具
|
||||
- `getAllBaseTools` 不包含 ExecuteTool 当 tool search 禁用时: mock `isToolSearchEnabledOptimistic` 返回 `false`,验证 `getAllBaseTools()` 返回的工具列表中不包含 `name: 'ExecuteTool'` 的工具
|
||||
- `getAllBaseTools` 中 ExecuteTool 紧随 ToolSearchTool: 验证在 tool search 启用时,ExecuteTool 在工具列表中的位置紧跟 ToolSearchTool
|
||||
- Mock 策略: 使用 `bun:test` 的 `mock` 函数 mock `src/utils/toolSearch.js` 的 `isToolSearchEnabledOptimistic`
|
||||
- 运行命令: `bun test src/__tests__/toolSearchIntegration.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证系统提示词包含 ExecuteTool 引导
|
||||
- `grep -n "ExecuteTool" src/constants/prompts.ts`
|
||||
- 预期: 至少 2 行(import + 引导文本)
|
||||
|
||||
- [x] 验证 ExecuteTool 已注册到 getAllBaseTools
|
||||
- `grep -n "ExecuteTool" src/tools.ts`
|
||||
- 预期: 至少 2 行(import + 注册)
|
||||
|
||||
- [x] 验证 ExecuteTool 与 ToolSearchTool 在同一条件块中注册
|
||||
- `grep -A1 "isToolSearchEnabledOptimistic" src/tools.ts | grep -c "ExecuteTool"`
|
||||
- 预期: 输出 1(ExecuteTool 在 isToolSearchEnabledOptimistic 条件块中)
|
||||
|
||||
- [x] 验证 toolSearch.ts 中无运行时 shouldDefer 引用(仅注释)
|
||||
- `grep -n "shouldDefer" src/utils/toolSearch.ts`
|
||||
- 预期: 无输出或仅在注释中出现
|
||||
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
- 预期: 无新增类型错误
|
||||
|
||||
- [x] 运行新增单元测试
|
||||
- `bun test src/__tests__/toolSearchIntegration.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
- [x] 验证现有 tools.test.ts 未回归
|
||||
- `bun test src/__tests__/tools.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 预取管道
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 本 Task 实现工具搜索预取管道,在用户输入后异步触发 TF-IDF 工具搜索,将推荐结果以 attachment 消息注入 API 请求,使模型在每轮对话中自动获得最相关的延迟工具提示。
|
||||
[修改原因] — 当前项目仅实现了 skill 搜索的预取管道(`skillSearch/prefetch.ts`),缺少工具维度的预取。工具预取需复用 skill prefetch 的集成模式(turn-0 阻塞式 + inter-turn 异步式),但使用独立的 attachment type(`tool_discovery`)和独立的搜索函数(`toolIndex.searchTools`)。
|
||||
[上下游影响] — 本 Task 依赖 Task 2(`toolIndex.ts` 的 `getToolIndex` 和 `searchTools`)。本 Task 的输出(`prefetch.ts` 模块和集成代码)被 Task 7(用户推荐 UI)间接依赖,UI 组件需要消费预取结果来渲染推荐提示条。
|
||||
|
||||
**涉及文件:**
|
||||
- 新建: `src/services/toolSearch/prefetch.ts`
|
||||
- 修改: `src/utils/attachments.ts`
|
||||
- 修改: `src/query.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 新建 `src/services/toolSearch/prefetch.ts`,定义 `ToolDiscoveryResult` 类型和 `tool_discovery` attachment 构建函数
|
||||
- 位置: 新建文件 `src/services/toolSearch/prefetch.ts`,文件开头
|
||||
- 导入依赖:
|
||||
```typescript
|
||||
import type { Attachment } from '../../utils/attachments.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import type { Tool } from '../../Tool.js'
|
||||
import { getToolIndex, searchTools } from './toolIndex.js'
|
||||
import type { ToolSearchResult } from './toolIndex.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
```
|
||||
- 定义 `ToolDiscoveryResult` 类型:
|
||||
```typescript
|
||||
export type ToolDiscoveryResult = {
|
||||
name: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
score: number
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
}
|
||||
```
|
||||
- 定义 `buildToolDiscoveryAttachment` 函数:
|
||||
```typescript
|
||||
function buildToolDiscoveryAttachment(
|
||||
tools: ToolDiscoveryResult[],
|
||||
trigger: 'assistant_turn' | 'user_input',
|
||||
queryText: string,
|
||||
durationMs: number,
|
||||
indexSize: number,
|
||||
): Attachment {
|
||||
return {
|
||||
type: 'tool_discovery',
|
||||
tools,
|
||||
trigger,
|
||||
queryText: queryText.slice(0, 200),
|
||||
durationMs,
|
||||
indexSize,
|
||||
} as Attachment
|
||||
}
|
||||
```
|
||||
- 原因: `tool_discovery` 作为独立的 attachment type 与 `skill_discovery` 并列,数据结构不同(工具无 `shortId`/`autoLoaded`/`content`/`path`/`gap`,增加 `searchHint`/`isMcp`/`isDeferred`/`inputSchema`),不能复用 `skill_discovery` 类型
|
||||
|
||||
- [x] 实现 `startToolSearchPrefetch` 异步预取函数 — inter-turn 场景,在 query loop 中异步触发
|
||||
- 位置: `src/services/toolSearch/prefetch.ts`,在 `buildToolDiscoveryAttachment` 之后
|
||||
- 函数签名:
|
||||
```typescript
|
||||
export async function startToolSearchPrefetch(
|
||||
tools: Tool[],
|
||||
messages: Message[],
|
||||
): Promise<Attachment[]>
|
||||
```
|
||||
- 核心逻辑(参照 `skillSearch/prefetch.ts:startSkillDiscoveryPrefetch` L249-296 的模式):
|
||||
1. 调用 `extractQueryFromMessages(null, messages)` 提取用户查询文本(复用 `skillSearch/prefetch.ts` 导出的 `extractQueryFromMessages` 函数,该函数已导出且逻辑通用)
|
||||
2. `queryText` 为空时返回 `[]`
|
||||
3. 记录 `startedAt = Date.now()`
|
||||
4. 调用 `getToolIndex(tools)` 获取缓存的工具索引
|
||||
5. 调用 `searchTools(queryText, index, 3)` 搜索 top-3 工具(预取场景限制 3 条,减少 token 开销)
|
||||
6. 过滤会话内已发现的工具(定义模块级 `discoveredToolsThisSession: Set<string>`,与 skill prefetch 的 `discoveredThisSession` 独立)
|
||||
7. 结果为空时返回 `[]`
|
||||
8. 记录 `logForDebugging` 日志
|
||||
9. 返回 `[buildToolDiscoveryAttachment(filteredResults, 'assistant_turn', queryText, durationMs, index.length)]`
|
||||
10. catch 块返回 `[]`(fire-and-forget,不向上传播错误)
|
||||
- 原因: 异步预取不阻塞主流程,与 skill prefetch 保持一致的错误处理策略(静默失败)
|
||||
|
||||
- [x] 实现 `getTurnZeroToolSearchPrefetch` 同步获取函数 — turn-0 场景,用户首次输入时阻塞式获取
|
||||
- 位置: `src/services/toolSearch/prefetch.ts`,在 `startToolSearchPrefetch` 之后
|
||||
- 函数签名:
|
||||
```typescript
|
||||
export async function getTurnZeroToolSearchPrefetch(
|
||||
input: string,
|
||||
tools: Tool[],
|
||||
): Promise<Attachment | null>
|
||||
```
|
||||
- 核心逻辑(参照 `skillSearch/prefetch.ts:getTurnZeroSkillDiscovery` L308-356 的模式):
|
||||
1. `input` 为空时返回 `null`
|
||||
2. 记录 `startedAt = Date.now()`
|
||||
3. 调用 `getToolIndex(tools)` 获取工具索引
|
||||
4. 调用 `searchTools(input, index, 3)` 搜索 top-3 工具
|
||||
5. 结果为空时返回 `null`
|
||||
6. 将结果工具名加入 `discoveredToolsThisSession`
|
||||
7. 记录 `logForDebugging` 日志
|
||||
8. 返回 `buildToolDiscoveryAttachment(results, 'user_input', input, durationMs, index.length)`
|
||||
9. catch 块返回 `null`
|
||||
- 原因: turn-0 是唯一的阻塞式入口,因为此时没有其他计算可以隐藏预取延迟。与 skill prefetch 保持一致的设计
|
||||
|
||||
- [x] 实现 `collectToolSearchPrefetch` 结果收集函数 — 等待异步预取完成并收集结果
|
||||
- 位置: `src/services/toolSearch/prefetch.ts`,在 `getTurnZeroToolSearchPrefetch` 之后
|
||||
- 函数签名:
|
||||
```typescript
|
||||
export async function collectToolSearchPrefetch(
|
||||
pending: Promise<Attachment[]>,
|
||||
): Promise<Attachment[]>
|
||||
```
|
||||
- 核心逻辑(与 `skillSearch/prefetch.ts:collectSkillDiscoveryPrefetch` L298-306 完全一致):
|
||||
```typescript
|
||||
try {
|
||||
return await pending
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
```
|
||||
- 原因: 包装 Promise,确保预取失败时返回空数组而非抛出异常
|
||||
|
||||
- [x] 在 `src/utils/attachments.ts` 中注册 `tool_discovery` attachment type — 扩展 Attachment 联合类型
|
||||
- 位置: `src/utils/attachments.ts` 的 `Attachment` 类型定义中,在 `skill_discovery` 类型分支(L534-L555)之后
|
||||
- 新增 import(文件顶部 import 区域):
|
||||
```typescript
|
||||
import type { ToolDiscoveryResult } from '../services/toolSearch/prefetch.js'
|
||||
```
|
||||
- 在 `skill_discovery` 分支后追加 `tool_discovery` 类型:
|
||||
```typescript
|
||||
| {
|
||||
type: 'tool_discovery'
|
||||
tools: ToolDiscoveryResult[]
|
||||
trigger: 'assistant_turn' | 'user_input'
|
||||
queryText: string
|
||||
durationMs: number
|
||||
indexSize: number
|
||||
}
|
||||
```
|
||||
- 原因: `createAttachmentMessage` 接收 `Attachment` 类型参数,必须将 `tool_discovery` 注册到联合类型中才能通过类型检查
|
||||
|
||||
- [x] 在 `src/utils/attachments.ts` 中集成 turn-0 工具预取 — 在 skill discovery 附件之后添加 tool discovery 附件
|
||||
- 位置: `src/utils/attachments.ts` 的 `getAttachmentMessages` 函数中,在 skill discovery 的 `maybe('skill_discovery', ...)` 调用块(L818-L831)之后
|
||||
- 新增条件 require 模块(与 `skillSearchModules` 模式一致,在文件顶部 ~L92 `skillSearchModules` 定义之后):
|
||||
```typescript
|
||||
const toolSearchModules = feature('EXPERIMENTAL_TOOL_SEARCH')
|
||||
? {
|
||||
prefetch:
|
||||
require('../services/toolSearch/prefetch.js') as typeof import('../services/toolSearch/prefetch.js'),
|
||||
}
|
||||
: null
|
||||
```
|
||||
- 在 skill discovery 的 spread 数组中追加 tool discovery 附件(在 `]` 闭合 `maybe('skill_discovery', ...)` 之后,在外层 spread `...(feature('EXPERIMENTAL_SKILL_SEARCH') &&` 的 `]` 之前):
|
||||
```typescript
|
||||
...(feature('EXPERIMENTAL_TOOL_SEARCH') &&
|
||||
toolSearchModules &&
|
||||
!options?.skipSkillDiscovery
|
||||
? [
|
||||
maybe('tool_discovery', async () => {
|
||||
if (suppressNextDiscovery) {
|
||||
return []
|
||||
}
|
||||
const result =
|
||||
await toolSearchModules.prefetch.getTurnZeroToolSearchPrefetch(
|
||||
input,
|
||||
context.options.tools ?? [],
|
||||
)
|
||||
return result ? [result] : []
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
```
|
||||
- 注意: `suppressNextDiscovery` 与 skill discovery 共用同一个标志(skill expansion 路径不应触发工具发现,语义一致)
|
||||
- 原因: turn-0 预取与 skill discovery 共享同一集成点(`getAttachmentMessages`),两者互不干扰,各自生成独立 attachment
|
||||
|
||||
- [x] 在 `src/query.ts` 中集成 inter-turn 工具预取触发 — 在 skill prefetch 之后异步启动工具预取
|
||||
- 位置: `src/query.ts` 文件顶部 conditional require 区域(~L68-70 `skillPrefetch` 定义之后)
|
||||
- 新增 conditional require:
|
||||
```typescript
|
||||
const toolSearchPrefetch = feature('EXPERIMENTAL_TOOL_SEARCH')
|
||||
? (require('./services/toolSearch/prefetch.js') as typeof import('./services/toolSearch/prefetch.js'))
|
||||
: null
|
||||
```
|
||||
- 位置: `src/query.ts` 的 `queryLoop` 函数中,在 `pendingSkillPrefetch` 定义(L480-484)之后
|
||||
- 新增工具预取触发:
|
||||
```typescript
|
||||
const pendingToolPrefetch = toolSearchPrefetch?.startToolSearchPrefetch(
|
||||
state.tools ?? [],
|
||||
messages,
|
||||
)
|
||||
```
|
||||
- 原因: 与 skill prefetch 保持相同的触发时机(每轮迭代开始时异步启动),两者并行执行互不阻塞
|
||||
|
||||
- [x] 在 `src/query.ts` 中集成工具预取结果消费 — 在 skill prefetch 收集之后收集工具预取结果
|
||||
- 位置: `src/query.ts` 的 `queryLoop` 函数中,在 skill prefetch 结果消费块(L1910-L1918)之后
|
||||
- 新增工具预取结果消费:
|
||||
```typescript
|
||||
if (toolSearchPrefetch && pendingToolPrefetch) {
|
||||
const toolAttachments =
|
||||
await toolSearchPrefetch.collectToolSearchPrefetch(pendingToolPrefetch)
|
||||
for (const att of toolAttachments) {
|
||||
const msg = createAttachmentMessage(att)
|
||||
yield msg
|
||||
toolResults.push(msg)
|
||||
}
|
||||
}
|
||||
```
|
||||
- 原因: 与 skill prefetch 结果消费保持一致的位置和模式(post-tools 阶段注入),确保预取结果在本轮工具执行完成后、下一轮模型调用前注入
|
||||
|
||||
- [x] 为 `prefetch.ts` 核心逻辑编写单元测试
|
||||
- 测试文件: `src/services/toolSearch/__tests__/prefetch.test.ts`(新建)
|
||||
- 测试框架: `bun:test`
|
||||
- 测试场景:
|
||||
- `startToolSearchPrefetch` — 正常调用: 构造 mock Tool 数组和 mock messages,mock `getToolIndex` 返回固定索引,mock `searchTools` 返回匹配结果,验证返回的 `Attachment[]` 包含 `type: 'tool_discovery'` 且 `tools` 非空、`trigger` 为 `'assistant_turn'`
|
||||
- `startToolSearchPrefetch` — 空查询: messages 中无用户文本内容,验证返回空数组
|
||||
- `startToolSearchPrefetch` — 无匹配: `searchTools` 返回空数组,验证返回空数组
|
||||
- `startToolSearchPrefetch` — 异常安全: mock `getToolIndex` 抛出异常,验证返回空数组(不抛出)
|
||||
- `startToolSearchPrefetch` — 会话去重: 连续两次调用传入相同工具名,第二次返回空数组(已被 `discoveredToolsThisSession` 过滤)
|
||||
- `getTurnZeroToolSearchPrefetch` — 正常调用: 传入有效 input 和 mock tools,验证返回非 null 的 `Attachment`,`trigger` 为 `'user_input'`
|
||||
- `getTurnZeroToolSearchPrefetch` — 空输入: 传入空字符串,验证返回 null
|
||||
- `getTurnZeroToolSearchPrefetch` — 无匹配: `searchTools` 返回空数组,验证返回 null
|
||||
- `collectToolSearchPrefetch` — 正常收集: 传入 resolved promise,验证返回对应 attachment 数组
|
||||
- `collectToolSearchPrefetch` — 异常安全: 传入 rejected promise,验证返回空数组
|
||||
- `buildToolDiscoveryAttachment` — 返回的 attachment 对象包含 `type: 'tool_discovery'`、`tools`、`trigger`、`queryText`、`durationMs`、`indexSize` 字段
|
||||
- Mock 策略: 使用 `bun:test` 的 `mock` 函数 mock `./toolIndex.js` 的 `getToolIndex` 和 `searchTools`;构造 `Partial<Tool>` 类型的 mock Tool 对象;构造包含 `{ type: 'user', content: 'test query' }` 的 mock Message 数组
|
||||
- 运行命令: `bun test src/services/toolSearch/__tests__/prefetch.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `prefetch.ts` 文件存在且导出正确
|
||||
- `grep -c "export async function\|export type" src/services/toolSearch/prefetch.ts`
|
||||
- 预期: 至少 5(startToolSearchPrefetch, getTurnZeroToolSearchPrefetch, collectToolSearchPrefetch, ToolDiscoveryResult, extractQueryFromMessages import)
|
||||
|
||||
- [x] 验证 `tool_discovery` 类型已注册到 Attachment 联合类型
|
||||
- `grep -n "tool_discovery" src/utils/attachments.ts`
|
||||
- 预期: 至少 2 行(类型定义 + maybe 调用)
|
||||
|
||||
- [x] 验证 `query.ts` 中工具预取触发和消费代码已添加
|
||||
- `grep -n "toolSearchPrefetch\|pendingToolPrefetch\|collectToolSearchPrefetch" src/query.ts`
|
||||
- 预期: 至少 6 行(conditional require + start 调用 + if + collect 调用 + yield)
|
||||
|
||||
- [x] 验证 `attachments.ts` 中 turn-0 工具预取已集成
|
||||
- `grep -n "getTurnZeroToolSearchPrefetch\|toolSearchModules" src/utils/attachments.ts`
|
||||
- 预期: 至少 3 行(conditional require + getTurnZero 调用 + toolSearchModules 使用)
|
||||
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
- 预期: 无新增类型错误
|
||||
|
||||
- [x] 验证单元测试通过
|
||||
- `bun test src/services/toolSearch/__tests__/prefetch.test.ts 2>&1 | tail -10`
|
||||
- 预期: 输出包含 "pass" 且无 "fail"
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `src/services/toolSearch/prefetch.ts` 的 `extractQueryFromMessages` 复用了 `src/services/skillSearch/prefetch.ts` 的同名导出函数。修改 `skillSearch/prefetch.ts` 的 `extractQueryFromMessages` 时需同步检查工具预取的行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 用户推荐 UI
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 在 REPL 输入区域上方渲染工具推荐提示条,帮助用户了解哪些工具适合当前任务,提升工具发现体验
|
||||
[修改原因] — 当前缺少面向用户的工具推荐可视化,预取管道(Task 6)产出的匹配结果无法被用户感知
|
||||
[上下游影响] — 本 Task 消费 Task 6 `collectToolSearchPrefetch()` 的预取结果数据;本 Task 的组件挂载到 REPL.tsx 的对话框优先级系统中
|
||||
|
||||
**涉及文件:**
|
||||
- 新建: `src/components/ToolSearchHint.tsx`
|
||||
- 新建: `src/components/__tests__/ToolSearchHint.test.ts`
|
||||
- 修改: `src/screens/REPL.tsx`
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 新建 `src/components/ToolSearchHint.tsx` — Ink 组件,渲染工具推荐提示条
|
||||
- 位置: 新建文件,参照 `src/components/ClaudeCodeHint/PluginHintMenu.tsx` 的结构模式
|
||||
- 组件签名:
|
||||
```typescript
|
||||
type ToolSearchHintItem = {
|
||||
name: string;
|
||||
description: string;
|
||||
score: number;
|
||||
};
|
||||
type Props = {
|
||||
tools: ToolSearchHintItem[];
|
||||
onSelect: (toolName: string) => void;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
export function ToolSearchHint({ tools, onSelect, onDismiss }: Props): React.ReactNode;
|
||||
```
|
||||
- 使用 `PermissionDialog`(从 `src/components/permissions/PermissionDialog.js`)作为外层容器,title 设为 `"Tool Recommendation"`
|
||||
- 使用 `Select`(从 `src/components/CustomSelect/select.js`)渲染可选工具列表,每个选项格式为: `<工具名> — <描述截断至 60 字符> (score: 0.XX)`
|
||||
- 额外增加一个 "Dismiss" 选项(value: `'dismiss'`),排在选项列表末尾
|
||||
- `onSelect` 回调: 当用户选中某个工具时调用 `onDismiss()` 清除推荐,并调用 `onSelect(toolName)` 将工具名传递给 REPL 层追加到用户消息上下文
|
||||
- 30 秒自动 dismiss(复用 `PluginHintMenu` 的 `AUTO_DISMISS_MS = 30_000` 模式),通过 `setTimeout` + `useRef` 实现,超时调用 `onDismiss()`
|
||||
- `useEffect` 清理函数中 `clearTimeout` 防止内存泄漏
|
||||
- 原因: 遵循现有 UI 提示集成模式(PluginHintMenu),保证交互风格一致
|
||||
|
||||
- [x] 新建 `src/hooks/useToolSearchHint.ts` — 自定义 Hook,管理工具推荐状态与生命周期
|
||||
- 位置: 新建文件,参照 `src/hooks/useClaudeCodeHintRecommendation.tsx` 的状态管理模式
|
||||
- Hook 签名:
|
||||
```typescript
|
||||
type ToolSearchHintResult = {
|
||||
tools: ToolSearchHintItem[];
|
||||
visible: boolean;
|
||||
handleSelect: (toolName: string) => void;
|
||||
handleDismiss: () => void;
|
||||
};
|
||||
export function useToolSearchHint(): ToolSearchHintResult;
|
||||
```
|
||||
- 内部使用 `React.useSyncExternalStore` 订阅预取结果(从 Task 6 的 `src/services/toolSearch/prefetch.ts` 中导出的模块级缓存),subscribe 函数和 getSnapshot 函数从 prefetch 模块获取
|
||||
- `tools` 字段: 从预取结果中提取前 3 个工具,每个工具包含 `name`、`description`(截断至 60 字符)、`score`
|
||||
- `visible` 字段: 当 `tools` 非空且最高 score >= 0.15 时为 true
|
||||
- `handleSelect`: 记录用户选择(analytics 事件 `tengu_tool_search_hint_select`),然后清除推荐状态
|
||||
- `handleDismiss`: 记录 dismiss 事件(analytics 事件 `tengu_tool_search_hint_dismiss`),清除推荐状态
|
||||
- 清除推荐状态时调用 prefetch 模块的清除函数(`clearToolSearchPrefetchResults()`,由 Task 6 提供)
|
||||
- 原因: 将状态管理与 UI 渲染解耦,遵循现有 hook 模式(useClaudeCodeHintRecommendation)
|
||||
|
||||
- [x] 修改 `src/screens/REPL.tsx` — 集成 ToolSearchHint 组件到对话框优先级系统
|
||||
- 位置: `getFocusedInputDialog()` 函数(~L2377),在返回类型联合中新增 `'tool-search-hint'`
|
||||
- 在 `getFocusedInputDialog()` 函数体中,在 `plugin-hint` 判断(~L2446)之后、`desktop-upsell` 判断(~L2449)之前,新增一个优先级分支:
|
||||
```typescript
|
||||
if (allowDialogsWithAnimation && toolSearchHint.visible) return 'tool-search-hint';
|
||||
```
|
||||
- 位置: 文件顶部 import 区域(~L448,`PluginHintMenu` import 附近),新增 import:
|
||||
```typescript
|
||||
import { ToolSearchHint } from '../components/ToolSearchHint.js';
|
||||
import { useToolSearchHint } from '../hooks/useToolSearchHint.js';
|
||||
```
|
||||
- 位置: hook 调用区域(~L1038,`useClaudeCodeHintRecommendation` 调用之后),新增:
|
||||
```typescript
|
||||
const toolSearchHint = useToolSearchHint();
|
||||
```
|
||||
- 位置: JSX 渲染区域(~L6174,`PluginHintMenu` 渲染块之后),新增条件渲染块:
|
||||
```tsx
|
||||
{focusedInputDialog === 'tool-search-hint' && toolSearchHint.visible && (
|
||||
<ToolSearchHint
|
||||
tools={toolSearchHint.tools}
|
||||
onSelect={toolSearchHint.handleSelect}
|
||||
onDismiss={toolSearchHint.handleDismiss}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
- 原因: 遵循 REPL 的 focusedInputDialog 优先级系统,确保工具推荐提示在合适的时机显示,不阻塞高优先级对话框
|
||||
|
||||
- [x] 为 `ToolSearchHint` 组件和 `useToolSearchHint` hook 编写单元测试
|
||||
- 测试文件: `src/components/__tests__/ToolSearchHint.test.ts`
|
||||
- 测试场景:
|
||||
- 当 `tools` 数组为空时,`useToolSearchHint` 返回 `visible: false`
|
||||
- 当 `tools` 数组非空且最高 score >= 0.15 时,`useToolSearchHint` 返回 `visible: true` 且 `tools` 包含最多 3 个条目
|
||||
- 当最高 score < 0.15 时,`useToolSearchHint` 返回 `visible: false`
|
||||
- `handleDismiss` 调用后推荐状态被清除
|
||||
- `handleSelect` 调用后推荐状态被清除且回调被触发
|
||||
- 使用 `bun:test` 框架(与项目现有测试一致)
|
||||
- 运行命令: `bun test src/components/__tests__/ToolSearchHint.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证新文件已创建且导出正确
|
||||
- `grep -c "export function ToolSearchHint" src/components/ToolSearchHint.tsx && grep -c "export function useToolSearchHint" src/hooks/useToolSearchHint.ts`
|
||||
- 预期: 两个 grep 均返回 1
|
||||
- [x] 验证 REPL.tsx 集成正确
|
||||
- `grep -c "ToolSearchHint" src/screens/REPL.tsx && grep -c "tool-search-hint" src/screens/REPL.tsx`
|
||||
- 预期: 两个 grep 均返回值 >= 2(import + hook + 渲染 + 优先级判断)
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `npx tsc --noEmit --pretty 2>&1 | grep -E "ToolSearchHint|useToolSearchHint" | head -5`
|
||||
- 预期: 无输出(无相关类型错误)
|
||||
- [x] 验证单元测试通过
|
||||
- `bun test src/components/__tests__/ToolSearchHint.test.ts`
|
||||
- 预期: 所有测试通过,无失败
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 全功能验收
|
||||
|
||||
**前置条件:**
|
||||
- Plan 1(Task 1-4)和 Plan 2(Task 5-7)全部完成
|
||||
- `bun run build` 可用
|
||||
|
||||
**端到端验证:**
|
||||
|
||||
1. 运行完整测试套件确保无回归
|
||||
- `bun test 2>&1 | tail -20`
|
||||
- 预期: 全部测试通过(包含 Plan 1 和 Plan 2 新增的所有测试文件)
|
||||
- 失败排查: 检查对应 Task 的测试步骤,确认 mock 配置和 import 路径
|
||||
|
||||
2. 运行 precheck 确保 typecheck + lint + test 全部通过
|
||||
- `bun run precheck 2>&1 | tail -20`
|
||||
- 预期: 零错误通过
|
||||
- 失败排查: 类型错误检查 import 路径;lint 错误检查格式;测试失败检查对应 Task
|
||||
|
||||
3. 验证系统提示词引导文本正确注入
|
||||
- `bun run dev -- --dump-system-prompt 2>&1 | grep -A5 "ToolSearch"`
|
||||
- 预期: 输出包含 "use ToolSearch to discover" 引导文本
|
||||
- 失败排查: 检查 Task 5 的 prompts.ts 修改
|
||||
|
||||
4. 验证 ExecuteTool 在工具列表中可见
|
||||
- `bun run dev -- --dump-system-prompt 2>&1 | grep "ExecuteTool"`
|
||||
- 预期: 输出包含 ExecuteTool 工具定义
|
||||
- 失败排查: 检查 Task 5 的 tools.ts 注册
|
||||
|
||||
5. 验证构建产物正确
|
||||
- `bun run build 2>&1 | tail -5`
|
||||
- 预期: 构建成功,输出 dist/cli.js
|
||||
- 失败排查: 检查新增文件的 import 是否兼容 Bun.build splitting
|
||||
|
||||
6. 验证延迟工具数量正确
|
||||
- `grep -c "isDeferredTool" src/utils/toolSearch.ts src/services/api/claude.ts packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts 2>/dev/null`
|
||||
- 预期: 所有调用点仍在使用 isDeferredTool(已被 Task 1 重构为白名单制)
|
||||
- 失败排查: 检查 Task 1 的 isDeferredTool 重构
|
||||
@@ -41,11 +41,7 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||
import type { APIError } from '@anthropic-ai/sdk'
|
||||
import type {
|
||||
CompactMetadata,
|
||||
Message,
|
||||
SystemCompactBoundaryMessage,
|
||||
} from './types/message.js'
|
||||
import type { Message, SystemCompactBoundaryMessage } from './types/message.js'
|
||||
import type { OrphanedPermission } from './types/textInputTypes.js'
|
||||
import { createAbortController } from './utils/abortController.js'
|
||||
import type { AttributionState } from './utils/commitAttribution.js'
|
||||
@@ -1051,7 +1047,9 @@ export class QueryEngine {
|
||||
initialAppState.fastMode,
|
||||
),
|
||||
uuid: randomUUID(),
|
||||
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
|
||||
errors: [
|
||||
`Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`,
|
||||
],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
logEvent,
|
||||
logEventAsync,
|
||||
} from '../services/analytics/index.js'
|
||||
import { isInBundledMode } from '../utils/bundledMode.js'
|
||||
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { rcLog } from './rcDebugLog.js'
|
||||
|
||||
@@ -72,7 +72,6 @@ import type {
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ import type {
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
|
||||
/**
|
||||
* StdoutMessage with optional session_id. The transport layer accepts
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Companion display card — shown by /buddy (no args).
|
||||
* Mirrors official vc8 component: bordered box with sprite, stats, last reaction.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useInput } from '@anthropic/ink';
|
||||
import { renderSprite } from './sprites.js';
|
||||
|
||||
59
src/cli/__tests__/userFacingErrorMessages.test.ts
Normal file
59
src/cli/__tests__/userFacingErrorMessages.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing error messages include actionable guidance.
|
||||
* These are pure string-formatting tests — no side effects.
|
||||
*/
|
||||
|
||||
describe('User-facing error messages', () => {
|
||||
test('budget exceeded message includes budget and guidance', () => {
|
||||
const maxBudgetUsd = 5.0
|
||||
const message = `Error: Exceeded USD budget ($${maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`
|
||||
|
||||
expect(message).toContain('Exceeded USD budget')
|
||||
expect(message).toContain('$5')
|
||||
expect(message).toContain('--max-budget-usd')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
|
||||
test('max turns message includes guidance', () => {
|
||||
const maxTurns = 10
|
||||
const message = `Error: Reached max turns (${maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`
|
||||
|
||||
expect(message).toContain('max turns')
|
||||
expect(message).toContain('--max-turns')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
|
||||
test('structured output retry message includes guidance', () => {
|
||||
const message =
|
||||
'Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.'
|
||||
|
||||
expect(message).toContain('structured output')
|
||||
expect(message).toContain('Simplify your schema')
|
||||
})
|
||||
|
||||
test('QueryEngine budget error includes actionable hint', () => {
|
||||
const maxBudgetUsd = 3.0
|
||||
const message = `Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`
|
||||
|
||||
expect(message).toContain('maximum budget')
|
||||
expect(message).toContain('--max-budget-usd')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Onboarding security copy', () => {
|
||||
test('security heading uses friendly tone', () => {
|
||||
const heading = 'Before you start, keep in mind:'
|
||||
expect(heading).not.toContain('Security')
|
||||
expect(heading).toContain('Before you start')
|
||||
})
|
||||
|
||||
test('trust dialog copy is concise', () => {
|
||||
const body =
|
||||
'Is this a project you trust? (Your own code, a well-known open source project, or work from your team).'
|
||||
expect(body.length).toBeLessThan(120)
|
||||
expect(body).toContain('trust')
|
||||
})
|
||||
})
|
||||
@@ -68,13 +68,3 @@ export class TmuxEngine implements BgEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTmuxInstallHint(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
return 'Install with: brew install tmux'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return 'tmux is not natively available on Windows. Consider using WSL.'
|
||||
}
|
||||
return 'Install with: sudo apt install tmux (or your package manager)'
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { stat } from 'fs/promises';
|
||||
import pMap from 'p-map';
|
||||
import { cwd } from 'process';
|
||||
import React from 'react';
|
||||
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js';
|
||||
import { wrappedRender as render } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
|
||||
@@ -112,7 +112,6 @@ import type {
|
||||
ModelInfo,
|
||||
SDKMessage,
|
||||
SDKUserMessage,
|
||||
SDKUserMessageReplay,
|
||||
PermissionResult,
|
||||
McpServerConfigForProcessTransport,
|
||||
McpServerStatus,
|
||||
@@ -961,14 +960,18 @@ export async function runHeadless(
|
||||
writeToStdout(`Execution error`)
|
||||
break
|
||||
case 'error_max_turns':
|
||||
writeToStdout(`Error: Reached max turns (${options.maxTurns})`)
|
||||
writeToStdout(
|
||||
`Error: Reached max turns (${options.maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`,
|
||||
)
|
||||
break
|
||||
case 'error_max_budget_usd':
|
||||
writeToStdout(`Error: Exceeded USD budget (${options.maxBudgetUsd})`)
|
||||
writeToStdout(
|
||||
`Error: Exceeded USD budget ($${options.maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`,
|
||||
)
|
||||
break
|
||||
case 'error_max_structured_output_retries':
|
||||
writeToStdout(
|
||||
`Error: Failed to provide valid structured output after maximum retries`,
|
||||
`Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5473,7 +5476,7 @@ function getStructuredIO(
|
||||
*/
|
||||
export async function handleOrphanedPermissionResponse({
|
||||
message,
|
||||
setAppState,
|
||||
setAppState: _setAppState,
|
||||
onEnqueued,
|
||||
handledToolUseIds,
|
||||
}: {
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import chalk from 'chalk'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import {
|
||||
getLatestVersion,
|
||||
type InstallStatus,
|
||||
installGlobalPackage,
|
||||
} from 'src/utils/autoUpdater.js'
|
||||
import { regenerateCompletionCache } from 'src/utils/completionCache.js'
|
||||
import {
|
||||
getGlobalConfig,
|
||||
type InstallMethod,
|
||||
saveGlobalConfig,
|
||||
} from 'src/utils/config.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { getDoctorDiagnostic } from 'src/utils/doctorDiagnostic.js'
|
||||
import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'
|
||||
import {
|
||||
installOrUpdateClaudePackage,
|
||||
localInstallationExists,
|
||||
} from 'src/utils/localInstaller.js'
|
||||
import {
|
||||
installLatest as installLatestNative,
|
||||
removeInstalledSymlink,
|
||||
} from 'src/utils/nativeInstaller/index.js'
|
||||
import { getPackageManager } from 'src/utils/nativeInstaller/packageManagers.js'
|
||||
import { writeToStdout } from 'src/utils/process.js'
|
||||
import { gte } from 'src/utils/semver.js'
|
||||
import { getInitialSettings } from 'src/utils/settings/settings.js'
|
||||
|
||||
export async function update() {
|
||||
logEvent('tengu_update_check', {})
|
||||
writeToStdout(`Current version: ${MACRO.VERSION}\n`)
|
||||
|
||||
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
|
||||
writeToStdout(`Checking for updates to ${channel} version...\n`)
|
||||
|
||||
logForDebugging('update: Starting update check')
|
||||
|
||||
// Run diagnostic to detect potential issues
|
||||
logForDebugging('update: Running diagnostic')
|
||||
const diagnostic = await getDoctorDiagnostic()
|
||||
logForDebugging(`update: Installation type: ${diagnostic.installationType}`)
|
||||
logForDebugging(
|
||||
`update: Config install method: ${diagnostic.configInstallMethod}`,
|
||||
)
|
||||
|
||||
// Check for multiple installations
|
||||
if (diagnostic.multipleInstallations.length > 1) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(chalk.yellow('Warning: Multiple installations found') + '\n')
|
||||
for (const install of diagnostic.multipleInstallations) {
|
||||
const current =
|
||||
diagnostic.installationType === install.type
|
||||
? ' (currently running)'
|
||||
: ''
|
||||
writeToStdout(`- ${install.type} at ${install.path}${current}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
// Display warnings if any exist
|
||||
if (diagnostic.warnings.length > 0) {
|
||||
writeToStdout('\n')
|
||||
for (const warning of diagnostic.warnings) {
|
||||
logForDebugging(`update: Warning detected: ${warning.issue}`)
|
||||
|
||||
// Don't skip PATH warnings - they're always relevant
|
||||
// The user needs to know that 'which claude' points elsewhere
|
||||
logForDebugging(`update: Showing warning: ${warning.issue}`)
|
||||
|
||||
writeToStdout(chalk.yellow(`Warning: ${warning.issue}\n`))
|
||||
|
||||
writeToStdout(chalk.bold(`Fix: ${warning.fix}\n`))
|
||||
}
|
||||
}
|
||||
|
||||
// Update config if installMethod is not set (but skip for package managers)
|
||||
const config = getGlobalConfig()
|
||||
if (
|
||||
!config.installMethod &&
|
||||
diagnostic.installationType !== 'package-manager'
|
||||
) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout('Updating configuration to track installation method...\n')
|
||||
let detectedMethod: 'local' | 'native' | 'global' | 'unknown' = 'unknown'
|
||||
|
||||
// Map diagnostic installation type to config install method
|
||||
switch (diagnostic.installationType) {
|
||||
case 'npm-local':
|
||||
detectedMethod = 'local'
|
||||
break
|
||||
case 'native':
|
||||
detectedMethod = 'native'
|
||||
break
|
||||
case 'npm-global':
|
||||
detectedMethod = 'global'
|
||||
break
|
||||
default:
|
||||
detectedMethod = 'unknown'
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
installMethod: detectedMethod,
|
||||
}))
|
||||
writeToStdout(`Installation method set to: ${detectedMethod}\n`)
|
||||
}
|
||||
|
||||
// Check if running from development build
|
||||
if (diagnostic.installationType === 'development') {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(
|
||||
chalk.yellow('Warning: Cannot update development build') + '\n',
|
||||
)
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
// Check if running from a package manager
|
||||
if (diagnostic.installationType === 'package-manager') {
|
||||
const packageManager = await getPackageManager()
|
||||
writeToStdout('\n')
|
||||
|
||||
if (packageManager === 'homebrew') {
|
||||
writeToStdout('Claude is managed by Homebrew.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else if (packageManager === 'winget') {
|
||||
writeToStdout('Claude is managed by winget.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(
|
||||
chalk.bold(' winget upgrade Anthropic.ClaudeCode') + '\n',
|
||||
)
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else if (packageManager === 'apk') {
|
||||
writeToStdout('Claude is managed by apk.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else {
|
||||
// pacman, deb, and rpm don't get specific commands because they each have
|
||||
// multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala,
|
||||
// rpm: dnf/yum/zypper)
|
||||
writeToStdout('Claude is managed by a package manager.\n')
|
||||
writeToStdout('Please use your package manager to update.\n')
|
||||
}
|
||||
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
// Check for config/reality mismatch (skip for package-manager installs)
|
||||
if (
|
||||
config.installMethod &&
|
||||
diagnostic.configInstallMethod !== 'not set' &&
|
||||
diagnostic.installationType !== 'package-manager'
|
||||
) {
|
||||
const runningType = diagnostic.installationType
|
||||
const configExpects = diagnostic.configInstallMethod
|
||||
|
||||
// Map installation types for comparison
|
||||
const typeMapping: Record<string, string> = {
|
||||
'npm-local': 'local',
|
||||
'npm-global': 'global',
|
||||
native: 'native',
|
||||
development: 'development',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
|
||||
const normalizedRunningType = typeMapping[runningType] || runningType
|
||||
|
||||
if (
|
||||
normalizedRunningType !== configExpects &&
|
||||
configExpects !== 'unknown'
|
||||
) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(chalk.yellow('Warning: Configuration mismatch') + '\n')
|
||||
writeToStdout(`Config expects: ${configExpects} installation\n`)
|
||||
writeToStdout(`Currently running: ${runningType}\n`)
|
||||
writeToStdout(
|
||||
chalk.yellow(
|
||||
`Updating the ${runningType} installation you are currently using`,
|
||||
) + '\n',
|
||||
)
|
||||
|
||||
// Update config to match reality
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
installMethod: normalizedRunningType as InstallMethod,
|
||||
}))
|
||||
writeToStdout(
|
||||
`Config updated to reflect current installation method: ${normalizedRunningType}\n`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle native installation updates first
|
||||
if (diagnostic.installationType === 'native') {
|
||||
logForDebugging(
|
||||
'update: Detected native installation, using native updater',
|
||||
)
|
||||
try {
|
||||
const result = await installLatestNative(channel, true)
|
||||
|
||||
// Handle lock contention gracefully
|
||||
if (result.lockFailed) {
|
||||
const pidInfo = result.lockHolderPid
|
||||
? ` (PID ${result.lockHolderPid})`
|
||||
: ''
|
||||
writeToStdout(
|
||||
chalk.yellow(
|
||||
`Another Claude process${pidInfo} is currently running. Please try again in a moment.`,
|
||||
) + '\n',
|
||||
)
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
if (!result.latestVersion) {
|
||||
process.stderr.write('Failed to check for updates\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
if (result.latestVersion === MACRO.VERSION) {
|
||||
writeToStdout(
|
||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
||||
)
|
||||
} else {
|
||||
writeToStdout(
|
||||
chalk.green(
|
||||
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
|
||||
) + '\n',
|
||||
)
|
||||
await regenerateCompletionCache()
|
||||
}
|
||||
await gracefulShutdown(0)
|
||||
} catch (error) {
|
||||
process.stderr.write('Error: Failed to install native update\n')
|
||||
process.stderr.write(String(error) + '\n')
|
||||
process.stderr.write('Try running "claude doctor" for diagnostics\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to existing JS/npm-based update logic
|
||||
// Remove native installer symlink since we're not using native installation
|
||||
// But only if user hasn't migrated to native installation
|
||||
if (config.installMethod !== 'native') {
|
||||
await removeInstalledSymlink()
|
||||
}
|
||||
|
||||
logForDebugging('update: Checking npm registry for latest version')
|
||||
logForDebugging(`update: Package URL: ${MACRO.PACKAGE_URL}`)
|
||||
const npmTag = channel === 'stable' ? 'stable' : 'latest'
|
||||
const npmCommand = `npm view ${MACRO.PACKAGE_URL}@${npmTag} version`
|
||||
logForDebugging(`update: Running: ${npmCommand}`)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
logForDebugging(
|
||||
`update: Latest version from npm: ${latestVersion || 'FAILED'}`,
|
||||
)
|
||||
|
||||
if (!latestVersion) {
|
||||
logForDebugging('update: Failed to get latest version from npm registry')
|
||||
process.stderr.write(chalk.red('Failed to check for updates') + '\n')
|
||||
process.stderr.write('Unable to fetch latest version from npm registry\n')
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Possible causes:\n')
|
||||
process.stderr.write(' • Network connectivity issues\n')
|
||||
process.stderr.write(' • npm registry is unreachable\n')
|
||||
process.stderr.write(' • Corporate proxy/firewall blocking npm\n')
|
||||
if (MACRO.PACKAGE_URL && !MACRO.PACKAGE_URL.startsWith('@anthropic')) {
|
||||
process.stderr.write(
|
||||
' • Internal/development build not published to npm\n',
|
||||
)
|
||||
}
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Try:\n')
|
||||
process.stderr.write(' • Check your internet connection\n')
|
||||
process.stderr.write(' • Run with --debug flag for more details\n')
|
||||
const packageName =
|
||||
MACRO.PACKAGE_URL ||
|
||||
(process.env.USER_TYPE === 'ant'
|
||||
? '@anthropic-ai/claude-cli'
|
||||
: '@anthropic-ai/claude-code')
|
||||
process.stderr.write(
|
||||
` • Manually check: npm view ${packageName} version\n`,
|
||||
)
|
||||
|
||||
process.stderr.write(' • Check if you need to login: npm whoami\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
// Check if versions match exactly, including any build metadata (like SHA)
|
||||
if (latestVersion === MACRO.VERSION) {
|
||||
writeToStdout(
|
||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
||||
)
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
writeToStdout(
|
||||
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
|
||||
)
|
||||
writeToStdout('Installing update...\n')
|
||||
|
||||
// Determine update method based on what's actually running
|
||||
let useLocalUpdate = false
|
||||
let updateMethodName = ''
|
||||
|
||||
switch (diagnostic.installationType) {
|
||||
case 'npm-local':
|
||||
useLocalUpdate = true
|
||||
updateMethodName = 'local'
|
||||
break
|
||||
case 'npm-global':
|
||||
useLocalUpdate = false
|
||||
updateMethodName = 'global'
|
||||
break
|
||||
case 'unknown': {
|
||||
// Fallback to detection if we can't determine installation type
|
||||
const isLocal = await localInstallationExists()
|
||||
useLocalUpdate = isLocal
|
||||
updateMethodName = isLocal ? 'local' : 'global'
|
||||
writeToStdout(
|
||||
chalk.yellow('Warning: Could not determine installation type') + '\n',
|
||||
)
|
||||
writeToStdout(
|
||||
`Attempting ${updateMethodName} update based on file detection...\n`,
|
||||
)
|
||||
break
|
||||
}
|
||||
default:
|
||||
process.stderr.write(
|
||||
`Error: Cannot update ${diagnostic.installationType} installation\n`,
|
||||
)
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
writeToStdout(`Using ${updateMethodName} installation update method...\n`)
|
||||
|
||||
logForDebugging(`update: Update method determined: ${updateMethodName}`)
|
||||
logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`)
|
||||
|
||||
let status: InstallStatus
|
||||
|
||||
if (useLocalUpdate) {
|
||||
logForDebugging(
|
||||
'update: Calling installOrUpdateClaudePackage() for local update',
|
||||
)
|
||||
status = await installOrUpdateClaudePackage(channel)
|
||||
} else {
|
||||
logForDebugging('update: Calling installGlobalPackage() for global update')
|
||||
status = await installGlobalPackage()
|
||||
}
|
||||
|
||||
logForDebugging(`update: Installation status: ${status}`)
|
||||
|
||||
switch (status) {
|
||||
case 'success':
|
||||
writeToStdout(
|
||||
chalk.green(
|
||||
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
|
||||
) + '\n',
|
||||
)
|
||||
await regenerateCompletionCache()
|
||||
break
|
||||
case 'no_permissions':
|
||||
process.stderr.write(
|
||||
'Error: Insufficient permissions to install update\n',
|
||||
)
|
||||
if (useLocalUpdate) {
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
process.stderr.write(
|
||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
)
|
||||
} else {
|
||||
process.stderr.write('Try running with sudo or fix npm permissions\n')
|
||||
process.stderr.write(
|
||||
'Or consider using native installation with: claude install\n',
|
||||
)
|
||||
}
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
case 'install_failed':
|
||||
process.stderr.write('Error: Failed to install update\n')
|
||||
if (useLocalUpdate) {
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
process.stderr.write(
|
||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'Or consider using native installation with: claude install\n',
|
||||
)
|
||||
}
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
case 'in_progress':
|
||||
process.stderr.write(
|
||||
'Error: Another instance is currently performing an update\n',
|
||||
)
|
||||
process.stderr.write('Please wait and try again later\n')
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
}
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ export async function updateCCB(): Promise<void> {
|
||||
|
||||
try {
|
||||
if (pkgManager === 'bun') {
|
||||
execSync(`bun update -g ${PACKAGE_NAME}`, {
|
||||
execSync(`bun install -g ${PACKAGE_NAME}@latest`, {
|
||||
stdio: 'inherit',
|
||||
cwd: homedir(),
|
||||
timeout: 120_000,
|
||||
@@ -153,7 +153,9 @@ export async function updateCCB(): Promise<void> {
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
if (pkgManager === 'bun') {
|
||||
process.stderr.write(chalk.bold(` bun update -g ${PACKAGE_NAME}`) + '\n')
|
||||
process.stderr.write(
|
||||
chalk.bold(` bun install -g ${PACKAGE_NAME}@latest`) + '\n',
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(
|
||||
chalk.bold(` npm install -g ${PACKAGE_NAME}@latest`) + '\n',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { resolve } from 'path';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Dialog } from '../../components/design-system/Dialog.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Doctor } from '../../screens/Doctor.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -43,8 +43,12 @@ export async function call(
|
||||
// Omitting subagent_type triggers implicit fork.
|
||||
const input = {
|
||||
prompt: directive,
|
||||
fork: true, // 触发 AgentTool 的 fork 路径:继承父会话上下文 + system prompt + 模型
|
||||
run_in_background: true, // fork always runs async
|
||||
description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`,
|
||||
// description 只显示在底部 selector / BackgroundTasksDialog,保持简短标签
|
||||
// 即可;用户输入的 prompt 会作为第一条用户消息呈现在主视图里,这里不要
|
||||
// 重复显示。
|
||||
description: 'forked from main',
|
||||
};
|
||||
|
||||
// Call AgentTool with proper parameters:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { HelpV2 } from '../../components/HelpV2/HelpV2.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
|
||||
export function CheckGitHubStep() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Workflow } from './types.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'src/components/CustomSelect/index.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { resetCostState } from '../../bootstrap/state.js';
|
||||
import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { createPermissionRetryMessage } from '../../utils/messages.js';
|
||||
|
||||
@@ -36,7 +36,7 @@ function getMergedEnv(): Record<string, string> {
|
||||
return merged
|
||||
}
|
||||
|
||||
const call: LocalCommandCall = async (args, context) => {
|
||||
const call: LocalCommandCall = async (args, _context) => {
|
||||
const arg = args.trim().toLowerCase()
|
||||
|
||||
// No argument: show current provider
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ChildProcess } from 'child_process';
|
||||
import { resolve } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getBridgeDisabledReason, isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
|
||||
import { getBridgeDisabledReason } from '../../bridge/bridgeEnabled.js';
|
||||
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js';
|
||||
import { BRIDGE_LOGIN_INSTRUCTION } from '../../bridge/types.js';
|
||||
import { Dialog } from '../../components/design-system/Dialog.js';
|
||||
|
||||
55
src/commands/resume/__tests__/resume.test.ts
Normal file
55
src/commands/resume/__tests__/resume.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing guidance in model picker and resume command
|
||||
* is concise and actionable. Pure string tests — no side effects.
|
||||
*/
|
||||
|
||||
describe('ModelPicker subtitle', () => {
|
||||
test('subtitle mentions effort and context controls', () => {
|
||||
const subtitle =
|
||||
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
|
||||
expect(subtitle).toContain('effort')
|
||||
expect(subtitle).toContain('1M context')
|
||||
expect(subtitle).toContain('sessions')
|
||||
})
|
||||
|
||||
test('subtitle is under 120 characters', () => {
|
||||
const subtitle =
|
||||
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
|
||||
expect(subtitle.length).toBeLessThan(120)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resume error messages', () => {
|
||||
test('session not found suggests /resume to browse', () => {
|
||||
const message =
|
||||
'Session my-session was not found. Run /resume without arguments to browse all sessions.'
|
||||
expect(message).toContain('not found')
|
||||
expect(message).toContain('/resume')
|
||||
expect(message).toContain('browse')
|
||||
})
|
||||
|
||||
test('multiple matches suggests /resume to pick', () => {
|
||||
const message =
|
||||
'Found 3 sessions matching test. Run /resume to pick one from the list.'
|
||||
expect(message).toContain('3 sessions')
|
||||
expect(message).toContain('/resume')
|
||||
expect(message).toContain('pick')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cost command subscriber messages', () => {
|
||||
test('overage message mentions the key behavior', () => {
|
||||
const msg =
|
||||
'You are currently using your overages to power your Claude Code usage. We will automatically switch you back to your subscription rate limits when they reset'
|
||||
expect(msg).toContain('overages')
|
||||
expect(msg).toContain('automatically switch')
|
||||
})
|
||||
|
||||
test('subscription message is concise', () => {
|
||||
const msg =
|
||||
'You are currently using your subscription to power your Claude Code usage'
|
||||
expect(msg.length).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
@@ -36,9 +36,9 @@ type ResumeResult =
|
||||
function resumeHelpMessage(result: ResumeResult): string {
|
||||
switch (result.resultType) {
|
||||
case 'sessionNotFound':
|
||||
return `Session ${chalk.bold(result.arg)} was not found.`;
|
||||
return `Session ${chalk.bold(result.arg)} was not found. Run ${chalk.bold('/resume')} without arguments to browse all sessions.`;
|
||||
case 'multipleMatches':
|
||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`;
|
||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Run ${chalk.bold('/resume')} to pick one from the list.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js';
|
||||
import React from 'react';
|
||||
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { checkOverageGate, confirmOverage, launchRemoteReview } from './reviewRemote.js';
|
||||
import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js';
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
exportInstincts,
|
||||
findPromotionCandidates,
|
||||
generateSkillCandidates,
|
||||
importInstincts,
|
||||
ingestTranscript,
|
||||
listKnownProjects,
|
||||
loadInstincts,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Stats } from '../../components/Stats.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js';
|
||||
import type { Command } from '../commands.js';
|
||||
import { DIAMOND_OPEN } from '../constants/figures.js';
|
||||
@@ -21,7 +20,6 @@ import { logForDebugging } from '../utils/debug.js';
|
||||
import { errorMessage } from '../utils/errors.js';
|
||||
import { logError } from '../utils/log.js';
|
||||
import { enqueuePendingNotification } from '../utils/messageQueueManager.js';
|
||||
import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js';
|
||||
import { updateTaskState } from '../utils/task/framework.js';
|
||||
import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js';
|
||||
import { pollForApprovedExitPlanMode, UltraplanPollError } from '../utils/ultraplan/ccrSession.js';
|
||||
@@ -36,13 +34,6 @@ import { registerCleanup } from '../utils/cleanupRegistry.js';
|
||||
// TODO(prod-hardening): OAuth token may go stale over the 30min poll;
|
||||
// consider refresh.
|
||||
|
||||
/**
|
||||
* Multi-agent exploration is slow; 30min timeout.
|
||||
*
|
||||
* @deprecated use getUltraplanTimeoutMs()
|
||||
*/
|
||||
const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web';
|
||||
|
||||
export function getUltraplanTimeoutMs(): number {
|
||||
@@ -61,15 +52,6 @@ export function isUltraplanEnabled(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// CCR runs against the first-party API — use the canonical ID, not the
|
||||
// provider-specific string getModelStrings() would return (which may be a
|
||||
// Bedrock ARN or Vertex ID on the local CLI). Read at call time, not module
|
||||
// load: the GrowthBook cache is empty at import and `/config` Gates can flip
|
||||
// it between invocations.
|
||||
function getUltraplanModel(): string {
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus47.firstParty);
|
||||
}
|
||||
|
||||
// prompt.txt is wrapped in <system-reminder> so the CCR browser hides
|
||||
// scaffolding (CLI_BLOCK_TAGS dropped by stripSystemNotifications)
|
||||
// while the model still sees full text.
|
||||
@@ -84,19 +66,6 @@ const _rawPrompt = require('../utils/ultraplan/prompt.txt');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default).trimEnd();
|
||||
|
||||
// Dev-only prompt override resolved eagerly at module load.
|
||||
// Gated to ant builds (USER_TYPE is a build-time define,
|
||||
// so the override path is DCE'd from external builds).
|
||||
// Shell-set env only, so top-level process.env read is fine
|
||||
// — settings.env never injects this.
|
||||
// @deprecated use buildUltraplanPrompt()
|
||||
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- ant-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
|
||||
const ULTRAPLAN_INSTRUCTIONS: string =
|
||||
process.env.USER_TYPE === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE
|
||||
? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd()
|
||||
: DEFAULT_INSTRUCTIONS;
|
||||
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
|
||||
|
||||
/**
|
||||
* Assemble the initial CCR user message. seedPlan and blurb stay outside the
|
||||
* system-reminder so the browser renders them; scaffolding is hidden.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { formatCost } from '../cost-tracker.js';
|
||||
import { Box, Text, ProgressBar } from '@anthropic/ink';
|
||||
import { formatTokens } from '../utils/format.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
type RateLimitBucket = {
|
||||
utilization: number;
|
||||
resets_at: number;
|
||||
};
|
||||
|
||||
type BuiltinStatusLineProps = {
|
||||
modelName: string;
|
||||
contextUsedPct: number;
|
||||
usedTokens: number;
|
||||
contextWindowSize: number;
|
||||
totalCostUsd: number;
|
||||
rateLimits: {
|
||||
five_hour?: RateLimitBucket;
|
||||
seven_day?: RateLimitBucket;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a countdown from now until the given epoch time (in seconds).
|
||||
* Returns a compact human-readable string like "3h12m", "5d20h", "45m", or "now".
|
||||
*/
|
||||
export function formatCountdown(epochSeconds: number): string {
|
||||
const diff = epochSeconds - Date.now() / 1000;
|
||||
if (diff <= 0) return 'now';
|
||||
|
||||
const days = Math.floor(diff / 86400);
|
||||
const hours = Math.floor((diff % 86400) / 3600);
|
||||
const minutes = Math.floor((diff % 3600) / 60);
|
||||
|
||||
if (days >= 1) return `${days}d${hours}h`;
|
||||
if (hours >= 1) return `${hours}h${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <Text dimColor>{' \u2502 '}</Text>;
|
||||
}
|
||||
|
||||
function BuiltinStatusLineInner({
|
||||
modelName,
|
||||
contextUsedPct,
|
||||
usedTokens,
|
||||
contextWindowSize,
|
||||
totalCostUsd,
|
||||
rateLimits,
|
||||
}: BuiltinStatusLineProps) {
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
// Force re-render every 60s so countdowns stay current
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const hasResetTime = (rateLimits.five_hour?.resets_at ?? 0) || (rateLimits.seven_day?.resets_at ?? 0);
|
||||
if (!hasResetTime) return;
|
||||
const id = setInterval(() => setTick(t => t + 1), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [rateLimits.five_hour?.resets_at, rateLimits.seven_day?.resets_at]);
|
||||
|
||||
// Suppress unused-variable lint for tick (it exists only to trigger re-renders)
|
||||
void tick;
|
||||
|
||||
// Model display: use first two words (e.g. "Opus 4.6") instead of just first word
|
||||
const modelParts = modelName.split(' ');
|
||||
const shortModel = modelParts.length >= 2 ? `${modelParts[0]} ${modelParts[1]}` : modelName;
|
||||
|
||||
const wide = columns >= 100;
|
||||
const narrow = columns < 60;
|
||||
|
||||
const hasFiveHour = rateLimits.five_hour != null;
|
||||
const hasSevenDay = rateLimits.seven_day != null;
|
||||
|
||||
const fiveHourPct = hasFiveHour ? Math.round(rateLimits.five_hour!.utilization * 100) : 0;
|
||||
const sevenDayPct = hasSevenDay ? Math.round(rateLimits.seven_day!.utilization * 100) : 0;
|
||||
|
||||
// Token display: "50k/1M"
|
||||
const tokenDisplay = `${formatTokens(usedTokens)}/${formatTokens(contextWindowSize)}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Model name */}
|
||||
<Text>{shortModel}</Text>
|
||||
|
||||
{/* Context usage with token counts */}
|
||||
<Separator />
|
||||
<Text dimColor>Context </Text>
|
||||
<Text>{contextUsedPct}%</Text>
|
||||
{!narrow && <Text dimColor> ({tokenDisplay})</Text>}
|
||||
|
||||
{/* 5-hour session rate limit */}
|
||||
{hasFiveHour && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text dimColor>Session </Text>
|
||||
{wide && (
|
||||
<>
|
||||
<ProgressBar
|
||||
ratio={rateLimits.five_hour!.utilization}
|
||||
width={10}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
<Text> </Text>
|
||||
</>
|
||||
)}
|
||||
<Text>{fiveHourPct}%</Text>
|
||||
{!narrow && rateLimits.five_hour!.resets_at > 0 && (
|
||||
<Text dimColor> {formatCountdown(rateLimits.five_hour!.resets_at)}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 7-day weekly rate limit */}
|
||||
{hasSevenDay && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text dimColor>Weekly </Text>
|
||||
{wide && (
|
||||
<>
|
||||
<ProgressBar
|
||||
ratio={rateLimits.seven_day!.utilization}
|
||||
width={10}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
<Text> </Text>
|
||||
</>
|
||||
)}
|
||||
<Text>{sevenDayPct}%</Text>
|
||||
{!narrow && rateLimits.seven_day!.resets_at > 0 && (
|
||||
<Text dimColor> {formatCountdown(rateLimits.seven_day!.resets_at)}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{totalCostUsd > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text>{formatCost(totalCostUsd)}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner);
|
||||
@@ -79,7 +79,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>
|
||||
Compact summary
|
||||
Conversation summarized to free up context
|
||||
{!isTranscriptMode && (
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
@@ -87,7 +87,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
action="app:toggleTranscript"
|
||||
context="Global"
|
||||
fallback="ctrl+o"
|
||||
description="expand"
|
||||
description="view summary"
|
||||
parens
|
||||
/>
|
||||
</Text>
|
||||
|
||||
@@ -11,12 +11,12 @@ import { getSSLErrorHint } from '@ant/model-provider';
|
||||
import { sendNotification } from '../services/notifier.js';
|
||||
import { OAuthService } from '../services/oauth/index.js';
|
||||
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
|
||||
|
||||
import { logError } from '../utils/log.js';
|
||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import { Select } from './CustomSelect/select.js';
|
||||
import { Spinner } from './Spinner.js';
|
||||
import TextInput from './TextInput.js';
|
||||
import { fi } from 'zod/v4/locales';
|
||||
|
||||
type Props = {
|
||||
onDone(): void;
|
||||
@@ -596,7 +596,7 @@ function OAuthStatusMessage({
|
||||
[activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel],
|
||||
);
|
||||
|
||||
const switchTo = useCallback(
|
||||
const _switchTo = useCallback(
|
||||
(target: Field) => {
|
||||
setOAuthStatus(buildState(activeField, inputValue, target));
|
||||
setInputValue(displayValues[target] ?? '');
|
||||
|
||||
@@ -115,6 +115,8 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
agents,
|
||||
skills,
|
||||
messageBreakdown,
|
||||
cacheHitRate,
|
||||
cacheThreshold,
|
||||
} = data;
|
||||
|
||||
// Filter out categories with 0 tokens for the legend, and exclude Free space, Autocompact buffer, and deferred
|
||||
@@ -166,6 +168,12 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
{model} · {formatTokens(totalTokens)}/{formatTokens(rawMaxTokens)} tokens ({percentage}%)
|
||||
</Text>
|
||||
<CollapseStatus />
|
||||
{cacheHitRate !== undefined && cacheThreshold !== undefined && (
|
||||
<Text color={cacheHitRate < cacheThreshold ? 'warning' : undefined}>
|
||||
Cache hit rate: {cacheHitRate.toFixed(0)}%
|
||||
{cacheHitRate < cacheThreshold ? ` (below ${cacheThreshold}% threshold)` : ''}
|
||||
</Text>
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Text dimColor italic>
|
||||
Estimated usage by category
|
||||
|
||||
@@ -25,24 +25,24 @@ export function useFrustrationDetection(
|
||||
const [state, setState] = useState<FrustrationState>('closed')
|
||||
|
||||
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
|
||||
if (config.transcriptShareDismissed) {
|
||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||
}
|
||||
|
||||
if (!isPolicyAllowed('product_feedback' as any)) {
|
||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||
}
|
||||
|
||||
if (isLoading || hasActivePrompt || otherSurveyOpen) {
|
||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||
}
|
||||
const policyAllowed = isPolicyAllowed('product_feedback' as any)
|
||||
const shouldSkip =
|
||||
config.transcriptShareDismissed ||
|
||||
!policyAllowed ||
|
||||
isLoading ||
|
||||
hasActivePrompt ||
|
||||
otherSurveyOpen
|
||||
|
||||
const frustrated = detectFrustration(messages)
|
||||
|
||||
const effectiveState =
|
||||
frustrated && state === 'closed' ? 'transcript_prompt' : state
|
||||
const effectiveState = shouldSkip
|
||||
? 'closed'
|
||||
: frustrated && state === 'closed'
|
||||
? 'transcript_prompt'
|
||||
: state
|
||||
|
||||
function handleTranscriptSelect(choice: string) {
|
||||
const handleTranscriptSelect = (choice: string) => {
|
||||
if (shouldSkip) return
|
||||
if (choice === 'yes') {
|
||||
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
|
||||
setState('submitted')
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import * as React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { count } from '../utils/array.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
import { StructuredDiffList } from './StructuredDiffList.js';
|
||||
|
||||
type Props = {
|
||||
filePath: string;
|
||||
structuredPatch: { lines: string[] }[];
|
||||
structuredPatch: StructuredPatchHunk[];
|
||||
firstLine: string | null;
|
||||
fileContent?: string;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
previewHint?: string;
|
||||
@@ -14,10 +19,13 @@ type Props = {
|
||||
export function FileEditToolUpdatedMessage({
|
||||
filePath,
|
||||
structuredPatch,
|
||||
firstLine,
|
||||
fileContent,
|
||||
style,
|
||||
verbose,
|
||||
previewHint,
|
||||
}: Props): React.ReactNode {
|
||||
const { columns } = useTerminalSize();
|
||||
const numAdditions = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), 0);
|
||||
const numRemovals = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), 0);
|
||||
|
||||
@@ -39,7 +47,7 @@ export function FileEditToolUpdatedMessage({
|
||||
|
||||
// Plan files: invert condensed behavior
|
||||
// - Regular mode: just show the hint (user can type /plan to see full content)
|
||||
// - Condensed mode (subagent view): show the text
|
||||
// - Condensed mode (subagent view): show the diff
|
||||
if (previewHint) {
|
||||
if (style !== 'condensed' && !verbose) {
|
||||
return (
|
||||
@@ -52,5 +60,19 @@ export function FileEditToolUpdatedMessage({
|
||||
return text;
|
||||
}
|
||||
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Box flexDirection="column">
|
||||
<Text>{text}</Text>
|
||||
<StructuredDiffList
|
||||
hunks={structuredPatch}
|
||||
dim={false}
|
||||
width={columns - 12}
|
||||
filePath={filePath}
|
||||
firstLine={firstLine}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
</Box>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import { relative } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
|
||||
import { getCwd } from 'src/utils/cwd.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { HighlightedCode } from './HighlightedCode.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
import { StructuredDiffList } from './StructuredDiffList.js';
|
||||
|
||||
const MAX_LINES_TO_RENDER = 10;
|
||||
|
||||
type Props = {
|
||||
file_path: string;
|
||||
operation: 'write' | 'update';
|
||||
// For updates - show diff
|
||||
patch?: StructuredPatchHunk[];
|
||||
firstLine: string | null;
|
||||
fileContent?: string;
|
||||
// For new file creation - show content preview
|
||||
content?: string;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
export function FileEditToolUseRejectedMessage({ file_path, operation, style, verbose }: Props): React.ReactNode {
|
||||
export function FileEditToolUseRejectedMessage({
|
||||
file_path,
|
||||
operation,
|
||||
patch,
|
||||
firstLine,
|
||||
fileContent,
|
||||
content,
|
||||
style,
|
||||
verbose,
|
||||
}: Props): React.ReactNode {
|
||||
const { columns } = useTerminalSize();
|
||||
const text = (
|
||||
<Box flexDirection="row">
|
||||
<Text color="subtle">User rejected {operation} to </Text>
|
||||
@@ -26,5 +48,42 @@ export function FileEditToolUseRejectedMessage({ file_path, operation, style, ve
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
}
|
||||
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
// For new file creation, show content preview (dimmed)
|
||||
if (operation === 'write' && content !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
const numLines = lines.length;
|
||||
const plusLines = numLines - MAX_LINES_TO_RENDER;
|
||||
const truncatedContent = verbose ? content : lines.slice(0, MAX_LINES_TO_RENDER).join('\n');
|
||||
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Box flexDirection="column">
|
||||
{text}
|
||||
<HighlightedCode code={truncatedContent || '(No content)'} filePath={file_path} width={columns - 12} dim />
|
||||
{!verbose && plusLines > 0 && <Text dimColor>… +{plusLines} lines</Text>}
|
||||
</Box>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
// For updates, show diff
|
||||
if (!patch || patch.length === 0) {
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Box flexDirection="column">
|
||||
{text}
|
||||
<StructuredDiffList
|
||||
hunks={patch}
|
||||
dim
|
||||
width={columns - 12}
|
||||
filePath={file_path}
|
||||
firstLine={firstLine}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
</Box>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,28 @@ import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js';
|
||||
export function General(): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1} gap={1}>
|
||||
<Box>
|
||||
<Text>
|
||||
Claude understands your codebase, makes edits with your permission, and executes commands — right from your
|
||||
terminal.
|
||||
</Text>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold>Getting started</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold>1. </Text>
|
||||
<Text>Ask a question or describe a task — Claude will explore your code and respond.</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>2. </Text>
|
||||
<Text>When Claude wants to edit files or run commands, you review and approve each action.</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>3. </Text>
|
||||
<Text>Type </Text>
|
||||
<Text bold>/commit</Text>
|
||||
<Text> to commit changes, </Text>
|
||||
<Text bold>/help</Text>
|
||||
<Text> for commands, or </Text>
|
||||
<Text bold>?</Text>
|
||||
<Text> for shortcuts.</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
|
||||
74
src/components/HelpV2/__tests__/General.test.ts
Normal file
74
src/components/HelpV2/__tests__/General.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing permission and help copy meets usability standards.
|
||||
* These are pure string tests — no side effects, no React rendering.
|
||||
*/
|
||||
|
||||
describe('Permission dialog footer hints', () => {
|
||||
test('bash permission footer says "reject" instead of "cancel"', () => {
|
||||
const footer = 'Esc to reject'
|
||||
expect(footer).toContain('reject')
|
||||
expect(footer).not.toContain('cancel')
|
||||
})
|
||||
|
||||
test('bash permission footer tab hint says "add feedback"', () => {
|
||||
const tabHint = 'Tab to add feedback'
|
||||
expect(tabHint).toContain('feedback')
|
||||
expect(tabHint).not.toContain('amend')
|
||||
})
|
||||
|
||||
test('file permission footer matches bash footer language', () => {
|
||||
const bashFooter = 'Esc to reject'
|
||||
const fileFooter = 'Esc to reject'
|
||||
expect(bashFooter).toBe(fileFooter)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission option labels', () => {
|
||||
test('.claude/ folder option is under 60 chars', () => {
|
||||
const label = 'Yes, allow edits to .claude/ config for this session'
|
||||
expect(label.length).toBeLessThan(60)
|
||||
expect(label).toContain('.claude/')
|
||||
})
|
||||
|
||||
test('accept-once option has simple label', () => {
|
||||
const label = 'Yes'
|
||||
expect(label).toBe('Yes')
|
||||
})
|
||||
|
||||
test('reject option has simple label', () => {
|
||||
const label = 'No'
|
||||
expect(label).toBe('No')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help General page getting started guide', () => {
|
||||
test('step 1 mentions exploring code', () => {
|
||||
const step1 =
|
||||
'Ask a question or describe a task — Claude will explore your code and respond.'
|
||||
expect(step1).toContain('explore')
|
||||
expect(step1).toContain('question')
|
||||
})
|
||||
|
||||
test('step 2 mentions reviewing actions', () => {
|
||||
const step2 =
|
||||
'When Claude wants to edit files or run commands, you review and approve each action.'
|
||||
expect(step2).toContain('review')
|
||||
expect(step2).toContain('approve')
|
||||
})
|
||||
|
||||
test('step 3 mentions key commands', () => {
|
||||
const step3 = '/commit'
|
||||
const step3b = '/help'
|
||||
const step3c = '?'
|
||||
expect(step3).toBe('/commit')
|
||||
expect(step3b).toBe('/help')
|
||||
expect(step3c).toBe('?')
|
||||
})
|
||||
|
||||
test('heading says "Getting started"', () => {
|
||||
const heading = 'Getting started'
|
||||
expect(heading).toBe('Getting started')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
incrementOverageCreditUpsellSeenCount,
|
||||
createOverageCreditFeed,
|
||||
} from './OverageCreditUpsell.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import { getEffortSuffix } from '../../utils/effort.js';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import figures from 'figures';
|
||||
import { homedir } from 'os';
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Step } from '../../projectOnboardingState.js';
|
||||
import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/referral.js';
|
||||
|
||||
@@ -229,7 +229,7 @@ export function ModelPicker({
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{headerText ??
|
||||
'Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.'}
|
||||
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'}
|
||||
</Text>
|
||||
{sessionModel && (
|
||||
<Text dimColor>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
||||
|
||||
const securityStep = (
|
||||
<Box flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<Text bold>Security notes:</Text>
|
||||
<Text bold>Before you start, keep in mind:</Text>
|
||||
<Box flexDirection="column" width={70}>
|
||||
{/**
|
||||
* OrderedList misnumbers items when rendering conditionally,
|
||||
@@ -89,18 +89,18 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
||||
*/}
|
||||
<OrderedList>
|
||||
<OrderedList.Item>
|
||||
<Text>Claude can make mistakes</Text>
|
||||
<Text>Always review changes before accepting</Text>
|
||||
<Text dimColor wrap="wrap">
|
||||
You should always review Claude's responses, especially when
|
||||
Claude can make mistakes — especially when running commands
|
||||
<Newline />
|
||||
running code.
|
||||
or editing files. You stay in control of every action.
|
||||
<Newline />
|
||||
</Text>
|
||||
</OrderedList.Item>
|
||||
<OrderedList.Item>
|
||||
<Text>Due to prompt injection risks, only use it with code you trust</Text>
|
||||
<Text>Only use Claude Code on projects you trust</Text>
|
||||
<Text dimColor wrap="wrap">
|
||||
For more details see:
|
||||
Untrusted code could contain prompt injection attacks.
|
||||
<Newline />
|
||||
<Link url="https://code.claude.com/docs/en/security" />
|
||||
</Text>
|
||||
@@ -111,7 +111,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
||||
</Box>
|
||||
);
|
||||
|
||||
const preflightStep = <PreflightStep onSuccess={goToNextStep} />;
|
||||
const _preflightStep = <PreflightStep onSuccess={goToNextStep} />;
|
||||
// Create the steps array - determine which steps to include based on reAuth and oauthEnabled
|
||||
const apiKeyNeedingApproval = useMemo(() => {
|
||||
// Add API key step if needed
|
||||
|
||||
@@ -24,7 +24,6 @@ import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js';
|
||||
import { toIDEDisplayName } from '../../utils/ide.js';
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
|
||||
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js';
|
||||
import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js';
|
||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
|
||||
import { IdeStatusIndicator } from '../IdeStatusIndicator.js';
|
||||
import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js';
|
||||
@@ -57,13 +56,13 @@ type Props = {
|
||||
|
||||
export function Notifications({
|
||||
apiKeyStatus,
|
||||
autoUpdaterResult,
|
||||
autoUpdaterResult: _autoUpdaterResult,
|
||||
debug,
|
||||
isAutoUpdating,
|
||||
isAutoUpdating: _isAutoUpdating,
|
||||
verbose,
|
||||
messages,
|
||||
onAutoUpdaterResult,
|
||||
onChangeIsUpdating,
|
||||
onAutoUpdaterResult: _onAutoUpdaterResult,
|
||||
onChangeIsUpdating: _onChangeIsUpdating,
|
||||
ideSelection,
|
||||
mcpClients,
|
||||
isInputWrapped = false,
|
||||
@@ -102,9 +101,6 @@ export function Notifications({
|
||||
const shouldShowIdeSelection =
|
||||
ideStatus === 'connected' && (ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0));
|
||||
|
||||
// Hide update installed message when showing IDE selection
|
||||
const shouldShowAutoUpdater = !shouldShowIdeSelection || isAutoUpdating || autoUpdaterResult?.status !== 'success';
|
||||
|
||||
// Check if we're in overage mode for UI indicators
|
||||
const isInOverageMode = claudeAiLimits.isUsingOverage;
|
||||
const subscriptionType = getSubscriptionType();
|
||||
@@ -157,12 +153,6 @@ export function Notifications({
|
||||
verbose={verbose}
|
||||
tokenUsage={tokenUsage}
|
||||
mainLoopModel={mainLoopModel}
|
||||
shouldShowAutoUpdater={shouldShowAutoUpdater}
|
||||
autoUpdaterResult={autoUpdaterResult}
|
||||
isAutoUpdating={isAutoUpdating}
|
||||
isShowingCompactMessage={isShowingCompactMessage}
|
||||
onAutoUpdaterResult={onAutoUpdaterResult}
|
||||
onChangeIsUpdating={onChangeIsUpdating}
|
||||
/>
|
||||
</Box>
|
||||
</SentryErrorBoundary>
|
||||
@@ -180,12 +170,6 @@ function NotificationContent({
|
||||
verbose,
|
||||
tokenUsage,
|
||||
mainLoopModel,
|
||||
shouldShowAutoUpdater,
|
||||
autoUpdaterResult,
|
||||
isAutoUpdating,
|
||||
isShowingCompactMessage,
|
||||
onAutoUpdaterResult,
|
||||
onChangeIsUpdating,
|
||||
}: {
|
||||
ideSelection: IDESelection | undefined;
|
||||
mcpClients?: MCPServerConnection[];
|
||||
@@ -200,12 +184,6 @@ function NotificationContent({
|
||||
verbose: boolean;
|
||||
tokenUsage: number;
|
||||
mainLoopModel: string;
|
||||
shouldShowAutoUpdater: boolean;
|
||||
autoUpdaterResult: AutoUpdaterResult | null;
|
||||
isAutoUpdating: boolean;
|
||||
isShowingCompactMessage: boolean;
|
||||
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
|
||||
onChangeIsUpdating: (isUpdating: boolean) => void;
|
||||
}): ReactNode {
|
||||
// Poll apiKeyHelper inflight state to show slow-helper notice.
|
||||
// Gated on configuration — most users never set apiKeyHelper, so the
|
||||
@@ -226,10 +204,14 @@ function NotificationContent({
|
||||
}, []);
|
||||
|
||||
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
|
||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceError = feature('VOICE_MODE') ? useVoiceState(s => s.voiceError) : null;
|
||||
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
|
||||
const voiceStateRaw = useVoiceState(s => s.voiceState);
|
||||
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
|
||||
const voiceEnabledRaw = useVoiceEnabled();
|
||||
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
|
||||
const voiceErrorRaw = useVoiceState(s => s.voiceError);
|
||||
const voiceError = feature('VOICE_MODE') ? voiceErrorRaw : null;
|
||||
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
|
||||
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
|
||||
|
||||
// When voice is actively recording or processing, replace all
|
||||
// notifications with just the voice indicator.
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js
|
||||
import { formatImageRef, formatPastedTextRef, getPastedTextRefNumLines, parseReferences } from '../../history.js';
|
||||
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js';
|
||||
import { type HistoryMode, useArrowKeyHistory } from '../../hooks/useArrowKeyHistory.js';
|
||||
import { useBackgroundAgentTasks } from '../../hooks/useBackgroundAgentTasks.js';
|
||||
import { useDoublePress } from '../../hooks/useDoublePress.js';
|
||||
import { useHistorySearch } from '../../hooks/useHistorySearch.js';
|
||||
import type { IDESelection } from '../../hooks/useIdeSelection.js';
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js';
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import type { PermissionMode } from '../../types/permissions.js';
|
||||
import type { BaseTextInputProps, PromptInputMode, VimMode } from '../../types/textInputTypes.js';
|
||||
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
|
||||
import { count } from '../../utils/array.js';
|
||||
@@ -329,7 +329,7 @@ function PromptInput({
|
||||
const hasTungstenSession = useAppState(s => process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined);
|
||||
const tmuxFooterVisible = process.env.USER_TYPE === 'ant' && hasTungstenSession;
|
||||
// WebBrowser pill — visible when a browser is open
|
||||
const bagelFooterVisible = useAppState(s => false);
|
||||
const bagelFooterVisible = useAppState(_s => false);
|
||||
const teamContext = useAppState(s => s.teamContext);
|
||||
const queuedCommands = useCommandQueue();
|
||||
const promptSuggestionState = useAppState(s => s.promptSuggestion);
|
||||
@@ -347,8 +347,8 @@ function PromptInput({
|
||||
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
|
||||
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
|
||||
// its own marginTop, so the gap stays even without ours.
|
||||
const briefOwnsGap =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false;
|
||||
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
|
||||
const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState && !viewingAgentTaskId : false;
|
||||
const mainLoopModel_ = useAppState(s => s.mainLoopModel);
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
|
||||
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
|
||||
@@ -416,6 +416,16 @@ function PromptInput({
|
||||
// First ↓ selects the pill, second ↓ moves to row 0. Prevents double-select
|
||||
// of pill + row when both bg tasks (pill) and forked agents (rows) are visible.
|
||||
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex);
|
||||
const selectedBgAgentIndex = useAppState(s => s.selectedBgAgentIndex);
|
||||
const setSelectedBgAgentIndex = useCallback(
|
||||
(v: number | ((prev: number) => number)) =>
|
||||
setAppState(prev => {
|
||||
const next = typeof v === 'function' ? v(prev.selectedBgAgentIndex) : v;
|
||||
if (next === prev.selectedBgAgentIndex) return prev;
|
||||
return { ...prev, selectedBgAgentIndex: next };
|
||||
}),
|
||||
[setAppState],
|
||||
);
|
||||
const setCoordinatorTaskIndex = useCallback(
|
||||
(v: number | ((prev: number) => number)) =>
|
||||
setAppState(prev => {
|
||||
@@ -502,10 +512,13 @@ function PromptInput({
|
||||
(runningTaskCount > 0 || (process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0)) &&
|
||||
!shouldHideTasksFooter(tasks, showSpinnerTree);
|
||||
const teamsFooterVisible = cachedTeams.length > 0;
|
||||
const bgAgentList = useBackgroundAgentTasks();
|
||||
const bgAgentFooterVisible = bgAgentList.length > 0;
|
||||
|
||||
const footerItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
bgAgentFooterVisible && 'bg_agent',
|
||||
tasksFooterVisible && 'tasks',
|
||||
tmuxFooterVisible && 'tmux',
|
||||
bagelFooterVisible && 'bagel',
|
||||
@@ -514,6 +527,7 @@ function PromptInput({
|
||||
companionFooterVisible && 'companion',
|
||||
].filter(Boolean) as FooterItem[],
|
||||
[
|
||||
bgAgentFooterVisible,
|
||||
tasksFooterVisible,
|
||||
tmuxFooterVisible,
|
||||
bagelFooterVisible,
|
||||
@@ -538,9 +552,10 @@ function PromptInput({
|
||||
|
||||
const tasksSelected = footerItemSelected === 'tasks';
|
||||
const tmuxSelected = footerItemSelected === 'tmux';
|
||||
const bagelSelected = footerItemSelected === 'bagel';
|
||||
const _bagelSelected = footerItemSelected === 'bagel';
|
||||
const teamsSelected = footerItemSelected === 'teams';
|
||||
const bridgeSelected = footerItemSelected === 'bridge';
|
||||
const bgAgentSelected = footerItemSelected === 'bg_agent';
|
||||
|
||||
function selectFooterItem(item: FooterItem | null): void {
|
||||
setAppState(prev => (prev.footerSelection === item ? prev : { ...prev, footerSelection: item }));
|
||||
@@ -548,6 +563,9 @@ function PromptInput({
|
||||
setTeammateFooterIndex(0);
|
||||
setCoordinatorTaskIndex(minCoordinatorIndex);
|
||||
}
|
||||
if (item === 'bg_agent') {
|
||||
setSelectedBgAgentIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
// delta: +1 = down/right, -1 = up/left. Returns true if nav happened
|
||||
@@ -1809,6 +1827,15 @@ function PromptInput({
|
||||
useKeybindings(
|
||||
{
|
||||
'footer:up': () => {
|
||||
// ↑ in bg_agent pill: move selection up (-1 = main). At -1, leave pill.
|
||||
if (bgAgentSelected) {
|
||||
if (selectedBgAgentIndex > -1) {
|
||||
setSelectedBgAgentIndex(prev => prev - 1);
|
||||
} else {
|
||||
selectFooterItem(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ↑ scrolls within the coordinator task list before leaving the pill
|
||||
if (
|
||||
tasksSelected &&
|
||||
@@ -1822,6 +1849,13 @@ function PromptInput({
|
||||
navigateFooter(-1, true);
|
||||
},
|
||||
'footer:down': () => {
|
||||
// ↓ in bg_agent pill: move selection down through agents. Clamp at last.
|
||||
if (bgAgentSelected) {
|
||||
if (selectedBgAgentIndex < bgAgentList.length - 1) {
|
||||
setSelectedBgAgentIndex(prev => prev + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ↓ scrolls within the coordinator task list, never leaves the pill
|
||||
if (tasksSelected && process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0) {
|
||||
if (coordinatorTaskIndex < coordinatorTaskCount - 1) {
|
||||
@@ -1907,6 +1941,15 @@ function PromptInput({
|
||||
setShowBridgeDialog(true);
|
||||
selectFooterItem(null);
|
||||
break;
|
||||
case 'bg_agent':
|
||||
if (selectedBgAgentIndex === -1) {
|
||||
exitTeammateView(setAppState);
|
||||
} else {
|
||||
const picked = bgAgentList[selectedBgAgentIndex];
|
||||
if (picked) enterTeammateView(picked.agentId, setAppState);
|
||||
}
|
||||
// Keep the pill focused so ↑/↓ continue to work after Enter.
|
||||
break;
|
||||
}
|
||||
},
|
||||
'footer:clearSelection': () => {
|
||||
@@ -2068,7 +2111,8 @@ function PromptInput({
|
||||
|
||||
useBuddyNotification();
|
||||
|
||||
const companionSpeaking = feature('BUDDY') ? useAppState(s => s.companionReaction !== undefined) : false;
|
||||
const companionReactionState = useAppState(s => s.companionReaction);
|
||||
const companionSpeaking = feature('BUDDY') ? companionReactionState !== undefined : false;
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { memo, type ReactNode, useMemo, useRef, useState } from 'react';
|
||||
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
|
||||
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js';
|
||||
import { useSetPromptOverlay } from '../../context/promptOverlayContext.js';
|
||||
|
||||
@@ -230,9 +230,12 @@ function ModeIndicator({
|
||||
proactiveModule?.getNextTickAt ?? NULL,
|
||||
NULL,
|
||||
);
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
|
||||
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : false;
|
||||
const voiceEnabledRaw = useVoiceEnabled();
|
||||
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
|
||||
const voiceStateRaw = useVoiceState(s => s.voiceState);
|
||||
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
|
||||
const voiceWarmingUpRaw = useVoiceState(s => s.voiceWarmingUp);
|
||||
const voiceWarmingUp = feature('VOICE_MODE') ? voiceWarmingUpRaw : false;
|
||||
const hasSelection = useHasSelection();
|
||||
const selGetState = useSelection().getState;
|
||||
const hasNextTick = nextTickAt !== null;
|
||||
@@ -250,16 +253,19 @@ function ModeIndicator({
|
||||
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
|
||||
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
|
||||
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
|
||||
const voiceKeyShortcut = feature('VOICE_MODE') ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '';
|
||||
const voiceKeyShortcutRaw = useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space');
|
||||
const voiceKeyShortcut = feature('VOICE_MODE') ? voiceKeyShortcutRaw : '';
|
||||
// Captured at mount so the hint doesn't flicker mid-session if another
|
||||
// CC instance increments the counter. Incremented once via useEffect the
|
||||
// first time voice is enabled in this session — approximates "hint was
|
||||
// shown" without tracking the exact render-time condition (which depends
|
||||
// on parts/hintParts computed after the early-return hooks boundary).
|
||||
const [voiceHintUnderCap] = feature('VOICE_MODE')
|
||||
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
|
||||
: [false];
|
||||
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
|
||||
const [voiceHintUnderCapRaw] = useState(
|
||||
() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS,
|
||||
);
|
||||
const voiceHintUnderCap = feature('VOICE_MODE') ? voiceHintUnderCapRaw : false;
|
||||
const voiceHintIncrementedRefRaw = useRef(false);
|
||||
const voiceHintIncrementedRef = feature('VOICE_MODE') ? voiceHintIncrementedRefRaw : null;
|
||||
useEffect(() => {
|
||||
if (feature('VOICE_MODE')) {
|
||||
if (!voiceEnabled || !voiceHintUnderCap) return;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { memo, type ReactNode } from 'react';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
|
||||
@@ -80,7 +80,8 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
|
||||
// already indent themselves). Gate mirrors the brief-spinner/message
|
||||
// check elsewhere — no teammate-view override needed since this
|
||||
// component early-returns when viewing a teammate.
|
||||
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
|
||||
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
|
||||
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
|
||||
|
||||
// createUserMessage mints a fresh UUID per call; without memoization, streaming
|
||||
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { SandboxViolationEvent } from '../utils/sandbox/sandbox-adapter.js';
|
||||
|
||||
@@ -15,14 +15,11 @@ import {
|
||||
} from '../../utils/config.js';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
permissionModeTitle,
|
||||
permissionModeShortTitle,
|
||||
permissionModeFromString,
|
||||
toExternalPermissionMode,
|
||||
isExternalPermissionMode,
|
||||
EXTERNAL_PERMISSION_MODES,
|
||||
PERMISSION_MODES,
|
||||
type ExternalPermissionMode,
|
||||
type PermissionMode,
|
||||
} from '../../utils/permissions/PermissionMode.js';
|
||||
import {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
|
||||
import { isEnvTruthy } from '../utils/envUtils.js';
|
||||
import { count } from '../utils/array.js';
|
||||
import sample from 'lodash-es/sample.js';
|
||||
import { formatDuration, formatNumber, formatSecondsShort } from '../utils/format.js';
|
||||
import { formatDuration, formatNumber } from '../utils/format.js';
|
||||
import type { Theme } from 'src/utils/theme.js';
|
||||
import { activityManager } from '../utils/activityManager.js';
|
||||
import { getSpinnerVerbs } from '../constants/spinnerVerbs.js';
|
||||
@@ -78,10 +78,8 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
|
||||
// teammate view needs the real spinner (which shows teammate status).
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
|
||||
// Hoisted to mount-time — this component re-renders at animation framerate.
|
||||
const briefEnvEnabled =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||
: false;
|
||||
const briefEnvEnabledRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
|
||||
const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ? briefEnvEnabledRaw : false;
|
||||
|
||||
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
|
||||
// BriefTool.ts would leak tool-name strings into external builds. Single
|
||||
@@ -244,7 +242,7 @@ function SpinnerWithVerbInner({
|
||||
|
||||
// TTFT display is gated to internal builds — apiMetricsRef was removed from
|
||||
// props during a refactor, so skip this until it's re-threaded.
|
||||
let ttftText: string | null = null;
|
||||
const _ttftText: string | null = null;
|
||||
|
||||
// When leader is idle but teammates are running (and we're viewing the leader),
|
||||
// show a static dim idle display instead of the animated spinner — otherwise
|
||||
|
||||
@@ -288,6 +288,15 @@ function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props
|
||||
}
|
||||
}, [lastAssistantMessageId, permissionMode, vimMode, mainLoopModel, scheduleUpdate]);
|
||||
|
||||
// Time-driven refresh: tick setInterval(refreshInterval seconds) through the
|
||||
// existing debounced scheduleUpdate so interval + message-change don't double-fire.
|
||||
const refreshIntervalMs = (settings?.statusLine?.refreshInterval ?? 0) * 1000;
|
||||
useEffect(() => {
|
||||
if (refreshIntervalMs <= 0) return;
|
||||
const id = setInterval(() => scheduleUpdate(), refreshIntervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshIntervalMs, scheduleUpdate]);
|
||||
|
||||
// When the statusLine command changes (hot reload), log the next result
|
||||
const statusLineCommand = settings?.statusLine?.command;
|
||||
const isFirstSettingsRender = useRef(true);
|
||||
|
||||
@@ -44,14 +44,18 @@ export default function TextInput(props: Props): React.ReactNode {
|
||||
const settings = useSettings();
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false;
|
||||
|
||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
|
||||
const voiceStateRaw = useVoiceState(s => s.voiceState);
|
||||
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
|
||||
const isVoiceRecording = voiceState === 'recording';
|
||||
|
||||
const audioLevels = feature('VOICE_MODE') ? useVoiceState(s => s.voiceAudioLevels) : [];
|
||||
const audioLevelsRaw = useVoiceState(s => s.voiceAudioLevels);
|
||||
const audioLevels = feature('VOICE_MODE') ? audioLevelsRaw : [];
|
||||
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0));
|
||||
|
||||
const needsAnimation = isVoiceRecording && !reducedMotion;
|
||||
const [animRef, animTime] = feature('VOICE_MODE') ? useAnimationFrame(needsAnimation ? 50 : null) : [() => {}, 0];
|
||||
const [animRefRaw, animTimeRaw] = useAnimationFrame(needsAnimation ? 50 : null);
|
||||
const animRef = feature('VOICE_MODE') ? animRefRaw : () => {};
|
||||
const animTime = feature('VOICE_MODE') ? animTimeRaw : 0;
|
||||
|
||||
// Show hint when terminal regains focus and clipboard has an image
|
||||
useClipboardImageHint(isTerminalFocused, !!props.onImagePaste);
|
||||
|
||||
53
src/components/ToolSearchHint.tsx
Normal file
53
src/components/ToolSearchHint.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Select } from './CustomSelect/select.js';
|
||||
import { PermissionDialog } from './permissions/PermissionDialog.js';
|
||||
|
||||
type ToolSearchHintItem = {
|
||||
name: string;
|
||||
description: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
tools: ToolSearchHintItem[];
|
||||
onSelect: (toolName: string) => void;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
const AUTO_DISMISS_MS = 30_000;
|
||||
|
||||
export function ToolSearchHint({ tools, onSelect, onDismiss }: Props): React.ReactNode {
|
||||
const onSelectRef = React.useRef(onSelect);
|
||||
const onDismissRef = React.useRef(onDismiss);
|
||||
onSelectRef.current = onSelect;
|
||||
onDismissRef.current = onDismiss;
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(ref => ref.current(), AUTO_DISMISS_MS, onDismissRef);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
const options = tools.map(t => ({
|
||||
label: `${t.name} — ${t.description.slice(0, 60)} (score: ${t.score.toFixed(2)})`,
|
||||
value: t.name,
|
||||
}));
|
||||
|
||||
options.push({ label: 'Dismiss', value: '__dismiss__' });
|
||||
|
||||
return (
|
||||
<PermissionDialog title="Tool Recommendation">
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => {
|
||||
if (value === '__dismiss__') {
|
||||
onDismissRef.current();
|
||||
} else {
|
||||
onDismissRef.current();
|
||||
onSelectRef.current(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PermissionDialog>
|
||||
);
|
||||
}
|
||||
@@ -174,10 +174,9 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
|
||||
<Text bold>{getFsImplementation().cwd()}</Text>
|
||||
|
||||
<Text>
|
||||
Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open
|
||||
source project, or work from your team). If not, take a moment to review what{"'"}s in this folder first.
|
||||
Is this a project you trust? (Your own code, a well-known open source project, or work from your team).
|
||||
</Text>
|
||||
<Text>Claude Code{"'"}ll be able to read, edit, and execute files here.</Text>
|
||||
<Text>Once trusted, Claude Code can read, edit, and run commands in this folder.</Text>
|
||||
|
||||
<Text dimColor>
|
||||
<Link url="https://code.claude.com/docs/en/security">Security guide</Link>
|
||||
|
||||
80
src/components/__tests__/ToolSearchHint.test.ts
Normal file
80
src/components/__tests__/ToolSearchHint.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { mock } from 'bun:test'
|
||||
import { logMock } from '../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../tests/mocks/debug'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||
getFeatureValue_DEPRECATED: async () => undefined,
|
||||
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||
hasGrowthBookEnvOverride: () => false,
|
||||
getAllGrowthBookFeatures: () => ({}),
|
||||
getGrowthBookConfigOverrides: () => ({}),
|
||||
setGrowthBookConfigOverride: () => {},
|
||||
clearGrowthBookConfigOverrides: () => {},
|
||||
getApiBaseUrlHost: () => undefined,
|
||||
onGrowthBookRefresh: () => {},
|
||||
initializeGrowthBook: async () => {},
|
||||
checkSecurityRestrictionGate: async () => false,
|
||||
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||
refreshGrowthBookAfterAuthChange: () => {},
|
||||
resetGrowthBook: () => {},
|
||||
refreshGrowthBookFeatures: async () => {},
|
||||
setupPeriodicGrowthBookRefresh: () => {},
|
||||
stopPeriodicGrowthBookRefresh: () => {},
|
||||
getDynamicConfig_CACHED_MAY_BE_STALE: () => undefined,
|
||||
getDynamicConfig_BLOCKS_ON_INIT: async () => undefined,
|
||||
}))
|
||||
|
||||
const {
|
||||
subscribeToToolSearchPrefetch,
|
||||
getToolSearchPrefetchSnapshot,
|
||||
clearToolSearchPrefetchResults,
|
||||
} = await import('src/services/toolSearch/prefetch.js')
|
||||
|
||||
const { useToolSearchHint } = await import('src/hooks/useToolSearchHint.js')
|
||||
|
||||
describe('useToolSearchHint', () => {
|
||||
// We test the subscription/snapshot API directly since
|
||||
// React hooks require a renderer.
|
||||
test('returns empty tools when no prefetch result', () => {
|
||||
clearToolSearchPrefetchResults()
|
||||
const snapshot = getToolSearchPrefetchSnapshot()
|
||||
expect(snapshot).toEqual([])
|
||||
})
|
||||
|
||||
test('snapshot updates when listeners are notified', () => {
|
||||
clearToolSearchPrefetchResults()
|
||||
|
||||
// Simulate what prefetch does: set results and notify
|
||||
const mockSetResults = (results: unknown[]) => {
|
||||
// We can't directly set latestPrefetchResult, but we can test
|
||||
// the clear function and subscription mechanism
|
||||
clearToolSearchPrefetchResults()
|
||||
}
|
||||
|
||||
// Test subscription
|
||||
let callCount = 0
|
||||
const unsubscribe = subscribeToToolSearchPrefetch(() => {
|
||||
callCount++
|
||||
})
|
||||
expect(callCount).toBe(0)
|
||||
|
||||
// Trigger a notification via clear
|
||||
mockSetResults([])
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
// Unsubscribe and verify no more calls
|
||||
unsubscribe()
|
||||
clearToolSearchPrefetchResults()
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
test('clearToolSearchPrefetchResults resets snapshot', () => {
|
||||
clearToolSearchPrefetchResults()
|
||||
expect(getToolSearchPrefetchSnapshot()).toEqual([])
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user