mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62b384e36 | ||
|
|
d7001b870f | ||
|
|
18437c20d2 | ||
|
|
02298cb199 | ||
|
|
b2b1981da3 | ||
|
|
33c52578a6 | ||
|
|
e33b17bde7 | ||
|
|
797424115d | ||
|
|
efc218d8a9 | ||
|
|
a91653a0dd | ||
|
|
c982104476 | ||
|
|
6dd378bf15 | ||
|
|
ed61932748 | ||
|
|
b1c4f40f90 | ||
|
|
f91060836f | ||
|
|
9d17597e58 | ||
|
|
f2b751f659 | ||
|
|
d4a601475f | ||
|
|
897c186f28 | ||
|
|
03598d3f84 | ||
|
|
7b52054ff5 | ||
|
|
66c892521b | ||
|
|
dab04af7c9 | ||
|
|
5b5fbb2f47 | ||
|
|
9bfa868e61 | ||
|
|
f6dcf63902 | ||
|
|
5957e26d9b | ||
|
|
58c3feb56a | ||
|
|
e2f4d558e1 | ||
|
|
9afcb398ca | ||
|
|
c80a6d062b | ||
|
|
a05242cef0 | ||
|
|
27b334aceb | ||
|
|
27b665ac79 | ||
|
|
ea399f1862 | ||
|
|
c499bfb4ed | ||
|
|
b67e9f9d38 | ||
|
|
2bca31e525 | ||
|
|
2cc9a7daef | ||
|
|
d66a6f6124 | ||
|
|
48a19b8a0d | ||
|
|
5157b09743 | ||
|
|
ecd3f9d791 | ||
|
|
5b941d4ad4 | ||
|
|
ae7a4e5ae5 | ||
|
|
e5f31afebd | ||
|
|
fc8d531a7d | ||
|
|
835dd2d804 | ||
|
|
0face46fbe | ||
|
|
d451e30741 | ||
|
|
e7070e072f | ||
|
|
833181e025 | ||
|
|
80b46d2221 | ||
|
|
78d46aa233 | ||
|
|
b3d28bcdf1 | ||
|
|
1f80043928 | ||
|
|
3d7b32f52e | ||
|
|
2c8a22d4b3 | ||
|
|
ea5147420d | ||
|
|
3d0f1acfb7 | ||
|
|
478091567d | ||
|
|
b4e52d0c9e | ||
|
|
d11b35e023 | ||
|
|
8570b6ba01 | ||
|
|
db606b5589 | ||
|
|
27a01113e4 | ||
|
|
4a39fd74b1 | ||
|
|
5486d3c02c | ||
|
|
aaabf0c168 | ||
|
|
43c20a43c2 | ||
|
|
17c06690d8 | ||
|
|
89800137b6 |
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: Bug 报告
|
||||
description: 报告一个可复现的 bug
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
## 发帖前必读
|
||||
|
||||
- [ ] 我已经搜索过 [现有 Issues](https://github.com/claude-code-best/claude-code/issues),没有找到重复。
|
||||
- [ ] 我使用的是 **最新版本**(`bun run build` 或最新 release)。
|
||||
- [ ] 我已经阅读过 [README](https://github.com/claude-code-best/claude-code) 和相关文档。
|
||||
|
||||
**未完成以上检查的 Issue 将被直接关闭。**
|
||||
|
||||
---
|
||||
|
||||
## 运行环境
|
||||
|
||||
| 项目| 值|
|
||||
|---|---|
|
||||
| 操作系统| 例如 macOS 15.4、Ubuntu 24.04|
|
||||
| Bun 版本| 例如 `bun --version` 的输出|
|
||||
| Claude Code 版本| 例如 `2.4.3` 或 commit hash|
|
||||
| 安装方式| `bun run build` / npm / 其他|
|
||||
| 模型| 例如 claude-sonnet-4-6、claude-opus-4-7|
|
||||
|
||||
## 复现步骤
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 期望行为
|
||||
|
||||
<!-- 应该发生什么? -->
|
||||
|
||||
## 实际行为
|
||||
|
||||
<!-- 实际发生了什么?如有必要可附截图。 -->
|
||||
|
||||
## 相关日志
|
||||
|
||||
<!-- 粘贴终端输出或错误信息,请使用 triple backticks 代码块。 -->
|
||||
|
||||
```text
|
||||
```
|
||||
|
||||
## 补充信息
|
||||
|
||||
<!-- 其他上下文 — 配置、环境变量、尝试过的 workaround 等。 -->
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/claude-code-best/claude-code/discussions
|
||||
about: 使用问题、功能建议和一般讨论 — 请使用 Discussions 而非 Issues。
|
||||
- name: 📖 项目文档
|
||||
url: https://github.com/claude-code-best/claude-code
|
||||
about: 提交 issue 前,请先阅读 README 和相关文档,你的问题可能已经有答案了。
|
||||
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: 功能建议
|
||||
description: 提出新功能或改进建议
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
## 发帖前必读
|
||||
|
||||
- [ ] 我已经搜索过 [现有 Issues](https://github.com/claude-code-best/claude-code/issues),没有找到重复。
|
||||
- [ ] 这是功能建议,不是 Bug 报告或使用问题。
|
||||
- [ ] 使用问题请前往 [Discussions](https://github.com/claude-code-best/claude-code/discussions)。
|
||||
|
||||
---
|
||||
|
||||
## 要解决的问题
|
||||
|
||||
<!-- 这个功能解决什么问题?为什么需要它? -->
|
||||
|
||||
## 建议方案
|
||||
|
||||
<!-- 描述你建议的实现方式,尽量简洁具体。 -->
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
<!-- 还有没有想到的其他实现思路? -->
|
||||
|
||||
## 补充信息
|
||||
|
||||
<!-- 截图、草图、参考资料,或其他有助于说明需求的内容。 -->
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -42,7 +42,8 @@ jobs:
|
||||
run: |
|
||||
# Tolerate pre-existing flaky tests (Bun mock pollution / order-dependent state).
|
||||
# We still require lcov.info to be generated and contain real coverage data.
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s || true
|
||||
set -o pipefail
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
test -s coverage/lcov.info
|
||||
grep -q '^SF:' coverage/lcov.info
|
||||
|
||||
|
||||
4
.github/workflows/publish-npm.yml
vendored
4
.github/workflows/publish-npm.yml
vendored
@@ -3,11 +3,11 @@ name: Publish to npm
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.9.0)'
|
||||
description: "版本号 (例如: v1.9.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
52
CLAUDE.md
52
CLAUDE.md
@@ -78,8 +78,9 @@ bun run docs:dev
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。构建时会将 `vendor/audio-capture/` 和 `src/utils/vendor/ripgrep/` 复制到 `dist/vendor/` 下。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,chunk 输出到 `dist/chunks/`。post-build 同样复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/ripgrep.ts` 和 `packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,代码分割模式,chunk 输出到 `dist/chunks/`。post-build 遍历 `dist/` 和 `dist/chunks/` 下所有 `.js` 文件做 `globalThis.Bun` 解构 patch,复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/distRoot.ts` 提供共享的 `distRoot` 函数,通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 或 `lastIndexOf('src')` 定位根目录。`ripgrep.ts`、`computerUse/setup.ts`、`claudeInChrome/setup.ts`、`updateCCB.ts` 均使用 `distRoot` 而非内联 `import.meta.url` 路径推算。`packages/audio-capture-napi/src/index.ts` 有独立的 `lastIndexOf('dist')` 逻辑,功能等价。
|
||||
- **为什么 Vite 必须代码分割**: Bun/JSC 会全量解析单个大 JS 文件的 bytecode 和 JIT,单文件 17MB 产物导致 RSS 暴涨至 ~1GB(Node/V8 懒解析仅需 ~220MB)。代码分割为 600+ 小 chunk 后 Bun 按需加载,`--version` RSS 从 966MB 降至 35MB,完整加载从 1GB+ 降至 ~500MB。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 17 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
@@ -119,11 +120,6 @@ bun run docs:dev
|
||||
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
|
||||
- Provider selection in `src/utils/model/providers.ts`。优先级:modelType 参数 > 环境变量 > 默认 firstParty。
|
||||
|
||||
### Encoding Detection
|
||||
|
||||
- **`src/utils/encoding.ts`** — 文件编码检测的唯一入口。提供 `detectEncoding`(三层检测:BOM → UTF-8 fatal → ICU 回退链)和 `decodeBuffer`/`encodeString` 函数。检测基于文件头部 4KB,零外部依赖,仅使用 TextDecoder API。ISO-8859-1 作为最终兜底编码(单字节编码永远成功)。`FileEncoding` 类型扩展了 `BufferEncoding`,覆盖 gbk/gb18030/shift_jis/euc-kr/euc-jp/big5/iso-8859-1。
|
||||
- `fs.readFileSync(path, { encoding })` 的 `encoding` 选项只接受 `BufferEncoding`,不支持 `gbk`/`shift_jis` 等 ICU 编码名。读取非 UTF-8 文件时必须先 `fs.readFileSync(path)` 读 Buffer,再用 `TextDecoder` 解码。项目中所有文件读取路径(fileRead.ts、fileReadCache.ts、file.ts)已统一使用 `decodeBuffer` 函数处理此逻辑。
|
||||
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
@@ -319,6 +315,48 @@ mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
|
||||
#### 跨文件 mock 污染(process-global `mock.module`)
|
||||
|
||||
**Bun 的 `mock.module` 是进程全局的(last-write-wins),不是 per-file 隔离的。** 一个测试文件的 `mock.module` 会污染同一进程中所有其他测试文件的 `require`/`import`。
|
||||
|
||||
**关键事实(Bun 1.x 实测验证):**
|
||||
- 测试文件执行顺序**不是严格字母序**,不要假设文件 A 一定在文件 B 之前执行。
|
||||
- `mock.module` 在 `beforeAll` 内部调用时**不会被提升**(hoist),但仍会污染后续加载的文件。
|
||||
- `require()` 和 `import()` 共享同一模块注册表,`mock.module` 对两者都生效。
|
||||
- 一个模块一旦被某个文件的 `mock.module` 替换,同一进程中所有后续 `require`/`import` 都会返回 mock 值,即使调用方使用不同的 specifier 路径。
|
||||
|
||||
**核心规则:不要 mock 被测模块的上层业务模块。**
|
||||
|
||||
错误做法(会污染同目录的 `api.test.ts`):
|
||||
```ts
|
||||
// launchSchedule.test.ts — 直接 mock 源 API 模块 ❌
|
||||
mock.module('src/commands/schedule/triggersApi.js', () => ({
|
||||
listTriggers: listTriggersMock,
|
||||
// ...
|
||||
}))
|
||||
```
|
||||
|
||||
正确做法(mock 底层 HTTP 层,不污染业务模块):参考 `launchSkillStore.test.ts`、`launchVault.test.ts` 的模式。
|
||||
```ts
|
||||
// launchSchedule.test.ts — mock axios 而非 triggersApi ✅
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
|
||||
beforeAll(() => { axiosHandle.useStubs = true })
|
||||
afterAll(() => { axiosHandle.useStubs = false })
|
||||
```
|
||||
|
||||
**判断标准:** 如果目录下同时有 `launch*.test.ts`(集成测试)和 `api.test.ts`(回归测试),`launch*.test.ts` 必须 mock axios 而非源 API 模块。`api.test.ts` 需要测试真实 API 模块的 HTTP 方法/URL/错误处理逻辑,被 mock 后就无法测试。
|
||||
|
||||
**排查 mock 污染的方法:**
|
||||
1. 单独运行可疑文件确认其通过:`bun test path/to/suspect.test.ts`
|
||||
2. 与同目录其他文件一起运行定位污染源:`bun test path/to/__tests__/`
|
||||
3. 在两个文件中各加 `console.error('[file] milestone')` 追踪实际执行顺序
|
||||
4. 检查 `mock.module` 的 specifier 是否与同目录其他测试的 `require`/`import` 路径解析到同一模块
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
|
||||
> Which Claude do you like? The open source one is the best.
|
||||
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 完整复原的工程化项目。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 并在此基础上扩展了更多好玩的特性。
|
||||
|
||||
> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
||||
[Peri Code](https://github.com/KonghaYao/peri):Claude Code 兼容的 Rust Agent,多年大模型经验匠心制作,国内大模型(DeepSeek/GLM)精调,CPU/内存极致优化,在开发版/树莓派上也能跑 CC 一样的体验。
|
||||
|
||||
[文档在这里](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组,群主在线答疑](https://discord.gg/uApuzJWGKX)
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -150,7 +149,6 @@ bun run build
|
||||
|
||||
需要填写的字段:
|
||||
|
||||
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
| ------------ | ------------- | ---------------------------- |
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.5 MiB |
54
docs/performance-reporter.md
Normal file
54
docs/performance-reporter.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 内存占用 1G 调研报告
|
||||
|
||||
> 诊断 session `a3593062` RSS 达 1.09 GB,定位 Bun 运行时内存膨胀根因
|
||||
|
||||
## 数据收集
|
||||
|
||||
- **诊断数据**: RSS 1,118 MB,V8 heap 84 MB,原生内存缺口 1,034 MB(92%)
|
||||
- **构建方式**: `bun run build:vite` → Vite/Rollup 单文件构建,产物 17MB `dist/cli.js`
|
||||
- **Vite 配置**: `codeSplitting: false`(`vite.config.ts:97`),所有代码内联为单文件
|
||||
- **Node.js 对比**: 相同 17MB 产物,Node.js RSS 仅 223 MB(`--version`)/ 340 MB(完整加载)
|
||||
|
||||
## 探索与验证
|
||||
|
||||
### 已确认
|
||||
|
||||
| 问题 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| **根因: Vite 单文件构建 + Bun 解析大文件内存效率低** | `vite.config.ts:97` | `codeSplitting: false` 产出 17MB 单文件,Bun/JSC 解析时 RSS 暴涨至 966MB |
|
||||
| Node.js 对同等 17MB 文件仅需 223MB | 实测 | V8 对大文件解析的内存效率远优于 JSC |
|
||||
| Bun.build 代码分割可解决问题 | 实测 | `bun run build`(代码分割 → 627 chunk)Bun RSS 仅 30MB(`--version`)/ 318MB(完整加载) |
|
||||
|
||||
### 已否认
|
||||
|
||||
- 不是 feature flags 数量问题 — 全部 35 features 开启时,代码分割构建内存正常
|
||||
- 不是内存泄漏 — `detachedContexts: 0`,`activeHandles: 0`
|
||||
- 不是原生 addon 问题 — vendor 文件仅 2.7MB
|
||||
- 不是 TypeScript 源码体量问题 — `bun run dev`(直接加载 TS)完整路径仅 345MB
|
||||
|
||||
## 结论
|
||||
|
||||
**根因是 Vite 构建配置 `codeSplitting: false`,产出 17MB 单文件,Bun/JSC 解析单文件大 JS 时内存效率极差(966MB vs Node 的 223MB)。**
|
||||
|
||||
实测对比矩阵:
|
||||
|
||||
| 构建方式 | 产物结构 | Bun RSS | Node RSS | Bun/Node |
|
||||
|----------|----------|---------|----------|----------|
|
||||
| `build:vite` | 17MB 单文件 | **966 MB** | 223 MB | 4.3x |
|
||||
| `build:vite` pipe mode | 同上 | **1,088 MB** | 340 MB | 3.2x |
|
||||
| `build` (Bun) | 627 chunk | 30 MB | 42 MB | 0.7x |
|
||||
| `build` (Bun) pipe mode | 同上 | 318 MB | 253 MB | 1.3x |
|
||||
| `bun run dev` TS 源码 | 动态加载 | 42 MB | — | — |
|
||||
| `bun run dev` pipe mode | 动态加载 | 345 MB | — | — |
|
||||
|
||||
核心差异:
|
||||
- **Node/V8** 解析 17MB 文件只需 223MB — V8 的懒解析(lazy parsing)只编译入口需要的部分
|
||||
- **Bun/JSC** 解析 17MB 文件需要 966MB — JSC 对单文件做全量编译,bytecode + JIT 占用大量原生内存
|
||||
- 代码分割后(627 个小 chunk),Bun 按需加载,内存回到正常水平
|
||||
|
||||
## 建议
|
||||
|
||||
1. **开启 Vite 代码分割** — 在 `vite.config.ts` 中启用 `codeSplitting: true` 或使用 Rollup 的 `manualChunks` 配置。这是最直接的修复
|
||||
2. **或切换到 Bun.build** — `bun run build` 已默认启用代码分割(`splitting: true`),Bun RSS 仅 30-318MB
|
||||
3. **如果必须单文件** — 考虑用 Node.js 运行 Vite 产物(`node dist/cli-node.js`),代价是失去 Bun 特有 API
|
||||
4. **验证 `codeSplitting: false` 的存在理由** — 注释说"all dynamic imports inlined",可能是为了简化部署。评估是否真的需要单文件
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.4.0",
|
||||
"version": "2.6.6",
|
||||
"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>",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SocketConnectionError } from './mcpSocketClient.js'
|
||||
import {
|
||||
localPlatformLabel,
|
||||
type BridgePermissionRequest,
|
||||
toLoggerDetail,
|
||||
type ChromeExtensionInfo,
|
||||
type ClaudeForChromeContext,
|
||||
type PermissionMode,
|
||||
@@ -578,7 +579,7 @@ export class BridgeClient implements SocketClient {
|
||||
const durationMs = Date.now() - this.connectionStartTime
|
||||
logger.error(
|
||||
`[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
|
||||
error,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
trackEvent?.('chrome_bridge_connection_failed', {
|
||||
duration_ms: durationMs,
|
||||
@@ -618,7 +619,10 @@ export class BridgeClient implements SocketClient {
|
||||
)
|
||||
this.handleMessage(message)
|
||||
} catch (error) {
|
||||
logger.error(`[${serverName}] Failed to parse bridge message:`, error)
|
||||
logger.error(
|
||||
`[${serverName}] Failed to parse bridge message:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -862,7 +866,10 @@ export class BridgeClient implements SocketClient {
|
||||
const allowed = await pending.onPermissionRequest(request)
|
||||
this.sendPermissionResponse(requestId, allowed)
|
||||
} catch (error) {
|
||||
logger.error(`[${serverName}] Error handling permission request:`, error)
|
||||
logger.error(
|
||||
`[${serverName}] Error handling permission request:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
this.sendPermissionResponse(requestId, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ export { localPlatformLabel } from './types.js'
|
||||
export type {
|
||||
BridgeConfig,
|
||||
ChromeExtensionInfo,
|
||||
ChromeBridgeTrackEventMetadata,
|
||||
ClaudeForChromeContext,
|
||||
Logger,
|
||||
LoggerDetail,
|
||||
PermissionMode,
|
||||
SocketClient,
|
||||
} from './types.js'
|
||||
export { toLoggerDetail } from './types.js'
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
export class SocketConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -87,7 +88,10 @@ class McpSocketClient {
|
||||
await this.validateSocketSecurity(socketPath)
|
||||
} catch (error) {
|
||||
this.connecting = false
|
||||
logger.info(`[${serverName}] Security validation failed:`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Security validation failed:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
// Don't retry on security failures (wrong perms/owner) - those won't
|
||||
// self-resolve. Only the error handler retries on transient errors.
|
||||
return
|
||||
@@ -145,14 +149,20 @@ class McpSocketClient {
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Failed to parse message:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('error', (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Socket error (code: ${error.code}):`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PermissionOverrides,
|
||||
SocketClient,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
export const handleToolCall = async (
|
||||
context: ClaudeForChromeContext,
|
||||
@@ -44,7 +45,10 @@ export const handleToolCall = async (
|
||||
|
||||
return handleToolCallDisconnected(context)
|
||||
} catch (error) {
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
|
||||
context.logger.info(
|
||||
`[${context.serverName}] Error calling tool:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
|
||||
if (error instanceof SocketConnectionError) {
|
||||
return handleToolCallDisconnected(context)
|
||||
@@ -165,8 +169,7 @@ async function handleToolCallConnected(
|
||||
|
||||
// Fallback for unexpected result format
|
||||
context.logger.warn(
|
||||
`[${context.serverName}] Unexpected result format from socket bridge`,
|
||||
response,
|
||||
`[${context.serverName}] Unexpected result format from socket bridge: ${JSON.stringify(response)}`,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,84 @@
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
/**
|
||||
* Logger 第二参数的可选类型。
|
||||
* 调用方通过 util.format 追加详情,实践中多为 catch 到的异常对象。
|
||||
*/
|
||||
export type LoggerDetail = Error | NodeJS.ErrnoException
|
||||
|
||||
/** 将 unknown 收窄为 LoggerDetail,供 catch 块传给 logger 使用。 */
|
||||
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
|
||||
return detail instanceof Error ? detail : undefined
|
||||
}
|
||||
|
||||
/** 宿主注入的日志接口,与 DebugLogger(util.format)对齐。 */
|
||||
export interface Logger {
|
||||
info: (message: string, detail?: LoggerDetail) => void // 信息
|
||||
error: (message: string, detail?: LoggerDetail) => void // 错误
|
||||
warn: (message: string, detail?: LoggerDetail) => void // 警告
|
||||
debug: (message: string, detail?: LoggerDetail) => void // 调试
|
||||
silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge 连接失败时的 error_type 枚举。
|
||||
* 由 bridgeClient 在 getUserId / getOAuthToken / WebSocket 创建失败时上报。
|
||||
*/
|
||||
export type ChromeBridgeConnectionErrorType =
|
||||
| 'no_user_id' // 无法获取用户 UUID
|
||||
| 'no_oauth_token' // 无法获取 OAuth token
|
||||
| 'websocket_error' // WebSocket 创建或运行异常
|
||||
|
||||
/** 工具调用相关遥测元数据(started / completed / timeout / error)。 */
|
||||
export type ChromeBridgeToolCallMetadata = {
|
||||
tool_name: string // MCP 工具名
|
||||
tool_use_id: string // 本次调用的 UUID
|
||||
duration_ms?: number // 耗时(毫秒)
|
||||
timeout_ms?: number // 超时阈值(毫秒),仅 timeout 事件
|
||||
error_message?: string // 错误摘要(截断),仅 error 事件
|
||||
}
|
||||
|
||||
/** Bridge 连接失败遥测元数据。 */
|
||||
export type ChromeBridgeConnectionFailedMetadata = {
|
||||
duration_ms: number // 自连接开始到失败的耗时(毫秒)
|
||||
error_type: ChromeBridgeConnectionErrorType // 失败原因分类
|
||||
reconnect_attempt: number // 当前重连尝试次数
|
||||
}
|
||||
|
||||
/** Bridge 开始连接遥测元数据。 */
|
||||
export type ChromeBridgeConnectionStartedMetadata = {
|
||||
bridge_url: string // 目标 WebSocket URL(含用户路径)
|
||||
}
|
||||
|
||||
/** Bridge 断开连接遥测元数据。 */
|
||||
export type ChromeBridgeDisconnectedMetadata = {
|
||||
close_code: number // WebSocket 关闭码
|
||||
duration_since_connect_ms: number // 自连接成功到断开的时长(毫秒)
|
||||
reconnect_attempt: number // 即将进行的重连序号
|
||||
}
|
||||
|
||||
/** Bridge 连接成功遥测元数据。 */
|
||||
export type ChromeBridgeConnectionSucceededMetadata = {
|
||||
duration_ms: number // 自开始到连接就绪的耗时(毫秒)
|
||||
status: 'paired' | 'waiting' // paired=已配对扩展;waiting=等待扩展接入
|
||||
}
|
||||
|
||||
/** Bridge 重连次数耗尽遥测元数据。 */
|
||||
export type ChromeBridgeReconnectExhaustedMetadata = {
|
||||
total_attempts: number // 累计重连次数上限
|
||||
}
|
||||
|
||||
/**
|
||||
* trackEvent 回调的 metadata 联合类型。
|
||||
* 各变体对应 bridgeClient 内 chrome_bridge_* 事件;null 表示无附加字段。
|
||||
*/
|
||||
export type ChromeBridgeTrackEventMetadata =
|
||||
| ChromeBridgeToolCallMetadata
|
||||
| ChromeBridgeConnectionFailedMetadata
|
||||
| ChromeBridgeConnectionStartedMetadata
|
||||
| ChromeBridgeDisconnectedMetadata
|
||||
| ChromeBridgeConnectionSucceededMetadata
|
||||
| ChromeBridgeReconnectExhaustedMetadata
|
||||
| null // 无元数据(如 peer_connected / peer_disconnected)
|
||||
|
||||
export type PermissionMode =
|
||||
| 'ask'
|
||||
| 'skip_all_permission_checks'
|
||||
@@ -48,10 +121,10 @@ export interface ClaudeForChromeContext {
|
||||
bridgeConfig?: BridgeConfig
|
||||
/** If set, permission mode is sent to the extension immediately on bridge connection. */
|
||||
initialPermissionMode?: PermissionMode
|
||||
/** Optional callback to track telemetry events for bridge connections */
|
||||
trackEvent?: <K extends string>(
|
||||
eventName: K,
|
||||
metadata: Record<string, unknown> | null,
|
||||
/** Bridge 遥测回调;eventName 为 chrome_bridge_* 事件名 */
|
||||
trackEvent?: (
|
||||
eventName: string, // 事件名
|
||||
metadata: ChromeBridgeTrackEventMetadata, // 事件元数据
|
||||
) => void
|
||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import type { ScreenshotResult } from './executor.js'
|
||||
import type { Logger } from './types.js'
|
||||
import { type Logger, toLoggerDetail } from './types.js'
|
||||
|
||||
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
|
||||
export type CropRawPatchFn = (
|
||||
@@ -165,7 +165,10 @@ export async function validateClickTarget(
|
||||
} catch (err) {
|
||||
// Skip validation on technical errors, execute action anyway.
|
||||
// Battle-tested: validation failure must never block the click.
|
||||
logger.debug('[pixelCompare] validation error, skipping', err)
|
||||
logger.debug(
|
||||
'[pixelCompare] validation error, skipping',
|
||||
toLoggerDetail(err),
|
||||
)
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import type {
|
||||
ResolvedAppRequest,
|
||||
TeachStepRequest,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
/**
|
||||
* Finder is never hidden by the hide loop (hiding Finder kills the Desktop),
|
||||
@@ -4446,7 +4447,10 @@ export async function handleToolCall(
|
||||
// For ungated tools, the executor may have been mid-call; that's fine —
|
||||
// the result is still a tool error, never an implicit success.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`[${serverName}] tool=${name} threw: ${msg}`, err)
|
||||
logger.error(
|
||||
`[${serverName}] tool=${name} threw: ${msg}`,
|
||||
toLoggerDetail(err),
|
||||
)
|
||||
return errorResult(`Tool "${name}" failed: ${msg}`, 'executor_threw')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@ import type {
|
||||
* cross-respawn `scaleCoord` survival. */
|
||||
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'>
|
||||
|
||||
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
|
||||
/**
|
||||
* Logger 第二参数的可选类型(与 claude-for-chrome-mcp 对齐)。
|
||||
* 实践中多为 catch 到的 Error。
|
||||
*/
|
||||
export type LoggerDetail = Error | NodeJS.ErrnoException
|
||||
|
||||
/** 将 unknown 收窄为 LoggerDetail,供 catch 块传给 logger 使用。 */
|
||||
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
|
||||
return detail instanceof Error ? detail : undefined
|
||||
}
|
||||
|
||||
/** 宿主注入的日志接口(与 claude-for-chrome-mcp/src/types.ts 对齐)。 */
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
info: (message: string, detail?: LoggerDetail) => void // 信息
|
||||
error: (message: string, detail?: LoggerDetail) => void // 错误
|
||||
warn: (message: string, detail?: LoggerDetail) => void // 警告
|
||||
debug: (message: string, detail?: LoggerDetail) => void // 调试
|
||||
silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Cursor = any
|
||||
/** 渲染帧中虚拟终端光标的状态(列/行坐标与是否绘制),供 diff 与光标 preamble 使用。 */
|
||||
export type Cursor = {
|
||||
x: number // 光标所在列,从 0 开始计
|
||||
y: number // 光标所在行,从 0 开始计
|
||||
visible: boolean // 本帧是否应在终端绘制光标(隐藏时不发射光标移动序列)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||
import type { FocusManager } from './focus.js'
|
||||
import { createLayoutNode } from './layout/engine.js'
|
||||
import type { LayoutNode } from './layout/node.js'
|
||||
@@ -45,10 +46,9 @@ export type DOMElement = {
|
||||
dirty: boolean
|
||||
// Set by the reconciler's hideInstance/unhideInstance; survives style updates.
|
||||
isHidden?: boolean
|
||||
// Event handlers set by the reconciler for the capture/bubble dispatcher.
|
||||
// Stored separately from attributes so handler identity changes don't
|
||||
// mark dirty and defeat the blit optimization.
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
// 协调器写入的事件处理器(捕获/冒泡分发用)。
|
||||
// 与 attributes 分离,避免 handler 引用变化触发 dirty 破坏 blit 优化。
|
||||
_eventHandlers?: Partial<EventHandlerProps> // 见 event-handlers.ts EventHandlerProps
|
||||
|
||||
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
|
||||
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type PasteEvent = any
|
||||
/** Box 等组件上 `onPaste` / `onPasteCapture` 收到的粘贴事件形状(与括号粘贴解析结果对齐的占位约定)。 */
|
||||
export type PasteEvent = {
|
||||
pastedText: string // 终端括号粘贴模式下解析出的 UTF-8 文本;允许为空字符串以表示空粘贴
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ResizeEvent = any
|
||||
/** 终端尺寸变化时 `onResize` 回调收到的事件载荷(与 `stdout.columns` / `stdout.rows` 一致)。 */
|
||||
export type ResizeEvent = {
|
||||
columns: number // 当前终端列数(宽度)
|
||||
rows: number // 当前终端行数(高度)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,10 @@ export class TerminalEvent extends Event {
|
||||
_prepareForTarget(_target: EventTarget): void {}
|
||||
}
|
||||
|
||||
import type { EventHandlerProps } from './event-handlers.js'
|
||||
|
||||
/** 终端事件系统的目标节点(DOM 树节点或根节点)。 */
|
||||
export type EventTarget = {
|
||||
parentNode: EventTarget | undefined
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
parentNode: EventTarget | undefined // 父节点,根节点为 undefined
|
||||
_eventHandlers?: Partial<EventHandlerProps> // 事件处理器,与 dom.ts DOMElement 同构
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
type TextNode,
|
||||
} from './dom.js'
|
||||
import { Dispatcher } from './events/dispatcher.js'
|
||||
import { EVENT_HANDLER_PROPS } from './events/event-handlers.js'
|
||||
import {
|
||||
EVENT_HANDLER_PROPS,
|
||||
type EventHandlerProps,
|
||||
} from './events/event-handlers.js'
|
||||
import { getFocusManager, getRootNode } from './focus.js'
|
||||
import { LayoutDisplay } from './layout/node.js'
|
||||
import applyStyles, { type Styles, type TextStyles } from './styles.js'
|
||||
@@ -111,7 +114,11 @@ type HostContext = {
|
||||
isInsideText: boolean
|
||||
}
|
||||
|
||||
function setEventHandler(node: DOMElement, key: string, value: unknown): void {
|
||||
function setEventHandler<K extends keyof EventHandlerProps>(
|
||||
node: DOMElement,
|
||||
key: K,
|
||||
value: EventHandlerProps[K],
|
||||
): void {
|
||||
if (!node._eventHandlers) {
|
||||
node._eventHandlers = {}
|
||||
}
|
||||
@@ -135,7 +142,11 @@ function applyProp(node: DOMElement, key: string, value: unknown): void {
|
||||
}
|
||||
|
||||
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||
setEventHandler(node, key, value)
|
||||
setEventHandler(
|
||||
node,
|
||||
key as keyof EventHandlerProps,
|
||||
value as EventHandlerProps[keyof EventHandlerProps],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -441,7 +452,11 @@ const reconciler = createReconciler<
|
||||
}
|
||||
|
||||
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||
setEventHandler(node, key, value)
|
||||
setEventHandler(
|
||||
node,
|
||||
key as keyof EventHandlerProps,
|
||||
value as EventHandlerProps[keyof EventHandlerProps],
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
let finishReason: string | undefined
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedReadTokens = 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const usage = chunk.usageMetadata
|
||||
@@ -23,6 +24,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
inputTokens = usage.promptTokenCount ?? inputTokens
|
||||
outputTokens =
|
||||
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
|
||||
cachedReadTokens = usage.cachedContentTokenCount ?? cachedReadTokens
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
@@ -41,7 +43,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
},
|
||||
} as unknown as BetaRawMessageStreamEvent
|
||||
@@ -204,7 +206,10 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export type GeminiUsageMetadata = {
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
cachedContentTokenCount?: number
|
||||
}
|
||||
|
||||
export type GeminiCandidate = {
|
||||
|
||||
@@ -551,7 +551,8 @@ describe('prompt caching support', () => {
|
||||
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(800)
|
||||
expect(msgStart.message.usage.input_tokens).toBe(1000)
|
||||
// input_tokens = prompt_tokens - cached_tokens = 1000 - 800 = 200
|
||||
expect(msgStart.message.usage.input_tokens).toBe(200)
|
||||
})
|
||||
|
||||
test('defaults cache_read_input_tokens to 0 when no cached_tokens', async () => {
|
||||
@@ -750,7 +751,8 @@ describe('prompt caching support', () => {
|
||||
|
||||
// message_delta carries the real values from the trailing chunk
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.input_tokens).toBe(30011)
|
||||
// input_tokens = prompt_tokens - cached_tokens = 30011 - 19904 = 10107
|
||||
expect(msgDelta.usage.input_tokens).toBe(10107)
|
||||
expect(msgDelta.usage.output_tokens).toBe(190)
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(19904)
|
||||
expect(msgDelta.usage.cache_creation_input_tokens).toBe(0)
|
||||
@@ -821,7 +823,34 @@ describe('prompt caching support', () => {
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(1500)
|
||||
expect(msgDelta.usage.input_tokens).toBe(2000)
|
||||
// input_tokens = prompt_tokens - cached_tokens = 2000 - 1500 = 500
|
||||
expect(msgDelta.usage.input_tokens).toBe(500)
|
||||
expect(msgDelta.usage.output_tokens).toBe(100)
|
||||
})
|
||||
|
||||
test('subtracts cached_tokens from input_tokens to match Anthropic semantic', async () => {
|
||||
// Anthropic's input_tokens = non-cached tokens only.
|
||||
// OpenAI's prompt_tokens = total input including cached.
|
||||
// The adapter must subtract: input_tokens = prompt_tokens - cached_tokens.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 34097,
|
||||
completion_tokens: 30,
|
||||
total_tokens: 34127,
|
||||
prompt_tokens_details: { cached_tokens: 34048 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
// input_tokens = 34097 - 34048 = 49 (non-cached input only)
|
||||
expect(msgDelta.usage.input_tokens).toBe(49)
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(34048)
|
||||
expect(msgDelta.usage.output_tokens).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,10 +13,10 @@ import { randomUUID } from 'crypto'
|
||||
* finish_reason → message_delta(stop_reason) + message_stop
|
||||
*
|
||||
* Usage field mapping (OpenAI → Anthropic):
|
||||
* prompt_tokens → input_tokens
|
||||
* completion_tokens → output_tokens
|
||||
* prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
||||
* (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
||||
* prompt_tokens - cached_tokens → input_tokens (non-cached input only)
|
||||
* completion_tokens → output_tokens
|
||||
* prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
||||
* (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
||||
*
|
||||
* All four fields are emitted in the post-loop message_delta (not message_start)
|
||||
* so that trailing usage chunks (sent after finish_reason by some
|
||||
@@ -54,6 +54,9 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
let textBlockOpen = false
|
||||
|
||||
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
||||
// rawInputTokens tracks the raw prompt_tokens (OpenAI total, including cached).
|
||||
// inputTokens is the derived Anthropic value (non-cached only = rawInputTokens - cachedReadTokens).
|
||||
let rawInputTokens = 0
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedReadTokens = 0
|
||||
@@ -71,12 +74,17 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
|
||||
// Extract usage from any chunk that carries it.
|
||||
if (chunk.usage) {
|
||||
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
||||
rawInputTokens = chunk.usage.prompt_tokens ?? rawInputTokens
|
||||
const rawCached =
|
||||
((chunk.usage as any).prompt_tokens_details?.cached_tokens as
|
||||
| number
|
||||
| undefined) ?? cachedReadTokens
|
||||
// Anthropic's input_tokens = non-cached input only. OpenAI's prompt_tokens
|
||||
// includes cached tokens, so subtract. Clamp to 0 in case cached > total
|
||||
// due to a streaming race.
|
||||
inputTokens = Math.max(0, rawInputTokens - rawCached)
|
||||
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
||||
const details = (chunk.usage as any).prompt_tokens_details
|
||||
if (details?.cached_tokens != null) {
|
||||
cachedReadTokens = details.cached_tokens
|
||||
}
|
||||
cachedReadTokens = rawCached
|
||||
}
|
||||
|
||||
// Emit message_start on first chunk
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BASH_TOOL_NAME = any
|
||||
/** Bash 工具在 API 与 Agent 提示串中的注册名称字面量(与 `@claude-code-best/builtin-tools` 中 `BASH_TOOL_NAME` 常量一致)。 */
|
||||
export type BASH_TOOL_NAME = 'Bash'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = any
|
||||
/** ExitPlanMode 工具在 API 中的注册名称字面量(与内置 ExitPlanMode 工具 `name` 一致)。 */
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_EDIT_TOOL_NAME = any
|
||||
/** Edit(文件编辑)工具在 API 中的注册名称字面量(与 `FILE_EDIT_TOOL_NAME` 常量 `'Edit'` 一致)。 */
|
||||
export type FILE_EDIT_TOOL_NAME = 'Edit'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_READ_TOOL_NAME = any
|
||||
/** Read(文件读取)工具在 API 中的注册名称字面量(与 `FILE_READ_TOOL_NAME` 常量 `'Read'` 一致)。 */
|
||||
export type FILE_READ_TOOL_NAME = 'Read'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_WRITE_TOOL_NAME = any
|
||||
/** Write(文件写入)工具在 API 中的注册名称字面量(与 `FILE_WRITE_TOOL_NAME` 常量 `'Write'` 一致)。 */
|
||||
export type FILE_WRITE_TOOL_NAME = 'Write'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GLOB_TOOL_NAME = any
|
||||
/** Glob(文件名模式匹配)工具在 API 中的注册名称字面量(与 `GLOB_TOOL_NAME` 常量 `'Glob'` 一致)。 */
|
||||
export type GLOB_TOOL_NAME = 'Glob'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GREP_TOOL_NAME = any
|
||||
/** Grep(内容搜索)工具在 API 中的注册名称字面量(与 `GREP_TOOL_NAME` 常量 `'Grep'` 一致)。 */
|
||||
export type GREP_TOOL_NAME = 'Grep'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = any
|
||||
/** NotebookEdit(笔记本单元格编辑)工具在 API 中的注册名称字面量(与 `NOTEBOOK_EDIT_TOOL_NAME` 常量一致)。 */
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = 'NotebookEdit'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SEND_MESSAGE_TOOL_NAME = any
|
||||
/** SendMessage(向用户/通道发消息)工具在 API 中的注册名称字面量(与 `SEND_MESSAGE_TOOL_NAME` 常量一致)。 */
|
||||
export type SEND_MESSAGE_TOOL_NAME = 'SendMessage'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_FETCH_TOOL_NAME = any
|
||||
/** WebFetch(拉取并处理 URL 内容)工具在 API 中的注册名称字面量(与 `WEB_FETCH_TOOL_NAME` 常量一致)。 */
|
||||
export type WEB_FETCH_TOOL_NAME = 'WebFetch'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_SEARCH_TOOL_NAME = any
|
||||
/** WebSearch(联网搜索)工具在 API 中的注册名称字面量(与 `WEB_SEARCH_TOOL_NAME` 常量一致)。 */
|
||||
export type WEB_SEARCH_TOOL_NAME = 'WebSearch'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isUsing3PServices = any
|
||||
/** 是否正在使用第三方(非 Anthropic 直连)API 或服务;与仓库根 `src/utils/auth.ts` 中 `isUsing3PServices` 签名一致。 */
|
||||
export type isUsing3PServices = () => boolean // 返回 true 表示当前配置走兼容层或第三方模型端点
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type hasEmbeddedSearchTools = any
|
||||
/** 当前构建是否将 Glob/Grep 嵌入其它工具而不单独注册;与仓库根 `src/utils/embeddedTools.ts` 中 `hasEmbeddedSearchTools` 一致。 */
|
||||
export type hasEmbeddedSearchTools = () => boolean // 返回 true 时工具列表不包含独立的 Glob/Grep 工具名
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getSettings_DEPRECATED = any
|
||||
import type { SettingsJson } from 'src/utils/settings/types.js'
|
||||
|
||||
/** 返回各设置来源合并后的快照(已废弃函数名,行为同 `getInitialSettings`);与 `src/utils/settings/settings.ts` 一致。 */
|
||||
export type getSettings_DEPRECATED = () => SettingsJson // 无参数;至少得到可空字段填充后的合并设置对象
|
||||
|
||||
@@ -12,9 +12,7 @@ import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
export function areExplorePlanAgentsEnabled(): boolean {
|
||||
if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) {
|
||||
// 3P default: true — Bedrock/Vertex keep agents enabled (matches pre-experiment
|
||||
// external behavior). A/B test treatment sets false to measure impact of removal.
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type buildTool = any
|
||||
export type ToolDef = any
|
||||
export type toolMatchesName = any
|
||||
/** 根据工具定义装配宿主侧可调用 `Tool` 实例的工厂函数类型。 */
|
||||
export type buildTool = typeof import('src/Tool.js').buildTool
|
||||
|
||||
/** 工具定义泛型(输入 Schema、权限、进度等);与宿主 `ToolDef` 一致。 */
|
||||
export type ToolDef = import('src/Tool.js').ToolDef
|
||||
|
||||
/** 判断工具主名称或别名是否与查询名称相等;与宿主 `toolMatchesName` 一致。 */
|
||||
export type toolMatchesName = typeof import('src/Tool.js').toolMatchesName
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ConfigurableShortcutHint = any
|
||||
/** 可配置快捷键提示组件(从 keybindings 解析展示文案);与宿主 `ConfigurableShortcutHint` 组件类型一致。 */
|
||||
export type ConfigurableShortcutHint =
|
||||
typeof import('src/components/ConfigurableShortcutHint.js').ConfigurableShortcutHint
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CtrlOToExpand = any
|
||||
export type SubAgentProvider = any
|
||||
/** 「Ctrl+O 展开」提示组件;与宿主 `src/components/CtrlOToExpand.tsx` 中 `CtrlOToExpand` 一致。 */
|
||||
export type CtrlOToExpand =
|
||||
typeof import('src/components/CtrlOToExpand.js').CtrlOToExpand
|
||||
|
||||
/** 标记子 Agent 输出上下文,用于抑制重复的展开提示;与宿主 `SubAgentProvider` 一致。 */
|
||||
export type SubAgentProvider =
|
||||
typeof import('src/components/CtrlOToExpand.js').SubAgentProvider
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Byline = any
|
||||
/** Ink 底部快捷键说明行容器组件;与 `@anthropic/ink` 导出的 `Byline` 一致。 */
|
||||
export type Byline = typeof import('@anthropic/ink').Byline
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type KeyboardShortcutHint = any
|
||||
/** Ink 快捷键「按键 + 动作」展示组件;与 `@anthropic/ink` 导出的 `KeyboardShortcutHint` 一致。 */
|
||||
export type KeyboardShortcutHint =
|
||||
typeof import('@anthropic/ink').KeyboardShortcutHint
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Message = any
|
||||
export type NormalizedUserMessage = any
|
||||
/** 对话消息联合类型(含用户/助手/系统等);与宿主 `src/types/message.js` 重导出一致。 */
|
||||
export type Message = import('src/types/message.js').Message
|
||||
|
||||
/** 归一化后的用户消息形状;与宿主 `src/types/message.js` 中 `NormalizedUserMessage` 一致。 */
|
||||
export type NormalizedUserMessage =
|
||||
import('src/types/message.js').NormalizedUserMessage
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logForDebugging = any
|
||||
/** 写入调试日志文件(受日志级别与过滤规则约束);与宿主 `src/utils/debug.js` 中 `logForDebugging` 一致。 */
|
||||
export type logForDebugging =
|
||||
typeof import('src/utils/debug.js').logForDebugging
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getQuerySourceForAgent = any
|
||||
/** 按内置/自定义 Agent 类型解析用于遥测或分类的 `QuerySource`;与宿主 `getQuerySourceForAgent` 一致。 */
|
||||
export type getQuerySourceForAgent =
|
||||
typeof import('src/utils/promptCategory.js').getQuerySourceForAgent
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SettingSource = any
|
||||
/** 设置文件来源层级标识(用户/项目/本地等);与宿主 `src/utils/settings/constants.js` 中 `SettingSource` 一致。 */
|
||||
export type SettingSource =
|
||||
import('src/utils/settings/constants.js').SettingSource
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getAllowedChannels = any
|
||||
export type getQuestionPreviewFormat = any
|
||||
/** 返回当前允许展示的通道列表(含名称、连接状态等);与宿主 `src/bootstrap/state.js` 中 `getAllowedChannels` 一致。 */
|
||||
export type getAllowedChannels =
|
||||
typeof import('src/bootstrap/state.js').getAllowedChannels
|
||||
|
||||
/** 返回问题预览渲染格式(Markdown/HTML)或未配置;与宿主 `getQuestionPreviewFormat` 一致。 */
|
||||
export type getQuestionPreviewFormat =
|
||||
typeof import('src/bootstrap/state.js').getQuestionPreviewFormat
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type MessageResponse = any
|
||||
/** 工具结果在消息流中的外层布局组件;与宿主 `src/components/MessageResponse.js` 中 `MessageResponse` 一致。 */
|
||||
export type MessageResponse =
|
||||
typeof import('src/components/MessageResponse.js').MessageResponse
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BLACK_CIRCLE = any
|
||||
/** 列表/状态行中使用的实心圆点字符(平台相关);与宿主 `src/constants/figures.js` 中 `BLACK_CIRCLE` 常量类型一致。 */
|
||||
export type BLACK_CIRCLE =
|
||||
typeof import('src/constants/figures.js').BLACK_CIRCLE
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getModeColor = any
|
||||
/** 将权限模式映射为 Ink 主题颜色键,用于状态行等 UI;与宿主 `getModeColor` 一致。 */
|
||||
export type getModeColor =
|
||||
typeof import('src/utils/permissions/PermissionMode.js').getModeColor
|
||||
|
||||
@@ -29,7 +29,6 @@ import { extractClaudeCodeHints } from 'src/utils/claudeCodeHints.js';
|
||||
import { detectCodeIndexingFromCommand } from 'src/utils/codeIndexing.js';
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js';
|
||||
import { isENOENT, ShellError } from 'src/utils/errors.js';
|
||||
import { decodeBuffer } from 'src/utils/encoding.js';
|
||||
import { detectFileEncoding, detectLineEndings, getFileModificationTime, writeTextContent } from 'src/utils/file.js';
|
||||
import { fileHistoryEnabled, fileHistoryTrackEdit } from 'src/utils/fileHistory.js';
|
||||
import { truncate } from 'src/utils/format.js';
|
||||
@@ -512,8 +511,7 @@ async function applySedEdit(
|
||||
const encoding = detectFileEncoding(absoluteFilePath);
|
||||
let originalContent: string;
|
||||
try {
|
||||
const rawBuffer = await fs.readFileBytes(absoluteFilePath);
|
||||
originalContent = decodeBuffer(rawBuffer, encoding);
|
||||
originalContent = await fs.readFile(absoluteFilePath, { encoding });
|
||||
} catch (e) {
|
||||
if (isENOENT(e)) {
|
||||
return {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ToolPermissionContext = any
|
||||
/** 工具权限检查用的不可变上下文快照;与宿主 `src/Tool.js` 中 `ToolPermissionContext` 一致。 */
|
||||
export type ToolPermissionContext = import('src/Tool.js').ToolPermissionContext
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getOriginalCwd = any
|
||||
/** 返回进程启动时的原始工作目录(不受中途切换工作区影响);与宿主 `getOriginalCwd` 一致。 */
|
||||
export type getOriginalCwd =
|
||||
typeof import('src/bootstrap/state.js').getOriginalCwd
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CanUseToolFn = any
|
||||
/** 工具调用权限判定回调(交互/自动模式分支);与宿主 `src/hooks/useCanUseTool.tsx` 中 `CanUseToolFn` 一致。 */
|
||||
export type CanUseToolFn = import('src/hooks/useCanUseTool.js').CanUseToolFn
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any
|
||||
/** 从磁盘缓存读取 GrowthBook/门控配置(可能略旧);与宿主 `getFeatureValue_CACHED_MAY_BE_STALE` 一致。 */
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE =
|
||||
typeof import('src/services/analytics/growthbook.js').getFeatureValue_CACHED_MAY_BE_STALE
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logEvent = any
|
||||
/** 同步记录分析事件(未附加 sink 时入队);与宿主 `src/services/analytics/index.js` 中 `logEvent` 一致。 */
|
||||
export type logEvent = typeof import('src/services/analytics/index.js').logEvent
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type AppState = any
|
||||
/** REPL 全局 UI 与权限等状态快照类型;与宿主 `src/state/AppStateStore.js` 中 `AppState` 一致。 */
|
||||
export type AppState = import('src/state/AppStateStore.js').AppState
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setCwd = any
|
||||
/** 将 Shell 会话当前目录设为解析后的物理路径;与宿主 `src/utils/Shell.js` 中 `setCwd` 一致。 */
|
||||
export type setCwd = typeof import('src/utils/Shell.js').setCwd
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getCwd = any
|
||||
/** 返回当前 Shell/会话逻辑工作目录字符串;与宿主 `src/utils/cwd.js` 中 `getCwd` 一致。 */
|
||||
export type getCwd = typeof import('src/utils/cwd.js').getCwd
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type pathInAllowedWorkingPath = any
|
||||
/** 判断路径是否落在当前工具允许的合并工作目录内;与宿主 `pathInAllowedWorkingPath` 一致。 */
|
||||
export type pathInAllowedWorkingPath =
|
||||
typeof import('src/utils/permissions/filesystem.js').pathInAllowedWorkingPath
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type removeSandboxViolationTags = any
|
||||
/** 从展示文本中剥离沙箱违规相关的标记标签,避免 UI 噪音;与宿主 `removeSandboxViolationTags` 一致。 */
|
||||
export type removeSandboxViolationTags =
|
||||
typeof import('src/utils/sandbox/sandbox-ui-utils.js').removeSandboxViolationTags
|
||||
|
||||
@@ -10,8 +10,14 @@ import {
|
||||
} from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import {
|
||||
extractDiscoveredToolNames,
|
||||
isSearchExtraToolsEnabledOptimistic,
|
||||
isSearchExtraToolsToolAvailable,
|
||||
} from 'src/utils/searchExtraTools.js'
|
||||
import { DESCRIPTION, getPrompt } from './prompt.js'
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
import { isDeferredTool } from '../SearchExtraToolsTool/prompt.js'
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
@@ -74,6 +80,32 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: block execution of undiscovered deferred tools.
|
||||
// When tool search is active, deferred tools must be discovered via
|
||||
// SearchExtraTools first so the model has seen their schemas and knows
|
||||
// the correct parameters. Executing an undiscovered tool almost always
|
||||
// fails with parameter validation errors.
|
||||
if (
|
||||
isSearchExtraToolsEnabledOptimistic() &&
|
||||
isSearchExtraToolsToolAvailable(tools) &&
|
||||
isDeferredTool(targetTool)
|
||||
) {
|
||||
const discovered = extractDiscoveredToolNames(context.messages)
|
||||
if (!discovered.has(input.tool_name)) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Tool "${input.tool_name}" has not been discovered yet. You must first use SearchExtraTools to discover this tool before executing it.\n\nUsage: SearchExtraTools("select:${input.tool_name}")`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the target tool is currently enabled
|
||||
if (!targetTool.isEnabled()) {
|
||||
return {
|
||||
@@ -89,6 +121,29 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// Validate input before delegating — prevents crashes when the model
|
||||
// omits required params (e.g. TeamCreate without team_name →
|
||||
// sanitizeName(undefined).replace() TypeError).
|
||||
if (targetTool.validateInput) {
|
||||
const validation = await targetTool.validateInput(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
)
|
||||
if (!validation.result) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Invalid parameters for tool "${input.tool_name}": ${validation.message}`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions on the target tool
|
||||
const permResult = await targetTool.checkPermissions?.(
|
||||
input.params as Record<string, unknown>,
|
||||
@@ -132,7 +187,7 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
},
|
||||
renderToolUseMessage(input) {
|
||||
return `Executing ${input.tool_name}...`
|
||||
return `${input.tool_name}`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'ExecuteExtraTool'
|
||||
|
||||
@@ -33,10 +33,10 @@ mock.module('src/utils/searchExtraTools.js', () => ({
|
||||
isSearchExtraToolsEnabledOptimistic: () => true,
|
||||
getAutoSearchExtraToolsCharThreshold: () => 100,
|
||||
getSearchExtraToolsMode: () => 'tst' as const,
|
||||
isSearchExtraToolsToolAvailable: async () => true,
|
||||
isSearchExtraToolsToolAvailable: () => true,
|
||||
isSearchExtraToolsEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
extractDiscoveredToolNames: () => new Set(['TestTool', 'SecretTool']),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
@@ -154,6 +154,26 @@ describe('ExecuteTool', () => {
|
||||
expect(result.newMessages).toBeDefined()
|
||||
})
|
||||
|
||||
test('returns error when deferred tool has not been discovered via SearchExtraTools', async () => {
|
||||
const mockTarget = makeMockTool('UndiscoveredTool', 'result')
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'UndiscoveredTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'UndiscoveredTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
expect(result.newMessages![0].content).toContain('has not been discovered')
|
||||
})
|
||||
|
||||
test('has correct name', () => {
|
||||
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* 薄层子进程包装器,在独立的 bun:test 进程中运行实际测试。
|
||||
* 这样可以防止其他测试文件的 mock.module() 漏出(例如 agentToolUtils.test.ts
|
||||
* 对 src/Tool.js 的 mock)影响 ExecuteTool 的测试。
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { resolve, relative } from 'path'
|
||||
|
||||
|
||||
@@ -4,16 +4,34 @@ export const DESCRIPTION =
|
||||
'ExecuteExtraTool — a first-class core tool that is always loaded and available. Execute any deferred tool by name with parameters. Use it after discovering a tool via SearchExtraTools. This is NOT a remote or external tool — it runs locally with full permissions.'
|
||||
|
||||
export function getPrompt(): string {
|
||||
return `ExecuteExtraTool — a first-class core tool, always loaded, always available in your tool list. Runs locally with full permissions — NOT a remote or external tool. You do NOT need to search for it.
|
||||
return `ExecuteExtraTool — always loaded, always available. Runs locally with full permissions — NOT a remote or external tool.
|
||||
|
||||
This tool accepts a tool_name and params object, looks up the target tool in the global tool registry, and delegates execution to it. The target tool runs with the same permissions and capabilities as if it were called directly.
|
||||
## What it does
|
||||
Accepts a tool_name and params, looks up the target tool in the registry, and delegates execution to it. The target tool runs with the same permissions as if called directly.
|
||||
|
||||
When to use: After SearchExtraTools discovers a deferred tool name, call this tool with {"tool_name": "<name>", "params": {...}} to invoke it immediately.
|
||||
When NOT to use: For core tools already in your tool list (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill, etc.) — call those directly.
|
||||
## When to use
|
||||
ONLY for deferred tools discovered via SearchExtraTools. Core tools (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill) are always in your tool list — call them directly, NOT through ExecuteExtraTool.
|
||||
|
||||
Inputs:
|
||||
- tool_name: The exact name of the target tool (string)
|
||||
- params: The parameters to pass to the target tool (object)
|
||||
## How to call — two-step workflow
|
||||
|
||||
If the tool is not found, an error message will be returned suggesting to use SearchExtraTools to discover available tools.`
|
||||
Step 1: SearchExtraTools discovers the tool name and schema.
|
||||
Step 2: This tool executes it.
|
||||
|
||||
Example — user asks to schedule a cron job:
|
||||
SearchExtraTools({"query": "select:CronCreate"})
|
||||
→ Response: "Found deferred tool(s): CronCreate"
|
||||
ExecuteExtraTool({"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check deploy"}})
|
||||
→ Response: Cron job created
|
||||
|
||||
Example — MCP tool:
|
||||
SearchExtraTools({"query": "select:mcp__slack__send_message"})
|
||||
→ Response: "Found deferred tool(s): mcp__slack__send_message"
|
||||
ExecuteExtraTool({"tool_name": "mcp__slack__send_message", "params": {"channel": "C123", "text": "hello"}})
|
||||
|
||||
## Inputs
|
||||
- tool_name: Exact name of the target tool (string, e.g. "CronCreate", "mcp__slack__send_message")
|
||||
- params: Object with the target tool's parameters. Check the tool's schema from SearchExtraTools discover: response.
|
||||
|
||||
## Failure handling
|
||||
If this tool returns an error, do NOT retry or re-search. Tell the user what failed and suggest alternatives.`
|
||||
}
|
||||
|
||||
@@ -34,11 +34,6 @@ import {
|
||||
type LineEndingType,
|
||||
readFileSyncWithMetadata,
|
||||
} from 'src/utils/fileRead.js'
|
||||
import {
|
||||
detectEncoding,
|
||||
decodeBuffer,
|
||||
type FileEncoding,
|
||||
} from 'src/utils/encoding.js'
|
||||
import { formatFileSize } from 'src/utils/format.js'
|
||||
import { getFsImplementation } from 'src/utils/fsOperations.js'
|
||||
import { fetchSingleFileGitDiff, type ToolUseDiff } from 'src/utils/gitDiff.js'
|
||||
@@ -75,7 +70,6 @@ import {
|
||||
areFileEditsInputsEquivalent,
|
||||
findActualString,
|
||||
getPatchForEdit,
|
||||
preserveQuoteStyle,
|
||||
} from './utils.js'
|
||||
|
||||
// V8/Bun string length limit is ~2^30 characters (~1 billion). For typical
|
||||
@@ -207,8 +201,13 @@ export const FileEditTool = buildTool({
|
||||
let fileContent: string | null
|
||||
try {
|
||||
const fileBuffer = await fs.readFileBytes(fullFilePath)
|
||||
const encoding: FileEncoding = detectEncoding(fileBuffer)
|
||||
fileContent = decodeBuffer(fileBuffer, encoding).replaceAll('\r\n', '\n')
|
||||
const encoding: BufferEncoding =
|
||||
fileBuffer.length >= 2 &&
|
||||
fileBuffer[0] === 0xff &&
|
||||
fileBuffer[1] === 0xfe
|
||||
? 'utf16le'
|
||||
: 'utf8'
|
||||
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
|
||||
} catch (e) {
|
||||
if (isENOENT(e)) {
|
||||
fileContent = null
|
||||
@@ -297,7 +296,7 @@ export const FileEditTool = buildTool({
|
||||
|
||||
const file = fileContent
|
||||
|
||||
// Use findActualString to handle quote normalization
|
||||
// Use findActualString to find exact match
|
||||
const actualOldString = findActualString(file, old_string)
|
||||
if (!actualOldString) {
|
||||
return {
|
||||
@@ -452,23 +451,16 @@ export const FileEditTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use findActualString to handle quote normalization
|
||||
// 3. Find the exact string in file content
|
||||
const actualOldString =
|
||||
findActualString(originalFileContents, old_string) || old_string
|
||||
|
||||
// Preserve curly quotes in new_string when the file uses them
|
||||
const actualNewString = preserveQuoteStyle(
|
||||
old_string,
|
||||
actualOldString,
|
||||
new_string,
|
||||
)
|
||||
|
||||
// 4. Generate patch
|
||||
const { patch, updatedFile } = getPatchForEdit({
|
||||
filePath: absoluteFilePath,
|
||||
fileContents: originalFileContents,
|
||||
oldString: actualOldString,
|
||||
newString: actualNewString,
|
||||
newString: new_string,
|
||||
replaceAll: replace_all,
|
||||
})
|
||||
|
||||
@@ -584,7 +576,7 @@ export const FileEditTool = buildTool({
|
||||
function readFileForEdit(absoluteFilePath: string): {
|
||||
content: string
|
||||
fileExists: boolean
|
||||
encoding: FileEncoding
|
||||
encoding: BufferEncoding
|
||||
lineEndings: LineEndingType
|
||||
} {
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ 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';
|
||||
import { findActualString, getPatchForEdit } from './utils.js';
|
||||
|
||||
export function userFacingName(
|
||||
input:
|
||||
@@ -265,12 +265,11 @@ async function loadRejectionDiff(
|
||||
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,
|
||||
newString: newString,
|
||||
replaceAll,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -4,45 +4,8 @@ import { logMock } from '../../../../../../tests/mocks/log'
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
const {
|
||||
normalizeQuotes,
|
||||
stripTrailingWhitespace,
|
||||
findActualString,
|
||||
preserveQuoteStyle,
|
||||
applyEditToFile,
|
||||
LEFT_SINGLE_CURLY_QUOTE,
|
||||
RIGHT_SINGLE_CURLY_QUOTE,
|
||||
LEFT_DOUBLE_CURLY_QUOTE,
|
||||
RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
} = await import('../utils')
|
||||
|
||||
// ─── normalizeQuotes ────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeQuotes', () => {
|
||||
test('converts left single curly to straight', () => {
|
||||
expect(normalizeQuotes(`${LEFT_SINGLE_CURLY_QUOTE}hello`)).toBe("'hello")
|
||||
})
|
||||
|
||||
test('converts right single curly to straight', () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_SINGLE_CURLY_QUOTE}`)).toBe("hello'")
|
||||
})
|
||||
|
||||
test('converts left double curly to straight', () => {
|
||||
expect(normalizeQuotes(`${LEFT_DOUBLE_CURLY_QUOTE}hello`)).toBe('"hello')
|
||||
})
|
||||
|
||||
test('converts right double curly to straight', () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_DOUBLE_CURLY_QUOTE}`)).toBe('hello"')
|
||||
})
|
||||
|
||||
test('leaves straight quotes unchanged', () => {
|
||||
expect(normalizeQuotes('\'hello\' "world"')).toBe('\'hello\' "world"')
|
||||
})
|
||||
|
||||
test('handles empty string', () => {
|
||||
expect(normalizeQuotes('')).toBe('')
|
||||
})
|
||||
})
|
||||
const { stripTrailingWhitespace, findActualString, applyEditToFile } =
|
||||
await import('../utils')
|
||||
|
||||
// ─── stripTrailingWhitespace ────────────────────────────────────────────
|
||||
|
||||
@@ -91,12 +54,6 @@ describe('findActualString', () => {
|
||||
expect(findActualString('hello world', 'hello')).toBe('hello')
|
||||
})
|
||||
|
||||
test('finds match with curly quotes normalized', () => {
|
||||
const fileContent = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`
|
||||
const result = findActualString(fileContent, '"hello"')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when not found', () => {
|
||||
expect(findActualString('hello world', 'xyz')).toBeNull()
|
||||
})
|
||||
@@ -107,124 +64,13 @@ describe('findActualString', () => {
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
// ── Tab/space normalization (Bug #2 reproduction) ──
|
||||
|
||||
test('finds match when search uses spaces but file uses tabs', () => {
|
||||
// File content uses Tab indentation
|
||||
const fileContent = '\tif (x) {\n\t\treturn 1;\n\t}'
|
||||
// User copies from Read output which renders tabs as spaces
|
||||
const searchWithSpaces = ' if (x) {\n return 1;\n }'
|
||||
const result = findActualString(fileContent, searchWithSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
test('finds match when search mixes tabs and spaces inconsistently', () => {
|
||||
const fileContent = '\tconst x = 1; // comment'
|
||||
const searchMixed = ' const x = 1; // comment'
|
||||
const result = findActualString(fileContent, searchMixed)
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
test('finds match for single-line tab-to-space mismatch', () => {
|
||||
const fileContent = '\t\torder_price = NormalizeDouble(ask, digits);'
|
||||
const searchSpaces = ' order_price = NormalizeDouble(ask, digits);'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
// ── CJK / UTF-8 characters (Bug #1 reproduction) ──
|
||||
// ── CJK / UTF-8 characters ──
|
||||
|
||||
test('finds match with CJK characters in content', () => {
|
||||
const fileContent = 'input int x = 620; // 止盈点数(点) — 32个pip=320点'
|
||||
const result = findActualString(fileContent, fileContent)
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
test('finds match with CJK characters when tab/space differs', () => {
|
||||
const fileContent = '\t// 向上突破 → Sell Limit (逆方向做空)'
|
||||
const searchSpaces = ' // 向上突破 → Sell Limit (逆方向做空)'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
// ── Multiline with tabs + CJK (combined Bug #1 + #2) ──
|
||||
|
||||
test('finds multiline match with tabs and CJK characters', () => {
|
||||
const fileContent =
|
||||
'\tif(effective_dir == BREAKOUT_UP)\n\t\t{\n\t\t\t// 向上突破\n\t\t}'
|
||||
const searchSpaces =
|
||||
' if(effective_dir == BREAKOUT_UP)\n {\n // 向上突破\n }'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
// ── Returned string must be a valid substring of fileContent ──
|
||||
|
||||
test('returned string from tab match is a real substring of fileContent', () => {
|
||||
const fileContent = 'prefix\n\t\tindented code\nsuffix'
|
||||
const searchSpaces = 'prefix\n indented code\nsuffix'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
|
||||
test('returned string from partial tab match is a real substring', () => {
|
||||
const fileContent = 'line1\n\tif (x) {\n\t\tdoStuff();\n\t}\nline5'
|
||||
const searchSpaces = ' if (x) {\n doStuff();\n }'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
|
||||
test('tab match with mixed indentation levels', () => {
|
||||
const fileContent =
|
||||
'class Foo {\n\t\tmethod1() {\n\t\t\treturn 42;\n\t\t}\n}'
|
||||
const searchSpaces =
|
||||
'class Foo {\n method1() {\n return 42;\n }\n}'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── preserveQuoteStyle ─────────────────────────────────────────────────
|
||||
|
||||
describe('preserveQuoteStyle', () => {
|
||||
test('returns newString unchanged when no normalization happened', () => {
|
||||
expect(preserveQuoteStyle('hello', 'hello', 'world')).toBe('world')
|
||||
})
|
||||
|
||||
test('converts straight double quotes to curly in replacement', () => {
|
||||
const oldString = '"hello"'
|
||||
const actualOldString = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`
|
||||
const newString = '"world"'
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE)
|
||||
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE)
|
||||
})
|
||||
|
||||
test('converts straight single quotes to curly in replacement', () => {
|
||||
const oldString = "'hello'"
|
||||
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}hello${RIGHT_SINGLE_CURLY_QUOTE}`
|
||||
const newString = "'world'"
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
expect(result).toContain(LEFT_SINGLE_CURLY_QUOTE)
|
||||
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
})
|
||||
|
||||
test('treats apostrophe in contraction as right curly quote', () => {
|
||||
const oldString = "'it's a test'"
|
||||
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}it${RIGHT_SINGLE_CURLY_QUOTE}s a test${RIGHT_SINGLE_CURLY_QUOTE}`
|
||||
const newString = "'don't worry'"
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
// The leading ' at position 0 should be LEFT_SINGLE_CURLY_QUOTE
|
||||
expect(result[0]).toBe(LEFT_SINGLE_CURLY_QUOTE)
|
||||
// The apostrophe in "don't" (between n and t) should be RIGHT_SINGLE_CURLY_QUOTE
|
||||
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── applyEditToFile ────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,27 +15,6 @@ import {
|
||||
} from 'src/utils/file.js'
|
||||
import type { EditInput, FileEdit } from './types.js'
|
||||
|
||||
// Claude can't output curly quotes, so we define them as constants here for Claude to use
|
||||
// in the code. We do this because we normalize curly quotes to straight quotes
|
||||
// when applying edits.
|
||||
export const LEFT_SINGLE_CURLY_QUOTE = '‘'
|
||||
export const RIGHT_SINGLE_CURLY_QUOTE = '’'
|
||||
export const LEFT_DOUBLE_CURLY_QUOTE = '“'
|
||||
export const RIGHT_DOUBLE_CURLY_QUOTE = '”'
|
||||
|
||||
/**
|
||||
* Normalizes quotes in a string by converting curly quotes to straight quotes
|
||||
* @param str The string to normalize
|
||||
* @returns The string with all curly quotes replaced by straight quotes
|
||||
*/
|
||||
export function normalizeQuotes(str: string): string {
|
||||
return str
|
||||
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
|
||||
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
|
||||
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
|
||||
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips trailing whitespace from each line in a string while preserving line endings
|
||||
* @param str The string to process
|
||||
@@ -64,261 +43,22 @@ export function stripTrailingWhitespace(str: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes whitespace for fuzzy matching by converting tabs to spaces
|
||||
* and collapsing leading whitespace on each line to a canonical form.
|
||||
* This handles the case where Read tool output renders tabs as spaces,
|
||||
* so users copy spaces from the output but the file actually has tabs.
|
||||
*/
|
||||
function normalizeWhitespace(str: string): string {
|
||||
return str.replace(/\t/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the actual string in the file content that matches the search string,
|
||||
* accounting for quote normalization and tab/space differences.
|
||||
*
|
||||
* Matching cascade:
|
||||
* 1. Exact match
|
||||
* 2. Quote normalization (curly → straight quotes)
|
||||
* 3. Tab/space normalization (tabs ↔ spaces in leading whitespace)
|
||||
* 4. Quote + tab/space normalization combined
|
||||
* Finds the exact string in the file content.
|
||||
*
|
||||
* @param fileContent The file content to search in
|
||||
* @param searchString The string to search for
|
||||
* @returns The actual string found in the file, or null if not found
|
||||
* @returns The search string if found, or null if not found
|
||||
*/
|
||||
export function findActualString(
|
||||
fileContent: string,
|
||||
searchString: string,
|
||||
): string | null {
|
||||
// First try exact match
|
||||
if (fileContent.includes(searchString)) {
|
||||
return searchString
|
||||
}
|
||||
|
||||
// Try with normalized quotes
|
||||
const normalizedSearch = normalizeQuotes(searchString)
|
||||
const normalizedFile = normalizeQuotes(fileContent)
|
||||
|
||||
const searchIndex = normalizedFile.indexOf(normalizedSearch)
|
||||
if (searchIndex !== -1) {
|
||||
// Find the actual string in the file that matches
|
||||
return fileContent.substring(searchIndex, searchIndex + searchString.length)
|
||||
}
|
||||
|
||||
// Try with tab/space normalization — handles the case where Read output
|
||||
// renders tabs as spaces and the user copies the rendered version
|
||||
const wsNormalizedFile = normalizeWhitespace(fileContent)
|
||||
const wsNormalizedSearch = normalizeWhitespace(searchString)
|
||||
|
||||
const wsSearchIndex = wsNormalizedFile.indexOf(wsNormalizedSearch)
|
||||
if (wsSearchIndex !== -1) {
|
||||
// Map the match position back to the original file content.
|
||||
// We need to find the corresponding range in the original string.
|
||||
return mapNormalizedMatchBackToFile(
|
||||
fileContent,
|
||||
wsNormalizedFile,
|
||||
wsSearchIndex,
|
||||
wsNormalizedSearch.length,
|
||||
)
|
||||
}
|
||||
|
||||
// Try combined: quote normalization + tab/space normalization
|
||||
const combinedFile = normalizeWhitespace(normalizedFile)
|
||||
const combinedSearch = normalizeWhitespace(normalizedSearch)
|
||||
|
||||
const combinedIndex = combinedFile.indexOf(combinedSearch)
|
||||
if (combinedIndex !== -1) {
|
||||
return mapNormalizedMatchBackToFile(
|
||||
fileContent,
|
||||
combinedFile,
|
||||
combinedIndex,
|
||||
combinedSearch.length,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a match found in a normalized version of fileContent, map the match
|
||||
* position back to the original fileContent and extract the corresponding
|
||||
* substring.
|
||||
*
|
||||
* Strategy: walk through both strings character by character, building a
|
||||
* mapping from normalized offset to original offset. When a tab is expanded
|
||||
* to 4 spaces in the normalized version, the normalized offset advances by 4
|
||||
* while the original offset advances by 1.
|
||||
*/
|
||||
function mapNormalizedMatchBackToFile(
|
||||
fileContent: string,
|
||||
normalizedFile: string,
|
||||
normalizedStart: number,
|
||||
normalizedLength: number,
|
||||
): string {
|
||||
// Build a sparse mapping from normalized position → original position.
|
||||
// We only need to map the range [normalizedStart, normalizedStart + normalizedLength].
|
||||
let normPos = 0
|
||||
let origPos = 0
|
||||
let origStart = -1
|
||||
let origEnd = -1
|
||||
|
||||
while (
|
||||
origPos < fileContent.length &&
|
||||
normPos <= normalizedStart + normalizedLength
|
||||
) {
|
||||
if (normPos === normalizedStart) {
|
||||
origStart = origPos
|
||||
}
|
||||
if (normPos === normalizedStart + normalizedLength) {
|
||||
origEnd = origPos
|
||||
break
|
||||
}
|
||||
|
||||
const origChar = fileContent[origPos]!
|
||||
if (origChar === '\t') {
|
||||
// Tab expands to 4 spaces in normalized version
|
||||
const nextNormPos = normPos + 4
|
||||
// If normalizedStart falls within this expanded tab, snap to origPos
|
||||
if (
|
||||
normPos < normalizedStart &&
|
||||
nextNormPos > normalizedStart &&
|
||||
origStart === -1
|
||||
) {
|
||||
origStart = origPos
|
||||
}
|
||||
if (
|
||||
normPos < normalizedStart + normalizedLength &&
|
||||
nextNormPos > normalizedStart + normalizedLength &&
|
||||
origEnd === -1
|
||||
) {
|
||||
origEnd = origPos + 1
|
||||
}
|
||||
normPos = nextNormPos
|
||||
origPos++
|
||||
} else {
|
||||
normPos++
|
||||
origPos++
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't map precisely, use character-count heuristic
|
||||
if (origStart === -1) origStart = 0
|
||||
if (origEnd === -1) {
|
||||
// Approximate: use the ratio of original to normalized length
|
||||
const ratio = fileContent.length / normalizedFile.length
|
||||
origEnd = Math.round(origStart + normalizedLength * ratio)
|
||||
}
|
||||
|
||||
return fileContent.substring(origStart, origEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* When old_string matched via quote normalization (curly quotes in file,
|
||||
* straight quotes from model), apply the same curly quote style to new_string
|
||||
* so the edit preserves the file's typography.
|
||||
*
|
||||
* Uses a simple open/close heuristic: a quote character preceded by whitespace,
|
||||
* start of string, or opening punctuation is treated as an opening quote;
|
||||
* otherwise it's a closing quote.
|
||||
*/
|
||||
export function preserveQuoteStyle(
|
||||
oldString: string,
|
||||
actualOldString: string,
|
||||
newString: string,
|
||||
): string {
|
||||
// If they're the same, no normalization happened
|
||||
if (oldString === actualOldString) {
|
||||
return newString
|
||||
}
|
||||
|
||||
// Detect which curly quote types were in the file
|
||||
const hasDoubleQuotes =
|
||||
actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
|
||||
actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE)
|
||||
const hasSingleQuotes =
|
||||
actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
|
||||
actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
|
||||
if (!hasDoubleQuotes && !hasSingleQuotes) {
|
||||
return newString
|
||||
}
|
||||
|
||||
let result = newString
|
||||
|
||||
if (hasDoubleQuotes) {
|
||||
result = applyCurlyDoubleQuotes(result)
|
||||
}
|
||||
if (hasSingleQuotes) {
|
||||
result = applyCurlySingleQuotes(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isOpeningContext(chars: string[], index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true
|
||||
}
|
||||
const prev = chars[index - 1]
|
||||
return (
|
||||
prev === ' ' ||
|
||||
prev === '\t' ||
|
||||
prev === '\n' ||
|
||||
prev === '\r' ||
|
||||
prev === '(' ||
|
||||
prev === '[' ||
|
||||
prev === '{' ||
|
||||
prev === '\u2014' || // em dash
|
||||
prev === '\u2013' // en dash
|
||||
)
|
||||
}
|
||||
|
||||
function applyCurlyDoubleQuotes(str: string): string {
|
||||
const chars = [...str]
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] === '"') {
|
||||
result.push(
|
||||
isOpeningContext(chars, i)
|
||||
? LEFT_DOUBLE_CURLY_QUOTE
|
||||
: RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
)
|
||||
} else {
|
||||
result.push(chars[i]!)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
function applyCurlySingleQuotes(str: string): string {
|
||||
const chars = [...str]
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] === "'") {
|
||||
// Don't convert apostrophes in contractions (e.g., "don't", "it's")
|
||||
// An apostrophe between two letters is a contraction, not a quote
|
||||
const prev = i > 0 ? chars[i - 1] : undefined
|
||||
const next = i < chars.length - 1 ? chars[i + 1] : undefined
|
||||
const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)
|
||||
const nextIsLetter = next !== undefined && /\p{L}/u.test(next)
|
||||
if (prevIsLetter && nextIsLetter) {
|
||||
// Apostrophe in a contraction — use right single curly quote
|
||||
result.push(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
} else {
|
||||
result.push(
|
||||
isOpeningContext(chars, i)
|
||||
? LEFT_SINGLE_CURLY_QUOTE
|
||||
: RIGHT_SINGLE_CURLY_QUOTE,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result.push(chars[i]!)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform edits to ensure replace_all always has a boolean value
|
||||
* @param edits Array of edits with optional replace_all
|
||||
|
||||
@@ -383,8 +383,8 @@ export const NotebookEditTool = buildTool({
|
||||
const language = notebook.metadata.language_info?.name ?? 'python'
|
||||
let new_cell_id
|
||||
if (
|
||||
notebook.nbformat > 4 ||
|
||||
(notebook.nbformat === 4 && notebook.nbformat_minor >= 5)
|
||||
(notebook.nbformat ?? 4) > 4 ||
|
||||
((notebook.nbformat ?? 4) === 4 && (notebook.nbformat_minor ?? 0) >= 5)
|
||||
) {
|
||||
if (edit_mode === 'insert') {
|
||||
new_cell_id = Math.random().toString(36).substring(2, 15)
|
||||
|
||||
@@ -25,13 +25,39 @@ function getToolLocationHint(): string {
|
||||
|
||||
const PROMPT_TAIL = ` Returns matching tool names.
|
||||
|
||||
IMPORTANT: ExecuteExtraTool is always available in your tool list. After this search returns tool names, you MUST call ExecuteExtraTool with {"tool_name": "<returned_name>", "params": {...}} to invoke the deferred tool. This is the ONLY way to execute deferred tools — do not read source code or analyze whether the tool is callable, just use ExecuteExtraTool directly.
|
||||
## Two-step workflow (MUST follow exactly)
|
||||
|
||||
Query forms:
|
||||
- "select:CronCreate,Snip" — fetch these exact tools by name
|
||||
- "discover:schedule cron job" — pure discovery, returns tool info (name, description) without loading. Use when you want to understand available tools before deciding which to invoke.
|
||||
Deferred tools CANNOT be called directly. You MUST use this two-step pattern:
|
||||
|
||||
Step 1 — Search: Call this tool (SearchExtraTools) to discover the target tool.
|
||||
Input: {"query": "select:CronCreate"}
|
||||
Response: "Found 1 deferred tool(s): CronCreate. Use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}} to invoke."
|
||||
|
||||
Step 2 — Execute: Call ExecuteExtraTool to run the discovered tool.
|
||||
Input: {"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check the deploy"}}
|
||||
Response: the actual tool result.
|
||||
|
||||
## Example: user asks "schedule a cron to check deploy every 5 minutes"
|
||||
|
||||
1. SearchExtraTools({"query": "select:CronCreate"})
|
||||
→ Response: Found deferred tool CronCreate
|
||||
2. ExecuteExtraTool({"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check the deploy"}})
|
||||
→ Response: Cron job created successfully
|
||||
|
||||
If you don't know the exact tool name, use keyword search first:
|
||||
1. SearchExtraTools({"query": "cron schedule"})
|
||||
→ Response: Found deferred tool(s): CronCreate
|
||||
2. ExecuteExtraTool({"tool_name": "CronCreate", "params": {...}})
|
||||
|
||||
## Query forms
|
||||
- "select:CronCreate" — exact tool name (fastest, preferred when you know the name from <available-deferred-tools>)
|
||||
- "select:CronCreate,CronList" — comma-separated multi-select
|
||||
- "discover:schedule cron job" — returns tool name + description + schema without loading. Use to understand a tool before calling it.
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms
|
||||
|
||||
## Failure policy
|
||||
If ExecuteExtraTool fails, do NOT re-search for the same tool — it will loop. Stop and tell the user what failed.`
|
||||
|
||||
/**
|
||||
* Check if a tool should be deferred (requires SearchExtraTools to load).
|
||||
|
||||
@@ -59,7 +59,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
||||
'ACP', // ACP 代理协议,支持外部 agent 接入
|
||||
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
// 'HISTORY_SNIP', // 已禁用:snip 功能暂时关闭
|
||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
// 'FORK_SUBAGENT', // 已禁用:通过 Agent tool 的特殊方式实现了等效功能,无需再开
|
||||
|
||||
@@ -9,28 +9,52 @@
|
||||
import { readdir, readFile, writeFile, cp } from 'node:fs/promises'
|
||||
import { chmodSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { execSync } from 'node:child_process'
|
||||
|
||||
const outdir = 'dist'
|
||||
|
||||
async function postBuild() {
|
||||
// Step 1: Patch globalThis.Bun destructuring in the single bundled file
|
||||
const cliPath = join(outdir, 'cli.js')
|
||||
// Step 1: Patch globalThis.Bun destructuring in ALL output files
|
||||
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
||||
const BUN_DESTRUCTURE_SAFE =
|
||||
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
|
||||
let bunPatched = 0
|
||||
{
|
||||
const content = await readFile(cliPath, 'utf-8')
|
||||
const files = await readdir(outdir)
|
||||
const jsFiles = files.filter(f => f.endsWith('.js'))
|
||||
|
||||
for (const file of jsFiles) {
|
||||
const filePath = join(outdir, file)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
BUN_DESTRUCTURE.lastIndex = 0
|
||||
if (BUN_DESTRUCTURE.test(content)) {
|
||||
await writeFile(
|
||||
cliPath,
|
||||
filePath,
|
||||
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||
)
|
||||
bunPatched++
|
||||
}
|
||||
}
|
||||
|
||||
// Also patch chunk files in dist/chunks/
|
||||
const chunksDir = join(outdir, 'chunks')
|
||||
let chunkFiles: string[] = []
|
||||
try {
|
||||
chunkFiles = (await readdir(chunksDir)).filter(f => f.endsWith('.js'))
|
||||
} catch {
|
||||
// No chunks directory — single-file build fallback
|
||||
}
|
||||
|
||||
for (const file of chunkFiles) {
|
||||
const filePath = join(chunksDir, file)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
BUN_DESTRUCTURE.lastIndex = 0
|
||||
if (BUN_DESTRUCTURE.test(content)) {
|
||||
await writeFile(
|
||||
filePath,
|
||||
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||
)
|
||||
bunPatched++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Copy native addon files
|
||||
@@ -55,7 +79,7 @@ async function postBuild() {
|
||||
chmodSync(cliNode, 0o755)
|
||||
|
||||
console.log(
|
||||
`Post-build complete: patched ${bunPatched} Bun destructure, generated entry points`,
|
||||
`Post-build complete: patched ${bunPatched} Bun destructure across ${jsFiles.length + chunkFiles.length} files, generated entry points`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
# Feature: 20260510_F001 - multi-encoding-file-tools
|
||||
|
||||
## 需求背景
|
||||
|
||||
当前文件读写工具(FileReadTool、FileWriteTool、FileEditTool)的编码检测非常简单——仅通过 BOM 头识别 UTF-8 和 UTF-16LE,其他所有情况默认按 UTF-8 处理。对于 GBK/GB2312 等非 BOM 编码文件,读取时会产生乱码,导致 AI 模型无法正确理解和编辑这些文件。
|
||||
|
||||
这在中文 Windows 用户场景中尤其常见:许多旧项目、日志文件、配置文件使用 GBK 编码,当前工具链无法处理。
|
||||
|
||||
## 目标
|
||||
|
||||
- 文件读取时自动检测编码并正确解码,对 AI 模型完全透明(不增加 encoding 参数)
|
||||
- 文件写入时保持原文件编码,不改变用户的编码习惯
|
||||
- 覆盖 GBK 编码(最常见非 UTF-8 CJK 编码),latin1 作为最终兜底
|
||||
- 零外部依赖,仅使用 Node.js/Bun 内置的 TextDecoder/TextEncoder
|
||||
|
||||
## 范围变更
|
||||
|
||||
**仅保留 GBK 编码支持**。Shift_JIS、EUC-JP、EUC-KR、Big5、GB18030、ISO-8859-1 已移出范围。原因:多编码回退链存在字节序列歧义(如 GBK 和 Shift_JIS 共享大量有效字节范围),导致误检测。GBK 覆盖了最核心的中文 Windows 用户场景。
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 架构概述
|
||||
|
||||
新增一个独立的编码工具模块 `src/utils/encoding.ts`,提供编码检测和解码/编码函数。现有文件读写路径通过调用此模块实现对非 UTF-8 编码的支持。
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ src/utils/encoding.ts │
|
||||
│ detectEncoding(buffer) │
|
||||
│ decodeBuffer(buf, enc) │
|
||||
│ encodeString(str, enc) │
|
||||
└─────────┬───────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
fileRead.ts readFileInRange.ts file.ts
|
||||
(readFileSync (异步读取路径) (writeTextContent)
|
||||
WithMetadata)
|
||||
```
|
||||
|
||||
### 编码检测算法(三层检测)
|
||||
|
||||
检测基于文件头部 4KB 数据,分三层依次判断:
|
||||
|
||||
**第一层:BOM 检测(现有逻辑保留)**
|
||||
- `FF FE` → UTF-16LE
|
||||
- `EF BB BF` → UTF-8(带 BOM)
|
||||
|
||||
**第二层:UTF-8 验证**
|
||||
- 用 `new TextDecoder('utf-8', { fatal: true })` 对头部 4KB 做解码
|
||||
- 成功 → 文件为 UTF-8(覆盖绝大多数现代源码文件)
|
||||
- 失败(抛出 TypeError)→ 进入第三层
|
||||
|
||||
**第三层:GBK 回退**
|
||||
- 用 `new TextDecoder('gbk', { fatal: true })` 尝试解码头部 4KB
|
||||
- 成功 → 文件为 GBK(覆盖中文 Windows 用户最常见的非 UTF-8 编码)
|
||||
- 失败 → `latin1`(单字节编码,永远成功,作为最终兜底)
|
||||
|
||||
```typescript
|
||||
// src/utils/encoding.ts 核心逻辑
|
||||
|
||||
export type FileEncoding = BufferEncoding | 'gbk'
|
||||
export type DetectedEncoding = string
|
||||
|
||||
export function detectEncoding(buffer: Buffer): FileEncoding {
|
||||
// Layer 1: BOM
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||||
return 'utf-16le'
|
||||
}
|
||||
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
|
||||
return 'utf-8'
|
||||
}
|
||||
|
||||
// Layer 2: UTF-8 validation
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
return 'utf-8'
|
||||
} catch {}
|
||||
|
||||
// Layer 3: GBK fallback
|
||||
try {
|
||||
new TextDecoder('gbk', { fatal: true }).decode(buffer)
|
||||
return 'gbk'
|
||||
} catch {}
|
||||
|
||||
return 'latin1'
|
||||
}
|
||||
```
|
||||
|
||||
### 读取路径改造
|
||||
|
||||
#### `src/utils/fileRead.ts` — `detectEncodingForResolvedPath`
|
||||
|
||||
将现有的 BOM-only 检测替换为调用 `encoding.ts` 的 `detectEncoding` 函数。返回值从 `BufferEncoding` 改为 `FileEncoding`(`BufferEncoding | 'gbk'`)。
|
||||
|
||||
`readFileSyncWithMetadata` 函数先读 raw Buffer,再用 `decodeBuffer` 解码,而非使用 `fs.readFileSync` 的 encoding 选项(该选项只接受 `BufferEncoding`,不支持 `gbk`)。
|
||||
|
||||
#### `src/utils/readFileInRange.ts` — 异步读取
|
||||
|
||||
当前两个路径(fast path 和 streaming path)都硬编码 `encoding: 'utf8'`:
|
||||
|
||||
**Fast path 改造**:
|
||||
- `readFile` 改为读取 Buffer(去掉 encoding 参数)
|
||||
- 读取后调用 `detectEncoding(buffer)` 检测编码
|
||||
- 用 `decodeBuffer` 解码为字符串
|
||||
- 后续行处理逻辑不变
|
||||
|
||||
**Streaming path 改造**:
|
||||
- `createReadStream` 去掉 `encoding: 'utf8'`,改为 Buffer 模式
|
||||
- 第一个 chunk 做编码检测(同时保留 BOM 剥离逻辑)
|
||||
- 后续 chunk 拼接后用 `TextDecoder` 解码
|
||||
- 注意:streaming 路径需要特殊处理——先收集足够字节做检测,再逐行扫描
|
||||
|
||||
**Streaming 编码处理策略**:
|
||||
streaming 路径改为两阶段:
|
||||
1. **检测阶段**:前 4KB 数据到达后立即检测编码
|
||||
2. **解码阶段**:用检测到的编码创建一个 `TextDecoder`(`{ stream: true }` 模式),逐 chunk 解码
|
||||
|
||||
### 写入路径改造
|
||||
|
||||
#### 编码回写策略
|
||||
|
||||
写入时需要将内部 UTF-8 字符串编码回原文件编码。由于 `TextEncoder` 只支持 UTF-8 输出,需要使用 `TextDecoder` 的反向操作。
|
||||
|
||||
**最终决定**:对于非 UTF-8 文件的写回,尝试使用 `Buffer.from(content, encoding)` 编码,失败则自动转换为 UTF-8 并在结果消息中注明。这样既满足了零依赖约束,也避免了数据损坏。
|
||||
|
||||
#### `src/utils/file.ts` — `writeTextContent`
|
||||
|
||||
现有函数签名 `writeTextContent(filePath, content, encoding, lineEndings)` 已接受 encoding 参数。需要:
|
||||
- 扩展类型,接受 `FileEncoding` 而非仅 `BufferEncoding`
|
||||
- 对于 UTF-8 和 UTF-16LE,行为不变
|
||||
- 对于 GBK,使用 `encodeString` 函数尝试编码,失败则回退为 UTF-8 写入
|
||||
|
||||
#### `FileWriteTool` 和 `FileEditTool`
|
||||
|
||||
这两个工具的 `call` 方法中,`writeTextContent` 调用已传递 `encoding`(来自 `readFileSyncWithMetadata` 的返回值)。改动很小——只需确保类型系统接受新编码名。
|
||||
|
||||
### 类型扩展
|
||||
|
||||
```typescript
|
||||
// 扩展编码类型 — 仅添加 GBK
|
||||
export type FileEncoding = BufferEncoding | 'gbk'
|
||||
```
|
||||
|
||||
在 `readFileSyncWithMetadata` 返回类型中将 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`。
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 关键技术决策
|
||||
|
||||
1. **检测只用头部 4KB**:避免全文件扫描,性能开销极小(多几次 TextDecoder 调用,每次 ~1μs)
|
||||
2. **GBK 作为唯一回退**:中文 Windows 用户最多,且避免了多编码回退链的字节序列歧义问题
|
||||
3. **TextDecoder fatal 模式**:`{ fatal: true }` 是检测的关键——如果字节序列不符合编码规范会抛异常,借此区分不同编码
|
||||
4. **streaming 路径的两阶段设计**:先攒够检测数据再开始行扫描,避免半字符解码问题
|
||||
5. **latin1 最终兜底**:单字节编码永远成功,确保任何文件都能被读取
|
||||
|
||||
### 难点
|
||||
|
||||
1. **Streaming 编码解码**:`TextDecoder` 支持 `{ stream: true }` 模式处理多字节字符的 chunk 边界,但需要在检测完成前缓冲数据
|
||||
2. **编码回写的零依赖方案**:`TextEncoder` 只输出 UTF-8,非 UTF-8 编码回写需要额外处理。务实方案是 UTF-8 写入 + 消息提示
|
||||
3. **混合编码文件**:极少见,不在本次覆盖范围内
|
||||
|
||||
### 依赖
|
||||
|
||||
- 零外部依赖,仅使用 `TextDecoder`(Node.js 13+ / Bun 内置 full-icu)
|
||||
- Bun 运行时对 GBK 的 TextDecoder 支持已验证可用(Bun 1.3.13)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] FileReadTool 能正确读取 GBK 编码的中文文本文件,显示正确的中文内容
|
||||
- [x] FileReadTool 能正确读取 UTF-8 文件(行为不变,回归测试通过)
|
||||
- [x] FileReadTool 能正确读取 UTF-16LE 文件(行为不变)
|
||||
- [x] FileEditTool 能编辑 GBK 文件并写回,内容不乱码
|
||||
- [x] FileWriteTool 编辑 GBK 文件后写回,编码保持或合理转换
|
||||
- [x] readFileInRange 的 fast path 路径支持非 UTF-8 编码
|
||||
- [x] readFileInRange 的 streaming path 支持非 UTF-8 编码
|
||||
- [x] 编码检测性能:4KB 数据检测耗时 < 1ms
|
||||
- [x] `bun run precheck` typecheck + lint + 相关测试零错误
|
||||
- [x] 新增编码相关单元测试覆盖检测和解码逻辑
|
||||
@@ -1,161 +0,0 @@
|
||||
# 多编码文件工具 人工验收清单
|
||||
|
||||
**生成时间:** 2026-05-10
|
||||
**关联计划:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-plan.md
|
||||
**关联设计:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-design.md
|
||||
|
||||
---
|
||||
|
||||
所有验收项均可通过 Shell 命令自动化验证,无需人类参与。仍将生成清单用于自动执行。
|
||||
|
||||
**范围变更:** 仅保留 GBK 编码支持,Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030 已移除。
|
||||
|
||||
---
|
||||
|
||||
## 验收前准备
|
||||
|
||||
### 环境要求
|
||||
|
||||
- [x] [AUTO] 检查 Bun 运行时版本: `bun --version`
|
||||
- [x] [AUTO] 安装依赖: `bun install`
|
||||
|
||||
### 测试数据准备
|
||||
|
||||
- [x] [AUTO] 创建 GBK 编码测试文件: `bun -e "const fs = require('fs'); const b = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3, 0xCA, 0xC0, 0xBD, 0xE7, 0x0A]); fs.writeFileSync('/tmp/test-gbk.txt', b)"`
|
||||
- [x] [AUTO] 创建 UTF-8 测试文件: `bun -e "require('fs').writeFileSync('/tmp/test-utf8.txt', 'Hello 世界\n')"`
|
||||
- [x] [AUTO] 创建 UTF-16LE 测试文件: `bun -e "const fs = require('fs'); const b = Buffer.from('Hello','utf16le'); fs.writeFileSync('/tmp/test-utf16le.txt', b)"`
|
||||
|
||||
---
|
||||
|
||||
## 验收项目
|
||||
|
||||
### 场景 1:读取 GBK 编码文件(中文场景)
|
||||
|
||||
**用户目标:** 用户有一个 GBK 编码的中文文件,通过 FileReadTool 读取后看到正确的中文内容
|
||||
|
||||
**触发路径:**
|
||||
1. 系统检测到非 UTF-8 字节序列
|
||||
2. 编码回退识别为 GBK
|
||||
3. 用 GBK 解码输出中文文本
|
||||
|
||||
#### - [x] 1.1 GBK 文件同步读取
|
||||
- **来源:** spec-plan-acceptance.md §2 / spec-design.md §验收标准
|
||||
- **目的:** 确认 GBK 文件读取解码正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-gbk.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `你好世界`
|
||||
2. [A] 上条命令输出 encoding 字段 → 期望包含: `gbk`
|
||||
|
||||
#### - [x] 1.2 GBK 文件异步路径读取
|
||||
- **来源:** spec-plan-acceptance.md §6 / spec-design.md §验收标准
|
||||
- **目的:** 确认 readFileInRange fast path 支持 GBK
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { readFileInRange } from './src/utils/readFileInRange.js'; const r = await readFileInRange('/tmp/test-gbk.txt', 0); console.log('content:', r.content); console.log('totalLines:', r.totalLines)"` → 期望包含: `你好世界`
|
||||
2. [A] 上条命令输出 totalLines → 期望包含: `1`
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:写入非 UTF-8 编码文件
|
||||
|
||||
**用户目标:** 用户通过 FileEditTool/FileWriteTool 编辑 GBK 文件后写回,内容不损坏
|
||||
|
||||
**触发路径:**
|
||||
1. 系统检测原文件编码
|
||||
2. 编辑内容后写回
|
||||
3. 非标准编码回退为 UTF-8 写入(零依赖约束)
|
||||
|
||||
#### - [x] 3.1 GBK 文件写入(UTF-8 回退)
|
||||
- **来源:** spec-plan-acceptance.md §7 / spec-design.md §写入路径改造
|
||||
- **目的:** 确认非 UTF-8 编码写入不损坏内容
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { writeTextContent } from './src/utils/file.js'; writeTextContent('/tmp/test-gbk-write.txt', '测试写入', 'gbk', 'LF'); const fs = require('fs'); const content = fs.readFileSync('/tmp/test-gbk-write.txt', 'utf8'); console.log('written:', content)"` → 期望包含: `测试写入`
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:UTF-8 文件读取回归
|
||||
|
||||
**用户目标:** 用户读取 UTF-8 文件,行为与改动前完全一致
|
||||
|
||||
**触发路径:**
|
||||
1. UTF-8 fatal 验证通过
|
||||
2. 内容正常输出
|
||||
|
||||
#### - [x] 4.1 UTF-8 文件读取回归
|
||||
- **来源:** spec-plan-acceptance.md §4 / spec-design.md §验收标准
|
||||
- **目的:** 确认 UTF-8 读取无回归
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf8.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `Hello 世界`
|
||||
2. [A] 上条命令输出 encoding 字段 → 期望包含: `utf`
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:UTF-16LE 文件读取回归
|
||||
|
||||
**用户目标:** 用户读取 UTF-16LE(BOM)文件,行为与改动前完全一致
|
||||
|
||||
**触发路径:**
|
||||
1. BOM 检测层识别 FF FE 标记
|
||||
2. 用 UTF-16LE 解码
|
||||
|
||||
#### - [x] 5.1 UTF-16LE 文件读取回归
|
||||
- **来源:** spec-plan-acceptance.md §5 / spec-design.md §验收标准
|
||||
- **目的:** 确认 UTF-16LE BOM 读取无回归
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf16le.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `utf-16le`
|
||||
2. [A] 上条命令输出 content 字段 → 期望包含: `Hello`
|
||||
|
||||
---
|
||||
|
||||
### 场景 6:编码检测性能
|
||||
|
||||
**用户目标:** 编码检测不应影响文件读取的响应速度
|
||||
|
||||
**触发路径:**
|
||||
1. 对 4KB 数据执行 1000 次检测
|
||||
2. 验证平均耗时 < 1ms
|
||||
|
||||
#### - [x] 6.1 检测性能基准
|
||||
- **来源:** spec-plan-acceptance.md §8 / spec-design.md §实现要点
|
||||
- **目的:** 确认编码检测性能达标
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { detectEncoding } from './src/utils/encoding.js'; const buf = Buffer.alloc(4096, 0x41); const start = performance.now(); for (let i = 0; i < 1000; i++) detectEncoding(buf); const avg = (performance.now() - start) / 1000; console.log('avg:', avg, 'ms'); process.exit(avg < 1 ? 0 : 1)"` → 期望包含: `avg:`
|
||||
|
||||
---
|
||||
|
||||
### 场景 7:构建和测试完整性
|
||||
|
||||
**用户目标:** 整体代码质量无退化,所有测试通过
|
||||
|
||||
**触发路径:**
|
||||
1. 执行完整 precheck(typecheck + lint + test)
|
||||
2. 确认零错误
|
||||
|
||||
#### - [x] 7.1 编码相关单元测试
|
||||
- **来源:** spec-plan.md Task 1-4 检查步骤 / spec-design.md §验收标准
|
||||
- **目的:** 确认编码相关测试全部通过
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/utils/__tests__/encoding.test.ts` → 期望包含: `0 fail`
|
||||
2. [A] `bun test src/utils/__tests__/fileRead.test.ts` → 期望包含: `0 fail`
|
||||
3. [A] `bun test src/utils/__tests__/readFileInRange.test.ts` → 期望包含: `0 fail`
|
||||
4. [A] `bun test src/utils/__tests__/file.test.ts` → 期望包含: `0 fail`
|
||||
|
||||
---
|
||||
|
||||
## 验收后清理
|
||||
|
||||
- [x] [AUTO] 清理临时测试文件: `rm -f /tmp/test-gbk.txt /tmp/test-utf8.txt /tmp/test-utf16le.txt /tmp/test-gbk-write.txt`
|
||||
|
||||
---
|
||||
|
||||
## 验收结果汇总
|
||||
|
||||
| 场景 | 序号 | 验收项 | [A] | [H] | 结果 |
|
||||
|------|------|--------|-----|-----|------|
|
||||
| 场景 1 | 1.1 | GBK 同步读取 | 2 | 0 | ✅ |
|
||||
| 场景 1 | 1.2 | GBK 异步路径读取 | 2 | 0 | ✅ |
|
||||
| 场景 3 | 3.1 | GBK 写入(回退) | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.1 | UTF-8 回归 | 2 | 0 | ✅ |
|
||||
| 场景 5 | 5.1 | UTF-16LE 回归 | 2 | 0 | ✅ |
|
||||
| 场景 6 | 6.1 | 检测性能 | 1 | 0 | ✅ |
|
||||
| 场景 7 | 7.1 | 编码单元测试 | 4 | 0 | ✅ |
|
||||
|
||||
**验收结论:** ✅ 全部通过
|
||||
@@ -1,47 +0,0 @@
|
||||
### Acceptance Task: 多编码文件工具验收
|
||||
|
||||
**前置条件:**
|
||||
- 所有 Task 0-4 已执行完毕
|
||||
- 运行环境: 当前开发环境(Bun)
|
||||
|
||||
**范围变更:** 仅保留 GBK 编码支持,Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030/ISO-8859-1 已移除。
|
||||
|
||||
**端到端验证:**
|
||||
|
||||
1. 运行完整测试套件确保无回归
|
||||
- `bun run precheck`
|
||||
- 预期: typecheck + lint fix + test 全部零错误通过
|
||||
- 失败排查: 检查各 Task 的测试步骤,特别是 Task 1 的编码检测测试和 Task 3 的 readFileInRange 测试
|
||||
|
||||
2. 验证 GBK 文件读取正确性
|
||||
- 创建 GBK 编码测试文件:`bun -e "const fs = require('fs'); const b = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3, 0xCA, 0xC0, 0xBD, 0xE7, 0x0A]); fs.writeFileSync('/tmp/test-gbk.txt', b)"`
|
||||
- 读取并验证:`bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-gbk.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
|
||||
- 预期: encoding 为 `gbk`,content 为 "你好世界"
|
||||
- 失败排查: 检查 Task 1 的 detectEncoding 逻辑、Task 2 的 readFileSyncWithMetadata 集成
|
||||
|
||||
3. 验证 UTF-8 文件读取回归
|
||||
- `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const fs = require('fs'); fs.writeFileSync('/tmp/test-utf8.txt', 'Hello 世界\n'); const r = readFileSyncWithMetadata('/tmp/test-utf8.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
|
||||
- 预期: encoding 为 `utf-8`,content 为 "Hello 世界"
|
||||
- 失败排查: 检查 Task 1 的 UTF-8 fatal 验证逻辑
|
||||
|
||||
4. 验证 UTF-16LE 文件读取回归
|
||||
- `bun -e "const fs = require('fs'); const b = Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from('Hello', 'utf16le')]); fs.writeFileSync('/tmp/test-utf16le.txt', b); import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf16le.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
|
||||
- 预期: encoding 为 `utf-16le`,content 为 "Hello"
|
||||
- 失败排查: 检查 Task 1 的 BOM 检测层、Task 2 的集成
|
||||
|
||||
5. 验证 readFileInRange 异步路径的 GBK 支持
|
||||
- `bun -e "import { readFileInRange } from './src/utils/readFileInRange.js'; const r = await readFileInRange('/tmp/test-gbk.txt', 0); console.log('content:', r.content); console.log('totalLines:', r.totalLines)"`
|
||||
- 预期: content 为 "你好世界",totalLines 为 1
|
||||
- 失败排查: 检查 Task 3 的 fast path 改造
|
||||
|
||||
6. 验证 GBK 文件写入(UTF-8 回退)
|
||||
- `bun -e "import { writeTextContent } from './src/utils/file.js'; writeTextContent('/tmp/test-gbk-write.txt', '测试写入', 'gbk', 'LF'); const fs = require('fs'); const content = fs.readFileSync('/tmp/test-gbk-write.txt', 'utf8'); console.log('written:', content)"`
|
||||
- 预期: 文件成功写入,内容为 "测试写入"(UTF-8 回退或 GBK 编码均可接受)
|
||||
- 失败排查: 检查 Task 4 的 writeTextContent 改造和 encodeString 函数
|
||||
|
||||
7. 验证编码检测性能
|
||||
- `bun -e "import { detectEncoding } from './src/utils/encoding.js'; const buf = Buffer.alloc(4096, 0x41); const start = performance.now(); for (let i = 0; i < 1000; i++) detectEncoding(buf); console.log('avg:', (performance.now() - start) / 1000, 'ms')"`
|
||||
- 预期: 平均检测耗时 < 1ms
|
||||
- 失败排查: 检查 Task 1 的检测逻辑是否有不必要的重复操作
|
||||
|
||||
---
|
||||
@@ -1,34 +0,0 @@
|
||||
### Task 0: 环境准备
|
||||
|
||||
**背景:**
|
||||
确保构建和测试工具链在当前开发环境中可用,验证 Bun 运行时对 GBK 编码的 TextDecoder 支持情况。
|
||||
|
||||
**涉及文件:**
|
||||
- 无文件修改,仅验证环境
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 验证 Bun 运行时可用
|
||||
- 运行命令: `bun --version`
|
||||
- 预期: 输出 Bun 版本号
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- 运行命令: `bunx tsc --noEmit 2>&1 | tail -5`
|
||||
- 预期: 无错误输出(或仅有已知的 pre-existing 错误)
|
||||
- [x] 验证 Bun 对 GBK 编码的 TextDecoder 支持
|
||||
- 运行命令: `bun -e "const d = new TextDecoder('gbk', { fatal: true }); const buf = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3]); console.log(d.decode(buf))"`
|
||||
- 预期: 输出 "你好"(GBK 编码的中文字符)
|
||||
- [x] 验证测试框架可用
|
||||
- 运行命令: `bun test src/utils/__tests__/hash.test.ts 2>&1 | tail -3`
|
||||
- 预期: 测试运行成功,无框架错误
|
||||
|
||||
**检查步骤:**
|
||||
- [x] Bun 版本确认
|
||||
- `bun --version`
|
||||
- 预期: 输出有效版本号
|
||||
- [x] GBK 编码支持确认
|
||||
- `bun -e "console.log(new TextDecoder('gbk').decode(Buffer.from([0xC4, 0xE3, 0xBA, 0xC3])))"`
|
||||
- 预期: 输出 "你好"
|
||||
- [x] 现有测试通过
|
||||
- `bun test src/utils/__tests__/file.test.ts 2>&1 | tail -3`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
---
|
||||
@@ -1,141 +0,0 @@
|
||||
### Task 1: 编码检测核心模块
|
||||
|
||||
**背景:**
|
||||
当前 `src/utils/fileRead.ts` 的 `detectEncodingForResolvedPath` 仅通过 BOM 头识别 UTF-8 和 UTF-16LE,其他所有文件一律返回 `utf8`,导致 GBK 等非 UTF-8 编码文件读取乱码。本 Task 新建独立的编码检测工具模块 `src/utils/encoding.ts`,实现三层编码检测算法(BOM → UTF-8 fatal 验证 → GBK 回退),为后续 Task 2/3/4 的读写路径改造提供统一的编码检测和解码能力。本 Task 无前置依赖,是后续所有 Task 的基础。
|
||||
|
||||
**涉及文件:**
|
||||
- 新建: `src/utils/encoding.ts`
|
||||
- 新建: `src/utils/__tests__/encoding.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 创建 `src/utils/encoding.ts`,定义类型
|
||||
- 位置: 文件顶部
|
||||
- 导出以下类型:
|
||||
```typescript
|
||||
/** 扩展编码类型,覆盖最常见的非 UTF-8 CJK 编码 */
|
||||
export type FileEncoding = BufferEncoding | 'gbk'
|
||||
|
||||
/** TextDecoder 接受的编码名(string),比 FileEncoding 更宽泛 */
|
||||
export type DetectedEncoding = string
|
||||
```
|
||||
- 原因: 后续 Task 2/3/4 需要这些类型来做编码标注和类型收窄
|
||||
|
||||
- [x] 实现 `detectEncoding(buffer: Buffer): FileEncoding` 函数
|
||||
- 位置: `src/utils/encoding.ts`,类型定义之后
|
||||
- 三层检测逻辑:
|
||||
```typescript
|
||||
export function detectEncoding(buffer: Buffer): FileEncoding {
|
||||
// Layer 1: BOM 检测(与现有 fileRead.ts 逻辑一致)
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||||
return 'utf-16le'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 3 &&
|
||||
buffer[0] === 0xef &&
|
||||
buffer[1] === 0xbb &&
|
||||
buffer[2] === 0xbf
|
||||
) {
|
||||
return 'utf-8'
|
||||
}
|
||||
|
||||
// Layer 2: UTF-8 fatal 验证
|
||||
// fatal: true 模式下,无效 UTF-8 字节序列会抛出 TypeError
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
return 'utf-8'
|
||||
} catch {
|
||||
// 不是合法 UTF-8,进入 Layer 3
|
||||
}
|
||||
|
||||
// Layer 3: GBK 回退
|
||||
try {
|
||||
new TextDecoder('gbk', { fatal: true }).decode(buffer)
|
||||
return 'gbk'
|
||||
} catch {
|
||||
// 不是合法 GBK,latin1 作为最终兜底
|
||||
}
|
||||
|
||||
return 'latin1'
|
||||
}
|
||||
```
|
||||
- 原因: BOM 必须优先于 fatal 验证;GBK 作为唯一回退避免了多编码链的字节歧义问题;latin1 单字节编码永远成功
|
||||
|
||||
- [x] 实现 `decodeBuffer(buffer: Buffer, encoding: DetectedEncoding): string` 函数
|
||||
- 位置: `src/utils/encoding.ts`,`detectEncoding` 之后
|
||||
- 逻辑:
|
||||
```typescript
|
||||
export function decodeBuffer(
|
||||
buffer: Buffer,
|
||||
encoding: DetectedEncoding,
|
||||
): string {
|
||||
return new TextDecoder(encoding).decode(buffer)
|
||||
}
|
||||
```
|
||||
- 原因: 统一解码入口,后续 Task 2/3 的读取路径都调用此函数
|
||||
|
||||
- [x] 实现 `encodeString(content: string, encoding: DetectedEncoding): { buffer: Buffer; converted: boolean }` 函数
|
||||
- 位置: `src/utils/encoding.ts`,`decodeBuffer` 之后
|
||||
- 逻辑:
|
||||
```typescript
|
||||
export function encodeString(
|
||||
content: string,
|
||||
encoding: DetectedEncoding,
|
||||
): { buffer: Buffer; converted: boolean } {
|
||||
if (encoding === 'utf-8' || encoding === 'utf8') {
|
||||
return { buffer: Buffer.from(content, 'utf-8'), converted: false }
|
||||
}
|
||||
if (encoding === 'utf-16le') {
|
||||
return { buffer: Buffer.from(content, 'utf-16le'), converted: false }
|
||||
}
|
||||
|
||||
// 其他编码(如 gbk):尝试 Buffer.from,失败则回退为 UTF-8
|
||||
try {
|
||||
const buf = Buffer.from(content, encoding as BufferEncoding)
|
||||
return { buffer: buf, converted: false }
|
||||
} catch {
|
||||
return { buffer: Buffer.from(content, 'utf-8'), converted: true }
|
||||
}
|
||||
}
|
||||
```
|
||||
- 原因: `Buffer.from` 在 Bun 中可能支持 GBK 编码名,但 Node.js 不支持。try-catch 策略兼容两种运行时;`converted` 标志让 Task 4 的写入路径能向用户报告编码转换
|
||||
|
||||
- [x] 为编码检测和解码函数编写单元测试
|
||||
- 测试文件: `src/utils/__tests__/encoding.test.ts`
|
||||
- 测试场景:
|
||||
- **BOM 检测 — UTF-16LE**: 输入 `Buffer.from([0xff, 0xfe, 0x48, 0x00])` → 预期返回 `'utf-16le'`
|
||||
- **BOM 检测 — UTF-8 BOM**: 输入 `Buffer.from([0xef, 0xbb, 0xbf, 0x48, 0x65])` → 预期返回 `'utf-8'`
|
||||
- **UTF-8 验证**: 输入 `Buffer.from('Hello, 世界', 'utf-8')` → 预期返回 `'utf-8'`
|
||||
- **GBK 检测**: 输入 `Buffer.from([0xc4, 0xe3, 0xba, 0xc3])` → 预期返回 `'gbk'`
|
||||
- **空 buffer**: 输入 `Buffer.alloc(0)` → 预期返回 `'utf-8'`
|
||||
- **latin1 兜底**: 输入随机字节 `Buffer.from([0x80, 0x81, 0x82, 0x83, 0x84, 0x85])` → 预期返回 `'latin1'`
|
||||
- **BOM 优先于内容分析**: 输入带 UTF-8 BOM 的数据 → 预期返回 `'utf-8'`
|
||||
- **decodeBuffer — UTF-8**: 输入 UTF-8 编码的 buffer + encoding `'utf-8'` → 预期返回正确的中文字符串
|
||||
- **decodeBuffer — GBK**: 输入 GBK 编码的 buffer + encoding `'gbk'` → 预期返回正确的中文字符串
|
||||
- **decodeBuffer — UTF-16LE**: 输入 UTF-16LE 编码的 buffer + encoding `'utf-16le'` → 预期返回正确字符串
|
||||
- **decodeBuffer — 空 buffer**: 输入空 buffer → 预期返回空字符串
|
||||
- **encodeString — UTF-8**: 输入字符串 + encoding `'utf-8'` → 预期 `{ converted: false }`
|
||||
- **encodeString — utf8 别名**: 输入字符串 + encoding `'utf8'` → 预期 `{ converted: false }`
|
||||
- **encodeString — UTF-16LE**: 输入字符串 + encoding `'utf-16le'` → 预期 `{ converted: false }`
|
||||
- **encodeString — GBK**: 输入字符串 + encoding `'gbk'` → 预期返回有效的 Buffer(converted 视运行时而定)
|
||||
- 运行命令: `bun test src/utils/__tests__/encoding.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `encoding.ts` 文件存在且导出正确
|
||||
- `grep -c "export" src/utils/encoding.ts`
|
||||
- 预期: 输出 >= 4(至少导出 FileEncoding, DetectedEncoding, detectEncoding, decodeBuffer, encodeString 共 5 个导出)
|
||||
|
||||
- [x] 验证类型检查通过
|
||||
- `bunx tsc --noEmit src/utils/encoding.ts 2>&1 | head -5`
|
||||
- 预期: 无类型错误输出
|
||||
|
||||
- [x] 运行编码检测单元测试
|
||||
- `bun test src/utils/__tests__/encoding.test.ts`
|
||||
- 预期: 所有测试通过,无失败用例
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `src/utils/encoding.ts` 是文件编码检测的唯一入口,提供 `detectEncoding`(三层检测:BOM → UTF-8 fatal → GBK 回退)和 `decodeBuffer`/`encodeString` 函数。检测基于文件头部 4KB,零外部依赖,仅使用 TextDecoder API。`FileEncoding` 类型为 `BufferEncoding | 'gbk'`,覆盖最常见非 UTF-8 CJK 编码。latin1 作为最终兜底编码(单字节编码永远成功)。
|
||||
|
||||
---
|
||||
@@ -1,163 +0,0 @@
|
||||
### Task 2: 同步读取路径集成
|
||||
|
||||
**背景:**
|
||||
当前同步读取路径(`fileRead.ts` → `file.ts` → `fileReadCache.ts`)的编码检测仅通过 BOM 头识别 UTF-8 和 UTF-16LE,非 BOM 编码文件一律按 UTF-8 读取导致乱码。本 Task 将 `detectEncodingForResolvedPath` 的内部实现从 BOM-only 升级为调用 Task 1 创建的 `encoding.ts` 三层检测,并将返回类型从 `BufferEncoding` 扩展为 `FileEncoding`。同时将所有 `fs.readFileSync(path, { encoding })` 调用改为先读 Buffer 再用 `decodeBuffer` 解码,以支持 `gbk` 等非 `BufferEncoding` 编码。本 Task 依赖 Task 1(`src/utils/encoding.ts`),输出被 Task 4(写入路径适配)依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/utils/fileRead.ts`
|
||||
- 修改: `src/utils/file.ts`
|
||||
- 修改: `src/utils/fileReadCache.ts`
|
||||
- 新建: `src/utils/__tests__/fileRead.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `fileRead.ts` 中导入 `encoding.ts` 的类型和函数
|
||||
- 位置: `src/utils/fileRead.ts` 文件顶部 import 区域,在 `import { getFsImplementation, safeResolvePath } from './fsOperations.js'` 之后
|
||||
- 添加导入:
|
||||
```typescript
|
||||
import { type FileEncoding, decodeBuffer, detectEncoding } from './encoding.js'
|
||||
```
|
||||
- 原因: 后续步骤需要 `FileEncoding` 类型、`detectEncoding` 检测函数和 `decodeBuffer` 解码函数
|
||||
|
||||
- [x] 改造 `detectEncodingForResolvedPath` 函数,使用 `encoding.ts` 的三层检测
|
||||
- 位置: `src/utils/fileRead.ts` 的 `detectEncodingForResolvedPath` 函数
|
||||
- 将函数体替换为以下逻辑:
|
||||
```typescript
|
||||
export function detectEncodingForResolvedPath(
|
||||
resolvedPath: string,
|
||||
): FileEncoding {
|
||||
const { buffer, bytesRead } = getFsImplementation().readSync(resolvedPath, {
|
||||
length: 4096,
|
||||
})
|
||||
|
||||
// Empty files default to utf8 — nothing to detect
|
||||
if (bytesRead === 0) {
|
||||
return 'utf8'
|
||||
}
|
||||
|
||||
return detectEncoding(buffer.subarray(0, bytesRead))
|
||||
}
|
||||
```
|
||||
- 关键变更:
|
||||
- 返回类型从 `BufferEncoding` 改为 `FileEncoding`
|
||||
- 删除内联的 BOM 检测逻辑,改为调用 `detectEncoding(buffer.subarray(0, bytesRead))`
|
||||
- 使用 `buffer.subarray(0, bytesRead)` 截取实际读取的字节,避免尾部零字节干扰检测
|
||||
- 原因: 将检测逻辑委托给 `encoding.ts` 的三层算法,消除代码重复
|
||||
|
||||
- [x] 改造 `readFileSyncWithMetadata` 函数,支持非 `BufferEncoding` 解码
|
||||
- 位置: `src/utils/fileRead.ts` 的 `readFileSyncWithMetadata` 函数
|
||||
- 将函数签名和内部逻辑改为:
|
||||
```typescript
|
||||
export function readFileSyncWithMetadata(filePath: string): {
|
||||
content: string
|
||||
encoding: FileEncoding
|
||||
lineEndings: LineEndingType
|
||||
} {
|
||||
const fs = getFsImplementation()
|
||||
const { resolvedPath, isSymlink } = safeResolvePath(fs, filePath)
|
||||
|
||||
if (isSymlink) {
|
||||
logForDebugging(`Reading through symlink: ${filePath} -> ${resolvedPath}`)
|
||||
}
|
||||
|
||||
const encoding = detectEncodingForResolvedPath(resolvedPath)
|
||||
// Read raw Buffer first — fs.readFileSync encoding option only accepts
|
||||
// BufferEncoding, not gbk etc.
|
||||
const rawBuffer = fs.readFileBytesSync(resolvedPath)
|
||||
const raw = decodeBuffer(rawBuffer, encoding)
|
||||
const lineEndings = detectLineEndingsForString(raw.slice(0, 4096))
|
||||
return {
|
||||
content: raw.replaceAll('\r\n', '\n'),
|
||||
encoding,
|
||||
lineEndings,
|
||||
}
|
||||
}
|
||||
```
|
||||
- 关键变更:
|
||||
- 返回类型中 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`
|
||||
- `fs.readFileSync(resolvedPath, { encoding })` 改为 `fs.readFileBytesSync(resolvedPath)` 读取 Buffer
|
||||
- 新增 `decodeBuffer(rawBuffer, encoding)` 解码为字符串
|
||||
- 原因: `fs.readFileSync` 的 `encoding` 选项只接受 `BufferEncoding`(utf8/utf16le/latin1 等),传入 `'gbk'` 会在运行时报错
|
||||
|
||||
- [x] 更新 `file.ts` 中 `detectFileEncoding` 的返回类型
|
||||
- 位置: `src/utils/file.ts` 的 `detectFileEncoding` 函数签名
|
||||
- 将 `): BufferEncoding {` 改为 `): FileEncoding {`
|
||||
- 在文件顶部 import 区域添加:
|
||||
```typescript
|
||||
import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'
|
||||
```
|
||||
- 原因: `detectFileEncoding` 调用 `detectEncodingForResolvedPath`,返回类型已改为 `FileEncoding`
|
||||
|
||||
- [x] 更新 `file.ts` 中 `detectLineEndings` 的 encoding 参数类型和解码逻辑
|
||||
- 位置: `src/utils/file.ts` 的 `detectLineEndings` 函数
|
||||
- 将函数签名改为:
|
||||
```typescript
|
||||
export function detectLineEndings(
|
||||
filePath: string,
|
||||
encoding: FileEncoding = 'utf8',
|
||||
): LineEndingType {
|
||||
```
|
||||
- 将内部 `buffer.toString(encoding, 0, bytesRead)` 改为:
|
||||
```typescript
|
||||
const content = decodeBuffer(buffer.subarray(0, bytesRead), encoding)
|
||||
```
|
||||
- 原因: `buffer.toString('gbk')` 不可靠,统一使用 `decodeBuffer` 通过 `TextDecoder` 解码
|
||||
|
||||
- [x] 更新 `fileReadCache.ts` 的类型和解码逻辑
|
||||
- 位置: `src/utils/fileReadCache.ts`
|
||||
- 在文件顶部 import 区域添加:
|
||||
```typescript
|
||||
import { type FileEncoding, decodeBuffer } from './encoding.js'
|
||||
```
|
||||
- 将 `CachedFileData` 类型中 `encoding: BufferEncoding` 改为 `encoding: FileEncoding`
|
||||
- 将 `readFile` 方法返回类型改为 `{ content: string; encoding: FileEncoding }`
|
||||
- 将缓存未命中读取逻辑改为:
|
||||
```typescript
|
||||
const encoding = detectFileEncoding(filePath)
|
||||
const rawBuffer = fs.readFileBytesSync(filePath)
|
||||
const content = decodeBuffer(rawBuffer, encoding).replaceAll('\r\n', '\n')
|
||||
```
|
||||
- 原因: 与 `fileRead.ts` 相同——必须改为 Buffer 读取 + `decodeBuffer` 解码
|
||||
|
||||
- [x] 为改造后的 `detectEncodingForResolvedPath` 和 `readFileSyncWithMetadata` 编写单元测试
|
||||
- 测试文件: `src/utils/__tests__/fileRead.test.ts`
|
||||
- 测试场景:
|
||||
- **UTF-8 文件读取**: 创建临时 UTF-8 文件 → 返回 `encoding: 'utf-8'`,content 与写入内容一致
|
||||
- **GBK 文件读取**: 创建临时 GBK 编码文件 → 返回 `encoding: 'gbk'`,content 包含正确的中文字符
|
||||
- **空文件读取**: 创建空文件 → 返回 `encoding: 'utf8'`,content 为空字符串
|
||||
- **UTF-16LE BOM 文件读取**: 创建带 BOM 的 UTF-16LE 文件 → 返回 `encoding: 'utf-16le'`
|
||||
- **detectEncodingForResolvedPath 返回类型**: 验证返回值为 `FileEncoding` 类型
|
||||
- Mock 策略: 使用 `tests/mocks/debug.ts` mock `debug.ts`,使用 `tests/mocks/log.ts` mock `log.ts`
|
||||
- 运行命令: `bun test src/utils/__tests__/fileRead.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `fileRead.ts` 的导入和返回类型已更新
|
||||
- `grep -n "FileEncoding\|decodeBuffer\|detectEncoding" src/utils/fileRead.ts`
|
||||
- 预期: 输出包含 import 行中的 `FileEncoding`、`decodeBuffer`,以及函数体中的 `detectEncoding` 调用
|
||||
|
||||
- [x] 验证 `file.ts` 的类型已更新
|
||||
- `grep -n "FileEncoding\|decodeBuffer" src/utils/file.ts`
|
||||
- 预期: `detectFileEncoding` 返回 `FileEncoding`,`detectLineEndings` 参数类型为 `FileEncoding`
|
||||
|
||||
- [x] 验证 `fileReadCache.ts` 的类型已更新
|
||||
- `grep -n "FileEncoding\|decodeBuffer" src/utils/fileReadCache.ts`
|
||||
- 预期: `CachedFileData` 和 `readFile` 返回类型使用 `FileEncoding`
|
||||
|
||||
- [x] 验证 `fileRead.ts` 中不再有内联 BOM 检测逻辑
|
||||
- `grep -c "0xff\|0xfe\|0xef\|0xbb\|0xbf" src/utils/fileRead.ts`
|
||||
- 预期: 输出为 0
|
||||
|
||||
- [x] 运行 fileRead 单元测试
|
||||
- `bun test src/utils/__tests__/fileRead.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
- [x] 运行 precheck 确认无类型/lint/测试错误
|
||||
- `bun run precheck`
|
||||
- 预期: 零错误通过
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `fs.readFileSync(path, { encoding })` 的 `encoding` 选项只接受 `BufferEncoding`(utf8/utf16le/latin1/ascii/binary/hex/base64/ucs2/utf16le),不支持 `gbk` 等 ICU 编码名。读取非 UTF-8 文件时必须先 `fs.readFileSync(path)` 读 Buffer,再用 `TextDecoder` 解码。项目中所有文件读取路径(fileRead.ts、fileReadCache.ts、file.ts)已统一使用 `decodeBuffer` 函数处理此逻辑。
|
||||
|
||||
---
|
||||
@@ -1,161 +0,0 @@
|
||||
### Task 3: 异步读取路径改造
|
||||
|
||||
**背景:**
|
||||
当前 `src/utils/readFileInRange.ts` 是 FileReadTool 的核心异步读取函数,提供 fast path(小文件整体读入)和 streaming path(大文件逐块扫描)两条路径,两者均硬编码 `encoding: 'utf8'`,导致非 UTF-8 编码文件读取乱码。本 Task 将两条路径改造为 Buffer 读取 + 编码检测 + TextDecoder 解码模式。fast path 改造简单(整体读 Buffer 后检测解码),streaming path 需要两阶段设计(先收集前 4KB 做编码检测,再用 `TextDecoder({ stream: true })` 逐 chunk 解码)。本 Task 依赖 Task 1(`src/utils/encoding.ts` 的 `detectEncoding` 和 `decodeBuffer`),输出被 Task 4 依赖(通过 `readFileInRange` 的返回值间接影响)。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/utils/readFileInRange.ts`
|
||||
- 新建: `src/utils/__tests__/readFileInRange.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `readFileInRange.ts` 中导入 `encoding.ts` 的函数
|
||||
- 位置: `src/utils/readFileInRange.ts` 文件顶部 import 区域,在 `import { formatFileSize } from './format.js'` 之后
|
||||
- 添加导入:
|
||||
```typescript
|
||||
import { detectEncoding, decodeBuffer } from './encoding.js'
|
||||
```
|
||||
- 原因: fast path 和 streaming path 都需要 `detectEncoding` 做编码检测,fast path 需要 `decodeBuffer` 做一次性解码
|
||||
|
||||
- [x] 改造 fast path — 将 `readFile` 从 UTF-8 字符串读取改为 Buffer 读取 + 检测 + 解码
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `readFileInRange` 函数内 fast path 分支
|
||||
- 将以下代码:
|
||||
```typescript
|
||||
const text = await readFile(filePath, { encoding: 'utf8', signal })
|
||||
return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
|
||||
```
|
||||
替换为:
|
||||
```typescript
|
||||
const rawBuffer = await readFile(filePath, { signal })
|
||||
const encoding = detectEncoding(rawBuffer)
|
||||
const text = decodeBuffer(rawBuffer, encoding)
|
||||
return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
|
||||
```
|
||||
- 关键变更: `readFile` 去掉 `encoding: 'utf8'` 选项,返回 `Buffer`;调用 `detectEncoding(rawBuffer)` 检测编码;调用 `decodeBuffer(rawBuffer, encoding)` 解码为字符串。
|
||||
- 原因: `readFile` 的 `encoding` 选项只支持 `BufferEncoding`,不支持 `gbk` 等 ICU 编码名
|
||||
|
||||
- [x] 改造 streaming path — 扩展 `StreamState` 类型,增加编码检测和解码相关字段
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `StreamState` 类型定义
|
||||
- 在现有字段之后添加以下字段:
|
||||
```typescript
|
||||
type StreamState = {
|
||||
// ... 现有字段保持不变 ...
|
||||
/** 编码检测状态:null 表示尚未检测,string 表示已检测完成 */
|
||||
encoding: string | null
|
||||
/** TextDecoder 实例:检测完成后创建,用于逐 chunk 流式解码 */
|
||||
decoder: TextDecoder | null
|
||||
/** 检测阶段缓冲区:收集原始字节直到满 4KB 或 stream 结束 */
|
||||
detectionBuffer: number[]
|
||||
}
|
||||
```
|
||||
- 原因: streaming 模式下 chunk 是增量到达的,需要缓冲阶段收集足够字节来调用 `detectEncoding`
|
||||
|
||||
- [x] 改造 `streamOnData` — 处理 Buffer chunk,实现两阶段(检测阶段 + 解码阶段)
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `streamOnData` 函数
|
||||
- 将函数签名从 `streamOnData(this: StreamState, chunk: string): void` 改为 `streamOnData(this: StreamState, chunk: Buffer): void`
|
||||
- 替换函数体为两阶段逻辑:
|
||||
```typescript
|
||||
function streamOnData(this: StreamState, chunk: Buffer): void {
|
||||
this.totalBytesRead += chunk.length
|
||||
|
||||
// ... maxBytes 检查保持不变 ...
|
||||
|
||||
// Phase 1: 编码检测阶段
|
||||
if (this.encoding === null) {
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
this.detectionBuffer.push(chunk[i])
|
||||
}
|
||||
if (this.detectionBuffer.length >= 4096) {
|
||||
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
|
||||
this.decoder = new TextDecoder(this.encoding, { stream: true })
|
||||
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
|
||||
this.detectionBuffer = []
|
||||
processTextChunk(this, decoded)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: 解码阶段
|
||||
const decoded = this.decoder!.decode(chunk, { stream: true })
|
||||
processTextChunk(this, decoded)
|
||||
}
|
||||
```
|
||||
- 原因: 两阶段设计确保编码检测在足够数据上执行(至少 4KB),检测完成后用 `TextDecoder({ stream: true })` 逐 chunk 解码
|
||||
|
||||
- [x] 提取行扫描逻辑为独立的 `processTextChunk` 辅助函数
|
||||
- 位置: `src/utils/readFileInRange.ts`,在 `streamOnData` 函数定义之前
|
||||
- 从原 `streamOnData` 提取行扫描逻辑到独立函数 `processTextChunk(state: StreamState, text: string): void`
|
||||
- 行扫描逻辑与原实现完全一致,仅变量名从 `this.` 改为 `state.`
|
||||
- 原因: 检测阶段和解码阶段复用同一段行扫描逻辑
|
||||
|
||||
- [x] 改造 `streamOnEnd` — 处理检测阶段缓冲区残留和最终 fragment
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `streamOnEnd` 函数
|
||||
- 在函数体开头插入检测阶段完成逻辑:
|
||||
```typescript
|
||||
if (this.encoding === null) {
|
||||
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
|
||||
this.decoder = new TextDecoder(this.encoding, { stream: true })
|
||||
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
|
||||
this.detectionBuffer = []
|
||||
processTextChunk(this, decoded)
|
||||
}
|
||||
```
|
||||
- 原因: 小文件可能 < 4KB,stream 在检测缓冲区未满时就结束。必须在 `streamOnEnd` 中完成检测和解码
|
||||
|
||||
- [x] 改造 `readFileInRangeStreaming` — 创建 Buffer 模式的 stream,初始化新增字段
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `readFileInRangeStreaming` 函数
|
||||
- 将 `createReadStream` 调用去掉 `encoding: 'utf8'` 选项
|
||||
- 在 `state` 对象初始化中添加新字段: `encoding: null, decoder: null, detectionBuffer: []`
|
||||
- 原因: 去掉 `encoding: 'utf8'` 后,`data` 事件回调接收 `Buffer` 对象
|
||||
|
||||
- [x] 更新文件顶部注释,反映编码检测能力
|
||||
- 位置: `src/utils/readFileInRange.ts` 文件顶部注释
|
||||
- 注释已更新为: `Both paths auto-detect encoding via encoding.ts (BOM → UTF-8 fatal → fallback chain), decode with TextDecoder, and strip BOM and \r (CRLF → LF).`
|
||||
|
||||
- [x] 为改造后的 `readFileInRange` 编写单元测试
|
||||
- 测试文件: `src/utils/__tests__/readFileInRange.test.ts`
|
||||
- 测试场景:
|
||||
- **Fast path — UTF-8 文件**: 创建临时 UTF-8 文件 → 返回正确的 `content`、`lineCount`、`totalLines`
|
||||
- **Fast path — GBK 文件**: 创建临时 GBK 编码文件 → 返回正确的中文内容(非乱码),`totalBytes` 正确
|
||||
- **Fast path — 带行范围读取 GBK 文件**: 创建包含多行的 GBK 文件 → 返回指定行范围,内容正确
|
||||
- **Streaming path — 大 UTF-8 文件**: 创建超过 10MB 阈值的 UTF-8 文件 → 返回正确内容
|
||||
- **Streaming path — 大 GBK 文件**: 创建超过 10MB 阈值的 GBK 编码文件 → 返回正确的中文内容
|
||||
- **BOM 剥离**: 创建带 UTF-8 BOM 的文件 → `content` 不包含 BOM 字符
|
||||
- **空文件**: 创建空文件 → `content` 为空字符串,`totalLines` 为 1,`totalBytes` 为 0
|
||||
- 运行命令: `bun test src/utils/__tests__/readFileInRange.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `readFileInRange.ts` 已导入 `encoding.ts` 的函数
|
||||
- `grep -n "detectEncoding\|decodeBuffer" src/utils/readFileInRange.ts`
|
||||
- 预期: import 行包含 `detectEncoding` 和 `decodeBuffer`,函数体中包含调用
|
||||
|
||||
- [x] 验证 streaming path 不再硬编码 `encoding: 'utf8'`
|
||||
- `grep -n "encoding: 'utf8'\|encoding: \"utf8\"" src/utils/readFileInRange.ts`
|
||||
- 预期: 无匹配结果
|
||||
|
||||
- [x] 验证 `createReadStream` 调用无 encoding 选项
|
||||
- `grep -A3 "createReadStream" src/utils/readFileInRange.ts`
|
||||
- 预期: `createReadStream` 的选项对象中不包含 `encoding` 属性
|
||||
|
||||
- [x] 验证 `StreamState` 类型包含编码检测新字段
|
||||
- `grep -n "encoding:\|decoder:\|detectionBuffer:" src/utils/readFileInRange.ts`
|
||||
- 预期: `StreamState` 类型定义中包含 `encoding`、`decoder`、`detectionBuffer` 字段
|
||||
|
||||
- [x] 验证 `processTextChunk` 函数存在
|
||||
- `grep -n "function processTextChunk" src/utils/readFileInRange.ts`
|
||||
- 预期: 函数定义存在
|
||||
|
||||
- [x] 运行 readFileInRange 单元测试
|
||||
- `bun test src/utils/__tests__/readFileInRange.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
- [x] 运行 precheck 确认无类型/lint/测试错误
|
||||
- `bun run precheck`
|
||||
- 预期: 零错误通过
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `readFileInRange.ts` 的 streaming path 使用两阶段编码检测:先收集前 4KB 字节调用 `detectEncoding`,再用 `TextDecoder({ stream: true })` 逐 chunk 流式解码。`TextDecoder` 的 `{ stream: true }` 模式会自动处理多字节字符跨 chunk 边界问题。对于 < 4KB 的小文件,检测在 `streamOnEnd` 中完成。
|
||||
|
||||
---
|
||||
@@ -1,155 +0,0 @@
|
||||
### Task 4: 写入路径和工具层适配
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 当用户通过 FileEditTool 或 FileWriteTool 编辑非 UTF-8 编码文件(如 GBK)时,写入操作需要将内部 UTF-8 字符串编码回原文件编码,否则写入的内容会乱码。当前 `writeTextContent` 只接受 `BufferEncoding` 类型,无法处理 gbk 等编码。
|
||||
[修改原因] — `writeTextContent` 的 `encoding` 参数类型为 `BufferEncoding`,`writeFileSyncAndFlush_DEPRECATED` 内部直接将 encoding 传给 `fs.writeFileSync`(只接受标准 BufferEncoding)。`FileEditTool.validateInput` 中硬编码了 BOM-only 编码检测,无法识别 GBK 文件。
|
||||
[上下游影响] — 本 Task 依赖 Task 1 创建的 `encodeString` 函数和 `FileEncoding` 类型。`FileEditTool` 和 `FileWriteTool` 通过 `writeTextContent` 间接依赖本 Task 的改造。BashTool 和 NotebookEditTool 也调用 `writeTextContent`,签名变更后它们无需额外改动(encoding 参数类型由上游传入,自动兼容)。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/utils/file.ts`
|
||||
- 修改: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `src/utils/file.ts` 中合并 `encodeString` 到 Task 2 已创建的 `encoding.js` 导入
|
||||
- 位置: 文件导入区域,Task 2 已添加的 `import { type FileEncoding, decodeBuffer } from './encoding.js'` 行
|
||||
- 将该行改为: `import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'`
|
||||
- 原因: 避免对同一模块创建两个 import 语句
|
||||
|
||||
- [x] 将 `writeTextContent` 的 `encoding` 参数类型从 `BufferEncoding` 改为 `FileEncoding`
|
||||
- 位置: `src/utils/file.ts:writeTextContent()`
|
||||
- 修改函数签名:
|
||||
```typescript
|
||||
export function writeTextContent(
|
||||
filePath: string,
|
||||
content: string,
|
||||
encoding: FileEncoding,
|
||||
endings: LineEndingType,
|
||||
): void
|
||||
```
|
||||
- 修改函数体,在行尾处理之后、调用 `writeFileSyncAndFlush_DEPRECATED` 之前,增加编码判断逻辑:
|
||||
```typescript
|
||||
const BUFFER_ENCODINGS = new Set<string>([
|
||||
'utf8', 'utf-8', 'utf16le', 'ucs2', 'ucs-2',
|
||||
'ascii', 'latin1', 'binary', 'base64', 'hex',
|
||||
])
|
||||
|
||||
if (BUFFER_ENCODINGS.has(encoding)) {
|
||||
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding: encoding as BufferEncoding })
|
||||
} else {
|
||||
// 非 BufferEncoding(如 gbk),使用 encodeString 获取 Buffer
|
||||
const { buffer, converted } = encodeString(toWrite, encoding)
|
||||
writeFileSyncAndFlush_DEPRECATED(filePath, buffer, { buffer })
|
||||
if (converted) {
|
||||
logForDebugging(
|
||||
`writeTextContent: encoding '${encoding}' unsupported for write, fell back to UTF-8 for ${filePath}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
- 原因: `fs.writeFileSync` 只接受标准 BufferEncoding,对于 gbk 等编码必须先转为 Buffer 再写入
|
||||
|
||||
- [x] 扩展 `writeFileSyncAndFlush_DEPRECATED` 支持 Buffer 写入
|
||||
- 位置: `src/utils/file.ts:writeFileSyncAndFlush_DEPRECATED()`
|
||||
- 修改函数签名中 `content` 参数类型和 `options` 类型:
|
||||
```typescript
|
||||
export function writeFileSyncAndFlush_DEPRECATED(
|
||||
filePath: string,
|
||||
content: string | Buffer,
|
||||
options: { encoding?: BufferEncoding; mode?: number; buffer?: Buffer } = {},
|
||||
): void
|
||||
```
|
||||
- 修改原子写入路径的 `writeOptions` 构建逻辑:
|
||||
```typescript
|
||||
const isBufferWrite = Buffer.isBuffer(content) || options.buffer !== undefined
|
||||
const writeData = options.buffer ?? content
|
||||
const writeOptions: {
|
||||
encoding?: BufferEncoding
|
||||
flush: boolean
|
||||
mode?: number
|
||||
} = {
|
||||
flush: true,
|
||||
...(isBufferWrite ? {} : { encoding: options.encoding ?? 'utf-8' }),
|
||||
}
|
||||
```
|
||||
- 修改非原子回退路径,使用相同的 `isBufferWrite` / `writeData` / `writeOptions` 模式
|
||||
- 原因: `fs.writeFileSync(path, buffer)` 可以直接写入 Buffer,不需要 encoding 参数
|
||||
|
||||
- [x] 在 `FileEditTool.ts` 中导入 `FileEncoding` 和 `detectEncoding` / `decodeBuffer`
|
||||
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts` 导入区域
|
||||
- 添加: `import { detectEncoding, decodeBuffer, type FileEncoding } from 'src/utils/encoding.js'`
|
||||
- 原因: `validateInput` 编码检测和 `readFileForEdit` 返回类型需要 `FileEncoding` 类型
|
||||
|
||||
- [x] 将 `readFileForEdit` 返回类型中的 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`
|
||||
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts:readFileForEdit()`
|
||||
- 修改返回类型声明:
|
||||
```typescript
|
||||
function readFileForEdit(absoluteFilePath: string): {
|
||||
content: string
|
||||
fileExists: boolean
|
||||
encoding: FileEncoding
|
||||
lineEndings: LineEndingType
|
||||
}
|
||||
```
|
||||
- 原因: `readFileSyncWithMetadata` 返回的 `encoding` 类型已由 Task 2 改为 `FileEncoding`
|
||||
|
||||
- [x] 改造 `FileEditTool.validateInput` 中的编码检测逻辑
|
||||
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts:validateInput()`
|
||||
- 将现有的 BOM-only 编码检测:
|
||||
```typescript
|
||||
const encoding: BufferEncoding =
|
||||
fileBuffer.length >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe
|
||||
? 'utf16le'
|
||||
: 'utf8'
|
||||
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
|
||||
```
|
||||
- 替换为:
|
||||
```typescript
|
||||
const encoding: FileEncoding = detectEncoding(fileBuffer)
|
||||
fileContent = decodeBuffer(fileBuffer, encoding).replaceAll('\r\n', '\n')
|
||||
```
|
||||
- 原因: 使 validateInput 也能正确识别 GBK 文件,避免编辑时因编码检测不一致导致 old_string 匹配失败
|
||||
|
||||
- [x] 为 `writeTextContent` 的多编码写入能力编写单元测试
|
||||
- 测试文件: `src/utils/__tests__/file.test.ts`
|
||||
- 在现有测试 describe 块之后追加新的 describe('writeTextContent with multi-encoding') 块
|
||||
- 测试场景:
|
||||
- UTF-8 写入: 写入 UTF-8 内容 → 文件内容正确,无回退警告
|
||||
- UTF-16LE 写入: 写入 UTF-16LE 内容(含 BOM) → 文件二进制内容与预期一致
|
||||
- GBK 写入回退: 对 gbk 编码调用 `writeTextContent` → 文件以 UTF-8 写入(`encodeString` 回退行为),内容不损坏
|
||||
- CRLF 行尾 + GBK: `endings: 'CRLF'` + gbk 编码 → 行尾正确转换为 `\r\n`,编码回退为 UTF-8
|
||||
- 注意: 需要 mock `src/utils/debug.ts`(使用共享 mock `tests/mocks/debug.ts`)
|
||||
- 运行命令: `bun test src/utils/__tests__/file.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证 `writeTextContent` 签名使用 `FileEncoding` 类型
|
||||
- `grep -n 'encoding: FileEncoding' src/utils/file.ts`
|
||||
- 预期: 输出包含 `writeTextContent` 函数定义行
|
||||
|
||||
- [x] 验证 `writeFileSyncAndFlush_DEPRECATED` 支持 Buffer 写入
|
||||
- `grep -n 'content: string | Buffer' src/utils/file.ts`
|
||||
- 预期: 输出包含 `writeFileSyncAndFlush_DEPRECATED` 函数定义行
|
||||
|
||||
- [x] 验证 `FileEditTool.readFileForEdit` 返回类型已更新
|
||||
- `grep -n 'encoding: FileEncoding' packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
|
||||
- 预期: 输出包含 `readFileForEdit` 函数的返回类型声明
|
||||
|
||||
- [x] 验证 `FileEditTool.validateInput` 使用 `detectEncoding`
|
||||
- `grep -n 'detectEncoding' packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
|
||||
- 预期: 输出包含 validateInput 内部的调用
|
||||
|
||||
- [x] 运行 file.ts 单元测试
|
||||
- `bun test src/utils/__tests__/file.test.ts`
|
||||
- 预期: 所有测试通过,无新增失败
|
||||
|
||||
- [x] 运行 FileEditTool 工具函数测试
|
||||
- `bun test packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts`
|
||||
- 预期: 所有现有测试通过
|
||||
|
||||
- [x] 运行完整 precheck
|
||||
- `bun run precheck`
|
||||
- 预期: typecheck + lint + test 零错误通过
|
||||
|
||||
---
|
||||
@@ -1,49 +0,0 @@
|
||||
# 多编码文件工具 执行计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**目标:** 为文件读写工具添加自动编码检测,支持 GBK 编码的透明读写(latin1 作为最终兜底)。
|
||||
|
||||
**技术栈:** TextDecoder/TextEncoder(零外部依赖)、Bun test 框架、TypeScript strict mode
|
||||
|
||||
**设计文档:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-design.md
|
||||
|
||||
**范围变更:** 仅保留 GBK 编码支持,Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030/ISO-8859-1 已移除。
|
||||
|
||||
## 改动总览
|
||||
|
||||
新建编码检测核心模块 `src/utils/encoding.ts`,提供三层检测(BOM → UTF-8 fatal 验证 → GBK 回退 → latin1 兜底)和解码工具函数。同步读取路径(fileRead.ts → file.ts → fileReadCache.ts)集成新检测逻辑,异步读取路径(readFileInRange.ts)改造为 Buffer 读取 + 检测后解码。写入路径(writeTextContent)扩展类型支持新编码名,非标准编码回退为 UTF-8 写入。FileEditTool 和 FileWriteTool 仅需类型适配。
|
||||
|
||||
---
|
||||
|
||||
## 任务索引
|
||||
|
||||
### Task 0: 环境准备
|
||||
📄 详情见: `spec-plan-task-0.md`
|
||||
|
||||
验证构建工具链和测试环境是否就绪,确认 Bun 运行时对 GBK 编码的 TextDecoder 支持。
|
||||
|
||||
### Task 1: 编码检测核心模块
|
||||
📄 详情见: `spec-plan-task-1.md`
|
||||
|
||||
新建 `src/utils/encoding.ts`,实现三层编码检测算法(BOM → UTF-8 fatal 验证 → GBK 回退)和 Buffer 解码/编码函数。
|
||||
|
||||
### Task 2: 同步读取路径集成
|
||||
📄 详情见: `spec-plan-task-2.md`
|
||||
|
||||
改造 `fileRead.ts` 和 `file.ts` 的编码检测,集成新模块,更新类型定义。
|
||||
|
||||
### Task 3: 异步读取路径改造
|
||||
📄 详情见: `spec-plan-task-3.md`
|
||||
|
||||
改造 `readFileInRange.ts` 的 fast path 和 streaming path,支持非 UTF-8 编码。
|
||||
|
||||
### Task 4: 写入路径和工具层适配
|
||||
📄 详情见: `spec-plan-task-4.md`
|
||||
|
||||
扩展写入路径类型,更新 FileEditTool/FileWriteTool 的类型注解。
|
||||
|
||||
### Acceptance Task
|
||||
📄 详情见: `spec-plan-acceptance.md`
|
||||
|
||||
端到端验证所有功能是否正确实现。
|
||||
@@ -377,9 +377,6 @@ const cronJitterConfigModule =
|
||||
require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
|
||||
const cronGate =
|
||||
require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
|
||||
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
|
||||
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
|
||||
: null
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
const SHUTDOWN_TEAM_PROMPT = `<system-reminder>
|
||||
@@ -985,7 +982,14 @@ export async function runHeadless(
|
||||
// the forked agent mid-flight. Gated by isExtractModeActive so the
|
||||
// tengu_slate_thimble flag controls non-interactive extraction end-to-end.
|
||||
if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) {
|
||||
await extractMemoriesModule!.drainPendingExtraction()
|
||||
try {
|
||||
const { drainPendingExtraction } = await import(
|
||||
'../services/extractMemories/extractMemories.js'
|
||||
)
|
||||
await drainPendingExtraction()
|
||||
} catch {
|
||||
// Module load failure — non-critical at shutdown
|
||||
}
|
||||
}
|
||||
|
||||
gracefulShutdownSync(
|
||||
@@ -4962,7 +4966,7 @@ function handleChannelEnable(
|
||||
// channel messages queue at priority 'next' and are seen by the model on
|
||||
// the turn after they arrive.
|
||||
connection.client.setNotificationHandler(
|
||||
ChannelMessageNotificationSchema(),
|
||||
ChannelMessageNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { content, meta } = notification.params
|
||||
logMCPDebug(
|
||||
@@ -5038,7 +5042,7 @@ function reregisterChannelHandlerAfterReconnect(
|
||||
'Channel notifications re-registered after reconnect',
|
||||
)
|
||||
connection.client.setNotificationHandler(
|
||||
ChannelMessageNotificationSchema(),
|
||||
ChannelMessageNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { content, meta } = notification.params
|
||||
logMCPDebug(
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Transport = any
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
|
||||
/** WebSocket / SSE+POST / Hybrid 等会话上行传输的共有接口。 */
|
||||
export type Transport = {
|
||||
setOnData(callback: (data: string) => void): void // 注册下行数据回调(按行文本)
|
||||
setOnClose(callback: (closeCode?: number) => void): void // 连接关闭时回调(可选关闭码)
|
||||
connect(): void | Promise<void> // 建立或重连传输
|
||||
write(message: StdoutMessage): void | Promise<void> // 向上游发送一条控制/流式消息
|
||||
close(): void // 主动关闭并释放资源
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import chalk from 'chalk'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { distRoot } from '../utils/distRoot.js'
|
||||
import { execFileNoThrowWithCwd } from '../utils/execFileNoThrow.js'
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||
import { writeToStdout } from '../utils/process.js'
|
||||
@@ -19,12 +19,9 @@ import { writeToStdout } from '../utils/process.js'
|
||||
const PACKAGE_NAME = 'claude-code-best'
|
||||
|
||||
function getCurrentVersion(): string {
|
||||
// Read version from the nearest package.json (walks up from this file)
|
||||
// Read version from the nearest package.json (walks up from dist root)
|
||||
try {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
// In dev: src/cli/updateCCB.ts → ../../package.json
|
||||
// In build: dist/chunks/xxx.js → ../../package.json (may not exist)
|
||||
const pkgPath = join(__dirname, '..', '..', 'package.json')
|
||||
const pkgPath = join(distRoot, '..', 'package.json')
|
||||
if (existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
if (pkg.version) return pkg.version
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Tests for AgentsPlatformView.tsx
|
||||
* Covers all 5 modes: list (empty), list (with agents), created, deleted, ran, error
|
||||
*/
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
|
||||
// Mock cron utility before importing AgentsPlatformView
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
cronToHuman: (expr: string) => `HumanCron(${expr})`,
|
||||
parseCronExpression: () => null,
|
||||
computeNextCronRun: () => null,
|
||||
}));
|
||||
|
||||
const { AgentsPlatformView } = await import('../AgentsPlatformView.js');
|
||||
|
||||
const sampleAgent = {
|
||||
id: 'agt_abc123',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup report',
|
||||
status: 'active' as const,
|
||||
timezone: 'UTC',
|
||||
next_run: '2026-05-05T09:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('AgentsPlatformView list mode', () => {
|
||||
test('empty list shows placeholder message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[]} />);
|
||||
expect(out).toContain('No scheduled agents');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent count', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Scheduled Agents (1)');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent status', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('active');
|
||||
});
|
||||
|
||||
test('non-empty list shows human-readable schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('list shows agent prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
|
||||
test('list shows next run date', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
// next_run is formatted via toLocaleString — just check it's rendered
|
||||
expect(out).toContain('Next run');
|
||||
});
|
||||
|
||||
test('list with null next_run shows em dash', async () => {
|
||||
const agentNoNextRun = { ...sampleAgent, next_run: null };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[agentNoNextRun]} />);
|
||||
expect(out).toContain('—');
|
||||
});
|
||||
|
||||
test('multiple agents rendered', async () => {
|
||||
const agent2 = { ...sampleAgent, id: 'agt_xyz', cron_expr: '0 10 * * 2' };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent, agent2]} />);
|
||||
expect(out).toContain('Scheduled Agents (2)');
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('agt_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView created mode', () => {
|
||||
test('shows Agent created', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Agent created');
|
||||
});
|
||||
|
||||
test('shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('shows schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('shows prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView deleted mode', () => {
|
||||
test('shows deleted confirmation with id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="deleted" id="agt_abc123" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView ran mode', () => {
|
||||
test('shows triggered with agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('triggered');
|
||||
});
|
||||
|
||||
test('shows run id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('run_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView error mode', () => {
|
||||
test('shows error message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="error" message="Network failure" />);
|
||||
expect(out).toContain('Network failure');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,24 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchAgentsPlatform.tsx
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock agentsApi.ts itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Let real agentsApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
@@ -9,42 +27,40 @@ mock.module('bun:bundle', () => ({
|
||||
}))
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const realAnalytics = await import('src/services/analytics/index.js')
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
...realAnalytics,
|
||||
logEvent: logEventMock,
|
||||
logEventAsync: mock(() => Promise.resolve()),
|
||||
_resetForTesting: mock(() => {}),
|
||||
attachAnalyticsSink: mock(() => {}),
|
||||
stripProtoFields: mock((v: unknown) => v),
|
||||
}))
|
||||
|
||||
// ── agentsApi mock ──────────────────────────────────────────────────────────
|
||||
const listMock = mock(async () => [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
])
|
||||
const createMock = mock(async (cron: string, prompt: string) => ({
|
||||
id: 'agt_new',
|
||||
cron_expr: cron,
|
||||
prompt,
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const realAuth = await import('src/utils/auth.js')
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
...realAuth,
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ap' }),
|
||||
}))
|
||||
const deleteMock = mock(async () => undefined)
|
||||
const runMock = mock(async () => ({ run_id: 'run_123' }))
|
||||
|
||||
mock.module('src/commands/agents-platform/agentsApi.js', () => ({
|
||||
listAgents: listMock,
|
||||
createAgent: createMock,
|
||||
deleteAgent: deleteMock,
|
||||
runAgent: runMock,
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-ap',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
const realTeleportApi = await import('src/utils/teleport/api.js')
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
...realTeleportApi,
|
||||
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key-ap',
|
||||
}),
|
||||
prepareApiRequest: async () => ({
|
||||
apiKey: 'test-api-key-ap',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── cron mock ───────────────────────────────────────────────────────────────
|
||||
@@ -57,19 +73,42 @@ mock.module('src/utils/cron.js', () => ({
|
||||
computeNextCronRun: () => null,
|
||||
}))
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
let callAgentsPlatform: typeof import('../launchAgentsPlatform.js').callAgentsPlatform
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchAgentsPlatform.js')
|
||||
callAgentsPlatform = mod.callAgentsPlatform
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listMock.mockClear()
|
||||
createMock.mockClear()
|
||||
deleteMock.mockClear()
|
||||
runMock.mockClear()
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
})
|
||||
|
||||
function makeContext() {
|
||||
@@ -79,8 +118,23 @@ function makeContext() {
|
||||
describe('callAgentsPlatform', () => {
|
||||
test('list (empty args) calls listAgents and returns element', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
@@ -91,21 +145,43 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('list sub-command calls listAgents', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: [] },
|
||||
status: 200,
|
||||
})
|
||||
await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('create with valid cron calls createAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'agt_new',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
status: 201,
|
||||
})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'create 0 9 * * 1 Run standup',
|
||||
)
|
||||
expect(createMock).toHaveBeenCalledTimes(1)
|
||||
const [cron, prompt] = createMock.mock.calls[0] as [string, string]
|
||||
expect(cron).toBe('0 9 * * 1')
|
||||
expect(prompt).toBe('Run standup')
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
]
|
||||
const url = callArgs[0]
|
||||
const body = callArgs[1] as Record<string, unknown>
|
||||
expect(url).toContain('/v1/agents')
|
||||
expect(body.cron_expr).toBe('0 9 * * 1')
|
||||
expect(body.prompt).toBe('Run standup')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_create',
|
||||
@@ -122,7 +198,7 @@ describe('callAgentsPlatform', () => {
|
||||
'create INVALID INVALID * * * my prompt',
|
||||
)
|
||||
// cron = 'INVALID INVALID * * *', mock returns null → no API call
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
@@ -131,12 +207,18 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('delete with id calls deleteAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'delete agt_abc',
|
||||
)
|
||||
expect(deleteMock).toHaveBeenCalledWith('agt_abc')
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosDeleteMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
]
|
||||
expect(callArgs[0]).toContain('agt_abc')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_delete',
|
||||
@@ -146,12 +228,23 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('run with id calls runAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_123' },
|
||||
status: 200,
|
||||
})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'run agt_xyz',
|
||||
)
|
||||
expect(runMock).toHaveBeenCalledWith('agt_xyz')
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
]
|
||||
expect(callArgs[0]).toContain('agt_xyz')
|
||||
expect(callArgs[0]).toContain('/run')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_run',
|
||||
@@ -167,11 +260,11 @@ describe('callAgentsPlatform', () => {
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
expect(axiosGetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('listAgents API error → error view returned', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('network error'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('network error'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(result).not.toBeNull()
|
||||
@@ -183,6 +276,10 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('started event fires on every call', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: [] },
|
||||
status: 200,
|
||||
})
|
||||
await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_started',
|
||||
@@ -190,10 +287,10 @@ describe('callAgentsPlatform', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ── Error-path branches (lines 77-86, 100-109, 128-136) ──────────────────
|
||||
// ── Error-path branches ──────────────────────────────────────────────────
|
||||
|
||||
test('createAgent API error → error view returned', async () => {
|
||||
createMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -212,7 +309,7 @@ describe('callAgentsPlatform', () => {
|
||||
})
|
||||
|
||||
test('deleteAgent API error → error view returned', async () => {
|
||||
deleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -231,7 +328,7 @@ describe('callAgentsPlatform', () => {
|
||||
})
|
||||
|
||||
test('runAgent API error → error view returned', async () => {
|
||||
runMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -253,7 +350,7 @@ describe('callAgentsPlatform', () => {
|
||||
const onDone = mock(() => {})
|
||||
// Only 4 cron fields — parseArgs returns invalid
|
||||
await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *')
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
import { AutofixProgress } from '../AutofixProgress.js';
|
||||
|
||||
describe('AutofixProgress', () => {
|
||||
describe.skipIf(!!process.env.CI)('AutofixProgress', () => {
|
||||
test('renders target in header', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
|
||||
expect(out).toContain('acme/myrepo#42');
|
||||
|
||||
133
src/commands/autofix-pr/__tests__/extractAutofixResult.test.ts
Normal file
133
src/commands/autofix-pr/__tests__/extractAutofixResult.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { SDKMessage } from '../../../entrypoints/agentSdkTypes.js'
|
||||
import {
|
||||
AUTOFIX_RESULT_TAG,
|
||||
extractAutofixResultFromLog,
|
||||
} from '../extractAutofixResult.js'
|
||||
|
||||
function hookProgressMessage(stdout: string): SDKMessage {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'hook_progress',
|
||||
stdout,
|
||||
} as unknown as SDKMessage
|
||||
}
|
||||
|
||||
function assistantTextMessage(text: string): SDKMessage {
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text }],
|
||||
},
|
||||
} as unknown as SDKMessage
|
||||
}
|
||||
|
||||
const sampleTag = (summary: string): string =>
|
||||
`<${AUTOFIX_RESULT_TAG}>
|
||||
<pr-number>42</pr-number>
|
||||
<commits-pushed>
|
||||
<commit sha="abc123">${summary}</commit>
|
||||
</commits-pushed>
|
||||
<ci-status>green</ci-status>
|
||||
<summary>${summary}</summary>
|
||||
</${AUTOFIX_RESULT_TAG}>`
|
||||
|
||||
describe('extractAutofixResultFromLog', () => {
|
||||
test('returns null on empty log', () => {
|
||||
expect(extractAutofixResultFromLog([])).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when no tag present', () => {
|
||||
const log = [
|
||||
assistantTextMessage('just some normal text without the tag'),
|
||||
hookProgressMessage('hook output without tag'),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBeNull()
|
||||
})
|
||||
|
||||
test('extracts from hook stdout', () => {
|
||||
const tag = sampleTag('fixed lint error')
|
||||
const log = [hookProgressMessage(`prefix\n${tag}\nsuffix`)]
|
||||
const result = extractAutofixResultFromLog(log)
|
||||
expect(result).toBe(tag)
|
||||
})
|
||||
|
||||
test('extracts from assistant text', () => {
|
||||
const tag = sampleTag('typecheck fixed')
|
||||
const log = [assistantTextMessage(`Done!\n${tag}`)]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(tag)
|
||||
})
|
||||
|
||||
test('extracts from hook_response subtype too', () => {
|
||||
const tag = sampleTag('via hook_response')
|
||||
const log = [
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'hook_response',
|
||||
stdout: tag,
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(tag)
|
||||
})
|
||||
|
||||
test('returns the latest tag when multiple appear in different messages', () => {
|
||||
const older = sampleTag('older attempt')
|
||||
const newer = sampleTag('newer attempt')
|
||||
const log = [
|
||||
assistantTextMessage(`first try\n${older}`),
|
||||
assistantTextMessage(`retry\n${newer}`),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(newer)
|
||||
})
|
||||
|
||||
test('returns null when open tag exists but close tag is missing (truncated)', () => {
|
||||
const log = [
|
||||
assistantTextMessage(
|
||||
`<${AUTOFIX_RESULT_TAG}>\n<summary>got cut off mid-write...`,
|
||||
),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns earlier complete tag when latest open tag is truncated within the same block', () => {
|
||||
// Retry scenario: a full result was emitted, then a second result tag
|
||||
// started but got cut off. We should surface the earlier complete pair
|
||||
// rather than dropping the whole block.
|
||||
const complete = sampleTag('earlier complete result')
|
||||
const truncated = `<${AUTOFIX_RESULT_TAG}>\n<summary>truncated retry...`
|
||||
const log = [assistantTextMessage(`${complete}\n${truncated}`)]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(complete)
|
||||
})
|
||||
|
||||
test('walks backwards so hook stdout from later in log wins over earlier assistant text', () => {
|
||||
const earlier = sampleTag('via assistant first')
|
||||
const later = sampleTag('via hook later')
|
||||
const log = [
|
||||
assistantTextMessage(`some output\n${earlier}`),
|
||||
hookProgressMessage(later),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(later)
|
||||
})
|
||||
|
||||
test('ignores tag-shaped strings that span across messages (no concatenation)', () => {
|
||||
// Open tag in one message, close tag in another — should NOT be stitched.
|
||||
const log = [
|
||||
assistantTextMessage(`<${AUTOFIX_RESULT_TAG}>\n<summary>part 1`),
|
||||
assistantTextMessage(`part 2</summary>\n</${AUTOFIX_RESULT_TAG}>`),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBeNull()
|
||||
})
|
||||
|
||||
test('extracts when assistant content is a string (not block array)', () => {
|
||||
// Some SDK paths emit assistant content as a raw string instead of
|
||||
// a content-block array. Current implementation skips those — verify
|
||||
// graceful no-op rather than crash.
|
||||
const log = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: { content: sampleTag('string content') },
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ mock.module('src/utils/teleport.js', () => ({
|
||||
}))
|
||||
|
||||
const registerMock = mock(() => ({
|
||||
taskId: 'task-abc',
|
||||
taskId: 'framework-task-id',
|
||||
sessionId: 'session-123',
|
||||
cleanup: () => {},
|
||||
}))
|
||||
@@ -56,14 +56,41 @@ const checkEligibilityMock = mock(() =>
|
||||
const getSessionUrlMock = mock(
|
||||
(id: string) => `https://claude.ai/session/${id}`,
|
||||
)
|
||||
const registerCompletionHookMock = mock<
|
||||
(taskType: string, hook: (taskId: string, metadata?: unknown) => void) => void
|
||||
>(() => {})
|
||||
const registerCompletionCheckerMock = mock<
|
||||
(
|
||||
taskType: string,
|
||||
checker: (metadata?: unknown) => Promise<string | null>,
|
||||
) => void
|
||||
>(() => {})
|
||||
const registerContentExtractorMock = mock<
|
||||
(taskType: string, extractor: (log: unknown[]) => string | null) => void
|
||||
>(() => {})
|
||||
|
||||
mock.module('src/tasks/RemoteAgentTask/RemoteAgentTask.js', () => ({
|
||||
checkRemoteAgentEligibility: checkEligibilityMock,
|
||||
registerRemoteAgentTask: registerMock,
|
||||
registerCompletionHook: registerCompletionHookMock,
|
||||
registerCompletionChecker: registerCompletionCheckerMock,
|
||||
registerContentExtractor: registerContentExtractorMock,
|
||||
getRemoteTaskSessionUrl: getSessionUrlMock,
|
||||
formatPreconditionError: (e: { type: string }) => e.type,
|
||||
}))
|
||||
|
||||
const fetchPrHeadShaMock = mock<
|
||||
(owner: string, repo: string, prNumber: number) => Promise<string | null>
|
||||
>(() => Promise.resolve('sha-baseline-abc123'))
|
||||
|
||||
// Mock prFetch.ts (gh CLI spawn layer) — keeping the pure decision matrix
|
||||
// in prOutcomeCheck.ts unmocked so its tests are unaffected by this file's
|
||||
// process-global mock.module pollution.
|
||||
mock.module('src/commands/autofix-pr/prFetch.js', () => ({
|
||||
fetchPrHeadSha: fetchPrHeadShaMock,
|
||||
checkPrAutofixOutcome: mock(() => Promise.resolve({ completed: false })),
|
||||
}))
|
||||
|
||||
const detectRepoMock = mock(() =>
|
||||
Promise.resolve({ host: 'github.com', owner: 'acme', name: 'myrepo' }),
|
||||
)
|
||||
@@ -375,6 +402,326 @@ describe('callAutofixPr', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Regression suite for the taskId-mismatch latent bug + completion hook wiring.
|
||||
// Before this fix, createAutofixTeammate generated a teammate UUID, that UUID
|
||||
// was used to acquire the singleton monitor lock, and registerRemoteAgentTask
|
||||
// generated a *different* framework taskId. When the framework eventually
|
||||
// called clearActiveMonitor(frameworkTaskId) on natural completion, the guard
|
||||
// failed (active.taskId !== frameworkTaskId) and the lock stayed acquired,
|
||||
// blocking any subsequent /autofix-pr invocations in the same process.
|
||||
describe('callAutofixPr · completion hook wiring (taskId mismatch regression)', () => {
|
||||
test('updateActiveMonitor swaps lock taskId to framework-assigned id after register', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const monitor = getActiveMonitor() as { taskId: string } | null
|
||||
expect(monitor).not.toBeNull()
|
||||
// registerMock returns 'framework-task-id'; before the fix this would be
|
||||
// a teammate-generated random UUID instead.
|
||||
expect(monitor?.taskId).toBe('framework-task-id')
|
||||
})
|
||||
|
||||
test('framework hook → clearActiveMonitor releases lock on natural completion', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(getActiveMonitor()).not.toBeNull()
|
||||
|
||||
// Find the hook the module registered at import time. We grab the last
|
||||
// call so re-imports across tests don't break this — only the most recent
|
||||
// registration is what the framework would invoke now.
|
||||
const calls = registerCompletionHookMock.mock.calls
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const lastCall = calls[calls.length - 1]
|
||||
expect(lastCall?.[0]).toBe('autofix-pr')
|
||||
const hook = lastCall?.[1] as (id: string, metadata?: unknown) => void
|
||||
expect(typeof hook).toBe('function')
|
||||
|
||||
// Simulate the framework invoking the hook with the framework taskId
|
||||
// after a terminal transition. Before the fix this would no-op against
|
||||
// a lock keyed by the teammate UUID.
|
||||
hook('framework-task-id', { owner: 'acme', repo: 'myrepo', prNumber: 42 })
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('subsequent /autofix-pr succeeds after framework hook clears the lock', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
// Simulate natural completion via the registered hook
|
||||
const calls = registerCompletionHookMock.mock.calls
|
||||
const hook = calls[calls.length - 1]?.[1] as (
|
||||
id: string,
|
||||
metadata?: unknown,
|
||||
) => void
|
||||
hook('framework-task-id', { owner: 'acme', repo: 'myrepo', prNumber: 42 })
|
||||
|
||||
onDone.mockClear()
|
||||
await callAutofixPr(onDone, makeContext(), '99')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
// Should be the success path, not "already monitoring"
|
||||
expect(firstArg).not.toMatch(/already monitoring/i)
|
||||
expect(firstArg).toMatch(/Autofix launched/)
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2: completionChecker wiring + initialHeadSha capture
|
||||
describe('callAutofixPr · Phase 2 completionChecker integration', () => {
|
||||
test('completionChecker is registered at module load with autofix-pr type', () => {
|
||||
// The registration happens during the beforeAll dynamic import; just
|
||||
// verify the mock recorded a call. Filter by task type so any future
|
||||
// additional registrations elsewhere don't break this assertion.
|
||||
const calls = registerCompletionCheckerMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const hook = calls[calls.length - 1]?.[1]
|
||||
expect(typeof hook).toBe('function')
|
||||
})
|
||||
|
||||
test('callAutofixPr captures initialHeadSha via fetchPrHeadSha', async () => {
|
||||
fetchPrHeadShaMock.mockClear()
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(fetchPrHeadShaMock).toHaveBeenCalledWith('acme', 'myrepo', 42)
|
||||
})
|
||||
|
||||
test('initialHeadSha is passed into remoteTaskMetadata on register', async () => {
|
||||
fetchPrHeadShaMock.mockImplementationOnce(() =>
|
||||
Promise.resolve('sha-from-launch'),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(registerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteTaskMetadata: expect.objectContaining({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 42,
|
||||
initialHeadSha: 'sha-from-launch',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPrHeadSha failure → metadata initialHeadSha undefined, launch still succeeds', async () => {
|
||||
fetchPrHeadShaMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('gh not installed')),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(registerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteTaskMetadata: expect.objectContaining({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 42,
|
||||
initialHeadSha: undefined,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
// Launch must NOT fail just because SHA capture failed
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix launched/)
|
||||
})
|
||||
|
||||
test('fetchPrHeadSha returning null → metadata initialHeadSha undefined', async () => {
|
||||
fetchPrHeadShaMock.mockImplementationOnce(() => Promise.resolve(null))
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(registerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteTaskMetadata: expect.objectContaining({
|
||||
initialHeadSha: undefined,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2 (cont.): exercise the registered completionChecker arrow body
|
||||
// directly. The earlier suite verifies it was registered but never invokes
|
||||
// the arrow itself, leaving the throttle / metadata-guard / gh-CLI dispatch
|
||||
// branches uncovered.
|
||||
describe('callAutofixPr · Phase 2 completionChecker arrow body', () => {
|
||||
// Pull the most recent registered checker — beforeAll registers once at
|
||||
// module load; nothing else re-registers across this file's tests.
|
||||
function getChecker(): (metadata?: unknown) => Promise<string | null> {
|
||||
const calls = registerCompletionCheckerMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
const fn = calls[calls.length - 1]?.[1]
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error('completionChecker not registered')
|
||||
}
|
||||
return fn
|
||||
}
|
||||
|
||||
test('returns null when metadata is undefined (early guard)', async () => {
|
||||
const checker = getChecker()
|
||||
expect(await checker(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when checkPrAutofixOutcome reports not completed', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
;(checkPrAutofixOutcome as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() => Promise.resolve({ completed: false }),
|
||||
)
|
||||
const checker = getChecker()
|
||||
// Distinct PR number to dodge the in-process throttle map carried over
|
||||
// from earlier tests.
|
||||
const result = await checker({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 1001,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns the summary string when checkPrAutofixOutcome reports completed', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
;(checkPrAutofixOutcome as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
completed: true,
|
||||
summary: 'acme/myrepo#1002 merged. Autofix monitoring complete.',
|
||||
}),
|
||||
)
|
||||
const checker = getChecker()
|
||||
const result = await checker({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 1002,
|
||||
})
|
||||
expect(result).toBe('acme/myrepo#1002 merged. Autofix monitoring complete.')
|
||||
})
|
||||
|
||||
test('passes initialHeadSha through to checkPrAutofixOutcome', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||
checkMock.mockClear()
|
||||
checkMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({ completed: false }),
|
||||
)
|
||||
const checker = getChecker()
|
||||
await checker({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 1003,
|
||||
initialHeadSha: 'sha-baseline-xyz',
|
||||
})
|
||||
expect(checkMock).toHaveBeenCalledWith({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 1003,
|
||||
initialHeadSha: 'sha-baseline-xyz',
|
||||
})
|
||||
})
|
||||
|
||||
test('throttles back-to-back calls for the same PR within CHECK_INTERVAL_MS', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||
checkMock.mockClear()
|
||||
checkMock.mockImplementation(() => Promise.resolve({ completed: false }))
|
||||
const checker = getChecker()
|
||||
const meta = { owner: 'acme', repo: 'myrepo', prNumber: 1004 }
|
||||
await checker(meta)
|
||||
// Second call within the 5s throttle window must short-circuit to null
|
||||
// without invoking the gh CLI layer again.
|
||||
const callCountAfterFirst = checkMock.mock.calls.length
|
||||
const result = await checker(meta)
|
||||
expect(result).toBeNull()
|
||||
expect(checkMock.mock.calls.length).toBe(callCountAfterFirst)
|
||||
})
|
||||
|
||||
test('completionHook with metadata clears the throttle entry (re-launch can re-check immediately)', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||
checkMock.mockClear()
|
||||
checkMock.mockImplementation(() => Promise.resolve({ completed: false }))
|
||||
const checker = getChecker()
|
||||
const meta = { owner: 'acme', repo: 'myrepo', prNumber: 1005 }
|
||||
await checker(meta) // populate throttle map
|
||||
|
||||
// Invoke the registered completion hook with the same metadata so the
|
||||
// throttle entry is wiped, then verify the next checker call dispatches
|
||||
// gh CLI again instead of short-circuiting.
|
||||
const hookCalls = registerCompletionHookMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
const hook = hookCalls[hookCalls.length - 1]?.[1] as (
|
||||
id: string,
|
||||
metadata?: unknown,
|
||||
) => void
|
||||
hook('any-task-id', meta)
|
||||
|
||||
const callCountBefore = checkMock.mock.calls.length
|
||||
await checker(meta)
|
||||
expect(checkMock.mock.calls.length).toBe(callCountBefore + 1)
|
||||
})
|
||||
|
||||
test('completionHook without metadata still clears the active monitor lock', async () => {
|
||||
// Lock is set via callAutofixPr; hook then invoked with undefined metadata
|
||||
// to exercise the `if (meta)` short-circuit branch (the lock-clear half
|
||||
// still has to run regardless of metadata presence).
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(getActiveMonitor()).not.toBeNull()
|
||||
const hookCalls = registerCompletionHookMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
const hook = hookCalls[hookCalls.length - 1]?.[1] as (
|
||||
id: string,
|
||||
metadata?: unknown,
|
||||
) => void
|
||||
hook('framework-task-id', undefined)
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 3: content extractor wiring + initialMessage tag instruction
|
||||
describe('callAutofixPr · Phase 3 content extractor integration', () => {
|
||||
test('registerContentExtractor is called at module load with autofix-pr type', () => {
|
||||
const calls = registerContentExtractorMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const extractor = calls[calls.length - 1]?.[1]
|
||||
expect(typeof extractor).toBe('function')
|
||||
})
|
||||
|
||||
test('initialMessage instructs the remote agent to emit an <autofix-result> tag', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
// teleportMock's typed signature has no args, so calls[0] is a
|
||||
// zero-length tuple. We know teleportToRemote is invoked with one
|
||||
// options object, so double-cast through unknown to read the args.
|
||||
const calls = teleportMock.mock.calls as unknown as Array<
|
||||
[{ initialMessage?: string }]
|
||||
>
|
||||
const teleportArgs = calls[0]?.[0]
|
||||
expect(teleportArgs?.initialMessage).toContain('<autofix-result>')
|
||||
expect(teleportArgs?.initialMessage).toContain('</autofix-result>')
|
||||
expect(teleportArgs?.initialMessage).toContain('<ci-status>')
|
||||
expect(teleportArgs?.initialMessage).toContain('<summary>')
|
||||
})
|
||||
|
||||
test('registered extractor returns string for valid log and null for empty', () => {
|
||||
const calls = registerContentExtractorMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
const extractor = calls[calls.length - 1]?.[1] as
|
||||
| ((log: unknown[]) => string | null)
|
||||
| undefined
|
||||
expect(extractor).toBeDefined()
|
||||
// Empty log → null
|
||||
expect(extractor?.([])).toBeNull()
|
||||
// Log with assistant text containing tag → returns it
|
||||
const logWithTag = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done\n<autofix-result><summary>x</summary></autofix-result>',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(extractor?.(logWithTag)).toContain('<autofix-result>')
|
||||
})
|
||||
})
|
||||
|
||||
// Cover ../index.ts load() — placed in this test file so all the heavy mocks
|
||||
// (teleport / detectRepository / RemoteAgentTask / bootstrap-state / analytics /
|
||||
// skillDetect) are already registered when load() dynamically imports
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isMonitoring,
|
||||
setActiveMonitor,
|
||||
trySetActiveMonitor,
|
||||
updateActiveMonitor,
|
||||
} from '../monitorState.js'
|
||||
|
||||
function makeState(
|
||||
@@ -76,4 +77,41 @@ describe('monitorState', () => {
|
||||
// First state remains
|
||||
expect(getActiveMonitor()?.prNumber).toBe(1)
|
||||
})
|
||||
|
||||
test('updateActiveMonitor returns false when no active monitor', () => {
|
||||
expect(updateActiveMonitor({ taskId: 'task-x' })).toBe(false)
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('updateActiveMonitor merges partial fields into the active monitor', () => {
|
||||
setActiveMonitor(makeState({ taskId: 'tentative-uuid' }))
|
||||
expect(updateActiveMonitor({ taskId: 'framework-task-id' })).toBe(true)
|
||||
const after = getActiveMonitor()
|
||||
expect(after?.taskId).toBe('framework-task-id')
|
||||
// Other fields untouched
|
||||
expect(after?.owner).toBe('acme')
|
||||
expect(after?.repo).toBe('myrepo')
|
||||
expect(after?.prNumber).toBe(42)
|
||||
})
|
||||
|
||||
test('updateActiveMonitor with new taskId makes clearActiveMonitor recognise framework taskId', () => {
|
||||
// Reproduce the latent bug scenario: lock acquired with one taskId,
|
||||
// framework assigns a different one. Before the fix, the framework's
|
||||
// clearActiveMonitor(frameworkTaskId) would no-op because guard fails.
|
||||
setActiveMonitor(makeState({ taskId: 'teammate-uuid' }))
|
||||
// Framework cleanup using its own taskId — would fail guard before the fix
|
||||
clearActiveMonitor('framework-uuid')
|
||||
expect(getActiveMonitor()).not.toBeNull()
|
||||
// After updateActiveMonitor swaps the taskId, framework cleanup works
|
||||
updateActiveMonitor({ taskId: 'framework-uuid' })
|
||||
clearActiveMonitor('framework-uuid')
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('updateActiveMonitor does not change abortController identity', () => {
|
||||
const ac = new AbortController()
|
||||
setActiveMonitor(makeState({ abortController: ac, taskId: 'tentative' }))
|
||||
updateActiveMonitor({ taskId: 'updated' })
|
||||
expect(getActiveMonitor()?.abortController).toBe(ac)
|
||||
})
|
||||
})
|
||||
|
||||
193
src/commands/autofix-pr/__tests__/prOutcomeCheck.test.ts
Normal file
193
src/commands/autofix-pr/__tests__/prOutcomeCheck.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type PrViewPayload,
|
||||
summariseAutofixOutcome,
|
||||
} from '../prOutcomeCheck.js'
|
||||
|
||||
function basePayload(overrides: Partial<PrViewPayload> = {}): PrViewPayload {
|
||||
return {
|
||||
headRefOid: 'sha-baseline',
|
||||
state: 'OPEN',
|
||||
statusCheckRollup: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const identity = (overrides: Partial<{ initialHeadSha: string }> = {}) => ({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 42,
|
||||
initialHeadSha: 'sha-baseline',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('summariseAutofixOutcome · terminal PR states', () => {
|
||||
test('MERGED → completed regardless of head SHA / CI', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({ state: 'MERGED', headRefOid: 'sha-baseline' }),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
summary: 'acme/myrepo#42 merged. Autofix monitoring complete.',
|
||||
})
|
||||
})
|
||||
|
||||
test('CLOSED → completed regardless of head SHA / CI', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({ state: 'CLOSED' }),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
summary:
|
||||
'acme/myrepo#42 closed without merge. Autofix monitoring complete.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('summariseAutofixOutcome · OPEN PR without push', () => {
|
||||
test('no initialHeadSha baseline → not completed (cannot detect push)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({ state: 'OPEN' }),
|
||||
identity({ initialHeadSha: undefined as unknown as string }),
|
||||
)
|
||||
expect(result).toEqual({ completed: false })
|
||||
})
|
||||
|
||||
test('headRefOid unchanged → not completed (autofix has not pushed yet)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({ state: 'OPEN', headRefOid: 'sha-baseline' }),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({ completed: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('summariseAutofixOutcome · OPEN PR with push, CI variations', () => {
|
||||
test('push detected + no checks configured → completed (success)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
summary: 'Autofix pushed commits to acme/myrepo#42, CI green.',
|
||||
})
|
||||
})
|
||||
|
||||
test('push detected + CI pending → not completed (wait for CI)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{ status: 'IN_PROGRESS', conclusion: null, name: 'ci' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({ completed: false })
|
||||
})
|
||||
|
||||
test('push detected + CI all green → completed (success summary)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'ci' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result.completed).toBe(true)
|
||||
if (result.completed) {
|
||||
expect(result.summary).toContain('CI green')
|
||||
expect(result.summary).toContain('acme/myrepo#42')
|
||||
}
|
||||
})
|
||||
|
||||
test('push detected + CI red → completed (failure summary surfaces the red)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{ status: 'COMPLETED', conclusion: 'FAILURE', name: 'ci' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result.completed).toBe(true)
|
||||
if (result.completed) {
|
||||
expect(result.summary).toContain('CI is failing')
|
||||
expect(result.summary).toContain('1/2 checks failing')
|
||||
}
|
||||
})
|
||||
|
||||
test('statusCheckRollup undefined → treated as no checks configured (success)', () => {
|
||||
// Distinct from empty-array: GitHub omits the field entirely on PRs
|
||||
// without any configured checks. The !rollup branch covers undefined.
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: undefined,
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result.completed).toBe(true)
|
||||
if (result.completed) {
|
||||
expect(result.summary).toContain('CI green')
|
||||
}
|
||||
})
|
||||
|
||||
test('check with COMPLETED status but empty conclusion → counted as pending', () => {
|
||||
// Edge case: GitHub sometimes reports a check as COMPLETED with a null/
|
||||
// missing conclusion (in-flight result mid-write). The defensive branch
|
||||
// treats empty conclusion after a passed status check as pending.
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{ status: 'COMPLETED', conclusion: null, name: 'ci-in-flight' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({ completed: false })
|
||||
})
|
||||
|
||||
test('neutral / skipped conclusions count as success (not failure)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{
|
||||
status: 'COMPLETED',
|
||||
conclusion: 'NEUTRAL',
|
||||
name: 'optional-check',
|
||||
},
|
||||
{ status: 'COMPLETED', conclusion: 'SKIPPED', name: 'docs-check' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'ci' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result.completed).toBe(true)
|
||||
if (result.completed) {
|
||||
expect(result.summary).toContain('CI green')
|
||||
}
|
||||
})
|
||||
})
|
||||
92
src/commands/autofix-pr/extractAutofixResult.ts
Normal file
92
src/commands/autofix-pr/extractAutofixResult.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Extract the <autofix-result> tag from a remote autofix-pr session log.
|
||||
//
|
||||
// The remote agent emits a structured XML block as its final message
|
||||
// (initialMessage in launchAutofixPr.ts instructs it to). The tag carries
|
||||
// PR-specific outcome data — commits pushed, files changed, CI status,
|
||||
// summary — that the framework's generic "task completed" notification
|
||||
// can't convey. We surface it to the local model by injecting the tag
|
||||
// verbatim into the message queue (analogous to <remote-review> handling).
|
||||
//
|
||||
// Resilient to two production realities:
|
||||
// 1. The tag may appear in either an assistant text block or a hook
|
||||
// stdout (some autofix skills wrap the final report in a hook).
|
||||
// 2. The tag may not appear at all (older agents, truncated runs) —
|
||||
// caller falls back to generic completion notification.
|
||||
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKMessage,
|
||||
} from '../../entrypoints/agentSdkTypes.js'
|
||||
|
||||
export const AUTOFIX_RESULT_TAG = 'autofix-result'
|
||||
|
||||
const TAG_OPEN = `<${AUTOFIX_RESULT_TAG}>`
|
||||
const TAG_CLOSE = `</${AUTOFIX_RESULT_TAG}>`
|
||||
|
||||
/**
|
||||
* Walk the session log for an <autofix-result> tag. Returns the full tag
|
||||
* (including delimiters) so the caller can inject it as-is into the
|
||||
* notification; returns null if no tag is present.
|
||||
*
|
||||
* Search order:
|
||||
* 1. Latest hook_progress / hook_response stdout (autofix skills that
|
||||
* use hooks to format the report write here first).
|
||||
* 2. Latest assistant text block (agents that don't use hooks write the
|
||||
* tag inline in their final message).
|
||||
*
|
||||
* Latest-wins so re-tries within the same session don't surface stale
|
||||
* earlier results.
|
||||
*/
|
||||
export function extractAutofixResultFromLog(log: SDKMessage[]): string | null {
|
||||
// Walk backwards so we hit the most recent tag first.
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const msg = log[i]
|
||||
if (!msg) continue
|
||||
|
||||
// Hook stdout (system messages of subtype hook_progress / hook_response).
|
||||
if (
|
||||
msg.type === 'system' &&
|
||||
(msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')
|
||||
) {
|
||||
const stdout = (msg as { stdout?: unknown }).stdout
|
||||
if (typeof stdout === 'string') {
|
||||
const extracted = extractBetween(stdout, TAG_OPEN, TAG_CLOSE)
|
||||
if (extracted) return extracted
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Assistant text blocks.
|
||||
if (msg.type === 'assistant') {
|
||||
const content = (msg as SDKAssistantMessage).message?.content
|
||||
if (!content || typeof content === 'string') continue
|
||||
for (const block of content as Array<{ type: string; text?: string }>) {
|
||||
if (block.type !== 'text' || typeof block.text !== 'string') continue
|
||||
if (!block.text.includes(TAG_OPEN)) continue
|
||||
const extracted = extractBetween(block.text, TAG_OPEN, TAG_CLOSE)
|
||||
if (extracted) return extracted
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Walks open tags from latest to earliest, returning the first complete
|
||||
// open/close pair. Guards against a truncated final tag shadowing an
|
||||
// earlier complete pair within the same text block (e.g., a retry wrote a
|
||||
// full result, then the model started a second tag that got cut off).
|
||||
function extractBetween(
|
||||
text: string,
|
||||
open: string,
|
||||
close: string,
|
||||
): string | null {
|
||||
let searchFrom = text.length
|
||||
while (searchFrom >= 0) {
|
||||
const start = text.lastIndexOf(open, searchFrom)
|
||||
if (start === -1) return null
|
||||
const end = text.indexOf(close, start + open.length)
|
||||
if (end !== -1) return text.slice(start, end + close.length)
|
||||
searchFrom = start - 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
checkRemoteAgentEligibility,
|
||||
formatPreconditionError,
|
||||
getRemoteTaskSessionUrl,
|
||||
registerCompletionChecker,
|
||||
registerCompletionHook,
|
||||
registerContentExtractor,
|
||||
registerRemoteAgentTask,
|
||||
type AutofixPrRemoteTaskMetadata,
|
||||
type BackgroundRemoteSessionPrecondition,
|
||||
} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
@@ -26,10 +30,66 @@ import {
|
||||
getActiveMonitor,
|
||||
isMonitoring,
|
||||
trySetActiveMonitor,
|
||||
updateActiveMonitor,
|
||||
} from './monitorState.js'
|
||||
import { extractAutofixResultFromLog } from './extractAutofixResult.js'
|
||||
import { parseAutofixArgs } from './parseArgs.js'
|
||||
import { checkPrAutofixOutcome, fetchPrHeadSha } from './prFetch.js'
|
||||
import { detectAutofixSkills, formatSkillsHint } from './skillDetect.js'
|
||||
|
||||
// Throttle map for the completionChecker: gh CLI is called at most once per
|
||||
// PR per CHECK_INTERVAL_MS, regardless of the framework's 1s poll cadence.
|
||||
// Key is `${owner}/${repo}#${prNumber}`. Cleared when the completion hook
|
||||
// fires so a re-launched monitor starts with a fresh budget.
|
||||
const lastCheckAt = new Map<string, number>()
|
||||
const CHECK_INTERVAL_MS = 5_000
|
||||
|
||||
function throttleKey(meta: AutofixPrRemoteTaskMetadata): string {
|
||||
return `${meta.owner}/${meta.repo}#${meta.prNumber}`
|
||||
}
|
||||
|
||||
// Register the completionChecker once at module load. The framework calls it
|
||||
// on every poll tick for tasks with remoteTaskType==='autofix-pr'; throttle
|
||||
// inside so we don't fire gh CLI 60×/min. Returns the summary string on
|
||||
// completion (becomes the task-notification body) or null to keep polling.
|
||||
registerCompletionChecker('autofix-pr', async metadata => {
|
||||
const meta = metadata as AutofixPrRemoteTaskMetadata | undefined
|
||||
if (!meta) return null
|
||||
|
||||
const key = throttleKey(meta)
|
||||
const now = Date.now()
|
||||
if (now - (lastCheckAt.get(key) ?? 0) < CHECK_INTERVAL_MS) return null
|
||||
lastCheckAt.set(key, now)
|
||||
|
||||
const result = await checkPrAutofixOutcome({
|
||||
owner: meta.owner,
|
||||
repo: meta.repo,
|
||||
prNumber: meta.prNumber,
|
||||
initialHeadSha: meta.initialHeadSha,
|
||||
})
|
||||
return result.completed ? result.summary : null
|
||||
})
|
||||
|
||||
// Release the singleton monitor lock when the framework transitions the
|
||||
// autofix task to a terminal state. Without this, the lock — keyed by the
|
||||
// framework-assigned taskId (after callAutofixPr's updateActiveMonitor swap)
|
||||
// — would dangle past natural completion, blocking subsequent /autofix-pr
|
||||
// invocations until the process restarts. Registered at module load; the
|
||||
// framework's runCompletionHook invokes it once per terminal transition.
|
||||
// Also clear the per-PR throttle entry so a re-launch starts fresh.
|
||||
registerCompletionHook('autofix-pr', (taskId, metadata) => {
|
||||
clearActiveMonitor(taskId)
|
||||
const meta = metadata as AutofixPrRemoteTaskMetadata | undefined
|
||||
if (meta) lastCheckAt.delete(throttleKey(meta))
|
||||
})
|
||||
|
||||
// Phase 3 content return: extract the <autofix-result> tag from the session
|
||||
// log so the local model sees the agent's structured outcome (commits
|
||||
// pushed, files changed, CI status) inline in the completion task-
|
||||
// notification — instead of just a file-path pointer. The framework falls
|
||||
// back to the generic notification if extraction returns null.
|
||||
registerContentExtractor('autofix-pr', log => extractAutofixResultFromLog(log))
|
||||
|
||||
function makeErrorText(message: string, code: string): string {
|
||||
logEvent('tengu_autofix_pr_result', {
|
||||
result:
|
||||
@@ -198,7 +258,23 @@ export const callAutofixPr: LocalJSXCommandCall = async (
|
||||
// 4.5 compose message
|
||||
const target = `${owner}/${repo}#${prNumber}`
|
||||
const branchName = `refs/pull/${prNumber}/head`
|
||||
const initialMessage = `Auto-fix failing CI checks on PR #${prNumber} in ${owner}/${repo}.${skillsHint}`
|
||||
const initialMessage = `Auto-fix failing CI checks on PR #${prNumber} in ${owner}/${repo}.${skillsHint}
|
||||
|
||||
When you finish (or hit a blocker you can't recover from), output the following XML tag as your final message so the local user gets a structured summary:
|
||||
|
||||
<autofix-result>
|
||||
<pr-number>${prNumber}</pr-number>
|
||||
<commits-pushed>
|
||||
<commit sha="...">commit message</commit>
|
||||
</commits-pushed>
|
||||
<files-changed>
|
||||
<file path="...">N changes</file>
|
||||
</files-changed>
|
||||
<ci-status>green | red | pending | unknown</ci-status>
|
||||
<summary>One-sentence summary of what was fixed or why it could not be fixed.</summary>
|
||||
</autofix-result>
|
||||
|
||||
If no fix was needed, omit <commits-pushed> and <files-changed> and explain in <summary>. If you only attempted partial work, list the commits you did push and explain the remainder in <summary>.`
|
||||
|
||||
// 4.6 in-process teammate
|
||||
const teammate = createAutofixTeammate(initialMessage, target)
|
||||
@@ -274,18 +350,35 @@ export const callAutofixPr: LocalJSXCommandCall = async (
|
||||
return null
|
||||
}
|
||||
|
||||
// 4.8b capture PR head SHA before registering so the completionChecker
|
||||
// can detect when the agent has pushed new commits. Best-effort — if gh
|
||||
// is unavailable or the call fails, leave initialHeadSha undefined and
|
||||
// the checker falls back to terminal-state-only completion (closed /
|
||||
// merged). Don't block on this; teleport succeeded already.
|
||||
const initialHeadSha =
|
||||
(await fetchPrHeadSha(owner, repo, prNumber).catch(() => null)) ??
|
||||
undefined
|
||||
|
||||
// 4.9 register task. If this throws, release the lock so the user can
|
||||
// retry — the remote CCR session is already created so we surface a
|
||||
// dedicated error code.
|
||||
//
|
||||
// After registration succeeds, swap the lock's taskId from the tentative
|
||||
// teammate UUID (used to acquire the lock atomically before teleport) to
|
||||
// the framework-assigned taskId. Without this swap, the framework's own
|
||||
// cleanup path (clearActiveMonitor(frameworkTaskId) on natural completion)
|
||||
// would no-op against a lock keyed by teammate.taskId, leaving the
|
||||
// singleton lock dangling and blocking future /autofix-pr invocations.
|
||||
try {
|
||||
registerRemoteAgentTask({
|
||||
const { taskId: frameworkTaskId } = registerRemoteAgentTask({
|
||||
remoteTaskType: 'autofix-pr',
|
||||
session,
|
||||
command: `/autofix-pr ${prNumber}`,
|
||||
context,
|
||||
isLongRunning: true,
|
||||
remoteTaskMetadata: { owner, repo, prNumber },
|
||||
remoteTaskMetadata: { owner, repo, prNumber, initialHeadSha },
|
||||
})
|
||||
updateActiveMonitor({ taskId: frameworkTaskId })
|
||||
} catch (regErr: unknown) {
|
||||
clearActiveMonitor(teammate.taskId)
|
||||
const regMsg = regErr instanceof Error ? regErr.message : String(regErr)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user