Merge branch 'main' into pr/smallflyingpig/36

# Conflicts:
#	src/entrypoints/cli.tsx
This commit is contained in:
claude-code-best
2026-04-02 21:25:53 +08:00
184 changed files with 11136 additions and 1771 deletions

View File

@@ -20,9 +20,6 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Test
run: bun test

View File

@@ -12,27 +12,38 @@ This is a **reverse-engineered / decompiled** version of Anthropic's official Cl
# Install dependencies
bun install
# Dev mode (direct execution via Bun)
# Dev mode (runs cli.tsx with MACRO defines injected via -d flags)
bun run dev
# equivalent to: bun run src/entrypoints/cli.tsx
# Pipe mode
echo "say hello" | bun run src/entrypoints/cli.tsx -p
# Build (outputs dist/cli.js, ~25MB)
# Build (code splitting, outputs dist/cli.js + ~450 chunk files)
bun run build
# Test
bun test # run all tests
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
# Lint & Format (Biome)
bun run lint # check only
bun run lint:fix # auto-fix
bun run format # format all src/
```
No test runner is configured. No linter is configured.
详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`
## Architecture
### Runtime & Build
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
- **Build**: `bun build src/entrypoints/cli.tsx --outdir dist --target bun` — single-file bundle.
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + ~450 chunk files。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx``scripts/defines.ts` 集中管理 define map。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`.
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
### Entry & Bootstrap
@@ -106,6 +117,15 @@ All `feature('FLAG_NAME')` calls come from `bun:bundle` (a build-time API). In t
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
- **`src/types/permissions.ts`** — Permission mode and result types.
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/`,共享 mock/fixture 在 `tests/mocks/`
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
- **当前状态**: 1286 tests / 67 files / 0 fail详见 `docs/testing-spec.md` 的覆盖状态表和评分)
## Working with This Codebase
- **Don't try to fix all tsc errors** — they're from decompilation and don't affect runtime.
@@ -113,3 +133,5 @@ All `feature('FLAG_NAME')` calls come from `bun:bundle` (a build-time API). In t
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — In `src/main.tsx` and other files, `import { feature } from 'bun:bundle'` works at build time. At dev-time, the polyfill in `cli.tsx` provides it.
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。

33
DEV-LOG.md Normal file
View File

@@ -0,0 +1,33 @@
# DEV-LOG
## Auto Mode 补全 (2026-04-02)
反编译丢失了 auto mode 分类器的三个 prompt 模板文件,代码逻辑完整但无法运行。
**新增:**
- `yolo-classifier-prompts/auto_mode_system_prompt.txt` — 主系统提示词
- `yolo-classifier-prompts/permissions_external.txt` — 外部权限模板(用户规则替换默认值)
- `yolo-classifier-prompts/permissions_anthropic.txt` — 内部权限模板(用户规则追加)
**改动:**
- `scripts/dev.ts` + `build.ts` — 扫描 `FEATURE_*` 环境变量注入 Bun `--feature`
- `cli.tsx` — 启动时打印已启用的 feature
- `permissionSetup.ts``AUTO_MODE_ENABLED_DEFAULT``feature('TRANSCRIPT_CLASSIFIER')` 决定,开 feature 即开 auto mode
- `docs/safety/auto-mode.mdx` — 补充 prompt 模板章节
**用法:** `FEATURE_TRANSCRIPT_CLASSIFIER=1 bun run dev`
**注意:** prompt 模板为重建产物。
---
## USER_TYPE=ant TUI 修复 (2026-04-02)
`global.d.ts` 声明的全局函数在反编译版本运行时未定义,导致 `USER_TYPE=ant` 时 TUI 崩溃。
修复方式:显式 import / 本地 stub / 全局 stub / 新建 stub 文件。涉及文件:
`cli.tsx`, `model.ts`, `context.ts`, `effort.ts`, `thinking.ts`, `undercover.ts`, `Spinner.tsx`, `AntModelSwitchCallout.tsx`(新建), `UndercoverAutoCallout.tsx`(新建)
注意:
- `USER_TYPE=ant` 启用 alt-screen 全屏模式,中心区域满屏是预期行为
- `global.d.ts` 中剩余未 stub 的全局函数(`getAntModels` 等)遇到 `X is not defined` 时按同样模式处理

View File

@@ -8,6 +8,11 @@ const outdir = "dist";
const { rmSync } = await import("fs");
rmSync(outdir, { recursive: true, force: true });
// Collect FEATURE_* env vars → Bun.build features
const features = Object.keys(process.env)
.filter(k => k.startsWith("FEATURE_"))
.map(k => k.replace("FEATURE_", ""));
// Step 2: Bundle with splitting
const result = await Bun.build({
entrypoints: ["src/entrypoints/cli.tsx"],
@@ -15,6 +20,7 @@ const result = await Bun.build({
target: "bun",
splitting: true,
define: getMacroDefines(),
features,
});
if (!result.success) {

263
docs/safety/auto-mode.mdx Normal file
View File

@@ -0,0 +1,263 @@
---
title: "Auto Mode - AI 分类器驱动的自主执行模式"
description: "详解 Claude Code 的 auto mode基于 transcript classifier 的自动权限决策、两阶段分类流水线、危险权限剥离机制、模式切换状态管理、以及与 plan mode 的协作方式。"
keywords: ["auto mode", "yoloClassifier", "transcript classifier", "权限分类", "自动执行", "两阶段分类"]
---
## 概述
Auto mode 是 Claude Code 的一种权限模式,让 AI 进入**连续自主执行**状态。与传统模式每个敏感操作都弹出权限对话框等待用户审批不同auto mode 使用 AI 分类器transcript classifier自动判断每个工具调用是否安全从而实现无中断的执行体验。
```
权限模式层级:
default → auto → bypassPermissions
(逐项确认) AI 分类器审批) (全部放行)
```
## 核心架构
### 1. AI 分类器yoloClassifier
分类器是 auto mode 的核心,位于 `src/utils/permissions/yoloClassifier.ts`。
每个工具调用经过分类器评估,返回三种裁决之一:
| 裁决 | 含义 | 处理方式 |
|------|------|---------|
| **allow** | 操作安全 | 直接执行,用户无感知 |
| **deny** | 操作危险 | 拒绝执行,向 AI 返回拒绝消息 |
| **ask** | 无法确定 | 回退到传统权限对话框 |
分类器的判断基于完整的对话上下文transcript而不仅仅看单条命令——它能理解操作的意图和上下文。
### 2. 两阶段分类流水线
分类器支持两阶段 XML 分类(`classifyYoloActionXml`
```
Stage 1 — "fast"(快速判断)
├── max_tokens=64stop_sequences 触发即时 yes/no
├── 如果 allow → 直接放行(最快路径)
└── 如果 block → 进入 Stage 2
Stage 2 — "thinking"(深度思考)
├── chain-of-thought 推理
├── 减少误报false positives
└── 最终决定 allow / deny / ask
```
两个阶段共享相同的 system prompt 和 user content利用 API 的 prompt caching1 小时 TTL优化性能。
可通过配置选择模式:
- `'both'`(默认)— 两阶段都跑
- `'fast'` — 只跑 Stage 1
- `'thinking'` — 只跑 Stage 2
### 3. 分类器结果类型
```typescript
// src/types/permissions.ts
type YoloClassifierResult = {
thinking?: string // 分类器的推理过程
shouldBlock: boolean // 是否阻止
reason: string // 决策原因
unavailable?: boolean // 分类器是否不可用
transcriptTooLong?: boolean // 对话是否超出上下文窗口
model: string // 使用的分类器模型
stage?: 'fast' | 'thinking' // 哪个阶段做出的决定
// ... token 使用量、耗时等监控字段
}
```
## 安全机制
### 危险权限剥离
进入 auto mode 时,系统调用 `stripDangerousPermissionsForAutoMode()``permissionSetup.ts:510`),移除所有可能绕过分类器的 allow 规则。
被剥离的规则类型(`dangerousPatterns.ts`
| 规则类型 | 示例 | 剥离原因 |
|---------|------|---------|
| **Bash 代码执行** | `Bash(python:*)`, `Bash(node:*)` | 解释器可执行任意代码,绕过分类器审查 |
| **Shell 入口** | `Bash(bash:*)`, `Bash(sh:*)` | 直接 shell 访问等同无限制 |
| **Agent 规则** | `Agent(*)` | 任何 Agent allow 规则会绕过分类器审批子代理 |
| **PowerShell 代码执行** | `PowerShell(node:*)` | 同 Bash 逻辑 |
| **权限提升** | `Bash(sudo:*)`, `Bash(eval:*)` | 可执行任意命令 |
剥离的规则被暂存在 `strippedDangerousRules` 中,退出 auto mode 时通过 `restoreDangerousPermissions()` 恢复。
### 模型支持检测
不是所有模型都支持 auto mode。`modelSupportsAutoMode()``src/utils/betas.ts`)检查当前模型是否具备安全分类能力。不支持的模型无法进入 auto mode。
### Circuit Breaker 机制
`autoModeState.ts` 维护一个 circuit breaker 标志:
```typescript
let autoModeCircuitBroken = false // 由远程配置控制
```
当远程配置GrowthBook `tengu_auto_mode_config.enabled`)设为 `'disabled'` 时circuit breaker 触发,阻止 auto mode 的进入和继续使用。这为 Anthropic 提供了远程紧急关停能力。
## 模式切换状态管理
### 进入 Auto Mode
`transitionPermissionMode()``permissionSetup.ts:597`)处理所有模式切换:
```
1. 检查 auto mode gate 是否开启isAutoModeGateEnabled
2. 设置 autoModeActive = true
3. 调用 stripDangerousPermissionsForAutoMode() 剥离危险规则
4. 向对话注入 Auto Mode 系统提示
```
### 退出 Auto Mode
```
1. 设置 autoModeActive = false
2. 设置 needsAutoModeExitAttachment = true触发退出通知
3. 调用 restoreDangerousPermissions() 恢复被剥离的规则
4. 向对话注入 "Exited Auto Mode" 提示
```
### 触发路径
Auto mode 可通过以下方式激活:
- CLI 参数 `--enable-auto-mode`
- settings.json 中的 `autoMode` 配置
- Plan mode 默认使用 auto mode 语义(`useAutoModeDuringPlan`,默认 true
- SDK 控制消息
- REPL 中 Shift+Tab 切换
## 系统提示词
### 进入时Full Instructions
注入到对话中的指令(`messages.ts:3464`
> Auto mode is active. The user chose continuous, autonomous execution. You should:
>
> 1. **Execute immediately** — 直接实现,做合理假设
> 2. **Minimize interruptions** — 常规决策自行判断,减少提问
> 3. **Prefer action over planning** — 默认直接编码,不进 plan mode
> 4. **Expect course corrections** — 用户可随时纠正
> 5. **Do not take overly destructive actions** — 删除数据/修改生产系统仍需确认
> 6. **Avoid data exfiltration** — 不主动分享密钥/内部文档
### 持续运行时Sparse Instructions
后续轮次注入简短提醒:
> Auto mode still active. Execute autonomously, minimize interruptions, prefer action over planning.
### 退出时Exit Instructions
> You have exited auto mode. Ask clarifying questions when the approach is ambiguous rather than making assumptions.
## 与 Plan Mode 的协作
Plan mode 默认使用 auto mode 语义(`getUseAutoModeDuringPlan()`,默认 true。这意味着
- Plan mode 进入时,如果 auto mode 可用,也会激活分类器
- `isAutoModeActive()` 是权威信号(`prePlanMode`/`strippedDangerousRules` 不可靠)
- 退出 plan mode 时会同时退出 auto mode
## 分类器不可用的降级策略
当分类器 API 不可用时(`unavailable: true` 或 `transcriptTooLong: true`
- 不会直接 allow — 回退到传统的权限对话框ask
- 向 AI 发送消息:"{model} is temporarily unavailable, so auto mode cannot determine the safety of {toolName} right now."
- 确定性错误(如对话过长)不重试,直接降级
## 分类器 Prompt 模板
分类器的行为由三个 prompt 文件控制,位于 `src/utils/permissions/yolo-classifier-prompts/`。这些文件在构建时通过 `require()` 内联为字符串常量,运行时不可修改。
### auto_mode_system_prompt.txt
主系统提示词,定义分类器的角色、分类流程和决策类别。包含:
- **分类流程**:理解操作 → 检查用户意图 → 评估风险
- **BLOCK 始终阻止**:外部代码执行、不可逆删除、未授权持久化、安全削弱、提权、网络服务
- **BLOCK 除非明确意图**CWD 外写入、系统包管理、git push、大规模变更
- **ALLOW 安全操作**读文件、搜索、git 只读命令、测试/lint/构建、CWD 内编辑
- `<permissions_template>` 占位符 — 运行时替换为具体权限模板external 或 anthropic
- 末尾 `Use the classify_result tool to report your classification.` — XML 模式下被替换为 `<block>` 标签格式说明
### permissions_external.txt
外部用户版本的权限模板。三个 `<user_*_to_replace>` 标签内包裹默认规则bullet 格式),用户自定义规则**整体替换**默认值:
```
<user_allow_rules_to_replace>
- 默认 allow 规则 1
- 默认 allow 规则 2
</user_allow_rules_to_replace>
```
- **allow**9 条默认规则只读命令、版本检查、git 只读、测试/lint/构建、包安装、CWD 内文件操作等)
- **soft_deny**10 条默认规则外部代码执行、递归删除、shell 配置修改、提权、网络服务等)
- **environment**4 条环境描述终端环境、auto mode 上下文、开发工具可用、语言/框架不限)
`getDefaultExternalAutoModeRules()` 从此文件提取 bullet 项,供 `claude auto-mode defaults` 命令输出。
### permissions_anthropic.txt
Anthropic 内部版本的权限模板。默认规则在标签**外部**,标签内为空,用户自定义规则以**追加**方式叠加:
```
- 默认规则(在标签外,始终生效)
<user_allow_rules_to_replace>
</user_allow_rules_to_replace>
```
相比 external 版本,额外包含:
- 云 CLI 只读命令aws describe, gcloud describe, kubectl get 等)
- 基础设施即代码 plan 命令terraform plan, pulumi preview 等)
- 对应的 deny 规则(云资源创建/修改/删除、IaC apply、生产环境访问等
### 模板替换流程
```
buildYoloSystemPrompt()
├── BASE_PROMPT.replace('<permissions_template>', EXTERNAL/ANTHROPIC_TEMPLATE)
├── .replace(<user_allow_rules_to_replace>, userAllow ?? defaults)
├── .replace(<user_deny_rules_to_replace>, userDeny ?? defaults)
└── .replace(<user_environment_to_replace>, userEnvironment ?? defaults)
```
- 外部模板:用户设置非空时**替换**对应标签内容,否则保留默认值
- 内部模板:用户设置**追加**到默认值之后(标签在末尾为空)
## 当前状态说明
> **注意**auto mode 的完整代码逻辑已存在于代码库中,但依赖 `feature('TRANSCRIPT_CLASSIFIER')` feature flag。
> 在当前反编译版本中,`feature()` 始终返回 `false`,因此 auto mode 不可用。
> 要启用需将 `feature('TRANSCRIPT_CLASSIFIER')` 改为 `true`,并确保 GrowthBook 配置源有合理的 fallback 默认值。
Prompt 模板文件为**重建产物**——原始文件在反编译过程中丢失,已根据代码逻辑和 `yoloClassifier.ts` 中的替换模式重新编写。
## 相关源码索引
| 文件 | 职责 |
|------|------|
| `src/utils/permissions/yoloClassifier.ts` | 分类器核心实现 |
| `src/utils/permissions/autoModeState.ts` | Auto mode 状态管理 |
| `src/utils/permissions/permissionSetup.ts` | 模式切换、危险权限剥离 |
| `src/utils/permissions/dangerousPatterns.ts` | 危险命令模式列表 |
| `src/utils/permissions/classifierDecision.ts` | 分类器决策处理 |
| `src/utils/permissions/classifierShared.ts` | 分类器共享逻辑 |
| `src/utils/permissions/bashClassifier.ts` | Bash 命令分类规则 |
| `src/utils/permissions/bypassPermissionsKillswitch.ts` | bypass 权限熔断器 |
| `src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt` | 分类器主系统提示词 |
| `src/utils/permissions/yolo-classifier-prompts/permissions_external.txt` | 外部权限模板 |
| `src/utils/permissions/yolo-classifier-prompts/permissions_anthropic.txt` | 内部权限模板 |
| `src/cli/handlers/autoMode.ts` | CLI `auto-mode` 子命令处理 |
| `src/utils/messages.ts` | Auto mode 系统提示词注入 |
| `src/types/permissions.ts` | 权限类型定义 |
| `src/utils/betas.ts` | 模型 auto mode 支持检测 |

View File

@@ -0,0 +1,361 @@
# Plan 10 — 修复 WEAK 评分测试文件
> 优先级:高 | 8 个文件 | 预估新增/修改 ~60 个测试用例
本计划修复 testing-spec.md 中评定为 WEAK 的 8 个测试文件的断言缺陷和覆盖缺口。
---
## 10.1 `src/utils/__tests__/format.test.ts`
**问题**`formatNumber``formatTokens``formatRelativeTime` 使用 `toContain` 代替精确匹配,无法检测格式回归。
### 修改清单
#### formatNumber — toContain → toBe
```typescript
// 当前(弱)
expect(formatNumber(1321)).toContain("k");
expect(formatNumber(1500000)).toContain("m");
// 修复为
expect(formatNumber(1321)).toBe("1.3k");
expect(formatNumber(1500000)).toBe("1.5m");
```
> 注意:`Intl.NumberFormat` 输出可能因 locale 不同。若 CI locale 不一致,改用 `toMatch(/^\d+(\.\d)?[km]$/)` 正则匹配。
#### formatTokens — 补精确断言
```typescript
expect(formatTokens(1000)).toBe("1k");
expect(formatTokens(1500)).toBe("1.5k");
```
#### formatRelativeTime — toContain → toBe
```typescript
// 当前(弱)
expect(formatRelativeTime(diff, now)).toContain("30");
expect(formatRelativeTime(diff, now)).toContain("ago");
// 修复为
expect(formatRelativeTime(diff, now)).toBe("30s ago");
```
#### 新增formatDuration 进位边界
| 用例 | 输入 | 期望 |
|------|------|------|
| 59.5s 进位 | 59500ms | 至少含 `1m` |
| 59m59s 进位 | 3599000ms | 至少含 `1h` |
| sub-millisecond | 0.5ms | `"<1ms"``"0ms"` |
#### 新增:未测试函数
| 函数 | 最少用例 |
|------|---------|
| `formatRelativeTimeAgo` | 2过去 / 未来) |
| `formatLogMetadata` | 1基本调用不抛错 |
| `formatResetTime` | 2有值 / null |
| `formatResetText` | 1基本调用 |
---
## 10.2 `src/tools/shared/__tests__/gitOperationTracking.test.ts`
**问题**`detectGitOperation` 内部调用 `getCommitCounter()``getPrCounter()``logEvent()`,测试产生分析副作用。
### 修改清单
#### 添加 analytics mock
在文件顶部添加 `mock.module`
```typescript
import { mock, afterAll, afterEach, beforeEach } from "bun:test";
mock.module("src/services/analytics/index.ts", () => ({
logEvent: mock(() => {}),
}));
mock.module("src/bootstrap/state.ts", () => ({
getCommitCounter: mock(() => ({ increment: mock(() => {}) })),
getPrCounter: mock(() => ({ increment: mock(() => {}) })),
}));
```
> 需验证 `detectGitOperation` 的实际导入路径,按需调整 mock 目标。
#### 新增:缺失的 GH PR actions
| 用例 | 输入 | 期望 |
|------|------|------|
| gh pr edit | `'gh pr edit 123 --title "fix"'` | `result.pr.number === 123` |
| gh pr close | `'gh pr close 456'` | `result.pr.number === 456` |
| gh pr ready | `'gh pr ready 789'` | `result.pr.number === 789` |
| gh pr comment | `'gh pr comment 123 --body "done"'` | `result.pr.number === 123` |
#### 新增parseGitCommitId 边界
| 用例 | 输入 | 期望 |
|------|------|------|
| 完整 40 字符 SHA | `'[abcdef0123456789abcdef0123456789abcdef01] ...'` | 返回完整 40 字符 |
| 畸形括号输出 | `'create mode 100644 file.txt'` | 返回 `null` |
---
## 10.3 `src/utils/permissions/__tests__/PermissionMode.test.ts`
**问题**`isExternalPermissionMode` 在非 ant 环境永远返回 truefalse 路径从未执行mode 覆盖不完整。
### 修改清单
#### 补全 mode 覆盖
| 函数 | 缺失的 mode |
|------|-------------|
| `permissionModeTitle` | `bypassPermissions`, `dontAsk` |
| `permissionModeShortTitle` | `dontAsk`, `acceptEdits` |
| `getModeColor` | `dontAsk`, `acceptEdits`, `plan` |
| `permissionModeFromString` | `acceptEdits`, `bypassPermissions` |
| `toExternalPermissionMode` | `acceptEdits`, `bypassPermissions` |
#### 修复 isExternalPermissionMode
```typescript
// 当前:只测了非 ant 环境(永远 true
// 需要新增 ant 环境测试
describe("when USER_TYPE is 'ant'", () => {
beforeEach(() => {
process.env.USER_TYPE = "ant";
});
afterEach(() => {
delete process.env.USER_TYPE;
});
test("returns false for 'auto' in ant context", () => {
expect(isExternalPermissionMode("auto")).toBe(false);
});
test("returns false for 'bubble' in ant context", () => {
expect(isExternalPermissionMode("bubble")).toBe(false);
});
test("returns true for non-ant modes in ant context", () => {
expect(isExternalPermissionMode("plan")).toBe(true);
});
});
```
#### 新增permissionModeSchema
| 用例 | 输入 | 期望 |
|------|------|------|
| 有效 mode | `'plan'` | `success: true` |
| 无效 mode | `'invalid'` | `success: false` |
---
## 10.4 `src/utils/permissions/__tests__/dangerousPatterns.test.ts`
**问题**:纯数据 smoke test无行为验证。
### 修改清单
#### 新增:重复值检查
```typescript
test("CROSS_PLATFORM_CODE_EXEC has no duplicates", () => {
const set = new Set(CROSS_PLATFORM_CODE_EXEC);
expect(set.size).toBe(CROSS_PLATFORM_CODE_EXEC.length);
});
test("DANGEROUS_BASH_PATTERNS has no duplicates", () => {
const set = new Set(DANGEROUS_BASH_PATTERNS);
expect(set.size).toBe(DANGEROUS_BASH_PATTERNS.length);
});
```
#### 新增:全量成员断言(用 Set 确保精确)
```typescript
test("CROSS_PLATFORM_CODE_EXEC contains expected interpreters", () => {
const expected = ["node", "python", "python3", "ruby", "perl", "php",
"bun", "deno", "npx", "tsx"];
const set = new Set(CROSS_PLATFORM_CODE_EXEC);
for (const entry of expected) {
expect(set.has(entry)).toBe(true);
}
});
```
#### 新增:空字符串不匹配
```typescript
test("empty string does not match any pattern", () => {
for (const pattern of DANGEROUS_BASH_PATTERNS) {
expect("".startsWith(pattern)).toBe(false);
}
});
```
---
## 10.5 `src/utils/__tests__/zodToJsonSchema.test.ts`
**问题**object 属性仅 `toBeDefined` 未验证类型结构optional 字段未验证 absence。
### 修改清单
#### 修复 object schema 测试
```typescript
// 当前(弱)
expect(schema.properties!.name).toBeDefined();
expect(schema.properties!.age).toBeDefined();
// 修复为
expect(schema.properties!.name).toEqual({ type: "string" });
expect(schema.properties!.age).toEqual({ type: "number" });
```
#### 修复 optional 字段测试
```typescript
test("optional field is not in required array", () => {
const schema = zodToJsonSchema(z.object({
required: z.string(),
optional: z.string().optional(),
}));
expect(schema.required).toEqual(["required"]);
expect(schema.required).not.toContain("optional");
});
```
#### 新增:缺失的 schema 类型
| 用例 | 输入 | 期望 |
|------|------|------|
| `z.literal("foo")` | `z.literal("foo")` | `{ const: "foo" }` |
| `z.null()` | `z.null()` | `{ type: "null" }` |
| `z.union()` | `z.union([z.string(), z.number()])` | `{ anyOf: [...] }` |
| `z.record()` | `z.record(z.string(), z.number())` | `{ type: "object", additionalProperties: { type: "number" } }` |
| `z.tuple()` | `z.tuple([z.string(), z.number()])` | `{ type: "array", items: [...], additionalItems: false }` |
| 嵌套 object | `z.object({ a: z.object({ b: z.string() }) })` | 验证嵌套属性结构 |
---
## 10.6 `src/utils/__tests__/envValidation.test.ts`
**问题**`validateBoundedIntEnvVar` lower bound=100 时 value=1 返回 `status: "valid"`,疑似源码 bug。
### 修改清单
#### 验证 lower bound 行为
```typescript
// 当前测试
test("value of 1 with lower bound 100", () => {
const result = validateBoundedIntEnvVar("1", { defaultValue: 100, upperLimit: 1000, lowerLimit: 100 });
// 如果源码有 bug这里应该暴露
expect(result.effective).toBeGreaterThanOrEqual(100);
expect(result.status).toBe(result.effective !== 100 ? "capped" : "valid");
});
```
#### 新增边界用例
| 用例 | value | lowerLimit | 期望 |
|------|-------|------------|------|
| 低于 lower bound | `"50"` | 100 | `effective: 100, status: "capped"` |
| 等于 lower bound | `"100"` | 100 | `effective: 100, status: "valid"` |
| 浮点截断 | `"50.7"` | 100 | `effective: 100`parseInt 截断后 cap |
| 空白字符 | `" 500 "` | 1 | `effective: 500, status: "valid"` |
| defaultValue 为 0 | `"0"` | 0 | 需确认 `parsed <= 0` 逻辑 |
> **行动**:先确认 `validateBoundedIntEnvVar` 源码中 lower bound 的实际执行路径。如果确实不生效,需先修源码再补测试。
---
## 10.7 `src/utils/__tests__/file.test.ts`
**问题**`addLineNumbers``toContain`,未验证完整格式。
### 修改清单
#### 修复 addLineNumbers 断言
```typescript
// 当前(弱)
expect(result).toContain("1");
expect(result).toContain("hello");
// 修复为(需确定 isCompactLinePrefixEnabled 行为)
// 假设 compact=false格式为 " 1→hello"
test("formats single line with tab prefix", () => {
// 先确认环境,如果 compact 模式不确定,用正则
expect(result).toMatch(/^\s*\d+[→\t]hello$/m);
});
```
#### 新增stripLineNumberPrefix 边界
| 用例 | 输入 | 期望 |
|------|------|------|
| 纯数字行 | `"123"` | `""` |
| 无内容前缀 | `"→"` | `""` |
| compact 格式 `"1\thello"` | `"1\thello"` | `"hello"` |
#### 新增pathsEqual 边界
| 用例 | a | b | 期望 |
|------|---|---|------|
| 尾部斜杠差异 | `"/a/b"` | `"/a/b/"` | `false` |
| `..` 段 | `"/a/../b"` | `"/b"` | 视实现而定 |
---
## 10.8 `src/utils/__tests__/notebook.test.ts`
**问题**`mapNotebookCellsToToolResult` 内容检查用 `toContain`,未验证 XML 格式。
### 修改清单
#### 修复 content 断言
```typescript
// 当前(弱)
expect(result).toContain("cell-0");
expect(result).toContain("print('hello')");
// 修复为
expect(result).toContain('<cell id="cell-0">');
expect(result).toContain("</cell>");
```
#### 新增parseCellId 边界
| 用例 | 输入 | 期望 |
|------|------|------|
| 负数 | `"cell--1"` | `null` |
| 前导零 | `"cell-007"` | `7` |
| 极大数 | `"cell-999999999"` | `999999999` |
#### 新增mapNotebookCellsToToolResult 边界
| 用例 | 输入 | 期望 |
|------|------|------|
| 空 data 数组 | `{ cells: [] }` | 空字符串或空结果 |
| 无 cell_id | `{ cell_type: "code", source: "x" }` | fallback 到 `cell-${index}` |
| error output | `{ output_type: "error", ename: "Error", evalue: "msg" }` | 包含 error 信息 |
---
## 验收标准
- [ ] `bun test` 全部通过
- [ ] 8 个文件评分从 WEAK 提升至 ACCEPTABLE 或 GOOD
- [ ] `toContain` 仅用于警告文本等确实不确定精确值的场景
- [ ] envValidation bug 确认并修复(或确认非 bug 并更新测试)

View File

@@ -0,0 +1,177 @@
# Plan 11 — 加强 ACCEPTABLE 评分测试
> 优先级:中 | ~15 个文件 | 预估新增 ~80 个测试用例
本计划对 ACCEPTABLE 评分文件中的具体缺陷进行定向加强。每个条目只列出需要改动的部分,不做全量重写。
---
## 11.1 `src/utils/__tests__/diff.test.ts`
| 改动 | 当前 | 改为 |
|------|------|------|
| `getPatchFromContents` 断言 | `hunks.length > 0` | 验证具体 `+`/`-` 行内容 |
| `$` 字符转义 | 未测试 | 新增含 `$` 的内容测试 |
| `ignoreWhitespace` 选项 | 未测试 | 新增 `ignoreWhitespace: true` 用例 |
| 删除全部内容 | 未测试 | `newContent: ""` |
| 多 hunk 偏移 | `adjustHunkLineNumbers` 仅单 hunk | 新增多 hunk 同数组测试 |
---
## 11.2 `src/utils/__tests__/path.test.ts`
当前仅覆盖 2/5+ 导出函数。新增:
| 函数 | 最少用例 | 关键边界 |
|------|---------|---------|
| `expandPath` | 6 | `~/` 展开、绝对路径直通、相对路径、空串、含 null 字节、`~user` 格式 |
| `toRelativePath` | 3 | 同级文件、子目录、父目录 |
| `sanitizePath` | 3 | 正常路径、含 `..` 段、空串 |
`containsPathTraversal` 补充:
- URL 编码 `%2e%2e%2f`(确认不匹配,记录为非需求)
- 混合分隔符 `foo/..\bar`
`normalizePathForConfigKey` 补充:
- 混合分隔符 `foo/bar\baz`
- 冗余分隔符 `foo//bar`
- Windows 盘符 `C:\foo\bar`
---
## 11.3 `src/utils/__tests__/uuid.test.ts`
| 改动 | 说明 |
|------|------|
| 大写测试断言强化 | `not.toBeNull()` → 验证标准化输出(小写+连字符格式) |
| 新增 `createAgentId` | 3 用例:无 label / 有 label / 输出格式正则 `/^a[a-z]*-[a-f0-9]{16}$/` |
| 前后空白 | `" 550e8400-... "` 期望 `null` |
---
## 11.4 `src/utils/__tests__/semver.test.ts`
| 用例 | 输入 | 期望 |
|------|------|------|
| pre-release 比较 | `gt("1.0.0", "1.0.0-alpha")` | `true` |
| pre-release 间比较 | `order("1.0.0-alpha", "1.0.0-beta")` | `-1` |
| tilde range | `satisfies("1.2.5", "~1.2.3")` | `true` |
| `*` 通配符 | `satisfies("2.0.0", "*")` | `true` |
| 畸形版本 | `order("abc", "1.0.0")` | 确认不抛错 |
| `0.0.0` | `gt("0.0.0", "0.0.0")` | `false` |
---
## 11.5 `src/utils/__tests__/hash.test.ts`
| 改动 | 当前 | 改为 |
|------|------|------|
| djb2 32 位检查 | `hash \| 0`(恒 true | `Number.isSafeInteger(hash) && Math.abs(hash) <= 0x7FFFFFFF` |
| hashContent 空串 | 未测试 | 新增 |
| hashContent 格式 | 未验证输出为数字串 | `toMatch(/^\d+$/)` |
| hashPair 空串 | 未测试 | `hashPair("", "b")`, `hashPair("", "")` |
| 已知答案 | 无 | 断言 `djb2Hash("hello")` 为特定值(需先在控制台运行一次确定) |
---
## 11.6 `src/utils/__tests__/claudemd.test.ts`
当前仅覆盖 3 个辅助函数。新增:
| 用例 | 函数 | 说明 |
|------|------|------|
| 未闭合注释 | `stripHtmlComments` | `"<!-- no close some text"` → 原样返回 |
| 跨行注释 | `stripHtmlComments` | `"<!--\nmulti\nline\n-->text"``"text"` |
| 同行注释+内容 | `stripHtmlComments` | `"<!-- note -->some text"``"some text"` |
| 内联代码中的注释 | `stripHtmlComments` | `` `<!-- kept -->` `` → 保留 |
| 大小写不敏感 | `isMemoryFilePath` | `"claude.md"`, `"CLAUDE.MD"` |
| 非 .md 规则文件 | `isMemoryFilePath` | `.claude/rules/foo.txt` → `false` |
| 空数组 | `getLargeMemoryFiles` | `[]` → `[]` |
---
## 11.7 `src/tools/FileEditTool/__tests__/utils.test.ts`
| 函数 | 新增用例 |
|------|---------|
| `normalizeQuotes` | 混合引号 `"`she said 'hello'"` |
| `stripTrailingWhitespace` | CR-only `\r`、无尾部换行、全空白串 |
| `findActualString` | 空 content、Unicode content |
| `preserveQuoteStyle` | 单引号、缩写中的撇号(如 `it's`)、空串 |
| `applyEditToFile` | `replaceAll=true` 零匹配、`oldString` 无尾部 `\n`、多行内容 |
---
## 11.8 `src/utils/model/__tests__/providers.test.ts`
| 改动 | 说明 |
|------|------|
| 删除 `originalEnv` | 未使用,消除死代码 |
| env 恢复改为快照 | `beforeEach` 保存 `process.env``afterEach` 恢复 |
| 新增三变量同时设置 | bedrock + vertex + foundry 全部为 `"1"`,验证优先级 |
| 新增非 `"1"` 值 | `"true"`, `"0"`, `""` |
| `isFirstPartyAnthropicBaseUrl` | URL 含路径 `/v1`、含尾斜杠、非 HTTPS |
---
## 11.9 `src/utils/__tests__/hyperlink.test.ts`
| 用例 | 说明 |
|------|------|
| 空 URL | `createHyperlink("http://x.com", "", { supported: true })` 不抛错 |
| undefined supportsHyperlinks | 选项未传时走默认检测 |
| 非 ant staging URL | `USER_TYPE !== "ant"` 时 staging 返回 `false` |
---
## 11.10 `src/utils/__tests__/objectGroupBy.test.ts`
| 用例 | 说明 |
|------|------|
| key 返回 undefined | `(_, i) => undefined` → 全部归入 `undefined` 组 |
| key 为特殊字符 | `({ name }) => name` 含空格/中文 |
---
## 11.11 `src/utils/__tests__/CircularBuffer.test.ts`
| 用例 | 说明 |
|------|------|
| capacity=1 | 添加 2 个元素,仅保留最后一个 |
| 空 buffer 调用 getRecent | 返回空数组 |
| getRecent(0) | 返回空数组 |
---
## 11.12 `src/utils/__tests__/contentArray.test.ts`
| 用例 | 说明 |
|------|------|
| 混合交替 | `[tool_result, text, tool_result]` — 验证插入到正确位置 |
---
## 11.13 `src/utils/__tests__/argumentSubstitution.test.ts`
| 用例 | 说明 |
|------|------|
| 转义引号 | `"he said \"hello\""` |
| 越界索引 | `$ARGUMENTS[99]`(参数不够时) |
| 多占位符 | `"cmd $0 $1 $0"` |
---
## 11.14 `src/utils/__tests__/messages.test.ts`
| 改动 | 说明 |
|------|------|
| `normalizeMessages` 断言加强 | 验证拆分后的消息内容,不只是长度 |
| `isNotEmptyMessage` 空白 | `[{ type: "text", text: " " }]` |
---
## 验收标准
- [ ] `bun test` 全部通过
- [ ] 目标文件评分从 ACCEPTABLE 提升至 GOOD
- [ ]`toContain` 用于精确值检查的场景

View File

@@ -0,0 +1,145 @@
# Plan 12 — Mock 可靠性修复
> 优先级:高 | 影响 4 个测试文件 | 预估修改 ~15 处
本计划修复测试中 mock 相关的副作用、状态泄漏和虚假测试。
---
## 12.1 `gitOperationTracking.test.ts` — 消除分析副作用
**当前问题**`detectGitOperation` 内部调用 `logEvent()``getCommitCounter().increment()``getPrCounter().increment()`,每次测试运行都触发真实分析代码。
**修复步骤**
1. 读取 `src/tools/shared/gitOperationTracking.ts`,确认 analytics 导入路径
2. 在测试文件顶部添加 `mock.module`
```typescript
import { mock } from "bun:test";
mock.module("src/services/analytics/index.ts", () => ({
logEvent: mock(() => {}),
// 按需补充其他导出
}));
```
3. 如果 `getCommitCounter` / `getPrCounter` 来自 `src/bootstrap/state.ts`
```typescript
mock.module("src/bootstrap/state.ts", () => ({
getCommitCounter: mock(() => ({ increment: mock(() => {}) })),
getPrCounter: mock(() => ({ increment: mock(() => {}) })),
// 保留其他被测函数实际需要的导出
}));
```
4. 使用 `await import()` 模式加载被测模块
5. 运行测试验证无副作用
**风险**`mock.module` 会替换整个模块。如果 `detectGitOperation` 还需要其他来自这些模块的导出,需在 mock 工厂中提供。
---
## 12.2 `PermissionMode.test.ts` — 修复 `isExternalPermissionMode` 虚假测试
**当前问题**`isExternalPermissionMode` 依赖 `process.env.USER_TYPE`。非 ant 环境下所有 mode 都返回 true测试从未覆盖 false 分支。
**修复步骤**
1. 新增 ant 环境测试组(见 Plan 10.3 详细用例)
2. 使用 `beforeEach`/`afterEach` 管理 `process.env.USER_TYPE`
```typescript
describe("when USER_TYPE is 'ant'", () => {
const originalUserType = process.env.USER_TYPE;
beforeEach(() => { process.env.USER_TYPE = "ant"; });
afterEach(() => {
if (originalUserType !== undefined) {
process.env.USER_TYPE = originalUserType;
} else {
delete process.env.USER_TYPE;
}
});
test("returns false for 'auto'", () => {
expect(isExternalPermissionMode("auto")).toBe(false);
});
test("returns false for 'bubble'", () => {
expect(isExternalPermissionMode("bubble")).toBe(false);
});
test("returns true for 'plan'", () => {
expect(isExternalPermissionMode("plan")).toBe(true);
});
});
```
3. 验证新增测试确实执行 false 路径
---
## 12.3 `providers.test.ts` — 环境变量快照恢复
**当前问题**
- `originalEnv` 声明后未使用
- `afterEach` 仅删除已知 3 个 key如果源码新增 env var测试间状态泄漏
**修复步骤**
```typescript
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
savedEnv = {};
for (const key of Object.keys(process.env)) {
savedEnv[key] = process.env[key];
}
});
afterEach(() => {
// 删除所有当前 env恢复快照
for (const key of Object.keys(process.env)) {
delete process.env[key];
}
for (const [key, value] of Object.entries(savedEnv)) {
if (value !== undefined) {
process.env[key] = value;
}
}
});
```
> 简化方案:只保存/恢复相关 key 列表 `["CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "ANTHROPIC_BASE_URL", "USER_TYPE"]`,但需注释说明新增 env var 时需同步更新。
---
## 12.4 `envUtils.test.ts` — 验证环境变量恢复完整性
**当前状态**:已有 `afterEach` 恢复。需审查:
1. 确认所有 `describe` 块中的 `afterEach` 都完整恢复了修改的 env var
2. 确认 `process.argv` 修改也被恢复(`getClaudeConfigHomeDir` 测试修改了 argv
3. 新增:`afterEach` 中断言无意外 env 泄漏可选CI-only
---
## 12.5 `sleep.test.ts` / `memoize.test.ts` — 时间敏感测试加固
**当前状态**:已有合理 margin。可选加固
| 文件 | 用例 | 当前 | 加固 |
|------|------|------|------|
| `sleep.test.ts` | `resolves after timeout` | `sleep(50)`, check `>= 40ms` | 增大 margin`sleep(50)`, check `>= 30ms` |
| `memoize.test.ts` | stale serve & refresh | TTL=1ms, wait 10ms | 增大 marginTTL=5ms, wait 50ms |
> 仅在 CI 出现 flaky 时执行此加固。
---
## 验收标准
- [ ] `gitOperationTracking.test.ts` 无分析副作用(可通过在 mock 中增加 `expect(logEvent).toHaveBeenCalledTimes(N)` 验证)
- [ ] `PermissionMode.test.ts``isExternalPermissionMode` 覆盖 true + false 分支
- [ ] `providers.test.ts``originalEnv` 死代码已删除
- [ ] 所有修改 env 的测试文件恢复完整
- [ ] `bun test` 全部通过

View File

@@ -0,0 +1,71 @@
# Plan 13 — truncate CJK/Emoji 补充测试
> 优先级:中 | 1 个文件 | 预估新增 ~15 个测试用例
`truncate.ts` 使用 `stringWidth` 和 grapheme segmentation 实现宽度感知截断,但现有测试仅覆盖 ASCII。这是核心场景缺失。
---
## 被测函数
- `truncateToWidth(text, maxWidth)` — 尾部截断加 `…`
- `truncateStartToWidth(text, maxWidth)` — 头部截断加 `…`
- `truncateToWidthNoEllipsis(text, maxWidth)` — 尾部截断无省略号
- `truncatePathMiddle(path, maxLength)` — 路径中间截断
- `wrapText(text, maxWidth)` — 按宽度换行
---
## 新增用例
### CJK 全角字符
| 用例 | 函数 | 输入 | maxWidth | 期望行为 |
|------|------|------|----------|----------|
| 纯中文截断 | `truncateToWidth` | `"你好世界"` | 4 | `"你好…"` (每个中文字占 2 宽度) |
| 中英混合 | `truncateToWidth` | `"hello你好"` | 8 | `"hello你…"` |
| 全角不截断 | `truncateToWidth` | `"你好"` | 4 | `"你好"` (恰好 4) |
| emoji 单字符 | `truncateToWidth` | `"👋"` | 2 | `"👋"` (emoji 通常 2 宽度) |
| emoji 截断 | `truncateToWidth` | `"hello 👋 world"` | 8 | 确认宽度计算正确 |
| 头部中文 | `truncateStartToWidth` | `"你好世界"` | 4 | `"…界"` |
| 无省略中文 | `truncateToWidthNoEllipsis` | `"你好世界"` | 4 | `"你好"` |
> **注意**`stringWidth` 对 CJK/emoji 的宽度计算取决于具体实现。先在 REPL 中运行确认实际宽度再写断言:
> ```typescript
> import { stringWidth } from "src/utils/truncate.ts";
> console.log(stringWidth("你好")); // 确认是 4 还是 2
> console.log(stringWidth("👋")); // 确认 emoji 宽度
> ```
### 路径中间截断补充
| 用例 | 输入 | maxLength | 期望 |
|------|------|-----------|------|
| 文件名超长 | `"/very/long/path/to/MyComponent.tsx"` | 10 | 含 `…` 且以 `.tsx` 结尾 |
| 无斜杠短串 | `"abc"` | 1 | 确认行为不抛错 |
| maxLength 极小 | `"/a/b"` | 1 | 确认不抛错 |
| maxLength=4 | `"/a/b/c.ts"` | 4 | 确认行为 |
### wrapText 补充
| 用例 | 输入 | maxWidth | 期望 |
|------|------|----------|------|
| 含换行符 | `"hello\nworld"` | 10 | 保留原有换行 |
| 宽度=0 | `"hello"` | 0 | 空串或原串(确认不抛错) |
---
## 实施步骤
1. 在 REPL 中确认 `stringWidth` 对 CJK/emoji 的实际返回值
2. 按实际值编写精确断言
3. 如果 `stringWidth` 依赖 ICU 或平台特性,添加平台检查(`process.platform !== "win32"` 跳过条件)
4. 运行测试
---
## 验收标准
- [ ] 至少 5 个 CJK/emoji 相关测试通过
- [ ] 断言基于实际 `stringWidth` 返回值,非猜测
- [ ] `bun test` 全部通过

View File

@@ -0,0 +1,191 @@
# Plan 14 — 集成测试搭建
> 优先级:中 | 新建 ~3 个测试文件 | 预估 ~30 个测试用例
当前 `tests/integration/` 目录为空spec 设计的三个集成测试均未创建。本计划搭建 mock 基础设施并实现核心集成测试。
---
## 14.1 搭建 `tests/mocks/` 基础设施
### 文件结构
```
tests/
├── mocks/
│ ├── api-responses.ts # Claude API mock 响应
│ ├── file-system.ts # 临时文件系统工具
│ └── fixtures/
│ ├── sample-claudemd.md # CLAUDE.md 样本
│ └── sample-messages.json # 消息样本
├── integration/
│ ├── tool-chain.test.ts
│ ├── context-build.test.ts
│ └── message-pipeline.test.ts
└── helpers/
└── setup.ts # 共享 beforeAll/afterAll
```
### `tests/mocks/file-system.ts`
```typescript
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
export async function createTempDir(prefix = "claude-test-"): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), prefix));
return dir;
}
export async function cleanupTempDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true });
}
export async function writeTempFile(dir: string, name: string, content: string): Promise<string> {
const path = join(dir, name);
await writeFile(path, content, "utf-8");
return path;
}
```
### `tests/mocks/fixtures/sample-claudemd.md`
```markdown
# Project Instructions
This is a sample CLAUDE.md file for testing.
```
### `tests/mocks/api-responses.ts`
```typescript
export const mockStreamResponse = {
type: "message_start" as const,
message: {
id: "msg_mock_001",
type: "message" as const,
role: "assistant",
content: [],
model: "claude-sonnet-4-20250514",
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 100, output_tokens: 0 },
},
};
export const mockTextBlock = {
type: "content_block_start" as const,
index: 0,
content_block: { type: "text" as const, text: "Mock response" },
};
export const mockToolUseBlock = {
type: "content_block_start" as const,
index: 1,
content_block: {
type: "tool_use" as const,
id: "toolu_mock_001",
name: "Read",
input: { file_path: "/tmp/test.txt" },
},
};
export const mockEndEvent = {
type: "message_stop" as const,
};
```
---
## 14.2 `tests/integration/tool-chain.test.ts`
**目标**:验证 Tool 注册 → 发现 → 权限检查链路。
### 前置条件
`src/tools.ts``getAllBaseTools` / `getTools` 导入链过重。策略:
- 尝试直接 import 并 mock 最重依赖
- 若不可行,改为测试 `src/Tool.ts``findToolByName` + 手动构造 tool 列表
### 用例
| # | 用例 | 验证点 |
|---|------|--------|
| 1 | `findToolByName("Bash")` 在已注册列表中查找 | 返回正确的 tool 定义 |
| 2 | `findToolByName("NonExistent")` | 返回 `undefined` |
| 3 | `findToolByName` 大小写不敏感 | `"bash"` 也能找到 |
| 4 | `filterToolsByDenyRules` 拒绝特定工具 | 被拒绝工具不在结果中 |
| 5 | `parseToolPreset("default")` 返回已知列表 | 包含核心 tools |
| 6 | `buildTool` 构建的 tool 可被 `findToolByName` 发现 | 端到端验证 |
> 如果 `getAllBaseTools` 确实不可导入,改用 mock tool list 替代。
---
## 14.3 `tests/integration/context-build.test.ts`
**目标**验证系统提示组装流程CLAUDE.md 加载 + git status + 日期注入)。
### 前置条件
`src/context.ts` 依赖链极重。策略:
- Mock `src/bootstrap/state.ts`(提供 cwd、projectRoot
- Mock `src/utils/git.ts`(提供 git status
- 使用真实 `src/utils/claudemd.ts` + 临时文件
### 用例
| # | 用例 | 验证点 |
|---|------|--------|
| 1 | 基本 context 构建 | 返回值包含系统提示字符串 |
| 2 | CLAUDE.md 内容出现在 context 中 | `stripHtmlComments` 后的内容被包含 |
| 3 | 多层目录 CLAUDE.md 合并 | 父目录 + 子目录 CLAUDE.md 都被加载 |
| 4 | 无 CLAUDE.md 时不报错 | context 正常返回,无 crash |
| 5 | git status 为 null | context 正常构建(测试环境中 git 不可用时) |
> **风险评估**:如果 mock `context.ts` 的依赖链成本过高,退化为测试 `buildEffectiveSystemPrompt`(已在 systemPrompt.test.ts 中完成),记录为已知限制。
---
## 14.4 `tests/integration/message-pipeline.test.ts`
**目标**:验证用户输入 → 消息格式化 → API 请求构建。
### 前置条件
`src/services/api/claude.ts` 构建最终 API 请求。策略:
- Mock Anthropic SDK 的 streaming endpoint
- 验证请求参数结构
### 用例
| # | 用例 | 验证点 |
|---|------|--------|
| 1 | 文本消息格式化 | `createUserMessage` 生成正确 role+content |
| 2 | tool_result 消息格式化 | 包含 tool_use_id 和 content |
| 3 | 多轮消息序列化 | messages 数组保持顺序 |
| 4 | 系统提示注入到请求 | API 请求的 system 字段非空 |
| 5 | 消息 normalize 后格式一致 | `normalizeMessages` 输出结构正确 |
> **现实评估**:消息格式化的大部分已在 `messages.test.ts` 覆盖。API 请求构建需要 mock SDK复杂度高。如果投入产出比低仅实现用例 1-3 和 5用例 4 标记为 stretch goal。
---
## 实施步骤
1. 创建 `tests/mocks/` 目录和基础文件
2. 实现 `tool-chain.test.ts`(最低风险,最高价值)
3. 评估 `context-build.test.ts` 可行性,决定是否实施
4. 实现 `message-pipeline.test.ts`(可降级为单元测试)
5. 更新 `testing-spec.md` 状态
---
## 验收标准
- [ ] `tests/mocks/` 基础设施可用
- [ ] 至少 `tool-chain.test.ts` 实现并通过
- [ ] 集成测试独立于单元测试运行:`bun test tests/integration/`
- [ ] 所有集成测试使用 `createTempDir` + `cleanupTempDir`,不留文件系统残留
- [ ] `bun test` 全部通过

View File

@@ -0,0 +1,67 @@
# Plan 15 — CLI 参数测试 + 覆盖率基线
> 优先级:低 | 预估 ~15 个测试用例
---
## 15.1 `src/main.tsx` CLI 参数测试
**目标**:覆盖 Commander.js 配置的参数解析和模式切换。
### 前置条件
`src/main.tsx` 的 Commander 实例通常在模块顶层创建。测试策略:
- 直接构造 Commander 实例或 mock `main.tsx` 的 program 导出
- 使用 `parseArgs` 而非 `parse`(不触发 `process.exit`
### 用例
| # | 用例 | 输入 | 期望 |
|---|------|------|------|
| 1 | 默认模式 | `[]` | 模式为 REPL |
| 2 | pipe 模式 | `["-p"]` | 模式为 pipe |
| 3 | pipe 带输入 | `["-p", "say hello"]` | 输入为 `"say hello"` |
| 4 | print 模式 | `["--print", "hello"]` | 等效于 pipe |
| 5 | verbose | `["-v"]` | verbose 标志为 true |
| 6 | model 选择 | `["--model", "claude-opus-4-6"]` | model 值正确传递 |
| 7 | system prompt | `["--system-prompt", "custom"]` | system prompt 被设置 |
| 8 | help | `["--help"]` | 显示帮助信息,不报错 |
| 9 | version | `["--version"]` | 显示版本号 |
| 10 | unknown flag | `["--nonexistent"]` | 不报错Commander 允许未知参数时) |
> **风险**`main.tsx` 可能执行初始化逻辑auth、analytics需要在 mock 环境中运行。如果复杂度过高,降级为只测试参数解析部分。
---
## 15.2 覆盖率基线
### 运行命令
```bash
bun test --coverage 2>&1 | tail -50
```
### 记录内容
| 模块 | 当前覆盖率 | 目标 |
|------|-----------|------|
| `src/utils/` | 待测量 | >= 80% |
| `src/utils/permissions/` | 待测量 | >= 60% |
| `src/utils/model/` | 待测量 | >= 60% |
| `src/Tool.ts` + `src/tools.ts` | 待测量 | >= 80% |
| `src/utils/claudemd.ts` | 待测量 | >= 40%(核心逻辑难测) |
| 整体 | 待测量 | 不设强制指标 |
### 后续行动
- 将基线数据填入 `testing-spec.md` §4
- 识别覆盖率最低的 10 个文件,排入后续测试计划
-`bun test --coverage` 输出不可用Bun 版本限制),改用手动计算已测/总导出函数比
---
## 验收标准
- [ ] CLI 参数至少覆盖 5 个核心 flag
- [ ] 覆盖率基线数据记录到 testing-spec.md
- [ ] `bun test` 全部通过

View File

@@ -0,0 +1,188 @@
# Phase 16 — 零依赖纯函数测试
> 创建日期2026-04-02
> 预计:+120 tests / 8 files
> 目标:覆盖所有零外部依赖的纯函数/类模块
所有模块均为纯函数或零外部依赖类mock 成本为零ROI 最高。
---
## 16.1 `src/utils/__tests__/stream.test.ts`~15 tests
**目标模块**: `src/utils/stream.ts`76 行)
**导出**: `Stream<T>` class — 手动异步队列,实现 `AsyncIterator<T>`
| 测试用例 | 验证点 |
|---------|--------|
| enqueue then read | 单条消息正确传递 |
| enqueue multiple then drain | 多条消息顺序消费 |
| done resolves pending readers | `done()` 后迭代结束 |
| done with no pending readers | 无等待时安全关闭 |
| error rejects pending readers | `error(e)` 传播异常 |
| error after done | 后续操作安全处理 |
| single-iteration guard | `return()` 后不可再迭代 |
| empty stream done immediately | 无数据时 done 返回 `{ done: true }` |
| concurrent enqueue | 多次 enqueue 不丢失 |
| backpressure | reader 慢于 writer 时不丢数据 |
---
## 16.2 `src/utils/__tests__/abortController.test.ts`~12 tests
**目标模块**: `src/utils/abortController.ts`99 行)
**导出**: `createAbortController()`, `createChildAbortController()`
| 测试用例 | 验证点 |
|---------|--------|
| parent abort propagates to child | `parent.abort()` → child aborted |
| child abort does NOT propagate to parent | `child.abort()` → parent still active |
| already-aborted parent → child immediately aborted | 创建时即继承 abort 状态 |
| child listener cleanup after parent abort | WeakRef 回收后无泄漏 |
| multiple children of same parent | 独立 abort 传播 |
| child abort then parent abort | 顺序无关 |
| signal.maxListeners raised | MaxListenersExceededWarning 不触发 |
---
## 16.3 `src/utils/__tests__/bufferedWriter.test.ts`~14 tests
**目标模块**: `src/utils/bufferedWriter.ts`100 行)
**导出**: `createBufferedWriter()`
| 测试用例 | 验证点 |
|---------|--------|
| single write buffered | write → buffer 累积 |
| flush on size threshold | 超过 maxSize 时自动 flush |
| flush on timer | 定时器触发 flush |
| immediate mode | `{ immediate: true }` 跳过缓冲 |
| overflow coalescing | overflow 内容合并到下次 flush |
| empty buffer flush | 无数据时 flush 无副作用 |
| close flushes remaining | close 触发最终 flush |
| multiple writes before flush | 批量写入合并 |
| flush callback receives concatenated data | writeFn 参数正确 |
**Mock**: 注入 `writeFn` 回调,可选 fake timers
---
## 16.4 `src/utils/__tests__/gitDiff.test.ts`~20 tests
**目标模块**: `src/utils/gitDiff.ts`532 行)
**可测函数**: `parseGitNumstat()`, `parseGitDiff()`, `parseShortstat()`
| 测试用例 | 验证点 |
|---------|--------|
| parseGitNumstat — single file | `1\t2\tpath` → { added: 1, deleted: 2, file: "path" } |
| parseGitNumstat — binary file | `-\t-\timage.png` → binary flag |
| parseGitNumstat — rename | `{ old => new }` 格式解析 |
| parseGitNumstat — empty diff | 空字符串 → [] |
| parseGitNumstat — multiple files | 多行正确分割 |
| parseGitDiff — added lines | `+` 开头行计数 |
| parseGitDiff — deleted lines | `-` 开头行计数 |
| parseGitDiff — hunk header | `@@ -a,b +c,d @@` 解析 |
| parseGitDiff — new file mode | `new file mode 100644` 检测 |
| parseGitDiff — deleted file mode | `deleted file mode` 检测 |
| parseGitDiff — binary diff | Binary files differ 处理 |
| parseShortstat — all components | `1 file changed, 5 insertions(+), 3 deletions(-)` |
| parseShortstat — insertions only | 无 deletions |
| parseShortstat — deletions only | 无 insertions |
| parseShortstat — files only | 仅 file changed |
| parseShortstat — empty | 空字符串 → 默认值 |
| parseShortstat — rename | `1 file changed, ...` 重命名 |
**Mock**: 无需 mock — 全部是纯字符串解析
---
## 16.5 `src/__tests__/history.test.ts`~18 tests
**目标模块**: `src/history.ts`464 行)
**可测函数**: `parseReferences()`, `expandPastedTextRefs()`, `formatPastedTextRef()`, `formatImageRef()`, `getPastedTextRefNumLines()`
| 测试用例 | 验证点 |
|---------|--------|
| parseReferences — text ref | `#1` → [{ type: "text", ref: 1 }] |
| parseReferences — image ref | `@1` → [{ type: "image", ref: 1 }] |
| parseReferences — multiple refs | `#1 #2 @3` → 3 refs |
| parseReferences — no refs | `"hello"` → [] |
| parseReferences — duplicate refs | `#1 #1` → 去重或保留 |
| parseReferences — zero ref | `#0` → 边界 |
| parseReferences — large ref | `#999` → 正常 |
| formatPastedTextRef — basic | 输出格式验证 |
| formatPastedTextRef — multiline | 多行内容格式 |
| getPastedTextRefNumLines — 1 line | 返回 1 |
| getPastedTextRefNumLines — multiple lines | 换行计数 |
| expandPastedTextRefs — single ref | 替换单个引用 |
| expandPastedTextRefs — multiple refs | 替换多个引用 |
| expandPastedTextRefs — no refs | 原样返回 |
| expandPastedTextRefs — mixed content | 文本 + 引用混合 |
| formatImageRef — basic | 输出格式 |
**Mock**: `mock.module("src/bootstrap/state.ts", ...)` 解锁模块
---
## 16.6 `src/utils/__tests__/sliceAnsi.test.ts`~16 tests
**目标模块**: `src/utils/sliceAnsi.ts`91 行)
**导出**: `sliceAnsi()` — ANSI 感知的字符串切片
| 测试用例 | 验证点 |
|---------|--------|
| plain text slice | `"hello".slice(1,3)` 等价 |
| preserve ANSI codes | `\x1b[31mhello\x1b[0m` 切片后保留颜色 |
| close opened styles | 切片点在 ANSI 样式中间时正确关闭 |
| hyperlink handling | OSC 8 超链接不被切断 |
| combining marks (diacritics) | `é` = `e\u0301` 不被切开 |
| Devanagari matras | 零宽字符不被切断 |
| full-width characters | CJK 字符宽度 = 2 |
| empty slice | 返回空字符串 |
| full slice | 返回完整字符串 |
| boundary at ANSI code | 边界恰好在 escape 序列上 |
| nested ANSI styles | 多层嵌套时正确处理 |
| slice start > end | 空结果 |
**Mock**: `mock.module("@alcalzone/ansi-tokenize", ...)`, `mock.module("ink/stringWidth", ...)`
---
## 16.7 `src/utils/__tests__/treeify.test.ts`~15 tests
**目标模块**: `src/utils/treeify.ts`170 行)
**导出**: `treeify()` — 递归树渲染
| 测试用例 | 验证点 |
|---------|--------|
| simple flat tree | `{ a: {}, b: {} }` → 2 行 |
| nested tree | `{ a: { b: { c: {} } } }` → 3 行缩进 |
| array values | `[1, 2, 3]` 渲染为列表 |
| circular reference | 不无限递归 |
| empty object | `{}` 处理 |
| single key | 布局适配 |
| branch vs last-branch character | ├─ vs └─ |
| custom prefix | options 前缀传递 |
| deep nesting | 5+ 层缩进正确 |
| mixed object/array | 混合结构 |
**Mock**: `mock.module("figures", ...)`, color 模块 mock
---
## 16.8 `src/utils/__tests__/words.test.ts`~10 tests
**目标模块**: `src/utils/words.ts`800 行,大部分是词表数据)
**导出**: `generateWordSlug()`, `generateShortWordSlug()`
| 测试用例 | 验证点 |
|---------|--------|
| generateWordSlug format | `adjective-verb-noun` 三段式 |
| generateShortWordSlug format | `adjective-noun` 两段式 |
| all parts non-empty | 无空段 |
| hyphen separator | `-` 分隔 |
| all parts from word lists | 成分来自预定义词表 |
| multiple calls uniqueness | 连续调用不总是相同 |
| no consecutive hyphens | 无 `--` |
| lowercase only | 全小写 |
**Mock**: `mock.module("crypto", ...)` 控制 `randomBytes` 实现确定性测试

View File

@@ -0,0 +1,203 @@
# Phase 17 — Tool 子模块纯逻辑测试
> 创建日期2026-04-02
> 预计:+150 tests / 11 files
> 目标:覆盖 Tool 目录下有丰富纯逻辑但零测试的子模块
---
## 17.1 `src/tools/PowerShellTool/__tests__/powershellSecurity.test.ts`~25 tests
**目标模块**: `src/tools/PowerShellTool/powershellSecurity.ts`1091 行)
**安全关键** — 检测 ~20 种攻击向量。
| 测试分组 | 测试数 | 验证点 |
|---------|-------|--------|
| Invoke-Expression 检测 | 3 | `IEX`, `Invoke-Expression`, 变形 |
| Download cradle 检测 | 3 | `Net.WebClient`, `Invoke-WebRequest`, pipe |
| Privilege escalation | 3 | `Start-Process -Verb RunAs`, `runas.exe` |
| COM object | 2 | `New-Object -ComObject`, WScript.Shell |
| Scheduled tasks | 2 | `schtasks`, `Register-ScheduledTask` |
| WMI | 2 | `Invoke-WmiMethod`, `Get-WmiObject` |
| Module loading | 2 | `Import-Module` 从网络路径 |
| 安全命令通过 | 3 | `Get-Process`, `Get-ChildItem`, `Write-Host` |
| 混淆绕过尝试 | 3 | base64, 字符串拼接, 空格变形 |
| 组合命令 | 2 | `;` 分隔的多命令 |
**Mock**: 构造 `ParsedPowerShellCommand` 对象(不需要真实 AST
---
## 17.2 `src/tools/PowerShellTool/__tests__/commandSemantics.test.ts`~10 tests
**目标模块**: `src/tools/PowerShellTool/commandSemantics.ts`143 行)
| 测试用例 | 验证点 |
|---------|--------|
| grep exit 0/1/2 | 语义映射 |
| robocopy exit codes | Windows 特殊退出码 |
| findstr exit codes | Windows find 工具 |
| unknown command | 默认语义 |
| extractBaseCommand — basic | `grep "pattern" file``grep` |
| extractBaseCommand — path | `C:\tools\rg.exe``rg` |
| heuristicallyExtractBaseCommand | 模糊匹配 |
---
## 17.3 `src/tools/PowerShellTool/__tests__/destructiveCommandWarning.test.ts`~15 tests
**目标模块**: `src/tools/PowerShellTool/destructiveCommandWarning.ts`110 行)
| 测试用例 | 验证点 |
|---------|--------|
| Remove-Item -Recurse -Force | 危险 |
| Format-Volume | 危险 |
| git reset --hard | 危险 |
| DROP TABLE | 危险 |
| Remove-Item (no -Force) | 安全 |
| Get-ChildItem | 安全 |
| 管道组合 | `rm -rf` + pipe |
| 大小写混合 | `ReMoVe-ItEm` |
---
## 17.4 `src/tools/PowerShellTool/__tests__/gitSafety.test.ts`~12 tests
**目标模块**: `src/tools/PowerShellTool/gitSafety.ts`177 行)
| 测试用例 | 验证点 |
|---------|--------|
| normalizeGitPathArg — forward slash | 规范化 |
| normalizeGitPathArg — backslash | Windows 路径规范化 |
| normalizeGitPathArg — NTFS short name | `GITFI~1``.git` |
| isGitInternalPathPS — .git/config | true |
| isGitInternalPathPS — normal file | false |
| isDotGitPathPS — hidden git dir | true |
| isDotGitPathPS — .gitignore | false |
| bare repo attack | `.git` 路径遍历 |
---
## 17.5 `src/tools/LSPTool/__tests__/formatters.test.ts`~20 tests
**目标模块**: `src/tools/LSPTool/formatters.ts`593 行)
| 测试用例 | 验证点 |
|---------|--------|
| formatGoToDefinitionResult — single | 单个定义 |
| formatGoToDefinitionResult — multiple | 多个定义(分组) |
| formatFindReferencesResult | 引用列表 |
| formatHoverResult — markdown | markdown 内容 |
| formatHoverResult — plaintext | 纯文本 |
| formatDocumentSymbolResult — classes | 类符号 |
| formatDocumentSymbolResult — functions | 函数符号 |
| formatDocumentSymbolResult — nested | 嵌套符号 |
| formatWorkspaceSymbolResult | 工作区符号 |
| formatPrepareCallHierarchyResult | 调用层次 |
| formatIncomingCallsResult | 入调用 |
| formatOutgoingCallsResult | 出调用 |
| empty results | 各函数空结果 |
| groupByFile helper | 文件分组逻辑 |
---
## 17.6 `src/tools/GrepTool/__tests__/utils.test.ts`~10 tests
**目标模块**: `src/tools/GrepTool/GrepTool.ts`577 行)
| 测试用例 | 验证点 |
|---------|--------|
| applyHeadLimit — within limit | 不截断 |
| applyHeadLimit — exceeds limit | 正确截断 |
| applyHeadLimit — offset + limit | 分页逻辑 |
| applyHeadLimit — zero limit | 边界 |
| formatLimitInfo — basic | 格式化输出 |
**Mock**: `mock.module("src/utils/log.ts", ...)` 解锁导入
---
## 17.7 `src/tools/WebFetchTool/__tests__/utils.test.ts`~15 tests
**目标模块**: `src/tools/WebFetchTool/utils.ts`531 行)
| 测试用例 | 验证点 |
|---------|--------|
| validateURL — valid http | 通过 |
| validateURL — valid https | 通过 |
| validateURL — ftp | 拒绝 |
| validateURL — no protocol | 拒绝 |
| validateURL — localhost | 处理 |
| isPermittedRedirect — same host | 允许 |
| isPermittedRedirect — different host | 拒绝 |
| isPermittedRedirect — subdomain | 处理 |
| isRedirectInfo — valid object | true |
| isRedirectInfo — invalid | false |
---
## 17.8 `src/tools/WebFetchTool/__tests__/preapproved.test.ts`~10 tests
**目标模块**: `src/tools/WebFetchTool/preapproved.ts`167 行)
| 测试用例 | 验证点 |
|---------|--------|
| exact hostname match | 通过 |
| subdomain match | 处理 |
| path prefix match | `/docs/api` 匹配 |
| path non-match | `/internal` 不匹配 |
| unknown hostname | false |
| empty pathname | 边界 |
---
## 17.9 `src/tools/FileReadTool/__tests__/utils.test.ts`~15 tests
**目标模块**: `src/tools/FileReadTool/FileReadTool.ts`1184 行)
| 测试用例 | 验证点 |
|---------|--------|
| isBlockedDevicePath — /dev/sda | true |
| isBlockedDevicePath — /dev/null | 处理 |
| isBlockedDevicePath — normal file | false |
| detectSessionFileType — .jsonl | 会话文件类型 |
| detectSessionFileType — unknown | 未知类型 |
| formatFileLines — basic | 行号格式 |
| formatFileLines — empty | 空文件 |
---
## 17.10 `src/tools/AgentTool/__tests__/agentToolUtils.test.ts`~18 tests
**目标模块**: `src/tools/AgentTool/agentToolUtils.ts`688 行)
| 测试用例 | 验证点 |
|---------|--------|
| filterToolsForAgent — builtin only | 只返回内置工具 |
| filterToolsForAgent — exclude async | 排除异步工具 |
| filterToolsForAgent — permission mode | 权限过滤 |
| resolveAgentTools — wildcard | 通配符展开 |
| resolveAgentTools — explicit list | 显式列表 |
| countToolUses — multiple | 消息中工具调用计数 |
| countToolUses — zero | 无工具调用 |
| extractPartialResult — text only | 提取文本 |
| extractPartialResult — mixed | 混合内容 |
| getLastToolUseName — basic | 最后工具名 |
| getLastToolUseName — no tool use | 无工具调用 |
**Mock**: `mock.module("src/bootstrap/state.ts", ...)`, `mock.module("src/utils/log.ts", ...)`
---
## 17.11 `src/tools/LSPTool/__tests__/schemas.test.ts`~5 tests
**目标模块**: `src/tools/LSPTool/schemas.ts`216 行)
| 测试用例 | 验证点 |
|---------|--------|
| isValidLSPOperation — goToDefinition | true |
| isValidLSPOperation — findReferences | true |
| isValidLSPOperation — hover | true |
| isValidLSPOperation — invalid | false |
| isValidLSPOperation — empty string | false |

View File

@@ -0,0 +1,110 @@
# Phase 18 — WEAK 修复 + ACCEPTABLE 加固
> 创建日期2026-04-02
> 预计:+30 tests / 4 files (修改现有)
> 目标:修复所有 WEAK 评分测试文件,消除系统性问题
---
## 18.1 `src/utils/__tests__/format.test.ts` — 断言精确化(+5 tests
**问题**: `formatNumber`/`formatTokens`/`formatRelativeTime` 使用 `toContain`
**修复**: 改为 `toBe` 精确匹配
```diff
- expect(formatNumber(1500000)).toContain("1.5")
+ expect(formatNumber(1500000)).toBe("1.5m")
```
新增测试:
| 测试用例 | 验证点 |
|---------|--------|
| formatNumber — 0 | `"0"` |
| formatNumber — billions | `"1.5b"` |
| formatTokens — thousands | 精确匹配 |
| formatRelativeTime — hours ago | 精确匹配 |
| formatRelativeTime — days ago | 精确匹配 |
---
## 18.2 `src/utils/__tests__/envValidation.test.ts` — Bug 确认(+3 tests
**问题**: `value=1, lowerBound=100` 返回 `status: "valid"` — 函数名暗示有下界检查
**计划**: 先读取源码确认 `defaultValue``lowerBound` 的语义关系,然后:
- 如果是源码 bug → 在测试中注释标记,不修改源码
- 如果是设计意图 → 更新测试描述明确语义
新增测试:
| 测试用例 | 验证点 |
|---------|--------|
| parseFloat truncation | `"50.9"` → 50 |
| whitespace handling | `" 500 "` → 500 |
| very large number | overflow 处理 |
---
## 18.3 `src/utils/permissions/__tests__/PermissionMode.test.ts` — false 路径(+8 tests
**问题**: `isExternalPermissionMode` false 路径从未执行
**修复**: 覆盖所有 5 种 mode 的 true/false 期望
| 测试用例 | 验证点 |
|---------|--------|
| isExternalPermissionMode — plan | false |
| isExternalPermissionMode — auto | false |
| isExternalPermissionMode — default | false |
| permissionModeFromString — all modes | 5 种 mode 全覆盖 |
| permissionModeFromString — invalid | 默认值 |
| permissionModeFromString — case insensitive | 大小写 |
| isPermissionMode — valid strings | true |
| isPermissionMode — invalid strings | false |
---
## 18.4 `src/tools/shared/__tests__/gitOperationTracking.test.ts` — mock analytics+4 tests
**问题**: 未 mock analytics 依赖,测试产生副作用
**修复**: 添加 `mock.module("src/services/analytics/...", ...)`
新增测试:
| 测试用例 | 验证点 |
|---------|--------|
| parseGitCommitId — all GH PR actions | 补齐 6 个 action |
| detectGitOperation — no analytics call | mock 验证 |
| detectGitCommitId — various formats | SHA/短 SHA/HEAD |
| git operation tracking — edge cases | 空输入、畸形输入 |
---
## 排除清单
以下模块 **不纳入测试**,原因合理:
| 模块 | 行数 | 排除原因 |
|------|------|---------|
| `query.ts` | 1732 | 核心循环40+ 依赖,需完整集成环境 |
| `QueryEngine.ts` | 1320 | 编排器30+ 依赖 |
| `utils/hooks.ts` | 5121 | 51 exportsspawn 子进程 |
| `utils/config.ts` | 1817 | 文件系统 + lockfile + 全局状态 |
| `utils/auth.ts` | 2002 | 多 provider 认证,平台特定 |
| `utils/fileHistory.ts` | 1115 | 重 I/O 文件备份 |
| `utils/sessionRestore.ts` | 551 | 恢复状态涉及多个子系统 |
| `utils/ripgrep.ts` | 679 | spawn 子进程 |
| `utils/yaml.ts` | 15 | 两行 wrapper |
| `utils/lockfile.ts` | 43 | trivial wrapper |
| `screens/` / `components/` | — | Ink 渲染测试环境 |
| `bridge/` / `remote/` / `ssh/` | — | 网络层 |
| `daemon/` / `server/` | — | 进程管理 |
---
## 预期成果
| 指标 | Phase 16 后 | Phase 17 后 | Phase 18 后 |
|------|-----------|-----------|-----------|
| 测试数 | ~1417 | ~1567 | ~1597 |
| 文件数 | 76 | 87 | 91 |
| WEAK 文件 | 6 | 4 | **0** |

View File

@@ -0,0 +1,435 @@
# Phase 19 - Batch 1: 零依赖微型 utils
> 预计 ~154 tests / 13 文件 | 全部纯函数,无需 mock
---
## 1. `src/utils/__tests__/semanticBoolean.test.ts` (~8 tests)
**源文件**: `src/utils/semanticBoolean.ts` (30 行)
**依赖**: `zod/v4`
### 测试用例
```typescript
describe("semanticBoolean", () => {
// 基本 Zod 行为
test("parses boolean true to true")
test("parses boolean false to false")
test("parses string 'true' to true")
test("parses string 'false' to false")
// 边界
test("rejects string 'TRUE' (case-sensitive)")
test("rejects string 'FALSE' (case-sensitive)")
test("rejects number 1")
test("rejects null")
test("rejects undefined")
// 自定义 inner schema
test("works with custom inner schema (z.boolean().optional())")
})
```
### Mock 需求
---
## 2. `src/utils/__tests__/semanticNumber.test.ts` (~10 tests)
**源文件**: `src/utils/semanticNumber.ts` (37 行)
**依赖**: `zod/v4`
### 测试用例
```typescript
describe("semanticNumber", () => {
test("parses number 42")
test("parses number 0")
test("parses negative number -5")
test("parses float 3.14")
test("parses string '42' to 42")
test("parses string '-7.5' to -7.5")
test("rejects string 'abc'")
test("rejects empty string ''")
test("rejects null")
test("rejects boolean true")
test("works with custom inner schema (z.number().int().min(0))")
})
```
### Mock 需求
---
## 3. `src/utils/__tests__/lazySchema.test.ts` (~6 tests)
**源文件**: `src/utils/lazySchema.ts` (9 行)
**依赖**: 无
### 测试用例
```typescript
describe("lazySchema", () => {
test("returns a function")
test("calls factory on first invocation")
test("returns cached result on subsequent invocations")
test("factory is called only once (call count verification)")
test("works with different return types")
test("each call to lazySchema returns independent cache")
})
```
### Mock 需求
---
## 4. `src/utils/__tests__/withResolvers.test.ts` (~8 tests)
**源文件**: `src/utils/withResolvers.ts` (14 行)
**依赖**: 无
### 测试用例
```typescript
describe("withResolvers", () => {
test("returns object with promise, resolve, reject")
test("promise resolves when resolve is called")
test("promise rejects when reject is called")
test("resolve passes value through")
test("reject passes error through")
test("promise is instanceof Promise")
test("works with generic type parameter")
test("resolve/reject can be called asynchronously")
})
```
### Mock 需求
---
## 5. `src/utils/__tests__/userPromptKeywords.test.ts` (~12 tests)
**源文件**: `src/utils/userPromptKeywords.ts` (28 行)
**依赖**: 无
### 测试用例
```typescript
describe("matchesNegativeKeyword", () => {
test("matches 'wtf'")
test("matches 'shit'")
test("matches 'fucking broken'")
test("does not match normal input like 'fix the bug'")
test("is case-insensitive")
test("matches partial word in sentence")
})
describe("matchesKeepGoingKeyword", () => {
test("matches exact 'continue'")
test("matches 'keep going'")
test("matches 'go on'")
test("does not match 'cont'")
test("does not match empty string")
test("matches within larger sentence 'please continue'")
})
```
### Mock 需求
---
## 6. `src/utils/__tests__/xdg.test.ts` (~15 tests)
**源文件**: `src/utils/xdg.ts` (66 行)
**依赖**: 无(通过 options 参数注入)
### 测试用例
```typescript
describe("getXDGStateHome", () => {
test("returns ~/.local/state by default")
test("respects XDG_STATE_HOME env var")
test("uses custom homedir from options")
})
describe("getXDGCacheHome", () => {
test("returns ~/.cache by default")
test("respects XDG_CACHE_HOME env var")
})
describe("getXDGDataHome", () => {
test("returns ~/.local/share by default")
test("respects XDG_DATA_HOME env var")
})
describe("getUserBinDir", () => {
test("returns ~/.local/bin")
test("uses custom homedir from options")
})
describe("resolveOptions", () => {
test("defaults env to process.env")
test("defaults homedir to os.homedir()")
test("merges partial options")
})
describe("path construction", () => {
test("all paths end with correct subdirectory")
test("respects HOME env via homedir override")
})
```
### Mock 需求
无(通过 options.env 和 options.homedir 注入)
---
## 7. `src/utils/__tests__/horizontalScroll.test.ts` (~20 tests)
**源文件**: `src/utils/horizontalScroll.ts` (138 行)
**依赖**: 无
### 测试用例
```typescript
describe("calculateHorizontalScrollWindow", () => {
// 基本场景
test("all items fit within available width")
test("single item selected within view")
test("selected item at beginning")
test("selected item at end")
test("selected item beyond visible range scrolls right")
test("selected item before visible range scrolls left")
// 箭头指示器
test("showLeftArrow when items hidden on left")
test("showRightArrow when items hidden on right")
test("no arrows when all items visible")
test("both arrows when items hidden on both sides")
// 边界条件
test("empty itemWidths array")
test("single item")
test("available width is 0")
test("item wider than available width")
test("all items same width")
test("varying item widths")
test("firstItemHasSeparator adds separator width to first item")
test("selectedIdx in middle of overflow")
test("scroll snaps to show selected at left edge")
test("scroll snaps to show selected at right edge")
})
```
### Mock 需求
---
## 8. `src/utils/__tests__/generators.test.ts` (~18 tests)
**源文件**: `src/utils/generators.ts` (89 行)
**依赖**: 无
### 测试用例
```typescript
describe("lastX", () => {
test("returns last yielded value")
test("returns only value from single-yield generator")
test("throws on empty generator")
})
describe("returnValue", () => {
test("returns generator return value")
test("returns undefined for void return")
})
describe("toArray", () => {
test("collects all yielded values")
test("returns empty array for empty generator")
test("preserves order")
})
describe("fromArray", () => {
test("yields all array elements")
test("yields nothing for empty array")
})
describe("all", () => {
test("merges multiple generators preserving yield order")
test("respects concurrency cap")
test("handles empty generator array")
test("handles single generator")
test("handles generators of different lengths")
test("yields all values from all generators")
})
```
### Mock 需求
无(用 fromArray 构造测试数据)
---
## 9. `src/utils/__tests__/sequential.test.ts` (~12 tests)
**源文件**: `src/utils/sequential.ts` (57 行)
**依赖**: 无
### 测试用例
```typescript
describe("sequential", () => {
test("wraps async function, returns same result")
test("single call resolves normally")
test("concurrent calls execute sequentially (FIFO order)")
test("preserves arguments correctly")
test("error in first call does not block subsequent calls")
test("preserves rejection reason")
test("multiple args passed correctly")
test("returns different wrapper for each call to sequential")
test("handles rapid concurrent calls")
test("execution order matches call order")
test("works with functions returning different types")
test("wrapper has same arity expectations")
})
```
### Mock 需求
---
## 10. `src/utils/__tests__/fingerprint.test.ts` (~15 tests)
**源文件**: `src/utils/fingerprint.ts` (77 行)
**依赖**: `crypto` (内置)
### 测试用例
```typescript
describe("FINGERPRINT_SALT", () => {
test("has expected value '59cf53e54c78'")
})
describe("extractFirstMessageText", () => {
test("extracts text from first user message")
test("extracts text from single user message with array content")
test("returns empty string when no user messages")
test("skips assistant messages")
test("handles mixed content blocks (text + image)")
})
describe("computeFingerprint", () => {
test("returns deterministic 3-char hex string")
test("same input produces same fingerprint")
test("different message text produces different fingerprint")
test("different version produces different fingerprint")
test("handles short strings (length < 21)")
test("handles empty string")
test("fingerprint is valid hex")
})
describe("computeFingerprintFromMessages", () => {
test("end-to-end: messages -> fingerprint")
})
```
### Mock 需求
需要 `mock.module` 处理 `UserMessage`/`AssistantMessage` 类型依赖(查看实际 import 情况)
---
## 11. `src/utils/__tests__/configConstants.test.ts` (~8 tests)
**源文件**: `src/utils/configConstants.ts` (22 行)
**依赖**: 无
### 测试用例
```typescript
describe("NOTIFICATION_CHANNELS", () => {
test("contains expected channels")
test("is readonly array")
test("includes 'auto', 'iterm2', 'terminal_bell'")
})
describe("EDITOR_MODES", () => {
test("contains 'normal' and 'vim'")
test("has exactly 2 entries")
})
describe("TEAMMATE_MODES", () => {
test("contains 'auto', 'tmux', 'in-process'")
test("has exactly 3 entries")
})
```
### Mock 需求
---
## 12. `src/utils/__tests__/directMemberMessage.test.ts` (~12 tests)
**源文件**: `src/utils/directMemberMessage.ts` (70 行)
**依赖**: 仅类型(可 mock
### 测试用例
```typescript
describe("parseDirectMemberMessage", () => {
test("parses '@agent-name hello world'")
test("parses '@agent-name single-word'")
test("returns null for non-matching input")
test("returns null for empty string")
test("returns null for '@name' without message")
test("handles hyphenated agent names like '@my-agent msg'")
test("handles multiline message content")
test("extracts correct recipientName and message")
})
// sendDirectMemberMessage 需要 mock teamContext/writeToMailbox
describe("sendDirectMemberMessage", () => {
test("returns error when no team context")
test("returns error for unknown recipient")
test("calls writeToMailbox with correct args for valid recipient")
test("returns success for valid message")
})
```
### Mock 需求
`sendDirectMemberMessage` 需要 mock `AppState['teamContext']``WriteToMailboxFn`
---
## 13. `src/utils/__tests__/collapseHookSummaries.test.ts` (~12 tests)
**源文件**: `src/utils/collapseHookSummaries.ts` (60 行)
**依赖**: 仅类型
### 测试用例
```typescript
describe("collapseHookSummaries", () => {
test("returns same messages when no hook summaries")
test("collapses consecutive messages with same hookLabel")
test("does not collapse messages with different hookLabels")
test("aggregates hookCount across collapsed messages")
test("merges hookInfos arrays")
test("merges hookErrors arrays")
test("takes max totalDurationMs")
test("takes any truthy preventContinuation")
test("leaves single hook summary unchanged")
test("handles three consecutive same-label summaries")
test("preserves non-hook messages in between")
test("returns empty array for empty input")
})
```
### Mock 需求
需要构造 `RenderableMessage` mock 对象

View File

@@ -0,0 +1,287 @@
# Phase 19 - Batch 2: 更多 utils + state + commands
> 预计 ~120 tests / 8 文件 | 部分需轻量 mock
---
## 1. `src/utils/__tests__/collapseTeammateShutdowns.test.ts` (~10 tests)
**源文件**: `src/utils/collapseTeammateShutdowns.ts` (56 行)
**依赖**: 仅类型
### 测试用例
```typescript
describe("collapseTeammateShutdowns", () => {
test("returns same messages when no teammate shutdowns")
test("leaves single shutdown message unchanged")
test("collapses consecutive shutdown messages into batch")
test("batch attachment has correct count")
test("does not collapse non-consecutive shutdowns")
test("preserves non-shutdown messages between shutdowns")
test("handles empty array")
test("handles mixed message types")
test("collapses more than 2 consecutive shutdowns")
test("non-teammate task_status messages are not collapsed")
})
```
### Mock 需求
构造 `RenderableMessage` mock 对象(带 `task_status` attachment`status=completed``taskType=in_process_teammate`
---
## 2. `src/utils/__tests__/privacyLevel.test.ts` (~12 tests)
**源文件**: `src/utils/privacyLevel.ts` (56 行)
**依赖**: `process.env`
### 测试用例
```typescript
describe("getPrivacyLevel", () => {
test("returns 'default' when no env vars set")
test("returns 'essential-traffic' when CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set")
test("returns 'no-telemetry' when DISABLE_TELEMETRY is set")
test("'essential-traffic' takes priority over 'no-telemetry'")
})
describe("isEssentialTrafficOnly", () => {
test("returns true for 'essential-traffic' level")
test("returns false for 'default' level")
test("returns false for 'no-telemetry' level")
})
describe("isTelemetryDisabled", () => {
test("returns true for 'no-telemetry' level")
test("returns true for 'essential-traffic' level")
test("returns false for 'default' level")
})
describe("getEssentialTrafficOnlyReason", () => {
test("returns env var name when restricted")
test("returns null when unrestricted")
})
```
### Mock 需求
`process.env` 保存/恢复模式(参考现有 `envUtils.test.ts`
---
## 3. `src/utils/__tests__/textHighlighting.test.ts` (~18 tests)
**源文件**: `src/utils/textHighlighting.ts` (167 行)
**依赖**: `@alcalzone/ansi-tokenize`
### 测试用例
```typescript
describe("segmentTextByHighlights", () => {
// 基本
test("returns single segment with no highlights")
test("returns highlighted segment for single highlight")
test("returns two segments for highlight covering middle portion")
test("returns three segments for highlight in the middle")
// 多高亮
test("handles non-overlapping highlights")
test("handles overlapping highlights (priority-based)")
test("handles adjacent highlights")
// 边界
test("highlight starting at 0")
test("highlight ending at text length")
test("highlight covering entire text")
test("empty text with highlights")
test("empty highlights array returns single segment")
// ANSI 处理
test("correctly segments text with ANSI escape codes")
test("handles text with mixed ANSI and highlights")
// 属性
test("preserves highlight color property")
test("preserves highlight priority property")
test("preserves dimColor and inverse flags")
test("highlights with start > end are handled gracefully")
})
```
### Mock 需求
可能需要 mock `@alcalzone/ansi-tokenize`,或直接使用(如果有安装)
---
## 4. `src/utils/__tests__/detectRepository.test.ts` (~15 tests)
**源文件**: `src/utils/detectRepository.ts` (179 行)
**依赖**: git 命令(`getRemoteUrl`
### 重点测试函数
**`parseGitRemote(input: string): ParsedRepository | null`** — 纯正则解析
**`parseGitHubRepository(input: string): string | null`** — 纯函数
### 测试用例
```typescript
describe("parseGitRemote", () => {
// HTTPS
test("parses HTTPS URL: https://github.com/owner/repo.git")
test("parses HTTPS URL without .git suffix")
test("parses HTTPS URL with subdirectory path (only takes first 2 segments)")
// SSH
test("parses SSH URL: git@github.com:owner/repo.git")
test("parses SSH URL without .git suffix")
// ssh://
test("parses ssh:// URL: ssh://git@github.com/owner/repo.git")
// git://
test("parses git:// URL")
// 边界
test("returns null for invalid URL")
test("returns null for empty string")
test("handles GHE hostname")
test("handles port number in URL")
})
describe("parseGitHubRepository", () => {
test("extracts 'owner/repo' from valid remote URL")
test("handles plain 'owner/repo' string input")
test("returns null for non-GitHub host (if restricted)")
test("returns null for invalid input")
test("is case-sensitive for owner/repo")
})
```
### Mock 需求
仅测试 `parseGitRemote``parseGitHubRepository`(纯函数),不需要 mock git
---
## 5. `src/utils/__tests__/markdown.test.ts` (~20 tests)
**源文件**: `src/utils/markdown.ts` (382 行)
**依赖**: `marked`, `cli-highlight`, theme types
### 重点测试函数
**`padAligned(content, displayWidth, targetWidth, align)`** — 纯函数
### 测试用例
```typescript
describe("padAligned", () => {
test("left-aligns: pads with spaces on right")
test("right-aligns: pads with spaces on left")
test("center-aligns: pads with spaces on both sides")
test("no padding when displayWidth equals targetWidth")
test("handles content wider than targetWidth")
test("null/undefined align defaults to left")
test("handles empty string content")
test("handles zero displayWidth")
test("handles zero targetWidth")
test("center alignment with odd padding distribution")
})
```
注意:`numberToLetter`/`numberToRoman`/`getListNumber` 是私有函数,除非从模块导出否则无法直接测试。如果确实私有,则通过 `applyMarkdown` 间接测试列表渲染:
```typescript
describe("list numbering (via applyMarkdown)", () => {
test("numbered list renders with digits")
test("nested ordered list uses letters (a, b, c)")
test("deep nested list uses roman numerals")
test("unordered list uses bullet markers")
})
```
### Mock 需求
`padAligned` 无需 mock。`applyMarkdown` 可能需要 mock theme 依赖。
---
## 6. `src/state/__tests__/store.test.ts` (~15 tests)
**源文件**: `src/state/store.ts` (35 行)
**依赖**: 无
### 测试用例
```typescript
describe("createStore", () => {
test("returns object with getState, setState, subscribe")
test("getState returns initial state")
test("setState updates state via updater function")
test("setState does not notify when state unchanged (Object.is)")
test("setState notifies subscribers on change")
test("subscribe returns unsubscribe function")
test("unsubscribe stops notifications")
test("multiple subscribers all get notified")
test("onChange callback is called on state change")
test("onChange is not called when state unchanged")
test("works with complex state objects")
test("works with primitive state")
test("updater receives previous state")
test("sequential setState calls produce final state")
test("subscriber called after all state changes in synchronous batch")
})
```
### Mock 需求
---
## 7. `src/commands/plugin/__tests__/parseArgs.test.ts` (~18 tests)
**源文件**: `src/commands/plugin/parseArgs.ts` (104 行)
**依赖**: 无
### 测试用例
```typescript
describe("parsePluginArgs", () => {
// 无参数
test("returns { type: 'menu' } for undefined")
test("returns { type: 'menu' } for empty string")
test("returns { type: 'menu' } for whitespace only")
// help
test("returns { type: 'help' } for 'help'")
// install
test("parses 'install my-plugin' -> { type: 'install', name: 'my-plugin' }")
test("parses 'install my-plugin@github' with marketplace")
test("parses 'install https://github.com/...' as URL marketplace")
// uninstall
test("returns { type: 'uninstall', name: '...' }")
// enable/disable
test("returns { type: 'enable', name: '...' }")
test("returns { type: 'disable', name: '...' }")
// validate
test("returns { type: 'validate', name: '...' }")
// manage
test("returns { type: 'manage' }")
// marketplace 子命令
test("parses 'marketplace add ...'")
test("parses 'marketplace remove ...'")
test("parses 'marketplace list'")
// 边界
test("handles extra whitespace")
test("handles unknown subcommand gracefully")
})
```
### Mock 需求

View File

@@ -0,0 +1,258 @@
# Phase 19 - Batch 3: Tool 子模块纯逻辑
> 预计 ~113 tests / 6 文件 | 采用 `mock.module()` + `await import()` 模式
---
## 1. `src/tools/GrepTool/__tests__/headLimit.test.ts` (~20 tests)
**源文件**: `src/tools/GrepTool/GrepTool.ts` (578 行)
**目标函数**: `applyHeadLimit<T>`, `formatLimitInfo` (非导出,需确认可测性)
### 测试策略
如果函数是文件内导出的,直接 `await import()` 获取。如果私有,则通过 GrepTool 的输出间接测试,或提取到独立文件。
### 测试用例
```typescript
describe("applyHeadLimit", () => {
test("returns full array when limit is undefined (default 250)")
test("applies limit correctly: limits to N items")
test("limit=0 means no limit (returns all)")
test("applies offset correctly")
test("offset + limit combined")
test("offset beyond array length returns empty")
test("returns appliedLimit when truncation occurred")
test("returns appliedLimit=undefined when no truncation")
test("limit larger than array returns all items with appliedLimit=undefined")
test("empty array returns empty with appliedLimit=undefined")
test("offset=0 is default")
test("negative limit behavior")
})
describe("formatLimitInfo", () => {
test("formats 'limit: N, offset: M' when both present")
test("formats 'limit: N' when only limit")
test("formats 'offset: M' when only offset")
test("returns empty string when both undefined")
test("handles limit=0 (no limit, should not appear)")
})
```
### Mock 需求
需 mock 重依赖链(`log`, `slowOperations` 等),通过 `mock.module()` + `await import()` 只取目标函数
---
## 2. `src/tools/MCPTool/__tests__/classifyForCollapse.test.ts` (~25 tests)
**源文件**: `src/tools/MCPTool/classifyForCollapse.ts` (605 行)
**目标函数**: `classifyMcpToolForCollapse`, `normalize`
### 测试用例
```typescript
describe("normalize", () => {
test("leaves snake_case unchanged: 'search_issues'")
test("converts camelCase to snake_case: 'searchIssues' -> 'search_issues'")
test("converts kebab-case to snake_case: 'search-issues' -> 'search_issues'")
test("handles mixed: 'searchIssuesByStatus' -> 'search_issues_by_status'")
test("handles already lowercase single word")
test("handles empty string")
test("handles PascalCase: 'SearchIssues' -> 'search_issues'")
})
describe("classifyMcpToolForCollapse", () => {
// 搜索工具
test("classifies Slack search_messages as search")
test("classifies GitHub search_code as search")
test("classifies Linear search_issues as search")
test("classifies Datadog search_logs as search")
test("classifies Notion search as search")
// 读取工具
test("classifies Slack get_message as read")
test("classifies GitHub get_file_contents as read")
test("classifies Linear get_issue as read")
test("classifies Filesystem read_file as read")
// 双重分类
test("some tools are both search and read")
test("some tools are neither search nor read")
// 未知工具
test("unknown tool returns { isSearch: false, isRead: false }")
test("tool name with camelCase variant still matches")
test("tool name with kebab-case variant still matches")
// server name 不影响分类
test("server name parameter is accepted but unused in current logic")
// 边界
test("empty tool name returns false/false")
test("case sensitivity check (should match after normalize)")
test("handles tool names with numbers")
})
```
### Mock 需求
文件自包含(仅内部 Set + normalize 函数),需确认 `normalize` 是否导出
---
## 3. `src/tools/FileReadTool/__tests__/blockedPaths.test.ts` (~18 tests)
**源文件**: `src/tools/FileReadTool/FileReadTool.ts` (1184 行)
**目标函数**: `isBlockedDevicePath`, `getAlternateScreenshotPath`
### 测试用例
```typescript
describe("isBlockedDevicePath", () => {
// 阻止的设备
test("blocks /dev/zero")
test("blocks /dev/random")
test("blocks /dev/urandom")
test("blocks /dev/full")
test("blocks /dev/stdin")
test("blocks /dev/tty")
test("blocks /dev/console")
test("blocks /dev/stdout")
test("blocks /dev/stderr")
test("blocks /dev/fd/0")
test("blocks /dev/fd/1")
test("blocks /dev/fd/2")
// 阻止 /proc
test("blocks /proc/self/fd/0")
test("blocks /proc/123/fd/2")
// 允许的路径
test("allows /dev/null")
test("allows regular file paths")
test("allows /home/user/file.txt")
})
describe("getAlternateScreenshotPath", () => {
test("returns undefined for path without AM/PM")
test("returns alternate path for macOS screenshot with regular space before AM")
test("returns alternate path for macOS screenshot with U+202F before PM")
test("handles path without time component")
test("handles multiple AM/PM occurrences")
test("returns undefined when no space variant difference")
})
```
### Mock 需求
需 mock 重依赖链,通过 `await import()` 获取函数
---
## 4. `src/tools/AgentTool/__tests__/agentDisplay.test.ts` (~15 tests)
**源文件**: `src/tools/AgentTool/agentDisplay.ts` (105 行)
**目标函数**: `resolveAgentOverrides`, `compareAgentsByName`
### 测试用例
```typescript
describe("resolveAgentOverrides", () => {
test("marks no overrides when all agents active")
test("marks inactive agent as overridden")
test("overriddenBy shows the overriding agent source")
test("deduplicates agents by (agentType, source)")
test("preserves agent definition properties")
test("handles empty arrays")
test("handles agent from git worktree (duplicate detection)")
})
describe("compareAgentsByName", () => {
test("sorts alphabetically ascending")
test("returns negative when a.name < b.name")
test("returns positive when a.name > b.name")
test("returns 0 for same name")
test("is case-sensitive")
})
describe("AGENT_SOURCE_GROUPS", () => {
test("contains expected source groups in order")
test("has unique labels")
})
```
### Mock 需求
需 mock `AgentDefinition`, `AgentSource` 类型依赖
---
## 5. `src/tools/AgentTool/__tests__/agentToolUtils.test.ts` (~20 tests)
**源文件**: `src/tools/AgentTool/agentToolUtils.ts` (688 行)
**目标函数**: `countToolUses`, `getLastToolUseName`, `extractPartialResult`
### 测试用例
```typescript
describe("countToolUses", () => {
test("counts tool_use blocks in messages")
test("returns 0 for messages without tool_use")
test("returns 0 for empty array")
test("counts multiple tool_use blocks across messages")
test("counts tool_use in single message with multiple blocks")
})
describe("getLastToolUseName", () => {
test("returns last tool name from assistant message")
test("returns undefined for message without tool_use")
test("returns the last tool when multiple tool_uses present")
test("handles message with non-array content")
})
describe("extractPartialResult", () => {
test("extracts text from last assistant message")
test("returns undefined for messages without assistant content")
test("handles interrupted agent with partial text")
test("returns undefined for empty messages")
test("concatenates multiple text blocks")
test("skips non-text content blocks")
})
```
### Mock 需求
需 mock 消息类型依赖
---
## 6. `src/tools/SkillTool/__tests__/skillSafety.test.ts` (~15 tests)
**源文件**: `src/tools/SkillTool/SkillTool.ts` (1110 行)
**目标函数**: `skillHasOnlySafeProperties`, `extractUrlScheme`
### 测试用例
```typescript
describe("skillHasOnlySafeProperties", () => {
test("returns true for command with only safe properties")
test("returns true for command with undefined extra properties")
test("returns false for command with unsafe meaningful property")
test("returns true for command with null extra properties")
test("returns true for command with empty array extra property")
test("returns true for command with empty object extra property")
test("returns false for command with non-empty unsafe array")
test("returns false for command with non-empty unsafe object")
test("returns true for empty command object")
})
describe("extractUrlScheme", () => {
test("extracts 'gs' from 'gs://bucket/path'")
test("extracts 'https' from 'https://example.com'")
test("extracts 'http' from 'http://example.com'")
test("extracts 's3' from 's3://bucket/path'")
test("defaults to 'gs' for unknown scheme")
test("defaults to 'gs' for path without scheme")
test("defaults to 'gs' for empty string")
})
```
### Mock 需求
需 mock 重依赖链,`await import()` 获取函数

View File

@@ -0,0 +1,215 @@
# Phase 19 - Batch 4: Services 纯逻辑
> 预计 ~84 tests / 5 文件 | 部分需轻量 mock
---
## 1. `src/services/compact/__tests__/grouping.test.ts` (~15 tests)
**源文件**: `src/services/compact/grouping.ts` (64 行)
**目标函数**: `groupMessagesByApiRound`
### 测试用例
```typescript
describe("groupMessagesByApiRound", () => {
test("returns single group for single API round")
test("splits at new assistant message ID")
test("keeps tool_result messages with their parent assistant message")
test("handles streaming chunks (same assistant ID stays grouped)")
test("returns empty array for empty input")
test("handles all user messages (no assistant)")
test("handles alternating assistant IDs")
test("three API rounds produce three groups")
test("user messages before first assistant go in first group")
test("consecutive user messages stay in same group")
test("does not produce empty groups")
test("handles single message")
test("preserves message order within groups")
test("handles system messages")
test("tool_result after assistant stays in same round")
})
```
### Mock 需求
需构造 `Message` mock 对象type: 'user'/'assistant', message: { id, content }
---
## 2. `src/services/compact/__tests__/stripMessages.test.ts` (~20 tests)
**源文件**: `src/services/compact/compact.ts` (1709 行)
**目标函数**: `stripImagesFromMessages`, `collectReadToolFilePaths` (私有)
### 测试用例
```typescript
describe("stripImagesFromMessages", () => {
// user 消息处理
test("replaces image block with [image] text")
test("replaces document block with [document] text")
test("preserves text blocks unchanged")
test("handles multiple image/document blocks in single message")
test("returns original message when no media blocks")
// tool_result 内嵌套
test("replaces image inside tool_result content")
test("replaces document inside tool_result content")
test("preserves non-media tool_result content")
// 非用户消息
test("passes through assistant messages unchanged")
test("passes through system messages unchanged")
// 边界
test("handles empty message array")
test("handles string content (non-array) in user message")
test("does not mutate original messages")
})
describe("collectReadToolFilePaths", () => {
// 注意:这是私有函数,可能需要通过 stripImagesFromMessages 或其他导出间接测试
// 如果不可直接测试,则跳过或通过集成测试覆盖
test("collects file_path from Read tool_use blocks")
test("skips tool_use with FILE_UNCHANGED_STUB result")
test("returns empty set for messages without Read tool_use")
test("handles multiple Read calls across messages")
test("normalizes paths via expandPath")
})
```
### Mock 需求
需 mock `expandPath`(如果 collectReadToolFilePaths 要测)
需 mock `log`, `slowOperations` 等重依赖
构造 `Message` mock 对象
---
## 3. `src/services/compact/__tests__/prompt.test.ts` (~12 tests)
**源文件**: `src/services/compact/prompt.ts` (375 行)
**目标函数**: `formatCompactSummary`
### 测试用例
```typescript
describe("formatCompactSummary", () => {
test("strips <analysis>...</analysis> block")
test("replaces <summary>...</summary> with 'Summary:\\n' prefix")
test("handles analysis + summary together")
test("handles summary without analysis")
test("handles analysis without summary")
test("collapses multiple newlines to double")
test("trims leading/trailing whitespace")
test("handles empty string")
test("handles plain text without tags")
test("handles multiline analysis content")
test("preserves content between analysis and summary")
test("handles nested-like tags gracefully")
})
```
### Mock 需求
需 mock 重依赖链(`log`, feature flags 等)
`formatCompactSummary` 是纯字符串处理,如果 import 链不太重则无需复杂 mock
---
## 4. `src/services/mcp/__tests__/channelPermissions.test.ts` (~25 tests)
**源文件**: `src/services/mcp/channelPermissions.ts` (241 行)
**目标函数**: `hashToId`, `shortRequestId`, `truncateForPreview`, `filterPermissionRelayClients`
### 测试用例
```typescript
describe("hashToId", () => {
test("returns 5-char string")
test("uses only letters a-z excluding 'l'")
test("is deterministic (same input = same output)")
test("different inputs produce different outputs (with high probability)")
test("handles empty string")
})
describe("shortRequestId", () => {
test("returns 5-char string from tool use ID")
test("is deterministic")
test("avoids profanity substrings (retries with salt)")
test("returns a valid ID even if all retries hit bad words (unlikely)")
})
describe("truncateForPreview", () => {
test("returns JSON string for object input")
test("truncates to <=200 chars when input is long")
test("adds ellipsis or truncation indicator")
test("returns short input unchanged")
test("handles string input")
test("handles null/undefined input")
})
describe("filterPermissionRelayClients", () => {
test("keeps connected clients in allowlist with correct capabilities")
test("filters out disconnected clients")
test("filters out clients not in allowlist")
test("filters out clients missing required capabilities")
test("returns empty array for empty input")
test("type predicate narrows correctly")
})
describe("PERMISSION_REPLY_RE", () => {
test("matches 'y abcde'")
test("matches 'yes abcde'")
test("matches 'n abcde'")
test("matches 'no abcde'")
test("is case-insensitive")
test("does not match without ID")
})
```
### Mock 需求
`hashToId` 可能需要确认导出状态
`filterPermissionRelayClients` 需要 mock 客户端类型
`truncateForPreview` 可能依赖 `jsonStringify`(需 mock `slowOperations`
---
## 5. `src/services/mcp/__tests__/officialRegistry.test.ts` (~12 tests)
**源文件**: `src/services/mcp/officialRegistry.ts` (73 行)
**目标函数**: `normalizeUrl` (私有), `isOfficialMcpUrl`, `resetOfficialMcpUrlsForTesting`
### 测试用例
```typescript
describe("normalizeUrl", () => {
// 注意:如果是私有的,通过 isOfficialMcpUrl 间接测试
test("removes trailing slash")
test("removes query parameters")
test("preserves path")
test("handles URL with port")
test("handles URL with hash fragment")
})
describe("isOfficialMcpUrl", () => {
test("returns false when registry not loaded (initial state)")
test("returns true for URL added to registry")
test("returns false for non-registered URL")
test("uses normalized URL for comparison")
})
describe("resetOfficialMcpUrlsForTesting", () => {
test("clears the cached URLs")
test("allows fresh start after reset")
})
describe("URL normalization + lookup integration", () => {
test("URL with trailing slash matches normalized version")
test("URL with query params matches normalized version")
test("different URLs do not match")
test("case sensitivity check")
})
```
### Mock 需求
需 mock `axios`(避免网络请求)
使用 `resetOfficialMcpUrlsForTesting` 做测试隔离

View File

@@ -0,0 +1,200 @@
# Phase 19 - Batch 5: MCP 配置 + modelCost
> 预计 ~80 tests / 4 文件 | 需中等 mock
---
## 1. `src/services/mcp/__tests__/configUtils.test.ts` (~30 tests)
**源文件**: `src/services/mcp/config.ts` (1580 行)
**目标函数**: `unwrapCcrProxyUrl`, `urlPatternToRegex` (私有), `commandArraysMatch` (私有), `toggleMembership` (私有), `addScopeToServers` (私有), `dedupPluginMcpServers`, `getMcpServerSignature` (如导出)
### 测试策略
私有函数如不可直接测试,通过公开的 `dedupPluginMcpServers` 间接覆盖。导出函数直接测。
### 测试用例
```typescript
describe("unwrapCcrProxyUrl", () => {
test("returns original URL when no CCR proxy markers")
test("extracts mcp_url from CCR proxy URL with /v2/session_ingress/shttp/mcp/")
test("extracts mcp_url from CCR proxy URL with /v2/ccr-sessions/")
test("returns original URL when mcp_url param is missing")
test("handles malformed URL gracefully")
test("handles URL with both proxy marker and mcp_url")
test("preserves non-CCR URLs unchanged")
})
describe("dedupPluginMcpServers", () => {
test("keeps unique plugin servers")
test("suppresses plugin server duplicated by manual config")
test("suppresses plugin server duplicated by earlier plugin")
test("keeps servers with null signature")
test("returns empty for empty inputs")
test("reports suppressed with correct duplicateOf name")
test("handles multiple plugins with same config")
})
describe("toggleMembership (via integration)", () => {
test("adds item when shouldContain=true and not present")
test("removes item when shouldContain=false and present")
test("returns same array when already in desired state")
})
describe("addScopeToServers (via integration)", () => {
test("adds scope to each server config")
test("returns empty object for undefined input")
test("returns empty object for empty input")
test("preserves all original config properties")
})
describe("urlPatternToRegex (via integration)", () => {
test("matches exact URL")
test("matches wildcard pattern *.example.com")
test("matches multiple wildcards")
test("does not match non-matching URL")
test("escapes regex special characters in pattern")
})
describe("commandArraysMatch (via integration)", () => {
test("returns true for identical arrays")
test("returns false for different lengths")
test("returns false for same length different elements")
test("returns true for empty arrays")
})
```
### Mock 需求
需 mock `feature()` (bun:bundle), `jsonStringify`, `safeParseJSON`, `log`
通过 `mock.module()` + `await import()` 解锁
---
## 2. `src/services/mcp/__tests__/filterUtils.test.ts` (~20 tests)
**源文件**: `src/services/mcp/utils.ts` (576 行)
**目标函数**: `filterToolsByServer`, `hashMcpConfig`, `isToolFromMcpServer`, `isMcpTool`, `parseHeaders`
### 测试用例
```typescript
describe("filterToolsByServer", () => {
test("filters tools matching server name prefix")
test("returns empty for no matching tools")
test("handles empty tools array")
test("normalizes server name for matching")
})
describe("hashMcpConfig", () => {
test("returns 16-char hex string")
test("is deterministic")
test("excludes scope from hash")
test("different configs produce different hashes")
test("key order does not affect hash (sorted)")
})
describe("isToolFromMcpServer", () => {
test("returns true when tool belongs to specified server")
test("returns false for different server")
test("returns false for non-MCP tool name")
test("handles empty tool name")
})
describe("isMcpTool", () => {
test("returns true for tool name starting with 'mcp__'")
test("returns true when tool.isMcp is true")
test("returns false for regular tool")
test("returns false when neither condition met")
})
describe("parseHeaders", () => {
test("parses 'Key: Value' format")
test("parses multiple headers")
test("trims whitespace around key and value")
test("throws on missing colon")
test("throws on empty key")
test("handles value with colons (like URLs)")
test("returns empty object for empty array")
test("handles duplicate keys (last wins)")
})
```
### Mock 需求
需 mock `normalizeNameForMCP`, `mcpInfoFromString`, `jsonStringify`, `createHash`
`parseHeaders` 是最独立的,可能不需要太多 mock
---
## 3. `src/services/mcp/__tests__/channelNotification.test.ts` (~15 tests)
**源文件**: `src/services/mcp/channelNotification.ts` (317 行)
**目标函数**: `wrapChannelMessage`, `findChannelEntry`
### 测试用例
```typescript
describe("wrapChannelMessage", () => {
test("wraps content in <channel> tag with source attribute")
test("escapes server name in attribute")
test("includes meta attributes when provided")
test("escapes meta values via escapeXmlAttr")
test("filters out meta keys not matching SAFE_META_KEY pattern")
test("handles empty meta")
test("handles content with special characters")
test("formats with newlines between tags and content")
})
describe("findChannelEntry", () => {
test("finds server entry by exact name match")
test("finds plugin entry by matching second segment")
test("returns undefined for no match")
test("handles empty channels array")
test("handles server name without colon")
test("handles 'plugin:name' format correctly")
test("prefers exact match over partial match")
})
```
### Mock 需求
需 mock `escapeXmlAttr`(来自 xml.ts已有测试或直接使用
`CHANNEL_TAG` 常量需确认导出
---
## 4. `src/utils/__tests__/modelCost.test.ts` (~15 tests)
**源文件**: `src/utils/modelCost.ts` (232 行)
**目标函数**: `formatModelPricing`, `COST_TIER_*` 常量
### 测试用例
```typescript
describe("COST_TIER constants", () => {
test("COST_TIER_3_15 has inputTokens=3, outputTokens=15")
test("COST_TIER_15_75 has inputTokens=15, outputTokens=75")
test("COST_TIER_5_25 has inputTokens=5, outputTokens=25")
test("COST_TIER_30_150 has inputTokens=30, outputTokens=150")
test("COST_HAIKU_35 has inputTokens=0.8, outputTokens=4")
test("COST_HAIKU_45 has inputTokens=1, outputTokens=5")
})
describe("formatModelPricing", () => {
test("formats integer prices without decimals: '$3/$15 per Mtok'")
test("formats float prices with 2 decimals: '$0.80/$4.00 per Mtok'")
test("formats mixed: '$5/$25 per Mtok'")
test("formats large prices: '$30/$150 per Mtok'")
test("formats $1/$5 correctly (integer but small)")
test("handles zero prices: '$0/$0 per Mtok'")
})
describe("MODEL_COSTS", () => {
test("maps known model names to cost tiers")
test("contains entries for claude-sonnet-4-6")
test("contains entries for claude-opus-4-6")
test("contains entries for claude-haiku-4-5")
})
```
### Mock 需求
需 mock `log`, `slowOperations` 等重依赖modelCost.ts 通常 import 链较重)
`formatModelPricing``COST_TIER_*` 是纯数据/纯函数mock 成功后直接测

View File

@@ -1,455 +1,296 @@
# Testing Specification
本文档定义 claude-code 项目的测试规范,作为编写和维护测试代码的统一标准
本文档定义 claude-code 项目的测试规范、当前覆盖状态和改进计划
## 1. 测试目标
## 1. 技术栈
| 目标 | 说明 |
|------|------|
| **防止回归** | 确保已有功能不被新改动破坏,每次 PR 必须通过全部测试 |
| **验证核心流程** | 覆盖 CLI 核心交互流程Tool 调用链、Context 构建、消息处理 |
| **文档化行为** | 通过测试用例记录各模块的预期行为,作为活文档供开发者参考 |
| | 选型 |
|----|------|
| 测试框架 | `bun:test` |
| 断言/Mock | `bun:test` 内置 |
| 覆盖率 | `bun test --coverage` |
| CI | GitHub Actionspush/PR 到 main 自动运行 |
## 2. 技术栈
| 项 | 选型 | 说明 |
|----|------|------|
| 测试框架 | `bun:test` | Bun 内置,零配置,与运行时一致 |
| 断言库 | `bun:test` 内置 `expect` | 兼容 Jest `expect` API |
| Mock | `bun:test` 内置 `mock`/`spyOn` | 配合手动 mock fixtures |
| 覆盖率 | `bun test --coverage` | 内置覆盖率报告 |
## 3. 测试层次
## 2. 测试层次
本项目采用 **单元测试 + 集成测试** 两层结构,不做 E2E 或快照测试。
### 3.1 单元测试
- **单元测试** — 纯函数、工具类、解析器。文件就近放置于 `src/**/__tests__/`
- **集成测试** — 多模块协作流程。集中于 `tests/integration/`
- **对象**:纯函数、工具类、解析器、独立模块
- **特征**:无外部依赖、执行快、可并行
- **示例场景**
- `src/utils/array.ts` — 数组操作函数
- `src/utils/path.ts` — 路径解析
- `src/utils/diff.ts` — diff 算法
- `src/utils/permissions/` — 权限判断逻辑
- `src/utils/model/` — 模型选择与 provider 路由
- Tool 的 `inputSchema` 校验逻辑
### 3.2 集成测试
- **对象**:多模块协作流程
- **特征**:可能需要 mock 外部服务API、文件系统测试模块间协作
- **示例场景**
- Tool 调用链:`tools.ts` 注册 → `findToolByName` → tool `call()` 执行
- Context 构建:`context.ts` 组装系统提示CLAUDE.md 加载 + git status + 日期)
- 消息处理管线:用户输入 → 消息格式化 → API 请求构建
## 4. 文件结构
采用 **混合模式**:单元测试就近放置,集成测试集中管理。
## 3. 文件结构与命名
```
src/
├── utils/
│ ├── array.ts
│ ├── __tests__/ # 单元测试:就近放置
│ │ ├── array.test.ts
│ │ ├── set.test.ts
│ │ └── path.test.ts
│ ├── model/
│ │ ├── providers.ts
│ │ └── __tests__/
│ │ └── providers.test.ts
│ └── permissions/
│ ├── index.ts
│ └── __tests__/
│ └── permissions.test.ts
├── tools/
│ ├── BashTool/
│ │ ├── index.ts
│ │ └── __tests__/
│ │ └── BashTool.test.ts
│ └── FileEditTool/
│ ├── index.ts
│ └── __tests__/
│ └── FileEditTool.test.ts
tests/ # 集成测试:集中管理
├── integration/
│ ├── tool-chain.test.ts
│ ├── context-build.test.ts
│ └── message-pipeline.test.ts
├── mocks/ # 通用 mock / fixtures
│ ├── api-responses.ts # Claude API mock 响应
│ ├── file-system.ts # 文件系统 mock 工具
│ └── fixtures/
│ ├── sample-claudemd.md
│ └── sample-messages.json
└── helpers/ # 测试辅助函数
└── setup.ts
├── utils/__tests__/ # 纯函数单元测试
├── tools/<Tool>/__tests__/ # Tool 单元测试
├── services/mcp/__tests__/ # MCP 单元测试
├── utils/permissions/__tests__/
├── utils/model/__tests__/
├── utils/settings/__tests__/
├── utils/shell/__tests__/
├── utils/git/__tests__/
└── __tests__/ # 顶层模块测试 (Tool.ts, tools.ts)
tests/
├── integration/ # 集成测试(尚未创建)
├── mocks/ # 共享 mock/fixture尚未创建
└── helpers/ # 测试辅助函数
```
### 命名规则
- 测试文件:`<module>.test.ts`
- 命名风格:`describe("functionName")` + `test("行为描述")`,英文
- 编写原则Arrange-Act-Assert、单一职责、独立性、边界覆盖
| 项 | 规则 |
|----|------|
| 测试文件 | `<module-name>.test.ts` |
| 测试目录 | `__tests__/`(单元)、`tests/integration/`(集成) |
| Fixture 文件 | `tests/mocks/fixtures/` 下按用途命名 |
| Helper 文件 | `tests/helpers/` 下按功能命名 |
## 4. 当前覆盖状态
## 5. 命名与编写规范
> 更新日期2026-04-02 | **1623 tests, 84 files, 0 fail, 851ms**
### 5.1 命名风格
### 4.1 可靠度评分
使用 `describe` + `it`/`test` 英文描述
每个测试文件按断言深度、边界覆盖、mock 质量、测试独立性综合评定
```typescript
import { describe, expect, test } from "bun:test";
describe("findToolByName", () => {
test("returns the tool when name matches exactly", () => {
// ...
});
test("returns undefined when no tool matches", () => {
// ...
});
test("is case-insensitive for tool name lookup", () => {
// ...
});
});
```
### 5.2 describe 块组织原则
- 顶层 `describe` 对应被测函数/类/模块名
- 可嵌套 `describe` 对分支场景分组(如 `describe("when input is empty", ...)`)
- 每个 `test` 应测试一个行为,命名采用 **"动作 + 预期结果"** 格式
### 5.3 编写原则
| 原则 | 说明 |
| 等级 | 含义 |
|------|------|
| **Arrange-Act-Assert** | 每个测试分三段:准备数据、执行操作、验证结果 |
| **单一职责** | 一个 `test` 只验证一个行为 |
| **独立性** | 测试之间无顺序依赖,无共享可变状态 |
| **可读性优先** | 测试代码是文档,宁可重复也不过度抽象 |
| **边界覆盖** | 空值、边界值、异常输入必须覆盖 |
| **GOOD** | 断言精确exact match边界充分结构清晰 |
| **ACCEPTABLE** | 正常路径覆盖完整,部分边界或断言可加强 |
| **WEAK** | 存在明显缺陷:断言过弱、重要边界缺失、或有脆弱性风险 |
### 5.4 异步测试
### 4.2 按模块分布
```typescript
test("reads file content correctly", async () => {
const content = await readFile("/tmp/test.txt");
expect(content).toContain("expected");
});
```
#### P0 — 核心模块
## 6. Mock 策略
| 文件 | Tests | 评分 | 覆盖范围 | 主要不足 |
|------|-------|------|----------|----------|
| `src/__tests__/Tool.test.ts` | 20 | GOOD | buildTool, toolMatchesName, findToolByName, filterToolProgressMessages | — |
| `src/__tests__/tools.test.ts` | 9 | ACCEPTABLE | parseToolPreset, filterToolsByDenyRules | 预设覆盖仅测 "default";有冗余用例 |
| `src/tools/FileEditTool/__tests__/utils.test.ts` | 22 | ACCEPTABLE | normalizeQuotes, applyEditToFile, preserveQuoteStyle | `findActualString` 断言过弱(`not.toBeNull``preserveQuoteStyle` 仅 2 用例 |
| `src/tools/shared/__tests__/gitOperationTracking.test.ts` | 20 | ACCEPTABLE | parseGitCommitId, detectGitOperation | 6 个 GH PR action 全覆盖;缺 `trackGitOperations` 测试(需 mock analytics |
| `src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts` | 21 | ACCEPTABLE | git/rm/SQL/k8s/terraform 危险模式 | safe commands 4 断言合一;缺少 `rm -rf /``DROP DATABASE`、管道命令 |
| `src/tools/BashTool/__tests__/commandSemantics.test.ts` | 10 | ACCEPTABLE | grep/diff/test/rg/find 退出码语义 | mock `splitCommand_DEPRECATED` 与实现可能分歧;覆盖可更全面 |
采用 **混合管理**:通用 mock 集中于 `tests/mocks/`,专用 mock 就近定义。
**Utils 纯函数19 文件):**
### 6.1 Claude API Mock集中管理
| 文件 | Tests | 评分 | 覆盖范围 | 主要不足 |
|------|-------|------|----------|----------|
| `utils/__tests__/array.test.ts` | 12 | GOOD | intersperse, count, uniq | — |
| `utils/__tests__/set.test.ts` | 11 | GOOD | difference, intersects, every, union | — |
| `utils/__tests__/xml.test.ts` | 9 | GOOD | escapeXml, escapeXmlAttr | 缺 null/undefined 输入测试 |
| `utils/__tests__/hash.test.ts` | 12 | ACCEPTABLE | djb2Hash, hashContent, hashPair | `hashContent`/`hashPair` 无已知答案断言(仅测确定性) |
| `utils/__tests__/stringUtils.test.ts` | 30 | GOOD | 10 个函数全覆盖,含 Unicode 边界 | — |
| `utils/__tests__/semver.test.ts` | 16 | ACCEPTABLE | gt/gte/lt/lte/satisfies/order | 缺 pre-release、tilde range、畸形版本串 |
| `utils/__tests__/uuid.test.ts` | 6 | ACCEPTABLE | validateUuid | 大写测试仅 `not.toBeNull`,未验证标准化输出 |
| `utils/__tests__/format.test.ts` | 27 | GOOD | formatFileSize, formatDuration, formatNumber, formatTokens, formatRelativeTime | 全部 `toBe` 精确匹配,含 billions/weeks/days 边界 |
| `utils/__tests__/frontmatterParser.test.ts` | 22 | GOOD | parseFrontmatter, splitPathInFrontmatter, parsePositiveIntFromFrontmatter | — |
| `utils/__tests__/file.test.ts` | 13 | ACCEPTABLE | convertLeadingTabsToSpaces, addLineNumbers, stripLineNumberPrefix | `addLineNumbers``toContain`;缺 Windows 路径分隔符测试 |
| `utils/__tests__/glob.test.ts` | 6 | ACCEPTABLE | extractGlobBaseDirectory | 缺绝对路径、根 `/`、Windows 路径 |
| `utils/__tests__/diff.test.ts` | 8 | ACCEPTABLE | adjustHunkLineNumbers, getPatchFromContents | `getPatchFromContents` 仅检查结构,未验证 diff 内容正确性 |
| `utils/__tests__/json.test.ts` | 15 | GOOD | safeParseJSON, parseJSONL, addItemToJSONCArray | — |
| `utils/__tests__/truncate.test.ts` | 18 | ACCEPTABLE | truncateToWidth, wrapText, truncatePathMiddle | **缺 CJK/emoji/wide-char 测试**(这是宽度感知实现的核心场景) |
| `utils/__tests__/path.test.ts` | 15 | ACCEPTABLE | containsPathTraversal, normalizePathForConfigKey | 仅覆盖 2/5+ 导出函数 |
| `utils/__tests__/tokens.test.ts` | 18 | GOOD | getTokenCountFromUsage, doesMostRecentAssistantMessageExceed200k 等 | — |
| `utils/__tests__/stream.test.ts` | 15 | GOOD | Stream\<T\> enqueue/read/drain/next/done/error/for-await | — |
| `utils/__tests__/abortController.test.ts` | 13 | GOOD | createAbortController/createChildAbortController 父子传播 | — |
| `utils/__tests__/bufferedWriter.test.ts` | 10 | GOOD | createBufferedWriter 立即/缓冲/flush/overflow | — |
| `utils/__tests__/gitDiff.test.ts` | 25 | GOOD | parseGitNumstat/parseGitDiff/parseShortstat 纯解析 | — |
| `utils/__tests__/sliceAnsi.test.ts` | 13 | GOOD | sliceAnsi ANSI 感知切片 + undoAnsiCodes | — |
| `utils/__tests__/treeify.test.ts` | 13 | ACCEPTABLE | treeify 扁平/嵌套/循环引用 | 缺深度嵌套性能测试 |
| `utils/__tests__/words.test.ts` | 11 | GOOD | slug 格式 (adjective-verb-noun)、唯一性 | — |
所有 API 测试全部使用 mock不调用真实 API。
**Context 构建3 文件):**
```typescript
// tests/mocks/api-responses.ts
export const mockStreamResponse = {
type: "message_start",
message: {
id: "msg_mock_001",
type: "message",
role: "assistant",
content: [],
model: "claude-sonnet-4-20250514",
// ...
},
};
| 文件 | Tests | 评分 | 覆盖范围 | 主要不足 |
|------|-------|------|----------|----------|
| `utils/__tests__/claudemd.test.ts` | 14 | ACCEPTABLE | stripHtmlComments, isMemoryFilePath, getLargeMemoryFiles | **仅测 3 个辅助函数**,核心发现/加载/`@include` 指令/memoization 未覆盖 |
| `utils/__tests__/systemPrompt.test.ts` | 8 | GOOD | buildEffectiveSystemPrompt | — |
| `__tests__/history.test.ts` | 26 | GOOD | parseReferences/expandPastedTextRefs/formatPastedTextRef 等 5 个函数 | — |
export const mockToolUseResponse = {
type: "content_block_start",
content_block: {
type: "tool_use",
id: "toolu_mock_001",
name: "Read",
input: { file_path: "/tmp/test.txt" },
},
};
```
#### P1 — 重要模块
### 6.2 模块级 Mock就近定义
| 文件 | Tests | 评分 | 覆盖范围 | 主要不足 |
|------|-------|------|----------|----------|
| `permissions/__tests__/permissionRuleParser.test.ts` | 16 | GOOD | escape/unescape 规则roundtrip 完整性 | — |
| `permissions/__tests__/permissions.test.ts` | 12 | ACCEPTABLE | getDenyRuleForTool, getAskRuleForTool, filterDeniedAgents | `as any` cast缺 MCP tool deny 测试 |
| `permissions/__tests__/shellRuleMatching.test.ts` | 19 | GOOD | 通配符、转义、正则特殊字符 | — |
| `permissions/__tests__/PermissionMode.test.ts` | 22 | ACCEPTABLE | permissionModeFromString, isExternalPermissionMode 等 | isExternalPermissionMode ant false 路径已覆盖;缺 `bubble` 模式独立测试 |
| `permissions/__tests__/dangerousPatterns.test.ts` | 7 | WEAK | CROSS_PLATFORM_CODE_EXEC, DANGEROUS_BASH_PATTERNS | 纯数据 smoke test无行为测试不验证数组无重复 |
| `model/__tests__/aliases.test.ts` | 15 | ACCEPTABLE | isModelAlias, isModelFamilyAlias | 缺 null/undefined/空串输入 |
| `model/__tests__/model.test.ts` | 13 | ACCEPTABLE | firstPartyNameToCanonical | 缺空串、非标准日期后缀 |
| `model/__tests__/providers.test.ts` | 9 | ACCEPTABLE | getAPIProvider, isFirstPartyAnthropicBaseUrl | `originalEnv` 声明未使用env 恢复不完整 |
| `utils/__tests__/messages.test.ts` | 36 | GOOD | createAssistantMessage, createUserMessage, extractTag 等 16 个 describe | `normalizeMessages` 仅检查长度未验证内容 |
```typescript
import { mock } from "bun:test";
**Tool 子模块8 文件):**
// mock 整个模块
mock.module("src/services/api/claude.ts", () => ({
createApiClient: () => ({
stream: mock(() => mockStreamResponse),
}),
}));
```
| 文件 | Tests | 评分 | 覆盖范围 | 主要不足 |
|------|-------|------|----------|----------|
| `tools/PowerShellTool/__tests__/powershellSecurity.test.ts` | 24 | GOOD | AST 安全检测Invoke-Expression/iex/encoded/dynamic/download/COM | — |
| `tools/PowerShellTool/__tests__/commandSemantics.test.ts` | 21 | GOOD | grep/rg/findstr/robocopy 退出码、pipeline last-segment | — |
| `tools/PowerShellTool/__tests__/destructiveCommandWarning.test.ts` | 38 | GOOD | Remove-Item/Format-Volume/Clear-Disk/git/SQL/COMPUTER/alias 全覆盖 | — |
| `tools/PowerShellTool/__tests__/gitSafety.test.ts` | 29 | GOOD | .git 路径检测/NTFS 短名/反斜杠/引号/反引号转义 | — |
| `tools/LSPTool/__tests__/formatters.test.ts` | 18 | GOOD | 全部 8 个 format 函数 null/empty/valid 输入 | — |
| `tools/LSPTool/__tests__/schemas.test.ts` | 13 | GOOD | isValidLSPOperation 类型守卫 9 种操作 + 无效/空/大小写 | — |
| `tools/WebFetchTool/__tests__/preapproved.test.ts` | 18 | GOOD | isPreapprovedHost 精确/路径作用域/子路径/大小写/子域名 | — |
| `tools/WebFetchTool/__tests__/urlValidation.test.ts` | 18 | GOOD | validateURL/isPermittedRedirect 本地重实现(避免重依赖链) | — |
### 6.3 文件系统 Mock
#### P2 — 补充模块
对于需要文件系统交互的测试,使用临时目录:
| 文件 | Tests | 评分 | 覆盖范围 | 主要不足 |
|------|-------|------|----------|----------|
| `utils/__tests__/cron.test.ts` | 31 | GOOD | parseCronExpression, computeNextCronRun, cronToHuman | 缺月边界、闰年 |
| `utils/__tests__/git.test.ts` | 15 | ACCEPTABLE | normalizeGitRemoteUrl (SSH/HTTPS/ssh://) | 缺 git://、file://、端口号 |
| `settings/__tests__/config.test.ts` | 38 | GOOD | SettingsSchema, type guards, validateSettingsFileContent, formatZodError | 缺 DeniedMcpServerEntrySchema |
```typescript
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll } from "bun:test";
#### P3-P6 — 扩展覆盖27 文件)
let tempDir: string;
| 文件 | Tests | 评分 | 备注 |
|------|-------|------|------|
| `utils/__tests__/errors.test.ts` | 33 | GOOD | — |
| `utils/__tests__/envUtils.test.ts` | 33 | GOOD | env 保存/恢复规范 |
| `utils/__tests__/effort.test.ts` | 30 | GOOD | 5 个 mock 模块,边界完整 |
| `utils/__tests__/argumentSubstitution.test.ts` | 22 | ACCEPTABLE | 缺转义引号、越界索引 |
| `utils/__tests__/sanitization.test.ts` | 14 | ACCEPTABLE | — |
| `utils/__tests__/sleep.test.ts` | 14 | GOOD | 时间相关测试margin 充足 |
| `utils/__tests__/CircularBuffer.test.ts` | 11 | ACCEPTABLE | 缺 capacity=1、空 buffer getRecent |
| `utils/__tests__/memoize.test.ts` | 18 | GOOD | 缓存 hit/stale/LRU 全覆盖 |
| `utils/__tests__/tokenBudget.test.ts` | 21 | GOOD | — |
| `utils/__tests__/displayTags.test.ts` | 17 | GOOD | — |
| `utils/__tests__/taggedId.test.ts` | 10 | GOOD | — |
| `utils/__tests__/controlMessageCompat.test.ts` | 15 | GOOD | — |
| `utils/__tests__/gitConfigParser.test.ts` | 21 | GOOD | — |
| `utils/__tests__/windowsPaths.test.ts` | 19 | GOOD | 双向 round-trip 测试 |
| `utils/__tests__/envExpansion.test.ts` | 15 | GOOD | — |
| `utils/__tests__/formatBriefTimestamp.test.ts` | 10 | GOOD | 固定 now 时间戳,确定性 |
| `utils/__tests__/notebook.test.ts` | 9 | ACCEPTABLE | 合并断言偏弱 |
| `utils/__tests__/hyperlink.test.ts` | 10 | ACCEPTABLE | 空串测试行为注释混乱 |
| `utils/__tests__/zodToJsonSchema.test.ts` | 9 | WEAK | **object 属性仅 `toBeDefined` 未验证类型**optional 字段未验证 absence |
| `utils/__tests__/objectGroupBy.test.ts` | 5 | ACCEPTABLE | 极简,缺 undefined key 测试 |
| `utils/__tests__/contentArray.test.ts` | 6 | ACCEPTABLE | 缺混合 tool_result+text 交替 |
| `utils/__tests__/slashCommandParsing.test.ts` | 8 | GOOD | — |
| `utils/__tests__/groupToolUses.test.ts` | 10 | GOOD | — |
| `utils/__tests__/shell/__tests__/outputLimits.test.ts` | 7 | ACCEPTABLE | — |
| `utils/__tests__/envValidation.test.ts` | 12 | GOOD | validateBoundedIntEnvVar | value=1 无下界确认为设计意图(函数仅校验 >0 和 <=upperLimit |
| `utils/git/__tests__/gitConfigParser.test.ts` | 20 | GOOD | — |
| `services/mcp/__tests__/mcpStringUtils.test.ts` | 16 | GOOD | — |
| `services/mcp/__tests__/normalization.test.ts` | 10 | GOOD | — |
beforeAll(async () => {
tempDir = await mkdtemp(join(tmpdir(), "claude-test-"));
});
### 4.3 评分汇总
afterAll(async () => {
await rm(tempDir, { recursive: true });
});
```
| 等级 | 文件数 | 占比 |
|------|--------|------|
| **GOOD** | 46 | 55% |
| **ACCEPTABLE** | 32 | 38% |
| **WEAK** | 6 | 7% |
## 7. 优先测试模块
## 5. 系统性问题
按优先级从高到低排列,括号内为目标覆盖率:
### 5.1 断言过弱Smell: `toContain` 代替精确匹配)
### P0 — 核心(行覆盖率 >= 80%
以下文件的部分测试使用 `toContain``not.toBeNull` 检查结果,当实现返回包含目标子串的任何字符串时测试仍通过,无法检测格式错误:
| 模块 | 路径 | 测试重点 |
|------|------|----------|
| **Tool 系统** | `src/tools/`, `src/Tool.ts`, `src/tools.ts` | tool 注册/发现、inputSchema 校验、call() 执行与错误处理 |
| **工具函数** | `src/utils/` 下纯函数 | 各种 utility 的正确性与边界情况 |
| **Context 构建** | `src/context.ts`, `src/utils/claudemd.ts` | 系统提示拼装、CLAUDE.md 发现与加载、context 内容完整性 |
| 文件 | 受影响函数 | 建议 |
|------|-----------|------|
| `file.test.ts` | addLineNumbers | 断言完整输出格式 |
| `diff.test.ts` | getPatchFromContents | 验证 hunk 内容正确性 |
| `notebook.test.ts` | mapNotebookCellsToToolResult | 验证合并后内容 |
| `uuid.test.ts` | validateUuid (uppercase) | 断言标准化后的精确值 |
### P1 — 重要(行覆盖率 >= 60%
### 5.2 集成测试空白
| 模块 | 路径 | 测试重点 |
|------|------|----------|
| **权限系统** | `src/utils/permissions/` | 权限模式判断、tool 许可/拒绝逻辑 |
| **模型路由** | `src/utils/model/` | provider 选择、模型名映射、fallback 逻辑 |
| **消息处理** | `src/types/message.ts`, `src/utils/messages.ts` | 消息类型构造、格式化、过滤 |
| **CLI 参数** | `src/main.tsx` 中的 Commander 配置 | 参数解析、模式切换REPL/pipe |
Spec 定义的三个集成测试均未创建:
### P2 — 补充
| 模块 | 路径 | 测试重点 |
|------|------|----------|
| **Cron 调度** | `src/utils/cron*.ts` | cron 表达式解析、任务调度逻辑 |
| **Git 工具** | `src/utils/git.ts` | git 命令构造、输出解析 |
| **Config** | `src/utils/config.ts`, `src/utils/settings/` | 配置加载、合并、默认值 |
## 8. 覆盖率要求
| 范围 | 目标 | 说明 |
| 计划 | 状态 | 依赖 |
|------|------|------|
| P0 核心模块 | **>= 80%** 行覆盖率 | Tool 系统、工具函数、Context 构建 |
| P1 重要模块 | **>= 60%** 行覆盖率 | 权限、模型路由、消息处理 |
| 整体 | 不设强制指标 | 逐步提升,不追求数字 |
| `tests/integration/tool-chain.test.ts` | 未创建 | 需 mock tools.ts 完整注册链 |
| `tests/integration/context-build.test.ts` | 未创建 | 需 mock context.ts 重依赖链 |
| `tests/integration/message-pipeline.test.ts` | 未创建 | 需 mock API 层 |
运行覆盖率报告:
`tests/mocks/` 目录也不存在,无共享 mock/fixture 基础设施。
```bash
bun test --coverage
```
### 5.3 Mock 相关
## 9. CI 集成
| 问题 | 影响文件 | 说明 |
|------|----------|------|
| 未 mock 重依赖 | `gitOperationTracking.test.ts` | `trackGitOperations` 调用 analytics/bootstrap测试仅覆盖 `detectGitOperation`(无副作用) |
| env 恢复不完整 | `providers.test.ts` | 仅删除已知 key新增 env var 会导致测试泄漏 |
已有 GitHub Actions 配置(`.github/workflows/ci.yml``bun test` 步骤已就位。
### 5.4 潜在 Bug
### CI 中测试的运行条件
- **push** 到 `main``feature/*` 分支时自动运行
- **pull_request** 到 `main` 分支时自动运行
- 测试失败将阻止合并
### 本地运行
```bash
# 运行全部测试
bun test
# 运行特定文件
bun test src/utils/__tests__/array.test.ts
# 运行匹配模式
bun test --filter "findToolByName"
# 带覆盖率
bun test --coverage
# watch 模式(开发时)
bun test --watch
```
## 10. 编写测试 Checklist
每次新增或修改测试时,确认以下事项:
- [ ] 测试文件位置正确(单元 → `__tests__/`,集成 → `tests/integration/`
- [ ] 命名遵循 `describe` + `test` 英文格式
- [ ] 每个 test 只验证一个行为
- [ ] 覆盖了正常路径、边界情况和错误情况
- [ ] 无硬编码的绝对路径或系统特定值
- [ ] Mock 使用得当(通用 → `tests/mocks/`,专用 → 就近)
- [ ] 测试可独立运行,无顺序依赖
- [ ] `bun test` 本地全部通过后再提交
## 11. 当前测试覆盖状态
> 更新日期2026-04-02 | 总计:**1177 tests, 64 files, 0 failures**
### P0 — 核心模块
| 测试计划 | 测试文件 | 测试数 | 覆盖范围 |
|----------|----------|--------|----------|
| 01 - Tool 系统 | `src/__tests__/Tool.test.ts` | 25 | buildTool, toolMatchesName, findToolByName, getEmptyToolPermissionContext, filterToolProgressMessages |
| | `src/__tests__/tools.test.ts` | 10 | parseToolPreset, filterToolsByDenyRules |
| | `src/tools/shared/__tests__/gitOperationTracking.test.ts` | 16 | parseGitCommitId, detectGitOperation |
| | `src/tools/FileEditTool/__tests__/utils.test.ts` | 24 | normalizeQuotes, stripTrailingWhitespace, findActualString, preserveQuoteStyle, applyEditToFile |
| 02 - Utils 纯函数 | `src/utils/__tests__/array.test.ts` | 12 | intersperse, count, uniq |
| | `src/utils/__tests__/set.test.ts` | 12 | difference, intersects, every, union |
| | `src/utils/__tests__/xml.test.ts` | 9 | escapeXml, escapeXmlAttr |
| | `src/utils/__tests__/hash.test.ts` | 12 | djb2Hash, hashContent, hashPair |
| | `src/utils/__tests__/stringUtils.test.ts` | 35 | escapeRegExp, capitalize, plural, firstLineOf, countCharInString, normalizeFullWidthDigits/Space, safeJoinLines, EndTruncatingAccumulator, truncateToLines |
| | `src/utils/__tests__/semver.test.ts` | 21 | gt, gte, lt, lte, satisfies, order |
| | `src/utils/__tests__/uuid.test.ts` | 6 | validateUuid |
| | `src/utils/__tests__/format.test.ts` | 24 | formatFileSize, formatSecondsShort, formatDuration, formatNumber, formatTokens, formatRelativeTime |
| | `src/utils/__tests__/frontmatterParser.test.ts` | 28 | parseFrontmatter, splitPathInFrontmatter, parsePositiveIntFromFrontmatter, parseBooleanFrontmatter, parseShellFrontmatter |
| | `src/utils/__tests__/file.test.ts` | 17 | convertLeadingTabsToSpaces, addLineNumbers, stripLineNumberPrefix, normalizePathForComparison, pathsEqual |
| | `src/utils/__tests__/glob.test.ts` | 6 | extractGlobBaseDirectory |
| | `src/utils/__tests__/diff.test.ts` | 8 | adjustHunkLineNumbers, getPatchFromContents |
| | `src/utils/__tests__/json.test.ts` | 27 | safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray (mock log.ts) |
| | `src/utils/__tests__/truncate.test.ts` | 24 | truncateToWidth, truncateStartToWidth, truncateToWidthNoEllipsis, truncatePathMiddle, truncate, wrapText |
| | `src/utils/__tests__/path.test.ts` | 15 | containsPathTraversal, normalizePathForConfigKey |
| | `src/utils/__tests__/tokens.test.ts` | 22 | getTokenCountFromUsage, getTokenUsage, tokenCountFromLastAPIResponse, messageTokenCountFromLastAPIResponse, getCurrentUsage, doesMostRecentAssistantMessageExceed200k, getAssistantMessageContentLength (mock log.ts, tokenEstimation, slowOperations) |
| 03 - Context 构建 | `src/utils/__tests__/claudemd.test.ts` | 16 | stripHtmlComments, isMemoryFilePath, getLargeMemoryFiles |
| | `src/utils/__tests__/systemPrompt.test.ts` | 9 | buildEffectiveSystemPrompt |
### P1 — 重要模块
| 测试计划 | 测试文件 | 测试数 | 覆盖范围 |
|----------|----------|--------|----------|
| 04 - 权限系统 | `src/utils/permissions/__tests__/permissionRuleParser.test.ts` | 25 | escapeRuleContent, unescapeRuleContent, permissionRuleValueFromString, permissionRuleValueToString, normalizeLegacyToolName |
| | `src/utils/permissions/__tests__/permissions.test.ts` | 13 | getDenyRuleForTool, getAskRuleForTool, getDenyRuleForAgent, filterDeniedAgents (mock log.ts, slowOperations) |
| 05 - 模型路由 | `src/utils/model/__tests__/aliases.test.ts` | 16 | isModelAlias, isModelFamilyAlias |
| | `src/utils/model/__tests__/model.test.ts` | 14 | firstPartyNameToCanonical |
| | `src/utils/model/__tests__/providers.test.ts` | 10 | getAPIProvider, isFirstPartyAnthropicBaseUrl |
| 06 - 消息处理 | `src/utils/__tests__/messages.test.ts` | 56 | createAssistantMessage, createUserMessage, isSyntheticMessage, getLastAssistantMessage, hasToolCallsInLastAssistantTurn, extractTag, isNotEmptyMessage, normalizeMessages, deriveUUID, isClassifierDenial 等 |
### P2 — 补充模块
| 测试计划 | 测试文件 | 测试数 | 覆盖范围 |
|----------|----------|--------|----------|
| 07 - Cron 调度 | `src/utils/__tests__/cron.test.ts` | 38 | parseCronExpression, computeNextCronRun, cronToHuman |
| 08 - Git 工具 | `src/utils/__tests__/git.test.ts` | 18 | normalizeGitRemoteUrl (SSH/HTTPS/ssh:///代理URL/大小写规范化) |
| 09 - 配置与设置 | `src/utils/settings/__tests__/config.test.ts` | 62 | SettingsSchema, PermissionsSchema, AllowedMcpServerEntrySchema, MCP 类型守卫, 设置常量函数, filterInvalidPermissionRules, validateSettingsFileContent, formatZodError |
### P3 — Phase 1 纯函数扩展
| 测试文件 | 测试数 | 覆盖范围 |
|----------|--------|----------|
| `src/utils/__tests__/errors.test.ts` | 28 | ClaudeError, AbortError, ConfigParseError, ShellError, TelemetrySafeError, isAbortError, hasExactErrorMessage, toError, errorMessage, getErrnoCode, isENOENT, getErrnoPath, shortErrorStack, isFsInaccessible, classifyAxiosError |
| `src/utils/permissions/__tests__/shellRuleMatching.test.ts` | 22 | permissionRuleExtractPrefix, hasWildcards, matchWildcardPattern, parsePermissionRule, suggestionForExactCommand, suggestionForPrefix |
| `src/utils/__tests__/argumentSubstitution.test.ts` | 18 | parseArguments, parseArgumentNames, generateProgressiveArgumentHint, substituteArguments |
| `src/utils/__tests__/CircularBuffer.test.ts` | 12 | CircularBuffer class: add, addAll, getRecent, toArray, clear, length |
| `src/utils/__tests__/sanitization.test.ts` | 14 | partiallySanitizeUnicode, recursivelySanitizeUnicode |
| `src/utils/__tests__/slashCommandParsing.test.ts` | 8 | parseSlashCommand |
| `src/utils/__tests__/contentArray.test.ts` | 6 | insertBlockAfterToolResults |
| `src/utils/__tests__/objectGroupBy.test.ts` | 5 | objectGroupBy |
### P4 — Phase 2 轻 Mock 扩展
| 测试文件 | 测试数 | 覆盖范围 |
|----------|--------|----------|
| `src/utils/__tests__/envUtils.test.ts` | 34 | isEnvTruthy, isEnvDefinedFalsy, parseEnvVars, hasNodeOption, getAWSRegion, getDefaultVertexRegion, getVertexRegionForModel, isBareMode, shouldMaintainProjectWorkingDir, getClaudeConfigHomeDir |
| `src/utils/__tests__/sleep.test.ts` | 14 | sleep (abort, throwOnAbort, abortError), withTimeout, sequential |
| `src/utils/__tests__/memoize.test.ts` | 16 | memoizeWithTTL, memoizeWithTTLAsync (dedup/cache/clear), memoizeWithLRU (eviction/cache methods) |
| `src/utils/__tests__/groupToolUses.test.ts` | 10 | applyGrouping (verbose, grouping, result collection, mixed messages) |
| `src/utils/permissions/__tests__/dangerousPatterns.test.ts` | 7 | CROSS_PLATFORM_CODE_EXEC, DANGEROUS_BASH_PATTERNS 常量验证 |
| `src/utils/shell/__tests__/outputLimits.test.ts` | 7 | getMaxOutputLength, BASH_MAX_OUTPUT_UPPER_LIMIT, BASH_MAX_OUTPUT_DEFAULT |
### P5 — Phase 3 补全 + Phase 4 工具模块
| 测试文件 | 测试数 | 覆盖范围 |
|----------|--------|----------|
| `src/utils/__tests__/zodToJsonSchema.test.ts` | 9 | zodToJsonSchema (string/number/object/enum/optional/array/boolean + caching) |
| `src/utils/permissions/__tests__/PermissionMode.test.ts` | 19 | PERMISSION_MODES, permissionModeFromString, permissionModeTitle, permissionModeShortTitle, permissionModeSymbol, getModeColor, isDefaultMode, toExternalPermissionMode, isExternalPermissionMode |
| `src/utils/__tests__/envValidation.test.ts` | 9 | validateBoundedIntEnvVar (default/valid/capped/invalid/boundary) |
| `src/services/mcp/__tests__/mcpStringUtils.test.ts` | 18 | mcpInfoFromString, getMcpPrefix, buildMcpToolName, getMcpDisplayName, getToolNameForPermissionCheck, extractMcpToolDisplayName |
| `src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts` | 22 | getDestructiveCommandWarning (git/rm/database/infrastructure patterns) |
| `src/tools/BashTool/__tests__/commandSemantics.test.ts` | 11 | interpretCommandResult (grep/diff/test/rg/find exit code semantics) |
### P6 — Phase 5 扩展覆盖
| 测试文件 | 测试数 | 覆盖范围 |
|----------|--------|----------|
| `src/utils/__tests__/tokenBudget.test.ts` | 20 | parseTokenBudget, findTokenBudgetPositions, getBudgetContinuationMessage |
| `src/utils/__tests__/displayTags.test.ts` | 17 | stripDisplayTags, stripDisplayTagsAllowEmpty, stripIdeContextTags |
| `src/utils/__tests__/taggedId.test.ts` | 10 | toTaggedId (prefix/uniqueness/format) |
| `src/utils/__tests__/controlMessageCompat.test.ts` | 15 | normalizeControlMessageKeys (snake_case→camelCase 转换) |
| `src/services/mcp/__tests__/normalization.test.ts` | 11 | normalizeNameForMCP (特殊字符/截断/空字符串/Unicode) |
| `src/services/mcp/__tests__/envExpansion.test.ts` | 14 | expandEnvVarsInString ($VAR/${VAR}/嵌套/未定义/转义) |
| `src/utils/git/__tests__/gitConfigParser.test.ts` | 20 | parseConfigString (key=value/section/subsection/多行/注释/引号) |
| `src/utils/__tests__/formatBriefTimestamp.test.ts` | 10 | formatBriefTimestamp (秒/分/时/天/周/月/年) |
| `src/utils/__tests__/hyperlink.test.ts` | 10 | createHyperlink (OSC 8 序列/file:///path/fallback) |
| `src/utils/__tests__/windowsPaths.test.ts` | 20 | windowsPathToPosixPath, posixPathToWindowsPath (驱动器/UNC/相对路径) |
| `src/utils/__tests__/notebook.test.ts` | 14 | parseCellId, mapNotebookCellsToToolResult (code/markdown/output) |
| `src/utils/__tests__/effort.test.ts` | 38 | isEffortLevel, parseEffortValue, isValidNumericEffort, convertEffortValueToLevel, getEffortLevelDescription, resolvePickerEffortPersistence |
### 已知限制
以下模块因 Bun 运行时限制或极重依赖链,暂时无法或不适合测试:
| 模块 | 问题 | 说明 |
| 文件 | 函数 | 问题 |
|------|------|------|
| `Bun.JSONL.parseChunk` | 处理畸形行时无限挂起 | Bun 1.3.10 bug错误恢复循环卡死已跳过 parseJSONL 畸形行测试 |
| `src/tools.ts` 部分函数 | `getAllBaseTools`/`getTools` 加载全量 tool | 导入链过重mock 难度大 |
| `src/tools/shared/spawnMultiAgent.ts` | 依赖 bootstrap/state + AppState + 50+ 模块 | mock 成本极高,投入产出比低 |
| `src/utils/messages.ts` 部分函数 | `withMemoryCorrectionHint` 等 | 依赖 `getFeatureValue_CACHED_MAY_BE_STALE` |
| ~~`envValidation.test.ts`~~ | ~~validateBoundedIntEnvVar~~ | ~~value=1 无下界检查~~**已确认**:函数仅校验 `parsed > 0``parsed <= upperLimit`,不强制 `parsed >= defaultValue`,为设计意图 |
### Mock 策略总结
### 5.5 已知限制
通过 `mock.module()` + `await import()` 模式成功解锁了以下重依赖模块的测试:
| 模块 | 问题 |
|------|------|
| `Bun.JSONL.parseChunk` | 畸形行时无限挂起Bun 1.3.10 bug |
| `context.ts` 核心逻辑 | 依赖 bootstrap/state + git + 50+ 模块mock 不可行 |
| `tools.ts` (getAllBaseTools) | 导入链过重 |
| `spawnMultiAgent.ts` | 50+ 依赖 |
| `messages.ts` 部分函数 | 依赖 `getFeatureValue_CACHED_MAY_BE_STALE` |
| UI 组件 (`screens/`, `components/`) | 需 Ink 渲染测试环境 |
### 5.6 Mock 模式
通过 `mock.module()` + `await import()` 解锁重依赖模块:
| 被 Mock 模块 | 解锁的测试 |
|-------------|-----------|
| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts, memoize.ts, PermissionMode.ts |
| `src/services/tokenEstimation.ts` | tokens.ts |
| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts, memoize.ts, PermissionMode.ts |
| `src/utils/debug.ts` | envValidation.ts, outputLimits.ts |
| `src/utils/bash/commands.ts` | commandSemantics.ts |
| `src/utils/thinking.js` | effort.ts |
| `src/utils/settings/settings.js` | effort.ts |
| `src/utils/auth.js` | effort.ts |
| `src/services/analytics/growthbook.js` | effort.ts, tokenBudget.ts |
| `src/utils/model/modelSupportOverrides.js` | effort.ts |
| `src/utils/log.ts` | json, tokens, FileEditTool/utils, permissions, memoize, PermissionMode |
| `src/services/tokenEstimation.ts` | tokens |
| `src/utils/slowOperations.ts` | tokens, permissions, memoize, PermissionMode |
| `src/utils/debug.ts` | envValidation, outputLimits |
| `src/utils/bash/commands.ts` | commandSemantics |
| `src/utils/thinking.js` | effort |
| `src/utils/settings/settings.js` | effort |
| `src/utils/auth.js` | effort |
| `src/services/analytics/growthbook.js` | effort, tokenBudget |
| `src/utils/powershell/dangerousCmdlets.js` | powershellSecurity |
| `src/utils/cwd.js` | gitSafety |
| `src/utils/powershell/parser.js` | gitSafety |
| `src/utils/stringUtils.js` | LSP formatters |
| `figures` | treeify |
**关键约束**`mock.module()` 必须在每个测试文件内联调用,不能从共享 helper 导入Bun 在 mock 生效前就解析了 helper 的导入)
**约束**`mock.module()` 必须在每个测试文件内联调用,不能从共享 helper 导入。
## 12. 后续测试覆盖计划
## 6. 完成状态
> **已完成** — Phase 1-4 增加 321 tests (647 → 968)Phase 5 增加 209 tests (968 → 1177)
>
> Phase 1-4 全部完成,详见上方 P3-P5 表格。
> Phase 5 新增 12 个测试文件覆盖effort、tokenBudget、displayTags、taggedId、controlMessageCompat、MCP normalization/envExpansion、gitConfigParser、formatBriefTimestamp、hyperlink、windowsPaths、notebook详见 P6 表格。
> 实际调整Phase 3 中 `context.ts` 因极重依赖链bootstrap/state + claudemd + git 等)且 `getGitStatus` 在 test 环境直接返回 null替换为 `envValidation.ts`更实用Phase 4 中 GlobTool 纯函数不足,替换为 `commandSemantics.ts` + `destructiveCommandWarning.ts`。
> 更新日期2026-04-02 | **1623 tests, 84 files, 0 fail, 851ms**
### 不纳入计划的模块
### 已完成
| 计划 | 状态 | 新增测试 | 说明 |
|------|------|---------|------|
| Plan 12 — Mock 可靠性 | **已完成** | +9 | PermissionMode ant false 路径、providers env 快照恢复 |
| Plan 10 — WEAK 修复 | **已完成** | +15 | format 断言精确化、envValidation 修正、zodToJsonSchema/destructors/notebook 加固 |
| Plan 13 — CJK/Emoji | **已完成** | +17 | truncate CJK/emoji 宽度感知测试 |
| Plan 11 — ACCEPTABLE 加强 | **已完成** | +62 | diff/uuid/hash/semver/path/claudemd/fileEdit/providers/messages 等 15 文件 |
| Plan 14 — 集成测试 | **已完成** | +43 | 搭建 tests/mocks/ + tool-chain/context-build/message-pipeline/cli-arguments |
| Plan 15 — CLI + 覆盖率 | **已完成** | +11 | Commander.js 参数解析、覆盖率基线 |
| Phase 16 — 零依赖纯函数 | **已完成** | +126 | stream/abortController/bufferedWriter/gitDiff/history/sliceAnsi/treeify/words 8 文件 |
| Phase 17 — 工具子模块 | **已完成** | +179 | PowerShell 安全/语义/破坏性/gitSafety + LSP 格式化/schema + WebFetch 预批准/URL 8 文件 |
| Phase 18 — WEAK 修复 | **已完成** | +20 | format 精确匹配、envValidation 边界、PermissionMode 补强、gitOperationTracking PR actions |
### 覆盖率基线
| 指标 | 数值 |
|------|------|
| 总测试数 | 1623 |
| 测试文件数 | 84 |
| 失败数 | 0 |
| 断言数 | 2516 |
| 运行耗时 | ~851ms |
| Tool.ts 行覆盖率 | 100% |
| 整体行覆盖率 | ~33%Bun coverage 限制:`mock.module` 模式下的模块不报告) |
> **注意**Bun `--coverage` 仅报告测试 import 链中直接加载的文件。使用 `mock.module()` + `await import()` 模式的源文件(大多数 `src/utils/` 纯函数)不显示在覆盖率报告中。实际测试覆盖率高于报告值。
### 不纳入计划
| 模块 | 原因 |
|------|------|
| `query.ts` / `QueryEngine.ts` | 核心循环,需集成测试环境 |
| `query.ts` / `QueryEngine.ts` | 核心循环,需完整集成环境 |
| `services/api/claude.ts` | 需 mock SDK 流式响应 |
| `spawnMultiAgent.ts` | 50+ 依赖mock 不可行 |
| `spawnMultiAgent.ts` | 50+ 依赖 |
| `modelCost.ts` | 依赖 bootstrap/state + analytics |
| `mcp/dateTimeParser.ts` | 调用 Haiku API |
| `screens/` / `components/` | UI 组件,需 Ink 渲染测试 |
## 13. 参考
- [Bun Test 文档](https://bun.sh/docs/cli/test)
- 现有测试示例:`src/utils/__tests__/set.test.ts`, `src/utils/__tests__/array.test.ts`
| `screens/` / `components/` | 需 Ink 渲染测试 |

View File

@@ -1,137 +0,0 @@
#!/usr/bin/env node
/**
* Analyzes TypeScript errors and creates stub modules with proper named exports.
* Run: node scripts/create-type-stubs.mjs
*/
import { execSync } from 'child_process';
import { writeFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
const ROOT = '/Users/konghayao/code/ai/claude-code';
// Run tsc and capture errors (tsc exits non-zero on type errors, that's expected)
let errors;
try {
errors = execSync('npx tsc --noEmit 2>&1', { encoding: 'utf-8', cwd: ROOT });
} catch (e) {
errors = e.stdout || '';
}
// Map: resolved file path -> Set of needed named exports
const stubExports = new Map();
// Map: resolved file path -> Set of needed default export names
const defaultExports = new Map();
for (const line of errors.split('\n')) {
// TS2614: Module '"X"' has no exported member 'Y'. Did you mean to use 'import Y from "X"' instead?
let m = line.match(/error TS2614: Module '"(.+?)"' has no exported member '(.+?)'\. Did you mean to use 'import .* from/);
if (m) {
const [, mod, member] = m;
if (!defaultExports.has(mod)) defaultExports.set(mod, new Set());
defaultExports.get(mod).add(member);
continue;
}
// TS2305: Module '"X"' has no exported member 'Y'
m = line.match(/error TS2305: Module '"(.+?)"' has no exported member '(.+?)'/);
if (m) {
const [, mod, member] = m;
if (!stubExports.has(mod)) stubExports.set(mod, new Set());
stubExports.get(mod).add(member);
}
// TS2724: '"X"' has no exported member named 'Y'. Did you mean 'Z'?
m = line.match(/error TS2724: '"(.+?)"' has no exported member named '(.+?)'/);
if (m) {
const [, mod, member] = m;
if (!stubExports.has(mod)) stubExports.set(mod, new Set());
stubExports.get(mod).add(member);
}
// TS2306: File 'X' is not a module
m = line.match(/error TS2306: File '(.+?)' is not a module/);
if (m) {
const filePath = m[1];
if (!stubExports.has(filePath)) stubExports.set(filePath, new Set());
}
// TS2307: Cannot find module 'X'
m = line.match(/^(.+?)\(\d+,\d+\): error TS2307: Cannot find module '(.+?)'/);
if (m) {
const [srcFile, mod] = [m[1], m[2]];
if (mod.endsWith('.md')) continue;
if (!mod.startsWith('.') && !mod.startsWith('src/')) continue;
// Will be resolved below
const srcDir = dirname(srcFile);
const resolved = join(ROOT, srcDir, mod).replace(/\.js$/, '.ts');
if (resolved.startsWith(ROOT + '/') && !existsSync(resolved)) {
if (!stubExports.has(resolved)) stubExports.set(resolved, new Set());
}
}
}
// Also parse actual import statements from source files to find what's needed
import { readFileSync } from 'fs';
const allSourceFiles = execSync('find src -name "*.ts" -o -name "*.tsx"', { encoding: 'utf-8', cwd: ROOT }).trim().split('\n');
for (const file of allSourceFiles) {
const content = readFileSync(join(ROOT, file), 'utf-8');
const srcDir = dirname(file);
// Find all import { X, Y } from 'module'
const importRegex = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"](.+?)['"]/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
const members = match[1].split(',').map(s => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
let mod = match[2];
if (!mod.startsWith('.') && !mod.startsWith('src/')) continue;
const resolved = join(ROOT, srcDir, mod).replace(/\.js$/, '.ts');
if (resolved.startsWith(ROOT + '/') && !existsSync(resolved)) {
if (!stubExports.has(resolved)) stubExports.set(resolved, new Set());
for (const member of members) {
stubExports.get(resolved).add(member);
}
}
}
}
// Now create/update all stub files
let created = 0;
for (const [filePath, exports] of stubExports) {
const relPath = filePath.replace(ROOT + '/', '');
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const lines = ['// Auto-generated type stub — replace with real implementation'];
for (const exp of exports) {
lines.push(`export type ${exp} = any;`);
}
// Check if there are default exports needed
for (const [mod, defs] of defaultExports) {
// Match the module path
const modNorm = mod.replace(/\.js$/, '').replace(/^src\//, '');
const filePathNorm = relPath.replace(/\.ts$/, '');
if (modNorm === filePathNorm || mod === relPath) {
for (const def of defs) {
lines.push(`export type ${def} = any;`);
}
}
}
// Ensure at least export {}
if (exports.size === 0) {
lines.push('export {};');
}
writeFileSync(filePath, lines.join('\n') + '\n');
created++;
}
console.log(`Created/updated ${created} stub files`);
console.log(`Total named exports resolved: ${[...stubExports.values()].reduce((a, b) => a + b.size, 0)}`);

View File

@@ -13,8 +13,18 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
`${k}:${v}`,
]);
// Bun --feature flags: enable feature() gates at runtime.
// Any env var matching FEATURE_<NAME>=1 will enable that feature.
// e.g. FEATURE_TRANSCRIPT_CLASSIFIER=1 bun run dev
const featureArgs: string[] = Object.entries(process.env)
.filter(([k]) => k.startsWith("FEATURE_"))
.flatMap(([k]) => {
const name = k.replace("FEATURE_", "");
return ["--feature", name];
});
const result = Bun.spawnSync(
["bun", "run", ...defineArgs, "src/entrypoints/cli.tsx", ...process.argv.slice(2)],
["bun", "run", ...defineArgs, ...featureArgs, "src/entrypoints/cli.tsx", ...process.argv.slice(2)],
{ stdio: ["inherit", "inherit", "inherit"] },
);

View File

@@ -1,160 +0,0 @@
#!/usr/bin/env node
/**
* Finds all stub files with `export default {} as any` and rewrites them
* with proper named exports based on what the source code actually imports.
*/
import { execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, relative, resolve } from 'path';
const ROOT = '/Users/konghayao/code/ai/claude-code';
// Step 1: Find all stub files with only `export default {} as any`
const stubFiles = new Set();
const allTsFiles = execSync('find src -name "*.ts" -o -name "*.tsx"', {
encoding: 'utf-8', cwd: ROOT
}).trim().split('\n');
for (const f of allTsFiles) {
const fullPath = join(ROOT, f);
const content = readFileSync(fullPath, 'utf-8').trim();
if (content === 'export default {} as any') {
stubFiles.add(f); // relative path like src/types/message.ts
}
}
console.log(`Found ${stubFiles.size} stub files with 'export default {} as any'`);
// Step 2: Scan all source files for imports from these stub modules
// Map: stub file path -> { types: Set<string>, values: Set<string> }
const stubNeeds = new Map();
for (const sf of stubFiles) {
stubNeeds.set(sf, { types: new Set(), values: new Set() });
}
// Helper: resolve an import path from a source file to a stub file
function resolveImport(srcFile, importPath) {
// Handle src/ prefix imports
if (importPath.startsWith('src/')) {
const resolved = importPath.replace(/\.js$/, '.ts');
if (stubFiles.has(resolved)) return resolved;
return null;
}
// Handle relative imports
if (importPath.startsWith('.')) {
const srcDir = dirname(srcFile);
const resolved = join(srcDir, importPath).replace(/\.js$/, '.ts');
if (stubFiles.has(resolved)) return resolved;
// Try .tsx
const resolvedTsx = join(srcDir, importPath).replace(/\.js$/, '.tsx');
if (stubFiles.has(resolvedTsx)) return resolvedTsx;
return null;
}
return null;
}
for (const srcFile of allTsFiles) {
if (stubFiles.has(srcFile)) continue; // skip stub files themselves
const fullPath = join(ROOT, srcFile);
const content = readFileSync(fullPath, 'utf-8');
// Match: import type { A, B } from 'path'
const typeImportRegex = /import\s+type\s+\{([^}]+)\}\s+from\s+['"](.+?)['"]/g;
let match;
while ((match = typeImportRegex.exec(content)) !== null) {
const members = match[1].split(',').map(s => {
const parts = s.trim().split(/\s+as\s+/);
return parts[0].trim();
}).filter(Boolean);
const resolved = resolveImport(srcFile, match[2]);
if (resolved && stubNeeds.has(resolved)) {
for (const m of members) stubNeeds.get(resolved).types.add(m);
}
}
// Match: import { A, B } from 'path' (NOT import type)
const valueImportRegex = /import\s+(?!type\s)\{([^}]+)\}\s+from\s+['"](.+?)['"]/g;
while ((match = valueImportRegex.exec(content)) !== null) {
const rawMembers = match[1];
const members = rawMembers.split(',').map(s => {
// Handle `type Foo` inline type imports
const trimmed = s.trim();
if (trimmed.startsWith('type ')) {
return { name: trimmed.replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim(), isType: true };
}
return { name: trimmed.split(/\s+as\s+/)[0].trim(), isType: false };
}).filter(m => m.name);
const resolved = resolveImport(srcFile, match[2]);
if (resolved && stubNeeds.has(resolved)) {
for (const m of members) {
if (m.isType) {
stubNeeds.get(resolved).types.add(m.name);
} else {
stubNeeds.get(resolved).values.add(m.name);
}
}
}
}
// Match: import Default from 'path'
const defaultImportRegex = /import\s+(?!type\s)(\w+)\s+from\s+['"](.+?)['"]/g;
while ((match = defaultImportRegex.exec(content)) !== null) {
const name = match[1];
if (name === 'type') continue;
const resolved = resolveImport(srcFile, match[2]);
if (resolved && stubNeeds.has(resolved)) {
stubNeeds.get(resolved).values.add('__default__:' + name);
}
}
}
// Step 3: Rewrite stub files
let updated = 0;
for (const [stubFile, needs] of stubNeeds) {
const fullPath = join(ROOT, stubFile);
const lines = ['// Auto-generated stub — replace with real implementation'];
let hasDefault = false;
// Add type exports
for (const t of needs.types) {
// Don't add as type if also in values
if (!needs.values.has(t)) {
lines.push(`export type ${t} = any;`);
}
}
// Add value exports (as const with any type)
for (const v of needs.values) {
if (v.startsWith('__default__:')) {
hasDefault = true;
continue;
}
// Check if it's likely a type (starts with uppercase and not a known function pattern)
// But since it's imported without `type`, treat as value to be safe
lines.push(`export const ${v}: any = (() => {}) as any;`);
}
// Add default export if needed
if (hasDefault) {
lines.push(`export default {} as any;`);
}
if (needs.types.size === 0 && needs.values.size === 0) {
lines.push('export {};');
}
writeFileSync(fullPath, lines.join('\n') + '\n');
updated++;
}
console.log(`Updated ${updated} stub files`);
// Print summary
for (const [stubFile, needs] of stubNeeds) {
if (needs.types.size > 0 || needs.values.size > 0) {
console.log(` ${stubFile}: ${needs.types.size} types, ${needs.values.size} values`);
}
}

View File

@@ -1,228 +0,0 @@
#!/usr/bin/env node
/**
* Fixes TS2339 "Property X does not exist on type 'typeof import(...)'"
* by adding missing exports to the stub module files.
* Also re-runs TS2305/TS2724 fixes.
*/
import { execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
const ROOT = '/Users/konghayao/code/ai/claude-code';
// Run tsc and capture errors
let errors;
try {
errors = execSync('npx tsc --noEmit 2>&1', { encoding: 'utf-8', cwd: ROOT, maxBuffer: 50 * 1024 * 1024 });
} catch (e) {
errors = e.stdout || '';
}
// ============================================================
// 1. Fix TS2339 on typeof import(...) - add missing exports
// ============================================================
// Map: module file path -> Set<property name>
const missingExports = new Map();
for (const line of errors.split('\n')) {
// TS2339: Property 'X' does not exist on type 'typeof import("path")'
let m = line.match(/error TS2339: Property '(\w+)' does not exist on type 'typeof import\("(.+?)"\)'/);
if (m) {
const [, prop, modPath] = m;
let filePath;
if (modPath.startsWith('/')) {
filePath = modPath;
} else {
continue; // skip non-absolute paths for now
}
// Try .ts then .tsx
for (const ext of ['.ts', '.tsx']) {
const fp = filePath + ext;
if (existsSync(fp)) {
if (!missingExports.has(fp)) missingExports.set(fp, new Set());
missingExports.get(fp).add(prop);
break;
}
}
}
// TS2339 on type '{ default: typeof import("...") }' (namespace import)
m = line.match(/error TS2339: Property '(\w+)' does not exist on type '\{ default: typeof import\("(.+?)"\)/);
if (m) {
const [, prop, modPath] = m;
for (const ext of ['.ts', '.tsx']) {
const fp = (modPath.startsWith('/') ? modPath : join(ROOT, modPath)) + ext;
if (existsSync(fp)) {
if (!missingExports.has(fp)) missingExports.set(fp, new Set());
missingExports.get(fp).add(prop);
break;
}
}
}
}
console.log(`Found ${missingExports.size} modules needing export additions for TS2339`);
let ts2339Fixed = 0;
for (const [filePath, props] of missingExports) {
const content = readFileSync(filePath, 'utf-8');
const existingExports = new Set();
// Parse existing exports
const exportRegex = /export\s+(?:type|const|function|class|let|var|default)\s+(\w+)/g;
let em;
while ((em = exportRegex.exec(content)) !== null) {
existingExports.add(em[1]);
}
const newExports = [];
for (const prop of props) {
if (!existingExports.has(prop) && !content.includes(`export { ${prop}`) && !content.includes(`, ${prop}`)) {
newExports.push(`export const ${prop}: any = (() => {}) as any;`);
ts2339Fixed++;
}
}
if (newExports.length > 0) {
const newContent = content.trimEnd() + '\n' + newExports.join('\n') + '\n';
writeFileSync(filePath, newContent);
}
}
console.log(`Added ${ts2339Fixed} missing exports for TS2339`);
// ============================================================
// 2. Fix TS2305 - Module has no exported member
// ============================================================
const ts2305Fixes = new Map();
for (const line of errors.split('\n')) {
let m = line.match(/^(.+?)\(\d+,\d+\): error TS2305: Module '"(.+?)"' has no exported member '(.+?)'/);
if (!m) continue;
const [, srcFile, mod, member] = m;
// Resolve module path
let resolvedPath;
if (mod.startsWith('.') || mod.startsWith('src/')) {
const base = mod.startsWith('.') ? join(dirname(srcFile), mod) : mod;
const resolved = join(ROOT, base).replace(/\.js$/, '');
for (const ext of ['.ts', '.tsx']) {
if (existsSync(resolved + ext)) {
resolvedPath = resolved + ext;
break;
}
}
}
if (resolvedPath) {
if (!ts2305Fixes.has(resolvedPath)) ts2305Fixes.set(resolvedPath, new Set());
ts2305Fixes.get(resolvedPath).add(member);
}
}
let ts2305Fixed = 0;
for (const [filePath, members] of ts2305Fixes) {
const content = readFileSync(filePath, 'utf-8');
const newExports = [];
for (const member of members) {
if (!content.includes(`export type ${member}`) && !content.includes(`export const ${member}`) && !content.includes(`export function ${member}`)) {
newExports.push(`export type ${member} = any;`);
ts2305Fixed++;
}
}
if (newExports.length > 0) {
writeFileSync(filePath, content.trimEnd() + '\n' + newExports.join('\n') + '\n');
}
}
console.log(`Added ${ts2305Fixed} missing exports for TS2305`);
// ============================================================
// 3. Fix TS2724 - no exported member named X. Did you mean Y?
// ============================================================
const ts2724Fixes = new Map();
for (const line of errors.split('\n')) {
let m = line.match(/^(.+?)\(\d+,\d+\): error TS2724: '"(.+?)"' has no exported member named '(.+?)'/);
if (!m) continue;
const [, srcFile, mod, member] = m;
let resolvedPath;
if (mod.startsWith('.') || mod.startsWith('src/')) {
const base = mod.startsWith('.') ? join(dirname(srcFile), mod) : mod;
const resolved = join(ROOT, base).replace(/\.js$/, '');
for (const ext of ['.ts', '.tsx']) {
if (existsSync(resolved + ext)) {
resolvedPath = resolved + ext;
break;
}
}
}
if (resolvedPath) {
if (!ts2724Fixes.has(resolvedPath)) ts2724Fixes.set(resolvedPath, new Set());
ts2724Fixes.get(resolvedPath).add(member);
}
}
let ts2724Fixed = 0;
for (const [filePath, members] of ts2724Fixes) {
const content = readFileSync(filePath, 'utf-8');
const newExports = [];
for (const member of members) {
if (!content.includes(`export type ${member}`) && !content.includes(`export const ${member}`)) {
newExports.push(`export type ${member} = any;`);
ts2724Fixed++;
}
}
if (newExports.length > 0) {
writeFileSync(filePath, content.trimEnd() + '\n' + newExports.join('\n') + '\n');
}
}
console.log(`Added ${ts2724Fixed} missing exports for TS2724`);
// ============================================================
// 4. Fix TS2307 - Cannot find module (create stub files)
// ============================================================
let ts2307Fixed = 0;
for (const line of errors.split('\n')) {
let m = line.match(/^(.+?)\(\d+,\d+\): error TS2307: Cannot find module '(.+?)'/);
if (!m) continue;
const [, srcFile, mod] = m;
if (mod.endsWith('.md') || mod.endsWith('.css')) continue;
if (!mod.startsWith('.') && !mod.startsWith('src/')) continue;
const srcDir = dirname(srcFile);
let resolved;
if (mod.startsWith('.')) {
resolved = join(ROOT, srcDir, mod).replace(/\.js$/, '.ts');
} else {
resolved = join(ROOT, mod).replace(/\.js$/, '.ts');
}
if (!existsSync(resolved) && resolved.startsWith(ROOT + '/src/')) {
const dir = dirname(resolved);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
// Collect imports from the source file for this module
const srcContent = readFileSync(join(ROOT, srcFile), 'utf-8');
const importRegex = new RegExp(`import\\s+(?:type\\s+)?\\{([^}]+)\\}\\s+from\\s+['"]${mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`, 'g');
const members = new Set();
let im;
while ((im = importRegex.exec(srcContent)) !== null) {
im[1].split(',').map(s => s.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim()).filter(Boolean).forEach(m => members.add(m));
}
const lines = ['// Auto-generated stub'];
for (const member of members) {
lines.push(`export type ${member} = any;`);
}
if (members.size === 0) lines.push('export {};');
writeFileSync(resolved, lines.join('\n') + '\n');
ts2307Fixed++;
}
}
console.log(`Created ${ts2307Fixed} new stub files for TS2307`);

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env node
/**
* 清除 src/ 下所有 .ts/.tsx 文件中的 //# sourceMappingURL= 行
* 用法: node scripts/remove-sourcemaps.mjs [--dry-run]
*/
import { readdir, readFile, writeFile } from "fs/promises";
import { join, extname } from "path";
const SRC_DIR = new URL("../src", import.meta.url).pathname;
const DRY_RUN = process.argv.includes("--dry-run");
const EXTENSIONS = new Set([".ts", ".tsx"]);
const PATTERN = /^\s*\/\/# sourceMappingURL=.*$/gm;
async function* walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (EXTENSIONS.has(extname(entry.name))) {
yield full;
}
}
}
let total = 0;
for await (const file of walk(SRC_DIR)) {
const content = await readFile(file, "utf8");
if (!PATTERN.test(content)) continue;
// reset lastIndex after test
PATTERN.lastIndex = 0;
const cleaned = content.replace(PATTERN, "").replace(/\n{3,}/g, "\n\n");
if (DRY_RUN) {
console.log(`[dry-run] ${file}`);
} else {
await writeFile(file, cleaned, "utf8");
}
total++;
}
console.log(`\n${DRY_RUN ? "[dry-run] " : ""}Processed ${total} files.`);

View File

@@ -1,201 +1,207 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from 'bun:test'
import {
buildTool,
toolMatchesName,
findToolByName,
getEmptyToolPermissionContext,
filterToolProgressMessages,
} from "../Tool";
} from '../Tool'
// Minimal tool definition for testing buildTool
function makeMinimalToolDef(overrides: Record<string, unknown> = {}) {
return {
name: "TestTool",
inputSchema: { type: "object" as const } as any,
name: 'TestTool',
inputSchema: { type: 'object' as const } as any,
maxResultSizeChars: 10000,
call: async () => ({ data: "ok" }),
description: async () => "A test tool",
prompt: async () => "test prompt",
mapToolResultToToolResultBlockParam: (content: unknown, toolUseID: string) => ({
type: "tool_result" as const,
call: async () => ({ data: 'ok' }),
description: async () => 'A test tool',
prompt: async () => 'test prompt',
mapToolResultToToolResultBlockParam: (
content: unknown,
toolUseID: string,
) => ({
type: 'tool_result' as const,
tool_use_id: toolUseID,
content: String(content),
}),
renderToolUseMessage: () => null,
...overrides,
};
}
}
describe("buildTool", () => {
test("fills in default isEnabled as true", () => {
const tool = buildTool(makeMinimalToolDef());
expect(tool.isEnabled()).toBe(true);
});
describe('buildTool', () => {
test('fills in default isEnabled as true', () => {
const tool = buildTool(makeMinimalToolDef())
expect(tool.isEnabled()).toBe(true)
})
test("fills in default isConcurrencySafe as false", () => {
const tool = buildTool(makeMinimalToolDef());
expect(tool.isConcurrencySafe({})).toBe(false);
});
test('fills in default isConcurrencySafe as false', () => {
const tool = buildTool(makeMinimalToolDef())
expect(tool.isConcurrencySafe({})).toBe(false)
})
test("fills in default isReadOnly as false", () => {
const tool = buildTool(makeMinimalToolDef());
expect(tool.isReadOnly({})).toBe(false);
});
test('fills in default isReadOnly as false', () => {
const tool = buildTool(makeMinimalToolDef())
expect(tool.isReadOnly({})).toBe(false)
})
test("fills in default isDestructive as false", () => {
const tool = buildTool(makeMinimalToolDef());
expect(tool.isDestructive!({})).toBe(false);
});
test('fills in default isDestructive as false', () => {
const tool = buildTool(makeMinimalToolDef())
expect(tool.isDestructive!({})).toBe(false)
})
test("fills in default checkPermissions as allow", async () => {
const tool = buildTool(makeMinimalToolDef());
const input = { foo: "bar" };
const result = await tool.checkPermissions(input, {} as any);
expect(result).toEqual({ behavior: "allow", updatedInput: input });
});
test('fills in default checkPermissions as allow', async () => {
const tool = buildTool(makeMinimalToolDef())
const input = { foo: 'bar' }
const result = await tool.checkPermissions(input, {} as any)
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
})
test("fills in default userFacingName from tool name", () => {
const tool = buildTool(makeMinimalToolDef());
expect(tool.userFacingName(undefined)).toBe("TestTool");
});
test('fills in default userFacingName from tool name', () => {
const tool = buildTool(makeMinimalToolDef())
expect(tool.userFacingName(undefined)).toBe('TestTool')
})
test("fills in default toAutoClassifierInput as empty string", () => {
const tool = buildTool(makeMinimalToolDef());
expect(tool.toAutoClassifierInput({})).toBe("");
});
test('fills in default toAutoClassifierInput as empty string', () => {
const tool = buildTool(makeMinimalToolDef())
expect(tool.toAutoClassifierInput({})).toBe('')
})
test("preserves explicitly provided methods", () => {
test('preserves explicitly provided methods', () => {
const tool = buildTool(
makeMinimalToolDef({
isEnabled: () => false,
isConcurrencySafe: () => true,
isReadOnly: () => true,
})
);
expect(tool.isEnabled()).toBe(false);
expect(tool.isConcurrencySafe({})).toBe(true);
expect(tool.isReadOnly({})).toBe(true);
});
}),
)
expect(tool.isEnabled()).toBe(false)
expect(tool.isConcurrencySafe({})).toBe(true)
expect(tool.isReadOnly({})).toBe(true)
})
test("preserves all non-defaultable properties", () => {
const tool = buildTool(makeMinimalToolDef());
expect(tool.name).toBe("TestTool");
expect(tool.maxResultSizeChars).toBe(10000);
expect(typeof tool.call).toBe("function");
expect(typeof tool.description).toBe("function");
expect(typeof tool.prompt).toBe("function");
});
});
test('preserves all non-defaultable properties', () => {
const tool = buildTool(makeMinimalToolDef())
expect(tool.name).toBe('TestTool')
expect(tool.maxResultSizeChars).toBe(10000)
expect(typeof tool.call).toBe('function')
expect(typeof tool.description).toBe('function')
expect(typeof tool.prompt).toBe('function')
})
})
describe("toolMatchesName", () => {
test("returns true for exact name match", () => {
expect(toolMatchesName({ name: "Bash" }, "Bash")).toBe(true);
});
describe('toolMatchesName', () => {
test('returns true for exact name match', () => {
expect(toolMatchesName({ name: 'Bash' }, 'Bash')).toBe(true)
})
test("returns false for non-matching name", () => {
expect(toolMatchesName({ name: "Bash" }, "Read")).toBe(false);
});
test('returns false for non-matching name', () => {
expect(toolMatchesName({ name: 'Bash' }, 'Read')).toBe(false)
})
test("returns true when name matches an alias", () => {
test('returns true when name matches an alias', () => {
expect(
toolMatchesName({ name: "Bash", aliases: ["BashTool", "Shell"] }, "BashTool")
).toBe(true);
});
toolMatchesName(
{ name: 'Bash', aliases: ['BashTool', 'Shell'] },
'BashTool',
),
).toBe(true)
})
test("returns false when aliases is undefined", () => {
expect(toolMatchesName({ name: "Bash" }, "BashTool")).toBe(false);
});
test('returns false when aliases is undefined', () => {
expect(toolMatchesName({ name: 'Bash' }, 'BashTool')).toBe(false)
})
test("returns false when aliases is empty", () => {
expect(
toolMatchesName({ name: "Bash", aliases: [] }, "BashTool")
).toBe(false);
});
});
test('returns false when aliases is empty', () => {
expect(toolMatchesName({ name: 'Bash', aliases: [] }, 'BashTool')).toBe(
false,
)
})
})
describe("findToolByName", () => {
describe('findToolByName', () => {
const mockTools = [
buildTool(makeMinimalToolDef({ name: "Bash" })),
buildTool(makeMinimalToolDef({ name: "Read", aliases: ["FileRead"] })),
buildTool(makeMinimalToolDef({ name: "Edit" })),
];
buildTool(makeMinimalToolDef({ name: 'Bash' })),
buildTool(makeMinimalToolDef({ name: 'Read', aliases: ['FileRead'] })),
buildTool(makeMinimalToolDef({ name: 'Edit' })),
]
test("finds tool by primary name", () => {
const tool = findToolByName(mockTools, "Bash");
expect(tool).toBeDefined();
expect(tool!.name).toBe("Bash");
});
test('finds tool by primary name', () => {
const tool = findToolByName(mockTools, 'Bash')
expect(tool).toBeDefined()
expect(tool!.name).toBe('Bash')
})
test("finds tool by alias", () => {
const tool = findToolByName(mockTools, "FileRead");
expect(tool).toBeDefined();
expect(tool!.name).toBe("Read");
});
test('finds tool by alias', () => {
const tool = findToolByName(mockTools, 'FileRead')
expect(tool).toBeDefined()
expect(tool!.name).toBe('Read')
})
test("returns undefined when no match", () => {
expect(findToolByName(mockTools, "NonExistent")).toBeUndefined();
});
test('returns undefined when no match', () => {
expect(findToolByName(mockTools, 'NonExistent')).toBeUndefined()
})
test("returns first match when duplicates exist", () => {
test('returns first match when duplicates exist', () => {
const dupeTools = [
buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 100 })),
buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 200 })),
];
const tool = findToolByName(dupeTools, "Bash");
expect(tool!.maxResultSizeChars).toBe(100);
});
});
buildTool(makeMinimalToolDef({ name: 'Bash', maxResultSizeChars: 100 })),
buildTool(makeMinimalToolDef({ name: 'Bash', maxResultSizeChars: 200 })),
]
const tool = findToolByName(dupeTools, 'Bash')
expect(tool!.maxResultSizeChars).toBe(100)
})
})
describe("getEmptyToolPermissionContext", () => {
test("returns default permission mode", () => {
const ctx = getEmptyToolPermissionContext();
expect(ctx.mode).toBe("default");
});
describe('getEmptyToolPermissionContext', () => {
test('returns default permission mode', () => {
const ctx = getEmptyToolPermissionContext()
expect(ctx.mode).toBe('default')
})
test("returns empty maps and arrays", () => {
const ctx = getEmptyToolPermissionContext();
expect(ctx.additionalWorkingDirectories.size).toBe(0);
expect(ctx.alwaysAllowRules).toEqual({});
expect(ctx.alwaysDenyRules).toEqual({});
expect(ctx.alwaysAskRules).toEqual({});
});
test('returns empty maps and arrays', () => {
const ctx = getEmptyToolPermissionContext()
expect(ctx.additionalWorkingDirectories.size).toBe(0)
expect(ctx.alwaysAllowRules).toEqual({})
expect(ctx.alwaysDenyRules).toEqual({})
expect(ctx.alwaysAskRules).toEqual({})
})
test("returns isBypassPermissionsModeAvailable as false", () => {
const ctx = getEmptyToolPermissionContext();
expect(ctx.isBypassPermissionsModeAvailable).toBe(false);
});
});
test('returns isBypassPermissionsModeAvailable as false', () => {
const ctx = getEmptyToolPermissionContext()
expect(ctx.isBypassPermissionsModeAvailable).toBe(false)
})
})
describe("filterToolProgressMessages", () => {
test("filters out hook_progress messages", () => {
describe('filterToolProgressMessages', () => {
test('filters out hook_progress messages', () => {
const messages = [
{ data: { type: "hook_progress", hookName: "pre" } },
{ data: { type: "tool_progress", toolName: "Bash" } },
] as any[];
const result = filterToolProgressMessages(messages);
expect(result).toHaveLength(1);
expect((result[0]!.data as any).type).toBe("tool_progress");
});
{ data: { type: 'hook_progress', hookName: 'pre' } },
{ data: { type: 'tool_progress', toolName: 'Bash' } },
] as any[]
const result = filterToolProgressMessages(messages)
expect(result).toHaveLength(1)
expect((result[0]!.data as any).type).toBe('tool_progress')
})
test("keeps tool progress messages", () => {
test('keeps tool progress messages', () => {
const messages = [
{ data: { type: "tool_progress", toolName: "Bash" } },
{ data: { type: "tool_progress", toolName: "Read" } },
] as any[];
const result = filterToolProgressMessages(messages);
expect(result).toHaveLength(2);
});
{ data: { type: 'tool_progress', toolName: 'Bash' } },
{ data: { type: 'tool_progress', toolName: 'Read' } },
] as any[]
const result = filterToolProgressMessages(messages)
expect(result).toHaveLength(2)
})
test("returns empty array for empty input", () => {
expect(filterToolProgressMessages([])).toEqual([]);
});
test('returns empty array for empty input', () => {
expect(filterToolProgressMessages([])).toEqual([])
})
test("handles messages without type field", () => {
test('handles messages without type field', () => {
const messages = [
{ data: { toolName: "Bash" } },
{ data: { type: "hook_progress" } },
] as any[];
const result = filterToolProgressMessages(messages);
expect(result).toHaveLength(1);
});
});
{ data: { toolName: 'Bash' } },
{ data: { type: 'hook_progress' } },
] as any[]
const result = filterToolProgressMessages(messages)
expect(result).toHaveLength(1)
})
})

View File

@@ -0,0 +1,167 @@
import { describe, expect, test } from 'bun:test'
import {
getPastedTextRefNumLines,
formatPastedTextRef,
formatImageRef,
parseReferences,
expandPastedTextRefs,
} from '../history'
describe('getPastedTextRefNumLines', () => {
test('returns 0 for single line (no newline)', () => {
expect(getPastedTextRefNumLines('hello')).toBe(0)
})
test('counts LF newlines', () => {
expect(getPastedTextRefNumLines('a\nb\nc')).toBe(2)
})
test('counts CRLF newlines', () => {
expect(getPastedTextRefNumLines('a\r\nb')).toBe(1)
})
test('counts CR newlines', () => {
expect(getPastedTextRefNumLines('a\rb')).toBe(1)
})
test('returns 0 for empty string', () => {
expect(getPastedTextRefNumLines('')).toBe(0)
})
test('trailing newline counts as one', () => {
expect(getPastedTextRefNumLines('a\n')).toBe(1)
})
})
describe('formatPastedTextRef', () => {
test('formats with lines count', () => {
expect(formatPastedTextRef(1, 10)).toBe('[Pasted text #1 +10 lines]')
})
test('formats without lines when 0', () => {
expect(formatPastedTextRef(3, 0)).toBe('[Pasted text #3]')
})
test('formats with large id', () => {
expect(formatPastedTextRef(99, 5)).toBe('[Pasted text #99 +5 lines]')
})
})
describe('formatImageRef', () => {
test('formats image reference', () => {
expect(formatImageRef(1)).toBe('[Image #1]')
})
test('formats with large id', () => {
expect(formatImageRef(42)).toBe('[Image #42]')
})
})
describe('parseReferences', () => {
test('parses Pasted text ref', () => {
const refs = parseReferences('[Pasted text #1 +5 lines]')
expect(refs).toHaveLength(1)
expect(refs[0]).toEqual({
id: 1,
match: '[Pasted text #1 +5 lines]',
index: 0,
})
})
test('parses Image ref', () => {
const refs = parseReferences('[Image #2]')
expect(refs).toHaveLength(1)
expect(refs[0]!.id).toBe(2)
})
test('parses Truncated text ref', () => {
const refs = parseReferences('[...Truncated text #3]')
expect(refs).toHaveLength(1)
expect(refs[0]!.id).toBe(3)
})
test('parses Pasted text without line count', () => {
const refs = parseReferences('[Pasted text #4]')
expect(refs).toHaveLength(1)
expect(refs[0]!.id).toBe(4)
})
test('parses multiple refs', () => {
const refs = parseReferences('hello [Pasted text #1] world [Image #2]')
expect(refs).toHaveLength(2)
expect(refs[0]!.id).toBe(1)
expect(refs[1]!.id).toBe(2)
})
test('returns empty for no refs', () => {
expect(parseReferences('plain text')).toEqual([])
})
test('filters out id 0', () => {
const refs = parseReferences('[Pasted text #0]')
expect(refs).toHaveLength(0)
})
test('captures correct index for embedded refs', () => {
const input = 'prefix [Pasted text #1] suffix'
const refs = parseReferences(input)
expect(refs[0]!.index).toBe(7)
})
test('handles duplicate refs', () => {
const refs = parseReferences('[Pasted text #1] and [Pasted text #1]')
expect(refs).toHaveLength(2)
})
})
describe('expandPastedTextRefs', () => {
test('replaces single text ref', () => {
const input = 'look at [Pasted text #1 +2 lines]'
const pastedContents = {
1: { id: 1, type: 'text' as const, content: 'line1\nline2\nline3' },
}
const result = expandPastedTextRefs(input, pastedContents)
expect(result).toBe('look at line1\nline2\nline3')
})
test('replaces multiple text refs in reverse order', () => {
const input = '[Pasted text #1] and [Pasted text #2]'
const pastedContents = {
1: { id: 1, type: 'text' as const, content: 'AAA' },
2: { id: 2, type: 'text' as const, content: 'BBB' },
}
const result = expandPastedTextRefs(input, pastedContents)
expect(result).toBe('AAA and BBB')
})
test('does not replace image refs', () => {
const input = '[Image #1]'
const pastedContents = {
1: { id: 1, type: 'image' as const, content: 'data' },
}
const result = expandPastedTextRefs(input, pastedContents)
expect(result).toBe('[Image #1]')
})
test('returns original when no refs', () => {
const input = 'no refs here'
const result = expandPastedTextRefs(input, {})
expect(result).toBe('no refs here')
})
test('skips refs with no matching pasted content', () => {
const input = '[Pasted text #99 +1 lines]'
const result = expandPastedTextRefs(input, {})
expect(result).toBe('[Pasted text #99 +1 lines]')
})
test('handles mixed content', () => {
const input = 'see [Pasted text #1] and [Image #2]'
const pastedContents = {
1: { id: 1, type: 'text' as const, content: 'code here' },
2: { id: 2, type: 'image' as const, content: 'img data' },
}
const result = expandPastedTextRefs(input, pastedContents)
expect(result).toBe('see code here and [Image #2]')
})
})

View File

@@ -1,82 +1,85 @@
import { describe, expect, test } from "bun:test";
import { parseToolPreset, filterToolsByDenyRules } from "../tools";
import { getEmptyToolPermissionContext } from "../Tool";
import { describe, expect, test } from 'bun:test'
import { parseToolPreset, filterToolsByDenyRules } from '../tools'
import { getEmptyToolPermissionContext } from '../Tool'
describe("parseToolPreset", () => {
describe('parseToolPreset', () => {
test('returns "default" for "default" input', () => {
expect(parseToolPreset("default")).toBe("default");
});
expect(parseToolPreset('default')).toBe('default')
})
test('returns "default" for "Default" input (case-insensitive)', () => {
expect(parseToolPreset("Default")).toBe("default");
});
expect(parseToolPreset('Default')).toBe('default')
})
test("returns null for unknown preset", () => {
expect(parseToolPreset("unknown")).toBeNull();
});
test('returns null for unknown preset', () => {
expect(parseToolPreset('unknown')).toBeNull()
})
test("returns null for empty string", () => {
expect(parseToolPreset("")).toBeNull();
});
test('returns null for empty string', () => {
expect(parseToolPreset('')).toBeNull()
})
test("returns null for random string", () => {
expect(parseToolPreset("custom-preset")).toBeNull();
});
});
test('returns null for random string', () => {
expect(parseToolPreset('custom-preset')).toBeNull()
})
})
// ─── filterToolsByDenyRules ─────────────────────────────────────────────
describe("filterToolsByDenyRules", () => {
describe('filterToolsByDenyRules', () => {
const mockTools = [
{ name: "Bash", mcpInfo: undefined },
{ name: "Read", mcpInfo: undefined },
{ name: "Write", mcpInfo: undefined },
{ name: "mcp__server__tool", mcpInfo: { serverName: "server", toolName: "tool" } },
];
{ name: 'Bash', mcpInfo: undefined },
{ name: 'Read', mcpInfo: undefined },
{ name: 'Write', mcpInfo: undefined },
{
name: 'mcp__server__tool',
mcpInfo: { serverName: 'server', toolName: 'tool' },
},
]
test("returns all tools when no deny rules", () => {
const ctx = getEmptyToolPermissionContext();
const result = filterToolsByDenyRules(mockTools, ctx);
expect(result).toHaveLength(4);
});
test('returns all tools when no deny rules', () => {
const ctx = getEmptyToolPermissionContext()
const result = filterToolsByDenyRules(mockTools, ctx)
expect(result).toHaveLength(4)
})
test("filters out denied tool by name", () => {
test('filters out denied tool by name', () => {
const ctx = {
...getEmptyToolPermissionContext(),
alwaysDenyRules: {
localSettings: ["Bash"],
localSettings: ['Bash'],
},
};
const result = filterToolsByDenyRules(mockTools, ctx as any);
expect(result.find((t) => t.name === "Bash")).toBeUndefined();
expect(result).toHaveLength(3);
});
}
const result = filterToolsByDenyRules(mockTools, ctx as any)
expect(result.find(t => t.name === 'Bash')).toBeUndefined()
expect(result).toHaveLength(3)
})
test("filters out multiple denied tools", () => {
test('filters out multiple denied tools', () => {
const ctx = {
...getEmptyToolPermissionContext(),
alwaysDenyRules: {
localSettings: ["Bash", "Write"],
localSettings: ['Bash', 'Write'],
},
};
const result = filterToolsByDenyRules(mockTools, ctx as any);
expect(result).toHaveLength(2);
expect(result.map((t) => t.name)).toEqual(["Read", "mcp__server__tool"]);
});
}
const result = filterToolsByDenyRules(mockTools, ctx as any)
expect(result).toHaveLength(2)
expect(result.map(t => t.name)).toEqual(['Read', 'mcp__server__tool'])
})
test("returns empty array when all tools denied", () => {
test('returns empty array when all tools denied', () => {
const ctx = {
...getEmptyToolPermissionContext(),
alwaysDenyRules: {
localSettings: mockTools.map((t) => t.name),
localSettings: mockTools.map(t => t.name),
},
};
const result = filterToolsByDenyRules(mockTools, ctx as any);
expect(result).toHaveLength(0);
});
}
const result = filterToolsByDenyRules(mockTools, ctx as any)
expect(result).toHaveLength(0)
})
test("handles empty tools array", () => {
const ctx = getEmptyToolPermissionContext();
expect(filterToolsByDenyRules([], ctx)).toEqual([]);
});
});
test('handles empty tools array', () => {
const ctx = getEmptyToolPermissionContext()
expect(filterToolsByDenyRules([], ctx)).toEqual([])
})
})

View File

@@ -10,12 +10,12 @@ import { getRainbowColor } from '../utils/thinking.js';
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
// Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean {
if (("external" as string) === 'ant') return true;
if ((process.env.USER_TYPE) === 'ant') return true;
const d = new Date();
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7;
}
export function isBuddyLive(): boolean {
if (("external" as string) === 'ant') return true;
if ((process.env.USER_TYPE) === 'ant') return true;
const d = new Date();
return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3;
}

View File

@@ -77,7 +77,7 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
}
// Redirect base /mcp command to /plugins installed tab for ant users
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
return <PluginSettings onComplete={onDone} args="manage" showMcpRedirectMessage />;
}
return <MCPSettings onComplete={onDone} />;

View File

@@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import { parsePluginArgs } from "../parseArgs";
describe("parsePluginArgs", () => {
// No args
test("returns { type: 'menu' } for undefined", () => {
expect(parsePluginArgs(undefined)).toEqual({ type: "menu" });
});
test("returns { type: 'menu' } for empty string", () => {
expect(parsePluginArgs("")).toEqual({ type: "menu" });
});
test("returns { type: 'menu' } for whitespace only", () => {
expect(parsePluginArgs(" ")).toEqual({ type: "menu" });
});
// Help
test("returns { type: 'help' } for 'help'", () => {
expect(parsePluginArgs("help")).toEqual({ type: "help" });
});
test("returns { type: 'help' } for '--help'", () => {
expect(parsePluginArgs("--help")).toEqual({ type: "help" });
});
test("returns { type: 'help' } for '-h'", () => {
expect(parsePluginArgs("-h")).toEqual({ type: "help" });
});
// Install
test("parses 'install my-plugin' -> { type: 'install', plugin: 'my-plugin' }", () => {
expect(parsePluginArgs("install my-plugin")).toEqual({
type: "install",
plugin: "my-plugin",
});
});
test("parses 'install my-plugin@github' with marketplace", () => {
expect(parsePluginArgs("install my-plugin@github")).toEqual({
type: "install",
plugin: "my-plugin",
marketplace: "github",
});
});
test("parses 'install https://github.com/...' as URL marketplace", () => {
expect(parsePluginArgs("install https://github.com/plugins/my-plugin")).toEqual({
type: "install",
marketplace: "https://github.com/plugins/my-plugin",
});
});
test("parses 'i plugin' as install shorthand", () => {
expect(parsePluginArgs("i plugin")).toEqual({
type: "install",
plugin: "plugin",
});
});
test("install without target returns type only", () => {
expect(parsePluginArgs("install")).toEqual({ type: "install" });
});
// Uninstall
test("returns { type: 'uninstall', plugin: '...' }", () => {
expect(parsePluginArgs("uninstall my-plugin")).toEqual({
type: "uninstall",
plugin: "my-plugin",
});
});
// Enable/disable
test("returns { type: 'enable', plugin: '...' }", () => {
expect(parsePluginArgs("enable my-plugin")).toEqual({
type: "enable",
plugin: "my-plugin",
});
});
test("returns { type: 'disable', plugin: '...' }", () => {
expect(parsePluginArgs("disable my-plugin")).toEqual({
type: "disable",
plugin: "my-plugin",
});
});
// Validate
test("returns { type: 'validate', path: '...' }", () => {
expect(parsePluginArgs("validate /path/to/plugin")).toEqual({
type: "validate",
path: "/path/to/plugin",
});
});
// Manage
test("returns { type: 'manage' }", () => {
expect(parsePluginArgs("manage")).toEqual({ type: "manage" });
});
// Marketplace
test("parses 'marketplace add ...'", () => {
expect(parsePluginArgs("marketplace add https://example.com")).toEqual({
type: "marketplace",
action: "add",
target: "https://example.com",
});
});
test("parses 'marketplace remove ...'", () => {
expect(parsePluginArgs("marketplace remove my-source")).toEqual({
type: "marketplace",
action: "remove",
target: "my-source",
});
});
test("parses 'marketplace list'", () => {
expect(parsePluginArgs("marketplace list")).toEqual({
type: "marketplace",
action: "list",
});
});
test("parses 'market' as alias for 'marketplace'", () => {
expect(parsePluginArgs("market list")).toEqual({
type: "marketplace",
action: "list",
});
});
// Boundary
test("handles extra whitespace", () => {
expect(parsePluginArgs(" install my-plugin ")).toEqual({
type: "install",
plugin: "my-plugin",
});
});
test("handles unknown subcommand gracefully", () => {
expect(parsePluginArgs("foobar")).toEqual({ type: "menu" });
});
test("marketplace without action returns type only", () => {
expect(parsePluginArgs("marketplace")).toEqual({ type: "marketplace" });
});
});

View File

@@ -119,7 +119,7 @@ export async function setupTerminal(theme: ThemeName): Promise<string> {
maybeMarkProjectOnboardingComplete();
// Install shell completions (ant-only, since the completion command is ant-only)
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
result += await setupShellCompletion(theme);
}
return result;

View File

@@ -29,10 +29,10 @@ const INTERNAL_MARKETPLACE_NAME = 'claude-code-marketplace';
const INTERNAL_MARKETPLACE_REPO = 'anthropics/claude-code-marketplace';
const OFFICIAL_MARKETPLACE_REPO = 'anthropics/claude-plugins-official';
function getMarketplaceName(): string {
return ("external" as string) === 'ant' ? INTERNAL_MARKETPLACE_NAME : OFFICIAL_MARKETPLACE_NAME;
return (process.env.USER_TYPE) === 'ant' ? INTERNAL_MARKETPLACE_NAME : OFFICIAL_MARKETPLACE_NAME;
}
function getMarketplaceRepo(): string {
return ("external" as string) === 'ant' ? INTERNAL_MARKETPLACE_REPO : OFFICIAL_MARKETPLACE_REPO;
return (process.env.USER_TYPE) === 'ant' ? INTERNAL_MARKETPLACE_REPO : OFFICIAL_MARKETPLACE_REPO;
}
function getPluginId(): string {
return `thinkback@${getMarketplaceName()}`;

View File

@@ -53,7 +53,7 @@ const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPromp
// Shell-set env only, so top-level process.env read is fine
// — settings.env never injects this.
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- ant-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
const ULTRAPLAN_INSTRUCTIONS: string = ("external" as string) === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS;
const ULTRAPLAN_INSTRUCTIONS: string = (process.env.USER_TYPE) === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS;
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
/**
@@ -464,7 +464,7 @@ export default {
name: 'ultraplan',
description: `~1030 min · Claude Code on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`,
argumentHint: '<prompt>',
isEnabled: () => ("external" as string) === 'ant',
isEnabled: () => (process.env.USER_TYPE) === 'ant',
load: () => Promise.resolve({
call
})

View File

@@ -0,0 +1,12 @@
// Stub — ant-only component, not available in decompiled build
import React from 'react';
export function AntModelSwitchCallout(_props: {
onDone: (selection: string, modelAlias?: string) => void;
}): React.ReactElement | null {
return null;
}
export function shouldShowModelSwitchCallout(): boolean {
return false;
}

View File

@@ -6,7 +6,7 @@ import { Text, useInterval } from '../ink.js';
// Show DevBar for dev builds or all ants
function shouldShowDevBar(): boolean {
return ("production" as string) === 'development' || ("external" as string) === 'ant';
return ("production" as string) === 'development' || (process.env.USER_TYPE) === 'ant';
}
export function DevBar() {
const $ = _c(5);

View File

@@ -32,7 +32,7 @@ import TextInput from './TextInput.js';
// This value was determined experimentally by testing the URL length limit
const GITHUB_URL_LIMIT = 7250;
const GITHUB_ISSUES_REPO_URL = ("external" as string) === 'ant' ? 'https://github.com/anthropics/claude-cli-internal/issues' : 'https://github.com/anthropics/claude-code/issues';
const GITHUB_ISSUES_REPO_URL = (process.env.USER_TYPE) === 'ant' ? 'https://github.com/anthropics/claude-cli-internal/issues' : 'https://github.com/anthropics/claude-code/issues';
type Props = {
abortSignal: AbortSignal;
messages: Message[];

View File

@@ -87,7 +87,7 @@ export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActi
});
}, []);
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
if (("external" as string) !== 'ant') {
if ((process.env.USER_TYPE) !== 'ant') {
return false;
}
if (selected_0 !== 'bad' && selected_0 !== 'good') {

View File

@@ -26,7 +26,7 @@ export function createRecentActivityFeed(activities: LogOption[]): FeedConfig {
}
export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
const lines: FeedLine[] = releaseNotes.map(note => {
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/);
if (match) {
return {
@@ -39,9 +39,9 @@ export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
text: note
};
});
const emptyMessage = ("external" as string) === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' : 'Check the Claude Code changelog for updates';
const emptyMessage = (process.env.USER_TYPE) === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' : 'Check the Claude Code changelog for updates';
return {
title: ("external" as string) === 'ant' ? "What's new [ANT-ONLY: Latest CC commits]" : "What's new",
title: (process.env.USER_TYPE) === 'ant' ? "What's new [ANT-ONLY: Latest CC commits]" : "What's new",
lines,
footer: lines.length > 0 ? '/release-notes for more' : undefined,
emptyMessage

View File

@@ -7,7 +7,7 @@ export function MemoryUsageIndicator(): React.ReactNode {
// the hook means the 10s polling interval is never set up in external builds.
// USER_TYPE is a build-time constant, so the hook call below is either always
// reached or dead-code-eliminated — never conditional at runtime.
if (("external" as string) !== 'ant') {
if ((process.env.USER_TYPE) !== 'ant') {
return null;
}

View File

@@ -118,7 +118,7 @@ export function MessageSelector({
...summarizeInputProps,
onChange: setSummarizeFromFeedback
});
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
baseOptions.push({
value: 'summarize_up_to',
label: 'Summarize up to here',

View File

@@ -184,7 +184,7 @@ export function NativeAutoUpdater({
{autoUpdaterResult?.status === 'install_failed' && <Text color="error" wrap="truncate">
Auto-update failed &middot; Try <Text bold>/status</Text>
</Text>}
{maxVersionIssue && ("external" as string) === 'ant' && <Text color="warning">
{maxVersionIssue && (process.env.USER_TYPE) === 'ant' && <Text color="warning">
Known issue: {maxVersionIssue} &middot; Run{' '}
<Text bold>claude rollback --safe</Text> to downgrade
</Text>}

View File

@@ -294,8 +294,8 @@ function PromptInput({
// otherwise bridge becomes an invisible selection stop.
const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting);
// Tmux pill (ant-only) — visible when there's an active tungsten session
const hasTungstenSession = useAppState(s => ("external" as string) === 'ant' && s.tungstenActiveSession !== undefined);
const tmuxFooterVisible = ("external" as string) === 'ant' && hasTungstenSession;
const hasTungstenSession = useAppState(s => (process.env.USER_TYPE) === 'ant' && s.tungstenActiveSession !== undefined);
const tmuxFooterVisible = (process.env.USER_TYPE) === 'ant' && hasTungstenSession;
// WebBrowser pill — visible when a browser is open
const bagelFooterVisible = useAppState(s => false);
const teamContext = useAppState(s => s.teamContext);
@@ -391,7 +391,7 @@ function PromptInput({
// exist. When only local_agent tasks are running (coordinator/fork mode), the
// pill is absent, so the -1 sentinel would leave nothing visually selected.
// In that case, skip -1 and treat 0 as the minimum selectable index.
const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !(("external" as string) === 'ant' && isPanelAgentTask(t))), [tasks]);
const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !((process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t))), [tasks]);
const minCoordinatorIndex = hasBgTaskPill ? -1 : 0;
// Clamp index when tasks complete and the list shrinks beneath the cursor
useEffect(() => {
@@ -455,7 +455,7 @@ function PromptInput({
// Panel shows retained-completed agents too (getVisibleAgentTasks), so the
// pill must stay navigable whenever the panel has rows — not just when
// something is running.
const tasksFooterVisible = (runningTaskCount > 0 || ("external" as string) === 'ant' && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree);
const tasksFooterVisible = (runningTaskCount > 0 || (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree);
const teamsFooterVisible = cachedTeams.length > 0;
const footerItems = useMemo(() => [tasksFooterVisible && 'tasks', tmuxFooterVisible && 'tmux', bagelFooterVisible && 'bagel', teamsFooterVisible && 'teams', bridgeFooterVisible && 'bridge', companionFooterVisible && 'companion'].filter(Boolean) as FooterItem[], [tasksFooterVisible, tmuxFooterVisible, bagelFooterVisible, teamsFooterVisible, bridgeFooterVisible, companionFooterVisible]);
@@ -1742,7 +1742,7 @@ function PromptInput({
useKeybindings({
'footer:up': () => {
// ↑ scrolls within the coordinator task list before leaving the pill
if (tasksSelected && ("external" as string) === 'ant' && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) {
if (tasksSelected && (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) {
setCoordinatorTaskIndex(prev => prev - 1);
return;
}
@@ -1750,7 +1750,7 @@ function PromptInput({
},
'footer:down': () => {
// ↓ scrolls within the coordinator task list, never leaves the pill
if (tasksSelected && ("external" as string) === 'ant' && coordinatorTaskCount > 0) {
if (tasksSelected && (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0) {
if (coordinatorTaskIndex < coordinatorTaskCount - 1) {
setCoordinatorTaskIndex(prev => prev + 1);
}
@@ -1813,7 +1813,7 @@ function PromptInput({
}
break;
case 'tmux':
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
setAppState(prev => prev.tungstenPanelAutoHidden ? {
...prev,
tungstenPanelAutoHidden: false

View File

@@ -143,11 +143,11 @@ function PromptInputFooter({
</Box>
<Box flexShrink={1} gap={1}>
{isFullscreen ? null : <Notifications apiKeyStatus={apiKeyStatus} autoUpdaterResult={autoUpdaterResult} debug={debug} isAutoUpdating={isAutoUpdating} verbose={verbose} messages={messages} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} ideSelection={ideSelection} mcpClients={mcpClients} isInputWrapped={isInputWrapped} isNarrow={isNarrow} />}
{("external" as string) === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
{(process.env.USER_TYPE) === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
</Box>
</Box>
{("external" as string) === 'ant' && <CoordinatorTaskPanel />}
{(process.env.USER_TYPE) === 'ant' && <CoordinatorTaskPanel />}
</>;
}
export default memo(PromptInputFooter);

View File

@@ -260,7 +260,7 @@ function ModeIndicator({
const expandedView = useAppState(s_3 => s_3.expandedView);
const showSpinnerTree = expandedView === 'teammates';
const prStatus = usePrStatus(isLoading, isPrStatusEnabled());
const hasTmuxSession = useAppState(s_4 => ("external" as string) === 'ant' && s_4.tungstenActiveSession !== undefined);
const hasTmuxSession = useAppState(s_4 => (process.env.USER_TYPE) === 'ant' && s_4.tungstenActiveSession !== undefined);
const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL);
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
@@ -274,7 +274,7 @@ function ModeIndicator({
const selGetState = useSelection().getState;
const hasNextTick = nextTickAt !== null;
const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false;
const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !(("external" as string) === 'ant' && isPanelAgentTask(t))), [tasks]);
const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !((process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t))), [tasks]);
const tasksV2 = useTasksV2();
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0;
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
@@ -365,7 +365,7 @@ function ModeIndicator({
// its click-target Box isn't nested inside the <Text wrap="truncate">
// wrapper (reconciler throws on Box-in-Text).
// Tmux pill (ant-only) — appears right after tasks in nav order
...(("external" as string) === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []), ...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />] : [])];
...((process.env.USER_TYPE) === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []), ...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />] : [])];
// Check if any in-process teammates exist (for hint text cycling)
const hasAnyInProcessTeammates = Object.values(tasks).some(t_2 => t_2.type === 'in_process_teammate' && t_2.status === 'running');
@@ -399,7 +399,7 @@ function ModeIndicator({
}
// Add "↓ to manage tasks" hint when panel has visible rows
const hasCoordinatorTasks = ("external" as string) === 'ant' && getVisibleAgentTasks(tasks).length > 0;
const hasCoordinatorTasks = (process.env.USER_TYPE) === 'ant' && getVisibleAgentTasks(tasks).length > 0;
// Tasks pill renders as a Box sibling (not a parts entry) so its
// click-target Box isn't nested inside <Text wrap="truncate"> — the

View File

@@ -392,7 +392,7 @@ export function Config({
}
}] : []),
// Speculation toggle (ant-only)
...(("external" as string) === 'ant' ? [{
...((process.env.USER_TYPE) === 'ant' ? [{
id: 'speculationEnabled',
label: 'Speculative execution',
value: globalConfig.speculationEnabled ?? true,

View File

@@ -1,4 +1,4 @@
import { c as _c } from "react/compiler-runtime";
import { c as _c } from 'react/compiler-runtime';
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { Box, Text } from '../ink.js';
import * as React from 'react';
@@ -46,7 +46,15 @@ type Props = {
pauseStartTimeRef: React.RefObject<number | null>;
spinnerTip?: string;
responseLengthRef: React.RefObject<number>;
apiMetricsRef?: React.RefObject<Array<{ ttftMs: number; firstTokenTime: number; lastTokenTime: number; responseLengthBaseline: number; endResponseLength: number }>>;
apiMetricsRef?: React.RefObject<
Array<{
ttftMs: number;
firstTokenTime: number;
lastTokenTime: number;
responseLengthBaseline: number;
endResponseLength: number;
}>
>;
overrideColor?: keyof Theme | null;
overrideShimmerColor?: keyof Theme | null;
overrideMessage?: string | null;
@@ -57,6 +65,9 @@ type Props = {
leaderIsIdle?: boolean;
};
// Polyfill ant-only global functions that are normally injected by the bundler.
const computeTtftText = (metrics: ApiMetricEntry[]): string => '';
// Thin wrapper: branches on isBriefOnly so the two variants have independent
// hook call chains. Without this split, toggling /brief mid-render would
// violate Rules of Hooks (the inner variant calls ~10 more hooks).
@@ -68,14 +79,22 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
// teammate view needs the real spinner (which shows teammate status).
const viewingAgentTaskId = useAppState(s_0 => s_0.viewingAgentTaskId);
// Hoisted to mount-time — this component re-renders at animation framerate.
const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []) : false;
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false;
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
// BriefTool.ts would leak tool-name strings into external builds. Single
// spinner instance → hooks stay unconditional (two subs, negligible).
if ((feature('KAIROS') || feature('KAIROS_BRIEF')) && (getKairosActive() || getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false))) && isBriefOnly && !viewingAgentTaskId) {
if (
(feature('KAIROS') || feature('KAIROS_BRIEF')) &&
(getKairosActive() ||
(getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
isBriefOnly &&
!viewingAgentTaskId
) {
return <BriefSpinner mode={props.mode} overrideMessage={props.overrideMessage} />;
}
return <SpinnerWithVerbInner {...props} />;
@@ -87,13 +106,14 @@ function SpinnerWithVerbInner({
pauseStartTimeRef,
spinnerTip,
responseLengthRef,
apiMetricsRef,
overrideColor,
overrideShimmerColor,
overrideMessage,
spinnerSuffix,
verbose,
hasActiveTools = false,
leaderIsIdle = false
leaderIsIdle = false,
}: Props): React.ReactNode {
const settings = useSettings();
const reducedMotion = settings.prefersReducedMotion ?? false;
@@ -112,13 +132,13 @@ function SpinnerWithVerbInner({
const selectedIPAgentIndex = useAppState(s_2 => s_2.selectedIPAgentIndex);
const viewSelectionMode = useAppState(s_3 => s_3.viewSelectionMode);
// Get foregrounded teammate (if viewing a teammate's transcript)
const foregroundedTeammate = viewingAgentTaskId ? getViewedTeammateTask({
viewingAgentTaskId,
tasks
}) : undefined;
const {
columns
} = useTerminalSize();
const foregroundedTeammate = viewingAgentTaskId
? getViewedTeammateTask({
viewingAgentTaskId,
tasks,
})
: undefined;
const { columns } = useTerminalSize();
const tasksV2 = useTasksV2();
// Track thinking status: 'thinking' | number (duration in ms) | null
@@ -168,7 +188,10 @@ function SpinnerWithVerbInner({
// Leader's own verb (always the leader's, regardless of who is foregrounded)
const leaderVerb = overrideMessage ?? currentTodo?.activeForm ?? currentTodo?.subject ?? randomVerb;
const effectiveVerb = foregroundedTeammate && !foregroundedTeammate.isIdle ? foregroundedTeammate.spinnerVerb ?? randomVerb : leaderVerb;
const effectiveVerb =
foregroundedTeammate && !foregroundedTeammate.isIdle
? (foregroundedTeammate.spinnerVerb ?? randomVerb)
: leaderVerb;
const message = effectiveVerb + '…';
// Track CLI activity when spinner is active
@@ -203,7 +226,10 @@ function SpinnerWithVerbInner({
// Stale read of the refs for showBtwTip below — we're off the 50ms clock
// so this only updates when props/app state change, which is sufficient for
// a coarse 30s threshold.
const elapsedSnapshot = pauseStartTimeRef.current !== null ? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current : Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current;
const elapsedSnapshot =
pauseStartTimeRef.current !== null
? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current
: Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current;
// Leader token count for TeammateSpinnerTree — read raw (non-animated) from
// the ref. The tree is only shown when teammates are running; teammate
@@ -220,7 +246,7 @@ function SpinnerWithVerbInner({
// doesn't trigger re-renders; we pick up updates on the parent's ~25x/turn
// re-render cadence, same as the old ApiMetricsLine did.
let ttftText: string | null = null;
if (("external" as string) === 'ant' && apiMetricsRef?.current && apiMetricsRef.current.length > 0) {
if (process.env.USER_TYPE === 'ant' && apiMetricsRef?.current && apiMetricsRef.current.length > 0) {
ttftText = computeTtftText(apiMetricsRef.current);
}
@@ -228,26 +254,49 @@ function SpinnerWithVerbInner({
// show a static dim idle display instead of the animated spinner — otherwise
// useStalledAnimation detects no new tokens after 3s and turns the spinner red.
if (leaderIsIdle && hasRunningTeammates && !foregroundedTeammate) {
return <Box flexDirection="column" width="100%" alignItems="flex-start">
return (
<Box flexDirection="column" width="100%" alignItems="flex-start">
<Box flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
<Text dimColor>
{TEARDROP_ASTERISK} Idle
{!allIdle && ' · teammates running'}
</Text>
</Box>
{showSpinnerTree && <TeammateSpinnerTree selectedIndex={selectedIPAgentIndex} isInSelectionMode={viewSelectionMode === 'selecting-agent'} allIdle={allIdle} leaderTokenCount={leaderTokenCount} leaderIdleText="Idle" />}
</Box>;
{showSpinnerTree && (
<TeammateSpinnerTree
selectedIndex={selectedIPAgentIndex}
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
allIdle={allIdle}
leaderTokenCount={leaderTokenCount}
leaderIdleText="Idle"
/>
)}
</Box>
);
}
// When viewing an idle teammate, show static idle display instead of animated spinner
if (foregroundedTeammate?.isIdle) {
const idleText = allIdle ? `${TEARDROP_ASTERISK} Worked for ${formatDuration(Date.now() - foregroundedTeammate.startTime)}` : `${TEARDROP_ASTERISK} Idle`;
return <Box flexDirection="column" width="100%" alignItems="flex-start">
const idleText = allIdle
? `${TEARDROP_ASTERISK} Worked for ${formatDuration(Date.now() - foregroundedTeammate.startTime)}`
: `${TEARDROP_ASTERISK} Idle`;
return (
<Box flexDirection="column" width="100%" alignItems="flex-start">
<Box flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
<Text dimColor>{idleText}</Text>
</Box>
{showSpinnerTree && hasRunningTeammates && <TeammateSpinnerTree selectedIndex={selectedIPAgentIndex} isInSelectionMode={viewSelectionMode === 'selecting-agent'} allIdle={allIdle} leaderVerb={leaderIsIdle ? undefined : leaderVerb} leaderIdleText={leaderIsIdle ? 'Idle' : undefined} leaderTokenCount={leaderTokenCount} />}
</Box>;
{showSpinnerTree && hasRunningTeammates && (
<TeammateSpinnerTree
selectedIndex={selectedIPAgentIndex}
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
allIdle={allIdle}
leaderVerb={leaderIsIdle ? undefined : leaderVerb}
leaderIdleText={leaderIsIdle ? 'Idle' : undefined}
leaderTokenCount={leaderTokenCount}
/>
)}
</Box>
);
}
// Time-based tip overrides: coarse thresholds so a stale ref read (we're
@@ -257,7 +306,13 @@ function SpinnerWithVerbInner({
const tipsEnabled = settings.spinnerTipsEnabled !== false;
const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000;
const showBtwTip = tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount;
const effectiveTip = contextTipsActive ? undefined : showClearTip && !nextTask ? 'Use /clear to start fresh when switching topics and free up context' : showBtwTip && !nextTask ? "Use /btw to ask a quick side question without interrupting Claude's current work" : spinnerTip;
const effectiveTip = contextTipsActive
? undefined
: showClearTip && !nextTask
? 'Use /clear to start fresh when switching topics and free up context'
: showBtwTip && !nextTask
? "Use /btw to ask a quick side question without interrupting Claude's current work"
: spinnerTip;
// Budget text (ant-only) — shown above the tip line
let budgetText: string | null = null;
@@ -268,37 +323,77 @@ function SpinnerWithVerbInner({
if (tokens >= budget) {
budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})`;
} else {
const pct = Math.round(tokens / budget * 100);
const pct = Math.round((tokens / budget) * 100);
const remaining = budget - tokens;
const rate = elapsedSnapshot > 5000 && tokens >= 2000 ? tokens / elapsedSnapshot : 0;
const eta = rate > 0 ? ` \u00B7 ~${formatDuration(remaining / rate, {
mostSignificantOnly: true
})}` : '';
const eta =
rate > 0
? ` \u00B7 ~${formatDuration(remaining / rate, {
mostSignificantOnly: true,
})}`
: '';
budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}`;
}
}
}
return <Box flexDirection="column" width="100%" alignItems="flex-start">
<SpinnerAnimationRow mode={mode} reducedMotion={reducedMotion} hasActiveTools={hasActiveTools} responseLengthRef={responseLengthRef} message={message} messageColor={messageColor} shimmerColor={shimmerColor} overrideColor={overrideColor} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} spinnerSuffix={spinnerSuffix} verbose={verbose} columns={columns} hasRunningTeammates={hasRunningTeammates} teammateTokens={teammateTokens} foregroundedTeammate={foregroundedTeammate} leaderIsIdle={leaderIsIdle} thinkingStatus={thinkingStatus} effortSuffix={effortSuffix} />
{showSpinnerTree && hasRunningTeammates ? <TeammateSpinnerTree selectedIndex={selectedIPAgentIndex} isInSelectionMode={viewSelectionMode === 'selecting-agent'} allIdle={allIdle} leaderVerb={leaderIsIdle ? undefined : leaderVerb} leaderIdleText={leaderIsIdle ? 'Idle' : undefined} leaderTokenCount={leaderTokenCount} /> : showExpandedTodos && tasksV2 && tasksV2.length > 0 ? <Box width="100%" flexDirection="column">
return (
<Box flexDirection="column" width="100%" alignItems="flex-start">
<SpinnerAnimationRow
mode={mode}
reducedMotion={reducedMotion}
hasActiveTools={hasActiveTools}
responseLengthRef={responseLengthRef}
message={message}
messageColor={messageColor}
shimmerColor={shimmerColor}
overrideColor={overrideColor}
loadingStartTimeRef={loadingStartTimeRef}
totalPausedMsRef={totalPausedMsRef}
pauseStartTimeRef={pauseStartTimeRef}
spinnerSuffix={spinnerSuffix}
verbose={verbose}
columns={columns}
hasRunningTeammates={hasRunningTeammates}
teammateTokens={teammateTokens}
foregroundedTeammate={foregroundedTeammate}
leaderIsIdle={leaderIsIdle}
thinkingStatus={thinkingStatus}
effortSuffix={effortSuffix}
/>
{showSpinnerTree && hasRunningTeammates ? (
<TeammateSpinnerTree
selectedIndex={selectedIPAgentIndex}
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
allIdle={allIdle}
leaderVerb={leaderIsIdle ? undefined : leaderVerb}
leaderIdleText={leaderIsIdle ? 'Idle' : undefined}
leaderTokenCount={leaderTokenCount}
/>
) : showExpandedTodos && tasksV2 && tasksV2.length > 0 ? (
<Box width="100%" flexDirection="column">
<MessageResponse>
<TaskListV2 tasks={tasksV2} />
</MessageResponse>
</Box> : nextTask || effectiveTip || budgetText ?
// IMPORTANT: we need this width="100%" to avoid an Ink bug where the
// tip gets duplicated over and over while the spinner is running if
// the terminal is very small. TODO: fix this in Ink.
<Box width="100%" flexDirection="column">
{budgetText && <MessageResponse>
</Box>
) : nextTask || effectiveTip || budgetText ? (
// IMPORTANT: we need this width="100%" to avoid an Ink bug where the
// tip gets duplicated over and over while the spinner is running if
// the terminal is very small. TODO: fix this in Ink.
<Box width="100%" flexDirection="column">
{budgetText && (
<MessageResponse>
<Text dimColor>{budgetText}</Text>
</MessageResponse>}
{(nextTask || effectiveTip) && <MessageResponse>
<Text dimColor>
{nextTask ? `Next: ${nextTask.subject}` : `Tip: ${effectiveTip}`}
</Text>
</MessageResponse>}
</Box> : null}
</Box>;
</MessageResponse>
)}
{(nextTask || effectiveTip) && (
<MessageResponse>
<Text dimColor>{nextTask ? `Next: ${nextTask.subject}` : `Tip: ${effectiveTip}`}</Text>
</MessageResponse>
)}
</Box>
) : null}
</Box>
);
}
// Brief/assistant mode spinner: single status line. PromptInput drops its
@@ -316,10 +411,7 @@ type BriefSpinnerProps = {
};
function BriefSpinner(t0) {
const $ = _c(31);
const {
mode,
overrideMessage
} = t0;
const { mode, overrideMessage } = t0;
const settings = useSettings();
const reducedMotion = settings.prefersReducedMotion ?? false;
const [randomVerb] = useState(_temp4);
@@ -329,7 +421,7 @@ function BriefSpinner(t0) {
let t2;
if ($[0] !== mode) {
t1 = () => {
const operationId = "spinner-" + mode;
const operationId = 'spinner-' + mode;
activityManager.startCLIActivity(operationId);
return () => {
activityManager.endCLIActivity(operationId);
@@ -346,12 +438,12 @@ function BriefSpinner(t0) {
useEffect(t1, t2);
const [, time] = useAnimationFrame(reducedMotion ? null : 120);
const runningCount = useAppState(_temp6);
const showConnWarning = connStatus === "reconnecting" || connStatus === "disconnected";
const connText = connStatus === "reconnecting" ? "Reconnecting" : "Disconnected";
const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected';
const connText = connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected';
const dotFrame = Math.floor(time / 300) % 3;
let t3;
if ($[3] !== dotFrame || $[4] !== reducedMotion) {
t3 = reducedMotion ? "\u2026 " : ".".repeat(dotFrame + 1).padEnd(3);
t3 = reducedMotion ? '\u2026 ' : '.'.repeat(dotFrame + 1).padEnd(3);
$[3] = dotFrame;
$[4] = reducedMotion;
$[5] = t3;
@@ -370,7 +462,8 @@ function BriefSpinner(t0) {
const verbWidth = t4;
let t5;
if ($[8] !== reducedMotion || $[9] !== showConnWarning || $[10] !== time || $[11] !== verb || $[12] !== verbWidth) {
const glimmerIndex = reducedMotion || showConnWarning ? -100 : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth);
const glimmerIndex =
reducedMotion || showConnWarning ? -100 : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth);
t5 = computeShimmerSegments(verb, glimmerIndex);
$[8] = reducedMotion;
$[9] = showConnWarning;
@@ -381,15 +474,9 @@ function BriefSpinner(t0) {
} else {
t5 = $[13];
}
const {
before,
shimmer,
after
} = t5;
const {
columns
} = useTerminalSize();
const rightText = runningCount > 0 ? `${runningCount} in background` : "";
const { before, shimmer, after } = t5;
const { columns } = useTerminalSize();
const rightText = runningCount > 0 ? `${runningCount} in background` : '';
let t6;
if ($[14] !== connText || $[15] !== showConnWarning || $[16] !== verbWidth) {
t6 = showConnWarning ? stringWidth(connText) : verbWidth;
@@ -403,8 +490,24 @@ function BriefSpinner(t0) {
const leftWidth = t6 + 3;
const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText));
let t7;
if ($[18] !== after || $[19] !== before || $[20] !== connText || $[21] !== dots || $[22] !== shimmer || $[23] !== showConnWarning) {
t7 = showConnWarning ? <Text color="error">{connText + dots}</Text> : <>{before ? <Text dimColor={true}>{before}</Text> : null}{shimmer ? <Text>{shimmer}</Text> : null}{after ? <Text dimColor={true}>{after}</Text> : null}<Text dimColor={true}>{dots}</Text></>;
if (
$[18] !== after ||
$[19] !== before ||
$[20] !== connText ||
$[21] !== dots ||
$[22] !== shimmer ||
$[23] !== showConnWarning
) {
t7 = showConnWarning ? (
<Text color="error">{connText + dots}</Text>
) : (
<>
{before ? <Text dimColor={true}>{before}</Text> : null}
{shimmer ? <Text>{shimmer}</Text> : null}
{after ? <Text dimColor={true}>{after}</Text> : null}
<Text dimColor={true}>{dots}</Text>
</>
);
$[18] = after;
$[19] = before;
$[20] = connText;
@@ -417,7 +520,12 @@ function BriefSpinner(t0) {
}
let t8;
if ($[25] !== pad || $[26] !== rightText) {
t8 = rightText ? <><Text>{" ".repeat(pad)}</Text><Text color="subtle">{rightText}</Text></> : null;
t8 = rightText ? (
<>
<Text>{' '.repeat(pad)}</Text>
<Text color="subtle">{rightText}</Text>
</>
) : null;
$[25] = pad;
$[26] = rightText;
$[27] = t8;
@@ -426,7 +534,12 @@ function BriefSpinner(t0) {
}
let t9;
if ($[28] !== t7 || $[29] !== t8) {
t9 = <Box flexDirection="row" width="100%" marginTop={1} paddingLeft={2}>{t7}{t8}</Box>;
t9 = (
<Box flexDirection="row" width="100%" marginTop={1} paddingLeft={2}>
{t7}
{t8}
</Box>
);
$[28] = t7;
$[29] = t8;
$[30] = t9;
@@ -447,22 +560,20 @@ function _temp5(s) {
return s.remoteConnectionStatus;
}
function _temp4() {
return sample(getSpinnerVerbs()) ?? "Working";
return sample(getSpinnerVerbs()) ?? 'Working';
}
export function BriefIdleStatus() {
const $ = _c(9);
const connStatus = useAppState(_temp7);
const runningCount = useAppState(_temp8);
const {
columns
} = useTerminalSize();
const showConnWarning = connStatus === "reconnecting" || connStatus === "disconnected";
const connText = connStatus === "reconnecting" ? "Reconnecting\u2026" : "Disconnected";
const leftText = showConnWarning ? connText : "";
const rightText = runningCount > 0 ? `${runningCount} in background` : "";
const { columns } = useTerminalSize();
const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected';
const connText = connStatus === 'reconnecting' ? 'Reconnecting\u2026' : 'Disconnected';
const leftText = showConnWarning ? connText : '';
const rightText = runningCount > 0 ? `${runningCount} in background` : '';
if (!leftText && !rightText) {
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
t0 = <Box height={2} />;
$[0] = t0;
} else {
@@ -481,7 +592,12 @@ export function BriefIdleStatus() {
}
let t1;
if ($[3] !== pad || $[4] !== rightText) {
t1 = rightText ? <><Text>{" ".repeat(pad)}</Text><Text color="subtle">{rightText}</Text></> : null;
t1 = rightText ? (
<>
<Text>{' '.repeat(pad)}</Text>
<Text color="subtle">{rightText}</Text>
</>
) : null;
$[3] = pad;
$[4] = rightText;
$[5] = t1;
@@ -490,7 +606,14 @@ export function BriefIdleStatus() {
}
let t2;
if ($[6] !== t0 || $[7] !== t1) {
t2 = <Box marginTop={1} paddingLeft={2}><Text>{t0}{t1}</Text></Box>;
t2 = (
<Box marginTop={1} paddingLeft={2}>
<Text>
{t0}
{t1}
</Text>
</Box>
);
$[6] = t0;
$[7] = t1;
$[8] = t2;
@@ -512,7 +635,7 @@ export function Spinner() {
const [ref, time] = useAnimationFrame(reducedMotion ? null : 120);
if (reducedMotion) {
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
t0 = <Text color="text"></Text>;
$[0] = t0;
} else {
@@ -520,7 +643,11 @@ export function Spinner() {
}
let t1;
if ($[1] !== ref) {
t1 = <Box ref={ref} flexWrap="wrap" height={1} width={2}>{t0}</Box>;
t1 = (
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
{t0}
</Box>
);
$[1] = ref;
$[2] = t1;
} else {
@@ -540,7 +667,11 @@ export function Spinner() {
}
let t2;
if ($[5] !== ref || $[6] !== t1) {
t2 = <Box ref={ref} flexWrap="wrap" height={1} width={2}>{t1}</Box>;
t2 = (
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
{t1}
</Box>
);
$[5] = ref;
$[6] = t1;
$[7] = t2;

View File

@@ -512,7 +512,7 @@ function OverviewTab({
</Box>
{/* Speculation time saved (ant-only) */}
{("external" as string) === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && <Box flexDirection="row" gap={4}>
{(process.env.USER_TYPE) === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && <Box flexDirection="row" gap={4}>
<Box flexDirection="column" width={28}>
<Text wrap="truncate">
Speculation saved:{' '}
@@ -1151,7 +1151,7 @@ function renderOverviewToAnsi(stats: ClaudeCodeStats): string[] {
lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal));
// Speculation time saved (ant-only)
if (("external" as string) === 'ant' && stats.totalSpeculationTimeSavedMs > 0) {
if ((process.env.USER_TYPE) === 'ant' && stats.totalSpeculationTimeSavedMs > 0) {
const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH);
lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs)));
}

View File

@@ -0,0 +1,9 @@
// Stub — ant-only component, not available in decompiled build
import React, { useEffect } from 'react';
export function UndercoverAutoCallout({ onDone }: { onDone: () => void }): React.ReactElement | null {
useEffect(() => {
onDone();
}, [onDone]);
return null;
}

View File

@@ -58,7 +58,7 @@ function getToolBuckets(): ToolBuckets {
},
EXECUTION: {
name: 'Execution tools',
toolNames: new Set([BashTool.name, ("external" as string) === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined))
toolNames: new Set([BashTool.name, (process.env.USER_TYPE) === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined))
},
MCP: {
name: 'MCP tools',

View File

@@ -114,7 +114,7 @@ export function AttachmentMessage({
// names — shortId is undefined outside ant builds anyway.
const names = attachment.skills.map(s => s.shortId ? `${s.name} [${s.shortId}]` : s.name).join(', ');
const firstId = attachment.skills[0]?.shortId;
const hint = ("external" as string) === 'ant' && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` : '';
const hint = (process.env.USER_TYPE) === 'ant' && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` : '';
return <Line>
<Text bold>{attachment.skills.length}</Text> relevant{' '}
{plural(attachment.skills.length, 'skill')}: {names}

View File

@@ -112,7 +112,7 @@ export function bashToolUseOptions({
// Skip when the editable prefix option is already shown — they serve the
// same role and having two identical-looking "don't ask again" inputs is confusing.
const editablePrefixShown = options.some(o => o.value === 'yes-prefix-edited');
if (("external" as string) === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && onClassifierDescriptionChange && !initialClassifierDescriptionEmpty && !descriptionAlreadyExists(classifierDescription ?? '', existingAllowDescriptions) && decisionReason?.type !== 'classifier') {
if ((process.env.USER_TYPE) === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && onClassifierDescriptionChange && !initialClassifierDescriptionEmpty && !descriptionAlreadyExists(classifierDescription ?? '', existingAllowDescriptions) && decisionReason?.type !== 'classifier') {
options.push({
type: 'input',
label: 'Yes, and don\u2019t ask again for',

View File

@@ -96,7 +96,7 @@ export function shouldHideTasksFooter(tasks: {
if (!showSpinnerTree) return false;
let hasVisibleTask = false;
for (const t of Object.values(tasks) as TaskState[]) {
if (!isBackgroundTask(t) || ("external" as string) === 'ant' && isPanelAgentTask(t)) {
if (!isBackgroundTask(t) || (process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t)) {
continue;
}
hasVisibleTask = true;

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env bun
import { feature } from 'bun:bundle'
// Runtime polyfill for bun:bundle (build-time macros)
const feature = (name: string) => name === "BUDDY";
if (typeof globalThis.MACRO === "undefined") {
@@ -21,17 +20,15 @@ if (typeof globalThis.MACRO === "undefined") {
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
// eslint-disable-next-line custom-rules/no-top-level-side-effects
process.env.COREPACK_ENABLE_AUTO_PIN = "0";
process.env.COREPACK_ENABLE_AUTO_PIN = '0';
// Set max heap size for child processes in CCR environments (containers have 16GB)
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level, custom-rules/safe-env-boolean-check
if (process.env.CLAUDE_CODE_REMOTE === "true") {
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
const existing = process.env.NODE_OPTIONS || "";
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
process.env.NODE_OPTIONS = existing
? `${existing} --max-old-space-size=8192`
: "--max-old-space-size=8192";
if (process.env.CLAUDE_CODE_REMOTE === 'true') {
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
const existing = process.env.NODE_OPTIONS || '';
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
process.env.NODE_OPTIONS = existing ? `${existing} --max-old-space-size=8192` : '--max-old-space-size=8192';
}
// Harness-science L0 ablation baseline. Inlined here (not init.ts) because
@@ -39,19 +36,19 @@ if (process.env.CLAUDE_CODE_REMOTE === "true") {
// module-level consts at import time — init() runs too late. feature() gate
// DCEs this entire block from external builds.
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
if (feature("ABLATION_BASELINE") && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
for (const k of [
"CLAUDE_CODE_SIMPLE",
"CLAUDE_CODE_DISABLE_THINKING",
"DISABLE_INTERLEAVED_THINKING",
"DISABLE_COMPACT",
"DISABLE_AUTO_COMPACT",
"CLAUDE_CODE_DISABLE_AUTO_MEMORY",
"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS",
]) {
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
process.env[k] ??= "1";
}
if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
for (const k of [
'CLAUDE_CODE_SIMPLE',
'CLAUDE_CODE_DISABLE_THINKING',
'DISABLE_INTERLEAVED_THINKING',
'DISABLE_COMPACT',
'DISABLE_AUTO_COMPACT',
'CLAUDE_CODE_DISABLE_AUTO_MEMORY',
'CLAUDE_CODE_DISABLE_BACKGROUND_TASKS',
]) {
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
process.env[k] ??= '1';
}
}
/**
@@ -60,262 +57,231 @@ if (feature("ABLATION_BASELINE") && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
* Fast-path for --version has zero imports beyond this file.
*/
async function main(): Promise<void> {
const args = process.argv.slice(2);
const args = process.argv.slice(2);
// Fast-path for --version/-v: zero module loading needed
if (
args.length === 1 &&
(args[0] === "--version" || args[0] === "-v" || args[0] === "-V")
) {
// MACRO.VERSION is inlined at build time
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${MACRO.VERSION} (Claude Code)`);
// Fast-path for --version/-v: zero module loading needed
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
// MACRO.VERSION is inlined at build time
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${MACRO.VERSION} (Claude Code)`);
return;
}
// For all other paths, load the startup profiler
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
profileCheckpoint('cli_entry');
// Fast-path for --dump-system-prompt: output the rendered system prompt and exit.
// Used by prompt sensitivity evals to extract the system prompt at a specific commit.
// Ant-only: eliminated from external builds via feature flag.
if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
profileCheckpoint('cli_dump_system_prompt_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { getMainLoopModel } = await import('../utils/model/model.js');
const modelIdx = args.indexOf('--model');
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel();
const { getSystemPrompt } = await import('../constants/prompts.js');
const prompt = await getSystemPrompt([], model);
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(prompt.join('\n'));
return;
}
if (process.argv[2] === '--claude-in-chrome-mcp') {
profileCheckpoint('cli_claude_in_chrome_mcp_path');
const { runClaudeInChromeMcpServer } = await import('../utils/claudeInChrome/mcpServer.js');
await runClaudeInChromeMcpServer();
return;
} else if (process.argv[2] === '--chrome-native-host') {
profileCheckpoint('cli_chrome_native_host_path');
const { runChromeNativeHost } = await import('../utils/claudeInChrome/chromeNativeHost.js');
await runChromeNativeHost();
return;
} else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') {
profileCheckpoint('cli_computer_use_mcp_path');
const { runComputerUseMcpServer } = await import('../utils/computerUse/mcpServer.js');
await runComputerUseMcpServer();
return;
}
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
// Must come before the daemon subcommand check: spawned per-worker, so
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
// workers are lean. If a worker kind needs configs/auth (assistant will),
// it calls them inside its run() fn.
if (feature('DAEMON') && args[0] === '--daemon-worker') {
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
await runDaemonWorker(args[1]);
return;
}
// Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`):
// serve local machine as bridge environment.
// feature() must stay inline for build-time dead code elimination;
// isBridgeEnabled() checks the runtime GrowthBook gate.
if (
feature('BRIDGE_MODE') &&
(args[0] === 'remote-control' ||
args[0] === 'rc' ||
args[0] === 'remote' ||
args[0] === 'sync' ||
args[0] === 'bridge')
) {
profileCheckpoint('cli_bridge_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { getBridgeDisabledReason, checkBridgeMinVersion } = await import('../bridge/bridgeEnabled.js');
const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js');
const { bridgeMain } = await import('../bridge/bridgeMain.js');
const { exitWithError } = await import('../utils/process.js');
// Auth check must come before the GrowthBook gate check — without auth,
// GrowthBook has no user context and would return a stale/default false.
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
// (not the stale disk cache), but init still needs auth headers to work.
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js');
if (!getClaudeAIOAuthTokens()?.accessToken) {
exitWithError(BRIDGE_LOGIN_ERROR);
}
const disabledReason = await getBridgeDisabledReason();
if (disabledReason) {
exitWithError(`Error: ${disabledReason}`);
}
const versionError = checkBridgeMinVersion();
if (versionError) {
exitWithError(versionError);
}
// Bridge is a remote control feature - check policy limits
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../services/policyLimits/index.js');
await waitForPolicyLimitsToLoad();
if (!isPolicyAllowed('allow_remote_control')) {
exitWithError("Error: Remote Control is disabled by your organization's policy.");
}
await bridgeMain(args.slice(1));
return;
}
// Fast-path for `claude daemon [subcommand]`: long-running supervisor.
if (feature('DAEMON') && args[0] === 'daemon') {
profileCheckpoint('cli_daemon_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { initSinks } = await import('../utils/sinks.js');
initSinks();
const { daemonMain } = await import('../daemon/main.js');
await daemonMain(args.slice(1));
return;
}
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`.
// Session management against the ~/.claude/sessions/ registry. Flag
// literals are inlined so bg.js only loads when actually dispatching.
if (
feature('BG_SESSIONS') &&
(args[0] === 'ps' ||
args[0] === 'logs' ||
args[0] === 'attach' ||
args[0] === 'kill' ||
args.includes('--bg') ||
args.includes('--background'))
) {
profileCheckpoint('cli_bg_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const bg = await import('../cli/bg.js');
switch (args[0]) {
case 'ps':
await bg.psHandler(args.slice(1));
break;
case 'logs':
await bg.logsHandler(args[1]);
break;
case 'attach':
await bg.attachHandler(args[1]);
break;
case 'kill':
await bg.killHandler(args[1]);
break;
default:
await bg.handleBgFlag(args);
}
return;
}
// Fast-path for template job commands.
if (feature('TEMPLATES') && (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')) {
profileCheckpoint('cli_templates_path');
const { templatesMain } = await import('../cli/handlers/templateJobs.js');
await templatesMain(args);
// process.exit (not return) — mountFleetView's Ink TUI can leave event
// loop handles that prevent natural exit.
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0);
}
// Fast-path for `claude environment-runner`: headless BYOC runner.
// feature() must stay inline for build-time dead code elimination.
if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') {
profileCheckpoint('cli_environment_runner_path');
const { environmentRunnerMain } = await import('../environment-runner/main.js');
await environmentRunnerMain(args.slice(1));
return;
}
// Fast-path for `claude self-hosted-runner`: headless self-hosted-runner
// targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS
// heartbeat). feature() must stay inline for build-time dead code elimination.
if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') {
profileCheckpoint('cli_self_hosted_runner_path');
const { selfHostedRunnerMain } = await import('../self-hosted-runner/main.js');
await selfHostedRunnerMain(args.slice(1));
return;
}
// Fast-path for --worktree --tmux: exec into tmux before loading full CLI
const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic');
if (
hasTmuxFlag &&
(args.includes('-w') || args.includes('--worktree') || args.some(a => a.startsWith('--worktree=')))
) {
profileCheckpoint('cli_tmux_worktree_fast_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { isWorktreeModeEnabled } = await import('../utils/worktreeModeEnabled.js');
if (isWorktreeModeEnabled()) {
const { execIntoTmuxWorktree } = await import('../utils/worktree.js');
const result = await execIntoTmuxWorktree(args);
if (result.handled) {
return;
}
// If not handled (e.g., error), fall through to normal CLI
if (result.error) {
const { exitWithError } = await import('../utils/process.js');
exitWithError(result.error);
}
}
}
// For all other paths, load the startup profiler
const { profileCheckpoint } = await import("../utils/startupProfiler.js");
profileCheckpoint("cli_entry");
// Redirect common update flag mistakes to the update subcommand
if (args.length === 1 && (args[0] === '--update' || args[0] === '--upgrade')) {
process.argv = [process.argv[0]!, process.argv[1]!, 'update'];
}
// Fast-path for --dump-system-prompt: output the rendered system prompt and exit.
// Used by prompt sensitivity evals to extract the system prompt at a specific commit.
// Ant-only: eliminated from external builds via feature flag.
if (feature("DUMP_SYSTEM_PROMPT") && args[0] === "--dump-system-prompt") {
profileCheckpoint("cli_dump_system_prompt_path");
const { enableConfigs } = await import("../utils/config.js");
enableConfigs();
const { getMainLoopModel } = await import("../utils/model/model.js");
const modelIdx = args.indexOf("--model");
const model =
(modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel();
const { getSystemPrompt } = await import("../constants/prompts.js");
const prompt = await getSystemPrompt([], model);
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(prompt.join("\n"));
return;
}
if (process.argv[2] === "--claude-in-chrome-mcp") {
profileCheckpoint("cli_claude_in_chrome_mcp_path");
const { runClaudeInChromeMcpServer } =
await import("../utils/claudeInChrome/mcpServer.js");
await runClaudeInChromeMcpServer();
return;
} else if (process.argv[2] === "--chrome-native-host") {
profileCheckpoint("cli_chrome_native_host_path");
const { runChromeNativeHost } =
await import("../utils/claudeInChrome/chromeNativeHost.js");
await runChromeNativeHost();
return;
} else if (
feature("CHICAGO_MCP") &&
process.argv[2] === "--computer-use-mcp"
) {
profileCheckpoint("cli_computer_use_mcp_path");
const { runComputerUseMcpServer } =
await import("../utils/computerUse/mcpServer.js");
await runComputerUseMcpServer();
return;
}
// --bare: set SIMPLE early so gates fire during module eval / commander
// option building (not just inside the action handler).
if (args.includes('--bare')) {
process.env.CLAUDE_CODE_SIMPLE = '1';
}
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
// Must come before the daemon subcommand check: spawned per-worker, so
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
// workers are lean. If a worker kind needs configs/auth (assistant will),
// it calls them inside its run() fn.
if (feature("DAEMON") && args[0] === "--daemon-worker") {
const { runDaemonWorker } = await import("../daemon/workerRegistry.js");
await runDaemonWorker(args[1]);
return;
}
// Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`):
// serve local machine as bridge environment.
// feature() must stay inline for build-time dead code elimination;
// isBridgeEnabled() checks the runtime GrowthBook gate.
if (
feature("BRIDGE_MODE") &&
(args[0] === "remote-control" ||
args[0] === "rc" ||
args[0] === "remote" ||
args[0] === "sync" ||
args[0] === "bridge")
) {
profileCheckpoint("cli_bridge_path");
const { enableConfigs } = await import("../utils/config.js");
enableConfigs();
const { getBridgeDisabledReason, checkBridgeMinVersion } =
await import("../bridge/bridgeEnabled.js");
const { BRIDGE_LOGIN_ERROR } = await import("../bridge/types.js");
const { bridgeMain } = await import("../bridge/bridgeMain.js");
const { exitWithError } = await import("../utils/process.js");
// Auth check must come before the GrowthBook gate check — without auth,
// GrowthBook has no user context and would return a stale/default false.
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
// (not the stale disk cache), but init still needs auth headers to work.
const { getClaudeAIOAuthTokens } = await import("../utils/auth.js");
if (!getClaudeAIOAuthTokens()?.accessToken) {
exitWithError(BRIDGE_LOGIN_ERROR);
}
const disabledReason = await getBridgeDisabledReason();
if (disabledReason) {
exitWithError(`Error: ${disabledReason}`);
}
const versionError = checkBridgeMinVersion();
if (versionError) {
exitWithError(versionError);
}
// Bridge is a remote control feature - check policy limits
const { waitForPolicyLimitsToLoad, isPolicyAllowed } =
await import("../services/policyLimits/index.js");
await waitForPolicyLimitsToLoad();
if (!isPolicyAllowed("allow_remote_control")) {
exitWithError(
"Error: Remote Control is disabled by your organization's policy.",
);
}
await bridgeMain(args.slice(1));
return;
}
// Fast-path for `claude daemon [subcommand]`: long-running supervisor.
if (feature("DAEMON") && args[0] === "daemon") {
profileCheckpoint("cli_daemon_path");
const { enableConfigs } = await import("../utils/config.js");
enableConfigs();
const { initSinks } = await import("../utils/sinks.js");
initSinks();
const { daemonMain } = await import("../daemon/main.js");
await daemonMain(args.slice(1));
return;
}
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`.
// Session management against the ~/.claude/sessions/ registry. Flag
// literals are inlined so bg.js only loads when actually dispatching.
if (
feature("BG_SESSIONS") &&
(args[0] === "ps" ||
args[0] === "logs" ||
args[0] === "attach" ||
args[0] === "kill" ||
args.includes("--bg") ||
args.includes("--background"))
) {
profileCheckpoint("cli_bg_path");
const { enableConfigs } = await import("../utils/config.js");
enableConfigs();
const bg = await import("../cli/bg.js");
switch (args[0]) {
case "ps":
await bg.psHandler(args.slice(1));
break;
case "logs":
await bg.logsHandler(args[1]);
break;
case "attach":
await bg.attachHandler(args[1]);
break;
case "kill":
await bg.killHandler(args[1]);
break;
default:
await bg.handleBgFlag(args);
}
return;
}
// Fast-path for template job commands.
if (
feature("TEMPLATES") &&
(args[0] === "new" || args[0] === "list" || args[0] === "reply")
) {
profileCheckpoint("cli_templates_path");
const { templatesMain } =
await import("../cli/handlers/templateJobs.js");
await templatesMain(args);
// process.exit (not return) — mountFleetView's Ink TUI can leave event
// loop handles that prevent natural exit.
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0);
}
// Fast-path for `claude environment-runner`: headless BYOC runner.
// feature() must stay inline for build-time dead code elimination.
if (
feature("BYOC_ENVIRONMENT_RUNNER") &&
args[0] === "environment-runner"
) {
profileCheckpoint("cli_environment_runner_path");
const { environmentRunnerMain } =
await import("../environment-runner/main.js");
await environmentRunnerMain(args.slice(1));
return;
}
// Fast-path for `claude self-hosted-runner`: headless self-hosted-runner
// targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS
// heartbeat). feature() must stay inline for build-time dead code elimination.
if (feature("SELF_HOSTED_RUNNER") && args[0] === "self-hosted-runner") {
profileCheckpoint("cli_self_hosted_runner_path");
const { selfHostedRunnerMain } =
await import("../self-hosted-runner/main.js");
await selfHostedRunnerMain(args.slice(1));
return;
}
// Fast-path for --worktree --tmux: exec into tmux before loading full CLI
const hasTmuxFlag =
args.includes("--tmux") || args.includes("--tmux=classic");
if (
hasTmuxFlag &&
(args.includes("-w") ||
args.includes("--worktree") ||
args.some((a) => a.startsWith("--worktree=")))
) {
profileCheckpoint("cli_tmux_worktree_fast_path");
const { enableConfigs } = await import("../utils/config.js");
enableConfigs();
const { isWorktreeModeEnabled } =
await import("../utils/worktreeModeEnabled.js");
if (isWorktreeModeEnabled()) {
const { execIntoTmuxWorktree } =
await import("../utils/worktree.js");
const result = await execIntoTmuxWorktree(args);
if (result.handled) {
return;
}
// If not handled (e.g., error), fall through to normal CLI
if (result.error) {
const { exitWithError } = await import("../utils/process.js");
exitWithError(result.error);
}
}
}
// Redirect common update flag mistakes to the update subcommand
if (
args.length === 1 &&
(args[0] === "--update" || args[0] === "--upgrade")
) {
process.argv = [process.argv[0]!, process.argv[1]!, "update"];
}
// --bare: set SIMPLE early so gates fire during module eval / commander
// option building (not just inside the action handler).
if (args.includes("--bare")) {
process.env.CLAUDE_CODE_SIMPLE = "1";
}
// No special flags detected, load and run the full CLI
const { startCapturingEarlyInput } = await import("../utils/earlyInput.js");
startCapturingEarlyInput();
profileCheckpoint("cli_before_main_import");
const { main: cliMain } = await import("../main.jsx");
profileCheckpoint("cli_after_main_import");
await cliMain();
profileCheckpoint("cli_after_main_complete");
// No special flags detected, load and run the full CLI
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js');
startCapturingEarlyInput();
profileCheckpoint('cli_before_main_import');
const { main: cliMain } = await import('../main.jsx');
profileCheckpoint('cli_after_main_import');
await cliMain();
profileCheckpoint('cli_after_main_complete');
}
// eslint-disable-next-line custom-rules/no-top-level-side-effects

View File

@@ -262,13 +262,10 @@ function isBeingDebugged() {
}
}
// Exit if we detect node debugging or inspection
if (("external" as string) !== 'ant' && isBeingDebugged()) {
// Use process.exit directly here since we're in the top-level code before imports
// and gracefulShutdown is not yet available
// eslint-disable-next-line custom-rules/no-top-level-side-effects
process.exit(1);
}
// Anti-debugging check disabled for local development
// if ((process.env.USER_TYPE) !== 'ant' && isBeingDebugged()) {
// process.exit(1);
// }
/**
* Per-session skill/plugin telemetry. Called from both the interactive path
@@ -337,7 +334,7 @@ function runMigrations(): void {
if (feature('TRANSCRIPT_CLASSIFIER')) {
resetAutoModeOptInForDefaultOffer();
}
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
migrateFennecToOpus();
}
saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION ? prev : {
@@ -425,7 +422,7 @@ export function startDeferredPrefetches(): void {
}
// Event loop stall detector — logs when the main thread is blocked >500ms
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
void import('./utils/eventLoopStallDetector.js').then(m => m.startEventLoopStallDetector());
}
}
@@ -1134,11 +1131,11 @@ async function run(): Promise<CommanderCommand> {
const disableSlashCommands = options.disableSlashCommands || false;
// Extract tasks mode options (ant-only)
const tasksOption = ("external" as string) === 'ant' && (options as {
const tasksOption = (process.env.USER_TYPE) === 'ant' && (options as {
tasks?: boolean | string;
}).tasks;
const taskListId = tasksOption ? typeof tasksOption === 'string' ? tasksOption : DEFAULT_TASKS_MODE_TASK_LIST_ID : undefined;
if (("external" as string) === 'ant' && taskListId) {
if ((process.env.USER_TYPE) === 'ant' && taskListId) {
process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId;
}
@@ -1528,7 +1525,7 @@ async function run(): Promise<CommanderCommand> {
};
// Store the explicit CLI flag so teammates can inherit it
setChromeFlagOverride(chromeOpts.chrome);
const enableClaudeInChrome = shouldEnableClaudeInChrome(chromeOpts.chrome) && (("external" as string) === 'ant' || isClaudeAISubscriber());
const enableClaudeInChrome = shouldEnableClaudeInChrome(chromeOpts.chrome) && ((process.env.USER_TYPE) === 'ant' || isClaudeAISubscriber());
const autoEnableClaudeInChrome = !enableClaudeInChrome && shouldAutoEnableClaudeInChrome();
if (enableClaudeInChrome) {
const platform = getPlatform();
@@ -1760,7 +1757,7 @@ async function run(): Promise<CommanderCommand> {
} = initResult;
// Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*))
if (("external" as string) === 'ant' && overlyBroadBashPermissions.length > 0) {
if ((process.env.USER_TYPE) === 'ant' && overlyBroadBashPermissions.length > 0) {
for (const permission of overlyBroadBashPermissions) {
logForDebugging(`Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`);
}
@@ -2010,7 +2007,7 @@ async function run(): Promise<CommanderCommand> {
// - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk)
// - flag absent from disk (== null also catches pre-#22279 poisoned null)
const explicitModel = options.model || process.env.ANTHROPIC_MODEL;
if (("external" as string) === 'ant' && explicitModel && explicitModel !== 'default' && !hasGrowthBookEnvOverride('tengu_ant_model_override') && getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null) {
if ((process.env.USER_TYPE) === 'ant' && explicitModel && explicitModel !== 'default' && !hasGrowthBookEnvOverride('tengu_ant_model_override') && getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null) {
await initializeGrowthBook();
}
@@ -2156,7 +2153,7 @@ async function run(): Promise<CommanderCommand> {
// Log agent memory loaded event for tmux teammates
if (customAgent.memory) {
logEvent('tengu_agent_memory_loaded', {
...(("external" as string) === 'ant' && {
...((process.env.USER_TYPE) === 'ant' && {
agent_type: customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
scope: customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -2220,7 +2217,7 @@ async function run(): Promise<CommanderCommand> {
getFpsMetrics = ctx.getFpsMetrics;
stats = ctx.stats;
// Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1)
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
installAsciicastRecorder();
}
const {
@@ -2816,7 +2813,7 @@ async function run(): Promise<CommanderCommand> {
if (!isBareMode()) {
startDeferredPrefetches();
void import('./utils/backgroundHousekeeping.js').then(m => m.startBackgroundHousekeeping());
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor());
}
}
@@ -3061,7 +3058,7 @@ async function run(): Promise<CommanderCommand> {
// - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth.
// - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this).
// Import is dynamic + async to avoid adding startup latency.
const sessionUploaderPromise = ("external" as string) === 'ant' ? import('./utils/sessionDataUploader.js') : null;
const sessionUploaderPromise = (process.env.USER_TYPE) === 'ant' ? import('./utils/sessionDataUploader.js') : null;
// Defer session uploader resolution to the onTurnComplete callback to avoid
// adding a new top-level await in main.tsx (performance-critical path).
@@ -3578,7 +3575,7 @@ async function run(): Promise<CommanderCommand> {
}
}
}
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
if (options.resume && typeof options.resume === 'string' && !maybeSessionId) {
// Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036)
const {
@@ -3813,7 +3810,7 @@ async function run(): Promise<CommanderCommand> {
if (canUserConfigureAdvisor()) {
program.addOption(new Option('--advisor <model>', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp());
}
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
program.addOption(new Option('--delegate-permissions', '[ANT-ONLY] Alias for --permission-mode auto.').implies({
permissionMode: 'auto'
}));
@@ -4367,7 +4364,7 @@ async function run(): Promise<CommanderCommand> {
});
// claude up — run the project's CLAUDE.md "# claude up" setup instructions.
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
program.command('up').description('[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md').action(async () => {
const {
up
@@ -4378,7 +4375,7 @@ async function run(): Promise<CommanderCommand> {
// claude rollback (ant-only)
// Rolls back to previous releases
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
program.command('rollback [target]').description('[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version').option('-l, --list', 'List recent published versions with ages').option('--dry-run', 'Show what would be installed without installing').option('--safe', 'Roll back to the server-pinned safe version (set by oncall during incidents)').action(async (target?: string, options?: {
list?: boolean;
dryRun?: boolean;
@@ -4402,7 +4399,7 @@ async function run(): Promise<CommanderCommand> {
});
// ant-only commands
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
const validateLogId = (value: string) => {
const maybeSessionId = validateUuid(value);
if (maybeSessionId) return maybeSessionId;
@@ -4436,7 +4433,7 @@ Examples:
} = await import('./cli/handlers/ant.js');
await exportHandler(source, outputFile);
});
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
const taskCmd = program.command('task').description('[ANT-ONLY] Manage task list tasks');
taskCmd.command('create <subject>').description('Create a new task').option('-d, --description <text>', 'Task description').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').action(async (subject: string, opts: {
description?: string;
@@ -4595,7 +4592,7 @@ async function logTenguInit({
assistantActivationPath: assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? 'latest') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(("external" as string) === 'ant' ? (() => {
...((process.env.USER_TYPE) === 'ant' ? (() => {
const cwd = getCwd();
const gitRoot = findGitRoot(cwd);
const rp = gitRoot ? relative(gitRoot, cwd) || '.' : undefined;

View File

@@ -104,13 +104,13 @@ const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').V
// Frustration detection is ant-only (dogfooding). Conditional require so external
// builds eliminate the module entirely (including its two O(n) useMemos that run
// on every messages change, plus the GrowthBook fetch).
const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = ("external" as string) === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({
const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = (process.env.USER_TYPE) === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({
state: 'closed',
handleTranscriptSelect: () => {}
});
// Ant-only org warning. Conditional require so the org UUID list is
// eliminated from external builds (one UUID is on excluded-strings).
const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = ("external" as string) === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {};
const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = (process.env.USER_TYPE) === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {};
// Dead code elimination: conditional import for coordinator mode
const getCoordinatorUserContext: (mcpClients: ReadonlyArray<{
name: string;
@@ -219,9 +219,9 @@ import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCall
import type { EffortValue } from '../utils/effort.js';
import { RemoteCallout } from '../components/RemoteCallout.js';
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
const AntModelSwitchCallout = ("external" as string) === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null;
const shouldShowAntModelSwitch = ("external" as string) === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false;
const UndercoverAutoCallout = ("external" as string) === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null;
const AntModelSwitchCallout = (process.env.USER_TYPE) === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null;
const shouldShowAntModelSwitch = (process.env.USER_TYPE) === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false;
const UndercoverAutoCallout = (process.env.USER_TYPE) === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null;
/* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
import { activityManager } from '../utils/activityManager.js';
import { createAbortController } from '../utils/abortController.js';
@@ -602,7 +602,7 @@ export function REPL({
// Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+
// includes, and these were on the render path (hot during PageUp spam).
const titleDisabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), []);
const moreRightEnabled = useMemo(() => ("external" as string) === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []);
const moreRightEnabled = useMemo(() => (process.env.USER_TYPE) === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []);
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
const disableMessageActions = feature('MESSAGE_ACTIONS') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
@@ -734,7 +734,7 @@ export function REPL({
const [showIdeOnboarding, setShowIdeOnboarding] = useState(false);
// Dead code elimination: model switch callout state (ant-only)
const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => {
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
return shouldShowAntModelSwitch();
}
return false;
@@ -1013,7 +1013,7 @@ export function REPL({
}, []);
const [showUndercoverCallout, setShowUndercoverCallout] = useState(false);
useEffect(() => {
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
void (async () => {
// Wait for repo classification to settle (memoized, no-op if primed).
const {
@@ -2045,10 +2045,10 @@ export function REPL({
if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding';
// Model switch callout (ant-only, eliminated from external builds)
if (("external" as string) === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
if ((process.env.USER_TYPE) === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
// Undercover auto-enable explainer (ant-only, eliminated from external builds)
if (("external" as string) === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
if ((process.env.USER_TYPE) === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
// Effort callout (shown once for Opus 4.6 users when effort is enabled)
if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout';
@@ -2486,7 +2486,7 @@ export function REPL({
dynamicSkillDirTriggers: new Set<string>(),
discoveredSkillNames: discoveredSkillNamesRef.current,
setResponseLength,
pushApiMetricsEntry: ("external" as string) === 'ant' ? (ttftMs: number) => {
pushApiMetricsEntry: (process.env.USER_TYPE) === 'ant' ? (ttftMs: number) => {
const now = Date.now();
const baseline = responseLengthRef.current;
apiMetricsRef.current.push({
@@ -2815,7 +2815,7 @@ export function REPL({
// Capture ant-only API metrics before resetLoadingState clears the ref.
// For multi-request turns (tool use loops), compute P50 across all requests.
if (("external" as string) === 'ant' && apiMetricsRef.current.length > 0) {
if ((process.env.USER_TYPE) === 'ant' && apiMetricsRef.current.length > 0) {
const entries = apiMetricsRef.current;
const ttfts = entries.map(e => e.ttftMs);
// Compute per-request OTPS using only active streaming time and
@@ -2943,7 +2943,7 @@ export function REPL({
// minutes — wiping the session made the pill disappear entirely, forcing
// the user to re-invoke Tmux just to peek. Skip on abort so the panel
// stays open for inspection (matches the turn-duration guard below).
if (("external" as string) === 'ant' && !abortController.signal.aborted) {
if ((process.env.USER_TYPE) === 'ant' && !abortController.signal.aborted) {
setAppState(prev => {
if (prev.tungstenActiveSession === undefined) return prev;
if (prev.tungstenPanelAutoHidden === true) return prev;
@@ -3066,7 +3066,7 @@ export function REPL({
}
// Atomically: clear initial message, set permission mode and rules, and store plan for verification
const shouldStorePlanForVerification = initialMsg.message.planContent && ("external" as string) === 'ant' && isEnvTruthy(undefined);
const shouldStorePlanForVerification = initialMsg.message.planContent && (process.env.USER_TYPE) === 'ant' && isEnvTruthy(undefined);
setAppState(prev => {
// Build and apply permission updates (mode + allowedPrompts rules)
let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdates(prev.toolPermissionContext, buildPermissionUpdates(initialMsg.mode, initialMsg.allowedPrompts)) : prev.toolPermissionContext;
@@ -3599,7 +3599,7 @@ export function REPL({
// Handler for when user presses 1 on survey thanks screen to share details
const handleSurveyRequestFeedback = useCallback(() => {
const command = ("external" as string) === 'ant' ? '/issue' : '/feedback';
const command = (process.env.USER_TYPE) === 'ant' ? '/issue' : '/feedback';
onSubmit(command, {
setCursorOffset: () => {},
clearBuffer: () => {},
@@ -4060,7 +4060,7 @@ export function REPL({
// - Workers receive permission responses via mailbox messages
// - Leaders receive permission requests via mailbox messages
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
// Tasks mode: watch for tasks and auto-process them
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
@@ -4169,7 +4169,7 @@ export function REPL({
// Fall back to default behavior
const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop';
if (("external" as string) === 'ant') {
if ((process.env.USER_TYPE) === 'ant') {
const cmd = currentHooks[completedCount]?.data.command;
const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : '';
return total === 1 ? `running ${hookType} hook${label}` : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`;
@@ -4578,7 +4578,7 @@ export function REPL({
{toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && <Box flexDirection="column" width="100%">
{toolJSX.jsx}
</Box>}
{("external" as string) === 'ant' && <TungstenLiveMonitor />}
{(process.env.USER_TYPE) === 'ant' && <TungstenLiveMonitor />}
{feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null}
<Box flexGrow={1} />
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} apiMetricsRef={apiMetricsRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
@@ -4801,7 +4801,7 @@ export function REPL({
});
}} />}
{focusedInputDialog === 'ide-onboarding' && <IdeOnboardingDialog onDone={() => setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />}
{("external" as string) === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && <AntModelSwitchCallout onDone={(selection: string, modelAlias?: string) => {
{(process.env.USER_TYPE) === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && <AntModelSwitchCallout onDone={(selection: string, modelAlias?: string) => {
setShowModelSwitchCallout(false);
if (selection === 'switch' && modelAlias) {
setAppState(prev => ({
@@ -4811,7 +4811,7 @@ export function REPL({
}));
}
}} />}
{("external" as string) === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && <UndercoverAutoCallout onDone={() => setShowUndercoverCallout(false)} />}
{(process.env.USER_TYPE) === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && <UndercoverAutoCallout onDone={() => setShowUndercoverCallout(false)} />}
{focusedInputDialog === 'effort-callout' && <EffortCallout model={mainLoopModel} onDone={selection => {
setShowEffortCallout(false);
if (selection !== 'dismiss') {
@@ -4894,7 +4894,7 @@ export function REPL({
{/* Frustration-triggered transcript sharing prompt */}
{frustrationDetection.state !== 'closed' && <FeedbackSurvey state={frustrationDetection.state} lastResponse={null} handleSelect={() => {}} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />}
{/* Skill improvement survey - appears when improvements detected (ant-only) */}
{("external" as string) === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
{(process.env.USER_TYPE) === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
{showIssueFlagBanner && <IssueFlagBanner />}
{}
<PromptInput debug={debug} ideSelection={ideSelection} hasSuppressedDialogs={!!hasSuppressedDialogs} isLocalJSXCommandActive={isShowingLocalJSXCommand} getToolUseContext={getToolUseContext} toolPermissionContext={toolPermissionContext} setToolPermissionContext={setToolPermissionContext} apiKeyStatus={apiKeyStatus} commands={commands} agents={agentDefinitions.activeAgents} isLoading={isLoading} onExit={handleExit} verbose={verbose} messages={messages} onAutoUpdaterResult={setAutoUpdaterResult} autoUpdaterResult={autoUpdaterResult} input={inputValue} onInputChange={setInputValue} mode={inputMode} onModeChange={setInputMode} stashedPrompt={stashedPrompt} setStashedPrompt={setStashedPrompt} submitCount={submitCount} onShowMessageSelector={handleShowMessageSelector} onMessageActionsEnter={
@@ -4987,7 +4987,7 @@ export function REPL({
setIsMessageSelectorVisible(false);
setMessageSelectorPreselect(undefined);
}} />}
{("external" as string) === 'ant' && <DevBar />}
{(process.env.USER_TYPE) === 'ant' && <DevBar />}
</Box>
{feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? <CompanionSprite /> : null}
</Box>} />

View File

@@ -0,0 +1,121 @@
import { describe, expect, test } from "bun:test";
import { groupMessagesByApiRound } from "../grouping";
function makeMsg(type: "user" | "assistant" | "system", id: string): any {
return {
type,
message: { id, content: `${type}-${id}` },
};
}
describe("groupMessagesByApiRound", () => {
// Boundary fires when: assistant msg with NEW id AND current group has items
test("splits before first assistant if user messages precede it", () => {
const messages = [makeMsg("user", "u1"), makeMsg("assistant", "a1")];
const groups = groupMessagesByApiRound(messages);
// user msgs form group 1, assistant starts group 2
expect(groups).toHaveLength(2);
expect(groups[0]).toHaveLength(1);
expect(groups[1]).toHaveLength(1);
});
test("single assistant message forms one group", () => {
const messages = [makeMsg("assistant", "a1")];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(1);
});
test("splits at new assistant message ID", () => {
const messages = [
makeMsg("user", "u1"),
makeMsg("assistant", "a1"),
makeMsg("assistant", "a2"),
];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(3);
});
test("keeps same-ID assistant messages in same group (streaming chunks)", () => {
const messages = [
makeMsg("assistant", "a1"),
makeMsg("assistant", "a1"),
makeMsg("assistant", "a1"),
];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(3);
});
test("returns empty array for empty input", () => {
expect(groupMessagesByApiRound([])).toEqual([]);
});
test("handles all user messages (no assistant)", () => {
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(1);
});
test("three API rounds produce correct groups", () => {
const messages = [
makeMsg("user", "u1"),
makeMsg("assistant", "a1"),
makeMsg("user", "u2"),
makeMsg("assistant", "a2"),
makeMsg("user", "u3"),
makeMsg("assistant", "a3"),
];
const groups = groupMessagesByApiRound(messages);
// [u1], [a1, u2], [a2, u3], [a3] = 4 groups
expect(groups).toHaveLength(4);
});
test("consecutive user messages stay in same group", () => {
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
expect(groupMessagesByApiRound(messages)).toHaveLength(1);
});
test("does not produce empty groups", () => {
const messages = [
makeMsg("assistant", "a1"),
makeMsg("assistant", "a2"),
];
const groups = groupMessagesByApiRound(messages);
for (const group of groups) {
expect(group.length).toBeGreaterThan(0);
}
});
test("handles single message", () => {
expect(groupMessagesByApiRound([makeMsg("user", "u1")])).toHaveLength(1);
});
test("preserves message order within groups", () => {
const messages = [makeMsg("assistant", "a1"), makeMsg("user", "u2")];
const groups = groupMessagesByApiRound(messages);
expect(groups[0][0].message.id).toBe("a1");
expect(groups[0][1].message.id).toBe("u2");
});
test("handles system messages", () => {
const messages = [
makeMsg("system", "s1"),
makeMsg("assistant", "a1"),
];
// system msg is non-assistant, goes to current. Then assistant a1 is new ID
// and current has items, so split.
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(2);
});
test("tool_result after assistant stays in same round", () => {
const messages = [
makeMsg("assistant", "a1"),
makeMsg("user", "tool_result_1"),
makeMsg("assistant", "a1"), // same ID = no new boundary
];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(3);
});
});

View File

@@ -0,0 +1,77 @@
import { mock, describe, expect, test } from "bun:test";
mock.module("bun:bundle", () => ({ feature: () => false }));
const { formatCompactSummary } = await import("../prompt");
describe("formatCompactSummary", () => {
test("strips <analysis>...</analysis> block", () => {
const input = "<analysis>my thought process</analysis>\n<summary>the summary</summary>";
const result = formatCompactSummary(input);
expect(result).not.toContain("<analysis>");
expect(result).not.toContain("my thought process");
});
test("replaces <summary>...</summary> with 'Summary:\\n' prefix", () => {
const input = "<summary>key points here</summary>";
const result = formatCompactSummary(input);
expect(result).toContain("Summary:");
expect(result).toContain("key points here");
expect(result).not.toContain("<summary>");
});
test("handles analysis + summary together", () => {
const input = "<analysis>thinking</analysis><summary>result</summary>";
const result = formatCompactSummary(input);
expect(result).not.toContain("thinking");
expect(result).toContain("result");
});
test("handles summary without analysis", () => {
const input = "<summary>just the summary</summary>";
const result = formatCompactSummary(input);
expect(result).toContain("just the summary");
});
test("handles analysis without summary", () => {
const input = "<analysis>just analysis</analysis>and some text";
const result = formatCompactSummary(input);
expect(result).not.toContain("just analysis");
expect(result).toContain("and some text");
});
test("collapses multiple newlines to double", () => {
const input = "hello\n\n\n\nworld";
const result = formatCompactSummary(input);
expect(result).not.toMatch(/\n{3,}/);
});
test("trims leading/trailing whitespace", () => {
const input = " \n hello \n ";
const result = formatCompactSummary(input);
expect(result).toBe("hello");
});
test("handles empty string", () => {
expect(formatCompactSummary("")).toBe("");
});
test("handles plain text without tags", () => {
const input = "just plain text";
expect(formatCompactSummary(input)).toBe("just plain text");
});
test("handles multiline analysis content", () => {
const input = "<analysis>\nline1\nline2\nline3\n</analysis><summary>ok</summary>";
const result = formatCompactSummary(input);
expect(result).not.toContain("line1");
expect(result).toContain("ok");
});
test("preserves content between analysis and summary", () => {
const input = "<analysis>thoughts</analysis>middle text<summary>final</summary>";
const result = formatCompactSummary(input);
expect(result).toContain("middle text");
expect(result).toContain("final");
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
// findChannelEntry extracted from ../channelNotification.ts (line 161)
// Copied to avoid heavy import chain
type ChannelEntry = {
kind: "server" | "plugin"
name: string
}
function findChannelEntry(
serverName: string,
channels: readonly ChannelEntry[],
): ChannelEntry | undefined {
const parts = serverName.split(":")
return channels.find(c =>
c.kind === "server"
? serverName === c.name
: parts[0] === "plugin" && parts[1] === c.name,
)
}
describe("findChannelEntry", () => {
test("finds server entry by exact name match", () => {
const channels = [{ kind: "server" as const, name: "my-server" }]
expect(findChannelEntry("my-server", channels)).toBeDefined()
expect(findChannelEntry("my-server", channels)!.name).toBe("my-server")
})
test("finds plugin entry by matching second segment", () => {
const channels = [{ kind: "plugin" as const, name: "slack" }]
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
})
test("returns undefined for no match", () => {
const channels = [{ kind: "server" as const, name: "other" }]
expect(findChannelEntry("my-server", channels)).toBeUndefined()
})
test("handles empty channels array", () => {
expect(findChannelEntry("my-server", [])).toBeUndefined()
})
test("handles server name without colon", () => {
const channels = [{ kind: "server" as const, name: "simple" }]
expect(findChannelEntry("simple", channels)).toBeDefined()
})
test("handles 'plugin:name' format correctly", () => {
const channels = [{ kind: "plugin" as const, name: "slack" }]
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
expect(findChannelEntry("plugin:discord:tg", channels)).toBeUndefined()
})
test("prefers exact match (server kind) over partial match", () => {
const channels = [
{ kind: "server" as const, name: "plugin:slack" },
{ kind: "plugin" as const, name: "slack" },
]
const result = findChannelEntry("plugin:slack", channels)
expect(result).toBeDefined()
expect(result!.kind).toBe("server")
})
test("plugin kind does not match bare name", () => {
const channels = [{ kind: "plugin" as const, name: "slack" }]
expect(findChannelEntry("slack", channels)).toBeUndefined()
})
})

View File

@@ -0,0 +1,165 @@
import { mock, describe, expect, test } from "bun:test";
mock.module("src/utils/slowOperations.js", () => ({
jsonStringify: (v: unknown) => JSON.stringify(v),
}));
mock.module("src/services/analytics/growthbook.js", () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
}));
const {
shortRequestId,
truncateForPreview,
PERMISSION_REPLY_RE,
createChannelPermissionCallbacks,
} = await import("../channelPermissions");
describe("shortRequestId", () => {
test("returns 5-char string from tool use ID", () => {
const result = shortRequestId("toolu_abc123");
expect(result).toHaveLength(5);
});
test("is deterministic (same input = same output)", () => {
const a = shortRequestId("toolu_abc123");
const b = shortRequestId("toolu_abc123");
expect(a).toBe(b);
});
test("different inputs produce different outputs", () => {
const a = shortRequestId("toolu_aaa");
const b = shortRequestId("toolu_bbb");
expect(a).not.toBe(b);
});
test("result contains only valid letters (no 'l')", () => {
const validChars = new Set("abcdefghijkmnopqrstuvwxyz");
for (let i = 0; i < 50; i++) {
const result = shortRequestId(`toolu_${i}`);
for (const ch of result) {
expect(validChars.has(ch)).toBe(true);
}
}
});
test("handles empty string", () => {
const result = shortRequestId("");
expect(result).toHaveLength(5);
});
});
describe("truncateForPreview", () => {
test("returns JSON string for object input", () => {
const result = truncateForPreview({ key: "value" });
expect(result).toBe('{"key":"value"}');
});
test("truncates to <=200 chars with ellipsis when input is long", () => {
const longObj = { data: "x".repeat(300) };
const result = truncateForPreview(longObj);
expect(result.length).toBeLessThanOrEqual(203); // 200 + '…'
expect(result.endsWith("…")).toBe(true);
});
test("returns short input unchanged", () => {
const result = truncateForPreview({ a: 1 });
expect(result).toBe('{"a":1}');
expect(result.endsWith("…")).toBe(false);
});
test("handles string input", () => {
const result = truncateForPreview("hello");
expect(result).toBe('"hello"');
});
test("handles null input", () => {
const result = truncateForPreview(null);
expect(result).toBe("null");
});
test("handles undefined input", () => {
const result = truncateForPreview(undefined);
// JSON.stringify(undefined) returns undefined, then .length throws → catch returns '(unserializable)'
expect(result).toBe("(unserializable)");
});
});
describe("PERMISSION_REPLY_RE", () => {
test("matches 'y abcde'", () => {
expect(PERMISSION_REPLY_RE.test("y abcde")).toBe(true);
});
test("matches 'yes abcde'", () => {
expect(PERMISSION_REPLY_RE.test("yes abcde")).toBe(true);
});
test("matches 'n abcde'", () => {
expect(PERMISSION_REPLY_RE.test("n abcde")).toBe(true);
});
test("matches 'no abcde'", () => {
expect(PERMISSION_REPLY_RE.test("no abcde")).toBe(true);
});
test("is case-insensitive", () => {
expect(PERMISSION_REPLY_RE.test("Y abcde")).toBe(true);
expect(PERMISSION_REPLY_RE.test("YES abcde")).toBe(true);
});
test("does not match without ID", () => {
expect(PERMISSION_REPLY_RE.test("yes")).toBe(false);
});
test("captures the ID from reply", () => {
const match = "y abcde".match(PERMISSION_REPLY_RE);
expect(match?.[2]).toBe("abcde");
});
});
describe("createChannelPermissionCallbacks", () => {
test("resolve returns false for unknown request ID", () => {
const cb = createChannelPermissionCallbacks();
expect(cb.resolve("unknown-id", "allow", "server")).toBe(false);
});
test("onResponse + resolve triggers handler", () => {
const cb = createChannelPermissionCallbacks();
let received: any = null;
cb.onResponse("test-id", (response) => {
received = response;
});
expect(cb.resolve("test-id", "allow", "test-server")).toBe(true);
expect(received).toEqual({
behavior: "allow",
fromServer: "test-server",
});
});
test("onResponse unsubscribe prevents resolve", () => {
const cb = createChannelPermissionCallbacks();
let called = false;
const unsub = cb.onResponse("test-id", () => {
called = true;
});
unsub();
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
expect(called).toBe(false);
});
test("duplicate resolve returns false (already consumed)", () => {
const cb = createChannelPermissionCallbacks();
cb.onResponse("test-id", () => {});
expect(cb.resolve("test-id", "allow", "server")).toBe(true);
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
});
test("is case-insensitive for request IDs", () => {
const cb = createChannelPermissionCallbacks();
let received: any = null;
cb.onResponse("ABC", (response) => {
received = response;
});
expect(cb.resolve("abc", "deny", "server")).toBe(true);
expect(received?.behavior).toBe("deny");
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
// parseHeaders is a pure function from ../utils.ts (line 325)
// Copied here to avoid triggering the heavy import chain of utils.ts
function parseHeaders(headerArray: string[]): Record<string, string> {
const headers: Record<string, string> = {}
for (const header of headerArray) {
const colonIndex = header.indexOf(":")
if (colonIndex === -1) {
throw new Error(
`Invalid header format: "${header}". Expected format: "Header-Name: value"`,
)
}
const key = header.substring(0, colonIndex).trim()
const value = header.substring(colonIndex + 1).trim()
if (!key) {
throw new Error(
`Invalid header: "${header}". Header name cannot be empty.`,
)
}
headers[key] = value
}
return headers
}
describe("parseHeaders", () => {
test("parses 'Key: Value' format", () => {
expect(parseHeaders(["Content-Type: application/json"])).toEqual({
"Content-Type": "application/json",
});
});
test("parses multiple headers", () => {
expect(parseHeaders(["Key1: val1", "Key2: val2"])).toEqual({
Key1: "val1",
Key2: "val2",
});
});
test("trims whitespace around key and value", () => {
expect(parseHeaders([" Key : Value "])).toEqual({ Key: "Value" });
});
test("throws on missing colon", () => {
expect(() => parseHeaders(["no colon here"])).toThrow();
});
test("throws on empty key", () => {
expect(() => parseHeaders([": value"])).toThrow();
});
test("handles value with colons (like URLs)", () => {
expect(parseHeaders(["url: http://example.com:8080"])).toEqual({
url: "http://example.com:8080",
});
});
test("returns empty object for empty array", () => {
expect(parseHeaders([])).toEqual({});
});
test("handles duplicate keys (last wins)", () => {
expect(parseHeaders(["K: v1", "K: v2"])).toEqual({ K: "v2" });
});
});

View File

@@ -0,0 +1,45 @@
import { mock, describe, expect, test, afterEach } from "bun:test";
mock.module("axios", () => ({
default: { get: async () => ({ data: { servers: [] } }) },
}));
mock.module("src/utils/debug.js", () => ({
logForDebugging: () => {},
}));
mock.module("src/utils/errors.js", () => ({
errorMessage: (e: any) => String(e),
}));
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(
"../officialRegistry"
);
describe("isOfficialMcpUrl", () => {
afterEach(() => {
resetOfficialMcpUrlsForTesting();
});
test("returns false when registry not loaded (initial state)", () => {
resetOfficialMcpUrlsForTesting();
expect(isOfficialMcpUrl("https://example.com")).toBe(false);
});
test("returns false for non-registered URL", () => {
expect(isOfficialMcpUrl("https://random-server.com/mcp")).toBe(false);
});
test("returns false for empty string", () => {
expect(isOfficialMcpUrl("")).toBe(false);
});
});
describe("resetOfficialMcpUrlsForTesting", () => {
test("can be called without error", () => {
expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow();
});
test("clears state so subsequent lookups return false", () => {
resetOfficialMcpUrlsForTesting();
expect(isOfficialMcpUrl("https://anything.com")).toBe(false);
});
});

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type getSessionId = any;
export type isSessionPersistenceDisabled = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export {};

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export {};

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type registerMcpAddCommand = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type registerMcpXaaIdpCommand = any;

View File

@@ -1,7 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type PermissionMode = any;
export type SDKCompactBoundaryMessage = any;
export type SDKMessage = any;
export type SDKPermissionDenial = any;
export type SDKStatus = any;
export type SDKUserMessageReplay = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type isAnalyticsDisabled = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type getFeatureValue_CACHED_MAY_BE_STALE = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any;
export type logEvent = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type initializeAnalyticsGates = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type accumulateUsage = any;
export type updateUsage = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type NonNullableUsage = any;
export type EMPTY_USAGE = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type logPermissionContextForAnts = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type fetchClaudeAIMcpConfigsIfEligible = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type clearServerCache = any;

View File

@@ -1,9 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type areMcpConfigsAllowedWithEnterpriseMcpConfig = any;
export type dedupClaudeAiMcpServers = any;
export type doesEnterpriseMcpConfigExist = any;
export type filterMcpServersByPolicy = any;
export type getClaudeCodeMcpConfigs = any;
export type getMcpServerSignature = any;
export type parseMcpConfig = any;
export type parseMcpConfigFromFilePath = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type excludeCommandsByServer = any;
export type excludeResourcesByServer = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type isXaaEnabled = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type getRelevantTips = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type setCwd = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type logContextMetrics = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type CLAUDE_IN_CHROME_MCP_SERVER_NAME = any;
export type isClaudeInChromeMCPServer = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type registerCleanup = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type eagerParseCliFlag = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type createEmptyAttributionState = any;

View File

@@ -1,4 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type countConcurrentSessions = any;
export type registerSession = any;
export type updateSessionName = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type getCwd = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type logForDebugging = any;
export type setHasFormattedOutput = any;

View File

@@ -1,6 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type errorMessage = any;
export type getErrnoCode = any;
export type isENOENT = any;
export type TeleportOperationError = any;
export type toError = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type getFsImplementation = any;
export type safeResolvePath = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type gracefulShutdown = any;
export type gracefulShutdownSync = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type setAllHookEventsEnabled = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type refreshModelCapabilities = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type peekForStdinData = any;
export type writeToStderr = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type checkForReleaseNotes = any;

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type ProcessedResume = any;
export type processResumedConversation = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type parseSettingSourcesFlag = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type initSinks = any;

Some files were not shown because too many files have changed in this diff Show More