mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Merge branch 'main' into pr/smallflyingpig/36
# Conflicts: # src/entrypoints/cli.tsx
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -20,9 +20,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Test
|
||||
run: bun test
|
||||
|
||||
|
||||
32
CLAUDE.md
32
CLAUDE.md
@@ -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
33
DEV-LOG.md
Normal 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` 时按同样模式处理
|
||||
6
build.ts
6
build.ts
@@ -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
263
docs/safety/auto-mode.mdx
Normal 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=64,stop_sequences 触发即时 yes/no
|
||||
├── 如果 allow → 直接放行(最快路径)
|
||||
└── 如果 block → 进入 Stage 2
|
||||
|
||||
Stage 2 — "thinking"(深度思考)
|
||||
├── chain-of-thought 推理
|
||||
├── 减少误报(false positives)
|
||||
└── 最终决定 allow / deny / ask
|
||||
```
|
||||
|
||||
两个阶段共享相同的 system prompt 和 user content,利用 API 的 prompt caching(1 小时 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 支持检测 |
|
||||
361
docs/test-plans/10-fix-weak-tests.md
Normal file
361
docs/test-plans/10-fix-weak-tests.md
Normal 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 环境永远返回 true,false 路径从未执行;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 并更新测试)
|
||||
177
docs/test-plans/11-strengthen-acceptable-tests.md
Normal file
177
docs/test-plans/11-strengthen-acceptable-tests.md
Normal 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` 用于精确值检查的场景
|
||||
145
docs/test-plans/12-mock-reliability.md
Normal file
145
docs/test-plans/12-mock-reliability.md
Normal 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 | 增大 margin:TTL=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` 全部通过
|
||||
71
docs/test-plans/13-cjk-truncate-tests.md
Normal file
71
docs/test-plans/13-cjk-truncate-tests.md
Normal 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` 全部通过
|
||||
191
docs/test-plans/14-integration-tests.md
Normal file
191
docs/test-plans/14-integration-tests.md
Normal 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` 全部通过
|
||||
67
docs/test-plans/15-cli-coverage-baseline.md
Normal file
67
docs/test-plans/15-cli-coverage-baseline.md
Normal 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` 全部通过
|
||||
188
docs/test-plans/phase-16-zero-dep-pure-functions.md
Normal file
188
docs/test-plans/phase-16-zero-dep-pure-functions.md
Normal 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` 实现确定性测试
|
||||
203
docs/test-plans/phase-17-tool-submodules.md
Normal file
203
docs/test-plans/phase-17-tool-submodules.md
Normal 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 |
|
||||
110
docs/test-plans/phase-18-weak-fixes.md
Normal file
110
docs/test-plans/phase-18-weak-fixes.md
Normal 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 exports,spawn 子进程 |
|
||||
| `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** |
|
||||
435
docs/test-plans/phase19-batch1-micro-utils.md
Normal file
435
docs/test-plans/phase19-batch1-micro-utils.md
Normal 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 对象
|
||||
287
docs/test-plans/phase19-batch2-utils-state-commands.md
Normal file
287
docs/test-plans/phase19-batch2-utils-state-commands.md
Normal 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 需求
|
||||
无
|
||||
258
docs/test-plans/phase19-batch3-tool-submodules.md
Normal file
258
docs/test-plans/phase19-batch3-tool-submodules.md
Normal 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()` 获取函数
|
||||
215
docs/test-plans/phase19-batch4-services.md
Normal file
215
docs/test-plans/phase19-batch4-services.md
Normal 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` 做测试隔离
|
||||
200
docs/test-plans/phase19-batch5-mcp-config.md
Normal file
200
docs/test-plans/phase19-batch5-mcp-config.md
Normal 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 成功后直接测
|
||||
@@ -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 Actions,push/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 渲染测试 |
|
||||
|
||||
@@ -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)}`);
|
||||
@@ -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"] },
|
||||
);
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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.`);
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
167
src/__tests__/history.test.ts
Normal file
167
src/__tests__/history.test.ts
Normal 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]')
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
147
src/commands/plugin/__tests__/parseArgs.test.ts
Normal file
147
src/commands/plugin/__tests__/parseArgs.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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: `~10–30 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
|
||||
})
|
||||
|
||||
12
src/components/AntModelSwitchCallout.tsx
Normal file
12
src/components/AntModelSwitchCallout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -184,7 +184,7 @@ export function NativeAutoUpdater({
|
||||
{autoUpdaterResult?.status === 'install_failed' && <Text color="error" wrap="truncate">
|
||||
✗ Auto-update failed · 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} · Run{' '}
|
||||
<Text bold>claude rollback --safe</Text> to downgrade
|
||||
</Text>}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
9
src/components/UndercoverAutoCallout.tsx
Normal file
9
src/components/UndercoverAutoCallout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
47
src/main.tsx
47
src/main.tsx
@@ -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;
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
121
src/services/compact/__tests__/grouping.test.ts
Normal file
121
src/services/compact/__tests__/grouping.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
src/services/compact/__tests__/prompt.test.ts
Normal file
77
src/services/compact/__tests__/prompt.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
69
src/services/mcp/__tests__/channelNotification.test.ts
Normal file
69
src/services/mcp/__tests__/channelNotification.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
165
src/services/mcp/__tests__/channelPermissions.test.ts
Normal file
165
src/services/mcp/__tests__/channelPermissions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
65
src/services/mcp/__tests__/filterUtils.test.ts
Normal file
65
src/services/mcp/__tests__/filterUtils.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
45
src/services/mcp/__tests__/officialRegistry.test.ts
Normal file
45
src/services/mcp/__tests__/officialRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getSessionId = any;
|
||||
export type isSessionPersistenceDisabled = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export {};
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export {};
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type registerMcpAddCommand = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type registerMcpXaaIdpCommand = any;
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isAnalyticsDisabled = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any;
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type initializeAnalyticsGates = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type accumulateUsage = any;
|
||||
export type updateUsage = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type NonNullableUsage = any;
|
||||
export type EMPTY_USAGE = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logPermissionContextForAnts = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type fetchClaudeAIMcpConfigsIfEligible = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type clearServerCache = any;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type excludeCommandsByServer = any;
|
||||
export type excludeResourcesByServer = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isXaaEnabled = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getRelevantTips = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setCwd = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logContextMetrics = any;
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type registerCleanup = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type eagerParseCliFlag = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type createEmptyAttributionState = any;
|
||||
@@ -1,4 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type countConcurrentSessions = any;
|
||||
export type registerSession = any;
|
||||
export type updateSessionName = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getCwd = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logForDebugging = any;
|
||||
export type setHasFormattedOutput = any;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFsImplementation = any;
|
||||
export type safeResolvePath = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type gracefulShutdown = any;
|
||||
export type gracefulShutdownSync = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setAllHookEventsEnabled = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type refreshModelCapabilities = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type peekForStdinData = any;
|
||||
export type writeToStderr = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type checkForReleaseNotes = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ProcessedResume = any;
|
||||
export type processResumedConversation = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type parseSettingSourcesFlag = any;
|
||||
@@ -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
Reference in New Issue
Block a user