mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1071270ce3 | ||
|
|
8399d9ed20 | ||
|
|
513ccc3003 | ||
|
|
e770f1ef9d | ||
|
|
227083d31f | ||
|
|
14c46df881 | ||
|
|
e0e4ee41c2 | ||
|
|
e9861415c0 | ||
|
|
423f114db6 | ||
|
|
c8a502f81f | ||
|
|
09fc515edb | ||
|
|
2fea429dc6 | ||
|
|
6a9da9d546 | ||
|
|
d27c6cbc64 | ||
|
|
ffd1c366eb | ||
|
|
5beeebad59 | ||
|
|
c676ac4693 | ||
|
|
eeb0f2776e | ||
|
|
6a70056910 | ||
|
|
7088fe3c8b | ||
|
|
b060eabda9 | ||
|
|
9da7345f8e | ||
|
|
8137b66a46 | ||
|
|
b681139b63 | ||
|
|
0b1e678fb7 | ||
|
|
81073135e2 | ||
|
|
ff03fe7fcb | ||
|
|
c82f59943c | ||
|
|
e70319e8f5 | ||
|
|
609e91143f | ||
|
|
637531f81f | ||
|
|
875510e1eb | ||
|
|
34bbc1d403 | ||
|
|
a14d3dc8f0 | ||
|
|
ab3d8ef87e | ||
|
|
dfce6d02f9 | ||
|
|
01cf45f4ac | ||
|
|
e6affc7053 | ||
|
|
bb07836231 | ||
|
|
87230cf3bf | ||
|
|
8c619a215c |
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -20,6 +20,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: bunx tsc --noEmit
|
||||
|
||||
- name: Test
|
||||
run: bun test
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ src/utils/vendor/
|
||||
|
||||
# AI tool runtime directories
|
||||
.agents/
|
||||
.claude/
|
||||
.codex/
|
||||
.omx/
|
||||
|
||||
|
||||
2
.mintignore
Normal file
2
.mintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
src/
|
||||
packages/
|
||||
225
CLAUDE.md
225
CLAUDE.md
@@ -4,7 +4,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. The codebase has ~1341 tsc errors from decompilation (mostly `unknown`/`never`/`{}` types) — these do **not** block Bun runtime execution.
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
使用 **Conventional Commits** 规范:
|
||||
|
||||
```
|
||||
<type>: <描述>
|
||||
```
|
||||
|
||||
常见 type:`feat`、`fix`、`docs`、`chore`、`refactor`
|
||||
|
||||
示例:
|
||||
- `feat: 添加模型 1M 上下文切换`
|
||||
- `fix: 修复初次登陆的校验问题`
|
||||
- `chore: remove prefetchOfficialMcpUrls call on startup`
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -21,11 +36,11 @@ bun run dev:inspect
|
||||
# Pipe mode
|
||||
echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
||||
|
||||
# Build (code splitting, outputs dist/cli.js + ~450 chunk files)
|
||||
# Build (code splitting, outputs dist/cli.js + chunk files)
|
||||
bun run build
|
||||
|
||||
# Test
|
||||
bun test # run all tests
|
||||
bun test # run all tests (2453 tests / 137 files / 0 fail)
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
@@ -40,6 +55,9 @@ bun run health
|
||||
# Check unused exports
|
||||
bun run check:unused
|
||||
|
||||
# Remote Control Server
|
||||
bun run rcs
|
||||
|
||||
# Docs dev server (Mintlify)
|
||||
bun run docs:dev
|
||||
```
|
||||
@@ -51,26 +69,30 @@ bun run docs:dev
|
||||
### Runtime & Build
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。默认启用 `AGENT_TRIGGERS_REMOTE`、`CHICAGO_MCP`、`VOICE_MODE` feature。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用 `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE`、`CHICAGO_MCP`、`VOICE_MODE` 六个 feature。
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`.
|
||||
- **Monorepo**: Bun workspaces — 14 个 internal packages in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
- `--version` / `-v` — 零模块加载
|
||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||
- `--computer-use-mcp` — 独立 MCP server 模式
|
||||
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
|
||||
- `remote-control` / `rc` / `bridge` — feature-gated (BRIDGE_MODE)
|
||||
- `daemon` — feature-gated (DAEMON)
|
||||
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
|
||||
- `daemon` [subcommand] — feature-gated (DAEMON)
|
||||
- `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS)
|
||||
- `new` / `list` / `reply` — Template job commands
|
||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
||||
- `--tmux` + `--worktree` 组合
|
||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
||||
2. **`src/main.tsx`** (~4680 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
||||
|
||||
### Core Loop
|
||||
@@ -82,21 +104,28 @@ bun run docs:dev
|
||||
### API Layer
|
||||
|
||||
- **`src/services/api/claude.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
|
||||
- Supports multiple providers: Anthropic direct, AWS Bedrock, Google Vertex, Azure.
|
||||
- Provider selection in `src/utils/model/providers.ts`.
|
||||
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
|
||||
- Provider selection in `src/utils/model/providers.ts`。优先级:modelType 参数 > 环境变量 > 默认 firstParty。
|
||||
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/tools/<ToolName>/`** — 61 个 tool 目录(如 BashTool, FileEditTool, GrepTool, AgentTool, WebFetchTool, LSPTool, MCPTool 等)。每个 tool 包含 `name`、`description`、`inputSchema`、`call()` 及可选的 React 渲染组件。
|
||||
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/tools/<ToolName>/`** — 55 个 tool 目录。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
||||
- **规划**: EnterPlanModeTool, ExitPlanModeV2Tool, VerifyPlanExecutionTool
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** — Tool 共享工具函数。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection.
|
||||
- **`src/ink/`** — Custom Ink framework (forked/internal): custom reconciler, hooks (`useInput`, `useTerminalSize`, `useSearchHighlight`), virtual list rendering.
|
||||
- **`src/components/`** — 大量 React 组件(170+ 项),渲染于终端 Ink 环境中。关键组件:
|
||||
- **`packages/@ant/ink/`** — Custom Ink framework(forked/internal),包含 components、core、hooks、keybindings、theme、utils。注意:不是 `src/ink/`。
|
||||
- **`src/components/`** — 149 个组件目录/文件,渲染于终端 Ink 环境中。关键组件:
|
||||
- `App.tsx` — Root provider (AppState, Stats, FpsMetrics)
|
||||
- `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering
|
||||
- `PromptInput/` — User input handling
|
||||
@@ -112,10 +141,30 @@ bun run docs:dev
|
||||
- **`src/state/selectors.ts`** — State selectors.
|
||||
- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode).
|
||||
|
||||
### Workspace Packages
|
||||
|
||||
| Package | 说明 |
|
||||
|---------|------|
|
||||
| `packages/@ant/ink/` | Forked Ink 框架(components、hooks、keybindings、theme) |
|
||||
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server(截图/键鼠/剪贴板/应用管理) |
|
||||
| `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) |
|
||||
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) |
|
||||
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI) |
|
||||
| `packages/swarm/` | Swarm 解耦模块 |
|
||||
| `packages/shell/` | Shell 抽象 |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(stub) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(stub) |
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** (~35 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
@@ -128,91 +177,64 @@ bun run docs:dev
|
||||
|
||||
### Feature Flag System
|
||||
|
||||
Feature flags control which functionality is enabled at runtime:
|
||||
Feature flags control which functionality is enabled at runtime. 代码中统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。
|
||||
|
||||
- **在代码中使用**: 统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。**不要**在 `cli.tsx` 或其他文件里自己定义 `feature` 函数或覆盖这个 import。
|
||||
- **启用方式**: 通过环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev` 启用 BUDDY 功能。
|
||||
- **Dev 默认 features**: `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE`、`CHICAGO_MCP`、`VOICE_MODE`(见 `scripts/dev.ts`)。
|
||||
- **Build 默认 features**: `AGENT_TRIGGERS_REMOTE`、`CHICAGO_MCP`、`VOICE_MODE`(见 `build.ts`)。
|
||||
- **常见 flag**: `BUDDY`, `DAEMON`, `BRIDGE_MODE`, `BG_SESSIONS`, `PROACTIVE`, `KAIROS`, `VOICE_MODE`, `FORK_SUBAGENT`, `SSH_REMOTE`, `DIRECT_CONNECT`, `TEMPLATES`, `CHICAGO_MCP`, `BYOC_ENVIRONMENT_RUNNER`, `SELF_HOSTED_RUNNER`, `COORDINATOR_MODE`, `UDS_INBOX`, `LODESTONE`, `ABLATION_BASELINE` 等。
|
||||
- **类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
|
||||
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`。
|
||||
|
||||
**Build 默认 features**(19 个,见 `build.ts`):
|
||||
- 基础: `BUDDY`, `TRANSCRIPT_CLASSIFIER`, `BRIDGE_MODE`, `AGENT_TRIGGERS_REMOTE`, `CHICAGO_MCP`, `VOICE_MODE`
|
||||
- 统计/缓存: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
|
||||
- P0 本地: `AGENT_TRIGGERS`, `ULTRATHINK`, `BUILTIN_EXPLORE_PLAN_AGENTS`, `LODESTONE`
|
||||
- P1 API 依赖: `EXTRACT_MEMORIES`, `VERIFICATION_AGENT`, `KAIROS_BRIEF`, `AWAY_SUMMARY`, `ULTRAPLAN`
|
||||
- P2: `DAEMON`
|
||||
|
||||
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
|
||||
|
||||
**类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
|
||||
|
||||
**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
||||
|
||||
#### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
||||
|
||||
#### Grok 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
||||
|
||||
- **`src/services/api/grok/`** — client、模型映射
|
||||
|
||||
详见各兼容层的 docs 文档。
|
||||
|
||||
### Stubbed/Deleted Modules
|
||||
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| Computer Use (`@ant/*`) | Restored — `computer-use-swift`, `computer-use-input`, `computer-use-mcp`, `claude-for-chrome-mcp` 均有完整实现,macOS + Windows 可用,Linux 后端待完成 |
|
||||
| `*-napi` packages | `audio-capture-napi`、`image-processor-napi` 已恢复实现;`color-diff-napi` 完整实现;`url-handler-napi`、`modifiers-napi` 仍为 stub |
|
||||
| Voice Mode | Restored — `src/voice/`、`src/hooks/useVoiceIntegration.tsx`、`src/services/voiceStreamSTT.ts` 等,Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI 兼容层 | Restored — `src/services/api/openai/`,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI 协议端点,通过 `CLAUDE_CODE_USE_OPENAI=1` 启用 |
|
||||
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux(后端完整度不一) |
|
||||
| `*-napi` packages | `audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`、`url-handler-napi` 仍为 stub |
|
||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / LSP Server | Removed |
|
||||
| Plugins / Marketplace | Removed |
|
||||
| MCP OAuth | Simplified |
|
||||
|
||||
### Computer Use
|
||||
|
||||
Feature flag `CHICAGO_MCP`,dev/build 默认启用。实现跨平台屏幕操控(macOS + Windows 可用,Linux 待完成)。
|
||||
|
||||
- **`packages/@ant/computer-use-mcp/`** — MCP server,注册截图/键鼠/剪贴板/应用管理工具
|
||||
- **`packages/@ant/computer-use-input/`** — 键鼠模拟,dispatcher + per-platform backend(`backends/darwin.ts`、`win32.ts`、`linux.ts`)
|
||||
- **`packages/@ant/computer-use-swift/`** — 截图 + 应用管理,同样 dispatcher + per-platform backend
|
||||
- **`packages/@ant/claude-for-chrome-mcp/`** — Chrome 浏览器控制(独立于 Computer Use,通过 `--chrome` CLI 参数启用)
|
||||
|
||||
详见 `docs/features/computer-use.md`。
|
||||
|
||||
### Voice Mode
|
||||
|
||||
Feature flag `VOICE_MODE`,dev/build 默认启用。Push-to-Talk 语音输入,音频通过 WebSocket 流式传输到 Anthropic STT(Nova 3)。需要 Anthropic OAuth(非 API key)。
|
||||
|
||||
- **`src/voice/voiceModeEnabled.ts`** — 三层门控(feature flag + GrowthBook + OAuth auth)
|
||||
- **`src/hooks/useVoice.ts`** — React hook 管理录音状态和 WebSocket 连接
|
||||
- **`src/services/voiceStreamSTT.ts`** — STT WebSocket 流式传输
|
||||
|
||||
详见 `docs/features/voice-mode.md`。
|
||||
|
||||
### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 环境变量启用,支持任意 OpenAI Chat Completions 协议端点(Ollama、DeepSeek、vLLM 等)。流适配器模式:在 `queryModel()` 中将 Anthropic 格式请求转为 OpenAI 格式,再将 SSE 流转换回 `BetaRawMessageStreamEvent`,下游代码完全不改。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- **`src/utils/model/providers.ts`** — 添加 `'openai'` provider 类型(最高优先级)
|
||||
|
||||
关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`OPENAI_DEFAULT_OPUS_MODEL`、`OPENAI_DEFAULT_SONNET_MODEL`、`OPENAI_DEFAULT_HAIKU_MODEL`。详见 `docs/plans/openai-compatibility.md`。
|
||||
|
||||
### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 环境变量或 `modelType: "gemini"` 设置启用,支持 Google Gemini API。独立的环境变量体系,不与 OpenAI 或 Anthropic 配置混杂。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- **`src/utils/model/providers.ts`** — 添加 `'gemini'` provider 类型
|
||||
- **`src/utils/managedEnvConstants.ts`** — Gemini 专用的 managed env vars
|
||||
|
||||
关键环境变量:
|
||||
- `CLAUDE_CODE_USE_GEMINI` - 启用 Gemini provider
|
||||
- `GEMINI_API_KEY` - API 密钥(必填)
|
||||
- `GEMINI_BASE_URL` - API 端点(可选,默认 `https://generativelanguage.googleapis.com/v1beta`)
|
||||
- `GEMINI_MODEL` - 直接指定模型(最高优先级)
|
||||
- `GEMINI_DEFAULT_HAIKU_MODEL` / `GEMINI_DEFAULT_SONNET_MODEL` / `GEMINI_DEFAULT_OPUS_MODEL` - 按能力级别映射
|
||||
- `GEMINI_DEFAULT_HAIKU_MODEL_NAME` / `DESCRIPTION` / `SUPPORTED_CAPABILITIES` - 显示名称和描述
|
||||
- `GEMINI_SMALL_FAST_MODEL` - 快速任务使用的模型(可选)
|
||||
|
||||
模型映射优先级(`src/services/api/gemini/modelMapping.ts`):
|
||||
1. `GEMINI_MODEL` - 直接覆盖
|
||||
2. `GEMINI_DEFAULT_*_MODEL` - 独立配置(推荐)
|
||||
3. `ANTHROPIC_DEFAULT_*_MODEL` - 向后兼容 fallback(已废弃)
|
||||
4. 原样返回 Anthropic 模型名
|
||||
|
||||
使用示例:
|
||||
```bash
|
||||
export CLAUDE_CODE_USE_GEMINI=1
|
||||
export GEMINI_API_KEY="your-api-key"
|
||||
export GEMINI_DEFAULT_SONNET_MODEL="gemini-2.5-flash"
|
||||
export GEMINI_DEFAULT_OPUS_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
### Key Type Files
|
||||
|
||||
- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers.
|
||||
@@ -223,20 +245,39 @@ export GEMINI_DEFAULT_OPUS_MODEL="gemini-2.5-pro"
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 2472 tests / 138 files / 0 fail
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
|
||||
- **当前状态**: ~1623 tests / 114 files (110 unit + 4 integration) / 0 fail(详见 `docs/testing-spec.md`)
|
||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bunx tsc --noEmit
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
- 生产代码禁止 `as any`;测试文件中 mock 数据可用 `as any`
|
||||
- 类型不匹配优先用 `as unknown as SpecificType` 双重断言,或补充 interface
|
||||
- 未知结构对象用 `Record<string, unknown>` 替代 `any`
|
||||
- 联合类型用类型守卫(type guard)收窄,不要强转
|
||||
- `msg.request` 属性访问:`const req = msg.request as Record<string, unknown>`
|
||||
- Ink `color` prop:用 `as keyof Theme` 而非 `as any`
|
||||
|
||||
## Working with This Codebase
|
||||
|
||||
- **Don't try to fix all tsc errors** — they're from decompilation and don't affect runtime.
|
||||
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。
|
||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
||||
- **`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` 运行。
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
189
DEV-LOG.md
189
DEV-LOG.md
@@ -1,5 +1,194 @@
|
||||
# DEV-LOG
|
||||
|
||||
## /poor 省流模式 (2026-04-11)
|
||||
|
||||
新增 `/poor` 命令,toggle 关闭 `extract_memories` 和 `prompt_suggestion`,省 token。
|
||||
|
||||
- 新增 `POOR` feature flag(build.ts + dev.ts)
|
||||
- `src/commands/poor/` — 命令定义 + toggle 实现 + 状态管理
|
||||
- `src/query/stopHooks.ts` — POOR 模式激活时跳过 extract_memories 和 prompt_suggestion
|
||||
|
||||
---
|
||||
|
||||
## Pipe IPC + LAN Pipes + Monitor Tool + 工具恢复 (2026-04-08 ~ 2026-04-11)
|
||||
|
||||
**分支**: `feat/pr-package-adapt`
|
||||
|
||||
### 背景
|
||||
|
||||
从 decompiled 代码恢复大量 stub 为完整实现,同时新增 LAN 跨机器通讯能力。本次 PR 覆盖:Pipe IPC 系统、LAN Pipes、Monitor Tool、20+ 工具/组件<E7BB84><E4BBB6><EFBFBD>复、REPL hook 架构重构。
|
||||
|
||||
### 实现
|
||||
|
||||
#### 1. PipeServer TCP 双模式(`src/utils/pipeTransport.ts`)
|
||||
|
||||
从原始的纯 UDS 服务器扩展为 UDS + TCP 双模式:
|
||||
|
||||
- 提取 `setupSocket()` 共享方法,UDS 和 TCP 的 socket 处理逻辑完全一致
|
||||
- `start(options?: PipeServerOptions)` 新增可选参数 `{ enableTcp, tcpPort }`
|
||||
- 内部维护两个 `net.Server`(UDS + TCP),共享同一组 `clients: Set<Socket>` 和 `handlers`
|
||||
- TCP server 绑定 `0.0.0.0` + 动态端口(port=0 由 OS 分配)
|
||||
- `tcpAddress` getter 暴露 TCP 端口信息
|
||||
- `close()` 同时关闭两个 server
|
||||
- 新增类型:`PipeTransportMode`、`TcpEndpoint`、`PipeServerOptions`
|
||||
|
||||
PipeClient 对应扩展:
|
||||
- 构造函数新增可选 `TcpEndpoint` 参数
|
||||
- `connect()` 根据是否有 TCP endpoint 分派到 `connectTcp()` 或 `connectUds()`
|
||||
- TCP 连接不需要文件存在轮询,直接建立连接
|
||||
|
||||
#### 2. LAN Beacon — UDP Multicast 发现(`src/utils/lanBeacon.ts`,新文件)
|
||||
|
||||
零配置局域网 peer 发现:
|
||||
|
||||
- **协议**:UDP multicast 组 `224.0.71.67`("CC" ASCII),端口 `7101`,TTL=1
|
||||
- **Announce 包**:JSON `{ proto, pipeName, machineId, hostname, ip, tcpPort, role, ts }`
|
||||
- **广播间隔**:3 秒,首次在 socket bind 完成后立即发送
|
||||
- **Peer 超时**:15 秒无 announce 视为 lost
|
||||
- **事件**:`peer-discovered`、`peer-lost`
|
||||
- **存储**:module-level singleton `getLanBeacon()`/`setLanBeacon()`,不挂在 Zustand state 上
|
||||
|
||||
关键修复:
|
||||
- `addMembership(group, localIp)` + `setMulticastInterface(localIp)` 指定 LAN 网卡,解决 Windows 上 WSL/Docker 虚拟网卡劫持 multicast 的问题
|
||||
- announce/cleanup 定时器移入 `bind()` 回调内,修复 socket 未就绪时发送的竞态
|
||||
|
||||
#### 3. Registry 扩展(`src/utils/pipeRegistry.ts`)
|
||||
|
||||
- `PipeRegistryEntry` 新增 `tcpPort?` 和 `lanVisible?` 字段
|
||||
- `mergeWithLanPeers(registry, lanPeers)` 合并本地 registry 和 LAN beacon peers,本地优先
|
||||
|
||||
#### 4. Peer Address 扩展(`src/utils/peerAddress.ts`)
|
||||
|
||||
- `parseAddress()` 新增 `tcp` scheme:`tcp:192.168.1.20:7100`
|
||||
- 新增 `parseTcpTarget()` 解析 `host:port` 字符串
|
||||
|
||||
#### 5. REPL 集成(`src/screens/REPL.tsx`)
|
||||
|
||||
三个阶段的改动:
|
||||
|
||||
**Bootstrap**:`createPipeServer()` 时根据 `feature('LAN_PIPES')` 传入 TCP 选项 → 启动 `LanBeacon` → 注册 entry 携带 tcpPort
|
||||
|
||||
**Heartbeat**(每 5 秒):
|
||||
- `refreshDiscoveredPipes()` 同时包含本地 subs 和 LAN beacon peers,防止 LAN peer 状态被覆盖
|
||||
- auto-attach 循环统一遍历本地 subs + LAN peers,LAN peers 通过 TCP endpoint 连接
|
||||
- cleanup 检查 LAN beacon peers 列表,避免误删存活的 LAN 连接
|
||||
- attach 请求携带 `machineId`,接收方区分 LAN peer(不要求 sub 角色)
|
||||
|
||||
**Cleanup**:通过 `getLanBeacon()` 获取并 `stop()`,`setLanBeacon(null)` 清除
|
||||
|
||||
#### 6. 命令更新
|
||||
|
||||
- `/pipes`(`src/commands/pipes/pipes.ts`):显示 `[LAN]` 标记的远端实例
|
||||
- `/attach`(`src/commands/attach/attach.ts`):自动查找 LAN beacon 获取 TCP endpoint
|
||||
- `SendMessageTool`(`src/tools/SendMessageTool/SendMessageTool.ts`):支持 `tcp:` scheme,权限检查要求用户确认
|
||||
|
||||
#### 7. Feature Flag
|
||||
|
||||
`LAN_PIPES` — 在 `scripts/dev.ts` 和 `build.ts` 的默认 features 列表中启用。所有 LAN 代码路径均通过 `feature('LAN_PIPES')` 门控。
|
||||
|
||||
#### 8. Pipe IPC 基础系统(`UDS_INBOX` feature)
|
||||
|
||||
- `PipeServer`/`PipeClient`:UDS 传输,NDJSON 协议(共享 `ndjsonFramer.ts`)
|
||||
- `PipeRegistry`:machineId 绑定的角色分配(main/sub),文件锁,并行探测
|
||||
- Master/slave attach 流程、prompt 转发、permission 转发
|
||||
- Heartbeat 生命周期(5s 间隔,stale entry 清理,busy flag 防重叠)
|
||||
- 命令:`/pipes`、`/attach`、`/detach`、`/send`、`/claim-main`、`/pipe-status`
|
||||
|
||||
#### 9. Monitor Tool(`MONITOR_TOOL` feature)
|
||||
|
||||
- `MonitorTool`:AI 可调用的后台 shell 监控工具
|
||||
- `/monitor` 命令:用户快捷入口,Windows 兼容(watch → PowerShell 循环)
|
||||
- `MonitorMcpTask`:从 stub 恢复完整生命周期(register/complete/fail/kill)
|
||||
- `MonitorPermissionRequest`:React 权限确认 UI
|
||||
- `MonitorMcpDetailDialog`:Shift+Down 详情面板
|
||||
|
||||
#### 10. 工具恢复(stub → 实现)
|
||||
|
||||
- SnipTool、SleepTool、ListPeersTool、SendUserFileTool
|
||||
- WebBrowserTool、SubscribePRTool、PushNotificationTool
|
||||
- CtxInspectTool、TerminalCaptureTool、WorkflowTool
|
||||
- REPLTool (.js → .ts)、VerifyPlanExecutionTool (.js → .ts)、SuggestBackgroundPRTool (.js → .ts)
|
||||
- 组件 .ts → .tsx 重写:MonitorPermissionRequest、ReviewArtifactPermissionRequest、MonitorMcpDetailDialog、WorkflowDetailDialog、WorkflowPermissionRequest
|
||||
|
||||
#### 11. REPL Hook 架构重构
|
||||
|
||||
从 REPL.tsx 提取 ~830 行 Pipe IPC 内联代码为 4 个独立 hook:
|
||||
|
||||
| Hook | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `usePipeIpc` | 623 | 生命周期:bootstrap、handlers、heartbeat、cleanup |
|
||||
| `usePipeRelay` | 38 | slave→master 消息回传(通过 `setPipeRelay` singleton) |
|
||||
| `usePipePermissionForward` | 159 | 权限请求转发 + 流式通知显示 |
|
||||
| `usePipeRouter` | 130 | selected pipe 输入路由 + role/IP 标签显示 |
|
||||
|
||||
共享工具:`ndjsonFramer.ts` 替换 3 份重复的 NDJSON 解析。
|
||||
|
||||
#### 12. Feature Flags 新增启用
|
||||
|
||||
UDS_INBOX、LAN_PIPES、MONITOR_TOOL、FORK_SUBAGENT、KAIROS、COORDINATOR_MODE、WORKFLOW_SCRIPTS、HISTORY_SNIP、CONTEXT_COLLAPSE
|
||||
|
||||
### 踩坑记录
|
||||
|
||||
1. **Multicast 绑错网卡**:Windows 上 `addMembership(group)` 不指定本地接口时,默认绑到 WSL/Docker 虚拟网卡(`172.19.112.1`),LAN 上的真实机器收不到。必须 `addMembership(group, localIp)` + `setMulticastInterface(localIp)`。
|
||||
|
||||
2. **Beacon ref 丢失**:最初用 `(store.getState() as any)._lanBeacon` 挂载 beacon 引用,但 Zustand `setState` 展开 `prev` 时不包含 `_lanBeacon` 属性,下次读取就是 `undefined`。改为 module-level singleton 解决。
|
||||
|
||||
3. **Heartbeat 清洗 LAN 连接**:`refreshDiscoveredPipes()` 每 5 秒用仅含本地 registry subs 的列表完全覆盖 `discoveredPipes` + `selectedPipes`,LAN peer 的发现和选择状态被持续清空。必须在 refresh 中同时包含 beacon peers。
|
||||
|
||||
4. **Heartbeat cleanup 误删**:`!aliveSubNames.has(slaveName)` 导致 LAN peer(不在本地 registry)被判定为死连接每 5 秒清除一次。需要同时检查 beacon peers 列表。
|
||||
|
||||
5. **跨机器 attach 被拒**:两台机器各自为 `main`,attach handler 硬编码 `role !== 'sub'` 拒绝。通过 attach_request 携带 `machineId`,接收方对不同 machineId 的请求放行。
|
||||
|
||||
6. **`feature()` 使用约束**:Bun 的 `feature()` 是编译时常量,只能在 `if` 语句或三元条件中直接使用,不能赋值给变量(如 `const x = feature('...')`),否则构建报错。
|
||||
|
||||
### 已知限制
|
||||
|
||||
- TCP 无认证:同 LAN 内任何设备知道端口号即可连接
|
||||
- JSON.parse 无 schema 验证:code review 建议增加 Zod 校验
|
||||
- Beacon 明文广播 IP/hostname/machineId:建议后续 hash 处理
|
||||
- `getLocalIp()` 可能返回 VPN 地址:多网卡环境需更精确的接口选择
|
||||
|
||||
### 测试
|
||||
|
||||
- `src/utils/__tests__/lanBeacon.test.ts`:7 个测试(mock dgram)
|
||||
- `src/utils/__tests__/peerAddress.test.ts`:8 个测试(纯函数)
|
||||
- 全量:2190 pass / 0 fail
|
||||
|
||||
### 防火墙配置
|
||||
|
||||
**Windows**(管理员 PowerShell):
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "Claude Code LAN Beacon (UDP)" -Direction Inbound -Protocol UDP -LocalPort 7101 -Action Allow -Profile Private
|
||||
New-NetFirewallRule -DisplayName "Claude Code LAN Pipes (TCP)" -Direction Inbound -Protocol TCP -LocalPort 1024-65535 -Program (Get-Command bun).Source -Action Allow -Profile Private
|
||||
New-NetFirewallRule -DisplayName "Claude Code LAN Beacon Out (UDP)" -Direction Outbound -Protocol UDP -RemotePort 7101 -Action Allow -Profile Private
|
||||
```
|
||||
|
||||
**macOS**(首次运行时系统会弹出"允许接受传入连接"对话框,点击允许即可。手动放行):
|
||||
```bash
|
||||
# 如果使用 pf <20><><EFBFBD>火墙,添加规则:
|
||||
echo "pass in proto udp from any to any port 7101" | sudo pfctl -ef -
|
||||
# 或<><E68896>接在 System Settings → Network → Firewall 中允许 bun 进程
|
||||
```
|
||||
|
||||
**Linux**(firewalld):
|
||||
```bash
|
||||
sudo firewall-cmd --zone=trusted --add-port=7101/udp --permanent
|
||||
sudo firewall-cmd --zone=trusted --add-port=1024-65535/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
**Linux**(iptables):
|
||||
```bash
|
||||
sudo iptables -A INPUT -p udp --dport 7101 -j ACCEPT
|
||||
sudo iptables -A INPUT -p tcp --dport 1024:65535 -m owner --uid-owner $(id -u) -j ACCEPT
|
||||
sudo iptables-save | sudo tee /etc/iptables/rules.v4
|
||||
```
|
||||
|
||||
**通用验证**:确认网络为局域网(非公共 WiFi),路<EFBC8C><E8B7AF><EFBFBD>器未开启 AP 隔离。
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Daemon + Remote Control Server 还原 (2026-04-07)
|
||||
|
||||
**分支**: `feat/daemon-remote-control-server`
|
||||
|
||||
32
README.md
32
README.md
@@ -14,8 +14,20 @@
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
|
||||
|
||||
- ✅ [x] V4 — 测试补全、[Buddy](https://ccb.agent-aura.top/docs/features/buddy)、[Auto Mode](https://ccb.agent-aura.top/docs/safety/auto-mode)、环境变量 Feature 开关
|
||||
- ✅ [x] V5 — [Sentry](https://ccb.agent-aura.top/docs/internals/sentry-setup) / [GrowthBook](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) 企业监控、[自定义 Login](https://ccb.agent-aura.top/docs/features/custom-platform-login)、[OpenAI 兼容](https://ccb.agent-aura.top/docs/plans/openai-compatibility)、[Web Search](https://ccb.agent-aura.top/docs/features/web-browser-tool)、[Computer Use](https://ccb.agent-aura.top/docs/features/computer-use) / [Chrome Use](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp)、[Voice Mode](https://ccb.agent-aura.top/docs/features/voice-mode)、[Bridge Mode](https://ccb.agent-aura.top/docs/features/bridge-mode)、[/dream 记忆整理](https://ccb.agent-aura.top/docs/features/auto-dream)
|
||||
| 特性 | 说明 | 文档 |
|
||||
|------|------|------|
|
||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||
| Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||
| Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||
| 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
||||
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||
| Computer Use / Chrome Use | 截图、键鼠控制、浏览器操控 | [Computer Use](https://ccb.agent-aura.top/docs/features/computer-use)<br>[Chrome Use](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
||||
| Sentry / GrowthBook 企业监控 | 企业级错误追踪与特性开关 | [Sentry](https://ccb.agent-aura.top/docs/internals/sentry-setup)<br>[GrowthBook](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||
| Langfuse 监控 | LLM 调用/工具执行/多 Agent 全链路追踪 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||
| Poor Mode | 穷鬼模式,关闭记忆提取和键入建议 | /poor 可以开关 |
|
||||
|
||||
|
||||
- 🔮 [ ] V6 — 大规模重构石山代码,全面模块分包(全新分支,main 封存为历史版本)
|
||||
|
||||
- 🚀 [想要启动项目](#快速开始源码版)
|
||||
@@ -30,13 +42,9 @@
|
||||
```sh
|
||||
bun i -g claude-code-best
|
||||
bun pm -g trust claude-code-best
|
||||
ccb # 直接打开 claude code
|
||||
```
|
||||
|
||||
⚠️ 国内对 github 网络较差的, 需要先设置这个环境变量
|
||||
|
||||
```bash
|
||||
DEFAULT_RELEASE_BASE=https://ghproxy.net/https://github.com/microsoft/ripgrep-prebuilt/releases/download/v15.0.1
|
||||
ccb # 以 nodejs 打开 claude code
|
||||
ccb-bun # 以 bun 形态打开
|
||||
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
|
||||
```
|
||||
|
||||
## ⚡ 快速开始(源码版)
|
||||
@@ -54,12 +62,6 @@ DEFAULT_RELEASE_BASE=https://ghproxy.net/https://github.com/microsoft/ripgrep-pr
|
||||
bun install
|
||||
```
|
||||
|
||||
⚠️ 国内对 github 网络较差的,可以使用这个环境变量
|
||||
|
||||
```bash
|
||||
DEFAULT_RELEASE_BASE=https://ghproxy.net/https://github.com/microsoft/ripgrep-prebuilt/releases/download/v15.0.1
|
||||
```
|
||||
|
||||
### ▶️ 运行
|
||||
|
||||
```bash
|
||||
|
||||
30
build.ts
30
build.ts
@@ -11,9 +11,6 @@ rmSync(outdir, { recursive: true, force: true })
|
||||
// Default features that match the official CLI build.
|
||||
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
|
||||
const DEFAULT_BUILD_FEATURES = [
|
||||
'BUDDY',
|
||||
'TRANSCRIPT_CLASSIFIER',
|
||||
'BRIDGE_MODE',
|
||||
'AGENT_TRIGGERS_REMOTE',
|
||||
'CHICAGO_MCP',
|
||||
'VOICE_MODE',
|
||||
@@ -33,6 +30,19 @@ const DEFAULT_BUILD_FEATURES = [
|
||||
'ULTRAPLAN',
|
||||
// P2: daemon + remote control server
|
||||
'DAEMON',
|
||||
// PR-package restored features
|
||||
'WORKFLOW_SCRIPTS',
|
||||
'HISTORY_SNIP',
|
||||
'CONTEXT_COLLAPSE',
|
||||
'MONITOR_TOOL',
|
||||
'FORK_SUBAGENT',
|
||||
// 'UDS_INBOX',
|
||||
'KAIROS',
|
||||
'COORDINATOR_MODE',
|
||||
'LAN_PIPES',
|
||||
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
|
||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||
'POOR',
|
||||
]
|
||||
|
||||
// Collect FEATURE_* env vars → Bun.build features
|
||||
@@ -102,3 +112,17 @@ if (!rgScript.success) {
|
||||
} else {
|
||||
console.log(`Bundled download-ripgrep script to ${outdir}/`)
|
||||
}
|
||||
|
||||
// Step 6: Generate cli-bun and cli-node executable entry points
|
||||
const cliBun = join(outdir, 'cli-bun.js')
|
||||
const cliNode = join(outdir, 'cli-node.js')
|
||||
|
||||
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n')
|
||||
await writeFile(cliNode, '#!/usr/bin/env node\nimport "./cli.js"\n')
|
||||
|
||||
// Make both executable
|
||||
const { chmodSync } = await import('fs')
|
||||
chmodSync(cliBun, 0o755)
|
||||
chmodSync(cliNode, 0o755)
|
||||
|
||||
console.log(`Generated ${cliBun} (shebang: bun) and ${cliNode} (shebang: node)`)
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.5 MiB |
188
docs.json
Normal file
188
docs.json
Normal file
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "mint",
|
||||
"name": "Claude Code Architecture",
|
||||
"colors": {
|
||||
"primary": "#D97706",
|
||||
"light": "#F59E0B",
|
||||
"dark": "#B45309"
|
||||
},
|
||||
"favicon": "/docs/favicon.svg",
|
||||
"navigation": {
|
||||
"groups": [
|
||||
{
|
||||
"group": "开始",
|
||||
"pages": [
|
||||
{
|
||||
"group": "介绍",
|
||||
"pages": [
|
||||
"docs/introduction/what-is-claude-code",
|
||||
"docs/introduction/why-this-whitepaper",
|
||||
"docs/introduction/architecture-overview"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "对话是如何运转的",
|
||||
"pages": [
|
||||
"docs/conversation/the-loop",
|
||||
"docs/conversation/streaming",
|
||||
"docs/conversation/multi-turn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "工具:AI 的双手",
|
||||
"pages": [
|
||||
"docs/tools/what-are-tools",
|
||||
"docs/tools/file-operations",
|
||||
"docs/tools/shell-execution",
|
||||
"docs/tools/search-and-navigation",
|
||||
"docs/tools/task-management"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "上下文工程",
|
||||
"pages": [
|
||||
"docs/context/system-prompt",
|
||||
"docs/context/project-memory",
|
||||
"docs/context/compaction",
|
||||
"docs/context/token-budget"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "多 Agent 协作",
|
||||
"pages": [
|
||||
"docs/agent/sub-agents",
|
||||
"docs/agent/worktree-isolation",
|
||||
"docs/agent/coordinator-and-swarm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "可扩展性",
|
||||
"pages": [
|
||||
"docs/extensibility/mcp-protocol",
|
||||
"docs/extensibility/hooks",
|
||||
"docs/extensibility/skills",
|
||||
"docs/extensibility/custom-agents"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "安全与权限",
|
||||
"pages": [
|
||||
"docs/safety/why-safety-matters",
|
||||
"docs/safety/permission-model",
|
||||
"docs/safety/sandbox",
|
||||
"docs/safety/plan-mode",
|
||||
"docs/safety/auto-mode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "揭秘:隐藏功能与内部机制",
|
||||
"pages": [
|
||||
"docs/internals/three-tier-gating",
|
||||
"docs/internals/feature-flags",
|
||||
"docs/internals/growthbook-ab-testing",
|
||||
"docs/internals/growthbook-adapter",
|
||||
"docs/internals/sentry-setup",
|
||||
"docs/internals/hidden-features",
|
||||
"docs/internals/ant-only-world",
|
||||
"docs/features/debug-mode",
|
||||
"docs/features/buddy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "隐藏功能详解",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Agent 与协作",
|
||||
"pages": [
|
||||
"docs/features/coordinator-mode",
|
||||
"docs/features/fork-subagent",
|
||||
"docs/features/daemon",
|
||||
"docs/features/teammem"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "运行模式",
|
||||
"pages": [
|
||||
"docs/features/kairos",
|
||||
"docs/features/voice-mode",
|
||||
"docs/features/bridge-mode",
|
||||
"docs/features/remote-control-self-hosting",
|
||||
"docs/features/proactive",
|
||||
"docs/features/ultraplan"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "工具增强",
|
||||
"pages": [
|
||||
"docs/features/mcp-skills",
|
||||
"docs/features/tree-sitter-bash",
|
||||
"docs/features/bash-classifier",
|
||||
"docs/features/web-browser-tool",
|
||||
"docs/features/experimental-skill-search"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "上下文与自动化",
|
||||
"pages": [
|
||||
"docs/features/token-budget",
|
||||
"docs/features/context-collapse",
|
||||
"docs/features/workflow-scripts",
|
||||
"docs/features/auto-dream"
|
||||
]
|
||||
},
|
||||
"docs/features/tier3-stubs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "基础设施与依赖",
|
||||
"pages": [
|
||||
"docs/auto-updater",
|
||||
"docs/lsp-integration",
|
||||
"docs/external-dependencies",
|
||||
"docs/telemetry-remote-config-audit"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"logo": {
|
||||
"light": "/docs/logo/light.svg",
|
||||
"dark": "/docs/logo/dark.svg"
|
||||
},
|
||||
"background": {
|
||||
"color": {
|
||||
"light": "#FFFFFF",
|
||||
"dark": "#0F172A"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"primary": {
|
||||
"type": "github",
|
||||
"href": "https://github.com/claude-code-best/claude-code"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"prompt": "搜索 Claude Code 架构文档..."
|
||||
},
|
||||
"seo": {
|
||||
"metatags": {
|
||||
"og:image": "https://ccb.agent-aura.top/docs/images/og-cover.png",
|
||||
"twitter:image": "https://ccb.agent-aura.top/docs/images/og-cover.png",
|
||||
"twitter:card": "summary_large_image"
|
||||
},
|
||||
"indexing": "navigable"
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"github": "https://github.com/anthropics/claude-code"
|
||||
}
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/docs/introduction",
|
||||
"destination": "/docs/introduction/what-is-claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
# 文档修正计划
|
||||
|
||||
> 目标:补充源码级洞察,让每篇文档从"概念科普"升级为"逆向工程白皮书"水准。
|
||||
|
||||
---
|
||||
|
||||
## 第一梯队:空壳页,需要大幅重写
|
||||
|
||||
### 1. `safety/sandbox.mdx` — 沙箱机制 ✅ DONE
|
||||
|
||||
**现状**:35 行,只列了"文件系统/网络/进程/时间"四个维度,没有任何实现细节。
|
||||
|
||||
**修正方向**:
|
||||
- 补充 macOS `sandbox-exec` 的实际调用方式,展示沙箱 profile 的关键片段
|
||||
- 说明 `getSandboxConfig()` 的判定逻辑:哪些命令走沙箱、哪些跳过
|
||||
- 补充 `dangerouslyDisableSandbox` 参数的设计权衡
|
||||
- 加入 Linux 平台的沙箱差异对比(seatbelt vs namespace)
|
||||
- 展示一次命令执行从权限检查→沙箱包裹→实际执行的完整链路
|
||||
|
||||
---
|
||||
|
||||
### 2. `introduction/what-is-claude-code.mdx` — 什么是 Claude Code ✅ DONE
|
||||
|
||||
**现状**:39 行,纯营销文案,和"普通聊天 AI"的对比表太低级。
|
||||
|
||||
**修正方向**:
|
||||
- 砍掉"能做什么"的泛泛列表,改为一个具体的端到端示例(从用户输入→系统处理→最终输出)
|
||||
- 用一张简化架构图替代文字描述,让读者 30 秒建立直觉
|
||||
- 补充 Claude Code 的技术定位:不是 IDE 插件、不是 Web Chat,而是 terminal-native agentic system
|
||||
- 加入与 Cursor / Copilot / Aider 等工具的定位差异(架构层面而非功能清单)
|
||||
|
||||
---
|
||||
|
||||
### 3. `introduction/why-this-whitepaper.mdx` — 为什么写这份白皮书 ✅ DONE
|
||||
|
||||
**现状**:40 行,全是空话,四张 Card 只是后续章节标题的预告。
|
||||
|
||||
**修正方向**:
|
||||
- 明确定位:这是对 Anthropic 官方 CLI 的逆向工程分析,不是官方文档
|
||||
- 列出逆向过程中发现的 3-5 个最意外/最精妙的设计决策(吊住读者胃口)
|
||||
- 说明白皮书的阅读路线图:推荐的阅读顺序和每个章节解决什么问题
|
||||
- 补充"这份白皮书不是什么"——不是使用教程,不是 API 文档
|
||||
|
||||
---
|
||||
|
||||
### 4. `safety/why-safety-matters.mdx` — 为什么安全至关重要 ✅ DONE
|
||||
|
||||
**现状**:40 行,只列了显而易见的风险,"安全 vs 效率的平衡"只有 3 个 bullet。
|
||||
|
||||
**修正方向**:
|
||||
- 从源码角度展示安全体系的全景图:权限规则 → 沙箱 → Plan Mode → 预算上限 → Hooks 的纵深防御链
|
||||
- 补充 Claude 自身 System Prompt 中的安全指令("执行前确认"、"优先可逆操作"等),展示 AI 端的安全约束
|
||||
- 用真实场景说明"安全 vs 效率"的工程权衡:比如 Read 工具为什么免审批、Bash 工具为什么要逐条确认
|
||||
- 加入 Prompt Injection 防御的简要说明(tool result 中的恶意内容如何被系统标记)
|
||||
|
||||
---
|
||||
|
||||
## 第二梯队:有骨架但太浅,需要补肉
|
||||
|
||||
### 5. `conversation/streaming.mdx` — 流式响应 ✅ DONE
|
||||
|
||||
**现状**:43 行,只说了"流式好"和 3 行 provider 表。
|
||||
|
||||
**修正方向**:
|
||||
- 补充 `BetaRawMessageStreamEvent` 的核心事件类型及其含义
|
||||
- 展示文本 chunk 和 tool_use block 交织的状态机流转
|
||||
- 说明流式中的错误处理:网络断开、API 限流、token 超限时的重试/降级策略
|
||||
- 补充 `processStreamEvents()` 的核心逻辑:如何从事件流中分离出文本、工具调用、usage 统计
|
||||
|
||||
---
|
||||
|
||||
### 6. `tools/search-and-navigation.mdx` — 搜索与导航 ✅ DONE
|
||||
|
||||
**现状**:43 行,只说 Glob 和 Grep 存在。
|
||||
|
||||
**修正方向**:
|
||||
- 补充 ripgrep 二进制的内嵌方式(vendor 目录、平台适配)
|
||||
- 说明搜索结果的 head_limit 默认 250 的设计原因(token 预算)
|
||||
- 展示 ToolSearch 的实现:如何用语义匹配在 50+ 工具(含 MCP)中找到最相关的
|
||||
- 补充 Glob 按修改时间排序的意义:最近修改的文件最可能与当前任务相关
|
||||
|
||||
---
|
||||
|
||||
### 7. `tools/task-management.mdx` — 任务管理 ✅ DONE
|
||||
|
||||
**现状**:50 行,只有流程 Steps 和状态展示的 4 个 bullet。
|
||||
|
||||
**修正方向**:
|
||||
- 补充任务的数据模型:id / subject / description / status / blockedBy / blocks / owner
|
||||
- 说明依赖管理的实现:blockedBy 如何阻止任务被认领、完成一个任务后如何自动解锁下游
|
||||
- 展示任务与 Agent 工具的联动:子 Agent 如何认领任务、报告进度
|
||||
- 补充 activeForm 字段的 UX 设计:进行中任务的 spinner 动画文案
|
||||
|
||||
---
|
||||
|
||||
### 8. `context/token-budget.mdx` — Token 预算管理 ✅ DONE
|
||||
|
||||
**现状**:55 行,预算控制只有 3 张 Card 各一句话。
|
||||
|
||||
**修正方向**:
|
||||
- 补充 `contextWindowTokens` 和 `maxOutputTokens` 的动态计算逻辑
|
||||
- 说明缓存 breakpoint 的放置策略:System Prompt 中不变内容在前、变化内容在后的原因
|
||||
- 展示工具输出截断的具体机制:超长结果如何被 truncate、何时触发 micro-compact
|
||||
- 补充 token 计数的实现:`countTokens` 的调用时机和近似 vs 精确计数的权衡
|
||||
|
||||
---
|
||||
|
||||
### 9. `agent/worktree-isolation.mdx` — Worktree 隔离 ✅ DONE
|
||||
|
||||
**现状**:55 行,只描述了 git worktree 的概念。
|
||||
|
||||
**修正方向**:
|
||||
- 展示 `.claude/worktrees/` 的目录结构和分支命名规则
|
||||
- 说明 worktree 的生命周期:创建时机(`isolation: "worktree"`)→ 子 Agent 执行 → 完成/放弃 → 自动清理
|
||||
- 补充 worktree 与子 Agent 的绑定关系:Agent 结束时如何判断 keep or remove
|
||||
- 加入 EnterWorktree / ExitWorktree 工具的交互设计
|
||||
|
||||
---
|
||||
|
||||
### 10. `extensibility/custom-agents.mdx` — 自定义 Agent ✅ DONE
|
||||
|
||||
**现状**:56 行,只有配置表和示例表。
|
||||
|
||||
**修正方向**:
|
||||
- 展示 agent markdown 文件的完整 frontmatter 格式(name / description / model / allowedTools 等)
|
||||
- 说明 agent 如何被加载和注入 System Prompt:`loadAgentDefinitions()` 的发现和合并逻辑
|
||||
- 展示工具限制的实现:allowedTools 如何过滤工具列表
|
||||
- 补充 agent 与 subagent_type 参数的关联:Agent 工具如何指定使用自定义 Agent
|
||||
@@ -10,13 +10,13 @@ keywords: ["协调者模式", "蜂群模式", "Agent Swarm", "多 Agent 协作",
|
||||
|
||||
| 维度 | Coordinator Mode | Agent Swarms |
|
||||
|------|-----------------|--------------|
|
||||
| **门控** | `feature('COORDINATOR_MODE')` + `CLAUDE_CODE_COORDINATOR_MODE=1` | 任务系统 V2(默认启用) |
|
||||
| **拓扑** | 星型:Coordinator 居中,Worker 外围 | 网状:对等 Agent 共享任务列表 |
|
||||
| **角色** | 明确分工:Coordinator 编排、Worker 执行 | 模糊:每个 Agent 自主认领任务 |
|
||||
| **通信** | `SendMessage` 定向通信 + `<task-notification>` | 任务文件系统 + 邮箱广播 |
|
||||
| **适用** | 需要集中决策的复杂任务 | 并行度高的独立子任务 |
|
||||
| **门控** | `feature('COORDINATOR_MODE')` + `CLAUDE_CODE_COORDINATOR_MODE=1` | `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` 环境变量 |
|
||||
| **拓扑** | 星型:Coordinator 居中,Worker 外围 | 星型+P2P 混合:Team Lead 协调,Teammate 间可直接通信 |
|
||||
| **角色** | 明确分工:Coordinator 编排、Worker 执行 | Team Lead 协调 + Teammate 自主认领任务 |
|
||||
| **通信** | `SendMessage` 定向通信 + `<task-notification>` | Mailbox 消息系统(message / broadcast) |
|
||||
| **适用** | 需要集中决策的复杂任务 | 并行度高、需要 Teammate 间直接协作的任务 |
|
||||
|
||||
两者不是互斥的——Coordinator Mode 可以在 Swarm 架构之上运行,将 Coordinator 作为特殊的 Leader Agent。
|
||||
两者不是互斥的——理论上 Coordinator Mode 可以在 Agent Teams 架构之上运行(概念层叠加,非嵌套团队),将 Coordinator 作为特殊的 Team Lead,但这部分集成(`workerAgent.ts` 中的 `getCoordinatorAgents`)目前为 stub 实现,尚未完整落地。
|
||||
|
||||
## Coordinator Mode:星型编排架构
|
||||
|
||||
@@ -45,7 +45,7 @@ Coordinator 被剥夺了所有"动手"工具,只保留编排能力:
|
||||
| **TaskStop** | 中途停止走错方向的 Worker |
|
||||
| **subscribe_pr_activity** | 订阅 GitHub PR 事件(review comments、CI 结果) |
|
||||
|
||||
Coordinator **不写代码、不读文件、不执行命令**——它只做三件事:理解需求、分配任务、综合结果。
|
||||
Coordinator **不写代码、不读文件、不执行命令**——它的核心职责是:理解需求、分配任务、综合结果,以及在无需工具时直接回答用户问题。
|
||||
|
||||
### Worker 的工具权限
|
||||
|
||||
@@ -53,7 +53,7 @@ Worker 的可用工具由 `getCoordinatorUserContext()`(`coordinatorMode.ts:80
|
||||
|
||||
```typescript
|
||||
// 简化模式下:只有 Bash + Read + Edit
|
||||
const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE')
|
||||
const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
||||
? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME]
|
||||
: Array.from(ASYNC_AGENT_ALLOWED_TOOLS)
|
||||
.filter(name => !INTERNAL_WORKER_TOOLS.has(name))
|
||||
@@ -63,7 +63,7 @@ const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE')
|
||||
|
||||
### Scratchpad:跨 Worker 的共享知识库
|
||||
|
||||
当 `tengu_scratch` feature flag 启用时,Coordinator 拥有一个 Scratchpad 目录:
|
||||
当 `isScratchpadGateEnabled()`(内部检查 `tengu_scratch` feature gate)启用时,Workers 获得一个 Scratchpad 目录,Coordinator 通过其系统上下文知晓该目录的存在:
|
||||
|
||||
```
|
||||
Scratchpad 目录:
|
||||
@@ -113,32 +113,84 @@ Coordinator System Prompt(`coordinatorMode.ts:111-369`,约 260 行)明确
|
||||
|
||||
这是 Coordinator Mode 最核心的设计约束:Coordinator 必须先理解,再分配。
|
||||
|
||||
## Agent Swarms:蜂群式协作
|
||||
## Agent Teams (Swarm):蜂群式协作
|
||||
|
||||
Swarm 模式基于任务系统 V2(详见[任务管理](../tools/task-management.mdx)),核心机制是**共享任务列表 + 竞争认领**:
|
||||
Swarm 模式基于任务系统 V2(详见[任务管理](../tools/task-management.mdx)),核心机制是**共享任务列表 + 竞争认领 + Mailbox 消息系统**:
|
||||
|
||||
### 团队初始化
|
||||
|
||||
```
|
||||
Leader 创建团队(TeamCreateTool)
|
||||
Team Lead 创建团队(TeamCreateTool)
|
||||
↓
|
||||
设置 teamName → setLeaderTeamName()
|
||||
↓
|
||||
所有 teammate 自动获得相同的 taskListId
|
||||
所有 Teammate 自动获得相同的 taskListId
|
||||
↓
|
||||
teammate 启动时:
|
||||
Teammate 启动时:
|
||||
1. CLAUDE_CODE_TASK_LIST_ID 环境变量(显式覆盖)
|
||||
2. teammate 上下文的 teamName(共享 leader 的任务列表)
|
||||
2. Teammate 上下文的 teamName(共享 Lead 的任务列表)
|
||||
3. CLAUDE_CODE_TEAM_NAME 环境变量
|
||||
4. leader 设置的 teamName
|
||||
4. Lead 设置的 teamName
|
||||
5. getSessionId()(兜底)
|
||||
```
|
||||
|
||||
多级优先级确保了 Leader 和所有 Teammate 指向同一个任务列表,无需额外协调。
|
||||
多级优先级确保了 Team Lead 和所有 Teammate 指向同一个任务列表,无需额外协调。
|
||||
|
||||
### 架构组件
|
||||
|
||||
官方 Agent Teams 架构定义了四个核心组件:
|
||||
|
||||
| 组件 | 角色 |
|
||||
|------|------|
|
||||
| **Team Lead** | 创建团队、分配任务、综合结果的主 Claude Code 会话 |
|
||||
| **Teammate** | 独立的 Claude Code 实例,各自拥有独立的上下文窗口 |
|
||||
| **Task List** | 共享的任务列表,Teammate 竞争认领和完成 |
|
||||
| **Mailbox** | 消息系统,支持 Teammate 间直接通信 |
|
||||
|
||||
### Mailbox 消息系统
|
||||
|
||||
官方架构中的 Mailbox 是 Teammate 间通信的核心原语,支持两种消息模式(`broadcast` 模式来自源码推断,官方文档未明确细分):
|
||||
|
||||
| 模式 | 作用 | 场景 |
|
||||
|------|------|------|
|
||||
| **message** | 定向发送给指定 Teammate | 传递具体指令、请求协作 |
|
||||
| **broadcast** | 广播给所有 Teammate | 全局通知、状态同步 |
|
||||
|
||||
Mailbox 的关键特性:
|
||||
- **自动投递**:消息自动送达目标 Teammate 的对话上下文
|
||||
- **空闲通知**(TeammateIdle):Teammate 完成当前任务进入空闲时,自动通过 Mailbox 通知 Team Lead
|
||||
- **直接通信**:与 Coordinator Mode 不同,Teammate 之间可以直接通信,无需经过 Lead 中转
|
||||
|
||||
### Hook 事件
|
||||
|
||||
Agent Teams 提供三个关键 Hook 事件,用于在团队生命周期中注入自定义逻辑:
|
||||
|
||||
| Hook | 触发时机 | 典型用途 |
|
||||
|------|---------|---------|
|
||||
| **TaskCreated** | 新任务添加到任务列表时 | 自动分配、优先级排序 |
|
||||
| **TaskCompleted** | 任务标记为完成时 | 结果通知、依赖解锁 |
|
||||
| **TeammateIdle** | Teammate 完成所有任务进入空闲时 | Lead 重新分配、动态扩缩容 |
|
||||
|
||||
### 限制
|
||||
|
||||
当前 Agent Teams 实现的限制:
|
||||
- **不支持嵌套团队**:Teammate 不能再创建子团队
|
||||
- **每 session 一个团队**:一个会话只能属于一个团队
|
||||
- **Lead 固定**:Team Lead 创建后不可更换
|
||||
- **不支持 in-process Teammate 的会话恢复**:进程重启后 in-process 类型 Teammate 的状态丢失
|
||||
|
||||
### 持久化存储
|
||||
|
||||
团队状态通过文件系统持久化,确保进程重启后可恢复:
|
||||
|
||||
```
|
||||
~/.claude/teams/{team-name}/config.json ← 团队配置
|
||||
~/.claude/tasks/{team-name}/ ← 共享任务列表(文件锁保护)
|
||||
```
|
||||
|
||||
### 任务认领与竞争
|
||||
|
||||
`claimTask()` 是 Swarm 的核心并发原语:
|
||||
`claimTask()` 是 Agent Teams 的核心并发原语:
|
||||
|
||||
```
|
||||
Teammate A 调用 TaskList → 发现 task #3 是 pending
|
||||
@@ -146,7 +198,7 @@ Teammate B 同时发现 task #3 是 pending
|
||||
↓
|
||||
两者同时尝试 TaskUpdate(task #3, {status: "in_progress"})
|
||||
↓
|
||||
文件锁 + 高水位标记保证原子性:
|
||||
文件锁保证原子性:
|
||||
- 第一个写入者获得 owner 锁定
|
||||
- 第二个写入者收到 already_claimed 错误
|
||||
↓
|
||||
@@ -166,8 +218,11 @@ unassignTeammateTasks()
|
||||
→ 扫描任务列表,找到 owner === teammateName 的未完成任务
|
||||
→ 重置为 pending + owner=undefined
|
||||
↓
|
||||
Leader 通过 mailbox 收到通知
|
||||
→ 重新分配或创建新 Teammate
|
||||
Team Lead 感知途径:
|
||||
1. 任务状态变化(pending 重置)—— 通过共享任务列表
|
||||
2. Mailbox 空闲通知(TeammateIdle hook)—— Teammate 停止时自动通知 Lead
|
||||
↓
|
||||
Team Lead 重新分配任务或创建新 Teammate
|
||||
```
|
||||
|
||||
## 任务类型全景
|
||||
@@ -186,11 +241,11 @@ Leader 通过 mailbox 收到通知
|
||||
|
||||
`InProcessTeammateTask` 与 `LocalAgentTask` 的关键差异:前者共享进程的内存空间和基础设施状态(如 MCP 连接池),但有独立的对话上下文和工具权限;后者是完全隔离的子进程,启动开销更大但更安全。
|
||||
|
||||
## Coordinator vs Swarm 的选择
|
||||
## Coordinator vs Agent Teams 的选择
|
||||
|
||||
| 场景 | 推荐模式 | 原因 |
|
||||
|------|---------|------|
|
||||
| "重构认证系统,需要多模块协调" | Coordinator | 需要集中决策,Worker 间有依赖 |
|
||||
| "修复 10 个独立的 lint 警告" | Swarm | 任务独立,可完全并行 |
|
||||
| "修复 10 个独立的 lint 警告" | Agent Teams | 任务独立,Teammate 可完全并行 |
|
||||
| "研究方案 A 和方案 B,然后选一个实现" | Coordinator | 先并行研究,再集中决策 |
|
||||
| "在大仓库中搜索所有 TODO 并分类" | Swarm | 无依赖,各自领任务即可 |
|
||||
| "在大仓库中搜索所有 TODO 并分类" | Agent Teams | 无依赖,各自领任务即可 |
|
||||
|
||||
@@ -14,8 +14,8 @@ keywords: ["子 Agent", "AgentTool", "任务委派", "forkSubagent", "子进程
|
||||
AI 生成 tool_use: { prompt: "修复 bug", subagent_type: "Explore" }
|
||||
↓
|
||||
AgentTool.call() ← 入口(AgentTool.tsx:239)
|
||||
├── 解析 effectiveType(fork vs 命名 agent)
|
||||
├── filterDeniedAgents() ← 权限过滤
|
||||
├── 解析 effectiveType(fork vs 命名 agent vs GP 回退)
|
||||
├── filterDeniedAgents() ← 仅命名 Agent 路径执行:权限过滤
|
||||
├── 检查 requiredMcpServers ← MCP 依赖验证(最长等 30s)
|
||||
├── assembleToolPool(workerPermissionContext) ← 独立组装工具池
|
||||
├── createAgentWorktree() ← 可选 worktree 隔离
|
||||
@@ -26,26 +26,30 @@ runAgent() ← 核心执行(runAgent.ts:248
|
||||
├── executeSubagentStartHooks() ← Hook 注入
|
||||
├── query() ← 进入标准 agentic loop
|
||||
│ ├── 消息流逐条 yield
|
||||
│ └── recordSidechainTranscript() ← JSONL 持久化
|
||||
│ └── recordSidechainTranscript() ← JSONL 持久化(~/.claude/projects/{project}/{session}/subagents/)
|
||||
↓
|
||||
finalizeAgentTool() ← 结果汇总
|
||||
├── 提取文本内容 + usage 统计
|
||||
└── mapToolResultToToolResultBlockParam() ← 格式化为 tool_result
|
||||
```
|
||||
|
||||
## 两种子 Agent 路径:命名 Agent vs Fork
|
||||
## 子 Agent 的三种路径
|
||||
|
||||
`AgentTool.call()` 根据是否提供 `subagent_type` 走两条完全不同的路径(`AgentTool.tsx:322-356`):
|
||||
`AgentTool.call()` 根据 `subagent_type` 参数和 Fork 实验开关,走三条不同的路径:
|
||||
|
||||
| 维度 | 命名 Agent(`subagent_type` 指定) | Fork 子进程(`subagent_type` 省略) |
|
||||
|------|-------------------------------------|--------------------------------------|
|
||||
| **触发条件** | `subagent_type` 有值 | `isForkSubagentEnabled()` && 未指定类型 |
|
||||
| **System Prompt** | Agent 自身的 `getSystemPrompt()` | 继承父 Agent 的完整 System Prompt |
|
||||
| **工具池** | `assembleToolPool()` 独立组装 | 父 Agent 的原始工具池(`useExactTools: true`) |
|
||||
| **上下文** | 仅任务描述 | 父 Agent 的完整对话历史(`forkContextMessages`) |
|
||||
| **模型** | 可独立指定 | 继承父模型(`model: 'inherit'`) |
|
||||
| **权限模式** | Agent 定义的 `permissionMode` | `'bubble'`(上浮到父终端) |
|
||||
| **目的** | 专业任务委派 | Prompt Cache 命中率优化 |
|
||||
| 维度 | 命名 Agent(`subagent_type` 指定) | Fork 子进程(Fork 启用 + 类型省略) | General-purpose 回退(Fork 关闭 + 类型省略) |
|
||||
|------|-------------------------------------|--------------------------------------|---------------------------------------------|
|
||||
| **触发条件** | `subagent_type` 有值 | `isForkSubagentEnabled() === true` 且未指定类型 | `isForkSubagentEnabled() === false` 且未指定类型 |
|
||||
| **System Prompt** | Agent 自身的 `getSystemPrompt()` | 继承父 Agent 的完整 System Prompt | General-purpose Agent 的 `getSystemPrompt()` |
|
||||
| **工具池** | `assembleToolPool()` 独立组装 | 父 Agent 的原始工具池(`useExactTools: true`) | `assembleToolPool()` 独立组装 |
|
||||
| **上下文** | 仅任务描述 | 父 Agent 的完整对话历史(`forkContextMessages`) | 仅任务描述 |
|
||||
| **模型** | 可独立指定 | 继承父模型(`model: 'inherit'`) | 可独立指定 |
|
||||
| **权限模式** | Agent 定义的 `permissionMode` | `'bubble'`(上浮到父终端) | Agent 定义的 `permissionMode` |
|
||||
| **目的** | 专业任务委派 | Prompt Cache 命中率优化 | 通用任务处理 |
|
||||
|
||||
<Note>
|
||||
Fork 实验的门控函数 `isForkSubagentEnabled()` 需要同时满足三个前提:`FORK_SUBAGENT` feature flag 已启用、当前不在 Coordinator 模式中、且不是非交互式会话。任一条件不满足时,省略 `subagent_type` 会静默降级为 General-purpose Agent,而非触发 Fork。
|
||||
</Note>
|
||||
|
||||
Fork 路径的设计核心是 **Prompt Cache 共享**:所有 fork 子进程共享父 Agent 的完整 `assistant` 消息(所有 `tool_use` 块),用相同的占位符 `tool_result` 填充,只有最后一个 `text` 块包含各自的指令。这使得 API 请求前缀字节完全一致,最大化缓存命中。
|
||||
|
||||
@@ -64,9 +68,41 @@ Fork 子进程保留 Agent 工具(为了 cache-identical tool defs),但通
|
||||
1. **`querySource` 检查**(压缩安全):`context.options.querySource === 'agent:builtin:fork'`
|
||||
2. **消息扫描**(降级兜底):检测 `<fork-boilerplate>` 标签
|
||||
|
||||
## 工具池的独立组装
|
||||
### 模型解析优先级
|
||||
|
||||
子 Agent 不继承父 Agent 的工具限制——它的工具池完全独立组装(`AgentTool.tsx:573-577`):
|
||||
子 Agent 的模型选择遵循严格的优先级链(`src/utils/model/agent.ts`):
|
||||
|
||||
```
|
||||
1. CLAUDE_CODE_SUBAGENT_MODEL 环境变量 ← 全局覆盖
|
||||
↓(未设置时)
|
||||
2. 每次调用的 model 参数 ← AgentTool 入参
|
||||
↓(未指定时)
|
||||
3. Agent 定义的 model frontmatter ← 如 "sonnet", "haiku", "inherit"
|
||||
↓(未定义时)
|
||||
4. 继承父对话模型(conversation model) ← getDefaultSubagentModel() 返回 "inherit"
|
||||
```
|
||||
|
||||
其中 `inherit` 不是简单的模型传递——它经过 `getRuntimeMainLoopModel()` 解析,确保 plan mode 下的 `opusplan→Opus` 等运行时映射正确生效。当 Agent 指定的模型族(如 `haiku`)与父模型同族时,直接复用父模型的精确 ID,避免跨 provider 降级。
|
||||
|
||||
## 命名 Agent 的工具池独立组装
|
||||
|
||||
### 内置 Agent
|
||||
|
||||
系统预定义了几个内置 Agent(`src/tools/AgentTool/builtinAgents.ts`),各有明确的职责和模型配置:
|
||||
|
||||
| Agent | 模型 | 权限 | 用途 |
|
||||
|-------|------|------|------|
|
||||
| **Explore** | Haiku(轻量快速) | 只读(Read/Grep/Glob) | 代码库搜索与探索 |
|
||||
| **Plan** | 继承父模型 | 只读 | 为 Plan Mode 收集研究信息 |
|
||||
| **General-purpose** | 继承父模型 | 全部工具 | 复杂的通用任务处理 |
|
||||
| **statusline-setup** | 继承父模型 | 受限 | 配置状态栏设置 |
|
||||
| **claude-code-guide** | 继承父模型 | 受限 | 解答 Claude Code 使用问题 |
|
||||
|
||||
用户还可通过 `.claude/agents/` 目录或 settings 定义自定义 Agent,作用域优先级为:managed settings > CLI `--agents` > 项目级 `.claude/agents/` > 用户级 `~/.claude/agents/` > plugin。
|
||||
|
||||
命名 Agent(包括 General-purpose 回退)不继承父 Agent 的工具限制——它的工具池完全独立组装。Fork 子进程则通过 `useExactTools: true` 直接继承父 Agent 的原始工具池,以保持 Prompt Cache 中工具定义的字节一致性。
|
||||
|
||||
命名 Agent 的工具池组装逻辑:
|
||||
|
||||
```typescript
|
||||
const workerPermissionContext = {
|
||||
@@ -93,6 +129,17 @@ const resolvedTools = useExactTools
|
||||
|
||||
`resolveAgentTools()` 会根据 Agent 定义中的 `tools` 字段过滤可用工具,将 `['*']` 映射为全量工具。
|
||||
|
||||
### Hook 事件
|
||||
|
||||
子 Agent 支持 Agent 定义 frontmatter 和全局 settings.json 两种级别的 Hook:
|
||||
|
||||
| 来源 | 事件 | 说明 |
|
||||
|------|------|------|
|
||||
| Agent frontmatter `hooks` | `PreToolUse` / `PostToolUse` | 工具调用前后拦截 |
|
||||
| Agent frontmatter `hooks` | `Stop` | 自动转换为 `SubagentStop`(`registerFrontmatterHooks` 传入 `isAgent=true`) |
|
||||
| settings.json | `SubagentStart` | 子 Agent 启动时触发(`executeSubagentStartHooks()`) |
|
||||
| settings.json | `SubagentStop` | 子 Agent 停止时触发 |
|
||||
|
||||
## Worktree 隔离机制
|
||||
|
||||
`isolation: "worktree"` 参数让子 Agent 在独立的 git worktree 中工作(`AgentTool.tsx:590-593`):
|
||||
@@ -115,19 +162,23 @@ Worktree 生命周期:
|
||||
|
||||
### 异步 Agent(后台运行)
|
||||
|
||||
当 `run_in_background=true` 或 `selectedAgent.background=true` 时,Agent 立即返回 `async_launched` 状态(`AgentTool.tsx:686-764`):
|
||||
当 `run_in_background=true`、`selectedAgent.background=true`、或系统判定应强制异步(如 `assistantForceAsync`、`proactiveModule` 激活)时,Agent 立即返回 `async_launched` 状态:
|
||||
|
||||
```
|
||||
registerAsyncAgent(agentId, ...) ← 注册到 AppState.tasks
|
||||
↓ (void — 火后不管)
|
||||
runAsyncAgentLifecycle() ← 后台执行
|
||||
runAsyncAgentLifecycle() ← 后台执行(agentToolUtils.ts)
|
||||
├── runAgent().onCacheSafeParams ← 进度摘要初始化
|
||||
├── 消息流迭代
|
||||
├── completeAsyncAgent() ← 标记完成
|
||||
├── classifyHandoffIfNeeded() ← 安全检查
|
||||
├── finalizeAgentTool() ← 结果汇总(提取文本 + usage 统计)
|
||||
├── completeAsyncAgent() ← 标记完成(先于通知,确保 TaskOutput 尽快解除阻塞)
|
||||
├── classifyHandoffIfNeeded() ← 安全分类(需 TRANSCRIPT_CLASSIFIER feature)
|
||||
├── getWorktreeResult() ← Worktree 清理(如有隔离)
|
||||
└── enqueueAgentNotification() ← 通知主 Agent
|
||||
```
|
||||
|
||||
如果异步 Agent 提供了 `name` 参数,还会注册到 `agentNameRegistry`,支撑 `SendMessage` 工具通过名称路由到该 Agent。
|
||||
|
||||
异步 Agent 获得独立的 `AbortController`,不与父 Agent 共享——用户按 ESC 取消主线程不会杀掉后台 Agent。
|
||||
|
||||
### 同步 Agent(前台运行)
|
||||
@@ -154,16 +205,16 @@ const raceResult = await Promise.race([
|
||||
|
||||
## 结果回传格式
|
||||
|
||||
`mapToolResultToToolResultBlockParam()` 根据状态返回不同格式(`AgentTool.tsx:1298-1375`):
|
||||
`mapToolResultToToolResultBlockParam()` 根据状态返回不同格式:
|
||||
|
||||
| 状态 | 返回内容 |
|
||||
|------|---------|
|
||||
| `completed` | 内容 + `<usage>` 块(token/tool_calls/duration) |
|
||||
| `async_launched` | agentId + outputFile 路径 + 操作指引 |
|
||||
| `completed` | 内容 + `<usage>` 块(token/tool_calls/duration);无内容时插入占位文本 `"(Subagent completed but returned no output.)"` 防止模型误判为空 |
|
||||
| `async_launched` | agentId + outputFile 路径 + 操作指引(指引内容取决于 `canReadOutputFile`:有读取权限时提示通过 Read/Bash 查看进度,否则仅告知已启动) |
|
||||
| `teammate_spawned` | agent_id + name + team_name |
|
||||
| `remote_launched` | taskId + sessionUrl + outputFile |
|
||||
|
||||
对于一次性内置 Agent(Explore、Plan),`<usage>` 块被省略——每周节省约 1-2 Gtok 的上下文窗口。
|
||||
对于一次性内置 Agent(Explore、Plan),当**不存在** worktree 隔离时,`<usage>` 块和 agentId 尾部被省略——每周节省约 1-2 Gtok 的上下文窗口。存在 worktree 时仍需返回 `worktreePath` 和 `worktreeBranch` 信息。
|
||||
|
||||
## MCP 依赖的等待机制
|
||||
|
||||
@@ -174,7 +225,7 @@ const MAX_WAIT_MS = 30_000 // 最长等 30 秒
|
||||
const POLL_INTERVAL_MS = 500 // 每 500ms 轮询
|
||||
```
|
||||
|
||||
早期退出条件:任何必需服务器进入 `failed` 状态时立即停止等待。工具可用性通过 `mcp__` 前缀工具名解析(`mcp__serverName__toolName`)判断。
|
||||
早期退出条件:任何必需服务器进入 `failed` 状态时立即停止等待。工具可用性通过 `mcp__` 前缀工具名解析(`mcp__serverName__toolName`)判断。等待结束后如果仍有必需服务器未就绪,`call()` 会抛出错误并明确列出缺失的服务器名称。
|
||||
|
||||
## 适用场景
|
||||
|
||||
|
||||
@@ -143,14 +143,18 @@ call() — 实际执行
|
||||
|
||||
## 与 Agent 工具的联动
|
||||
|
||||
Agent 工具(`AgentTool`)的 `isolation` 参数决定子 Agent 是否在 worktree 中运行:
|
||||
Agent 工具(`AgentTool`)的 `isolation` 参数决定子 Agent 是否在 worktree 中运行。注意 Agent 工具使用**专用的** `createAgentWorktree()`(`src/utils/worktree.ts`),而非用户会话用的 `createWorktreeForSession()`,两者有关键差异:
|
||||
|
||||
- `isolation: "worktree"` → 调用 `createWorktreeForSession()`,子 Agent 在独立 worktree 中执行
|
||||
- 无 isolation → 子 Agent 共享主工作目录
|
||||
| 维度 | `createWorktreeForSession`(用户会话) | `createAgentWorktree`(子 Agent) |
|
||||
|------|---------------------------------------|----------------------------------|
|
||||
| 调用者 | EnterWorktreeTool | AgentTool |
|
||||
| Session 管理 | 设置 `currentWorktreeSession` | **不设置** `currentWorktreeSession` |
|
||||
| 恢复已有 worktree | 直接复用 | 复用并 bump mtime(防止被周期性清理误删) |
|
||||
|
||||
子 Agent 结束时的处理:
|
||||
- **成功**:主 Agent 通过 `ExitWorktreeTool(action: "keep")` 保留 worktree,然后手动合并
|
||||
- **失败/放弃**:主 Agent 通过 `ExitWorktreeTool(action: "remove", discard_changes: true)` 清理
|
||||
子 Agent 结束时的处理由 `cleanupWorktreeIfNeeded()` 自动完成——它不走 `ExitWorktreeTool`(因为 Agent worktree 没有会话状态,`ExitWorktreeTool` 的 `validateInput` 会拒绝):
|
||||
- **有变更** → 保留 worktree,返回 `worktreePath` 供主 Agent 后续合并
|
||||
- **无变更** → 自动删除
|
||||
- **Hook-based** → 始终保留
|
||||
|
||||
## Session 状态持久化
|
||||
|
||||
@@ -168,6 +172,7 @@ Agent 工具(`AgentTool`)的 `isolation` 参数决定子 Agent 是否在 wor
|
||||
tmuxSessionName?: string, // 关联的 tmux session
|
||||
hookBased?: boolean, // 是否由 hook 创建
|
||||
creationDurationMs?: number, // 创建耗时(分析用)
|
||||
usedSparsePaths?: boolean, // 是否使用了 sparse checkout
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
# Feature 探索计划书
|
||||
|
||||
> 生成日期:2026-04-02
|
||||
> 代码库中已识别 89 个 feature flag,本文档按实现完整度和探索价值分级,制定探索优先级和路线图。
|
||||
>
|
||||
> **已完成**:BUDDY(✅ 2026-04-02)、TRANSCRIPT_CLASSIFIER / Auto Mode(✅ 2026-04-02)
|
||||
|
||||
---
|
||||
|
||||
## 一、总览
|
||||
|
||||
### 按实现状态分类
|
||||
|
||||
| 状态 | 数量 | 说明 |
|
||||
|------|------|------|
|
||||
| 已实现/可用 | 11 | 代码完整,开启 feature 后可运行(可能需要 OAuth 等外部依赖) |
|
||||
| 部分实现 | 8 | 核心逻辑存在但关键模块为 stub,需要补全 |
|
||||
| 纯 Stub | 15 | 所有函数/工具返回空值,需要从零实现 |
|
||||
| N/A | 55+ | 内部基础设施、低引用量辅助功能,或反编译丢失过多 |
|
||||
|
||||
### 启用方式
|
||||
|
||||
所有 feature 通过环境变量启用:
|
||||
|
||||
```bash
|
||||
# 单个 feature
|
||||
FEATURE_BUDDY=1 bun run dev
|
||||
|
||||
# 多个 feature 组合
|
||||
FEATURE_KAIROS=1 FEATURE_PROACTIVE=1 FEATURE_FORK_SUBAGENT=1 bun run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、Tier 1 — 已实现/可用(优先探索)
|
||||
|
||||
### 2.1 KAIROS(常驻助手模式)⭐ 最高优先级
|
||||
|
||||
- **引用数**:154(全库最大)
|
||||
- **功能**:将 CLI 变为常驻后台助手,支持:
|
||||
- 持久化 bridge 会话(跨重启复用 session)
|
||||
- 后台执行任务(用户离开终端时继续工作)
|
||||
- 推送通知到移动端(任务完成/需要输入时)
|
||||
- 每日记忆日志 + `/dream` 知识蒸馏
|
||||
- 外部频道消息接入(Slack/Discord/Telegram)
|
||||
- **子 Feature**:
|
||||
|
||||
| 子 Feature | 引用 | 功能 |
|
||||
|-----------|------|------|
|
||||
| `KAIROS_BRIEF` | 39 | Brief 工具(`SendUserMessage`),结构化消息输出 |
|
||||
| `KAIROS_CHANNELS` | 19 | 外部频道消息接入 |
|
||||
| `KAIROS_PUSH_NOTIFICATION` | 4 | 移动端推送通知 |
|
||||
| `KAIROS_GITHUB_WEBHOOKS` | 3 | GitHub PR webhook 订阅 |
|
||||
| `KAIROS_DREAM` | 1 | 夜间记忆蒸馏 |
|
||||
|
||||
- **关键文件**:`src/assistant/`、`src/tools/BriefTool/`、`src/services/mcp/channelNotification.ts`、`src/memdir/memdir.ts`
|
||||
- **外部依赖**:Anthropic OAuth(claude.ai 订阅)、GrowthBook 特性门控
|
||||
- **探索命令**:`FEATURE_KAIROS=1 FEATURE_KAIROS_BRIEF=1 FEATURE_PROACTIVE=1 bun run dev`
|
||||
|
||||
**探索步骤**:
|
||||
1. 开启 feature,观察启动行为变化
|
||||
2. 测试 `/assistant`、`/brief` 命令
|
||||
3. 验证 BriefTool 输出模式
|
||||
4. 尝试频道消息接入
|
||||
5. 测试 `/dream` 记忆蒸馏
|
||||
|
||||
---
|
||||
|
||||
### ~~2.2 TRANSCRIPT_CLASSIFIER(Auto Mode 分类器)~~ ✅ 已完成
|
||||
|
||||
- **引用数**:108
|
||||
- **功能**:使用 LLM 对用户意图进行分类,实现 auto mode(自动决定工具权限)
|
||||
- **状态**:✅ prompt 模板已重建,功能完整可用(2026-04-02 完成)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 VOICE_MODE(语音输入)
|
||||
|
||||
- **引用数**:46
|
||||
- **功能**:按键说话(Push-to-Talk),音频流式传输到 Anthropic STT 端点(Nova 3),实时转录显示
|
||||
- **当前状态**:**完整实现**,包括录音、WebSocket 流、转录插入
|
||||
- **关键文件**:`src/voice/voiceModeEnabled.ts`、`src/hooks/useVoice.ts`、`src/services/voiceStreamSTT.ts`
|
||||
- **外部依赖**:Anthropic OAuth(非 API key)、macOS 原生音频或 SoX
|
||||
- **探索命令**:`FEATURE_VOICE_MODE=1 bun run dev`
|
||||
- **默认快捷键**:长按空格键录音
|
||||
|
||||
**探索步骤**:
|
||||
1. 确认 OAuth token 可用
|
||||
2. 测试按住空格录音 → 释放后转录
|
||||
3. 验证实时中间转录显示
|
||||
4. 测试 `/voice` 命令切换
|
||||
|
||||
---
|
||||
|
||||
### 2.4 TEAMMEM(团队共享记忆)
|
||||
|
||||
- **引用数**:51
|
||||
- **功能**:基于 GitHub 仓库的团队共享记忆系统,`memory/team/` 目录双向同步到 Anthropic 服务器
|
||||
- **当前状态**:**完整实现**,包括增量同步、冲突解决、密钥扫描、路径穿越防护
|
||||
- **关键文件**:`src/services/teamMemorySync/`(index、watcher、secretScanner)、`src/memdir/teamMemPaths.ts`
|
||||
- **外部依赖**:Anthropic OAuth + GitHub remote(`getGithubRepo()`)
|
||||
- **探索命令**:`FEATURE_TEAMMEM=1 bun run dev`
|
||||
|
||||
**探索步骤**:
|
||||
1. 确认项目有 GitHub remote
|
||||
2. 开启后观察 `memory/team/` 目录创建
|
||||
3. 测试团队记忆写入和同步
|
||||
4. 验证密钥扫描防护
|
||||
|
||||
---
|
||||
|
||||
### 2.5 COORDINATOR_MODE(多 Agent 编排)
|
||||
|
||||
- **引用数**:32
|
||||
- **功能**:CLI 变为编排者,通过 AgentTool 派发任务给多个 worker 并行执行
|
||||
- **当前状态**:核心逻辑实现,worker agent 模块为 stub
|
||||
- **关键文件**:`src/coordinator/coordinatorMode.ts`(系统 prompt 完整)、`src/coordinator/workerAgent.ts`(stub)
|
||||
- **限制**:编排者只能使用 AgentTool/TaskStop/SendMessage,不能直接操作文件
|
||||
- **探索命令**:`FEATURE_COORDINATOR_MODE=1 CLAUDE_CODE_COORDINATOR_MODE=1 bun run dev`
|
||||
|
||||
**探索步骤**:
|
||||
1. 补全 `workerAgent.ts` stub
|
||||
2. 测试多 worker 并行任务派发
|
||||
3. 验证 worker 结果汇总
|
||||
|
||||
---
|
||||
|
||||
### 2.6 BRIDGE_MODE(远程控制)
|
||||
|
||||
- **引用数**:28
|
||||
- **功能**:本地 CLI 注册为 bridge 环境,可从 claude.ai 或其他控制面远程驱动
|
||||
- **当前状态**:v1(env-based)和 v2(env-less)实现均存在
|
||||
- **关键文件**:`src/bridge/bridgeEnabled.ts`、`src/bridge/replBridge.ts`(v1)、`src/bridge/remoteBridgeCore.ts`(v2)
|
||||
- **外部依赖**:claude.ai OAuth、GrowthBook 门控 `tengu_ccr_bridge`
|
||||
- **探索命令**:`FEATURE_BRIDGE_MODE=1 bun run dev`
|
||||
|
||||
---
|
||||
|
||||
### 2.7 FORK_SUBAGENT(上下文继承子 Agent)
|
||||
|
||||
- **引用数**:4
|
||||
- **功能**:AgentTool 生成 fork 子 agent,继承父级完整对话上下文,优化 prompt cache
|
||||
- **当前状态**:**完整实现**(`forkSubagent.ts`),支持 worktree 隔离通知、递归防护
|
||||
- **关键文件**:`src/tools/AgentTool/forkSubagent.ts`
|
||||
- **探索命令**:`FEATURE_FORK_SUBAGENT=1 bun run dev`
|
||||
|
||||
---
|
||||
|
||||
### 2.8 TOKEN_BUDGET(Token 预算控制)
|
||||
|
||||
- **引用数**:9
|
||||
- **功能**:解析用户指定的 token 预算(如 "spend 2M tokens"),自动持续工作直到达到目标
|
||||
- **当前状态**:解析器**完整实现**,支持简写和详细语法;QueryEngine 中的周转逻辑已连接
|
||||
- **关键文件**:`src/utils/tokenBudget.ts`、`src/QueryEngine.ts`
|
||||
- **探索命令**:`FEATURE_TOKEN_BUDGET=1 bun run dev`
|
||||
|
||||
---
|
||||
|
||||
### 2.9 MCP_SKILLS(MCP 技能发现)
|
||||
|
||||
- **引用数**:9
|
||||
- **功能**:将 MCP 服务器提供的 prompt 类型命令筛选为可调用技能
|
||||
- **当前状态**:**功能性实现**(config 门控筛选器)
|
||||
- **关键文件**:`src/commands.ts`(`getMcpSkillCommands()`)
|
||||
- **探索命令**:`FEATURE_MCP_SKILLS=1 bun run dev`
|
||||
|
||||
---
|
||||
|
||||
### 2.10 TREE_SITTER_BASH(Bash AST 解析)
|
||||
|
||||
- **引用数**:3
|
||||
- **功能**:纯 TypeScript bash 命令 AST 解析器,用于 fail-closed 权限匹配
|
||||
- **当前状态**:**完整实现**(`bashParser.ts` ~2000行 + `ast.ts` ~400行)
|
||||
- **关键文件**:`src/utils/vendor/tree-sitter-bash/`
|
||||
- **探索命令**:`FEATURE_TREE_SITTER_BASH=1 bun run dev`
|
||||
|
||||
---
|
||||
|
||||
### ~~2.11 BUDDY(虚拟伙伴)~~ ✅ 已完成
|
||||
|
||||
- **引用数**:16
|
||||
- **功能**:`/buddy` 命令,支持 hatch/rehatch/pet/mute/unmute
|
||||
- **状态**:✅ 已合入,功能完整可用(2026-04-02 完成)
|
||||
|
||||
---
|
||||
|
||||
## 三、Tier 2 — 部分实现(需要补全)
|
||||
|
||||
### 3.1 PROACTIVE(主动模式)
|
||||
|
||||
- **引用数**:37
|
||||
- **功能**:Tick 驱动的自主代理,定时唤醒执行工作,配合 SleepTool 控制节奏
|
||||
- **当前状态**:核心模块 `src/proactive/index.ts` **全部 stub**(activate/deactivate/pause 返回 false 或空操作)
|
||||
- **依赖**:与 KAIROS 强绑定(所有检查都是 `feature('PROACTIVE') || feature('KAIROS')`)
|
||||
- **补全工作量**:中等 — 需要实现 tick 生成、SleepTool 集成、暂停/恢复逻辑
|
||||
|
||||
### 3.2 BASH_CLASSIFIER(Bash 命令分类器)
|
||||
|
||||
- **引用数**:45
|
||||
- **功能**:LLM 驱动的 bash 命令意图分类(允许/拒绝/询问)
|
||||
- **当前状态**:`bashClassifier.ts` **全部 stub**(`matches: false`)
|
||||
- **补全工作量**:大 — 需要 LLM 调用实现、prompt 设计
|
||||
|
||||
### 3.3 ULTRAPLAN(增强规划)
|
||||
|
||||
- **引用数**:10
|
||||
- **功能**:关键字触发增强计划模式,输入 "ultraplan" 自动转为 plan
|
||||
- **当前状态**:关键字检测**完整实现**,`/ultraplan` 命令**为 stub**
|
||||
- **补全工作量**:小 — 只需实现命令处理逻辑
|
||||
|
||||
### 3.4 EXPERIMENTAL_SKILL_SEARCH(技能语义搜索)
|
||||
|
||||
- **引用数**:21
|
||||
- **功能**:DiscoverSkills 工具,根据当前任务语义搜索可用技能
|
||||
- **当前状态**:布线完整,核心搜索逻辑 stub
|
||||
- **补全工作量**:中等 — 需要实现搜索引擎和索引
|
||||
|
||||
### 3.5 CONTEXT_COLLAPSE(上下文折叠)
|
||||
|
||||
- **引用数**:20
|
||||
- **功能**:CtxInspectTool 让模型内省上下文窗口大小,优化压缩决策
|
||||
- **当前状态**:工具 stub,HISTORY_SNIP 子功能也 stub
|
||||
- **补全工作量**:中等
|
||||
|
||||
### 3.6 WORKFLOW_SCRIPTS(工作流自动化)
|
||||
|
||||
- **引用数**:10
|
||||
- **功能**:基于文件的自动化工作流 + `/workflows` 命令
|
||||
- **当前状态**:WorkflowTool、命令、加载器全部 stub
|
||||
- **补全工作量**:大 — 需要从零设计工作流 DSL
|
||||
|
||||
### 3.7 WEB_BROWSER_TOOL(浏览器工具)
|
||||
|
||||
- **引用数**:4
|
||||
- **功能**:模型可调用浏览器工具导航和交互网页
|
||||
- **当前状态**:工具注册存在,实现 stub
|
||||
- **补全工作量**:大
|
||||
|
||||
### 3.8 DAEMON(后台守护进程)
|
||||
|
||||
- **引用数**:3
|
||||
- **功能**:后台守护进程 + 远程控制服务器
|
||||
- **当前状态**:只有条件导入布线,无实现
|
||||
- **补全工作量**:极大
|
||||
|
||||
---
|
||||
|
||||
## 四、Tier 3 — 纯 Stub / N/A(低优先级)
|
||||
|
||||
| Feature | 引用 | 状态 | 说明 |
|
||||
|---------|------|------|------|
|
||||
| CHICAGO_MCP | 16 | N/A | Anthropic 内部 MCP 基础设施 |
|
||||
| UDS_INBOX | 17 | Stub | Unix 域套接字对等消息 |
|
||||
| MONITOR_TOOL | 13 | Stub | 文件/进程监控工具 |
|
||||
| BG_SESSIONS | 11 | Stub | 后台会话管理 |
|
||||
| SHOT_STATS | 10 | 无实现 | 逐 prompt 统计 |
|
||||
| EXTRACT_MEMORIES | 7 | 无实现 | 自动记忆提取 |
|
||||
| TEMPLATES | 6 | Stub | 项目/提示模板 |
|
||||
| LODESTONE | 6 | N/A | 内部基础设施 |
|
||||
| STREAMLINED_OUTPUT | 1 | — | 精简输出模式 |
|
||||
| HOOK_PROMPTS | 1 | — | Hook 提示词 |
|
||||
| CCR_AUTO_CONNECT | 3 | — | CCR 自动连接 |
|
||||
| CCR_MIRROR | 4 | — | CCR 镜像模式 |
|
||||
| CCR_REMOTE_SETUP | 1 | — | CCR 远程设置 |
|
||||
| NATIVE_CLIPBOARD_IMAGE | 2 | — | 原生剪贴板图片 |
|
||||
| CONNECTOR_TEXT | 7 | — | 连接器文本 |
|
||||
|
||||
以及其余 40+ 个低引用量 feature。
|
||||
|
||||
---
|
||||
|
||||
## 五、探索路线图
|
||||
|
||||
### Phase 1:快速验证(无外部依赖)
|
||||
|
||||
> 目标:确认代码可以正常运行,体验基本功能
|
||||
|
||||
| 优先级 | Feature | 命令 | 预期效果 |
|
||||
|--------|---------|------|----------|
|
||||
| 1 | BUDDY | `FEATURE_BUDDY=1 bun run dev` | `/buddy hatch` 生成伙伴 |
|
||||
| 2 | FORK_SUBAGENT | `FEATURE_FORK_SUBAGENT=1 bun run dev` | Agent 可生成上下文继承的子任务 |
|
||||
| 3 | TOKEN_BUDGET | `FEATURE_TOKEN_BUDGET=1 bun run dev` | 输入 "spend 500k tokens" 测试自动持续 |
|
||||
| 4 | TREE_SITTER_BASH | `FEATURE_TREE_SITTER_BASH=1 bun run dev` | 更精确的 bash 权限匹配 |
|
||||
| 5 | MCP_SKILLS | `FEATURE_MCP_SKILLS=1 bun run dev` | MCP 服务器 prompt 提升为技能 |
|
||||
|
||||
### Phase 2:核心功能探索(需要 OAuth)
|
||||
|
||||
> 目标:体验 KAIROS 全套能力
|
||||
|
||||
| 优先级 | Feature | 命令 | 预期效果 |
|
||||
|--------|---------|------|----------|
|
||||
| 1 | TRANSCRIPT_CLASSIFIER | `FEATURE_TRANSCRIPT_CLASSIFIER=1 bun run dev` | Auto mode 自动激活 |
|
||||
| 2 | KAIROS 全套 | `FEATURE_KAIROS=1 FEATURE_KAIROS_BRIEF=1 FEATURE_KAIROS_CHANNELS=1 FEATURE_PROACTIVE=1 bun run dev` | 常驻助手 + Brief 输出 + 频道消息 |
|
||||
| 3 | VOICE_MODE | `FEATURE_VOICE_MODE=1 bun run dev` | 按空格说话 |
|
||||
| 4 | TEAMMEM | `FEATURE_TEAMMEM=1 bun run dev` | 团队记忆同步 |
|
||||
| 5 | COORDINATOR_MODE | `FEATURE_COORDINATOR_MODE=1 CLAUDE_CODE_COORDINATOR_MODE=1 bun run dev` | 多 agent 编排 |
|
||||
|
||||
### Phase 3:Stub 补全开发
|
||||
|
||||
> 目标:将高价值 stub 实现为可用功能
|
||||
|
||||
| 优先级 | Feature | 补全难度 | 价值 |
|
||||
|--------|---------|----------|------|
|
||||
| 1 | PROACTIVE | 中 | 自主工作能力 |
|
||||
| 2 | ULTRAPLAN | 小 | 增强规划 |
|
||||
| 3 | CONTEXT_COLLAPSE | 中 | 长对话优化 |
|
||||
| 4 | EXPERIMENTAL_SKILL_SEARCH | 中 | 技能发现 |
|
||||
| 5 | BASH_CLASSIFIER | 大 | 安全增强 |
|
||||
|
||||
---
|
||||
|
||||
## 六、推荐组合方案
|
||||
|
||||
### "全功能助手"组合
|
||||
|
||||
```bash
|
||||
FEATURE_KAIROS=1 \
|
||||
FEATURE_KAIROS_BRIEF=1 \
|
||||
FEATURE_KAIROS_CHANNELS=1 \
|
||||
FEATURE_KAIROS_PUSH_NOTIFICATION=1 \
|
||||
FEATURE_PROACTIVE=1 \
|
||||
FEATURE_FORK_SUBAGENT=1 \
|
||||
FEATURE_TOKEN_BUDGET=1 \
|
||||
FEATURE_TRANSCRIPT_CLASSIFIER=1 \
|
||||
FEATURE_BUDDY=1 \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### "多 Agent 协作"组合
|
||||
|
||||
```bash
|
||||
FEATURE_COORDINATOR_MODE=1 \
|
||||
FEATURE_FORK_SUBAGENT=1 \
|
||||
FEATURE_BRIDGE_MODE=1 \
|
||||
FEATURE_BG_SESSIONS=1 \
|
||||
CLAUDE_CODE_COORDINATOR_MODE=1 \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### "开发者增强"组合
|
||||
|
||||
```bash
|
||||
FEATURE_TRANSCRIPT_CLASSIFIER=1 \
|
||||
FEATURE_TREE_SITTER_BASH=1 \
|
||||
FEATURE_TOKEN_BUDGET=1 \
|
||||
FEATURE_MCP_SKILLS=1 \
|
||||
FEATURE_CONTEXT_COLLAPSE=1 \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、风险与注意事项
|
||||
|
||||
1. **OAuth 依赖**:KAIROS、VOICE_MODE、TEAMMEM、BRIDGE_MODE 需要 Anthropic OAuth 认证(claude.ai 订阅),API key 用户无法使用
|
||||
2. **GrowthBook 门控**:部分功能(VOICE_MODE 的 `tengu_cobalt_frost`、TEAMMEM 的 `tengu_herring_clock`)即使 feature flag 开启,还需要服务端 GrowthBook 开关
|
||||
3. **反编译不完整**:所有"已实现"功能均为反编译产物,可能存在运行时错误,需要逐个验证
|
||||
4. **Proactive stub**:KAIROS 的自主工作能力依赖 PROACTIVE,但 PROACTIVE 核心是 stub,需先补全
|
||||
5. **tsc 错误**:代码库有 ~1341 个 TypeScript 编译错误(来自反编译),不影响 Bun 运行时但在 IDE 中会有大量红线
|
||||
|
||||
---
|
||||
|
||||
## 附录:Feature Flag 完整列表
|
||||
|
||||
共 89 个 feature flag(按引用数降序):
|
||||
|
||||
| Feature | 引用 | Tier |
|
||||
|---------|------|------|
|
||||
| KAIROS | 154 | 1 |
|
||||
| TRANSCRIPT_CLASSIFIER | 108 | 1 |
|
||||
| TEAMMEM | 51 | 1 |
|
||||
| VOICE_MODE | 46 | 1 |
|
||||
| BASH_CLASSIFIER | 45 | 2 |
|
||||
| KAIROS_BRIEF | 39 | 1 |
|
||||
| PROACTIVE | 37 | 2 |
|
||||
| COORDINATOR_MODE | 32 | 1 |
|
||||
| BRIDGE_MODE | 28 | 1 |
|
||||
| EXPERIMENTAL_SKILL_SEARCH | 21 | 2 |
|
||||
| CONTEXT_COLLAPSE | 20 | 2 |
|
||||
| KAIROS_CHANNELS | 19 | 1 |
|
||||
| UDS_INBOX | 17 | 3 |
|
||||
| CHICAGO_MCP | 16 | 3 |
|
||||
| BUDDY | 16 | 1 |
|
||||
| HISTORY_SNIP | 15 | 2 |
|
||||
| MONITOR_TOOL | 13 | 3 |
|
||||
| COMMIT_ATTRIBUTION | 12 | — |
|
||||
| CACHED_MICROCOMPACT | 12 | — |
|
||||
| BG_SESSIONS | 11 | 3 |
|
||||
| WORKFLOW_SCRIPTS | 10 | 2 |
|
||||
| ULTRAPLAN | 10 | 2 |
|
||||
| SHOT_STATS | 10 | 3 |
|
||||
| TOKEN_BUDGET | 9 | 1 |
|
||||
| PROMPT_CACHE_BREAK_DETECTION | 9 | — |
|
||||
| MCP_SKILLS | 9 | 1 |
|
||||
| EXTRACT_MEMORIES | 7 | 3 |
|
||||
| CONNECTOR_TEXT | 7 | — |
|
||||
| TEMPLATES | 6 | 3 |
|
||||
| LODESTONE | 6 | 3 |
|
||||
| TREE_SITTER_BASH_SHADOW | 5 | — |
|
||||
| QUICK_SEARCH | 5 | — |
|
||||
| MESSAGE_ACTIONS | 5 | — |
|
||||
| DOWNLOAD_USER_SETTINGS | 5 | — |
|
||||
| DIRECT_CONNECT | 5 | — |
|
||||
| WEB_BROWSER_TOOL | 4 | 2 |
|
||||
| VERIFICATION_AGENT | 4 | — |
|
||||
| TERMINAL_PANEL | 4 | — |
|
||||
| SSH_REMOTE | 4 | — |
|
||||
| REVIEW_ARTIFACT | 4 | — |
|
||||
| REACTIVE_COMPACT | 4 | — |
|
||||
| KAIROS_PUSH_NOTIFICATION | 4 | 1 |
|
||||
| HISTORY_PICKER | 4 | — |
|
||||
| FORK_SUBAGENT | 4 | 1 |
|
||||
| CCR_MIRROR | 4 | — |
|
||||
| TREE_SITTER_BASH | 3 | 1 |
|
||||
| MEMORY_SHAPE_TELEMETRY | 3 | — |
|
||||
| MCP_RICH_OUTPUT | 3 | — |
|
||||
| KAIROS_GITHUB_WEBHOOKS | 3 | 1 |
|
||||
| FILE_PERSISTENCE | 3 | — |
|
||||
| DAEMON | 3 | 2 |
|
||||
| CCR_AUTO_CONNECT | 3 | — |
|
||||
| UPLOAD_USER_SETTINGS | 2 | — |
|
||||
| POWERSHELL_AUTO_MODE | 2 | — |
|
||||
| OVERFLOW_TEST_TOOL | 2 | — |
|
||||
| NEW_INIT | 2 | — |
|
||||
| NATIVE_CLIPBOARD_IMAGE | 2 | — |
|
||||
| HARD_FAIL | 2 | — |
|
||||
| ENHANCED_TELEMETRY_BETA | 2 | — |
|
||||
| COWORKER_TYPE_TELEMETRY | 2 | — |
|
||||
| BREAK_CACHE_COMMAND | 2 | — |
|
||||
| AWAY_SUMMARY | 2 | — |
|
||||
| AUTO_THEME | 2 | — |
|
||||
| ALLOW_TEST_VERSIONS | 2 | — |
|
||||
| AGENT_TRIGGERS_REMOTE | 2 | — |
|
||||
| AGENT_MEMORY_SNAPSHOT | 2 | — |
|
||||
| UNATTENDED_RETRY | 1 | — |
|
||||
| ULTRATHINK | 1 | — |
|
||||
| TORCH | 1 | — |
|
||||
| STREAMLINED_OUTPUT | 1 | — |
|
||||
| SLOW_OPERATION_LOGGING | 1 | — |
|
||||
| SKILL_IMPROVEMENT | 1 | — |
|
||||
| SELF_HOSTED_RUNNER | 1 | — |
|
||||
| RUN_SKILL_GENERATOR | 1 | — |
|
||||
| PERFETTO_TRACING | 1 | — |
|
||||
| NATIVE_CLIENT_ATTESTATION | 1 | — |
|
||||
| KAIROS_DREAM | 1 | 1 |
|
||||
| IS_LIBC_MUSL | 1 | — |
|
||||
| IS_LIBC_GLIBC | 1 | — |
|
||||
| HOOK_PROMPTS | 1 | — |
|
||||
| DUMP_SYSTEM_PROMPT | 1 | — |
|
||||
| COMPACTION_REMINDERS | 1 | — |
|
||||
| CCR_REMOTE_SETUP | 1 | — |
|
||||
| BYOC_ENVIRONMENT_RUNNER | 1 | — |
|
||||
| BUILTIN_EXPLORE_PLAN_AGENTS | 1 | — |
|
||||
| BUILDING_CLAUDE_APPS | 1 | — |
|
||||
| ANTI_DISTILLATION_CC | 1 | — |
|
||||
| AGENT_TRIGGERS | 1 | — |
|
||||
| ABLATION_BASELINE | 1 | — |
|
||||
File diff suppressed because it is too large
Load Diff
562
docs/features/all-features-guide.md
Normal file
562
docs/features/all-features-guide.md
Normal file
@@ -0,0 +1,562 @@
|
||||
# Claude Code Best (CCB) — 全功能使用指南
|
||||
|
||||
本文档覆盖我们通过 13 个 PR 为 CCB 恢复/新增的**全部功能**,按类别组织,每个功能包含说明、使用方法和示例。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [Buddy 伴侣系统](#1-buddy-伴侣系统)
|
||||
2. [Remote Control 远程控制](#2-remote-control-远程控制)
|
||||
3. [定时任务 /schedule](#3-定时任务-schedule)
|
||||
4. [Voice Mode 语音模式](#4-voice-mode-语音模式)
|
||||
5. [Chrome 浏览器控制](#5-chrome-浏览器控制)
|
||||
6. [Computer Use 屏幕操控](#6-computer-use-屏幕操控)
|
||||
7. [Feature Flags 与 GrowthBook](#7-feature-flags-与-growthbook)
|
||||
8. [/ultraplan 高级规划](#8-ultraplan-高级规划)
|
||||
9. [Daemon 后台守护](#9-daemon-后台守护)
|
||||
10. [Pipe IPC 多实例协作](#10-pipe-ipc-多实例协作)
|
||||
11. [LAN Pipes 局域网群控](#11-lan-pipes-局域网群控)
|
||||
12. [Monitor 后台监控](#12-monitor-后台监控)
|
||||
13. [Workflow 工作流脚本](#13-workflow-工作流脚本)
|
||||
14. [Coordinator 多Worker协调](#14-coordinator-多worker协调)
|
||||
15. [Proactive 自主模式](#15-proactive-自主模式)
|
||||
16. [History / Snip 历史管理](#16-history--snip-历史管理)
|
||||
17. [Fork 子Agent](#17-fork-子agent)
|
||||
18. [其他恢复的工具](#18-其他恢复的工具)
|
||||
|
||||
---
|
||||
|
||||
## 1. Buddy 伴侣系统
|
||||
|
||||
**PR**: #82 `refactor(buddy): align companion system with official CLI`
|
||||
**Feature Flag**: `BUDDY`
|
||||
|
||||
### 说明
|
||||
Buddy 是一个后台运行的伴侣 AI,在你主对话进行的同时,异步观察会话内容并提供建议。
|
||||
|
||||
### 使用
|
||||
```bash
|
||||
# 启动时自动加载(feature 默认开启)
|
||||
bun run dev
|
||||
|
||||
# 在对话中,Buddy 会在适当时机自动提供建议
|
||||
# 例如当你在调试时,Buddy 可能提示你检查日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Remote Control 远程控制
|
||||
|
||||
**PR**: #60 `feat: enable Remote Control (BRIDGE_MODE)` + #170 `feat: restore daemon supervisor`
|
||||
**Feature Flag**: `BRIDGE_MODE`
|
||||
|
||||
### 说明
|
||||
通过 WebSocket 远程控制 Claude Code 会话。支持自托管私有部署。
|
||||
|
||||
### 使用
|
||||
```bash
|
||||
# 启动远程控制模式
|
||||
bun run dev -- remote-control
|
||||
|
||||
# 使用自托管服务器
|
||||
CLAUDE_BRIDGE_BASE_URL=https://your-server.com CLAUDE_BRIDGE_OAUTH_TOKEN=your-token bun run dev --remote-control
|
||||
|
||||
# 或通过 /remote-control 命令在会话中启动
|
||||
/remote-control
|
||||
```
|
||||
|
||||
### 命令
|
||||
- `claude remote-control` / `claude rc` — 启动远程控制客户端
|
||||
- `claude bridge` — 同上(别名)
|
||||
|
||||
---
|
||||
|
||||
## 3. 定时任务 /schedule
|
||||
|
||||
**PR**: #88 `feat: enable /schedule by adding AGENT_TRIGGERS_REMOTE`
|
||||
**Feature Flag**: `AGENT_TRIGGERS_REMOTE`
|
||||
|
||||
### 说明
|
||||
创建定时执行的远程 agent 任务,支持 cron 表达式。
|
||||
|
||||
### 使用
|
||||
```
|
||||
/schedule create "每天检查依赖更新" --cron "0 9 * * *" --prompt "检查 package.json 中的过期依赖并创建更新 PR"
|
||||
/schedule list — 列出所有定时任务
|
||||
/schedule delete <id> — 删除指定任务
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Voice Mode 语音模式
|
||||
|
||||
**PR**: #92 `feat: enable /voice mode with native audio binaries`
|
||||
**Feature Flag**: `VOICE_MODE`
|
||||
|
||||
### 说明
|
||||
Push-to-Talk 语音输入,音频通过 WebSocket 流式传输到 Anthropic STT(Nova 3)。需要 Anthropic OAuth 认证(非 API key)。
|
||||
|
||||
### 使用
|
||||
```bash
|
||||
# 确保已通过 OAuth 登录
|
||||
claude auth login
|
||||
|
||||
# 在会话中按住指定键说话
|
||||
# 松开后自动转写为文字输入
|
||||
```
|
||||
|
||||
### 前提条件
|
||||
- Anthropic OAuth 认证(不支持 API key 模式)
|
||||
- 系统麦克风权限
|
||||
|
||||
---
|
||||
|
||||
## 5. Chrome 浏览器控制
|
||||
|
||||
**PR**: #93 `feat: enable Claude in Chrome MCP with full browser control`
|
||||
**Feature Flag**: `CHICAGO_MCP`
|
||||
|
||||
### 说明
|
||||
通过 Chrome 扩展控制浏览器:导航、点击、填表、截图、执行 JS。
|
||||
|
||||
### 使用
|
||||
```bash
|
||||
# 启动带 Chrome 控制的模式
|
||||
bun run dev -- --chrome
|
||||
|
||||
# 安装 Chrome 扩展后,AI 可以:
|
||||
# - 打开网页、点击按钮
|
||||
# - 填写表单
|
||||
# - 截取页面内容
|
||||
# - 执行 JavaScript
|
||||
```
|
||||
|
||||
### AI 可用工具
|
||||
- `navigate` — 导航到 URL
|
||||
- `click` / `find` / `form_input` — 页面交互
|
||||
- `get_page_text` / `read_page` — 读取内容
|
||||
- `javascript_tool` — 执行 JS
|
||||
- `gif_creator` — 录制操作 GIF
|
||||
|
||||
---
|
||||
|
||||
## 6. Computer Use 屏幕操控
|
||||
|
||||
**PR**: #98 + #137 `feat: Computer Use — 跨平台 Executor + Python Bridge + GUI 无障碍`
|
||||
**Feature Flag**: `CHICAGO_MCP`
|
||||
|
||||
### 说明
|
||||
跨平台屏幕操控:截图、键鼠模拟、应用管理。支持 macOS + Windows,Linux 后端待完成。
|
||||
|
||||
### 使用
|
||||
```bash
|
||||
# 启动后 AI 可自动调用屏幕操控工具
|
||||
bun run dev
|
||||
|
||||
# AI 可以:
|
||||
# - 截取屏幕/窗口截图
|
||||
# - 模拟键盘输入和鼠标操作
|
||||
# - 列出运行的应用
|
||||
# - 使用剪贴板
|
||||
```
|
||||
|
||||
### 平台支持
|
||||
| 平台 | 截图 | 键鼠 | 应用管理 |
|
||||
|------|------|------|----------|
|
||||
| macOS | ✅ | ✅ | ✅ |
|
||||
| Windows | ✅ | ✅ | ✅ |
|
||||
| Linux | ⏳ | ⏳ | ⏳ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Feature Flags 与 GrowthBook
|
||||
|
||||
**PR**: #140 + #153 `feat: enable GrowthBook local gate defaults`
|
||||
**Feature Flags**: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
|
||||
|
||||
### 说明
|
||||
本地 GrowthBook gate defaults 机制,绕过远程 feature flag 服务,确保功能在无网络时也可使用。
|
||||
|
||||
### 使用
|
||||
```bash
|
||||
# 通过环境变量启用任意 feature
|
||||
FEATURE_PROACTIVE=1 bun run dev
|
||||
|
||||
# dev/build 模式有各自的默认启用列表
|
||||
# 查看 scripts/dev.ts 中的 DEFAULT_FEATURES
|
||||
```
|
||||
|
||||
### 关键 feature flags
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `SHOT_STATS` | API 调用统计 |
|
||||
| `TOKEN_BUDGET` | Token 预算控制 |
|
||||
| `PROMPT_CACHE_BREAK_DETECTION` | Prompt 缓存命中检测 |
|
||||
|
||||
---
|
||||
|
||||
## 8. /ultraplan 高级规划
|
||||
|
||||
**PR**: #156 `feat: enable /ultraplan and harden GrowthBook fallback chain`
|
||||
**Feature Flag**: `ULTRAPLAN`
|
||||
|
||||
### 说明
|
||||
高级多 agent 规划模式。将复杂任务分解为多个阶段,每阶段可分配给不同 agent 并行执行。
|
||||
|
||||
### 使用
|
||||
```
|
||||
/ultraplan 实现一个完整的用户认证系统,包括注册、登录、密码重置、OAuth 集成
|
||||
```
|
||||
|
||||
AI 会生成:
|
||||
1. 任务分解(多阶段)
|
||||
2. 每阶段的 agent 分配
|
||||
3. 依赖关系图
|
||||
4. 并行执行计划
|
||||
|
||||
---
|
||||
|
||||
## 9. Daemon 后台守护
|
||||
|
||||
**PR**: #170 `feat: restore daemon supervisor and remoteControlServer command`
|
||||
**Feature Flag**: `DAEMON`
|
||||
|
||||
### 说明
|
||||
Daemon 模式允许 Claude Code 作为后台长驻进程运行,管理多个 worker。
|
||||
|
||||
### 使用
|
||||
```bash
|
||||
# 启动 daemon
|
||||
claude daemon start
|
||||
|
||||
# 查看状态
|
||||
claude daemon status
|
||||
|
||||
# 停止
|
||||
claude daemon stop
|
||||
|
||||
# 启动远程控制服务器
|
||||
bun run rcs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Pipe IPC 多实例协作
|
||||
|
||||
**PR**: #241 `feat: restore pipe IPC, LAN pipes, monitor tool`
|
||||
**Feature Flag**: `UDS_INBOX`
|
||||
|
||||
### 说明
|
||||
同一台机器上的多个 Claude Code 实例通过 UDS(Unix Domain Socket / Windows Named Pipe)自动发现并协作。首个启动的实例成为 main,后续自动注册为 sub。
|
||||
|
||||
### 使用
|
||||
|
||||
**启动多实例**:
|
||||
```bash
|
||||
# 终端 1
|
||||
bun run dev
|
||||
# → 自动成为 main
|
||||
|
||||
# 终端 2
|
||||
bun run dev
|
||||
# → 自动成为 sub-1,被 main attach
|
||||
```
|
||||
|
||||
**管理实例**:
|
||||
```
|
||||
/pipes — 显示所有实例,Shift+↓ 展开选择面板
|
||||
/pipes select <name> — 选中实例
|
||||
/pipes all — 全选
|
||||
/pipes none — 取消全选
|
||||
/attach <name> — 手动 attach 某实例
|
||||
/detach <name> — 断开连接
|
||||
/send <name> <msg> — 向指定实例发送消息
|
||||
/claim-main — 强制声明为 main
|
||||
/pipe-status — 显示详细状态
|
||||
/peers — 列出所有已发现的 peer
|
||||
```
|
||||
|
||||
**选择面板操作**:
|
||||
1. 按 `Shift+↓` 展开面板
|
||||
2. `↑/↓` 移动光标
|
||||
3. `Space` 选中/取消 pipe
|
||||
4. `Enter` 确认关闭
|
||||
5. `←/→` 切换路由模式(selected pipes ↔ local main)
|
||||
|
||||
**消息广播**:
|
||||
选中 pipe 后,输入的消息自动路由到所有选中的 slave 执行,结果流式回传到 main。
|
||||
|
||||
**权限转发**:
|
||||
slave 执行需要权限的工具时(如 BashTool),权限请求自动转发到 main 的确认队列。
|
||||
|
||||
---
|
||||
|
||||
## 11. LAN Pipes 局域网群控
|
||||
|
||||
**PR**: #241(同上)
|
||||
**Feature Flag**: `LAN_PIPES`
|
||||
|
||||
### 说明
|
||||
在 Pipe IPC 基础上增加 TCP 传输层和 UDP Multicast 发现,实现跨机器零配置协作。
|
||||
|
||||
### 使用
|
||||
|
||||
**局域网多机器**:
|
||||
```bash
|
||||
# 机器 A (192.168.50.22)
|
||||
bun run dev
|
||||
|
||||
# 机器 B (192.168.50.27)
|
||||
bun run dev
|
||||
|
||||
# 两边启动后 3-5 秒自动发现和 attach
|
||||
# /pipes 显示 [LAN] 标记的远端实例
|
||||
```
|
||||
|
||||
**防火墙配置**(每台机器都需要):
|
||||
|
||||
Windows(管理员 PowerShell):
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "CCB LAN Beacon (UDP)" -Direction Inbound -Protocol UDP -LocalPort 7101 -Action Allow -Profile Private
|
||||
New-NetFirewallRule -DisplayName "CCB LAN Pipes (TCP)" -Direction Inbound -Protocol TCP -LocalPort 1024-65535 -Program (Get-Command bun).Source -Action Allow -Profile Private
|
||||
New-NetFirewallRule -DisplayName "CCB LAN Beacon Out (UDP)" -Direction Outbound -Protocol UDP -RemotePort 7101 -Action Allow -Profile Private
|
||||
```
|
||||
|
||||
macOS:
|
||||
```bash
|
||||
# 首次运行时系统弹对话框,点"允许"即可
|
||||
```
|
||||
|
||||
Linux:
|
||||
```bash
|
||||
sudo firewall-cmd --zone=trusted --add-port=7101/udp --permanent
|
||||
sudo firewall-cmd --zone=trusted --add-port=1024-65535/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
**通知显示格式**:
|
||||
```
|
||||
# 本机 sub
|
||||
Routed to [sub-1]; main can continue other tasks
|
||||
|
||||
# LAN peer
|
||||
Routed to [main] vmwin11/192.168.50.27; main can continue other tasks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Monitor 后台监控
|
||||
|
||||
**PR**: #241(同上)
|
||||
**Feature Flag**: `MONITOR_TOOL`
|
||||
|
||||
### 说明
|
||||
在后台运行 shell 命令持续监控输出(类似 `watch` 命令)。AI 也可自主调用 MonitorTool。
|
||||
|
||||
### 使用
|
||||
|
||||
**用户命令**:
|
||||
```
|
||||
/monitor tail -f /var/log/syslog
|
||||
/monitor watch -n 5 docker ps
|
||||
/monitor "while true; do curl -s localhost:3000/health; sleep 10; done"
|
||||
```
|
||||
|
||||
**查看监控**:
|
||||
- 按 `Shift+Down` 展开后台任务面板
|
||||
- 查看监控输出和状态
|
||||
|
||||
**Windows 兼容**:
|
||||
`watch -n <sec> <cmd>` 自动转为 PowerShell 循环:
|
||||
```powershell
|
||||
while($true){ <cmd>; Start-Sleep -Seconds <sec> }
|
||||
```
|
||||
|
||||
**AI 调用**:
|
||||
AI 可在对话中自动调用 `MonitorTool` 监控日志、构建输出等。
|
||||
|
||||
---
|
||||
|
||||
## 13. Workflow 工作流脚本
|
||||
|
||||
**PR**: #241(同上)
|
||||
**Feature Flag**: `WORKFLOW_SCRIPTS`
|
||||
|
||||
### 说明
|
||||
执行 `.claude/workflows/` 目录下的用户定义工作流脚本。
|
||||
|
||||
### 使用
|
||||
|
||||
**创建工作流**:
|
||||
```bash
|
||||
mkdir -p .claude/workflows
|
||||
cat > .claude/workflows/deploy.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
echo "Running tests..."
|
||||
bun test
|
||||
echo "Building..."
|
||||
bun run build
|
||||
echo "Deploying..."
|
||||
EOF
|
||||
chmod +x .claude/workflows/deploy.sh
|
||||
```
|
||||
|
||||
**列出可用工作流**:
|
||||
```
|
||||
/workflows
|
||||
```
|
||||
|
||||
**AI 调用**:
|
||||
AI 可通过 `WorkflowTool` 自动执行工作流:
|
||||
```
|
||||
请执行 deploy 工作流
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Coordinator 多Worker协调
|
||||
|
||||
**PR**: #241(同上)
|
||||
**Feature Flag**: `COORDINATOR_MODE`
|
||||
|
||||
### 说明
|
||||
启用 coordinator 模式后,AI 可自动将任务分配给多个 worker 并行执行。
|
||||
|
||||
### 使用
|
||||
```
|
||||
/coordinator — 切换 coordinator 模式开/关
|
||||
```
|
||||
|
||||
启用后,AI 在处理复杂任务时会:
|
||||
1. 分析任务可并行的部分
|
||||
2. 自动创建 worker 分支
|
||||
3. 分配子任务
|
||||
4. 汇总结果
|
||||
|
||||
---
|
||||
|
||||
## 15. Proactive 自主模式
|
||||
|
||||
**PR**: #241(同上)
|
||||
**Feature Flag**: `PROACTIVE` / `KAIROS`
|
||||
|
||||
### 说明
|
||||
启用后 AI 会主动发起操作(而不仅回应用户输入),例如自动检测文件变更、主动提出优化建议。
|
||||
|
||||
### 使用
|
||||
```
|
||||
/proactive — 切换 proactive 模式开/关
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. History / Snip 历史管理
|
||||
|
||||
**PR**: #241(同上)
|
||||
**Feature Flag**: `HISTORY_SNIP`
|
||||
|
||||
### 说明
|
||||
查看和管理对话历史,支持手动截断以释放上下文窗口空间。
|
||||
|
||||
### 使用
|
||||
```
|
||||
/history — 显示对话历史摘要
|
||||
/force-snip — 强制在当前位置截断历史
|
||||
```
|
||||
|
||||
AI 也可通过 `SnipTool` 自动截断过长的对话:
|
||||
```
|
||||
对话太长了,请帮我截断历史
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Fork 子Agent
|
||||
|
||||
**PR**: #241(同上)
|
||||
**Feature Flag**: `FORK_SUBAGENT`
|
||||
|
||||
### 说明
|
||||
在当前对话上下文中 fork 一个独立的子 agent,继承完整会话状态独立执行。
|
||||
|
||||
### 使用
|
||||
```
|
||||
/fork — 基于当前上下文 fork 子 agent
|
||||
```
|
||||
|
||||
子 agent 会:
|
||||
- 继承当前的全部对话历史
|
||||
- 在独立的执行环境中运行
|
||||
- 不影响主会话状态
|
||||
|
||||
---
|
||||
|
||||
## 18. 其他恢复的工具
|
||||
|
||||
以下工具从 stub 恢复为完整实现:
|
||||
|
||||
| 工具 | 说明 | 使用 |
|
||||
|------|------|------|
|
||||
| `SleepTool` | 暂停执行指定时间 | AI 在轮询场景自动调用 |
|
||||
| `WebBrowserTool` | 终端内网页交互 | AI 需要查看网页时调用 |
|
||||
| `SubscribePRTool` | 订阅 GitHub PR 变更 | `/subscribe-pr` 或 AI 调用 |
|
||||
| `PushNotificationTool` | 推送桌面通知 | AI 在长任务完成时调用 |
|
||||
| `CtxInspectTool` | 检查上下文窗口使用 | AI 判断上下文剩余空间 |
|
||||
| `TerminalCaptureTool` | 截取终端屏幕 | AI 需要看终端输出时调用 |
|
||||
| `SendUserFileTool` | 向用户发送文件 | AI 导出文件时调用 |
|
||||
| `REPLTool` | 启动子 REPL 会话 | AI 需要独立交互环境时调用 |
|
||||
| `VerifyPlanExecutionTool` | 验证执行计划完成度 | AI 完成计划后自动验证 |
|
||||
| `SuggestBackgroundPRTool` | 建议创建后台 PR | AI 发现可独立的变更时提议 |
|
||||
| `ListPeersTool` | 列出已发现的 peer | AI 查询多实例状态时调用 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:全部 Feature Flags
|
||||
|
||||
| Flag | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `BUDDY` | ✅ dev/build | 伴侣系统 |
|
||||
| `BRIDGE_MODE` | ✅ dev/build | 远程控制 |
|
||||
| `VOICE_MODE` | ✅ dev/build | 语音模式 |
|
||||
| `CHICAGO_MCP` | ✅ dev/build | Computer Use + Chrome |
|
||||
| `AGENT_TRIGGERS_REMOTE` | ✅ dev/build | 定时任务 |
|
||||
| `SHOT_STATS` | ✅ dev/build | API 统计 |
|
||||
| `TOKEN_BUDGET` | ✅ dev/build | Token 预算 |
|
||||
| `PROMPT_CACHE_BREAK_DETECTION` | ✅ dev/build | 缓存检测 |
|
||||
| `ULTRAPLAN` | ✅ dev/build | 高级规划 |
|
||||
| `DAEMON` | ✅ dev/build | 后台守护 |
|
||||
| `UDS_INBOX` | ✅ dev/build | Pipe IPC |
|
||||
| `LAN_PIPES` | ✅ dev/build | LAN 群控 |
|
||||
| `MONITOR_TOOL` | ✅ dev/build | 后台监控 |
|
||||
| `WORKFLOW_SCRIPTS` | ✅ dev/build | 工作流脚本 |
|
||||
| `FORK_SUBAGENT` | ✅ dev/build | 子 Agent |
|
||||
| `KAIROS` | ✅ dev/build | Kairos 调度 |
|
||||
| `COORDINATOR_MODE` | ✅ dev/build | 多 Worker |
|
||||
| `HISTORY_SNIP` | ✅ dev/build | 历史管理 |
|
||||
| `CONTEXT_COLLAPSE` | ✅ dev/build | 上下文折叠 |
|
||||
|
||||
手动启用任意 flag:
|
||||
```bash
|
||||
FEATURE_FLAG_NAME=1 bun run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:PR 列表
|
||||
|
||||
| PR | 日期 | 标题 |
|
||||
|----|------|------|
|
||||
| #60 | 2026-04-02 | feat: enable Remote Control (BRIDGE_MODE) |
|
||||
| #82 | 2026-04-03 | refactor(buddy): align companion system |
|
||||
| #88 | 2026-04-03 | feat: enable /schedule (AGENT_TRIGGERS_REMOTE) |
|
||||
| #89 | 2026-04-03 | feat: built-in status line |
|
||||
| #92 | 2026-04-03 | feat: enable /voice mode |
|
||||
| #93 | 2026-04-03 | feat: enable Chrome MCP |
|
||||
| #98 | 2026-04-03 | feat: enable Computer Use (macOS + Windows + Linux) |
|
||||
| #137 | 2026-04-05 | feat: Computer Use v2 — 跨平台 Executor |
|
||||
| #140 | 2026-04-05 | feat: enable SHOT_STATS, TOKEN_BUDGET |
|
||||
| #153 | 2026-04-06 | feat: enable GrowthBook local gate defaults |
|
||||
| #156 | 2026-04-06 | feat: enable /ultraplan |
|
||||
| #170 | 2026-04-07 | feat: restore daemon supervisor |
|
||||
| #241 | 2026-04-11 | feat: restore pipe IPC, LAN pipes, monitor tool |
|
||||
@@ -1011,38 +1011,32 @@ src/utils/swarm/ 目录(22 个文件):
|
||||
|
||||
## 28. UDS_INBOX
|
||||
|
||||
**编译时引用次数**: 18(单引号 17 + 双引号 1)
|
||||
**功能描述**: UDS(Unix Domain Socket)收件箱。允许 Claude Code 实例之间通过 Unix 套接字发送消息。
|
||||
**分类**: PARTIAL
|
||||
**缺失原因**: `src/utils/udsMessaging.ts` 仅 1 行,`src/utils/udsClient.ts` 仅 3 行(空壳),命令入口缺失
|
||||
**编译时引用次数**: 18(历史快照)
|
||||
**功能描述**: 本机进程间通信能力。当前由两层组成:
|
||||
1. `udsMessaging` / `udsClient`:通用 UDS 消息层,供 `SendMessageTool` 与 `/peers` 使用。
|
||||
2. `pipeTransport` / `pipeRegistry`:会话级 named-pipe 协调层,供 `/pipes`、`/attach`、`/detach`、`/send`、`/pipe-status`、`/history`、`/claim-main` 使用。
|
||||
|
||||
**当前分类**: IMPLEMENTED / EXPERIMENTAL
|
||||
|
||||
**当前事实**:
|
||||
- `src/utils/udsMessaging.ts` 与 `src/utils/udsClient.ts` 已实现,不再是空壳。
|
||||
- `src/utils/pipeTransport.ts` 使用本机 named pipe / Unix socket;`localIp` / `hostname` / `machineId` 仅用于注册表展示与身份判定,不是已上线的局域网传输层。
|
||||
- `src/screens/REPL.tsx` 内联承载当前有效的 pipe 控制平面;早期 hook 试验路径已清理。
|
||||
|
||||
**核心实现文件**:
|
||||
|
||||
| 文件路径 | 行数 | 功能说明 |
|
||||
|----------|------|----------|
|
||||
| src/tools/SendMessageTool/SendMessageTool.ts | 917 行 | 发送消息工具(完整实现) |
|
||||
| src/tools/SendMessageTool/prompt.ts | 49 行 | 消息工具提示词 |
|
||||
| src/utils/udsClient.ts | 3 行 | UDS 客户端(桩) |
|
||||
| src/utils/udsMessaging.ts | 1 行 | UDS 消息(桩) |
|
||||
| 文件路径 | 功能说明 |
|
||||
|----------|----------|
|
||||
| src/utils/udsMessaging.ts | 通用 UDS server / inbox |
|
||||
| src/utils/udsClient.ts | 通用 peer 发现、探活、发送 |
|
||||
| src/utils/pipeTransport.ts | named-pipe server/client、探活、AppState 扩展 |
|
||||
| src/utils/pipeRegistry.ts | main/sub 注册表、machineId、claim-main |
|
||||
| src/commands/peers/peers.ts | UDS peer 可达性检查 |
|
||||
| src/commands/pipes/pipes.ts | pipe 注册表检查与选择器入口 |
|
||||
| src/commands/attach/attach.ts | master -> slave attach |
|
||||
| src/screens/REPL.tsx | 当前生效的 REPL pipe bootstrap 与心跳 |
|
||||
|
||||
**引用该标志的文件(10 个)**:
|
||||
1. src/cli/print.ts — CLI 输出
|
||||
2. src/commands.ts — 命令注册(引用 `commands/peers/index.js`)
|
||||
3. src/components/messages/UserTextMessage.tsx — 用户消息
|
||||
4. src/main.tsx — 主入口
|
||||
5. src/setup.ts — 初始化
|
||||
6. src/tools.ts — 工具注册
|
||||
7. src/tools/SendMessageTool/SendMessageTool.ts — 发送消息工具
|
||||
8. src/tools/SendMessageTool/prompt.ts — 提示词
|
||||
9. src/utils/concurrentSessions.ts — 并发会话
|
||||
10. src/utils/messages/systemInit.ts — 系统初始化消息
|
||||
|
||||
**缺失文件**:
|
||||
- src/commands/peers/index.ts — 命令入口缺失
|
||||
- src/utils/udsMessaging.ts — 仅 1 行空壳
|
||||
- src/utils/udsClient.ts — 仅 3 行空壳
|
||||
|
||||
**启用所需修复**: 需要实现 UDS 客户端和消息模块,并创建命令入口。
|
||||
**备注**: 如需真实局域网通信,需要单独引入 TCP/WebSocket 传输、认证与发现机制;现有代码尚未实现该层。详见 `docs/features/uds-inbox.md`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
321
docs/features/lan-pipes-implementation.md
Normal file
321
docs/features/lan-pipes-implementation.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# LAN Pipes — 技术实现文档
|
||||
|
||||
面向开发者的实现细节。用户指南见 [lan-pipes.md](./lan-pipes.md)。
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Machine A (192.168.50.22) Machine B (192.168.50.27)
|
||||
┌───────────────────────────┐ ┌───────────────────────────┐
|
||||
│ PipeServer │ │ PipeServer │
|
||||
│ UDS: ~/.claude/pipes/ │ │ UDS: ~/.claude/pipes/ │
|
||||
│ cli-abc.sock │ │ cli-def.sock │
|
||||
│ TCP: 0.0.0.0:<random> │◄──TCP───►│ TCP: 0.0.0.0:<random> │
|
||||
├───────────────────────────┤ ├───────────────────────────┤
|
||||
│ LanBeacon │ │ LanBeacon │
|
||||
│ UDP 224.0.71.67:7101 │◄──UDP───►│ UDP 224.0.71.67:7101 │
|
||||
├───────────────────────────┤ ├───────────────────────────┤
|
||||
│ usePipeIpc (hook) │ │ usePipeIpc (hook) │
|
||||
│ initPipeServer │ │ initPipeServer │
|
||||
│ registerMessageHandlers │ │ registerMessageHandlers │
|
||||
│ runMainHeartbeat │ │ runSubHeartbeat │
|
||||
│ cleanupPipeIpc │ │ cleanupPipeIpc │
|
||||
└───────────────────────────┘ └───────────────────────────┘
|
||||
```
|
||||
|
||||
## Feature Flag
|
||||
|
||||
`LAN_PIPES` — 在 `scripts/dev.ts` 和 `build.ts` 的 `DEFAULT_FEATURES` 中启用。
|
||||
|
||||
所有 LAN 代码路径通过 `feature('LAN_PIPES')` 编译时门控。`feature()` 只能在 `if` 或三元中使用(Bun 编译时常量约束)。
|
||||
|
||||
---
|
||||
|
||||
## 核心文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/utils/pipeTransport.ts` | PipeServer/PipeClient(UDS + TCP 双模式) |
|
||||
| `src/utils/lanBeacon.ts` | UDP multicast beacon + module singleton |
|
||||
| `src/utils/ndjsonFramer.ts` | 共享 NDJSON socket 帧解析 |
|
||||
| `src/utils/pipeRegistry.ts` | 文件注册表 + `mergeWithLanPeers()` |
|
||||
| `src/utils/peerAddress.ts` | 地址解析(uds/bridge/tcp scheme) |
|
||||
| `src/utils/pipePermissionRelay.ts` | 权限转发 + `setPipeRelay`/`getPipeRelay` singleton |
|
||||
| `src/hooks/usePipeIpc.ts` | 生命周期 hook(从 REPL.tsx 提取) |
|
||||
| `src/hooks/usePipeRelay.ts` | 消息回传 hook |
|
||||
| `src/hooks/usePipePermissionForward.ts` | 权限转发 hook |
|
||||
| `src/hooks/usePipeRouter.ts` | 输入路由 hook |
|
||||
| `src/hooks/useMasterMonitor.ts` | slave 注册表 + 消息订阅 |
|
||||
|
||||
---
|
||||
|
||||
## PipeServer TCP 扩展
|
||||
|
||||
`src/utils/pipeTransport.ts`
|
||||
|
||||
### 类型
|
||||
|
||||
```typescript
|
||||
export type PipeTransportMode = 'uds' | 'tcp'
|
||||
export type TcpEndpoint = { host: string; port: number }
|
||||
export type PipeServerOptions = { enableTcp?: boolean; tcpPort?: number }
|
||||
```
|
||||
|
||||
### PipeServer 变更
|
||||
|
||||
- `setupSocket(socket)` — 从 start() 提取的共享方法,UDS 和 TCP 共用
|
||||
- `start(options?)` — 可选启用 TCP,port=0 让 OS 分配
|
||||
- 内部维护两个 `net.Server`,共享同一组 `clients: Set<Socket>` 和 `handlers`
|
||||
- `tcpAddress` getter 暴露 TCP 端口
|
||||
- `close()` 同时关闭两个 server
|
||||
|
||||
socket 帧解析使用 `attachNdjsonFramer()` from `ndjsonFramer.ts`(替代原先 3 份重复代码)。
|
||||
|
||||
### PipeClient 变更
|
||||
|
||||
- 构造函数新增可选 `TcpEndpoint` 参数
|
||||
- `connect()` 根据 tcpEndpoint 分派到 `connectTcp()` 或 `connectUds()`
|
||||
- TCP 不需要文件存在轮询,直接建连
|
||||
|
||||
---
|
||||
|
||||
## LAN Beacon
|
||||
|
||||
`src/utils/lanBeacon.ts`
|
||||
|
||||
### 协议参数
|
||||
|
||||
| 参数 | 值 |
|
||||
|------|-----|
|
||||
| Multicast 组 | `224.0.71.67` |
|
||||
| 端口 | `7101` |
|
||||
| 广播间隔 | `3000ms` |
|
||||
| Peer 超时 | `15000ms` |
|
||||
| TTL | `1` |
|
||||
|
||||
### Announce 包
|
||||
|
||||
```typescript
|
||||
type LanAnnounce = {
|
||||
proto: 'claude-pipe-v1'
|
||||
pipeName: string
|
||||
machineId: string
|
||||
hostname: string
|
||||
ip: string
|
||||
tcpPort: number
|
||||
role: 'main' | 'sub'
|
||||
ts: number
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```typescript
|
||||
class LanBeacon extends EventEmitter {
|
||||
constructor(announce: Omit<LanAnnounce, 'proto' | 'ts'>)
|
||||
start(): void
|
||||
stop(): void
|
||||
getPeers(): Map<string, LanAnnounce> // 防御性拷贝
|
||||
updateAnnounce(partial): void // 使用 spread(不可变更新)
|
||||
|
||||
on('peer-discovered', (peer: LanAnnounce) => void)
|
||||
on('peer-lost', (pipeName: string) => void)
|
||||
}
|
||||
```
|
||||
|
||||
### 存储
|
||||
|
||||
module-level singleton:`getLanBeacon()` / `setLanBeacon()`。不挂在 Zustand state 上(避免 `setState` 展开时丢失引用)。
|
||||
|
||||
### 网卡绑定
|
||||
|
||||
`addMembership(group, localIp)` + `setMulticastInterface(localIp)` 指定 LAN 网卡。解决 Windows 上 WSL/Docker 虚拟网卡劫持 multicast 的问题。
|
||||
|
||||
---
|
||||
|
||||
## Hook 架构
|
||||
|
||||
从 REPL.tsx 提取的 ~830 行 Pipe IPC 代码:
|
||||
|
||||
### usePipeIpc(生命周期)
|
||||
|
||||
`src/hooks/usePipeIpc.ts`(623 行)
|
||||
|
||||
在 REPL.tsx 顶层通过 feature-gated require 加载:
|
||||
|
||||
```typescript
|
||||
const usePipeIpc = feature('UDS_INBOX')
|
||||
? require('../hooks/usePipeIpc.js').usePipeIpc
|
||||
: () => undefined;
|
||||
|
||||
// 组件内
|
||||
usePipeIpc({ store, handleIncomingPrompt });
|
||||
```
|
||||
|
||||
内部使用 **lazy getter** 函数加载依赖(避免循环依赖导致 Bun 运行时崩溃):
|
||||
|
||||
```typescript
|
||||
const pt = () => require('../utils/pipeTransport.js')
|
||||
const pr = () => require('../utils/pipeRegistry.js')
|
||||
const mm = () => require('./useMasterMonitor.js')
|
||||
// ...
|
||||
```
|
||||
|
||||
`import type` 用于静态类型(不会触发模块加载)。
|
||||
|
||||
### 四个阶段函数
|
||||
|
||||
| 函数 | 职责 |
|
||||
|------|------|
|
||||
| `initPipeServer` | 角色判定 + server 创建 + beacon 启动 |
|
||||
| `registerMessageHandlers` | ping、attach、prompt、permission、detach 五个 handler |
|
||||
| `runMainHeartbeat` | cleanup + 发现 + auto-attach + 清理死连接 |
|
||||
| `runSubHeartbeat` | 检测 main 是否存活,死亡则接管或独立 |
|
||||
|
||||
### usePipeRelay(消息回传)
|
||||
|
||||
`src/hooks/usePipeRelay.ts`(38 行)
|
||||
|
||||
提供 `relayPipeMessage()` 和 `pipeReturnHadErrorRef`。relay 函数通过 `getPipeRelay()` module singleton 读取(替代 `globalThis.__pipeSendToMaster`)。
|
||||
|
||||
### usePipePermissionForward(权限转发)
|
||||
|
||||
`src/hooks/usePipePermissionForward.ts`(159 行)
|
||||
|
||||
订阅 `subscribePipeEntries()`,处理:
|
||||
- `permission_request` → 解析 payload → 查找 tool → 加入确认队列
|
||||
- `permission_cancel` → 从队列移除
|
||||
- `stream/error/done` → 转为系统消息显示(含 role + IP 标签)
|
||||
|
||||
### usePipeRouter(输入路由)
|
||||
|
||||
`src/hooks/usePipeRouter.ts`(130 行)
|
||||
|
||||
提供 `routeToSelectedPipes(input): boolean`。读取 `selectedPipes` + `routeMode`,逐个发送到已连接目标。通知显示 `[role] hostname/ip`(LAN peer)或 `[role]`(本机)。
|
||||
|
||||
---
|
||||
|
||||
## Registry 并行探测
|
||||
|
||||
`src/utils/pipeRegistry.ts`
|
||||
|
||||
### getAliveSubs()
|
||||
|
||||
```typescript
|
||||
export async function getAliveSubs(): Promise<PipeRegistrySub[]> {
|
||||
const registry = await readRegistry()
|
||||
const results = await Promise.all(
|
||||
registry.subs.map(sub =>
|
||||
isPipeAlive(sub.pipeName, 1000).then(alive => alive ? sub : null)
|
||||
)
|
||||
)
|
||||
return results.filter(Boolean)
|
||||
}
|
||||
```
|
||||
|
||||
### cleanupStaleEntries()
|
||||
|
||||
两阶段:
|
||||
1. **无锁并行探测**:`Promise.all` 探测 main + 所有 subs
|
||||
2. **短暂持锁写入**:`acquireLock()` → 重新读取 → 应用变更 → 写入 → `releaseLock()`
|
||||
|
||||
持锁时间从 N 秒降至 ~10ms。
|
||||
|
||||
### getMachineId()
|
||||
|
||||
Windows/macOS 使用 `execFile`(异步),不阻塞主线程。结果缓存,仅首次调用执行。
|
||||
|
||||
---
|
||||
|
||||
## NDJSON 协议
|
||||
|
||||
### 消息类型
|
||||
|
||||
| 类型 | 方向 | 数据 |
|
||||
|------|------|------|
|
||||
| `ping` / `pong` | 双向 | 无 |
|
||||
| `attach_request` | M→S | `meta: { machineId }` |
|
||||
| `attach_accept` / `attach_reject` | S→M | `data: reason` |
|
||||
| `detach` | M→S | 无 |
|
||||
| `prompt` | M→S | `data: prompt_text` |
|
||||
| `prompt_ack` | S→M | `data: 'accepted'` |
|
||||
| `stream` | S→M | `data: partial_text` |
|
||||
| `done` | S→M | 无 |
|
||||
| `error` | 双向 | `data: error_message` |
|
||||
| `permission_request` | S→M | `data: JSON(PipePermissionRequestPayload)` |
|
||||
| `permission_response` | M→S | `data: JSON(PipePermissionResponsePayload)` |
|
||||
| `permission_cancel` | M→S | `data: JSON({ requestId, reason })` |
|
||||
|
||||
### 帧格式
|
||||
|
||||
每行一个 JSON 对象,`\n` 分隔:
|
||||
```
|
||||
{"type":"ping","from":"cli-abc","ts":"2026-04-11T00:00:00.000Z"}\n
|
||||
{"type":"prompt","data":"检查 git status","from":"cli-abc"}\n
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 跨机器 Attach 流程
|
||||
|
||||
```
|
||||
CLI-B (192.168.50.27) 心跳循环
|
||||
→ beacon.getPeers() 发现 CLI-A (192.168.50.22)
|
||||
→ connectToPipe(pName, myName, 3000, { host: '192.168.50.22', port: 58853 })
|
||||
→ PipeClient.connectTcp() → net.createConnection({ host, port })
|
||||
→ client.send({ type: 'attach_request', meta: { machineId } })
|
||||
→ CLI-A 收到:
|
||||
isLanPeer = (msg.meta.machineId !== myMachineId) → true
|
||||
→ 不检查 role,直接 reply({ type: 'attach_accept' })
|
||||
→ setPipeRelay(socket.write)
|
||||
→ CLI-B 收到 attach_accept
|
||||
→ addSlaveClient(pName, client)
|
||||
→ store.setState: role='master', slaves[pName] = { status: 'idle' }
|
||||
```
|
||||
|
||||
关键:跨机器 attach 不要求对方是 sub 角色。通过 `machineId` 区分 LAN peer。
|
||||
|
||||
---
|
||||
|
||||
## SendMessageTool TCP 支持
|
||||
|
||||
`src/tools/SendMessageTool/SendMessageTool.ts`
|
||||
|
||||
- `to` 字段支持 `tcp:host:port` 格式
|
||||
- `checkPermissions`:`tcp:` scheme 返回 `behavior: 'ask'`,`classifierApprovable: false`
|
||||
- `call()`:创建临时 `PipeClient` → connect → send → disconnect
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
| 文件 | 测试数 | 覆盖 |
|
||||
|------|--------|------|
|
||||
| `lanBeacon.test.ts` | 7 | socket 初始化、announce、peer 发现/过滤/清理 |
|
||||
| `peerAddress.test.ts` | 8 | scheme 解析、parseTcpTarget、端口范围验证 |
|
||||
| `pipePermissionRelay.test.ts` | 2 | setPipeRelay singleton、权限请求/响应 |
|
||||
| `pipeTransport.test.ts` | 2 | UDS 基础行为 |
|
||||
| `useMasterMonitor.test.ts` | 5 | slave 注册/移除、事件发射 |
|
||||
|
||||
全量:2190 pass / 0 fail
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **TCP 无认证** — 同 LAN 内知道端口号即可连接
|
||||
2. **Beacon 明文广播** — IP/hostname/machineId 未 hash
|
||||
3. **单网卡选择** — `getLocalIp()` 返回首个非内部 IPv4,可能选到 VPN
|
||||
4. **端口随机** — 每次启动不同端口,依赖 beacon 发现
|
||||
5. **SendMessageTool 每次创建新连接** — 未复用已有 slave client
|
||||
|
||||
## 后续改进方向
|
||||
|
||||
1. HMAC-SHA256 TCP 握手认证
|
||||
2. machineId hash 后再广播
|
||||
3. 多网卡选择(优先 RFC 1918 地址)
|
||||
4. 固定端口范围配置
|
||||
5. TLS 加密传输
|
||||
6. SendMessageTool 复用已连接的 slave client
|
||||
193
docs/features/lan-pipes.md
Normal file
193
docs/features/lan-pipes.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# LAN Pipes — 局域网多机器群控指南
|
||||
|
||||
## 什么是 LAN Pipes
|
||||
|
||||
LAN Pipes 让多台机器上的 Claude Code 实例通过局域网自动发现并协作。你可以在一台机器(main)上操控其他机器(sub)上的 Claude Code,发送 prompt、查看执行结果、审批权限请求——全程零配置。
|
||||
|
||||
基于本机 Pipe IPC(`UDS_INBOX`)扩展,新增 TCP 传输层 + UDP Multicast 发现。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 两台或以上机器在同一局域网
|
||||
- 每台机器安装了 CCB 并能 `bun run dev`
|
||||
- Feature flag `LAN_PIPES`(dev/build 默认开启)
|
||||
- 防火墙允许 UDP 7101 + TCP 动态端口(见下方配置)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 第一步:配置防火墙
|
||||
|
||||
**每台机器都需要执行。**
|
||||
|
||||
**Windows**(管理员 PowerShell):
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "CCB LAN Beacon (UDP)" -Direction Inbound -Protocol UDP -LocalPort 7101 -Action Allow -Profile Private
|
||||
New-NetFirewallRule -DisplayName "CCB LAN Pipes (TCP)" -Direction Inbound -Protocol TCP -LocalPort 1024-65535 -Program (Get-Command bun).Source -Action Allow -Profile Private
|
||||
New-NetFirewallRule -DisplayName "CCB LAN Beacon Out (UDP)" -Direction Outbound -Protocol UDP -RemotePort 7101 -Action Allow -Profile Private
|
||||
```
|
||||
|
||||
验证网络为"专用"(非公共):`Get-NetConnectionProfile`
|
||||
|
||||
**macOS**:
|
||||
首次运行时系统弹出"允许接受传入连接"对话框,点击"允许"。
|
||||
|
||||
如果使用 pf 防火墙:
|
||||
```bash
|
||||
echo "pass in proto udp from any to any port 7101" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
**Linux**(firewalld):
|
||||
```bash
|
||||
sudo firewall-cmd --zone=trusted --add-port=7101/udp --permanent
|
||||
sudo firewall-cmd --zone=trusted --add-port=1024-65535/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
**Linux**(iptables):
|
||||
```bash
|
||||
sudo iptables -A INPUT -p udp --dport 7101 -j ACCEPT
|
||||
sudo iptables -A INPUT -p tcp --dport 1024:65535 -m owner --uid-owner $(id -u) -j ACCEPT
|
||||
```
|
||||
|
||||
### 第二步:启动
|
||||
|
||||
```bash
|
||||
# 机器 A(例如 192.168.50.22)
|
||||
bun run dev
|
||||
|
||||
# 机器 B(例如 192.168.50.27)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
启动后等待 3-5 秒(beacon 广播间隔),两边自动发现并连接。
|
||||
|
||||
### 第三步:查看和操作
|
||||
|
||||
在任一台机器上:
|
||||
```
|
||||
/pipes
|
||||
```
|
||||
|
||||
输出示例:
|
||||
```
|
||||
pipe: cli-a91bad56 (main) 192.168.50.22 2/3 selected
|
||||
|
||||
Main machine: 205d6c3a... (this machine)
|
||||
[main] cli-a91bad56 XC/192.168.50.22 [alive] (you)
|
||||
☑ [sub-1] cli-da029538 XC/192.168.50.22 [alive] [connected]
|
||||
|
||||
LAN Peers:
|
||||
☐ [main] cli-04d67950 vmwin11/192.168.50.27 tcp:192.168.50.27:58853 [LAN]
|
||||
```
|
||||
|
||||
### 第四步:选中目标并发送任务
|
||||
|
||||
1. 按 `Shift+↓` 展开选择面板
|
||||
2. `↑↓` 移动到 LAN peer
|
||||
3. `Space` 选中
|
||||
4. `Enter` 确认
|
||||
5. 输入 prompt,自动路由到远端执行
|
||||
|
||||
远端执行结果会流式回传到你的消息列表:
|
||||
```
|
||||
[main vmwin11/192.168.50.27 / cli-04d67950] 正在检查 git status...
|
||||
[main vmwin11/192.168.50.27 / cli-04d67950] Completed
|
||||
```
|
||||
|
||||
## 完整命令参考
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `/pipes` | 显示所有实例(本机 + LAN),Shift+↓ 展开选择面板 |
|
||||
| `/pipes select <name>` | 选中某实例 |
|
||||
| `/pipes all` | 全选 |
|
||||
| `/pipes none` | 取消全选 |
|
||||
| `/attach <name>` | 手动 attach(自动识别 LAN peer 并通过 TCP 连接) |
|
||||
| `/detach <name>` | 断开连接 |
|
||||
| `/send <name> <msg>` | 向指定 pipe 发送消息 |
|
||||
| `/send tcp:host:port <msg>` | 直接通过 TCP 地址发送 |
|
||||
| `/claim-main` | 强制声明为 main |
|
||||
| `/pipe-status` | 显示详细状态 |
|
||||
| `/peers` | 列出所有已发现的 peer |
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 快捷键 | 场景 | 作用 |
|
||||
|--------|------|------|
|
||||
| `Shift+↓` | 状态栏可见时 | 展开/收起选择面板 |
|
||||
| `↑ / ↓` | 面板展开时 | 移动光标 |
|
||||
| `Space` | 面板展开时 | 选中/取消 |
|
||||
| `Enter` | 面板展开时 | 确认关闭 |
|
||||
| `Esc` | 面板展开时 | 取消关闭 |
|
||||
| `← / →` | 有选中 pipe 时 | 切换路由模式 |
|
||||
| `M` | 面板展开时 | 同 ←/→ 切换路由模式 |
|
||||
|
||||
## 路由模式
|
||||
|
||||
| 模式 | 显示 | 行为 |
|
||||
|------|------|------|
|
||||
| `selected pipes only` | 绿色 | prompt 仅发送到选中的 pipe,本地不执行 |
|
||||
| `local main` | 灰色 | prompt 仅在本地执行,不转发 |
|
||||
|
||||
切换路由模式不会清空选择。
|
||||
|
||||
## 权限转发
|
||||
|
||||
当远端 slave 执行需要权限的工具(如 BashTool)时:
|
||||
1. slave 发送 `permission_request` 到 main
|
||||
2. main 弹出权限确认对话框,显示 `[role hostname/ip / pipeName]`
|
||||
3. 用户确认/拒绝
|
||||
4. 结果发回 slave,继续或中断
|
||||
|
||||
## 工作原理
|
||||
|
||||
### 发现机制
|
||||
|
||||
- 每台机器启动时创建 UDP multicast beacon
|
||||
- 组地址 `224.0.71.67`,端口 `7101`,TTL=1(不跨路由器)
|
||||
- 每 3 秒广播一次自身信息(pipeName、IP、TCP 端口、角色)
|
||||
- 15 秒未收到广播则标记 peer 丢失
|
||||
|
||||
### 通信机制
|
||||
|
||||
- 本机实例:UDS(Unix Domain Socket / Named Pipe)
|
||||
- 跨机器:TCP(动态端口,通过 beacon 发现)
|
||||
- 协议:NDJSON(每行一个 JSON 对象)
|
||||
- 消息类型:ping/pong、attach/detach、prompt/stream/done/error、permission
|
||||
|
||||
### 角色模型
|
||||
|
||||
| 角色 | 说明 |
|
||||
|------|------|
|
||||
| `main` | 首个启动的实例 |
|
||||
| `sub` | 同机后续启动的实例 |
|
||||
| `master` | attach 了至少一个 slave 的实例 |
|
||||
| `slave` | 被 master attach 的实例 |
|
||||
|
||||
跨机器 attach 时,两边都可以是 main——不要求对方必须是 sub。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 看不到 LAN peer
|
||||
|
||||
1. 检查防火墙是否放行 UDP 7101
|
||||
2. `Get-NetConnectionProfile`(Windows)确认网络为"专用"
|
||||
3. 确认两台机器在同一子网(`ping` 能通)
|
||||
4. 路由器未开启 AP 隔离
|
||||
|
||||
### 连接超时
|
||||
|
||||
1. 检查 TCP 入站防火墙规则
|
||||
2. 确认没有 VPN 劫持流量
|
||||
3. 尝试 `/send tcp:ip:port hello` 直接测试
|
||||
|
||||
### beacon 绑到了错误网卡
|
||||
|
||||
Windows 上 WSL/Docker 虚拟网卡可能劫持 multicast。beacon 会自动选择非内部 IPv4 接口。如果选错,检查 `getLocalIp()` 返回值。
|
||||
|
||||
## 安全说明
|
||||
|
||||
- TCP 连接当前**无认证**——同 LAN 内知道端口号即可连接
|
||||
- Multicast TTL=1,不跨路由器
|
||||
- AI 通过 `SendMessageTool` 发送 `tcp:` 消息时需**用户显式确认**
|
||||
- 建议仅在信任的局域网中使用
|
||||
205
docs/features/langfuse-monitoring.md
Normal file
205
docs/features/langfuse-monitoring.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Langfuse 监控集成
|
||||
|
||||
> 实现状态:已完成,通过环境变量启用
|
||||
> 依赖:`@langfuse/otel`、`@langfuse/tracing`、`@opentelemetry/sdk-trace-base`
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
Langfuse 是一个开源的 LLM 可观测性平台,用于追踪、监控和调试 AI 应用的请求链路。CCB 通过 OpenTelemetry (OTel) 桥接层将 Langfuse 集成到查询流程中,实现:
|
||||
|
||||
- **LLM 调用追踪** — 记录每次 API 请求的模型、Provider、输入/输出、Token 用量
|
||||
- **工具执行追踪** — 记录每个工具调用的名称、输入、输出、耗时和错误
|
||||
- **多 Agent 追踪** — 主 Agent 和子 Agent 各自独立的 Trace 链路
|
||||
- **数据脱敏** — 自动遮蔽敏感信息(API Key、文件内容、Shell 输出等)
|
||||
|
||||
## 二、启用方式
|
||||
|
||||
Langfuse 是开源项目,你可以 **自部署**(Docker / Kubernetes),也可以使用官方提供的 **[Langfuse Cloud](https://cloud.langfuse.com)** 免费测试。注册后在 Project Settings → API Keys 页面获取密钥。
|
||||
|
||||
核心只需要三个环境变量:
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
|---------|------|
|
||||
| `LANGFUSE_PUBLIC_KEY` | Langfuse 公钥(必填) |
|
||||
| `LANGFUSE_SECRET_KEY` | Langfuse 密钥(必填) |
|
||||
| `LANGFUSE_BASE_URL` | 服务地址,默认 `https://cloud.langfuse.com`;自部署时改为你的地址(必填) |
|
||||
|
||||
未配置时所有追踪函数为 no-op,零开销。
|
||||
|
||||
### 通过 settings.json 配置(推荐)
|
||||
|
||||
在 `.claude/settings.json` 的 `env` 字段中添加,这样每次启动自动生效:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"LANGFUSE_PUBLIC_KEY": "pk-xxx",
|
||||
"LANGFUSE_SECRET_KEY": "sk-xxx",
|
||||
"LANGFUSE_BASE_URL": "https://cloud.langfuse.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 其他可选参数
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `LANGFUSE_TRACING_ENVIRONMENT` | `development` | 环境标签,用于 Langfuse 面板筛选 |
|
||||
| `LANGFUSE_FLUSH_AT` | `20` | 批量发送的 span 数量阈值 |
|
||||
| `LANGFUSE_FLUSH_INTERVAL` | `10` | 定时刷新间隔(秒) |
|
||||
| `LANGFUSE_EXPORT_MODE` | `batched` | 导出模式:`batched`(批量)或 `immediate`(即时) |
|
||||
| `LANGFUSE_TIMEOUT` | `5` | 请求超时(秒) |
|
||||
|
||||
## 四、架构
|
||||
|
||||
### 4.1 模块结构
|
||||
|
||||
```
|
||||
src/services/langfuse/
|
||||
├── index.ts # 统一导出
|
||||
├── client.ts # OTel Provider + LangfuseSpanProcessor 初始化
|
||||
├── tracing.ts # Trace/Span 创建、LLM 和工具观察记录
|
||||
├── convert.ts # 内部 Message 类型 → Langfuse OpenAI 兼容格式转换
|
||||
└── sanitize.ts # 数据脱敏(敏感字段、文件路径、工具输出)
|
||||
```
|
||||
|
||||
### 4.2 追踪层级
|
||||
|
||||
```
|
||||
Trace (Agent Span) ← createTrace() / createSubagentTrace()
|
||||
├── Generation (LLM 调用) ← recordLLMObservation()
|
||||
├── Tool Observation (工具调用) ← recordToolObservation()
|
||||
├── Tool Observation (工具调用) ← recordToolObservation()
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 4.3 数据流
|
||||
|
||||
```
|
||||
query.ts ──→ createTrace() # 每个 query turn 创建根 trace
|
||||
│
|
||||
├── claude.ts ──→ recordLLMObservation() # API 调用完成后记录 LLM 观察
|
||||
│
|
||||
├── toolExecution.ts ──→ recordToolObservation() # 每个工具执行记录
|
||||
│
|
||||
└── query.ts ──→ endTrace() # turn 结束时关闭 trace
|
||||
|
||||
runAgent.ts ──→ createSubagentTrace() # 子 Agent 有独立 trace
|
||||
```
|
||||
|
||||
## 五、追踪详情
|
||||
|
||||
### 5.1 主 Agent Trace
|
||||
|
||||
每次 `query()` 调用(即用户一次对话 turn)创建一个类型为 `agent` 的根 Span:
|
||||
|
||||
- **名称**: `agent-run` 或 `agent-run:<querySource>`
|
||||
- **元数据**: `provider`、`model`、`agentType: "main"`
|
||||
- **Session ID**: 关联到 Langfuse 的 Session 功能,支持按会话聚合
|
||||
|
||||
### 5.2 子 Agent Trace
|
||||
|
||||
通过 `AgentTool` 启动的子 Agent 创建独立 Trace:
|
||||
|
||||
- **名称**: `agent:<agentType>`
|
||||
- **元数据**: `provider`、`model`、`agentType`、`agentId`
|
||||
- 独立于主 Trace,有自己的 Session 关联
|
||||
|
||||
### 5.3 LLM Generation
|
||||
|
||||
每次 API 调用记录为一个 `generation` 类型的 Span:
|
||||
|
||||
- **名称**: 按 Provider 映射(如 `ChatAnthropic`、`ChatOpenAI`、`ChatBedrockAnthropic` 等)
|
||||
- **记录内容**: 输入消息、输出消息、Token 用量(input/output)
|
||||
- **时间**: 精确记录 `startTime`、`endTime`、`completionStartTime`(TTFT 指标)
|
||||
|
||||
Provider 名称映射:
|
||||
|
||||
| Provider | Generation 名称 |
|
||||
|----------|-----------------|
|
||||
| `firstParty` | `ChatAnthropic` |
|
||||
| `bedrock` | `ChatBedrockAnthropic` |
|
||||
| `vertex` | `ChatVertexAnthropic` |
|
||||
| `foundry` | `ChatFoundry` |
|
||||
| `openai` | `ChatOpenAI` |
|
||||
| `gemini` | `ChatGoogleGenerativeAI` |
|
||||
| `grok` | `ChatXAI` |
|
||||
|
||||
### 5.4 工具执行
|
||||
|
||||
每个工具调用记录为一个 `tool` 类型的 Span:
|
||||
|
||||
- **名称**: 工具名(如 `FileEditTool`、`BashTool`)
|
||||
- **记录内容**: 输入(经脱敏)、输出(经脱敏)、`toolUseId`
|
||||
- **错误标记**: `isError` 标志 + `level: ERROR`
|
||||
|
||||
## 六、数据脱敏
|
||||
|
||||
所有上传到 Langfuse 的数据都会经过脱敏处理(`sanitize.ts`),确保敏感信息不会泄露:
|
||||
|
||||
### 6.1 全局脱敏(`sanitizeGlobal`)
|
||||
|
||||
- **Home 路径替换** — `/Users/xxx` → `~`
|
||||
- **敏感字段遮蔽** — 匹配 `api_key`、`token`、`secret`、`password`、`credential`、`auth_header` 等关键字的字段值替换为 `[REDACTED]`
|
||||
|
||||
### 6.2 工具输入脱敏(`sanitizeToolInput`)
|
||||
|
||||
- 敏感字段遮蔽(同全局)
|
||||
- `file_path`、`path`、`directory` 路径中的 Home 目录替换
|
||||
|
||||
### 6.3 工具输出脱敏(`sanitizeToolOutput`)
|
||||
|
||||
| 工具 | 脱敏策略 |
|
||||
|------|---------|
|
||||
| `FileReadTool`、`FileWriteTool`、`FileEditTool` | 完全遮蔽,仅保留字符数:`[file content redacted, N chars]` |
|
||||
| `BashTool`、`PowerShellTool` | 截断至 500 字符 |
|
||||
| `ConfigTool`、`MCPTool` | 完全遮蔽 |
|
||||
| 其他工具 | 原样保留 |
|
||||
|
||||
## 七、消息格式转换
|
||||
|
||||
`convert.ts` 将 CCB 内部的 Message 类型转换为 Langfuse 期望的 OpenAI 兼容格式:
|
||||
|
||||
- **输入**: `UserMessage | AssistantMessage[]` + 可选 system prompt → `{ role, content }[]`
|
||||
- **输出**: `AssistantMessage[]` → `{ role: 'assistant', content }`
|
||||
- **Content Block 映射**:
|
||||
- `text` → `{ type: 'text', text }`
|
||||
- `thinking` / `redacted_thinking` → `{ type: 'thinking', thinking }`
|
||||
- `tool_use` → `{ type: 'tool_use', id, name, input }`
|
||||
- `tool_result` → `{ type: 'tool_result', tool_use_id, content }`
|
||||
- `image` / `document` → 占位标记 `[image]` / `[document: name]`
|
||||
|
||||
## 八、生命周期
|
||||
|
||||
1. **初始化** — `initLangfuse()` 在 `src/entrypoints/init.ts` 启动时调用,创建 `LangfuseSpanProcessor` 和 `BasicTracerProvider`
|
||||
2. **运行时** — 各追踪函数通过 `isLangfuseEnabled()` 检查,未配置时直接返回 `null`/跳过
|
||||
3. **关闭** — `shutdownLangfuse()` 在进程退出时调用,强制 flush 并关闭 Processor
|
||||
|
||||
## 九、自部署 Langfuse
|
||||
|
||||
Langfuse 是开源项目,支持 Docker / Kubernetes 自部署:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name langfuse \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL=postgresql://... \
|
||||
langfuse/langfuse:latest
|
||||
```
|
||||
|
||||
自部署后,将 `LANGFUSE_BASE_URL` 指向你的实例地址即可。详见 [Langfuse 自部署文档](https://langfuse.com/docs/deployment/self-host)。
|
||||
|
||||
如果没有自部署需求,可以直接使用 [Langfuse Cloud](https://cloud.langfuse.com),提供免费额度可用于测试。
|
||||
|
||||
## 十、相关文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/services/langfuse/client.ts` | OTel Provider 初始化、生命周期管理 |
|
||||
| `src/services/langfuse/tracing.ts` | Trace/Span 创建和观察记录 |
|
||||
| `src/services/langfuse/convert.ts` | Message 格式转换 |
|
||||
| `src/services/langfuse/sanitize.ts` | 数据脱敏 |
|
||||
| `src/services/langfuse/__tests__/langfuse.test.ts` | 测试(568 行) |
|
||||
| `src/query.ts` | 主查询流程中的 Trace 集成 |
|
||||
| `src/services/tools/toolExecution.ts` | 工具执行中的观察记录 |
|
||||
| `src/tools/AgentTool/runAgent.ts` | 子 Agent Trace 创建 |
|
||||
342
docs/features/pipes-and-lan.md
Normal file
342
docs/features/pipes-and-lan.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# Pipes + LAN Pipes 完整功能指南
|
||||
|
||||
## 概述
|
||||
|
||||
Pipes 系统提供 Claude Code CLI 实例之间的通讯能力,分两层:
|
||||
|
||||
1. **Pipes(本机)**:同一台机器上的多个 CLI 实例通过 UDS(Unix Domain Socket / Windows Named Pipe)协作
|
||||
2. **LAN Pipes(局域网)**:不同机器上的 CLI 实例通过 TCP + UDP Multicast 协作
|
||||
|
||||
两层使用同一套协议(NDJSON)和同一套命令(`/pipes`、`/attach`、`/send` 等),对用户透明。
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Flag | 控制范围 | 默认 |
|
||||
|------|----------|------|
|
||||
| `UDS_INBOX` | 本机 Pipe IPC 全部功能 | dev/build 启用 |
|
||||
| `LAN_PIPES` | 局域网 TCP + beacon 扩展 | dev/build 启用 |
|
||||
|
||||
手动启用:`FEATURE_UDS_INBOX=1 FEATURE_LAN_PIPES=1 bun run dev`
|
||||
|
||||
## 快速上手
|
||||
|
||||
### 本机多实例
|
||||
|
||||
```bash
|
||||
# 终端 1
|
||||
bun run dev
|
||||
# 启动后自动注册为 main
|
||||
|
||||
# 终端 2
|
||||
bun run dev
|
||||
# 自动注册为 sub-1,被 main 自动 attach
|
||||
```
|
||||
|
||||
在终端 1 中输入 `/pipes`,可以看到两个实例。选中 sub-1 后,输入的消息会自动转发到 sub-1 执行。
|
||||
|
||||
### 局域网多机器
|
||||
|
||||
```bash
|
||||
# 机器 A (192.168.50.22)
|
||||
bun run dev
|
||||
|
||||
# 机器 B (192.168.50.27)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
两边启动后等 3-5 秒(beacon 广播间隔),LAN peers 会自动发现并 attach。输入 `/pipes` 可看到标记 `[LAN]` 的远端实例。
|
||||
|
||||
### 防火墙配置(两台机器都需要)
|
||||
|
||||
**Windows**(管理员 PowerShell):
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "Claude Code LAN Beacon (UDP)" -Direction Inbound -Protocol UDP -LocalPort 7101 -Action Allow -Profile Private
|
||||
New-NetFirewallRule -DisplayName "Claude Code LAN Pipes (TCP)" -Direction Inbound -Protocol TCP -LocalPort 1024-65535 -Program (Get-Command bun).Source -Action Allow -Profile Private
|
||||
New-NetFirewallRule -DisplayName "Claude Code LAN Beacon Out (UDP)" -Direction Outbound -Protocol UDP -RemotePort 7101 -Action Allow -Profile Private
|
||||
# 确认网络为"专用":Get-NetConnectionProfile
|
||||
```
|
||||
|
||||
**macOS**(首次运行时系统弹出对话框,点击"允许"即可):
|
||||
```bash
|
||||
# 如果需要手动放行 pf 防火墙:
|
||||
echo "pass in proto udp from any to any port 7101" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
**Linux**(firewalld / iptables):
|
||||
```bash
|
||||
# firewalld
|
||||
sudo firewall-cmd --zone=trusted --add-port=7101/udp --permanent
|
||||
sudo firewall-cmd --zone=trusted --add-port=1024-65535/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
|
||||
# 或 iptables
|
||||
sudo iptables -A INPUT -p udp --dport 7101 -j ACCEPT
|
||||
sudo iptables -A INPUT -p tcp --dport 1024:65535 -m owner --uid-owner $(id -u) -j ACCEPT
|
||||
```
|
||||
|
||||
确认:网络为局域网(非公共 WiFi),路由器未开启 AP 隔离。
|
||||
|
||||
## 交互面板与快捷键
|
||||
|
||||
### 状态栏
|
||||
|
||||
执行 `/pipes` 后,输入框底部出现 pipe 状态栏(单行):
|
||||
|
||||
```
|
||||
pipe: cli-a91bad56 (main) 192.168.50.22 2/3 selected selected pipes only · ←/→ or m switch · Shift+↓ edit
|
||||
```
|
||||
|
||||
状态栏始终可见(直到会话结束),显示:当前 pipe 名、角色、IP、已选数/总数、路由模式。
|
||||
|
||||
### 展开选择面板
|
||||
|
||||
按 **Shift+↓**(Shift + 下箭头)展开选择面板:
|
||||
|
||||
```
|
||||
pipe: cli-a91bad56 (main) 192.168.50.22 ↑↓ move Space select ←/→ or m route Enter/Esc close Shift+↓ toggle
|
||||
当前普通 prompt 走 已选 sub;切换不会清空选择
|
||||
☑ cli-da029538 (sub-1 XC/192.168.50.22)
|
||||
☐ cli-04d67950 (main vmwin11/192.168.50.27)
|
||||
☑ cli-893747d3 [offline] (sub-2 vmwin11/192.168.50.27)
|
||||
```
|
||||
|
||||
### 面板内快捷键
|
||||
|
||||
| 快捷键 | 场景 | 作用 |
|
||||
|--------|------|------|
|
||||
| **Shift+↓** | 状态栏可见时 | 展开/收起选择面板 |
|
||||
| **↑ / ↓** | 面板展开时 | 上下移动光标 |
|
||||
| **Space** | 面板展开时 | 切换当前光标所在 pipe 的选中状态(☑ ↔ ☐) |
|
||||
| **Enter** | 面板展开时 | 确认并关闭面板 |
|
||||
| **Esc** | 面板展开时 | 取消并关闭面板 |
|
||||
| **← / → 或 M** | 状态栏可见且有选中 pipe 时 | 切换路由模式(`selected pipes only` ↔ `local main`) |
|
||||
|
||||
### M 键 — 路由模式切换
|
||||
|
||||
M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开面板**:
|
||||
|
||||
| 模式 | 状态栏显示 | 行为 |
|
||||
|------|-----------|------|
|
||||
| `selected pipes only` | 绿色高亮 | 输入的 prompt **仅**发送到选中的 pipe,本地不执行 |
|
||||
| `local main` | 灰色 | 输入的 prompt 在**本地 main** 执行,不转发到任何 pipe |
|
||||
|
||||
切换路由模式**不会清空选择**。你可以在 `local main` 模式下保持选择,随时按 M 切回 `selected pipes only` 继续向远端发送。
|
||||
|
||||
### 完整操作流程示例
|
||||
|
||||
```
|
||||
1. 输入 /pipes → 状态栏出现,显示发现的实例
|
||||
2. 按 Shift+↓ → 展开选择面板
|
||||
3. 按 ↓ 移动到目标 pipe → 光标移到 cli-04d67950
|
||||
4. 按 Space → 选中 ☑ cli-04d67950
|
||||
5. 按 Enter → 确认,面板收起
|
||||
6. 输入 "帮我检查 git status" → prompt 自动发送到 cli-04d67950 执行
|
||||
7. 按 M → 切换到 local main 模式
|
||||
8. 输入 "本地做点什么" → 仅在本地执行
|
||||
9. 按 M → 切回 selected pipes only
|
||||
10. 输入 "继续远端任务" → 又发送到 cli-04d67950
|
||||
```
|
||||
|
||||
## 命令参考
|
||||
|
||||
### /pipes
|
||||
|
||||
显示所有发现的实例,管理选择状态。再次执行 `/pipes` 切换面板展开/收起。
|
||||
|
||||
```
|
||||
/pipes — 显示所有实例 + 切换选择面板
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes all — 全选
|
||||
/pipes none — 全部取消
|
||||
```
|
||||
|
||||
输出示例:
|
||||
```
|
||||
Your pipe: cli-a91bad56
|
||||
Role: main
|
||||
Machine ID: 205d6c3a...
|
||||
IP: 192.168.50.22
|
||||
Host: XC
|
||||
|
||||
Main machine: 205d6c3a... (this machine)
|
||||
[main] cli-a91bad56 XC/192.168.50.22 [alive] (you)
|
||||
☑ [sub-1] cli-da029538 XC/192.168.50.22 [alive] [connected]
|
||||
|
||||
LAN Peers:
|
||||
☐ [main] cli-04d67950 vmwin11/192.168.50.27 tcp:192.168.50.27:58853 [LAN]
|
||||
|
||||
Selected: cli-da029538
|
||||
```
|
||||
|
||||
### /attach <name>
|
||||
|
||||
手动 attach 到一个实例,使其成为你的 slave。
|
||||
|
||||
```
|
||||
/attach cli-04d67950 — 连接到指定 pipe(自动解析 LAN TCP 端点)
|
||||
```
|
||||
|
||||
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
||||
|
||||
### /detach <name>
|
||||
|
||||
断开与某个 slave 的连接。
|
||||
|
||||
```
|
||||
/detach cli-04d67950
|
||||
```
|
||||
|
||||
### /send <name> <message>
|
||||
|
||||
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
||||
|
||||
```
|
||||
/send cli-04d67950 请帮我检查一下日志
|
||||
/send tcp:192.168.50.27:58853 hello — 直接通过 TCP 地址发送
|
||||
```
|
||||
|
||||
### /claim-main
|
||||
|
||||
强制声明当前机器为 main(用于 main 意外退出后的恢复)。
|
||||
|
||||
## 消息路由
|
||||
|
||||
### 选中 pipe 后的自动路由
|
||||
|
||||
1. 通过 `/pipes select` 或 Shift+Down 面板选中一个或多个 pipe
|
||||
2. 在输入框中正常输入消息
|
||||
3. 消息自动发送到所有选中的已连接 pipe
|
||||
4. 每个 pipe 独立执行,结果流式回传到 main 的消息列表
|
||||
|
||||
### 路由模式
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| `selected`(默认) | 消息发送到选中的 pipe |
|
||||
| `local` | 消息仅在本地执行,不转发 |
|
||||
|
||||
## 架构
|
||||
|
||||
### 通信协议
|
||||
|
||||
所有通讯使用 NDJSON(Newline-Delimited JSON),每行一个消息:
|
||||
|
||||
```json
|
||||
{"type":"ping","from":"cli-abc","ts":"2026-04-11T00:00:00.000Z"}
|
||||
{"type":"prompt","data":"帮我查看 git status","from":"cli-abc","ts":"..."}
|
||||
{"type":"stream","data":"正在执行...","from":"cli-def","ts":"..."}
|
||||
{"type":"done","data":"","from":"cli-def","ts":"..."}
|
||||
```
|
||||
|
||||
### 消息类型
|
||||
|
||||
| 类型 | 方向 | 说明 |
|
||||
|------|------|------|
|
||||
| `ping`/`pong` | 双向 | 健康检查 |
|
||||
| `attach_request`/`accept`/`reject` | M→S/S→M | 连接控制 |
|
||||
| `detach` | M→S | 断开连接 |
|
||||
| `prompt` | M→S | 主向从发送 prompt |
|
||||
| `prompt_ack` | S→M | 从确认接收 |
|
||||
| `stream` | S→M | 从流式回传 AI 输出 |
|
||||
| `tool_start`/`tool_result` | S→M | 工具执行通知 |
|
||||
| `done` | S→M | 本轮完成 |
|
||||
| `error` | 双向 | 错误通知 |
|
||||
| `permission_request`/`response`/`cancel` | 双向 | 权限审批转发 |
|
||||
|
||||
### 传输层
|
||||
|
||||
```
|
||||
本机 LAN
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ PipeServer │ │ PipeServer │
|
||||
│ UDS sock │ │ UDS sock │
|
||||
│ TCP :rand │◄───TCP───►│ TCP :rand │
|
||||
├──────────────┤ ├──────────────┤
|
||||
│ LanBeacon │◄──UDP────►│ LanBeacon │
|
||||
│ 224.0.71.67 │ mcast │ 224.0.71.67 │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
- **UDS**:本机实例间通讯,通过文件系统路径寻址(`~/.claude/pipes/cli-xxx.sock`)
|
||||
- **TCP**:LAN 实例间通讯,动态端口,通过 beacon 发现
|
||||
- **UDP Multicast**:peer 发现,3 秒广播一次 announce 包
|
||||
|
||||
### 角色模型
|
||||
|
||||
| 角色 | 说明 |
|
||||
|------|------|
|
||||
| `main` | 首个启动的实例,管理 registry |
|
||||
| `sub` | 后续启动的同机实例(或被 attach 的 LAN 实例) |
|
||||
| `master` | attach 了至少一个 slave 的实例 |
|
||||
| `slave` | 被 master attach 控制的实例 |
|
||||
|
||||
角色转换:
|
||||
- 首个启动 → `main`
|
||||
- 同机后续启动 → `sub`(自动被 main attach → `slave`)
|
||||
- LAN 发现 → 两边都是 `main`,heartbeat 自动互相 attach
|
||||
- 被 attach → 变为 `slave`(可通过 `/detach` 恢复)
|
||||
|
||||
### 发现机制
|
||||
|
||||
**本机**:通过 `~/.claude/pipes/registry.json` 文件(带文件锁),`machineId` 绑定主机身份。
|
||||
|
||||
**LAN**:通过 UDP multicast beacon:
|
||||
1. 每 3 秒广播 `{ proto, pipeName, machineId, ip, tcpPort, role }`
|
||||
2. 收到其他实例的 announce → 记入 peers Map
|
||||
3. 15 秒未收到 → 标记 peer lost
|
||||
4. Heartbeat 合并 local registry + beacon peers → 统一 attach 目标列表
|
||||
|
||||
### Heartbeat 循环(5 秒间隔)
|
||||
|
||||
```
|
||||
main/master 角色:
|
||||
1. cleanupStaleEntries() — 清理 registry 中死掉的条目
|
||||
2. getAliveSubs() — 获取存活的本地 subs
|
||||
3. refreshDiscoveredPipes() — 刷新 discoveredPipes(包含 LAN peers)
|
||||
4. 合并 LAN peers 到 state
|
||||
5. 构建统一 attach 目标列表 — 本地 subs + LAN peers
|
||||
6. 遍历未连接的目标 → 自动 attach
|
||||
7. 清理断开的 slave 连接 — 同时检查 local registry 和 beacon
|
||||
|
||||
sub 角色:
|
||||
1. 检测 main 是否存活
|
||||
2. main 死亡 → 同机则接管 main 角色,跨机则独立
|
||||
```
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/utils/pipeTransport.ts` | PipeServer(双模 UDS+TCP)、PipeClient、类型定义 |
|
||||
| `src/utils/lanBeacon.ts` | UDP multicast beacon、singleton 管理 |
|
||||
| `src/utils/pipeRegistry.ts` | Registry CRUD、角色判定、machineId、LAN merge |
|
||||
| `src/utils/peerAddress.ts` | 地址解析(uds:/bridge:/tcp: scheme) |
|
||||
| `src/screens/REPL.tsx` | Bootstrap、heartbeat、cleanup、prompt 路由 |
|
||||
| `src/hooks/useMasterMonitor.ts` | Slave client registry、消息订阅 |
|
||||
| `src/hooks/useSlaveNotifications.ts` | Slave 端通知处理 |
|
||||
| `src/commands/pipes/pipes.ts` | /pipes 命令 |
|
||||
| `src/commands/attach/attach.ts` | /attach 命令 |
|
||||
| `src/commands/send/send.ts` | /send 命令 |
|
||||
| `src/tools/SendMessageTool/SendMessageTool.ts` | AI 发消息工具(含 tcp: 支持) |
|
||||
|
||||
## 后续优化方向
|
||||
|
||||
### 安全(P0)
|
||||
|
||||
1. **TCP 认证**:首次连接时交换 HMAC-SHA256 token(基于 machineId + session secret),防止未授权设备连接
|
||||
2. **JSON schema 验证**:在所有 `JSON.parse` 入口点增加 Zod 校验,防止 prototype pollution
|
||||
3. **Beacon 信息脱敏**:hash machineId 后再广播,不暴露硬件序列号
|
||||
|
||||
### 可靠性(P1)
|
||||
|
||||
4. **多网卡选择**:`getLocalIp()` 应优先选择 RFC 1918 地址,排除 VPN/Docker 接口
|
||||
5. **TCP target 验证**:`parseTcpTarget()` 应限制目标为已知 beacon peers 或 RFC 1918 范围
|
||||
6. **PipeServer close()**:改为 `Promise.allSettled` 并行关闭 UDS + TCP,加 `_closing` guard
|
||||
|
||||
### 功能(P2)
|
||||
|
||||
7. **mDNS/DNS-SD**:作为 multicast 受限环境下的 beacon 替代方案
|
||||
8. **固定端口配置**:允许用户指定 TCP 端口范围,便于防火墙精确配置
|
||||
9. **TLS 加密**:TCP 传输加密,防中间人窃听
|
||||
10. **双向 prompt**:当前只有 master → slave 方向,可考虑 slave 主动向 master 发送结果/请求
|
||||
278
docs/features/remote-control-self-hosting.md
Normal file
278
docs/features/remote-control-self-hosting.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Remote Control Server 私有化部署指南
|
||||
|
||||
本指南说明如何将 Remote Control Server (RCS) 部署到私有环境,并通过 Claude Code CLI 连接使用。
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────────┐
|
||||
│ Claude Code CLI │ ◄── HTTP/SSE/WS ─►│ Remote Control │
|
||||
│ (Bridge Worker) │ 长轮询 + 心跳 │ Server (RCS) │
|
||||
└──────────────────┘ │ │
|
||||
│ ┌──────────────┐ │
|
||||
┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │
|
||||
│ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │
|
||||
│ (/code/*) │ │ └──────────────┘ │
|
||||
└──────────────────┘ │ ┌──────────────┐ │
|
||||
│ │ JWT Auth │ │
|
||||
│ └──────────────┘ │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
**RCS 是一个纯内存的中间服务**,它的职责是:
|
||||
- 接收 Claude Code CLI 的环境注册和工作轮询
|
||||
- 提供 Web UI 供操作者远程监控和审批
|
||||
- 通过 WebSocket/SSE 双向传输消息
|
||||
- 管理会话、环境、权限请求
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一台可被 Claude Code CLI 和 Web 浏览器同时访问的服务器(物理机、VM、容器均可)
|
||||
- [Docker](https://www.docker.com/)
|
||||
- 启用 `BRIDGE_MODE` feature flag 的 Claude Code 构建
|
||||
|
||||
## 部署
|
||||
|
||||
### 构建 Docker 镜像
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
docker build -t rcs:latest -f packages/remote-control-server/Dockerfile .
|
||||
```
|
||||
|
||||
### 启动容器
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name rcs \
|
||||
-p 3000:3000 \
|
||||
-e RCS_API_KEYS=sk-rcs-your-secret-key-here \
|
||||
-e RCS_BASE_URL=https://rcs.example.com \
|
||||
-v rcs-data:/app/data \
|
||||
--restart unless-stopped \
|
||||
rcs:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
services:
|
||||
rcs:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/remote-control-server/Dockerfile
|
||||
args:
|
||||
VERSION: "0.1.0"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- RCS_API_KEYS=sk-rcs-your-secret-key-here
|
||||
- RCS_BASE_URL=https://rcs.example.com
|
||||
volumes:
|
||||
- rcs-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
rcs-data:
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 环境变量参考
|
||||
|
||||
### 服务器端
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `RCS_API_KEYS` | **是** | _(空)_ | API 密钥列表,逗号分隔。用于客户端认证和 JWT 签名。**务必设置强密钥** |
|
||||
| `RCS_PORT` | 否 | `3000` | 服务监听端口 |
|
||||
| `RCS_HOST` | 否 | `0.0.0.0` | 服务监听地址 |
|
||||
| `RCS_BASE_URL` | 否 | `http://localhost:3000` | 外部访问 URL。用于生成 WebSocket 连接地址,必须与客户端实际访问的地址一致 |
|
||||
| `RCS_VERSION` | 否 | `0.1.0` | 版本号,显示在 `/health` 响应中 |
|
||||
| `RCS_POLL_TIMEOUT` | 否 | `8` | V1 工作轮询超时(秒) |
|
||||
| `RCS_HEARTBEAT_INTERVAL` | 否 | `20` | 心跳间隔(秒) |
|
||||
| `RCS_JWT_EXPIRES_IN` | 否 | `3600` | JWT 令牌有效期(秒) |
|
||||
| `RCS_DISCONNECT_TIMEOUT` | 否 | `300` | 断线判定超时(秒) |
|
||||
|
||||
### 客户端(Claude Code CLI)
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `CLAUDE_BRIDGE_BASE_URL` | **是** | RCS 服务器地址,例如 `https://rcs.example.com`。设置此变量即启用自托管模式,跳过 GrowthBook 门控 |
|
||||
| `CLAUDE_BRIDGE_OAUTH_TOKEN` | **是** | 认证令牌,必须与服务器端 `RCS_API_KEYS` 中的某个值匹配 |
|
||||
| `CLAUDE_BRIDGE_SESSION_INGRESS_URL` | 否 | WebSocket 入口地址(默认与 `CLAUDE_BRIDGE_BASE_URL` 相同) |
|
||||
| `CLAUDE_CODE_REMOTE` | 否 | 设为 `1` 时标记为远程执行模式 |
|
||||
|
||||
## Claude Code 客户端连接
|
||||
|
||||
### 1. 设置环境变量
|
||||
|
||||
在运行 Claude Code 的机器上设置:
|
||||
|
||||
```bash
|
||||
export CLAUDE_BRIDGE_BASE_URL="https://rcs.example.com"
|
||||
export CLAUDE_BRIDGE_OAUTH_TOKEN="sk-rcs-your-secret-key-here"
|
||||
```
|
||||
|
||||
### 2. 启动 Claude Code
|
||||
|
||||
```bash
|
||||
# 使用 dev 模式(BRIDGE_MODE 默认启用)
|
||||
bun run dev
|
||||
|
||||
# 或使用构建产物
|
||||
bun run dist/cli.js
|
||||
```
|
||||
|
||||
### 3. 执行 /remote-control 命令
|
||||
|
||||
在 Claude Code 的 REPL 中输入:
|
||||
|
||||
```
|
||||
/remote-control
|
||||
```
|
||||
|
||||
CLI 会向 RCS 注册环境,注册成功后在终端显示连接 URL:
|
||||
|
||||
```
|
||||
https://rcs.example.com/code?bridge=<environmentId>
|
||||
```
|
||||
|
||||
同时支持 QR 码扫码打开。该 URL 即 Web UI 控制面板入口,在浏览器中打开即可远程操控当前会话。
|
||||
|
||||
若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项:
|
||||
- **Disconnect this session** — 断开远程连接
|
||||
- **Show QR code** — 显示/隐藏二维码
|
||||
- **Continue** — 保持连接,继续使用
|
||||
|
||||
也可通过 CLI 参数直接启动:
|
||||
|
||||
```bash
|
||||
claude remote-control
|
||||
# 或简写
|
||||
claude rc
|
||||
# 或
|
||||
claude bridge
|
||||
```
|
||||
|
||||
## Web UI 控制面板
|
||||
|
||||
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
|
||||
|
||||
- 查看已注册的运行环境
|
||||
- 创建和管理会话
|
||||
- 实时查看对话消息和工具调用
|
||||
- 审批 Claude Code 的工具权限请求
|
||||
|
||||
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
|
||||
|
||||
## 工作流程详解
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 完整工作流程 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
|
||||
1. Claude Code CLI 启动,设置环境变量指向自托管 RCS
|
||||
|
||||
2. 用户执行 /remote-control 命令
|
||||
|
||||
3. 注册环境
|
||||
CLI ──POST /v1/environments/bridge──► RCS
|
||||
CLI ◄── { environment_id, environment_secret } ── RCS
|
||||
|
||||
4. 终端显示连接 URL
|
||||
https://rcs.example.com/code?bridge=<environmentId>
|
||||
|
||||
5. 开始工作轮询(循环)
|
||||
CLI ──GET /v1/environments/:id/work/poll──► RCS
|
||||
(长轮询,等待任务分配,超时 8 秒后重试)
|
||||
|
||||
6. 浏览器打开 URL → Web UI 创建任务
|
||||
Browser ──POST /web/sessions──► RCS
|
||||
RCS 分配 work 给正在轮询的 CLI
|
||||
|
||||
7. CLI 收到任务并确认
|
||||
CLI ◄── { id, data: { type, sessionId } } ── RCS
|
||||
CLI ──POST /v1/environments/:id/work/:workId/ack──► RCS
|
||||
|
||||
8. 建立会话连接
|
||||
CLI ──WebSocket /v1/session_ingress──► RCS
|
||||
(或使用 V2 的 SSE + HTTP POST)
|
||||
|
||||
9. 双向通信
|
||||
CLI ──消息/工具调用结果──► RCS ──► Browser
|
||||
CLI ◄──权限审批/指令───── RCS ◄──── Browser
|
||||
|
||||
10. 心跳保活(每 20 秒)
|
||||
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
|
||||
|
||||
11. 任务完成 → 归档会话 → 注销环境
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### CLI 无法连接
|
||||
|
||||
```
|
||||
Error: Remote Control is not available in this build.
|
||||
```
|
||||
|
||||
**原因**:`BRIDGE_MODE` feature flag 未启用。
|
||||
|
||||
**解决**:使用 dev 模式(默认启用)或确保构建时包含 `BRIDGE_MODE` flag。
|
||||
|
||||
### 认证失败 (401)
|
||||
|
||||
```
|
||||
Error: Unauthorized
|
||||
```
|
||||
|
||||
**检查项**:
|
||||
1. `CLAUDE_BRIDGE_OAUTH_TOKEN` 是否与 `RCS_API_KEYS` 中的值匹配
|
||||
2. API Key 是否包含多余的空格或换行
|
||||
3. 两个环境变量是否都已正确设置
|
||||
|
||||
### WebSocket 连接中断
|
||||
|
||||
**检查项**:
|
||||
1. 如果使用反向代理,确认已正确配置 WebSocket 升级(`Upgrade` / `Connection` 头)
|
||||
2. 代理的 `proxy_read_timeout` 是否足够大(建议 86400 秒)
|
||||
3. 网络防火墙是否允许 WebSocket 流量
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl https://rcs.example.com/health
|
||||
# 预期: {"status":"ok","version":"0.1.0"}
|
||||
```
|
||||
|
||||
## 限制与注意事项
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 存储 | 纯内存存储(Map),服务器重启后所有会话和环境数据丢失 |
|
||||
| 扩展 | 不支持水平扩展(无共享状态),单实例部署 |
|
||||
| 并发 | 适合中小规模使用,大量并发会话可能需要性能调优 |
|
||||
| 数据持久化 | `/app/data` 卷已预留但当前未使用,未来可能用于持久化 |
|
||||
| Web UI 认证 | 基于 UUID,无用户账户系统,适合受信任网络环境 |
|
||||
|
||||
## 与云端模式对比
|
||||
|
||||
| 特性 | 云端 (Anthropic CCR) | 自托管 (RCS) |
|
||||
|------|---------------------|--------------|
|
||||
| 认证方式 | claude.ai OAuth 订阅 | API Key |
|
||||
| GrowthBook 门控 | 需要 `tengu_ccr_bridge` 通过 | 自动跳过 |
|
||||
| 功能标志 | 需要 `BRIDGE_MODE=1` | 同样需要 |
|
||||
| 部署位置 | Anthropic 云端 | 用户自有服务器 |
|
||||
| 数据流经 | Anthropic 基础设施 | 用户私有网络 |
|
||||
| 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key |
|
||||
|
||||
自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
| Feature | 引用 | 状态 | 类别 | 简要说明 |
|
||||
|---------|------|------|------|---------|
|
||||
| CHICAGO_MCP | 16 | N/A | 内部基础设施 | Anthropic 内部 MCP 基础设施,非外部可用 |
|
||||
| UDS_INBOX | 17 | Stub | 消息通信 | Unix 域套接字对等消息,进程间消息传递 |
|
||||
| MONITOR_TOOL | 13 | Stub | 工具 | 文件/进程监控工具,检测变更并通知 |
|
||||
| BG_SESSIONS | 11 | Stub | 会话管理 | 后台会话管理,支持多会话并行 |
|
||||
| SHOT_STATS | 10 | 无实现 | 统计 | 逐 prompt 统计信息收集 |
|
||||
@@ -68,7 +67,7 @@ BUILDING_CLAUDE_APPS, ANTI_DISTILLATION_CC, AGENT_TRIGGERS, ABLATION_BASELINE
|
||||
这些 feature 被列为 Tier 3 的原因:
|
||||
|
||||
1. **内部基础设施**(CHICAGO_MCP, LODESTONE):Anthropic 内部使用,外部无法运行
|
||||
2. **纯 Stub 且引用低**(UDS_INBOX, MONITOR_TOOL, BG_SESSIONS):需要大量工作才能实现
|
||||
2. **纯 Stub 且引用低**(MONITOR_TOOL, BG_SESSIONS):需要大量工作才能实现
|
||||
3. **实验性功能**(SHOT_STATS, EXTRACT_MEMORIES):尚在概念阶段
|
||||
4. **辅助功能**(STREAMLINED_OUTPUT, HOOK_PROMPTS):影响范围小
|
||||
5. **CCR 系列**:依赖远程控制基础设施,需要 BRIDGE_MODE 先完善
|
||||
|
||||
114
docs/features/uds-inbox.md
Normal file
114
docs/features/uds-inbox.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# UDS_INBOX / pipes
|
||||
|
||||
## 概述
|
||||
|
||||
`UDS_INBOX` 现在不是一个“空壳 flag”,而是一套已经落地的本机 IPC 能力。但它同时承载了两层不同目标,必须拆开理解:
|
||||
|
||||
1. **UDS peer messaging**
|
||||
- 面向任意 Claude Code 进程。
|
||||
- 使用 `src/utils/udsMessaging.ts` 和 `src/utils/udsClient.ts`。
|
||||
- 对外入口是 `/peers` 和 `SendMessageTool` 的 `uds:<socket-path>` 地址。
|
||||
2. **pipes control plane**
|
||||
- 面向交互式 REPL 会话之间的主从协作。
|
||||
- 使用 `src/utils/pipeTransport.ts`、`src/utils/pipeRegistry.ts` 和 `src/screens/REPL.tsx` 中的内联 bootstrap。
|
||||
- 对外入口是 `/pipes`、`/attach`、`/detach`、`/send`、`/pipe-status`、`/history`、`/claim-main`。
|
||||
|
||||
这两层都依赖本机 socket,但职责不同。`/peers` 解决“找到其他会话并发消息”,`/pipes` 解决“把一个 REPL 变成另一个 REPL 的受控 worker”。
|
||||
|
||||
## 为什么要有单独的 `pipes`
|
||||
|
||||
单独的 `pipes` 层有三个实际理由:
|
||||
|
||||
1. **命名与角色模型不同**
|
||||
- UDS peer 层按 `messagingSocketPath` 寻址。
|
||||
- pipes 层按 `cli-xxxxxxxx` 会话名、`main/sub/master/slave` 角色和 `machineId` 注册表工作。
|
||||
2. **交互语义不同**
|
||||
- peer 层是通用消息投递。
|
||||
- pipes 层需要 attach、detach、历史收集、选择性广播、状态栏和 REPL 快捷键。
|
||||
3. **UI 集成不同**
|
||||
- peer 层主要服务工具调用。
|
||||
- pipes 层直接影响 REPL 提交路径和 PromptInput 页脚。
|
||||
|
||||
如果把两者硬合并,`SendMessageTool` 的通用寻址和 REPL 的主从控制会互相污染,命令语义也会变得混乱。
|
||||
|
||||
## 当前通信模型
|
||||
|
||||
### 1. UDS peer messaging
|
||||
|
||||
- 服务端:`src/utils/udsMessaging.ts`
|
||||
- 客户端:`src/utils/udsClient.ts`
|
||||
- 发现方式:读取 `~/.claude/sessions/*.json`
|
||||
- 地址方式:`uds:<socket-path>`
|
||||
- 传输方式:**本机 Unix socket / Windows named pipe**
|
||||
|
||||
这层是真正的“通用收件箱”。
|
||||
|
||||
### 2. pipes control plane
|
||||
|
||||
- 服务端/客户端:`src/utils/pipeTransport.ts`
|
||||
- 注册表:`src/utils/pipeRegistry.ts`
|
||||
- 生效入口:`src/screens/REPL.tsx`
|
||||
- 发现方式:扫描 `~/.claude/pipes/` + `registry.json`
|
||||
- 会话名:`cli-${sessionId.slice(0, 8)}`
|
||||
- 传输方式:**本机 Unix socket / Windows named pipe**
|
||||
|
||||
这层是真正的“主从 REPL 协调平面”。
|
||||
|
||||
## 关于“局域网通信”的事实
|
||||
|
||||
当前实现**不是**真正的局域网传输。
|
||||
|
||||
代码里虽然保存了这些字段:
|
||||
|
||||
- `localIp`
|
||||
- `hostname`
|
||||
- `machineId`
|
||||
- `mac`
|
||||
|
||||
但这些字段当前只用于:
|
||||
|
||||
1. 注册表展示
|
||||
2. main/sub 身份判定
|
||||
3. `claim-main` 的机器级归属切换
|
||||
4. 状态输出与排障信息
|
||||
|
||||
它们**没有**被用于创建 TCP/WebSocket 连接。真正的传输仍然是 `getPipePath(name)` 返回的本机 socket 路径。
|
||||
|
||||
所以目前更准确的描述应该是:
|
||||
|
||||
- `pipes` 支持 **本机多实例协作**
|
||||
- `registry` 带有 **机器身份元数据**
|
||||
- 但 **尚未实现跨机器局域网 transport**
|
||||
|
||||
如果未来要做真局域网版本,至少还需要:
|
||||
|
||||
1. TCP/WebSocket transport
|
||||
2. 认证与会话授权
|
||||
3. 发现与地址交换
|
||||
4. 超时、重连和安全边界
|
||||
|
||||
## 当前 REPL 行为
|
||||
|
||||
当前线上行为由 `src/screens/REPL.tsx` 的内联实现负责:
|
||||
|
||||
1. 启动时创建当前 REPL 的 pipe server
|
||||
2. 通过 `pipeRegistry` 判定 `main` / `sub`
|
||||
3. 处理 `attach_request` / `detach` / `prompt`
|
||||
4. 主实例心跳探测并维护 `slaves`
|
||||
5. `/pipes` 打开状态栏并维护选择器
|
||||
6. 提交普通消息时,仅向**已连接**的 selected pipes 广播
|
||||
|
||||
最近的收敛点:
|
||||
|
||||
- 过去遗留了一套未接线的 hook 方案
|
||||
- 当前已明确以 `REPL.tsx` 内联 bootstrap 为唯一生效实现
|
||||
- 选中但未连接的 pipe 不再导致本地处理被错误跳过
|
||||
|
||||
## 文档与代码对齐约定
|
||||
|
||||
后续关于 `UDS_INBOX` / `pipes` 的说明应遵守以下表述:
|
||||
|
||||
1. 默认称为“本机 IPC / 本机多实例协作”
|
||||
2. 不把 `localIp` / `hostname` 元数据表述成已完成的 LAN transport
|
||||
3. 明确区分 `/peers` 和 `/pipes` 的两层职责
|
||||
4. 以 `src/screens/REPL.tsx`、`src/utils/pipeTransport.ts`、`src/utils/pipeRegistry.ts` 为事实来源
|
||||
@@ -1,190 +0,0 @@
|
||||
# OpenAI兼容模型中task工具使用指南
|
||||
|
||||
## 问题描述
|
||||
|
||||
当使用OpenAI兼容模型(如DeepSeek、Ollama、vLLM等)时,调用task工具(TaskGet、TaskCreate、TaskUpdate、TaskList)可能会出现以下错误:
|
||||
|
||||
```
|
||||
Error: InputValidationError: TaskGet failed due to the following issues:
|
||||
The required parameter `taskId` is missing
|
||||
An unexpected parameter `task_id` was provided
|
||||
|
||||
This tool's schema was not sent to the API — it was not in the discovered-tool set derived from message history. Without the schema in your prompt, typed parameters (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. Load the tool first: call ToolSearch with query "select:TaskGet", then retry this call.
|
||||
```
|
||||
|
||||
## 问题原因
|
||||
|
||||
### 1. 延迟加载工具(Deferred Tools)
|
||||
task工具都是延迟加载的(`shouldDefer: true`),这意味着:
|
||||
- 工具的模式(schema)不会在初始API调用中发送
|
||||
- 需要先通过`ToolSearch`工具发现
|
||||
- 只有在被发现后,工具模式才会被发送给API
|
||||
|
||||
### 2. 参数名转换问题
|
||||
- task工具使用驼峰命名:`taskId`
|
||||
- OpenAI兼容模型可能输出蛇形命名:`task_id`
|
||||
- 当工具模式没有被发送时,模型会猜测参数名,可能导致不匹配
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案1:先使用ToolSearch(推荐)
|
||||
在使用task工具之前,先调用`ToolSearch`工具:
|
||||
|
||||
```javascript
|
||||
// 第一步:发现task工具
|
||||
ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")
|
||||
|
||||
// 第二步:正常使用task工具
|
||||
TaskCreate({ subject: "任务标题", description: "任务描述" })
|
||||
TaskGet({ taskId: "1" })
|
||||
TaskUpdate({ taskId: "1", status: "completed" })
|
||||
TaskList()
|
||||
```
|
||||
|
||||
### 方案2:批量发现所有task工具
|
||||
```javascript
|
||||
// 一次性发现所有task工具
|
||||
ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")
|
||||
|
||||
// 然后可以任意使用task工具
|
||||
const task = await TaskCreate({ subject: "新任务", description: "任务描述" })
|
||||
console.log(`创建的任务ID: ${task.id}`)
|
||||
|
||||
const taskList = await TaskList()
|
||||
console.log(`当前有 ${taskList.tasks.length} 个任务`)
|
||||
```
|
||||
|
||||
### 方案3:单独发现特定工具
|
||||
```javascript
|
||||
// 只发现需要的工具
|
||||
ToolSearch("select:TaskGet")
|
||||
|
||||
// 然后使用该工具
|
||||
TaskGet({ taskId: "1" })
|
||||
```
|
||||
|
||||
## 参数名注意事项
|
||||
|
||||
在使用OpenAI兼容模型时,请注意参数名格式:
|
||||
|
||||
### ✅ 正确(驼峰命名)
|
||||
```javascript
|
||||
TaskGet({ taskId: "1" })
|
||||
TaskCreate({ subject: "标题", description: "描述" })
|
||||
TaskUpdate({ taskId: "1", status: "completed" })
|
||||
```
|
||||
|
||||
### ❌ 错误(蛇形命名)
|
||||
```javascript
|
||||
TaskGet({ task_id: "1" }) // 错误:应该使用taskId
|
||||
TaskCreate({ subject: "标题", description: "描述" }) // 正确
|
||||
TaskUpdate({ task_id: "1", status: "completed" }) // 错误:应该使用taskId
|
||||
```
|
||||
|
||||
## 常见问题解答
|
||||
|
||||
### Q1: 为什么需要先使用ToolSearch?
|
||||
A: task工具是延迟加载的,它们的模式只有在被`ToolSearch`工具发现后才会发送给API。没有工具模式,模型无法知道正确的参数名和类型。
|
||||
|
||||
### Q2: 每次会话都需要使用ToolSearch吗?
|
||||
A: 是的,每次新的会话都需要先使用ToolSearch发现工具。工具发现状态不会在会话之间保留。
|
||||
|
||||
### Q3: 使用Anthropic官方模型也需要这样吗?
|
||||
A: 通常不需要。Anthropic官方模型对延迟加载工具的处理更智能,但为了兼容性,建议在使用task工具前都先使用ToolSearch。
|
||||
|
||||
### Q4: 可以一次性发现所有工具吗?
|
||||
A: 可以,使用`ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")`可以一次性发现所有task工具。
|
||||
|
||||
### Q5: 如果忘记使用ToolSearch会怎样?
|
||||
A: 会收到参数验证错误,提示需要先使用ToolSearch。按照错误信息的指导操作即可。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **会话开始时发现工具**:在开始使用task工具前,先调用ToolSearch
|
||||
2. **批量发现**:一次性发现所有需要的task工具
|
||||
3. **检查参数名**:确保使用正确的驼峰命名参数
|
||||
4. **查看错误信息**:如果遇到错误,仔细阅读错误信息中的指导
|
||||
|
||||
## 示例工作流
|
||||
|
||||
```javascript
|
||||
// 1. 开始新会话
|
||||
// 2. 发现task工具
|
||||
ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")
|
||||
|
||||
// 3. 创建任务
|
||||
const newTask = await TaskCreate({
|
||||
subject: "修复OpenAI兼容性问题",
|
||||
description: "解决task工具在OpenAI兼容模型下的参数名问题"
|
||||
})
|
||||
|
||||
// 4. 获取任务详情
|
||||
const taskDetails = await TaskGet({ taskId: newTask.id })
|
||||
|
||||
// 5. 更新任务状态
|
||||
await TaskUpdate({
|
||||
taskId: newTask.id,
|
||||
status: "in_progress",
|
||||
activeForm: "修复OpenAI兼容性问题"
|
||||
})
|
||||
|
||||
// 6. 查看所有任务
|
||||
const allTasks = await TaskList()
|
||||
console.log(`当前有 ${allTasks.tasks.length} 个任务`)
|
||||
|
||||
// 7. 完成任务
|
||||
await TaskUpdate({
|
||||
taskId: newTask.id,
|
||||
status: "completed"
|
||||
})
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 错误:参数名不匹配
|
||||
**症状**:`taskId`参数缺失,发现`task_id`参数
|
||||
**解决**:确保使用驼峰命名的`taskId`,而不是蛇形命名的`task_id`
|
||||
|
||||
### 错误:工具模式未发送
|
||||
**症状**:`This tool's schema was not sent to the API`
|
||||
**解决**:先使用`ToolSearch("select:工具名")`发现工具
|
||||
|
||||
### 错误:工具不可用
|
||||
**症状**:工具调用失败,没有具体错误信息
|
||||
**解决**:检查工具是否启用(通过`isTodoV2Enabled()`),确保环境变量设置正确
|
||||
|
||||
## 相关配置
|
||||
|
||||
### 环境变量
|
||||
```bash
|
||||
# 启用OpenAI兼容模式
|
||||
export CLAUDE_CODE_USE_OPENAI=1
|
||||
export OPENAI_API_KEY=your-api-key
|
||||
export OPENAI_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# 配置模型映射
|
||||
export OPENAI_DEFAULT_SONNET_MODEL=deepseek-chat
|
||||
export OPENAI_DEFAULT_OPUS_MODEL=deepseek-chat
|
||||
export OPENAI_DEFAULT_HAIKU_MODEL=deepseek-chat
|
||||
```
|
||||
|
||||
### 设置文件
|
||||
通过`/login`命令配置OpenAI兼容模式后,设置会保存在`~/.claude/settings.json`:
|
||||
```json
|
||||
{
|
||||
"modelType": "openai",
|
||||
"openai": {
|
||||
"baseURL": "https://api.deepseek.com",
|
||||
"apiKey": "your-api-key",
|
||||
"models": {
|
||||
"haiku": "deepseek-chat",
|
||||
"sonnet": "deepseek-chat",
|
||||
"opus": "deepseek-chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
在使用OpenAI兼容模型时,task工具需要先通过`ToolSearch`发现才能正常使用。遵循"先发现,后使用"的原则,并注意参数名的正确格式(驼峰命名),可以确保task工具在OpenAI兼容模型下正常工作。
|
||||
@@ -1,425 +0,0 @@
|
||||
# OpenAI 协议兼容层
|
||||
|
||||
## 概述
|
||||
|
||||
claude-code 支持通过 OpenAI Chat Completions API(`/v1/chat/completions`)兼容任意 OpenAI 协议端点,包括 Ollama、DeepSeek、vLLM、One API、LiteLLM 等。
|
||||
|
||||
核心策略为**流适配器模式**:在 `queryModel()` 中插入提前返回分支,将 Anthropic 格式请求转为 OpenAI 格式,调用 OpenAI SDK,再将 SSE 流转换回 `BetaRawMessageStreamEvent` 格式。下游代码(流处理循环、query.ts、QueryEngine.ts、REPL)**完全不改**。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 必需 | 说明 |
|
||||
|---|---|---|
|
||||
| `CLAUDE_CODE_USE_OPENAI` | 是 | 设为 `1` 启用 OpenAI 后端 |
|
||||
| `OPENAI_API_KEY` | 是 | API key(Ollama 等可设为任意值) |
|
||||
| `OPENAI_BASE_URL` | 推荐 | 端点 URL(如 `http://localhost:11434/v1`) |
|
||||
| `OPENAI_MODEL` | 可选 | 覆盖所有请求的模型名(跳过映射) |
|
||||
| `OPENAI_DEFAULT_OPUS_MODEL` | 可选 | 覆盖 opus 家族对应的模型(如 `o3`, `o3-mini`, `o1-pro`) |
|
||||
| `OPENAI_DEFAULT_SONNET_MODEL` | 可选 | 覆盖 sonnet 家族对应的模型(如 `gpt-4o`, `gpt-4.1`) |
|
||||
| `OPENAI_DEFAULT_HAIKU_MODEL` | 可选 | 覆盖 haiku 家族对应的模型(如 `gpt-4o-mini`, `gpt-4.0-mini`) |
|
||||
| `OPENAI_ORG_ID` | 可选 | Organization ID |
|
||||
| `OPENAI_PROJECT_ID` | 可选 | Project ID |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# Ollama
|
||||
CLAUDE_CODE_USE_OPENAI=1 \
|
||||
OPENAI_API_KEY=ollama \
|
||||
OPENAI_BASE_URL=http://localhost:11434/v1 \
|
||||
OPENAI_MODEL=qwen2.5-coder-32b \
|
||||
bun run dev
|
||||
|
||||
# DeepSeek(自动支持 Thinking)
|
||||
CLAUDE_CODE_USE_OPENAI=1 \
|
||||
OPENAI_API_KEY=sk-xxx \
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1 \
|
||||
OPENAI_MODEL=deepseek-chat \
|
||||
bun run dev
|
||||
|
||||
# vLLM
|
||||
CLAUDE_CODE_USE_OPENAI=1 \
|
||||
OPENAI_API_KEY=token-abc123 \
|
||||
OPENAI_BASE_URL=http://localhost:8000/v1 \
|
||||
OPENAI_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct \
|
||||
bun run dev
|
||||
|
||||
# One API / LiteLLM
|
||||
CLAUDE_CODE_USE_OPENAI=1 \
|
||||
OPENAI_API_KEY=sk-your-key \
|
||||
OPENAI_BASE_URL=https://your-one-api.example.com/v1 \
|
||||
OPENAI_MODEL=gpt-4o \
|
||||
bun run dev
|
||||
|
||||
# 自定义模型映射(使用家族变量)
|
||||
CLAUDE_CODE_USE_OPENAI=1 \
|
||||
OPENAI_API_KEY=sk-xxx \
|
||||
OPENAI_BASE_URL=https://my-gateway.example.com/v1 \
|
||||
OPENAI_DEFAULT_SONNET_MODEL="gpt-4o-2024-11-20" \
|
||||
OPENAI_DEFAULT_HAIKU_MODEL="gpt-4o-mini" \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
### 请求流程
|
||||
|
||||
```
|
||||
queryModel() [claude.ts]
|
||||
├── 共享预处理(消息归一化、工具过滤、媒体裁剪)
|
||||
└── if (getAPIProvider() === 'openai')
|
||||
└── queryModelOpenAI() [openai/index.ts]
|
||||
├── resolveOpenAIModel() → 解析模型名
|
||||
├── normalizeMessagesForAPI() → 共享消息预处理
|
||||
├── toolToAPISchema() → 构建工具 schema
|
||||
├── anthropicMessagesToOpenAI() → 消息格式转换
|
||||
├── anthropicToolsToOpenAI() → 工具格式转换
|
||||
├── openai.chat.completions.create({ stream: true })
|
||||
└── adaptOpenAIStreamToAnthropic() → 流格式转换
|
||||
├── delta.reasoning_content → thinking 块
|
||||
├── delta.content → text 块
|
||||
├── delta.tool_calls → tool_use 块
|
||||
├── usage.cached_tokens → cache_read_input_tokens
|
||||
└── yield BetaRawMessageStreamEvent
|
||||
```
|
||||
|
||||
### 模型名解析优先级
|
||||
|
||||
`resolveOpenAIModel()` 的解析顺序:
|
||||
|
||||
1. `OPENAI_MODEL` 环境变量 → 直接使用,覆盖所有
|
||||
2. `OPENAI_DEFAULT_{FAMILY}_MODEL` 变量(如 `OPENAI_DEFAULT_SONNET_MODEL`)→ 按模型家族覆盖
|
||||
3. `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` 变量(向后兼容)
|
||||
4. 内置默认映射(见下表)
|
||||
5. 以上都不匹配 → 原名透传
|
||||
|
||||
### 内置模型映射
|
||||
|
||||
| Anthropic 模型 | OpenAI 映射 |
|
||||
|---|---|
|
||||
| `claude-sonnet-4-6` | `gpt-4o` |
|
||||
| `claude-sonnet-4-5-20250929` | `gpt-4o` |
|
||||
| `claude-sonnet-4-20250514` | `gpt-4o` |
|
||||
| `claude-3-7-sonnet-20250219` | `gpt-4o` |
|
||||
| `claude-3-5-sonnet-20241022` | `gpt-4o` |
|
||||
| `claude-opus-4-6` | `o3` |
|
||||
| `claude-opus-4-5-20251101` | `o3` |
|
||||
| `claude-opus-4-1-20250805` | `o3` |
|
||||
| `claude-opus-4-20250514` | `o3` |
|
||||
| `claude-haiku-4-5-20251001` | `gpt-4o-mini` |
|
||||
| `claude-3-5-haiku-20241022` | `gpt-4o-mini` |
|
||||
|
||||
同时会自动剥离 `[1m]` 后缀(Claude 特有的 modifier)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
### 新增文件
|
||||
|
||||
```
|
||||
src/services/api/openai/
|
||||
├── client.ts # OpenAI SDK 客户端工厂(~50 行)
|
||||
├── convertMessages.ts # Anthropic → OpenAI 消息格式转换(~190 行)
|
||||
├── convertTools.ts # Anthropic → OpenAI 工具格式转换(~70 行)
|
||||
├── streamAdapter.ts # SSE 流转换核心,含 thinking + caching(~270 行)
|
||||
├── modelMapping.ts # 模型名解析(~60 行)
|
||||
├── index.ts # 公共入口 queryModelOpenAI()(~110 行)
|
||||
└── __tests__/
|
||||
├── convertMessages.test.ts # 10 个测试
|
||||
├── convertTools.test.ts # 7 个测试
|
||||
├── modelMapping.test.ts # 6 个测试
|
||||
└── streamAdapter.test.ts # 14 个测试(含 thinking + caching)
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/utils/model/providers.ts` | 添加 `'openai'` provider 类型 + `CLAUDE_CODE_USE_OPENAI` 检查(最高优先级) |
|
||||
| `src/utils/model/configs.ts` | 每个 ModelConfig 添加 `openai` 键 |
|
||||
| `src/services/api/claude.ts` | 在 `stripExcessMediaItems()` 后插入 OpenAI 提前返回分支(~8 行) |
|
||||
| `package.json` | 添加 `"openai": "^4.73.0"` 依赖 |
|
||||
|
||||
## 消息转换规则
|
||||
|
||||
### Anthropic → OpenAI
|
||||
|
||||
| Anthropic | OpenAI |
|
||||
|---|---|
|
||||
| `system` prompt(`string[]`) | `role: "system"` 消息(`\n\n` 拼接) |
|
||||
| `user` + `text` 块 | `role: "user"` 消息 |
|
||||
| `assistant` + `text` 块 | `role: "assistant"` + `content` |
|
||||
| `assistant` + `tool_use` 块 | `role: "assistant"` + `tool_calls[]` |
|
||||
| `user` + `tool_result` 块 | `role: "tool"` + `tool_call_id` |
|
||||
| `thinking` 块 | 静默丢弃(请求侧) |
|
||||
|
||||
### 工具转换
|
||||
|
||||
| Anthropic | OpenAI |
|
||||
|---|---|
|
||||
| `{ name, description, input_schema }` | `{ type: "function", function: { name, description, parameters } }` |
|
||||
| `cache_control`, `defer_loading` 等字段 | 剥离 |
|
||||
| `tool_choice: { type: "auto" }` | `"auto"` |
|
||||
| `tool_choice: { type: "any" }` | `"required"` |
|
||||
| `tool_choice: { type: "tool", name }` | `{ type: "function", function: { name } }` |
|
||||
|
||||
### 消息转换示例
|
||||
|
||||
```
|
||||
Anthropic: OpenAI:
|
||||
[
|
||||
system: ["You are helpful."], [
|
||||
{ role: "system",
|
||||
{ role: "user", content: "You are helpful." },
|
||||
content: [ { role: "user",
|
||||
{ type: "text", text: "Run ls" } content: "Run ls"
|
||||
] },
|
||||
}, { role: "assistant",
|
||||
{ role: "assistant", content: "I'll check.",
|
||||
content: [ tool_calls: [{
|
||||
{ type: "text", text: "I'll check."}, id: "tu_123",
|
||||
{ type: "tool_use", type: "function",
|
||||
id: "tu_123", name: "bash", function: {
|
||||
input: { command: "ls" } } name: "bash",
|
||||
] arguments: '{"command":"ls"}'
|
||||
}, }] }
|
||||
{ role: "user", { role: "tool",
|
||||
content: [ tool_call_id: "tu_123",
|
||||
{ type: "tool_result", content: "file1\nfile2"
|
||||
tool_use_id: "tu_123", }
|
||||
content: "file1\nfile2" ]
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 流转换规则
|
||||
|
||||
### SSE Chunk → Anthropic Event 映射
|
||||
|
||||
| OpenAI Chunk | Anthropic Event |
|
||||
|---|---|
|
||||
| 首个 chunk | `message_start`(含 usage) |
|
||||
| `delta.reasoning_content` | `content_block_start(thinking)` + `thinking_delta` |
|
||||
| `delta.content` | `content_block_start(text)` + `text_delta` |
|
||||
| `delta.tool_calls` | `content_block_start(tool_use)` + `input_json_delta` |
|
||||
| `finish_reason: "stop"` | `message_delta(stop_reason: "end_turn")` |
|
||||
| `finish_reason: "tool_calls"` | `message_delta(stop_reason: "tool_use")` |
|
||||
| `finish_reason: "length"` | `message_delta(stop_reason: "max_tokens")` |
|
||||
|
||||
### 块顺序
|
||||
|
||||
当模型返回 `reasoning_content` 时(如 DeepSeek),块顺序与 Anthropic 一致:
|
||||
|
||||
```
|
||||
thinking block (index 0) ← delta.reasoning_content
|
||||
text block (index 1) ← delta.content
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```
|
||||
thinking block (index 0) ← delta.reasoning_content
|
||||
tool_use block (index 1) ← delta.tool_calls
|
||||
```
|
||||
|
||||
无 `reasoning_content` 时:
|
||||
|
||||
```
|
||||
text block (index 0) ← delta.content
|
||||
tool_use block (index 1) ← delta.tool_calls(如果有)
|
||||
```
|
||||
|
||||
### finish_reason 映射
|
||||
|
||||
| OpenAI | Anthropic |
|
||||
|---|---|
|
||||
| `stop` | `end_turn` |
|
||||
| `tool_calls` | `tool_use` |
|
||||
| `length` | `max_tokens` |
|
||||
| `content_filter` | `end_turn` |
|
||||
|
||||
### 事件序列示例
|
||||
|
||||
**纯文本响应**:
|
||||
```
|
||||
OpenAI chunks:
|
||||
delta.content = "Hello"
|
||||
delta.content = " world"
|
||||
finish_reason = "stop"
|
||||
|
||||
→ Anthropic events:
|
||||
message_start { message: { id, role: 'assistant', usage: {...} } }
|
||||
content_block_start { index: 0, content_block: { type: 'text' } }
|
||||
content_block_delta { index: 0, delta: { type: 'text_delta', text: 'Hello' } }
|
||||
content_block_delta { index: 0, delta: { type: 'text_delta', text: ' world' } }
|
||||
content_block_stop { index: 0 }
|
||||
message_delta { delta: { stop_reason: 'end_turn' } }
|
||||
message_stop
|
||||
```
|
||||
|
||||
**Thinking + 文本(DeepSeek 风格)**:
|
||||
```
|
||||
OpenAI chunks:
|
||||
delta.reasoning_content = "Let me think..."
|
||||
delta.reasoning_content = " step by step."
|
||||
delta.content = "The answer is 42."
|
||||
finish_reason = "stop"
|
||||
|
||||
→ Anthropic events:
|
||||
message_start { ... }
|
||||
content_block_start { index: 0, content_block: { type: 'thinking', signature: '' } }
|
||||
content_block_delta { index: 0, delta: { type: 'thinking_delta', thinking: 'Let me think...' } }
|
||||
content_block_delta { index: 0, delta: { type: 'thinking_delta', thinking: ' step by step.' } }
|
||||
content_block_stop { index: 0 }
|
||||
content_block_start { index: 1, content_block: { type: 'text' } }
|
||||
content_block_delta { index: 1, delta: { type: 'text_delta', text: 'The answer is 42.' } }
|
||||
content_block_stop { index: 1 }
|
||||
message_delta { delta: { stop_reason: 'end_turn' } }
|
||||
message_stop
|
||||
```
|
||||
|
||||
**工具调用**:
|
||||
```
|
||||
OpenAI chunks:
|
||||
delta.tool_calls[0] = { id: 'call_xxx', function: { name: 'bash', arguments: '' } }
|
||||
delta.tool_calls[0].function.arguments = '{"comm'
|
||||
delta.tool_calls[0].function.arguments = 'and":"ls"}'
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
→ Anthropic events:
|
||||
message_start { ... }
|
||||
content_block_start { index: 0, content_block: { type: 'tool_use', id: 'call_xxx', name: 'bash' } }
|
||||
content_block_delta { index: 0, delta: { type: 'input_json_delta', partial_json: '{"comm' } }
|
||||
content_block_delta { index: 0, delta: { type: 'input_json_delta', partial_json: 'and":"ls"}' } }
|
||||
content_block_stop { index: 0 }
|
||||
message_delta { delta: { stop_reason: 'tool_use' } }
|
||||
message_stop
|
||||
```
|
||||
|
||||
## 功能支持
|
||||
|
||||
### Thinking(思维链)
|
||||
|
||||
**请求侧**:不需要显式配置。支持思维链的模型(DeepSeek 等)会自动返回 `delta.reasoning_content`。
|
||||
|
||||
**响应侧**:`delta.reasoning_content` 被转换为 Anthropic `thinking` content block:
|
||||
|
||||
```ts
|
||||
// content_block_start
|
||||
{ type: 'content_block_start', index: 0,
|
||||
content_block: { type: 'thinking', thinking: '', signature: '' } }
|
||||
|
||||
// content_block_delta
|
||||
{ type: 'content_block_delta', index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: 'Let me analyze...' } }
|
||||
```
|
||||
|
||||
thinking block 在 text/tool_use block 之前自动关闭,保持 Anthropic 的块顺序。
|
||||
|
||||
### Prompt Caching
|
||||
|
||||
**请求侧**:OpenAI 端点使用自动缓存,无需显式设置 `cache_control`。
|
||||
|
||||
**响应侧**:OpenAI 的 `usage.prompt_tokens_details.cached_tokens` 被映射到 Anthropic 的 `cache_read_input_tokens`:
|
||||
|
||||
```
|
||||
OpenAI: usage.prompt_tokens_details.cached_tokens = 800
|
||||
↓
|
||||
Anthropic: message_start.message.usage.cache_read_input_tokens = 800
|
||||
```
|
||||
|
||||
在 `message_start` 的 usage 中报告缓存命中量。
|
||||
|
||||
### 工具调用(Tool Use)
|
||||
|
||||
完整支持 OpenAI function calling 格式。所有本地工具(Bash、FileEdit、Grep、Glob、Agent 等)透明工作——它们通过 JSON 输入输出通信,格式无关。
|
||||
|
||||
工具参数以 `input_json_delta` 形式流式传输,由下游代码拼接解析。
|
||||
|
||||
### 不支持的功能
|
||||
|
||||
| 功能 | 策略 |
|
||||
|---|---|
|
||||
| Beta Headers | 不发送 |
|
||||
| Server Tools (advisor) | 不发送 |
|
||||
| Structured Output | 不发送 |
|
||||
| Fast Mode / Effort | 不发送 |
|
||||
| Tool Search / defer_loading | 不启用,所有工具直接发送 |
|
||||
| Anthropic Signature | thinking block 的 `signature` 字段为空字符串 |
|
||||
| cache_creation_input_tokens | 始终为 0(OpenAI 不区分创建/读取) |
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 运行所有 OpenAI 适配层测试
|
||||
bun test src/services/api/openai/__tests__/
|
||||
|
||||
# 单独运行
|
||||
bun test src/services/api/openai/__tests__/streamAdapter.test.ts # 14 tests(含 thinking + caching)
|
||||
bun test src/services/api/openai/__tests__/convertMessages.test.ts # 10 tests
|
||||
bun test src/services/api/openai/__tests__/convertTools.test.ts # 7 tests
|
||||
bun test src/services/api/openai/__tests__/modelMapping.test.ts # 6 tests
|
||||
```
|
||||
|
||||
当前测试覆盖:**39 tests / 73 assertions / 0 fail**。
|
||||
|
||||
### 测试覆盖矩阵
|
||||
|
||||
| 功能 | convertMessages | convertTools | streamAdapter | modelMapping |
|
||||
|---|---|---|---|---|
|
||||
| 文本消息转换 | ✅ | | | |
|
||||
| tool_use 转换 | ✅ | | | |
|
||||
| tool_result 转换 | ✅ | | | |
|
||||
| thinking 剥离 | ✅ | | | |
|
||||
| 完整对话流程 | ✅ | | | |
|
||||
| 工具 schema 转换 | | ✅ | | |
|
||||
| tool_choice 映射 | | ✅ | | |
|
||||
| 纯文本流 | | | ✅ | |
|
||||
| 工具调用流 | | | ✅ | |
|
||||
| 混合文本+工具 | | | ✅ | |
|
||||
| finish_reason 映射 | | | ✅ | |
|
||||
| thinking 流 | | | ✅ | |
|
||||
| thinking+text 切换 | | | ✅ | |
|
||||
| thinking+tool_use 切换 | | | ✅ | |
|
||||
| 块索引正确性 | | | ✅ | |
|
||||
| cached_tokens 映射 | | | ✅ | |
|
||||
| OPENAI_MODEL 覆盖 | | | | ✅ |
|
||||
| 默认模型映射 | | | | ✅ |
|
||||
| 未知模型透传 | | | | ✅ |
|
||||
| [1m] 后缀剥离 | | | | ✅ |
|
||||
|
||||
## 端到端验证
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
bun install
|
||||
|
||||
# 2. 运行单元测试
|
||||
bun test src/services/api/openai/__tests__/
|
||||
|
||||
# 3. 连接实际端点(以 Ollama 为例)
|
||||
CLAUDE_CODE_USE_OPENAI=1 \
|
||||
OPENAI_API_KEY=ollama \
|
||||
OPENAI_BASE_URL=http://localhost:11434/v1 \
|
||||
OPENAI_MODEL=qwen2.5-coder-32b \
|
||||
bun run dev
|
||||
|
||||
# 4. 连接 DeepSeek(测试 thinking 支持)
|
||||
CLAUDE_CODE_USE_OPENAI=1 \
|
||||
OPENAI_API_KEY=sk-xxx \
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1 \
|
||||
OPENAI_MODEL=deepseek-reasoner \
|
||||
bun run dev
|
||||
|
||||
# 5. 确认现有测试不受影响
|
||||
bun test # 无 CLAUDE_CODE_USE_OPENAI 时走原有路径
|
||||
```
|
||||
|
||||
## 代码统计
|
||||
|
||||
| 类别 | 行数 |
|
||||
|---|---|
|
||||
| 新增源码 | ~620 行 |
|
||||
| 新增测试 | ~450 行 |
|
||||
| 改动现有代码 | ~25 行 |
|
||||
| **总计** | **~1100 行** |
|
||||
@@ -1,35 +0,0 @@
|
||||
# 社区项目 & Blog 合集
|
||||
|
||||
> 每日更新,欢迎自荐!
|
||||
|
||||
## 工具 & 应用
|
||||
|
||||
| 项目 | 描述 | 作者 |
|
||||
|------|------|------|
|
||||
| [4qtask.vercel.app](https://4qtask.vercel.app/) | 免费四象限时间管理工具 | @kevinhuky |
|
||||
| [kaying.studio](https://kaying.studio/) | 个人 AI 工具箱 | @kayingai |
|
||||
| [supsub.ai](https://supsub.ai/) | 高效阅读工具 | @hidumou |
|
||||
| [x-video-download.net](https://x-video-download.net/) | 视频下载工具 | @syakadou |
|
||||
| [1openapi.com](https://1openapi.com/) | API 中转站 | @thinker007 |
|
||||
| [claw-z.com](https://claw-z.com/) | 一键部署 OpenClaw AI Agent(场景驱动、全面管理) | @uhhc |
|
||||
| [gemini-watermark-remover.net](https://gemini-watermark-remover.net/) | Gemini 水印移除工具 | @syakadou |
|
||||
|
||||
## GitHub 开源项目
|
||||
|
||||
| 项目 | 描述 | 作者 |
|
||||
|------|------|------|
|
||||
| [VersperClaw](https://github.com/versperai/VersperClaw) | 全自动科研流 | @versperai |
|
||||
| [claude-reviews-claude](https://github.com/openedclaude/claude-reviews-claude) | 原汤化原食——Claude 如何看待眼中的老己 | @openedclaude |
|
||||
| [agentica](https://github.com/shibing624/agentica) | 自研 Agent 框架,借鉴 claude-code 多 Agent 处理 | @shibing624 |
|
||||
| [macman](https://github.com/tonngw/macman) | Mac 从 0 到 1 保姆级配置教程 | @tonngw |
|
||||
| [SuperSpec](https://github.com/asasugar/SuperSpec) | SDD / Spec-Driven Development | @asasugar |
|
||||
| [adnify](https://github.com/adnaan-worker/adnify) | 高颜值高定制化 AI 编辑器 | @adnaan-worker |
|
||||
| [another-rule-engine](https://github.com/eatmoreduck/another-rule-engine) | 基于 Groovy 的开源多功能决策引擎 | @eatmoreduck |
|
||||
| [creative_master](https://github.com/chatabc/creative_master) | AI 驱动的创意灵感管理工具 | @chatabc |
|
||||
| [RapidDoc](https://github.com/RapidAI/RapidDoc) | Office 文件解析工具转 Markdown(支持 PDF/Image/Word/PPT/Excel) | @hzkitt |
|
||||
|
||||
## Blog
|
||||
|
||||
| 链接 | 作者 |
|
||||
|------|------|
|
||||
| [blog.xiaohuangyu.space](https://blog.xiaohuangyu.space/) | @eatmoreduck |
|
||||
@@ -1,147 +0,0 @@
|
||||
# Tool 系统测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
Tool 系统是 Claude Code 的核心,负责工具的定义、注册、发现和过滤。本计划覆盖 `src/Tool.ts` 中的工具接口与工具函数、`src/tools.ts` 中的注册/过滤逻辑,以及各工具目录下可独立测试的纯函数。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/Tool.ts` | `buildTool`, `toolMatchesName`, `findToolByName`, `getEmptyToolPermissionContext`, `filterToolProgressMessages` |
|
||||
| `src/tools.ts` | `parseToolPreset`, `filterToolsByDenyRules`, `getAllBaseTools`, `getTools`, `assembleToolPool` |
|
||||
| `src/tools/shared/gitOperationTracking.ts` | `parseGitCommitId`, `detectGitOperation` |
|
||||
| `src/tools/shared/spawnMultiAgent.ts` | `resolveTeammateModel`, `generateUniqueTeammateName` |
|
||||
| `src/tools/GrepTool/GrepTool.ts` | `applyHeadLimit`, `formatLimitInfo`(内部辅助函数) |
|
||||
| `src/tools/FileEditTool/utils.ts` | 字符串匹配/补丁相关纯函数 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/Tool.ts
|
||||
|
||||
#### describe('buildTool')
|
||||
|
||||
- test('fills in default isEnabled as true') — 不传 isEnabled 时,构建的 tool.isEnabled() 应返回 true
|
||||
- test('fills in default isConcurrencySafe as false') — 默认值应为 false(fail-closed)
|
||||
- test('fills in default isReadOnly as false') — 默认假设有写操作
|
||||
- test('fills in default isDestructive as false') — 默认非破坏性
|
||||
- test('fills in default checkPermissions as allow') — 默认 checkPermissions 应返回 `{ behavior: 'allow', updatedInput }`
|
||||
- test('fills in default userFacingName from tool name') — userFacingName 默认应返回 tool.name
|
||||
- test('preserves explicitly provided methods') — 传入自定义 isEnabled 等方法时应覆盖默认值
|
||||
- test('preserves all non-defaultable properties') — name, inputSchema, call, description 等属性原样保留
|
||||
|
||||
#### describe('toolMatchesName')
|
||||
|
||||
- test('returns true for exact name match') — `{ name: 'Bash' }` 匹配 'Bash'
|
||||
- test('returns false for non-matching name') — `{ name: 'Bash' }` 不匹配 'Read'
|
||||
- test('returns true when name matches an alias') — `{ name: 'Bash', aliases: ['BashTool'] }` 匹配 'BashTool'
|
||||
- test('returns false when aliases is undefined') — `{ name: 'Bash' }` 不匹配 'BashTool'
|
||||
- test('returns false when aliases is empty') — `{ name: 'Bash', aliases: [] }` 不匹配 'BashTool'
|
||||
|
||||
#### describe('findToolByName')
|
||||
|
||||
- test('finds tool by primary name') — 从 tools 列表中按 name 找到工具
|
||||
- test('finds tool by alias') — 从 tools 列表中按 alias 找到工具
|
||||
- test('returns undefined when no match') — 找不到时返回 undefined
|
||||
- test('returns first match when duplicates exist') — 多个同名工具时返回第一个
|
||||
|
||||
#### describe('getEmptyToolPermissionContext')
|
||||
|
||||
- test('returns default permission mode') — mode 应为 'default'
|
||||
- test('returns empty maps and arrays') — additionalWorkingDirectories 为空 Map,rules 为空对象
|
||||
- test('returns isBypassPermissionsModeAvailable as false')
|
||||
|
||||
#### describe('filterToolProgressMessages')
|
||||
|
||||
- test('filters out hook_progress messages') — 移除 type 为 hook_progress 的消息
|
||||
- test('keeps tool progress messages') — 保留非 hook_progress 的消息
|
||||
- test('returns empty array for empty input')
|
||||
- test('handles messages without type field') — data 不含 type 时应保留
|
||||
|
||||
---
|
||||
|
||||
### src/tools.ts
|
||||
|
||||
#### describe('parseToolPreset')
|
||||
|
||||
- test('returns "default" for "default" input') — 精确匹配
|
||||
- test('returns "default" for "Default" input') — 大小写不敏感
|
||||
- test('returns null for unknown preset') — 未知字符串返回 null
|
||||
- test('returns null for empty string')
|
||||
|
||||
#### describe('filterToolsByDenyRules')
|
||||
|
||||
- test('returns all tools when no deny rules') — 空 deny 规则不过滤任何工具
|
||||
- test('filters out tools matching blanket deny rule') — deny rule `{ toolName: 'Bash' }` 应移除 Bash
|
||||
- test('does not filter tools with content-specific deny rules') — deny rule `{ toolName: 'Bash', ruleContent: 'rm -rf' }` 不移除 Bash(只在运行时阻止特定命令)
|
||||
- test('filters MCP tools by server name prefix') — deny rule `mcp__server` 应移除该 server 下所有工具
|
||||
- test('preserves tools not matching any deny rule')
|
||||
|
||||
#### describe('getAllBaseTools')
|
||||
|
||||
- test('returns a non-empty array of tools') — 至少包含核心工具
|
||||
- test('each tool has required properties') — 每个工具应有 name, inputSchema, call 等属性
|
||||
- test('includes BashTool, FileReadTool, FileEditTool') — 核心工具始终存在
|
||||
- test('includes TestingPermissionTool when NODE_ENV is test') — 需设置 env
|
||||
|
||||
#### describe('getTools')
|
||||
|
||||
- test('returns filtered tools based on permission context') — 根据 deny rules 过滤
|
||||
- test('returns simple tools in CLAUDE_CODE_SIMPLE mode') — 仅返回 Bash/Read/Edit
|
||||
- test('filters disabled tools via isEnabled') — isEnabled 返回 false 的工具被排除
|
||||
|
||||
---
|
||||
|
||||
### src/tools/shared/gitOperationTracking.ts
|
||||
|
||||
#### describe('parseGitCommitId')
|
||||
|
||||
- test('extracts commit hash from git commit output') — 从 `[main abc1234] message` 中提取 `abc1234`
|
||||
- test('returns null for non-commit output') — 无法解析时返回 null
|
||||
- test('handles various branch name formats') — `[feature/foo abc1234]` 等
|
||||
|
||||
#### describe('detectGitOperation')
|
||||
|
||||
- test('detects git commit operation') — 命令含 `git commit` 时识别为 commit
|
||||
- test('detects git push operation') — 命令含 `git push` 时识别
|
||||
- test('returns null for non-git commands') — 非 git 命令返回 null
|
||||
- test('detects git merge operation')
|
||||
- test('detects git rebase operation')
|
||||
|
||||
---
|
||||
|
||||
### src/tools/shared/spawnMultiAgent.ts
|
||||
|
||||
#### describe('resolveTeammateModel')
|
||||
|
||||
- test('returns specified model when provided')
|
||||
- test('falls back to default model when not specified')
|
||||
|
||||
#### describe('generateUniqueTeammateName')
|
||||
|
||||
- test('generates a name when no existing names') — 无冲突时返回基础名
|
||||
- test('appends suffix when name conflicts') — 与已有名称冲突时添加后缀
|
||||
- test('handles multiple conflicts') — 多次冲突时递增后缀
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `bun:bundle` (feature) | 已 polyfill 为 `() => false` | 不需额外 mock |
|
||||
| `process.env` | `bun:test` mock | 测试 `USER_TYPE`、`NODE_ENV`、`CLAUDE_CODE_SIMPLE` |
|
||||
| `getDenyRuleForTool` | mock module | `filterToolsByDenyRules` 测试中需控制返回值 |
|
||||
| `isToolSearchEnabledOptimistic` | mock module | `getAllBaseTools` 中条件加载 |
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
放在 `tests/integration/tool-chain.test.ts`:
|
||||
|
||||
### describe('Tool registration and discovery')
|
||||
|
||||
- test('getAllBaseTools returns tools that can be found by findToolByName') — 注册 → 查找完整链路
|
||||
- test('filterToolsByDenyRules + getTools produces consistent results') — 过滤管线一致性
|
||||
- test('assembleToolPool deduplicates built-in and MCP tools') — 合并去重逻辑
|
||||
@@ -1,416 +0,0 @@
|
||||
# 工具函数(纯函数)测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
覆盖 `src/utils/` 下所有可独立单元测试的纯函数。这些函数无外部依赖,输入输出确定性强,是测试金字塔的底层基石。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 状态 | 关键导出 |
|
||||
|------|------|----------|
|
||||
| `src/utils/array.ts` | **已有测试** | intersperse, count, uniq |
|
||||
| `src/utils/set.ts` | **已有测试** | difference, intersects, every, union |
|
||||
| `src/utils/xml.ts` | 待测 | escapeXml, escapeXmlAttr |
|
||||
| `src/utils/hash.ts` | 待测 | djb2Hash, hashContent, hashPair |
|
||||
| `src/utils/stringUtils.ts` | 待测 | escapeRegExp, capitalize, plural, firstLineOf, countCharInString, normalizeFullWidthDigits, normalizeFullWidthSpace, safeJoinLines, truncateToLines, EndTruncatingAccumulator |
|
||||
| `src/utils/semver.ts` | 待测 | gt, gte, lt, lte, satisfies, order |
|
||||
| `src/utils/uuid.ts` | 待测 | validateUuid, createAgentId |
|
||||
| `src/utils/format.ts` | 待测 | formatFileSize, formatSecondsShort, formatDuration, formatNumber, formatTokens, formatRelativeTime, formatRelativeTimeAgo |
|
||||
| `src/utils/json.ts` | 待测 | safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray |
|
||||
| `src/utils/truncate.ts` | 待测 | truncatePathMiddle, truncateToWidth, truncateStartToWidth, truncateToWidthNoEllipsis, truncate, wrapText |
|
||||
| `src/utils/diff.ts` | 待测 | adjustHunkLineNumbers, getPatchFromContents |
|
||||
| `src/utils/frontmatterParser.ts` | 待测 | parseFrontmatter, splitPathInFrontmatter, parsePositiveIntFromFrontmatter, parseBooleanFrontmatter, parseShellFrontmatter |
|
||||
| `src/utils/file.ts` | 待测(纯函数部分) | convertLeadingTabsToSpaces, addLineNumbers, stripLineNumberPrefix, pathsEqual, normalizePathForComparison |
|
||||
| `src/utils/glob.ts` | 待测(纯函数部分) | extractGlobBaseDirectory |
|
||||
| `src/utils/tokens.ts` | 待测 | getTokenCountFromUsage |
|
||||
| `src/utils/path.ts` | 待测(纯函数部分) | containsPathTraversal, normalizePathForConfigKey |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/xml.ts — 测试文件: `src/utils/__tests__/xml.test.ts`
|
||||
|
||||
#### describe('escapeXml')
|
||||
|
||||
- test('escapes ampersand') — `&` → `&`
|
||||
- test('escapes less-than') — `<` → `<`
|
||||
- test('escapes greater-than') — `>` → `>`
|
||||
- test('does not escape quotes') — `"` 和 `'` 保持原样
|
||||
- test('handles empty string') — `""` → `""`
|
||||
- test('handles string with no special chars') — `"hello"` 原样返回
|
||||
- test('escapes multiple special chars in one string') — `<a & b>` → `<a & b>`
|
||||
|
||||
#### describe('escapeXmlAttr')
|
||||
|
||||
- test('escapes all xml chars plus quotes') — `"` → `"`, `'` → `'`
|
||||
- test('escapes double quotes') — `he said "hi"` 正确转义
|
||||
- test('escapes single quotes') — `it's` 正确转义
|
||||
|
||||
---
|
||||
|
||||
### src/utils/hash.ts — 测试文件: `src/utils/__tests__/hash.test.ts`
|
||||
|
||||
#### describe('djb2Hash')
|
||||
|
||||
- test('returns consistent hash for same input') — 相同输入返回相同结果
|
||||
- test('returns different hashes for different inputs') — 不同输入大概率不同
|
||||
- test('returns a 32-bit integer') — 结果在 int32 范围内
|
||||
- test('handles empty string') — 空字符串有确定的哈希值
|
||||
- test('handles unicode strings') — 中文/emoji 等正确处理
|
||||
|
||||
#### describe('hashContent')
|
||||
|
||||
- test('returns consistent hash for same content') — 确定性
|
||||
- test('returns string result') — 返回值为字符串
|
||||
|
||||
#### describe('hashPair')
|
||||
|
||||
- test('returns consistent hash for same pair') — 确定性
|
||||
- test('order matters') — hashPair(a, b) ≠ hashPair(b, a)
|
||||
- test('handles empty strings')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/stringUtils.ts — 测试文件: `src/utils/__tests__/stringUtils.test.ts`
|
||||
|
||||
#### describe('escapeRegExp')
|
||||
|
||||
- test('escapes dots') — `.` → `\\.`
|
||||
- test('escapes asterisks') — `*` → `\\*`
|
||||
- test('escapes brackets') — `[` → `\\[`
|
||||
- test('escapes all special chars') — `.*+?^${}()|[]\` 全部转义
|
||||
- test('leaves normal chars unchanged') — `hello` 原样
|
||||
- test('escaped string works in RegExp') — `new RegExp(escapeRegExp('a.b'))` 精确匹配 `a.b`
|
||||
|
||||
#### describe('capitalize')
|
||||
|
||||
- test('uppercases first char') — `"foo"` → `"Foo"`
|
||||
- test('does NOT lowercase rest') — `"fooBar"` → `"FooBar"`(区别于 lodash capitalize)
|
||||
- test('handles single char') — `"a"` → `"A"`
|
||||
- test('handles empty string') — `""` → `""`
|
||||
- test('handles already capitalized') — `"Foo"` → `"Foo"`
|
||||
|
||||
#### describe('plural')
|
||||
|
||||
- test('returns singular for n=1') — `plural(1, 'file')` → `'file'`
|
||||
- test('returns plural for n=0') — `plural(0, 'file')` → `'files'`
|
||||
- test('returns plural for n>1') — `plural(3, 'file')` → `'files'`
|
||||
- test('uses custom plural form') — `plural(2, 'entry', 'entries')` → `'entries'`
|
||||
|
||||
#### describe('firstLineOf')
|
||||
|
||||
- test('returns first line of multi-line string') — `"a\nb\nc"` → `"a"`
|
||||
- test('returns full string when no newline') — `"hello"` → `"hello"`
|
||||
- test('handles empty string') — `""` → `""`
|
||||
- test('handles string starting with newline') — `"\nhello"` → `""`
|
||||
|
||||
#### describe('countCharInString')
|
||||
|
||||
- test('counts occurrences') — `countCharInString("aabac", "a")` → `3`
|
||||
- test('returns 0 when char not found') — `countCharInString("hello", "x")` → `0`
|
||||
- test('handles empty string') — `countCharInString("", "a")` → `0`
|
||||
- test('respects start position') — `countCharInString("aaba", "a", 2)` → `1`
|
||||
|
||||
#### describe('normalizeFullWidthDigits')
|
||||
|
||||
- test('converts full-width digits to half-width') — `"0123"` → `"0123"`
|
||||
- test('leaves half-width digits unchanged') — `"0123"` → `"0123"`
|
||||
- test('mixed content') — `"port 8080"` → `"port 8080"`
|
||||
|
||||
#### describe('normalizeFullWidthSpace')
|
||||
|
||||
- test('converts ideographic space to regular space') — `"\u3000"` → `" "`
|
||||
- test('converts multiple spaces') — `"a\u3000b\u3000c"` → `"a b c"`
|
||||
|
||||
#### describe('safeJoinLines')
|
||||
|
||||
- test('joins lines with default delimiter') — `["a","b"]` → `"a,b"`
|
||||
- test('truncates when exceeding maxSize') — 超限时截断并添加 `...[truncated]`
|
||||
- test('handles empty array') — `[]` → `""`
|
||||
- test('uses custom delimiter') — delimiter 为 `"\n"` 时按行连接
|
||||
|
||||
#### describe('truncateToLines')
|
||||
|
||||
- test('returns full text when within limit') — 行数不超限时原样返回
|
||||
- test('truncates and adds ellipsis') — 超限时截断并加 `…`
|
||||
- test('handles exact limit') — 刚好等于 maxLines 时不截断
|
||||
- test('handles single line') — 单行文本不截断
|
||||
|
||||
#### describe('EndTruncatingAccumulator')
|
||||
|
||||
- test('accumulates strings normally within limit')
|
||||
- test('truncates when exceeding maxSize')
|
||||
- test('reports truncated status correctly')
|
||||
- test('reports totalBytes including truncated content')
|
||||
- test('toString includes truncation marker')
|
||||
- test('clear resets all state')
|
||||
- test('append with Buffer works') — 接受 Buffer 类型
|
||||
|
||||
---
|
||||
|
||||
### src/utils/semver.ts — 测试文件: `src/utils/__tests__/semver.test.ts`
|
||||
|
||||
#### describe('gt / gte / lt / lte')
|
||||
|
||||
- test('gt: 2.0.0 > 1.0.0') → true
|
||||
- test('gt: 1.0.0 > 1.0.0') → false
|
||||
- test('gte: 1.0.0 >= 1.0.0') → true
|
||||
- test('lt: 1.0.0 < 2.0.0') → true
|
||||
- test('lte: 1.0.0 <= 1.0.0') → true
|
||||
- test('handles pre-release versions') — `1.0.0-beta < 1.0.0`
|
||||
|
||||
#### describe('satisfies')
|
||||
|
||||
- test('version satisfies caret range') — `satisfies('1.2.3', '^1.0.0')` → true
|
||||
- test('version does not satisfy range') — `satisfies('2.0.0', '^1.0.0')` → false
|
||||
- test('exact match') — `satisfies('1.0.0', '1.0.0')` → true
|
||||
|
||||
#### describe('order')
|
||||
|
||||
- test('returns -1 for lesser') — `order('1.0.0', '2.0.0')` → -1
|
||||
- test('returns 0 for equal') — `order('1.0.0', '1.0.0')` → 0
|
||||
- test('returns 1 for greater') — `order('2.0.0', '1.0.0')` → 1
|
||||
|
||||
---
|
||||
|
||||
### src/utils/uuid.ts — 测试文件: `src/utils/__tests__/uuid.test.ts`
|
||||
|
||||
#### describe('validateUuid')
|
||||
|
||||
- test('accepts valid v4 UUID') — `'550e8400-e29b-41d4-a716-446655440000'` → 返回 UUID
|
||||
- test('returns null for invalid format') — `'not-a-uuid'` → null
|
||||
- test('returns null for empty string') — `''` → null
|
||||
- test('returns null for null/undefined input')
|
||||
- test('accepts uppercase UUIDs') — 大写字母有效
|
||||
|
||||
#### describe('createAgentId')
|
||||
|
||||
- test('returns string starting with "a"') — 前缀为 `a`
|
||||
- test('has correct length') — 前缀 + 16 hex 字符
|
||||
- test('generates unique ids') — 连续两次调用结果不同
|
||||
|
||||
---
|
||||
|
||||
### src/utils/format.ts — 测试文件: `src/utils/__tests__/format.test.ts`
|
||||
|
||||
#### describe('formatFileSize')
|
||||
|
||||
- test('formats bytes') — `500` → `"500 bytes"`
|
||||
- test('formats kilobytes') — `1536` → `"1.5KB"`
|
||||
- test('formats megabytes') — `1572864` → `"1.5MB"`
|
||||
- test('formats gigabytes') — `1610612736` → `"1.5GB"`
|
||||
- test('removes trailing .0') — `1024` → `"1KB"` (不是 `"1.0KB"`)
|
||||
|
||||
#### describe('formatSecondsShort')
|
||||
|
||||
- test('formats milliseconds to seconds') — `1234` → `"1.2s"`
|
||||
- test('formats zero') — `0` → `"0.0s"`
|
||||
|
||||
#### describe('formatDuration')
|
||||
|
||||
- test('formats seconds') — `5000` → `"5s"`
|
||||
- test('formats minutes and seconds') — `65000` → `"1m 5s"`
|
||||
- test('formats hours') — `3661000` → `"1h 1m 1s"`
|
||||
- test('formats days') — `90061000` → `"1d 1h 1m"`
|
||||
- test('returns "0s" for zero') — `0` → `"0s"`
|
||||
- test('hideTrailingZeros omits zero components') — `3600000` + `hideTrailingZeros` → `"1h"`
|
||||
- test('mostSignificantOnly returns largest unit') — `3661000` + `mostSignificantOnly` → `"1h"`
|
||||
|
||||
#### describe('formatNumber')
|
||||
|
||||
- test('formats thousands') — `1321` → `"1.3k"`
|
||||
- test('formats small numbers as-is') — `900` → `"900"`
|
||||
- test('lowercase output') — `1500` → `"1.5k"` (不是 `"1.5K"`)
|
||||
|
||||
#### describe('formatTokens')
|
||||
|
||||
- test('strips .0 suffix') — `1000` → `"1k"` (不是 `"1.0k"`)
|
||||
- test('keeps non-zero decimal') — `1500` → `"1.5k"`
|
||||
|
||||
#### describe('formatRelativeTime')
|
||||
|
||||
- test('formats past time') — now - 3600s → `"1h ago"` (narrow style)
|
||||
- test('formats future time') — now + 3600s → `"in 1h"` (narrow style)
|
||||
- test('formats less than 1 second') — now → `"0s ago"`
|
||||
- test('uses custom now parameter for deterministic output')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/json.ts — 测试文件: `src/utils/__tests__/json.test.ts`
|
||||
|
||||
#### describe('safeParseJSON')
|
||||
|
||||
- test('parses valid JSON') — `'{"a":1}'` → `{ a: 1 }`
|
||||
- test('returns null for invalid JSON') — `'not json'` → null
|
||||
- test('returns null for null input') — `null` → null
|
||||
- test('returns null for undefined input') — `undefined` → null
|
||||
- test('returns null for empty string') — `""` → null
|
||||
- test('handles JSON with BOM') — BOM 前缀不影响解析
|
||||
- test('caches results for repeated calls') — 同一输入不重复解析
|
||||
|
||||
#### describe('safeParseJSONC')
|
||||
|
||||
- test('parses JSON with comments') — 含 `//` 注释的 JSON 正确解析
|
||||
- test('parses JSON with trailing commas') — 宽松模式
|
||||
- test('returns null for invalid input')
|
||||
- test('returns null for null input')
|
||||
|
||||
#### describe('parseJSONL')
|
||||
|
||||
- test('parses multiple JSON lines') — `'{"a":1}\n{"b":2}'` → `[{a:1}, {b:2}]`
|
||||
- test('skips malformed lines') — 含错误行时跳过该行
|
||||
- test('handles empty input') — `""` → `[]`
|
||||
- test('handles trailing newline') — 尾部换行不产生空元素
|
||||
- test('accepts Buffer input') — Buffer 类型同样工作
|
||||
- test('handles BOM prefix')
|
||||
|
||||
#### describe('addItemToJSONCArray')
|
||||
|
||||
- test('adds item to existing array') — `[1, 2]` + 3 → `[1, 2, 3]`
|
||||
- test('creates new array for empty content') — `""` + item → `[item]`
|
||||
- test('creates new array for non-array content') — `'"hello"'` + item → `[item]`
|
||||
- test('preserves comments in JSONC') — 注释不被丢弃
|
||||
- test('handles empty array') — `"[]"` + item → `[item]`
|
||||
|
||||
---
|
||||
|
||||
### src/utils/diff.ts — 测试文件: `src/utils/__tests__/diff.test.ts`
|
||||
|
||||
#### describe('adjustHunkLineNumbers')
|
||||
|
||||
- test('shifts line numbers by positive offset') — 所有 hunk 的 oldStart/newStart 增加 offset
|
||||
- test('shifts by negative offset') — 负 offset 减少行号
|
||||
- test('handles empty hunk array') — `[]` → `[]`
|
||||
|
||||
#### describe('getPatchFromContents')
|
||||
|
||||
- test('returns empty array for identical content') — 相同内容无差异
|
||||
- test('detects added lines') — 新内容多出行
|
||||
- test('detects removed lines') — 旧内容缺少行
|
||||
- test('detects modified lines') — 行内容变化
|
||||
- test('handles empty old content') — 从空文件到有内容
|
||||
- test('handles empty new content') — 删除所有内容
|
||||
|
||||
---
|
||||
|
||||
### src/utils/frontmatterParser.ts — 测试文件: `src/utils/__tests__/frontmatterParser.test.ts`
|
||||
|
||||
#### describe('parseFrontmatter')
|
||||
|
||||
- test('extracts YAML frontmatter between --- delimiters') — 正确提取 frontmatter 并返回 body
|
||||
- test('returns empty frontmatter for content without ---') — 无 frontmatter 时 data 为空
|
||||
- test('handles empty content') — `""` 正确处理
|
||||
- test('handles frontmatter-only content') — 只有 frontmatter 无 body
|
||||
- test('falls back to quoting on YAML parse error') — 无效 YAML 不崩溃
|
||||
|
||||
#### describe('splitPathInFrontmatter')
|
||||
|
||||
- test('splits comma-separated paths') — `"a.ts, b.ts"` → `["a.ts", "b.ts"]`
|
||||
- test('expands brace patterns') — `"*.{ts,tsx}"` → `["*.ts", "*.tsx"]`
|
||||
- test('handles string array input') — `["a.ts", "b.ts"]` → `["a.ts", "b.ts"]`
|
||||
- test('respects braces in comma splitting') — 大括号内的逗号不作为分隔符
|
||||
|
||||
#### describe('parsePositiveIntFromFrontmatter')
|
||||
|
||||
- test('returns number for valid positive int') — `5` → `5`
|
||||
- test('returns undefined for negative') — `-1` → undefined
|
||||
- test('returns undefined for non-number') — `"abc"` → undefined
|
||||
- test('returns undefined for float') — `1.5` → undefined
|
||||
|
||||
#### describe('parseBooleanFrontmatter')
|
||||
|
||||
- test('returns true for true') — `true` → true
|
||||
- test('returns true for "true"') — `"true"` → true
|
||||
- test('returns false for false') — `false` → false
|
||||
- test('returns false for other values') — `"yes"`, `1` → false
|
||||
|
||||
#### describe('parseShellFrontmatter')
|
||||
|
||||
- test('returns bash for "bash"') — 正确识别
|
||||
- test('returns powershell for "powershell"')
|
||||
- test('returns undefined for invalid value') — `"zsh"` → undefined
|
||||
|
||||
---
|
||||
|
||||
### src/utils/file.ts(纯函数部分)— 测试文件: `src/utils/__tests__/file.test.ts`
|
||||
|
||||
#### describe('convertLeadingTabsToSpaces')
|
||||
|
||||
- test('converts single tab to 2 spaces') — `"\thello"` → `" hello"`
|
||||
- test('converts multiple leading tabs') — `"\t\thello"` → `" hello"`
|
||||
- test('does not convert tabs within line') — `"a\tb"` 保持原样
|
||||
- test('handles mixed content')
|
||||
|
||||
#### describe('addLineNumbers')
|
||||
|
||||
- test('adds line numbers starting from 1') — 每行添加 `N\t` 前缀
|
||||
- test('respects startLine parameter') — 从指定行号开始
|
||||
- test('handles empty content')
|
||||
|
||||
#### describe('stripLineNumberPrefix')
|
||||
|
||||
- test('strips tab-prefixed line number') — `"1\thello"` → `"hello"`
|
||||
- test('strips padded line number') — `" 1\thello"` → `"hello"`
|
||||
- test('returns line unchanged when no prefix')
|
||||
|
||||
#### describe('pathsEqual')
|
||||
|
||||
- test('returns true for identical paths')
|
||||
- test('handles trailing slashes') — 带/不带尾部斜杠视为相同
|
||||
- test('handles case sensitivity based on platform')
|
||||
|
||||
#### describe('normalizePathForComparison')
|
||||
|
||||
- test('normalizes forward slashes')
|
||||
- test('resolves path for comparison')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/glob.ts(纯函数部分)— 测试文件: `src/utils/__tests__/glob.test.ts`
|
||||
|
||||
#### describe('extractGlobBaseDirectory')
|
||||
|
||||
- test('extracts static prefix from glob') — `"src/**/*.ts"` → `{ baseDir: "src", relativePattern: "**/*.ts" }`
|
||||
- test('handles root-level glob') — `"*.ts"` → `{ baseDir: ".", relativePattern: "*.ts" }`
|
||||
- test('handles deep static path') — `"src/utils/model/*.ts"` → baseDir 为 `"src/utils/model"`
|
||||
- test('handles Windows drive root') — `"C:\\Users\\**\\*.ts"` 正确分割
|
||||
|
||||
---
|
||||
|
||||
### src/utils/tokens.ts(纯函数部分)— 测试文件: `src/utils/__tests__/tokens.test.ts`
|
||||
|
||||
#### describe('getTokenCountFromUsage')
|
||||
|
||||
- test('sums input and output tokens') — `{ input_tokens: 100, output_tokens: 50 }` → 150
|
||||
- test('includes cache tokens') — cache_creation + cache_read 加入总数
|
||||
- test('handles zero values') — 全 0 时返回 0
|
||||
|
||||
---
|
||||
|
||||
### src/utils/path.ts(纯函数部分)— 测试文件: `src/utils/__tests__/path.test.ts`
|
||||
|
||||
#### describe('containsPathTraversal')
|
||||
|
||||
- test('detects ../ traversal') — `"../etc/passwd"` → true
|
||||
- test('detects mid-path traversal') — `"foo/../../bar"` → true
|
||||
- test('returns false for safe paths') — `"src/utils/file.ts"` → false
|
||||
- test('returns false for paths containing .. in names') — `"foo..bar"` → false
|
||||
|
||||
#### describe('normalizePathForConfigKey')
|
||||
|
||||
- test('converts backslashes to forward slashes') — `"src\\utils"` → `"src/utils"`
|
||||
- test('leaves forward slashes unchanged')
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
本计划中的函数大部分为纯函数,**不需要 mock**。少数例外:
|
||||
|
||||
| 函数 | 依赖 | 处理 |
|
||||
|------|------|------|
|
||||
| `hashContent` / `hashPair` | `Bun.hash` | Bun 运行时下自动可用 |
|
||||
| `formatRelativeTime` | `Date` | 使用 `now` 参数注入确定性时间 |
|
||||
| `safeParseJSON` | `logError` | 可通过 `shouldLogError: false` 跳过 |
|
||||
| `safeParseJSONC` | `logError` | mock `logError` 避免测试输出噪音 |
|
||||
@@ -1,134 +0,0 @@
|
||||
# Context 构建测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
Context 构建系统负责组装发送给 Claude API 的系统提示和用户上下文。包括 git 状态获取、CLAUDE.md 文件发现与加载、系统提示拼装三部分。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/context.ts` | `getSystemContext`, `getUserContext`, `getGitStatus`, `setSystemPromptInjection` |
|
||||
| `src/utils/claudemd.ts` | `stripHtmlComments`, `getClaudeMds`, `isMemoryFilePath`, `getLargeMemoryFiles`, `filterInjectedMemoryFiles`, `getExternalClaudeMdIncludes`, `hasExternalClaudeMdIncludes`, `processMemoryFile`, `getMemoryFiles` |
|
||||
| `src/utils/systemPrompt.ts` | `buildEffectiveSystemPrompt` |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/claudemd.ts — 纯函数部分
|
||||
|
||||
#### describe('stripHtmlComments')
|
||||
|
||||
- test('strips block-level HTML comments') — `"text <!-- comment --> more"` → content 不含注释
|
||||
- test('preserves inline content') — 行内文本保留
|
||||
- test('preserves code block content') — ` ```html\n<!-- not stripped -->\n``` ` 内的注释不移除
|
||||
- test('returns stripped: false when no comments') — 无注释时 stripped 为 false
|
||||
- test('returns stripped: true when comments exist')
|
||||
- test('handles empty string') — `""` → `{ content: "", stripped: false }`
|
||||
- test('handles multiple comments') — 多个注释全部移除
|
||||
|
||||
#### describe('getClaudeMds')
|
||||
|
||||
- test('assembles memory files with type descriptions') — 不同 type 的文件有不同前缀描述
|
||||
- test('includes instruction prompt prefix') — 输出包含指令前缀
|
||||
- test('handles empty memory files array') — 空数组返回空字符串或最小前缀
|
||||
- test('respects filter parameter') — filter 函数可过滤特定类型
|
||||
- test('concatenates multiple files with separators')
|
||||
|
||||
#### describe('isMemoryFilePath')
|
||||
|
||||
- test('returns true for CLAUDE.md path') — `"/project/CLAUDE.md"` → true
|
||||
- test('returns true for .claude/rules/ path') — `"/project/.claude/rules/foo.md"` → true
|
||||
- test('returns true for memory file path') — `"~/.claude/memory/foo.md"` → true
|
||||
- test('returns false for regular file') — `"/project/src/main.ts"` → false
|
||||
- test('returns false for unrelated .md file') — `"/project/README.md"` → false
|
||||
|
||||
#### describe('getLargeMemoryFiles')
|
||||
|
||||
- test('returns files exceeding 40K chars') — 内容 > MAX_MEMORY_CHARACTER_COUNT 的文件被返回
|
||||
- test('returns empty array when all files are small')
|
||||
- test('correctly identifies threshold boundary')
|
||||
|
||||
#### describe('filterInjectedMemoryFiles')
|
||||
|
||||
- test('filters out AutoMem type files') — feature flag 开启时移除自动记忆
|
||||
- test('filters out TeamMem type files')
|
||||
- test('preserves other types') — 非 AutoMem/TeamMem 的文件保留
|
||||
|
||||
#### describe('getExternalClaudeMdIncludes')
|
||||
|
||||
- test('returns includes from outside CWD') — 外部 @include 路径被识别
|
||||
- test('returns empty array when all includes are internal')
|
||||
|
||||
#### describe('hasExternalClaudeMdIncludes')
|
||||
|
||||
- test('returns true when external includes exist')
|
||||
- test('returns false when no external includes')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/systemPrompt.ts
|
||||
|
||||
#### describe('buildEffectiveSystemPrompt')
|
||||
|
||||
- test('returns default system prompt when no overrides') — 无任何覆盖时使用默认提示
|
||||
- test('overrideSystemPrompt replaces everything') — override 模式替换全部内容
|
||||
- test('customSystemPrompt replaces default') — `--system-prompt` 参数替换默认
|
||||
- test('appendSystemPrompt is appended after main prompt') — append 在主提示之后
|
||||
- test('agent definition replaces default prompt') — agent 模式使用 agent prompt
|
||||
- test('agent definition with append combines both') — agent prompt + append
|
||||
- test('override takes precedence over agent and custom') — 优先级最高
|
||||
- test('returns array of strings') — 返回值为 SystemPrompt 类型(字符串数组)
|
||||
|
||||
---
|
||||
|
||||
### src/context.ts — 需 Mock 的部分
|
||||
|
||||
#### describe('getGitStatus')
|
||||
|
||||
- test('returns formatted git status string') — 包含 branch、status、log、user
|
||||
- test('truncates status at 2000 chars') — 超长 status 被截断
|
||||
- test('returns null in test environment') — `NODE_ENV=test` 时返回 null
|
||||
- test('returns null in non-git directory') — 非 git 仓库返回 null
|
||||
- test('runs git commands in parallel') — 多个 git 命令并行执行
|
||||
|
||||
#### describe('getSystemContext')
|
||||
|
||||
- test('includes gitStatus key') — 返回对象包含 gitStatus
|
||||
- test('returns memoized result on subsequent calls') — 多次调用返回同一结果
|
||||
- test('skips git when instructions disabled')
|
||||
|
||||
#### describe('getUserContext')
|
||||
|
||||
- test('includes currentDate key') — 返回对象包含当前日期
|
||||
- test('includes claudeMd key when CLAUDE.md exists') — 加载 CLAUDE.md 内容
|
||||
- test('respects CLAUDE_CODE_DISABLE_CLAUDE_MDS env') — 设置后不加载 CLAUDE.md
|
||||
- test('returns memoized result')
|
||||
|
||||
#### describe('setSystemPromptInjection')
|
||||
|
||||
- test('clears memoized context caches') — 调用后下次 getSystemContext/getUserContext 重新计算
|
||||
- test('injection value is accessible via getSystemPromptInjection')
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 用途 |
|
||||
|------|-----------|------|
|
||||
| `execFileNoThrow` | `mock.module` | `getGitStatus` 中的 git 命令 |
|
||||
| `getMemoryFiles` | `mock.module` | `getUserContext` 中的 CLAUDE.md 加载 |
|
||||
| `getCwd` | `mock.module` | 路径解析上下文 |
|
||||
| `process.env.NODE_ENV` | 直接设置 | 测试环境检测 |
|
||||
| `process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS` | 直接设置 | 禁用 CLAUDE.md |
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
放在 `tests/integration/context-build.test.ts`:
|
||||
|
||||
### describe('Context assembly pipeline')
|
||||
|
||||
- test('getUserContext produces claudeMd containing CLAUDE.md content') — 端到端验证 CLAUDE.md 被正确加载到 context
|
||||
- test('buildEffectiveSystemPrompt + getUserContext produces complete prompt') — 系统提示 + 用户上下文完整性
|
||||
- test('setSystemPromptInjection invalidates and rebuilds context') — 注入后重新构建上下文
|
||||
@@ -1,104 +0,0 @@
|
||||
# 权限系统测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
权限系统控制工具是否可以执行,包含规则解析器、权限检查管线和权限模式判断。测试重点是纯函数解析器和规则匹配逻辑。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/permissions/permissionRuleParser.ts` | `permissionRuleValueFromString`, `permissionRuleValueToString`, `escapeRuleContent`, `unescapeRuleContent`, `normalizeLegacyToolName`, `getLegacyToolNames` |
|
||||
| `src/utils/permissions/PermissionMode.ts` | 权限模式常量和辅助函数 |
|
||||
| `src/utils/permissions/permissions.ts` | `hasPermissionsToUseTool`, `getDenyRuleForTool`, `checkRuleBasedPermissions` |
|
||||
| `src/types/permissions.ts` | `PermissionMode`, `PermissionBehavior`, `PermissionRule` 类型定义 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/permissions/permissionRuleParser.ts
|
||||
|
||||
#### describe('escapeRuleContent')
|
||||
|
||||
- test('escapes backslashes first') — `'test\\value'` → `'test\\\\value'`
|
||||
- test('escapes opening parentheses') — `'print(1)'` → `'print\\(1\\)'`
|
||||
- test('escapes closing parentheses') — `'func()'` → `'func\\(\\)'`
|
||||
- test('handles combined escape') — `'echo "test\\nvalue"'` 中的 `\\` 先转义
|
||||
- test('handles empty string') — `''` → `''`
|
||||
- test('no-op for string without special chars') — `'npm install'` 原样返回
|
||||
|
||||
#### describe('unescapeRuleContent')
|
||||
|
||||
- test('unescapes parentheses') — `'print\\(1\\)'` → `'print(1)'`
|
||||
- test('unescapes backslashes last') — `'test\\\\nvalue'` → `'test\\nvalue'`
|
||||
- test('handles empty string')
|
||||
- test('roundtrip: escape then unescape returns original') — `unescapeRuleContent(escapeRuleContent(x)) === x`
|
||||
|
||||
#### describe('permissionRuleValueFromString')
|
||||
|
||||
- test('parses tool name only') — `'Bash'` → `{ toolName: 'Bash' }`
|
||||
- test('parses tool name with content') — `'Bash(npm install)'` → `{ toolName: 'Bash', ruleContent: 'npm install' }`
|
||||
- test('parses content with escaped parentheses') — `'Bash(python -c "print\\(1\\)")'` → ruleContent 为 `'python -c "print(1)"'`
|
||||
- test('treats empty parens as tool-wide rule') — `'Bash()'` → `{ toolName: 'Bash' }`(无 ruleContent)
|
||||
- test('treats wildcard content as tool-wide rule') — `'Bash(*)'` → `{ toolName: 'Bash' }`
|
||||
- test('normalizes legacy tool names') — `'Task'` → `{ toolName: 'Agent' }`(或对应的 AGENT_TOOL_NAME)
|
||||
- test('handles malformed input: no closing paren') — `'Bash(npm'` → 整个字符串作为 toolName
|
||||
- test('handles malformed input: content after closing paren') — `'Bash(npm)extra'` → 整个字符串作为 toolName
|
||||
- test('handles missing tool name') — `'(foo)'` → 整个字符串作为 toolName
|
||||
|
||||
#### describe('permissionRuleValueToString')
|
||||
|
||||
- test('serializes tool name only') — `{ toolName: 'Bash' }` → `'Bash'`
|
||||
- test('serializes with content') — `{ toolName: 'Bash', ruleContent: 'npm install' }` → `'Bash(npm install)'`
|
||||
- test('escapes content with parentheses') — ruleContent 含 `()` 时正确转义
|
||||
- test('roundtrip: fromString then toString preserves value') — 往返一致
|
||||
|
||||
#### describe('normalizeLegacyToolName')
|
||||
|
||||
- test('maps Task to Agent tool name') — `'Task'` → AGENT_TOOL_NAME
|
||||
- test('maps KillShell to TaskStop tool name') — `'KillShell'` → TASK_STOP_TOOL_NAME
|
||||
- test('maps AgentOutputTool to TaskOutput tool name')
|
||||
- test('returns unknown names unchanged') — `'UnknownTool'` → `'UnknownTool'`
|
||||
|
||||
#### describe('getLegacyToolNames')
|
||||
|
||||
- test('returns legacy names for canonical name') — 给定 AGENT_TOOL_NAME 返回包含 `'Task'`
|
||||
- test('returns empty array for name with no legacy aliases')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/permissions/permissions.ts — 需 Mock
|
||||
|
||||
#### describe('getDenyRuleForTool')
|
||||
|
||||
- test('returns deny rule matching tool name') — 匹配到 blanket deny 规则时返回
|
||||
- test('returns null when no deny rules match') — 无匹配时返回 null
|
||||
- test('matches MCP tools by server prefix') — `mcp__server` 规则匹配该 server 下的 MCP 工具
|
||||
- test('does not match content-specific deny rules') — 有 ruleContent 的 deny 规则不作为 blanket deny
|
||||
|
||||
#### describe('checkRuleBasedPermissions')(集成级别)
|
||||
|
||||
- test('deny rule takes precedence over allow') — 同时有 allow 和 deny 时 deny 优先
|
||||
- test('ask rule prompts user') — 匹配 ask 规则返回 `{ behavior: 'ask' }`
|
||||
- test('allow rule permits execution') — 匹配 allow 规则返回 `{ behavior: 'allow' }`
|
||||
- test('passthrough when no rules match') — 无匹配规则返回 passthrough
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `bun:bundle` (feature) | 已 polyfill | BRIEF_TOOL_NAME 条件加载 |
|
||||
| Tool 常量导入 | 实际值 | AGENT_TOOL_NAME 等从常量文件导入 |
|
||||
| `appState` | mock object | `hasPermissionsToUseTool` 中的状态依赖 |
|
||||
| Tool 对象 | mock object | 模拟 tool 的 name, checkPermissions 等 |
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
### describe('Permission pipeline end-to-end')
|
||||
|
||||
- test('deny rule blocks tool before it runs') — deny 规则在 call 前拦截
|
||||
- test('bypassPermissions mode allows all') — bypass 模式下 ask → allow
|
||||
- test('dontAsk mode converts ask to deny') — dontAsk 模式下 ask → deny
|
||||
@@ -1,113 +0,0 @@
|
||||
# 模型路由测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
模型路由系统负责 API provider 选择、模型别名解析、模型名规范化和运行时模型决策。测试重点是纯函数和环境变量驱动的逻辑。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/model/aliases.ts` | `MODEL_ALIASES`, `MODEL_FAMILY_ALIASES`, `isModelAlias`, `isModelFamilyAlias` |
|
||||
| `src/utils/model/providers.ts` | `APIProvider`, `getAPIProvider`, `isFirstPartyAnthropicBaseUrl` |
|
||||
| `src/utils/model/model.ts` | `firstPartyNameToCanonical`, `getCanonicalName`, `parseUserSpecifiedModel`, `normalizeModelStringForAPI`, `getRuntimeMainLoopModel`, `getDefaultMainLoopModelSetting` |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/model/aliases.ts
|
||||
|
||||
#### describe('isModelAlias')
|
||||
|
||||
- test('returns true for "sonnet"') — 有效别名
|
||||
- test('returns true for "opus"')
|
||||
- test('returns true for "haiku"')
|
||||
- test('returns true for "best"')
|
||||
- test('returns true for "sonnet[1m]"')
|
||||
- test('returns true for "opus[1m]"')
|
||||
- test('returns true for "opusplan"')
|
||||
- test('returns false for full model ID') — `'claude-sonnet-4-6-20250514'` → false
|
||||
- test('returns false for unknown string') — `'gpt-4'` → false
|
||||
- test('is case-sensitive') — `'Sonnet'` → false(别名是小写)
|
||||
|
||||
#### describe('isModelFamilyAlias')
|
||||
|
||||
- test('returns true for "sonnet"')
|
||||
- test('returns true for "opus"')
|
||||
- test('returns true for "haiku"')
|
||||
- test('returns false for "best"') — best 不是 family alias
|
||||
- test('returns false for "opusplan"')
|
||||
- test('returns false for "sonnet[1m]"')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/model/providers.ts
|
||||
|
||||
#### describe('getAPIProvider')
|
||||
|
||||
- test('returns "firstParty" by default') — 无相关 env 时返回 firstParty
|
||||
- test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set') — env 为 truthy 值
|
||||
- test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set')
|
||||
- test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set')
|
||||
- test('bedrock takes precedence over vertex') — 多个 env 同时设置时 bedrock 优先
|
||||
|
||||
#### describe('isFirstPartyAnthropicBaseUrl')
|
||||
|
||||
- test('returns true when ANTHROPIC_BASE_URL is not set') — 默认 API
|
||||
- test('returns true for api.anthropic.com') — `'https://api.anthropic.com'` → true
|
||||
- test('returns false for custom URL') — `'https://my-proxy.com'` → false
|
||||
- test('returns false for invalid URL') — 非法 URL → false
|
||||
- test('returns true for staging URL when USER_TYPE is ant') — `'https://api-staging.anthropic.com'` + ant → true
|
||||
|
||||
---
|
||||
|
||||
### src/utils/model/model.ts
|
||||
|
||||
#### describe('firstPartyNameToCanonical')
|
||||
|
||||
- test('maps opus-4-6 full name to canonical') — `'claude-opus-4-6-20250514'` → `'claude-opus-4-6'`
|
||||
- test('maps sonnet-4-6 full name') — `'claude-sonnet-4-6-20250514'` → `'claude-sonnet-4-6'`
|
||||
- test('maps haiku-4-5') — `'claude-haiku-4-5-20251001'` → `'claude-haiku-4-5'`
|
||||
- test('maps 3P provider format') — `'us.anthropic.claude-opus-4-6-v1:0'` → `'claude-opus-4-6'`
|
||||
- test('maps claude-3-7-sonnet') — `'claude-3-7-sonnet-20250219'` → `'claude-3-7-sonnet'`
|
||||
- test('maps claude-3-5-sonnet') → `'claude-3-5-sonnet'`
|
||||
- test('maps claude-3-5-haiku') → `'claude-3-5-haiku'`
|
||||
- test('maps claude-3-opus') → `'claude-3-opus'`
|
||||
- test('is case insensitive') — `'Claude-Opus-4-6'` → `'claude-opus-4-6'`
|
||||
- test('falls back to input for unknown model') — `'unknown-model'` → `'unknown-model'`
|
||||
- test('differentiates opus-4 vs opus-4-5 vs opus-4-6') — 更具体的版本优先匹配
|
||||
|
||||
#### describe('parseUserSpecifiedModel')
|
||||
|
||||
- test('resolves "sonnet" to default sonnet model')
|
||||
- test('resolves "opus" to default opus model')
|
||||
- test('resolves "haiku" to default haiku model')
|
||||
- test('resolves "best" to best model')
|
||||
- test('resolves "opusplan" to default sonnet model') — opusplan 默认用 sonnet
|
||||
- test('appends [1m] suffix when alias has [1m]') — `'sonnet[1m]'` → 模型名 + `'[1m]'`
|
||||
- test('preserves original case for custom model names') — `'my-Custom-Model'` 保留大小写
|
||||
- test('handles [1m] suffix on non-alias models') — `'custom-model[1m]'` → `'custom-model[1m]'`
|
||||
- test('trims whitespace') — `' sonnet '` → 正确解析
|
||||
|
||||
#### describe('getRuntimeMainLoopModel')
|
||||
|
||||
- test('returns mainLoopModel by default') — 无特殊条件时原样返回
|
||||
- test('returns opus in plan mode when opusplan is set') — opusplan + plan mode → opus
|
||||
- test('returns sonnet in plan mode when haiku is set') — haiku + plan mode → sonnet 升级
|
||||
- test('returns mainLoopModel in non-plan mode') — 非 plan 模式不做替换
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `process.env.CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY` | 直接设置/恢复 | provider 选择 |
|
||||
| `process.env.ANTHROPIC_BASE_URL` | 直接设置/恢复 | URL 检测 |
|
||||
| `process.env.USER_TYPE` | 直接设置/恢复 | staging URL 和 ant 功能 |
|
||||
| `getModelStrings()` | mock.module | 返回固定模型 ID |
|
||||
| `getMainLoopModelOverride` | mock.module | 会话中模型覆盖 |
|
||||
| `getSettings_DEPRECATED` | mock.module | 用户设置中的模型 |
|
||||
| `getUserSpecifiedModelSetting` | mock.module | `getRuntimeMainLoopModel` 依赖 |
|
||||
| `isModelAllowed` | mock.module | allowlist 检查 |
|
||||
@@ -1,165 +0,0 @@
|
||||
# 消息处理测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
消息处理系统负责消息的创建、查询、规范化和文本提取。覆盖消息类型定义、消息工厂函数、消息过滤/查询工具和 API 规范化管线。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/types/message.ts` | `MessageType`, `Message`, `AssistantMessage`, `UserMessage`, `SystemMessage` 等类型 |
|
||||
| `src/utils/messages.ts` | 消息创建、查询、规范化、文本提取等函数(~3100 行) |
|
||||
| `src/utils/messages/mappers.ts` | 消息映射工具 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/messages.ts — 消息创建
|
||||
|
||||
#### describe('createAssistantMessage')
|
||||
|
||||
- test('creates message with type "assistant"') — type 字段正确
|
||||
- test('creates message with role "assistant"') — role 正确
|
||||
- test('creates message with empty content array') — 默认 content 为空
|
||||
- test('generates unique uuid') — 每次调用 uuid 不同
|
||||
- test('includes costUsd as 0')
|
||||
|
||||
#### describe('createUserMessage')
|
||||
|
||||
- test('creates message with type "user"') — type 字段正确
|
||||
- test('creates message with provided content') — content 正确传入
|
||||
- test('generates unique uuid')
|
||||
|
||||
#### describe('createSystemMessage')
|
||||
|
||||
- test('creates system message with correct type')
|
||||
- test('includes message content')
|
||||
|
||||
#### describe('createProgressMessage')
|
||||
|
||||
- test('creates progress message with data')
|
||||
- test('has correct type "progress"')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 消息查询
|
||||
|
||||
#### describe('getLastAssistantMessage')
|
||||
|
||||
- test('returns last assistant message from array') — 多条消息中返回最后一条 assistant
|
||||
- test('returns undefined for empty array')
|
||||
- test('returns undefined when no assistant messages exist')
|
||||
|
||||
#### describe('hasToolCallsInLastAssistantTurn')
|
||||
|
||||
- test('returns true when last assistant has tool_use content') — content 含 tool_use block
|
||||
- test('returns false when last assistant has only text')
|
||||
- test('returns false for empty messages')
|
||||
|
||||
#### describe('isSyntheticMessage')
|
||||
|
||||
- test('identifies interrupt message as synthetic') — INTERRUPT_MESSAGE 标记
|
||||
- test('identifies cancel message as synthetic')
|
||||
- test('returns false for normal user messages')
|
||||
|
||||
#### describe('isNotEmptyMessage')
|
||||
|
||||
- test('returns true for message with content')
|
||||
- test('returns false for message with empty content array')
|
||||
- test('returns false for message with empty text content')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 文本提取
|
||||
|
||||
#### describe('getAssistantMessageText')
|
||||
|
||||
- test('extracts text from text blocks') — content 含 `{ type: 'text', text: 'hello' }` 时提取
|
||||
- test('returns empty string for non-text content') — 仅含 tool_use 时返回空
|
||||
- test('concatenates multiple text blocks')
|
||||
|
||||
#### describe('getUserMessageText')
|
||||
|
||||
- test('extracts text from string content') — content 为纯字符串
|
||||
- test('extracts text from content array') — content 为数组时提取 text 块
|
||||
- test('handles empty content')
|
||||
|
||||
#### describe('extractTextContent')
|
||||
|
||||
- test('extracts text items from mixed content') — 过滤出 type: 'text' 的项
|
||||
- test('returns empty array for all non-text content')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 规范化
|
||||
|
||||
#### describe('normalizeMessages')
|
||||
|
||||
- test('converts raw messages to normalized format') — 消息数组规范化
|
||||
- test('handles empty array') — `[]` → `[]`
|
||||
- test('preserves message order')
|
||||
- test('handles mixed message types')
|
||||
|
||||
#### describe('normalizeMessagesForAPI')
|
||||
|
||||
- test('filters out system messages') — 系统消息不发送给 API
|
||||
- test('filters out progress messages')
|
||||
- test('filters out attachment messages')
|
||||
- test('preserves user and assistant messages')
|
||||
- test('reorders tool results to match API expectations')
|
||||
- test('handles empty array')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 合并
|
||||
|
||||
#### describe('mergeUserMessages')
|
||||
|
||||
- test('merges consecutive user messages') — 相邻用户消息合并
|
||||
- test('does not merge non-consecutive user messages')
|
||||
- test('preserves assistant messages between user messages')
|
||||
|
||||
#### describe('mergeAssistantMessages')
|
||||
|
||||
- test('merges consecutive assistant messages')
|
||||
- test('combines content arrays')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 辅助函数
|
||||
|
||||
#### describe('buildMessageLookups')
|
||||
|
||||
- test('builds index by message uuid') — 按 uuid 建立查找表
|
||||
- test('returns empty lookups for empty messages')
|
||||
- test('handles duplicate uuids gracefully')
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `crypto.randomUUID` | `mock` 或 spy | 消息创建中的 uuid 生成 |
|
||||
| Message 对象 | 手动构造 | 创建符合类型的 mock 消息对象 |
|
||||
|
||||
### Mock 消息工厂(放在 `tests/mocks/messages.ts`)
|
||||
|
||||
```typescript
|
||||
// 通用 mock 消息构造器
|
||||
export function mockAssistantMessage(overrides?: Partial<AssistantMessage>): AssistantMessage
|
||||
export function mockUserMessage(content: string, overrides?: Partial<UserMessage>): UserMessage
|
||||
export function mockSystemMessage(overrides?: Partial<SystemMessage>): SystemMessage
|
||||
export function mockToolUseBlock(name: string, input: unknown): ToolUseBlock
|
||||
export function mockToolResultMessage(toolUseId: string, content: string): UserMessage
|
||||
```
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
### describe('Message pipeline')
|
||||
|
||||
- test('create → normalize → API format produces valid request') — 创建消息 → normalizeMessagesForAPI → 验证输出结构
|
||||
- test('tool use and tool result pairing is preserved through normalization')
|
||||
- test('merge + normalize handles conversation with interruptions')
|
||||
@@ -1,112 +0,0 @@
|
||||
# Cron 调度测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
Cron 模块提供 cron 表达式解析、下次运行时间计算和人类可读描述。全部为纯函数,无外部依赖,是最适合单元测试的模块之一。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/cron.ts` | `CronFields`, `parseCronExpression`, `computeNextCronRun`, `cronToHuman` |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### describe('parseCronExpression')
|
||||
|
||||
#### 有效表达式
|
||||
|
||||
- test('parses wildcard fields') — `'* * * * *'` → 每个字段为完整范围
|
||||
- test('parses specific values') — `'30 14 1 6 3'` → minute=[30], hour=[14], dom=[1], month=[6], dow=[3]
|
||||
- test('parses step syntax') — `'*/5 * * * *'` → minute=[0,5,10,...,55]
|
||||
- test('parses range syntax') — `'1-5 * * * *'` → minute=[1,2,3,4,5]
|
||||
- test('parses range with step') — `'1-10/3 * * * *'` → minute=[1,4,7,10]
|
||||
- test('parses comma-separated list') — `'1,15,30 * * * *'` → minute=[1,15,30]
|
||||
- test('parses day-of-week 7 as Sunday alias') — `'0 0 * * 7'` → dow=[0]
|
||||
- test('parses range with day-of-week 7') — `'0 0 * * 5-7'` → dow=[0,5,6]
|
||||
- test('parses complex combined expression') — `'0,30 9-17 * * 1-5'` → 工作日 9-17 每半小时
|
||||
|
||||
#### 无效表达式
|
||||
|
||||
- test('returns null for wrong field count') — `'* * *'` → null
|
||||
- test('returns null for out-of-range values') — `'60 * * * *'` → null(minute max=59)
|
||||
- test('returns null for invalid step') — `'*/0 * * * *'` → null(step=0)
|
||||
- test('returns null for reversed range') — `'10-5 * * * *'` → null(lo>hi)
|
||||
- test('returns null for empty string') — `''` → null
|
||||
- test('returns null for non-numeric tokens') — `'abc * * * *'` → null
|
||||
|
||||
#### 字段范围验证
|
||||
|
||||
- test('minute: 0-59')
|
||||
- test('hour: 0-23')
|
||||
- test('dayOfMonth: 1-31')
|
||||
- test('month: 1-12')
|
||||
- test('dayOfWeek: 0-6 (plus 7 alias)')
|
||||
|
||||
---
|
||||
|
||||
### describe('computeNextCronRun')
|
||||
|
||||
#### 基本匹配
|
||||
|
||||
- test('finds next minute') — from 14:30:45, cron `'31 14 * * *'` → 14:31:00 同天
|
||||
- test('finds next hour') — from 14:30, cron `'0 15 * * *'` → 15:00 同天
|
||||
- test('rolls to next day') — from 14:30, cron `'0 10 * * *'` → 10:00 次日
|
||||
- test('rolls to next month') — from 1月31日, cron `'0 0 1 * *'` → 2月1日
|
||||
- test('is strictly after from date') — from 恰好匹配时应返回下一次而非当前时间
|
||||
|
||||
#### DOM/DOW 语义
|
||||
|
||||
- test('OR semantics when both dom and dow constrained') — dom=15, dow=3 → 匹配 15 号 OR 周三
|
||||
- test('only dom constrained uses dom') — dom=15, dow=* → 只匹配 15 号
|
||||
- test('only dow constrained uses dow') — dom=*, dow=3 → 只匹配周三
|
||||
- test('both wildcarded matches every day') — dom=*, dow=* → 每天
|
||||
|
||||
#### 边界情况
|
||||
|
||||
- test('handles month boundary') — 从 2 月 28 日寻找 2 月 29 日或 3 月 1 日
|
||||
- test('returns null after 366-day search') — 不可能匹配的表达式返回 null(理论上不会发生)
|
||||
- test('handles step across midnight') — `'0 0 * * *'` 从 23:59 → 次日 0:00
|
||||
|
||||
#### 每 N 分钟
|
||||
|
||||
- test('every 5 minutes from arbitrary time') — `'*/5 * * * *'` from 14:32 → 14:35
|
||||
- test('every minute') — `'* * * * *'` from 14:32:45 → 14:33:00
|
||||
|
||||
---
|
||||
|
||||
### describe('cronToHuman')
|
||||
|
||||
#### 常见模式
|
||||
|
||||
- test('every N minutes') — `'*/5 * * * *'` → `'Every 5 minutes'`
|
||||
- test('every minute') — `'*/1 * * * *'` → `'Every minute'`
|
||||
- test('every hour at :00') — `'0 * * * *'` → `'Every hour'`
|
||||
- test('every hour at :30') — `'30 * * * *'` → `'Every hour at :30'`
|
||||
- test('every N hours') — `'0 */2 * * *'` → `'Every 2 hours'`
|
||||
- test('daily at specific time') — `'30 9 * * *'` → `'Every day at 9:30 AM'`
|
||||
- test('specific day of week') — `'0 9 * * 3'` → `'Every Wednesday at 9:00 AM'`
|
||||
- test('weekdays') — `'0 9 * * 1-5'` → `'Weekdays at 9:00 AM'`
|
||||
|
||||
#### Fallback
|
||||
|
||||
- test('returns raw cron for complex patterns') — 非常见模式返回原始 cron 字符串
|
||||
- test('returns raw cron for wrong field count') — `'* * *'` → 原样返回
|
||||
|
||||
#### UTC 模式
|
||||
|
||||
- test('UTC option formats time in local timezone') — `{ utc: true }` 时 UTC 时间转本地显示
|
||||
- test('UTC midnight crossing adjusts day name') — UTC 时间跨天时本地星期名正确
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
**无需 Mock**。所有函数为纯函数,唯一的外部依赖是 `Date` 构造器和 `toLocaleTimeString`,可通过传入确定性的 `from` 参数控制。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `cronToHuman` 的时间格式化依赖系统 locale,测试中建议使用 `'en-US'` locale 或只验证部分输出
|
||||
- `computeNextCronRun` 使用本地时区,DST 相关测试需注意运行环境
|
||||
@@ -1,106 +0,0 @@
|
||||
# Git 工具测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
Git 工具模块提供 git 远程 URL 规范化、仓库根目录查找、裸仓库安全检测等功能。测试重点是纯函数的 URL 规范化和需要文件系统 mock 的仓库发现逻辑。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/git.ts` | `normalizeGitRemoteUrl`, `findGitRoot`, `findCanonicalGitRoot`, `getIsGit`, `isAtGitRoot`, `getRepoRemoteHash`, `isCurrentDirectoryBareGitRepo`, `gitExe`, `getGitState`, `stashToCleanState`, `preserveGitStateForIssue` |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### describe('normalizeGitRemoteUrl')(纯函数)
|
||||
|
||||
#### SSH 格式
|
||||
|
||||
- test('normalizes SSH URL') — `'git@github.com:owner/repo.git'` → `'github.com/owner/repo'`
|
||||
- test('normalizes SSH URL without .git suffix') — `'git@github.com:owner/repo'` → `'github.com/owner/repo'`
|
||||
- test('handles GitLab SSH') — `'git@gitlab.com:group/subgroup/repo.git'` → `'gitlab.com/group/subgroup/repo'`
|
||||
|
||||
#### HTTPS 格式
|
||||
|
||||
- test('normalizes HTTPS URL') — `'https://github.com/owner/repo.git'` → `'github.com/owner/repo'`
|
||||
- test('normalizes HTTPS URL without .git suffix') — `'https://github.com/owner/repo'` → `'github.com/owner/repo'`
|
||||
- test('normalizes HTTP URL') — `'http://github.com/owner/repo.git'` → `'github.com/owner/repo'`
|
||||
|
||||
#### SSH:// 协议格式
|
||||
|
||||
- test('normalizes ssh:// URL') — `'ssh://git@github.com/owner/repo'` → `'github.com/owner/repo'`
|
||||
- test('handles user prefix in ssh://') — `'ssh://user@host/path'` → `'host/path'`
|
||||
|
||||
#### 代理 URL(CCR git proxy)
|
||||
|
||||
- test('normalizes legacy proxy URL') — `'http://local_proxy@127.0.0.1:16583/git/owner/repo'` → `'github.com/owner/repo'`
|
||||
- test('normalizes GHE proxy URL') — `'http://user@127.0.0.1:8080/git/ghe.company.com/owner/repo'` → `'ghe.company.com/owner/repo'`
|
||||
|
||||
#### 边界情况
|
||||
|
||||
- test('returns null for empty string') — `''` → null
|
||||
- test('returns null for whitespace') — `' '` → null
|
||||
- test('returns null for unrecognized format') — `'not-a-url'` → null
|
||||
- test('output is lowercase') — `'git@GitHub.com:Owner/Repo.git'` → `'github.com/owner/repo'`
|
||||
- test('SSH and HTTPS for same repo produce same result') — 相同仓库不同协议 → 相同输出
|
||||
|
||||
---
|
||||
|
||||
### describe('findGitRoot')(需文件系统 Mock)
|
||||
|
||||
- test('finds git root from nested directory') — `/project/src/utils/` → `/project/`(假设 `/project/.git` 存在)
|
||||
- test('finds git root from root directory') — `/project/` → `/project/`
|
||||
- test('returns null for non-git directory') — 无 `.git` → null
|
||||
- test('handles worktree .git file') — `.git` 为文件时也识别
|
||||
- test('memoizes results') — 同一路径不重复查找
|
||||
|
||||
### describe('findCanonicalGitRoot')
|
||||
|
||||
- test('returns same as findGitRoot for regular repo')
|
||||
- test('resolves worktree to main repo root') — worktree 路径 → 主仓库根目录
|
||||
- test('returns null for non-git directory')
|
||||
|
||||
### describe('gitExe')
|
||||
|
||||
- test('returns git path string') — 返回字符串
|
||||
- test('memoizes the result') — 多次调用返回同一值
|
||||
|
||||
---
|
||||
|
||||
### describe('getRepoRemoteHash')(需 Mock)
|
||||
|
||||
- test('returns 16-char hex hash') — 返回值为 16 位十六进制字符串
|
||||
- test('returns null when no remote') — 无 remote URL 时返回 null
|
||||
- test('same repo SSH/HTTPS produce same hash') — 不同协议同一仓库 hash 相同
|
||||
|
||||
---
|
||||
|
||||
### describe('isCurrentDirectoryBareGitRepo')(需文件系统 Mock)
|
||||
|
||||
- test('detects bare git repo attack vector') — 目录含 HEAD + objects/ + refs/ 但无有效 .git/HEAD → true
|
||||
- test('returns false for normal directory') — 普通目录 → false
|
||||
- test('returns false for regular git repo') — 有效 .git 目录 → false
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `statSync` | mock module | `findGitRoot` 中的 .git 检测 |
|
||||
| `readFileSync` | mock module | worktree .git 文件读取 |
|
||||
| `realpathSync` | mock module | 路径解析 |
|
||||
| `execFileNoThrow` | mock module | git 命令执行 |
|
||||
| `whichSync` | mock module | `gitExe` 中的 git 路径查找 |
|
||||
| `getCwd` | mock module | 当前工作目录 |
|
||||
| `getRemoteUrl` | mock module | `getRepoRemoteHash` 依赖 |
|
||||
| 临时目录 | `mkdtemp` | 集成测试中创建临时 git 仓库 |
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
### describe('Git repo discovery')(放在 tests/integration/)
|
||||
|
||||
- test('findGitRoot works in actual git repo') — 在临时 git init 的目录中验证
|
||||
- test('normalizeGitRemoteUrl + getRepoRemoteHash produces stable hash') — URL → hash 端到端验证
|
||||
@@ -1,161 +0,0 @@
|
||||
# 配置系统测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
配置系统包含全局配置(GlobalConfig)、项目配置(ProjectConfig)和设置(Settings)三层。测试重点是纯函数校验逻辑、Zod schema 验证和配置合并策略。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/config.ts` | `getGlobalConfig`, `saveGlobalConfig`, `getCurrentProjectConfig`, `checkHasTrustDialogAccepted`, `isPathTrusted`, `getOrCreateUserID`, `isAutoUpdaterDisabled` |
|
||||
| `src/utils/settings/settings.ts` | `getSettingsForSource`, `parseSettingsFile`, `getSettingsFilePathForSource`, `getInitialSettings` |
|
||||
| `src/utils/settings/types.ts` | `SettingsSchema`(Zod schema) |
|
||||
| `src/utils/settings/validation.ts` | 设置验证函数 |
|
||||
| `src/utils/settings/constants.ts` | 设置常量 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/config.ts — 纯函数/常量
|
||||
|
||||
#### describe('DEFAULT_GLOBAL_CONFIG')
|
||||
|
||||
- test('has all required fields') — 默认配置对象包含所有必需字段
|
||||
- test('has null auth fields by default') — oauthAccount 等为 null
|
||||
|
||||
#### describe('DEFAULT_PROJECT_CONFIG')
|
||||
|
||||
- test('has empty allowedTools') — 默认为空数组
|
||||
- test('has empty mcpServers') — 默认为空对象
|
||||
|
||||
#### describe('isAutoUpdaterDisabled')
|
||||
|
||||
- test('returns true when CLAUDE_CODE_DISABLE_AUTOUPDATER is set') — env 设置时禁用
|
||||
- test('returns true when disableAutoUpdater config is true')
|
||||
- test('returns false by default')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/config.ts — 需 Mock
|
||||
|
||||
#### describe('getGlobalConfig')
|
||||
|
||||
- test('returns cached config on subsequent calls') — 缓存机制
|
||||
- test('returns TEST_GLOBAL_CONFIG_FOR_TESTING in test mode')
|
||||
- test('reads config from ~/.claude.json')
|
||||
- test('returns default config when file does not exist')
|
||||
|
||||
#### describe('saveGlobalConfig')
|
||||
|
||||
- test('applies updater function to current config') — updater 修改被保存
|
||||
- test('creates backup before writing') — 写入前备份
|
||||
- test('prevents auth state loss') — `wouldLoseAuthState` 检查
|
||||
|
||||
#### describe('getCurrentProjectConfig')
|
||||
|
||||
- test('returns project config for current directory')
|
||||
- test('returns default config when no project config exists')
|
||||
|
||||
#### describe('checkHasTrustDialogAccepted')
|
||||
|
||||
- test('returns true when trust is accepted in current directory')
|
||||
- test('returns true when parent directory is trusted') — 父目录信任传递
|
||||
- test('returns false when no trust accepted')
|
||||
- test('caches positive results')
|
||||
|
||||
#### describe('isPathTrusted')
|
||||
|
||||
- test('returns true for trusted path')
|
||||
- test('returns false for untrusted path')
|
||||
|
||||
#### describe('getOrCreateUserID')
|
||||
|
||||
- test('returns existing user ID from config')
|
||||
- test('creates and persists new ID when none exists')
|
||||
- test('returns consistent ID across calls')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/settings/settings.ts
|
||||
|
||||
#### describe('getSettingsFilePathForSource')
|
||||
|
||||
- test('returns ~/.claude/settings.json for userSettings') — 全局用户设置路径
|
||||
- test('returns .claude/settings.json for projectSettings') — 项目设置路径
|
||||
- test('returns .claude/settings.local.json for localSettings') — 本地设置路径
|
||||
|
||||
#### describe('parseSettingsFile')(需 Mock 文件读取)
|
||||
|
||||
- test('parses valid settings JSON') — 有效 JSON → `{ settings, errors: [] }`
|
||||
- test('returns errors for invalid fields') — 无效字段 → errors 非空
|
||||
- test('returns empty settings for non-existent file')
|
||||
- test('handles JSON with comments') — JSONC 格式支持
|
||||
|
||||
#### describe('getInitialSettings')
|
||||
|
||||
- test('merges settings from all sources') — user + project + local 合并
|
||||
- test('later sources override earlier ones') — 优先级:policy > user > project > local
|
||||
|
||||
---
|
||||
|
||||
### src/utils/settings/types.ts — Zod Schema 验证
|
||||
|
||||
#### describe('SettingsSchema validation')
|
||||
|
||||
- test('accepts valid minimal settings') — `{}` → 有效
|
||||
- test('accepts permissions block') — `{ permissions: { allow: ['Bash(*)'] } }` → 有效
|
||||
- test('accepts model setting') — `{ model: 'sonnet' }` → 有效
|
||||
- test('accepts hooks configuration') — 有效的 hooks 对象被接受
|
||||
- test('accepts env variables') — `{ env: { FOO: 'bar' } }` → 有效
|
||||
- test('rejects unknown top-level keys') — 未知字段被拒绝或忽略(取决于 schema 配置)
|
||||
- test('rejects invalid permission mode') — `{ permissions: { defaultMode: 'invalid' } }` → 错误
|
||||
- test('rejects non-string model') — `{ model: 123 }` → 错误
|
||||
- test('accepts mcpServers configuration') — MCP server 配置有效
|
||||
- test('accepts sandbox configuration')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/settings/validation.ts
|
||||
|
||||
#### describe('settings validation')
|
||||
|
||||
- test('validates permission rules format') — `'Bash(npm install)'` 格式正确
|
||||
- test('rejects malformed permission rules')
|
||||
- test('validates hook configuration structure')
|
||||
- test('provides helpful error messages') — 错误信息包含字段路径
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| 文件系统 | 临时目录 + mock | config 文件读写 |
|
||||
| `lockfile` | mock module | 文件锁 |
|
||||
| `getCwd` | mock module | 项目路径判断 |
|
||||
| `findGitRoot` | mock module | 项目根目录 |
|
||||
| `process.env` | 直接设置/恢复 | `CLAUDE_CODE_DISABLE_AUTOUPDATER` 等 |
|
||||
|
||||
### 测试用临时文件结构
|
||||
|
||||
```
|
||||
/tmp/claude-test-xxx/
|
||||
├── .claude/
|
||||
│ ├── settings.json # projectSettings
|
||||
│ └── settings.local.json # localSettings
|
||||
├── home/
|
||||
│ └── .claude/
|
||||
│ └── settings.json # userSettings(mock HOME)
|
||||
└── project/
|
||||
└── .git/
|
||||
```
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
### describe('Config + Settings merge pipeline')
|
||||
|
||||
- test('user settings + project settings merge correctly') — 验证合并优先级
|
||||
- test('deny rules from settings are reflected in tool permission context')
|
||||
- test('trust dialog state persists across config reads')
|
||||
@@ -1,361 +0,0 @@
|
||||
# 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 并更新测试)
|
||||
@@ -1,177 +0,0 @@
|
||||
# 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` 用于精确值检查的场景
|
||||
@@ -1,145 +0,0 @@
|
||||
# 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` 全部通过
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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` 全部通过
|
||||
@@ -1,191 +0,0 @@
|
||||
# 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` 全部通过
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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` 全部通过
|
||||
@@ -1,188 +0,0 @@
|
||||
# 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` 实现确定性测试
|
||||
@@ -1,203 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,110 +0,0 @@
|
||||
# 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** |
|
||||
@@ -1,435 +0,0 @@
|
||||
# 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 对象
|
||||
@@ -1,287 +0,0 @@
|
||||
# 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 需求
|
||||
无
|
||||
@@ -1,258 +0,0 @@
|
||||
# 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()` 获取函数
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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` 做测试隔离
|
||||
@@ -1,200 +0,0 @@
|
||||
# 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,296 +0,0 @@
|
||||
# Testing Specification
|
||||
|
||||
本文档定义 claude-code 项目的测试规范、当前覆盖状态和改进计划。
|
||||
|
||||
## 1. 技术栈
|
||||
|
||||
| 项 | 选型 |
|
||||
|----|------|
|
||||
| 测试框架 | `bun:test` |
|
||||
| 断言/Mock | `bun:test` 内置 |
|
||||
| 覆盖率 | `bun test --coverage` |
|
||||
| CI | GitHub Actions,push/PR 到 main 自动运行 |
|
||||
|
||||
## 2. 测试层次
|
||||
|
||||
本项目采用 **单元测试 + 集成测试** 两层结构,不做 E2E 或快照测试。
|
||||
|
||||
- **单元测试** — 纯函数、工具类、解析器。文件就近放置于 `src/**/__tests__/`。
|
||||
- **集成测试** — 多模块协作流程。集中于 `tests/integration/`。
|
||||
|
||||
## 3. 文件结构与命名
|
||||
|
||||
```
|
||||
src/
|
||||
├── 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、单一职责、独立性、边界覆盖
|
||||
|
||||
## 4. 当前覆盖状态
|
||||
|
||||
> 更新日期:2026-04-02 | **1623 tests, 84 files, 0 fail, 851ms**
|
||||
|
||||
### 4.1 可靠度评分
|
||||
|
||||
每个测试文件按断言深度、边界覆盖、mock 质量、测试独立性综合评定:
|
||||
|
||||
| 等级 | 含义 |
|
||||
|------|------|
|
||||
| **GOOD** | 断言精确(exact match),边界充分,结构清晰 |
|
||||
| **ACCEPTABLE** | 正常路径覆盖完整,部分边界或断言可加强 |
|
||||
| **WEAK** | 存在明显缺陷:断言过弱、重要边界缺失、或有脆弱性风险 |
|
||||
|
||||
### 4.2 按模块分布
|
||||
|
||||
#### P0 — 核心模块
|
||||
|
||||
| 文件 | 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` 与实现可能分歧;覆盖可更全面 |
|
||||
|
||||
**Utils 纯函数(19 文件):**
|
||||
|
||||
| 文件 | 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)、唯一性 | — |
|
||||
|
||||
**Context 构建(3 文件):**
|
||||
|
||||
| 文件 | 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 个函数 | — |
|
||||
|
||||
#### P1 — 重要模块
|
||||
|
||||
| 文件 | 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` 仅检查长度未验证内容 |
|
||||
|
||||
**Tool 子模块(8 文件):**
|
||||
|
||||
| 文件 | 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 本地重实现(避免重依赖链) | — |
|
||||
|
||||
#### 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 |
|
||||
|
||||
#### P3-P6 — 扩展覆盖(27 文件)
|
||||
|
||||
| 文件 | 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 | — |
|
||||
|
||||
### 4.3 评分汇总
|
||||
|
||||
| 等级 | 文件数 | 占比 |
|
||||
|------|--------|------|
|
||||
| **GOOD** | 46 | 55% |
|
||||
| **ACCEPTABLE** | 32 | 38% |
|
||||
| **WEAK** | 6 | 7% |
|
||||
|
||||
## 5. 系统性问题
|
||||
|
||||
### 5.1 断言过弱(Smell: `toContain` 代替精确匹配)
|
||||
|
||||
以下文件的部分测试使用 `toContain` 或 `not.toBeNull` 检查结果,当实现返回包含目标子串的任何字符串时测试仍通过,无法检测格式错误:
|
||||
|
||||
| 文件 | 受影响函数 | 建议 |
|
||||
|------|-----------|------|
|
||||
| `file.test.ts` | addLineNumbers | 断言完整输出格式 |
|
||||
| `diff.test.ts` | getPatchFromContents | 验证 hunk 内容正确性 |
|
||||
| `notebook.test.ts` | mapNotebookCellsToToolResult | 验证合并后内容 |
|
||||
| `uuid.test.ts` | validateUuid (uppercase) | 断言标准化后的精确值 |
|
||||
|
||||
### 5.2 集成测试空白
|
||||
|
||||
Spec 定义的三个集成测试均未创建:
|
||||
|
||||
| 计划 | 状态 | 依赖 |
|
||||
|------|------|------|
|
||||
| `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 基础设施。
|
||||
|
||||
### 5.3 Mock 相关
|
||||
|
||||
| 问题 | 影响文件 | 说明 |
|
||||
|------|----------|------|
|
||||
| 未 mock 重依赖 | `gitOperationTracking.test.ts` | `trackGitOperations` 调用 analytics/bootstrap,测试仅覆盖 `detectGitOperation`(无副作用) |
|
||||
| env 恢复不完整 | `providers.test.ts` | 仅删除已知 key,新增 env var 会导致测试泄漏 |
|
||||
|
||||
### 5.4 潜在 Bug
|
||||
|
||||
| 文件 | 函数 | 问题 |
|
||||
|------|------|------|
|
||||
| ~~`envValidation.test.ts`~~ | ~~validateBoundedIntEnvVar~~ | ~~value=1 无下界检查~~ — **已确认**:函数仅校验 `parsed > 0` 和 `parsed <= upperLimit`,不强制 `parsed >= defaultValue`,为设计意图 |
|
||||
|
||||
### 5.5 已知限制
|
||||
|
||||
| 模块 | 问题 |
|
||||
|------|------|
|
||||
| `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, 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 导入。
|
||||
|
||||
## 6. 完成状态
|
||||
|
||||
> 更新日期: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` | 核心循环,需完整集成环境 |
|
||||
| `services/api/claude.ts` | 需 mock SDK 流式响应 |
|
||||
| `spawnMultiAgent.ts` | 50+ 依赖 |
|
||||
| `modelCost.ts` | 依赖 bootstrap/state + analytics |
|
||||
| `mcp/dateTimeParser.ts` | 调用 Haiku API |
|
||||
| `screens/` / `components/` | 需 Ink 渲染测试 |
|
||||
@@ -1,444 +0,0 @@
|
||||
# ULTRAPLAN(增强规划)实现分析
|
||||
|
||||
> 生成日期:2026-04-02
|
||||
> Feature Flag:`FEATURE_ULTRAPLAN=1`
|
||||
> 引用数:10(跨 8 个文件)
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
ULTRAPLAN 是一个**远程增强规划**功能,将用户的规划请求发送到 Claude Code on the Web(CCR,云端容器)执行。使用 Opus 模型在云端生成高级计划,用户可以在浏览器中编辑和审批,然后选择在云端继续执行或将计划"传送"回本地终端执行。
|
||||
|
||||
**核心卖点**:
|
||||
- 终端不被阻塞 — 远程在云端规划,本地可继续工作
|
||||
- 使用最强大的模型(Opus)
|
||||
- 用户可在浏览器中实时查看和编辑计划
|
||||
- 支持多轮迭代(云端可追问,用户在浏览器回复)
|
||||
|
||||
---
|
||||
|
||||
## 二、架构总览
|
||||
|
||||
```
|
||||
用户输入 "ultraplan xxx"
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ 关键字检测层 (keyword.ts) │ 识别 "ultraplan" 关键字
|
||||
│ + 输入处理层 (processUserInput) │ 重写为 /ultraplan 命令
|
||||
└───────────┬─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ 命令处理层 (ultraplan.tsx) │ launchUltraplan()
|
||||
│ - 前置校验(资格、防重入) │ → launchDetached()
|
||||
│ - 构建提示词 │ buildUltraplanPrompt()
|
||||
└───────────┬─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ 远程会话层 │ teleportToRemote()
|
||||
│ - 创建 CCR 云端会话 │ permissionMode: 'plan'
|
||||
│ - 设置 plan 权限模式 │ model: Opus
|
||||
└───────────┬─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ 轮询层 (ccrSession.ts) │ pollForApprovedExitPlanMode()
|
||||
│ - ExitPlanModeScanner │ 每 3 秒轮询事件流
|
||||
│ - 状态机: running → needs_input │ 超时: 30 分钟
|
||||
│ → plan_ready │
|
||||
└───────────┬─────────────────────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
▼ ▼
|
||||
approved teleport
|
||||
(云端执行) (传送回本地)
|
||||
│ │
|
||||
│ ▼
|
||||
│ UltraplanChoiceDialog
|
||||
│ 用户选择执行方式
|
||||
▼ ▼
|
||||
完成通知 本地执行计划
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、模块详解
|
||||
|
||||
### 3.1 关键字检测 — `src/utils/ultraplan/keyword.ts`
|
||||
|
||||
负责检测用户输入中的 "ultraplan" 关键字。检测逻辑相当精细,避免误触发:
|
||||
|
||||
**触发条件**:输入中包含独立的 `ultraplan` 单词(大小写不敏感)。
|
||||
|
||||
**不触发的场景**:
|
||||
- 在引号/括号内:`` `ultraplan` ``、`"ultraplan"`、`[ultraplan]`、`{ultraplan}`
|
||||
- 路径/标识符上下文:`src/ultraplan/foo.ts`、`ultraplan.tsx`、`--ultraplan-mode`
|
||||
- 问句:`ultraplan?`
|
||||
- 斜杠命令内:`/rename ultraplan foo`
|
||||
- 已有 ultraplan 会话运行中或正在启动时
|
||||
|
||||
**关键字替换**:触发后将 `ultraplan` 替换为 `plan`,保持语法通顺(如 "please ultraplan this" → "please plan this")。
|
||||
|
||||
```typescript
|
||||
// 核心导出函数
|
||||
findUltraplanTriggerPositions(text) // 返回触发位置数组
|
||||
hasUltraplanKeyword(text) // 布尔判断
|
||||
replaceUltraplanKeyword(text) // 替换第一个触发词为 "plan"
|
||||
```
|
||||
|
||||
### 3.2 命令注册 — `src/commands.ts`
|
||||
|
||||
```typescript
|
||||
const ultraplan = feature('ULTRAPLAN')
|
||||
? require('./commands/ultraplan.js').default
|
||||
: null
|
||||
```
|
||||
|
||||
命令仅在 `FEATURE_ULTRAPLAN=1` 时加载。命令定义:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'local-jsx',
|
||||
name: 'ultraplan',
|
||||
description: '~10–30 min · Claude Code on the web drafts an advanced plan...',
|
||||
argumentHint: '<prompt>',
|
||||
isEnabled: () => process.env.USER_TYPE === 'ant', // 仅 ant 用户可用
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:`isEnabled` 检查 `USER_TYPE === 'ant'`(Anthropic 内部用户),这是命令级限制。关键字触发路径没有此限制,只要 feature flag 开启即可。
|
||||
|
||||
### 3.3 核心命令实现 — `src/commands/ultraplan.tsx`
|
||||
|
||||
#### 3.3.1 入口函数 `call()`
|
||||
|
||||
处理 `/ultraplan <prompt>` 斜杠命令:
|
||||
|
||||
1. **无参数调用**:显示使用帮助文本
|
||||
2. **已有活跃会话**:返回 "already polling" 提示
|
||||
3. **正常调用**:设置 `ultraplanLaunchPending` 状态,触发 `UltraplanLaunchDialog` 对话框
|
||||
|
||||
#### 3.3.2 `launchUltraplan()`
|
||||
|
||||
公共启动入口,被三个路径共享:
|
||||
- 斜杠命令 (`/ultraplan`)
|
||||
- 关键字触发 (`processUserInput.ts`)
|
||||
- Plan 审批对话框的 "Ultraplan" 按钮 (`ExitPlanModePermissionRequest`)
|
||||
|
||||
关键逻辑:
|
||||
1. 防重入检查(`ultraplanSessionUrl` / `ultraplanLaunching`)
|
||||
2. 同步设置 `ultraplanLaunching = true` 防止竞态
|
||||
3. 异步调用 `launchDetached()`
|
||||
4. 立即返回启动消息(不等远程会话创建)
|
||||
|
||||
#### 3.3.3 `launchDetached()`
|
||||
|
||||
异步后台流程:
|
||||
|
||||
1. **获取模型**:从 GrowthBook 读取 `tengu_ultraplan_model`,默认 `opus46` 的 firstParty ID
|
||||
2. **资格检查**:`checkRemoteAgentEligibility()` — 验证用户是否有权限使用远程 agent
|
||||
3. **构建提示词**:`buildUltraplanPrompt(blurb, seedPlan)`
|
||||
- 如有 `seedPlan`(来自 plan 审批对话框),作为草稿前缀
|
||||
- 加载 `prompt.txt` 中的指令模板
|
||||
- 附加用户 blurb
|
||||
4. **创建远程会话**:`teleportToRemote()`
|
||||
- `permissionMode: 'plan'` — 远程以 plan 模式运行
|
||||
- `ultraplan: true` — 标记为 ultraplan 会话
|
||||
- `useDefaultEnvironment: true` — 使用默认云端环境
|
||||
5. **注册任务**:`registerRemoteAgentTask()` 创建 `RemoteAgentTask` 追踪条目
|
||||
6. **启动轮询**:`startDetachedPoll()` 后台轮询审批状态
|
||||
|
||||
#### 3.3.4 提示词构建
|
||||
|
||||
```
|
||||
buildUltraplanPrompt(blurb, seedPlan?)
|
||||
```
|
||||
|
||||
- `prompt.txt`:当前为空文件(反编译丢失),原始内容应包含指导远程 agent 生成计划的系统指令
|
||||
- 开发者可通过 `ULTRAPLAN_PROMPT_FILE` 环境变量覆盖提示词文件(仅 `USER_TYPE=ant` 时生效)
|
||||
|
||||
#### 3.3.5 `startDetachedPoll()`
|
||||
|
||||
后台轮询管理:
|
||||
|
||||
1. 调用 `pollForApprovedExitPlanMode()` 等待计划审批
|
||||
2. 阶段变化时更新 `RemoteAgentTask.ultraplanPhase`(UI 展示)
|
||||
3. 审批完成后的两种路径:
|
||||
- **`executionTarget: 'remote'`**:用户选择在云端执行
|
||||
- 标记任务完成
|
||||
- 清除 `ultraplanSessionUrl`
|
||||
- 发送通知:结果将以 PR 形式提交
|
||||
- **`executionTarget: 'local'`**:用户选择传送回本地(teleport)
|
||||
- 设置 `ultraplanPendingChoice`
|
||||
- 触发 `UltraplanChoiceDialog` 对话框
|
||||
4. 失败时:归档远程会话、清除状态、发送错误通知
|
||||
|
||||
#### 3.3.6 `stopUltraplan()`
|
||||
|
||||
用户主动停止:
|
||||
|
||||
1. `RemoteAgentTask.kill()` 归档远程会话
|
||||
2. 清除所有 ultraplan 状态(`ultraplanSessionUrl`、`ultraplanPendingChoice`、`ultraplanLaunching`)
|
||||
3. 发送停止通知
|
||||
|
||||
### 3.4 CCR 会话轮询 — `src/utils/ultraplan/ccrSession.ts`
|
||||
|
||||
#### 3.4.1 `ExitPlanModeScanner`
|
||||
|
||||
纯状态机,无 I/O。摄入 `SDKMessage[]` 事件批次,分类 `ExitPlanMode` 工具调用的结果。
|
||||
|
||||
**状态类型**:
|
||||
|
||||
```typescript
|
||||
type ScanResult =
|
||||
| { kind: 'approved' } // 用户批准了计划
|
||||
| { kind: 'teleport' } // 用户点击"传送回本地"
|
||||
| { kind: 'rejected' } // 用户拒绝(可继续迭代)
|
||||
| { kind: 'pending' } // 等待用户审批中
|
||||
| { kind: 'terminated' } // 远程会话意外终止
|
||||
| { kind: 'unchanged' } // 无新事件,状态不变
|
||||
```
|
||||
|
||||
**优先级**:approved > terminated > rejected > pending > unchanged
|
||||
|
||||
**关键设计**:
|
||||
- 同一批事件可能包含审批和后续崩溃 — 不丢弃已审批的计划
|
||||
- 拒绝后重新扫描(`rescanAfterRejection`),因为新事件可能包含修改后的计划
|
||||
- 使用 `is_error: true` 判断拒绝,`content` 中查找标记提取计划文本
|
||||
|
||||
#### 3.4.2 `pollForApprovedExitPlanMode()`
|
||||
|
||||
轮询主循环:
|
||||
|
||||
- **轮询间隔**:3 秒
|
||||
- **超时**:30 分钟
|
||||
- **容错**:连续 5 次网络错误才放弃
|
||||
- **阶段推断**:
|
||||
- `hasPendingPlan`(有 ExitPlanMode 无结果)→ `plan_ready`
|
||||
- `quietIdle`(空闲且无新事件)→ `needs_input`(远程在等用户输入)
|
||||
- 其他 → `running`
|
||||
|
||||
#### 3.4.3 计划文本提取
|
||||
|
||||
两种提取路径:
|
||||
|
||||
1. **Approved**:从 `tool_result` 中查找 `## Approved Plan:\n` 或 `## Approved Plan (edited by user):\n` 标记
|
||||
2. **Teleport**:从 `tool_result` 中查找 `__ULTRAPLAN_TELEPORT_SENTINEL__` 标记(浏览器端嵌入)
|
||||
|
||||
### 3.5 输入处理集成 — `src/utils/processUserInput/processUserInput.ts`
|
||||
|
||||
关键字触发路径(在斜杠命令处理之前):
|
||||
|
||||
```typescript
|
||||
if (feature('ULTRAPLAN') &&
|
||||
mode === 'prompt' && // 非非交互模式
|
||||
!isNonInteractiveSession && // 非后台会话
|
||||
inputString !== null &&
|
||||
!inputString.startsWith('/') && // 非斜杠命令
|
||||
!ultraplanSessionUrl && // 无活跃会话
|
||||
!ultraplanLaunching && // 非正在启动
|
||||
hasUltraplanKeyword(inputString)) {
|
||||
// 重写为 /ultraplan 命令
|
||||
const rewritten = replaceUltraplanKeyword(inputString).trim()
|
||||
await processSlashCommand(`/ultraplan ${rewritten}`, ...)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 UI 层
|
||||
|
||||
#### 3.6.1 彩虹高亮 — `src/components/PromptInput/PromptInput.tsx`
|
||||
|
||||
当输入中检测到 `ultraplan` 关键字时:
|
||||
- 对每个字符施加**彩虹渐变色**高亮(`getRainbowColor()`)
|
||||
- 显示通知:"This prompt will launch an ultraplan session in Claude Code on the web"
|
||||
|
||||
#### 3.6.2 预启动对话框 — `UltraplanLaunchDialog`
|
||||
|
||||
在 REPL 的 `focusedInputDialog === 'ultraplan-launch'` 时渲染。
|
||||
|
||||
用户选择:
|
||||
- **确认**:调用 `launchUltraplan()`,先添加命令回显,异步启动远程会话
|
||||
- **取消**:清除 `ultraplanLaunchPending` 状态
|
||||
|
||||
#### 3.6.3 计划选择对话框 — `UltraplanChoiceDialog`
|
||||
|
||||
在 `focusedInputDialog === 'ultraplan-choice'` 时渲染。
|
||||
|
||||
当 teleport 路径返回已审批计划时,用户可选择执行方式。
|
||||
|
||||
#### 3.6.4 Plan 审批按钮 — `ExitPlanModePermissionRequest`
|
||||
|
||||
本地 Plan Mode 的审批对话框中,如果 `feature('ULTRAPLAN')` 开启,会显示额外的 "Ultraplan" 按钮:
|
||||
- 将当前本地计划作为 `seedPlan` 发送给远程
|
||||
- 按钮仅在无活跃 ultraplan 会话时显示
|
||||
|
||||
### 3.7 应用状态 — `src/state/AppStateStore.ts`
|
||||
|
||||
```typescript
|
||||
interface AppState {
|
||||
ultraplanLaunching?: boolean // 防重入锁(5 秒窗口)
|
||||
ultraplanSessionUrl?: string // 活跃远程会话 URL
|
||||
ultraplanPendingChoice?: { // 已审批计划等待选择
|
||||
plan: string
|
||||
sessionId: string
|
||||
taskId: string
|
||||
}
|
||||
ultraplanLaunchPending?: { // 预启动对话框
|
||||
blurb: string
|
||||
}
|
||||
isUltraplanMode?: boolean // 远程端:CCR 侧的 ultraplan 标记
|
||||
}
|
||||
```
|
||||
|
||||
### 3.8 远程任务追踪 — `src/tasks/RemoteAgentTask/RemoteAgentTask.tsx`
|
||||
|
||||
Ultraplan 使用 `RemoteAgentTask` 基础设施追踪远程会话:
|
||||
|
||||
```typescript
|
||||
registerRemoteAgentTask({
|
||||
remoteTaskType: 'ultraplan',
|
||||
session: { id, title },
|
||||
command: blurb,
|
||||
isUltraplan: true // 特殊标记,跳过通用轮询逻辑
|
||||
})
|
||||
```
|
||||
|
||||
`extractPlanFromLog()` 从 `<ultraplan>...</ultraplan>` XML 标签中提取计划内容。
|
||||
|
||||
---
|
||||
|
||||
## 四、数据流时序
|
||||
|
||||
```
|
||||
时间线 →
|
||||
|
||||
用户 本地 CLI CCR 云端
|
||||
│ │ │
|
||||
│ "ultraplan xxx" │ │
|
||||
│──────────────────────>│ │
|
||||
│ │ keyword 检测 + 重写 │
|
||||
│ │ /ultraplan "plan xxx" │
|
||||
│ │ │
|
||||
│ [UltraplanLaunch │ │
|
||||
│ Dialog] │ │
|
||||
│──── confirm ─────────>│ │
|
||||
│ │ launchDetached() │
|
||||
│ │─────────────────────────────>│
|
||||
│ │ teleportToRemote() │
|
||||
│ │ (permissionMode: 'plan') │
|
||||
│ │ │
|
||||
│ "Starting..." │ │
|
||||
│<──────────────────────│ │
|
||||
│ │ │
|
||||
│ (终端空闲,可继续) │ startDetachedPoll() │
|
||||
│ │ ═══ 3s 轮询循环 ═══ │
|
||||
│ │ │
|
||||
│ │ [浏览器打开]│
|
||||
│ │ [云端生成计划]
|
||||
│ │ │
|
||||
│ │ ← needs_input ─────────────│
|
||||
│ │ (云端追问用户) │
|
||||
│ │ │
|
||||
│ │ [用户在浏览器回复]
|
||||
│ │ │
|
||||
│ │ ← plan_ready ──────────────│
|
||||
│ │ (ExitPlanMode 等待审批) │
|
||||
│ │ │
|
||||
│ │ [用户审批/编辑]
|
||||
│ │ │
|
||||
│ ┌───────┤ ← approved ────────────────│
|
||||
│ │ │ │
|
||||
│ [远程执行] │ │ │
|
||||
│ 通知完成 │ │ │
|
||||
│ │ │ │
|
||||
│ └── OR ─┤ ← teleport ───────────────│
|
||||
│ │ │
|
||||
│ [UltraplanChoice │ │
|
||||
│ Dialog] │ │
|
||||
│── 选择执行方式 ───────>│ │
|
||||
│ │ 本地执行计划 │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、关键文件清单
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/utils/ultraplan/keyword.ts` | 关键字检测、高亮位置计算、关键字替换 |
|
||||
| `src/utils/ultraplan/ccrSession.ts` | CCR 会话轮询、ExitPlanMode 状态机、计划文本提取 |
|
||||
| `src/utils/ultraplan/prompt.txt` | 远程指令模板(当前为空,需重建) |
|
||||
| `src/commands/ultraplan.tsx` | `/ultraplan` 命令、启动/停止逻辑、提示词构建 |
|
||||
| `src/utils/processUserInput/processUserInput.ts` | 关键字触发 → `/ultraplan` 命令路由 |
|
||||
| `src/components/PromptInput/PromptInput.tsx` | 彩虹高亮 + 通知提示 |
|
||||
| `src/screens/REPL.tsx` | 对话框渲染(UltraplanLaunchDialog / UltraplanChoiceDialog) |
|
||||
| `src/components/permissions/ExitPlanModePermissionRequest/` | Plan 审批中的 "Ultraplan" 按钮 |
|
||||
| `src/state/AppStateStore.ts` | ultraplan 相关状态字段定义 |
|
||||
| `src/tasks/RemoteAgentTask/RemoteAgentTask.tsx` | 远程任务追踪 + `<ultraplan>` 标签提取 |
|
||||
| `src/constants/xml.ts` | `ULTRAPLAN_TAG = 'ultraplan'` |
|
||||
|
||||
---
|
||||
|
||||
## 六、依赖关系
|
||||
|
||||
### 外部依赖
|
||||
|
||||
| 依赖 | 用途 | 必要性 |
|
||||
|------|------|--------|
|
||||
| `teleportToRemote()` | 创建 CCR 云端会话 | 必须 — 核心功能 |
|
||||
| `checkRemoteAgentEligibility()` | 验证用户远程 agent 使用资格 | 必须 — 前置检查 |
|
||||
| `archiveRemoteSession()` | 归档/终止远程会话 | 必须 — 清理 |
|
||||
| GrowthBook `tengu_ultraplan_model` | 获取使用的模型 ID | 可选 — 默认 opus46 |
|
||||
| `@anthropic-ai/sdk` | SDKMessage 类型 | 必须 — 类型定义 |
|
||||
| `pollRemoteSessionEvents()` | 事件流分页轮询 | 必须 — 轮询基础设施 |
|
||||
|
||||
### 内部依赖
|
||||
|
||||
- **ExitPlanModeV2Tool**:远程端调用的工具,触发 plan 审批流程
|
||||
- **RemoteAgentTask**:任务追踪和状态管理基础设施
|
||||
- **AppState Store**:ultraplan 状态管理
|
||||
|
||||
---
|
||||
|
||||
## 七、当前状态与补全要点
|
||||
|
||||
| 组件 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 关键字检测 | ✅ 完整 | `keyword.ts` 逻辑完善 |
|
||||
| 命令框架 | ✅ 完整 | 注册、路由、防重入完整 |
|
||||
| 启动流程 | ✅ 完整 | `launchUltraplan` / `launchDetached` 完整 |
|
||||
| CCR 轮询 | ✅ 完整 | `ccrSession.ts` 状态机完整 |
|
||||
| UI 高亮/通知 | ✅ 完整 | 彩虹高亮 + 提示通知完整 |
|
||||
| 状态管理 | ✅ 完整 | AppState 字段完整 |
|
||||
| `prompt.txt` | ❌ 空文件 | 需要重建远程指令模板 |
|
||||
| `UltraplanLaunchDialog` | ⚠️ 全局声明 | 组件实现未找到(可能在内置包中) |
|
||||
| `UltraplanChoiceDialog` | ⚠️ 全局声明 | 组件实现未找到(可能在内置包中) |
|
||||
| `isEnabled` 限制 | ⚠️ `USER_TYPE === 'ant'` | 命令级限制,仅 Anthropic 内部用户 |
|
||||
|
||||
### 补全建议
|
||||
|
||||
1. **重建 `prompt.txt`**:这是远程 agent 的核心指令,定义如何进行多 agent 探索式规划。需要设计:
|
||||
- 规划方法论(多角度分析、风险评估、分阶段执行)
|
||||
- ExitPlanMode 工具的使用引导
|
||||
- 输出格式要求
|
||||
|
||||
2. **对话框组件**:`UltraplanLaunchDialog` 和 `UltraplanChoiceDialog` 在 `global.d.ts` 中声明但实现缺失,需要新建:
|
||||
- Launch Dialog:确认对话框(含 CCR 使用条款链接)
|
||||
- Choice Dialog:展示已审批计划 + 执行方式选择
|
||||
|
||||
3. **放宽 `isEnabled`**:如果要让非 ant 用户使用斜杠命令,需移除 `USER_TYPE === 'ant'` 检查
|
||||
|
||||
---
|
||||
|
||||
## 八、与相关 Feature 的关系
|
||||
|
||||
| Feature | 关系 |
|
||||
|---------|------|
|
||||
| `ULTRATHINK` | 类似的高能力模式,但 `ULTRATHINK` 只调高 effort,不启动远程会话 |
|
||||
| `FORK_SUBAGENT` | Ultraplan 不使用 fork subagent,使用的是 CCR 远程 agent |
|
||||
| `COORDINATOR_MODE` | 不同范式的多 agent,Coordinator 在本地编排,Ultraplan 在云端 |
|
||||
| `BRIDGE_MODE` | 底层依赖相同的 `teleportToRemote()` 基础设施 |
|
||||
| `ExitPlanModeTool` | 远程端的审批机制,Ultraplan 的核心交互模型 |
|
||||
22
mint.json
22
mint.json
@@ -124,7 +124,10 @@
|
||||
"docs/features/coordinator-mode",
|
||||
"docs/features/fork-subagent",
|
||||
"docs/features/daemon",
|
||||
"docs/features/teammem"
|
||||
"docs/features/teammem",
|
||||
"docs/features/pipes-and-lan",
|
||||
"docs/features/lan-pipes",
|
||||
"docs/features/uds-inbox"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -133,6 +136,7 @@
|
||||
"docs/features/kairos",
|
||||
"docs/features/voice-mode",
|
||||
"docs/features/bridge-mode",
|
||||
"docs/features/remote-control-self-hosting",
|
||||
"docs/features/proactive",
|
||||
"docs/features/ultraplan"
|
||||
]
|
||||
@@ -144,7 +148,11 @@
|
||||
"docs/features/tree-sitter-bash",
|
||||
"docs/features/bash-classifier",
|
||||
"docs/features/web-browser-tool",
|
||||
"docs/features/experimental-skill-search"
|
||||
"docs/features/web-search-tool",
|
||||
"docs/features/experimental-skill-search",
|
||||
"docs/features/langfuse-monitoring",
|
||||
"docs/features/computer-use",
|
||||
"docs/features/claude-in-chrome-mcp"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -174,7 +182,15 @@
|
||||
"docs/testing-spec.md",
|
||||
"docs/REVISION-PLAN.md",
|
||||
"docs/feature-exploration-plan.md",
|
||||
"docs/ultraplan-implementation.md"
|
||||
"docs/ultraplan-implementation.md",
|
||||
"docs/features/feature-flags-audit-complete.md",
|
||||
"docs/features/feature-flags-codex-review.md",
|
||||
"docs/features/growthbook-enablement-plan.md",
|
||||
"docs/features/computer-use-architecture-v2.md",
|
||||
"docs/features/computer-use-mcp-test-report.md",
|
||||
"docs/features/computer-use-tools-reference.md",
|
||||
"docs/features/computer-use-windows-enhancement.md",
|
||||
"docs/features/lan-pipes-implementation.md"
|
||||
],
|
||||
"footerSocials": {
|
||||
"github": "https://github.com/anthropics/claude-code"
|
||||
|
||||
32
package.json
32
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.2",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
@@ -25,8 +25,9 @@
|
||||
"bun": ">=1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"ccb": "dist/cli.js",
|
||||
"claude-code-best": "dist/cli.js"
|
||||
"ccb": "dist/cli-node.js",
|
||||
"ccb-bun": "dist/cli-bun.js",
|
||||
"claude-code-best": "dist/cli-node.js"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -34,8 +35,8 @@
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts/download-ripgrep.ts",
|
||||
"scripts/postinstall.cjs"
|
||||
"scripts/postinstall.cjs",
|
||||
"scripts/setup-chrome-mcp.mjs"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run build.ts",
|
||||
@@ -49,13 +50,18 @@
|
||||
"test": "bun test",
|
||||
"check:unused": "knip-bun",
|
||||
"health": "bun run scripts/health-check.ts",
|
||||
"postinstall": "node scripts/postinstall.cjs",
|
||||
"postinstall": "node scripts/postinstall.cjs && node scripts/setup-chrome-mcp.mjs",
|
||||
"docs:dev": "npx mintlify dev",
|
||||
"rcs": "bun run scripts/rcs.ts"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"mcp-chrome-bridge": "^1.0.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openai": "^6.33.0",
|
||||
"@types/he": "^1.2.3",
|
||||
"@langfuse/otel": "^5.1.0",
|
||||
"@langfuse/tracing": "^5.1.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
@@ -68,6 +74,7 @@
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@aws-sdk/client-bedrock": "^3.1020.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
||||
"@aws-sdk/client-sts": "^3.1020.0",
|
||||
@@ -101,11 +108,18 @@
|
||||
"@smithy/node-http-handler": "^4.5.1",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/cacache": "^20.0.1",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"@types/plist": "^3.0.5",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-reconciler": "^0.33.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/stack-utils": "^2.0.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ajv": "^8.18.0",
|
||||
"asciichart": "^1.5.25",
|
||||
"audio-capture-napi": "workspace:*",
|
||||
@@ -132,7 +146,6 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"image-processor-napi": "workspace:*",
|
||||
"indent-string": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
@@ -141,6 +154,7 @@
|
||||
"lru-cache": "^11.2.7",
|
||||
"marked": "^17.0.5",
|
||||
"modifiers-napi": "workspace:*",
|
||||
"openai": "^6.33.0",
|
||||
"p-map": "^7.0.4",
|
||||
"picomatch": "^4.0.4",
|
||||
"plist": "^3.1.0",
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface DisplayGeometry {
|
||||
scaleFactor: number
|
||||
originX: number
|
||||
originY: number
|
||||
label?: string
|
||||
isPrimary?: boolean
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
@@ -42,6 +44,7 @@ export interface ResolvePrepareCaptureResult extends ScreenshotResult {
|
||||
hidden: string[]
|
||||
activated?: string
|
||||
displayId: number
|
||||
captureError?: string
|
||||
}
|
||||
|
||||
export interface ComputerExecutorCapabilities {
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** Detect actual image MIME type from base64 data using magic bytes. */
|
||||
function detectMimeFromBase64(b64: string): string {
|
||||
// First byte is enough to distinguish PNG (0x89) from JPEG (0xFF)
|
||||
const c = b64.charCodeAt(0);
|
||||
if (c === 0x89) return "image/png";
|
||||
if (c === 0xFF) return "image/jpeg";
|
||||
// RIFF = WebP
|
||||
if (c === 0x52) return "image/webp";
|
||||
// GIF
|
||||
if (c === 0x47) return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
import { getDefaultTierForApp, getDeniedCategoryForApp, isPolicyDenied } from "./deniedApps.js";
|
||||
import type {
|
||||
ComputerExecutor,
|
||||
@@ -88,6 +101,8 @@ export type CuErrorKind =
|
||||
| "state_conflict" // wrong state for action (call sequence, mouse already held)
|
||||
| "grant_flag_required" // action needs a grant flag (systemKeyCombos, clipboard*) from request_access
|
||||
| "display_error" // display enumeration failed (platform)
|
||||
| "launch_failed" // failed to launch an external process (e.g. terminal)
|
||||
| "element_not_found" // UI element not found (e.g. window, automation element)
|
||||
| "other";
|
||||
|
||||
/**
|
||||
@@ -906,9 +921,10 @@ async function handleRequestAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const perms = recheck as { granted: false; accessibility: boolean; screenRecording: boolean };
|
||||
const missing: string[] = [];
|
||||
if (!recheck.accessibility) missing.push("Accessibility");
|
||||
if (!recheck.screenRecording) missing.push("Screen Recording");
|
||||
if (!perms.accessibility) missing.push("Accessibility");
|
||||
if (!perms.screenRecording) missing.push("Screen Recording");
|
||||
return errorResult(
|
||||
`macOS ${missing.join(" and ")} permission(s) not yet granted. ` +
|
||||
`The permission panel has been shown. Once the user grants the ` +
|
||||
@@ -1423,9 +1439,10 @@ async function handleRequestTeachAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const perms = recheck as { granted: false; accessibility: boolean; screenRecording: boolean };
|
||||
const missing: string[] = [];
|
||||
if (!recheck.accessibility) missing.push("Accessibility");
|
||||
if (!recheck.screenRecording) missing.push("Screen Recording");
|
||||
if (!perms.accessibility) missing.push("Accessibility");
|
||||
if (!perms.screenRecording) missing.push("Screen Recording");
|
||||
return errorResult(
|
||||
`macOS ${missing.join(" and ")} permission(s) not yet granted. ` +
|
||||
`The permission panel has been shown. Once the user grants the ` +
|
||||
@@ -2144,7 +2161,7 @@ async function handleScreenshot(
|
||||
|
||||
const monitorNote = await buildMonitorNote(
|
||||
adapter,
|
||||
shot.displayId,
|
||||
shot.displayId ?? 0,
|
||||
overrides.lastScreenshot?.displayId,
|
||||
overrides.onDisplayPinned !== undefined,
|
||||
);
|
||||
@@ -2158,7 +2175,7 @@ async function handleScreenshot(
|
||||
{
|
||||
type: "image",
|
||||
data: shot.base64,
|
||||
mimeType: "image/jpeg",
|
||||
mimeType: detectMimeFromBase64(shot.base64),
|
||||
},
|
||||
],
|
||||
screenshot: shot,
|
||||
@@ -2213,7 +2230,7 @@ async function handleScreenshot(
|
||||
|
||||
const monitorNote = await buildMonitorNote(
|
||||
adapter,
|
||||
shot.displayId,
|
||||
shot.displayId ?? 0,
|
||||
overrides.lastScreenshot?.displayId,
|
||||
overrides.onDisplayPinned !== undefined,
|
||||
);
|
||||
@@ -2227,7 +2244,7 @@ async function handleScreenshot(
|
||||
{
|
||||
type: "image",
|
||||
data: shot.base64,
|
||||
mimeType: "image/jpeg",
|
||||
mimeType: detectMimeFromBase64(shot.base64),
|
||||
},
|
||||
],
|
||||
// Piggybacked for serverDef.ts to stash on InternalServerContext.
|
||||
@@ -2306,7 +2323,7 @@ async function handleZoom(
|
||||
|
||||
// Return the image. NO `.screenshot` piggyback — this is the invariant.
|
||||
return {
|
||||
content: [{ type: "image", data: zoomed.base64, mimeType: "image/jpeg" }],
|
||||
content: [{ type: "image", data: zoomed.base64, mimeType: detectMimeFromBase64(zoomed.base64) }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4082,8 +4099,8 @@ export async function handleToolCall(
|
||||
);
|
||||
}
|
||||
tccState = {
|
||||
accessibility: osPerms.accessibility,
|
||||
screenRecording: osPerms.screenRecording,
|
||||
accessibility: (osPerms as { granted: false; accessibility: boolean; screenRecording: boolean }).accessibility,
|
||||
screenRecording: (osPerms as { granted: false; accessibility: boolean; screenRecording: boolean }).screenRecording,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ import type {
|
||||
SwiftBackend, WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
export type {
|
||||
DisplayGeometry,
|
||||
PrepareDisplayResult,
|
||||
AppInfo,
|
||||
InstalledApp,
|
||||
RunningApp,
|
||||
ScreenshotResult,
|
||||
ResolvePrepareCaptureResult,
|
||||
WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface DisplayGeometry {
|
||||
height: number
|
||||
scaleFactor: number
|
||||
displayId: number
|
||||
label?: string
|
||||
isPrimary?: boolean
|
||||
}
|
||||
|
||||
export interface PrepareDisplayResult {
|
||||
@@ -37,6 +39,9 @@ export interface ResolvePrepareCaptureResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
captureError?: string
|
||||
displayId?: number
|
||||
hidden?: string[]
|
||||
}
|
||||
|
||||
export interface WindowDisplayInfo {
|
||||
|
||||
@@ -92,14 +92,14 @@ function Box({
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={autoFocus}
|
||||
onClick={onClick}
|
||||
onFocus={onFocus}
|
||||
onFocusCapture={onFocusCapture}
|
||||
onBlur={onBlur}
|
||||
onBlurCapture={onBlurCapture}
|
||||
onFocus={onFocus as unknown as (event: React.FocusEvent<Element, Element>) => void}
|
||||
onFocusCapture={onFocusCapture as unknown as (event: React.FocusEvent<Element, Element>) => void}
|
||||
onBlur={onBlur as unknown as (event: React.FocusEvent<Element, Element>) => void}
|
||||
onBlurCapture={onBlurCapture as unknown as (event: React.FocusEvent<Element, Element>) => void}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyDown={onKeyDown as unknown as (event: React.KeyboardEvent<Element>) => void}
|
||||
onKeyDownCapture={onKeyDownCapture as unknown as (event: React.KeyboardEvent<Element>) => void}
|
||||
style={{
|
||||
flexWrap,
|
||||
flexDirection,
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
*/
|
||||
import bidiFactory from 'bidi-js'
|
||||
|
||||
type BidiInstance = {
|
||||
getEmbeddingLevels: (text: string, defaultDirection?: string) => { paragraphLevel: number; levels: Uint8Array }
|
||||
getReorderSegments: (text: string, embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, start?: number, end?: number) => [number, number][]
|
||||
getVisualOrder: (reorderSegments: [number, number][]) => number[]
|
||||
}
|
||||
|
||||
type ClusteredChar = {
|
||||
value: string
|
||||
width: number
|
||||
@@ -23,7 +29,7 @@ type ClusteredChar = {
|
||||
hyperlink: string | undefined
|
||||
}
|
||||
|
||||
let bidiInstance: ReturnType<typeof bidiFactory> | undefined
|
||||
let bidiInstance: BidiInstance | undefined
|
||||
let needsSoftwareBidi: boolean | undefined
|
||||
|
||||
function needsBidi(): boolean {
|
||||
@@ -38,7 +44,7 @@ function needsBidi(): boolean {
|
||||
|
||||
function getBidi() {
|
||||
if (!bidiInstance) {
|
||||
bidiInstance = bidiFactory()
|
||||
bidiInstance = (bidiFactory as unknown as () => BidiInstance)()
|
||||
}
|
||||
return bidiInstance
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClickEvent } from './click-event.js'
|
||||
import type { FocusEvent } from './focus-event.js'
|
||||
import type { KeyboardEvent } from './keyboard-event.js'
|
||||
import type { MouseActionEvent } from './mouse-action-event.js'
|
||||
import type { PasteEvent } from './paste-event.js'
|
||||
import type { ResizeEvent } from './resize-event.js'
|
||||
|
||||
@@ -9,6 +10,7 @@ type FocusEventHandler = (event: FocusEvent) => void
|
||||
type PasteEventHandler = (event: PasteEvent) => void
|
||||
type ResizeEventHandler = (event: ResizeEvent) => void
|
||||
type ClickEventHandler = (event: ClickEvent) => void
|
||||
type MouseActionEventHandler = (event: MouseActionEvent) => void
|
||||
type HoverEventHandler = () => void
|
||||
|
||||
/**
|
||||
@@ -33,6 +35,9 @@ export type EventHandlerProps = {
|
||||
onResize?: ResizeEventHandler
|
||||
|
||||
onClick?: ClickEventHandler
|
||||
onMouseDown?: MouseActionEventHandler
|
||||
onMouseUp?: MouseActionEventHandler
|
||||
onMouseDrag?: MouseActionEventHandler
|
||||
onMouseEnter?: HoverEventHandler
|
||||
onMouseLeave?: HoverEventHandler
|
||||
}
|
||||
@@ -51,6 +56,9 @@ export const HANDLER_FOR_EVENT: Record<
|
||||
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
||||
resize: { bubble: 'onResize' },
|
||||
click: { bubble: 'onClick' },
|
||||
mousedown: { bubble: 'onMouseDown' },
|
||||
mouseup: { bubble: 'onMouseUp' },
|
||||
mousedrag: { bubble: 'onMouseDrag' },
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +76,9 @@ export const EVENT_HANDLER_PROPS = new Set<string>([
|
||||
'onPasteCapture',
|
||||
'onResize',
|
||||
'onClick',
|
||||
'onMouseDown',
|
||||
'onMouseUp',
|
||||
'onMouseDrag',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave',
|
||||
])
|
||||
|
||||
44
packages/@ant/ink/src/core/events/mouse-action-event.ts
Normal file
44
packages/@ant/ink/src/core/events/mouse-action-event.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Event } from './event.js'
|
||||
import type { EventTarget } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Mouse action event (mousedown, mouseup, mousedrag).
|
||||
* Bubbles from the deepest hit node up through parentNode.
|
||||
*/
|
||||
export class MouseActionEvent extends Event {
|
||||
/** Action type */
|
||||
readonly type: 'mousedown' | 'mouseup' | 'mousedrag'
|
||||
/** 0-indexed screen column */
|
||||
readonly col: number
|
||||
/** 0-indexed screen row */
|
||||
readonly row: number
|
||||
/** Mouse button number */
|
||||
readonly button: number
|
||||
/**
|
||||
* Column relative to the current handler's Box.
|
||||
* Recomputed before each handler fires.
|
||||
*/
|
||||
localCol = 0
|
||||
/** Row relative to the current handler's Box. */
|
||||
localRow = 0
|
||||
|
||||
constructor(
|
||||
type: 'mousedown' | 'mouseup' | 'mousedrag',
|
||||
col: number,
|
||||
row: number,
|
||||
button: number,
|
||||
) {
|
||||
super()
|
||||
this.type = type
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.button = button
|
||||
}
|
||||
|
||||
/** Recompute local coords relative to the target Box. */
|
||||
prepareForTarget(target: EventTarget): void {
|
||||
const dom = target as unknown as { yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number } }
|
||||
this.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0)
|
||||
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
import { ClickEvent } from './events/click-event.js'
|
||||
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||
import { MouseActionEvent } from './events/mouse-action-event.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
|
||||
/**
|
||||
@@ -128,3 +129,43 @@ export function dispatchHover(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchMouseAction(
|
||||
root: DOMElement,
|
||||
col: number,
|
||||
row: number,
|
||||
button: number,
|
||||
type: 'mousedown' | 'mouseup' | 'mousedrag',
|
||||
targetOverride?: DOMElement,
|
||||
): DOMElement | null {
|
||||
let target: DOMElement | undefined =
|
||||
targetOverride ?? hitTest(root, col, row) ?? undefined
|
||||
if (!target) return null
|
||||
|
||||
const propName =
|
||||
type === 'mousedown'
|
||||
? 'onMouseDown'
|
||||
: type === 'mouseup'
|
||||
? 'onMouseUp'
|
||||
: 'onMouseDrag'
|
||||
|
||||
const event = new MouseActionEvent(type, col, row, button)
|
||||
let handledBy: DOMElement | null = null
|
||||
|
||||
while (target) {
|
||||
const handler = target._eventHandlers?.[propName] as
|
||||
| ((event: MouseActionEvent) => void)
|
||||
| undefined
|
||||
if (handler) {
|
||||
handledBy ??= target
|
||||
event.prepareForTarget(target)
|
||||
handler(event)
|
||||
if (event.didStopImmediatePropagation()) {
|
||||
return handledBy
|
||||
}
|
||||
}
|
||||
target = target.parentNode as DOMElement | undefined
|
||||
}
|
||||
|
||||
return handledBy
|
||||
}
|
||||
|
||||
@@ -352,8 +352,7 @@ export default class Ink {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error @types/react-reconciler@0.32.3 declares 11 args with transitionCallbacks,
|
||||
// but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks)
|
||||
// @ts-ignore createContainer arg count varies across react-reconciler versions
|
||||
this.container = reconciler.createContainer(
|
||||
this.rootNode,
|
||||
ConcurrentRoot,
|
||||
@@ -367,6 +366,7 @@ export default class Ink {
|
||||
noop, // onDefaultTransitionIndicator
|
||||
)
|
||||
|
||||
// @ts-ignore MACRO-replaced comparison — always false in production builds
|
||||
if ("production" === 'development') {
|
||||
reconciler.injectIntoDevTools({
|
||||
bundleType: 0,
|
||||
@@ -952,7 +952,7 @@ export default class Ink {
|
||||
|
||||
pause(): void {
|
||||
// Flush pending React updates and render before pausing.
|
||||
// @ts-expect-error flushSyncFromReconciler exists in react-reconciler 0.31 but not in @types/react-reconciler
|
||||
// @ts-ignore flushSyncFromReconciler exists in react-reconciler but not in @types
|
||||
reconciler.flushSyncFromReconciler()
|
||||
this.onRender()
|
||||
|
||||
@@ -1701,9 +1701,9 @@ export default class Ink {
|
||||
</App>
|
||||
)
|
||||
|
||||
// @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler
|
||||
// @ts-ignore updateContainerSync exists in react-reconciler but not in @types
|
||||
reconciler.updateContainerSync(tree, this.container, null, noop)
|
||||
// @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler
|
||||
// @ts-ignore flushSyncWork exists in react-reconciler but not in @types
|
||||
reconciler.flushSyncWork()
|
||||
}
|
||||
|
||||
@@ -1773,9 +1773,9 @@ export default class Ink {
|
||||
this.drainTimer = null
|
||||
}
|
||||
|
||||
// @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler
|
||||
// @ts-ignore updateContainerSync exists in react-reconciler but not in @types
|
||||
reconciler.updateContainerSync(null, this.container, null, noop)
|
||||
// @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler
|
||||
// @ts-ignore flushSyncWork exists in react-reconciler but not in @types
|
||||
reconciler.flushSyncWork()
|
||||
instances.delete(this.options.stdout)
|
||||
|
||||
@@ -1883,8 +1883,8 @@ export default class Ink {
|
||||
let reentered = false
|
||||
const intercept = (
|
||||
chunk: Uint8Array | string,
|
||||
encodingOrCb?: BufferEncoding | ((err?: Error) => void),
|
||||
cb?: (err?: Error) => void,
|
||||
encodingOrCb?: BufferEncoding | ((err?: Error | null) => void),
|
||||
cb?: (err?: Error | null) => void,
|
||||
): boolean => {
|
||||
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb
|
||||
// Reentrancy guard: logger.debug → writeToStderr → here. Pass
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import type { Key, InputEvent } from '../core/events/input-event.js'
|
||||
import type { ParsedKey } from '../core/parse-keypress.js'
|
||||
import useInput from './use-input.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
|
||||
@@ -212,8 +214,8 @@ export function useSearchInput({
|
||||
|
||||
// Bridge: subscribe via useInput and adapt to KeyboardEvent
|
||||
useInput(
|
||||
(_input: string, _key: unknown, event: { keypress: string }) => {
|
||||
handleKeyDown(new KeyboardEvent(event.keypress))
|
||||
(_input: string, _key: Key, event: InputEvent) => {
|
||||
handleKeyDown(new KeyboardEvent(event.keypress as ParsedKey))
|
||||
},
|
||||
{ isActive },
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// ============================================================
|
||||
export { default as wrappedRender, renderSync, createRoot } from './core/root.js'
|
||||
export type { RenderOptions, Instance, Root } from './core/root.js'
|
||||
|
||||
export * from './theme/theme-types.js'
|
||||
// InkCore class
|
||||
export { default as Ink } from './core/ink.js'
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { InputEvent } from '../core/events/input-event.js'
|
||||
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before
|
||||
// other handlers process them - this is required for chord sequence support
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings
|
||||
import useInput, { type Key } from '../hooks/use-input.js'
|
||||
import useInput from '../hooks/use-input.js'
|
||||
import type { Key } from '../core/events/input-event.js'
|
||||
import { KeybindingProvider } from './KeybindingContext.js'
|
||||
import { resolveKeyWithChordState } from './resolver.js'
|
||||
import type {
|
||||
|
||||
12
packages/@ant/ink/utils/systemThemeWatcher.ts
Normal file
12
packages/@ant/ink/utils/systemThemeWatcher.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { SystemTheme } from '../src/theme/systemTheme.js'
|
||||
|
||||
/**
|
||||
* Watch for live terminal theme changes via OSC 11 polling.
|
||||
* Stub implementation for the standalone @anthropic/ink package.
|
||||
*/
|
||||
export function watchSystemTheme(
|
||||
_querier: unknown,
|
||||
_setTheme: React.Dispatch<React.SetStateAction<SystemTheme>>,
|
||||
): () => void {
|
||||
return () => {}
|
||||
}
|
||||
@@ -37,6 +37,19 @@ const DEFAULT_FEATURES = [
|
||||
"KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN",
|
||||
// P2: daemon + remote control server
|
||||
"DAEMON",
|
||||
// PR-package restored features
|
||||
"WORKFLOW_SCRIPTS",
|
||||
"HISTORY_SNIP",
|
||||
"CONTEXT_COLLAPSE",
|
||||
"MONITOR_TOOL",
|
||||
"FORK_SUBAGENT",
|
||||
"UDS_INBOX",
|
||||
"KAIROS",
|
||||
"COORDINATOR_MODE",
|
||||
"LAN_PIPES",
|
||||
// "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性
|
||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||
"POOR",
|
||||
];
|
||||
|
||||
// Any env var matching FEATURE_<NAME>=1 will also enable that feature.
|
||||
|
||||
@@ -29,6 +29,7 @@ try {
|
||||
|
||||
const RG_VERSION = "15.0.1"
|
||||
const DEFAULT_RELEASE_BASE = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`
|
||||
const MIRROR_RELEASE_BASE = `https://ghproxy.net/https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`
|
||||
const RELEASE_BASE = (process.env.RIPGREP_DOWNLOAD_BASE ?? DEFAULT_RELEASE_BASE).replace(/\/$/, "")
|
||||
|
||||
const scriptDir = path.dirname(__filename)
|
||||
@@ -262,7 +263,6 @@ async function extractTarGz(buffer, binaryPath, extractedBinary, assetName) {
|
||||
async function downloadAndExtract() {
|
||||
const { target, ext } = getPlatformMapping()
|
||||
const assetName = `ripgrep-v${RG_VERSION}-${target}.${ext}`
|
||||
const downloadUrl = `${RELEASE_BASE}/${assetName}`
|
||||
|
||||
const binaryPath = getBinaryPath()
|
||||
const binaryDir = path.dirname(binaryPath)
|
||||
@@ -277,12 +277,32 @@ async function downloadAndExtract() {
|
||||
}
|
||||
|
||||
console.log(`[ripgrep] Downloading v${RG_VERSION} for ${target}...`)
|
||||
console.log(`[ripgrep] URL: ${downloadUrl}`)
|
||||
|
||||
const extractedBinary = process.platform === "win32" ? "rg.exe" : "rg"
|
||||
|
||||
const mirrors = [RELEASE_BASE]
|
||||
if (RELEASE_BASE === DEFAULT_RELEASE_BASE.replace(/\/$/, "")) {
|
||||
mirrors.push(MIRROR_RELEASE_BASE.replace(/\/$/, ""))
|
||||
}
|
||||
|
||||
let buffer
|
||||
let lastError
|
||||
for (const base of mirrors) {
|
||||
const url = `${base}/${assetName}`
|
||||
try {
|
||||
console.log(`[ripgrep] Trying ${url}`)
|
||||
buffer = await downloadUrlToBufferWithFallback(url)
|
||||
break
|
||||
} catch (e) {
|
||||
console.warn(`[ripgrep] Download from ${base} failed: ${e instanceof Error ? e.message : e}`)
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
if (!buffer) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await downloadUrlToBufferWithFallback(downloadUrl)
|
||||
console.log(`[ripgrep] Downloaded ${Math.round(buffer.length / 1024)} KB`)
|
||||
|
||||
mkdirSync(binaryDir, { recursive: true })
|
||||
|
||||
39
scripts/setup-chrome-mcp.mjs
Normal file
39
scripts/setup-chrome-mcp.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Unified Chrome MCP setup script.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/setup-chrome-mcp.mjs # Run full setup (fix-permissions → register → doctor)
|
||||
* node scripts/setup-chrome-mcp.mjs doctor # Run a single sub-command
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cliPath = require.resolve("mcp-chrome-bridge/dist/cli.js");
|
||||
|
||||
const userArgs = process.argv.slice(2);
|
||||
|
||||
if (userArgs.length > 0) {
|
||||
// Forward single sub-command
|
||||
execFileSync("node", [cliPath, ...userArgs], { stdio: "inherit" });
|
||||
} else {
|
||||
// Full setup sequence
|
||||
const steps = [
|
||||
["fix-permissions"],
|
||||
["register", "--browser", "chrome"],
|
||||
["doctor"],
|
||||
];
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const args = steps[i];
|
||||
const isLast = i === steps.length - 1;
|
||||
if (isLast) console.log(`\n[${i + 1}/${steps.length}] ${args.join(" ")}`);
|
||||
execFileSync("node", [cliPath, ...args], { stdio: isLast ? "inherit" : "pipe" });
|
||||
}
|
||||
|
||||
console.log("\nChrome MCP setup complete!");
|
||||
}
|
||||
@@ -563,16 +563,16 @@ export class QueryEngine {
|
||||
for (const msg of messagesFromUserInput) {
|
||||
if (
|
||||
msg.type === 'user' &&
|
||||
typeof msg.message.content === 'string' &&
|
||||
(msg.message.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
|
||||
msg.message.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`) ||
|
||||
typeof msg.message!.content === 'string' &&
|
||||
(msg.message!.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
|
||||
msg.message!.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`) ||
|
||||
msg.isCompactSummary)
|
||||
) {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: {
|
||||
...msg.message,
|
||||
content: stripAnsi(msg.message.content),
|
||||
content: stripAnsi(msg.message!.content),
|
||||
},
|
||||
session_id: getSessionId(),
|
||||
parent_tool_use_id: null,
|
||||
@@ -1089,7 +1089,7 @@ export class QueryEngine {
|
||||
const edeResultType = result?.type ?? 'undefined'
|
||||
const edeLastContentType =
|
||||
result?.type === 'assistant'
|
||||
? (last(result.message.content)?.type ?? 'none')
|
||||
? (last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])?.type ?? 'none')
|
||||
: 'n/a'
|
||||
|
||||
// Flush buffered transcript writes before yielding result.
|
||||
@@ -1147,7 +1147,7 @@ export class QueryEngine {
|
||||
let isApiError = false
|
||||
|
||||
if (result.type === 'assistant') {
|
||||
const lastContent = last(result.message.content)
|
||||
const lastContent = last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])
|
||||
if (
|
||||
lastContent?.type === 'text' &&
|
||||
!SYNTHETIC_MESSAGES.has(lastContent.text)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlockParam,
|
||||
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
export type { ToolResultBlockParam }
|
||||
import type {
|
||||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
@@ -76,6 +77,7 @@ import type { SpinnerMode } from './components/Spinner.js'
|
||||
import type { QuerySource } from './constants/querySource.js'
|
||||
import type { SDKStatus } from './entrypoints/agentSdkTypes.js'
|
||||
import type { AppState } from './state/AppState.js'
|
||||
import type { LangfuseSpan } from './services/langfuse/index.js'
|
||||
import type {
|
||||
HookProgress,
|
||||
PromptRequest,
|
||||
@@ -273,6 +275,8 @@ export type ToolUseContext = {
|
||||
) => (request: PromptRequest) => Promise<PromptResponse>
|
||||
toolUseId?: string
|
||||
criticalSystemReminder_EXPERIMENTAL?: string
|
||||
/** Langfuse root trace span for this query turn. Passed down to tool execution for observability. */
|
||||
langfuseTrace?: LangfuseSpan | null
|
||||
/** When true, preserve toolUseResult on messages even for subagents.
|
||||
* Used by in-process teammates whose transcripts are viewable by the user. */
|
||||
preserveToolUseResults?: boolean
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const isKairosEnabled: () => Promise<boolean> = () => Promise.resolve(false);
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
* Runtime gate for KAIROS features.
|
||||
*
|
||||
* Build-time: feature('KAIROS') must be on (checked by caller before
|
||||
* this module is required).
|
||||
*
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
|
||||
* switch, and kairosActive state must be true (set during bootstrap when
|
||||
* the session qualifies for KAIROS features).
|
||||
*/
|
||||
export async function isKairosEnabled(): Promise<boolean> {
|
||||
if (!feature('KAIROS')) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return getKairosActive()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const isAssistantMode: () => boolean = () => false;
|
||||
export const initializeAssistantTeam: () => Promise<void> = async () => {};
|
||||
export const markAssistantForced: () => void = () => {};
|
||||
export const isAssistantForced: () => boolean = () => false;
|
||||
export const getAssistantSystemPromptAddendum: () => string = () => '';
|
||||
export const getAssistantActivationPath: () => string | undefined = () => undefined;
|
||||
export {}
|
||||
export const isAssistantMode: () => boolean = () => false
|
||||
export const initializeAssistantTeam: () => Promise<void> = async () => {}
|
||||
export const markAssistantForced: () => void = () => {}
|
||||
export const isAssistantForced: () => boolean = () => false
|
||||
export const getAssistantSystemPromptAddendum: () => string = () => ''
|
||||
export const getAssistantActivationPath: () => string | undefined = () =>
|
||||
undefined
|
||||
|
||||
@@ -1429,7 +1429,7 @@ export function registerHookCallbacks(
|
||||
if (!STATE.registeredHooks[eventKey]) {
|
||||
STATE.registeredHooks[eventKey] = []
|
||||
}
|
||||
STATE.registeredHooks[eventKey]!.push(...matchers)
|
||||
STATE.registeredHooks[eventKey]!.push(...(matchers ?? []))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1451,7 +1451,7 @@ export function clearRegisteredPluginHooks(): void {
|
||||
const filtered: Partial<Record<HookEvent, RegisteredHookMatcher[]>> = {}
|
||||
for (const [event, matchers] of Object.entries(STATE.registeredHooks)) {
|
||||
// Keep only callback hooks (those without pluginRoot)
|
||||
const callbackHooks = matchers.filter(m => !('pluginRoot' in m))
|
||||
const callbackHooks = (matchers ?? []).filter(m => !('pluginRoot' in m))
|
||||
if (callbackHooks.length > 0) {
|
||||
filtered[event as HookEvent] = callbackHooks
|
||||
}
|
||||
|
||||
@@ -105,12 +105,12 @@ export function extractTitleText(m: Message): string | undefined {
|
||||
if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
|
||||
return undefined
|
||||
if (m.origin && (m.origin as { kind?: string }).kind !== 'human') return undefined
|
||||
const content = m.message.content
|
||||
const content = m.message!.content
|
||||
let raw: string | undefined
|
||||
if (typeof content === 'string') {
|
||||
raw = content
|
||||
} else {
|
||||
for (const block of content) {
|
||||
for (const block of content ?? []) {
|
||||
if (block.type === 'text') {
|
||||
raw = block.text
|
||||
break
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
waitForPolicyLimitsToLoad,
|
||||
} from '../services/policyLimits/index.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.js'
|
||||
import {
|
||||
checkAndRefreshOAuthTokenIfNeeded,
|
||||
getClaudeAIOAuthTokens,
|
||||
@@ -289,7 +290,7 @@ export async function initReplBridge(
|
||||
isSyntheticMessage(msg)
|
||||
)
|
||||
continue
|
||||
const rawContent = getContentText(msg.message.content)
|
||||
const rawContent = getContentText(msg.message!.content as string | ContentBlockParam[])
|
||||
if (!rawContent) continue
|
||||
const derived = deriveTitle(rawContent)
|
||||
if (!derived) continue
|
||||
|
||||
@@ -69,8 +69,21 @@ import type {
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
|
||||
/**
|
||||
* StdoutMessage with optional session_id. The transport layer accepts
|
||||
* StdoutMessage but we add session_id at runtime. Using optional because
|
||||
* the type system can't verify that adding session_id to a union type
|
||||
* is always valid, even though it is at runtime.
|
||||
*
|
||||
* We need to use 'as StdoutMessage' when passing to transport because
|
||||
* TypeScript can't verify that objects with session_id are valid StdoutMessage.
|
||||
*/
|
||||
type TransportMessage = StdoutMessage & { session_id?: string }
|
||||
|
||||
const ANTHROPIC_VERSION = '2023-06-01'
|
||||
|
||||
// Telemetry discriminator for ws_connected. 'initial' is the default and
|
||||
@@ -608,17 +621,17 @@ export async function initEnvLessBridgeCore(
|
||||
const msgs = flushGate.end()
|
||||
if (msgs.length === 0) return
|
||||
for (const msg of msgs) recentPostedUUIDs.add(msg.uuid)
|
||||
const events = toSDKMessages(msgs).map(m => ({
|
||||
const events: TransportMessage[] = toSDKMessages(msgs).map(m => ({
|
||||
...m,
|
||||
session_id: sessionId,
|
||||
}))
|
||||
})) as TransportMessage[]
|
||||
if (msgs.some(m => m.type === 'user')) {
|
||||
transport.reportState('running')
|
||||
}
|
||||
logForDebugging(
|
||||
`[remote-bridge] Drained ${msgs.length} queued message(s) after flush`,
|
||||
)
|
||||
void transport.writeBatch(events)
|
||||
void transport.writeBatch(events as StdoutMessage[])
|
||||
}
|
||||
|
||||
async function flushHistory(msgs: Message[]): Promise<void> {
|
||||
@@ -636,10 +649,10 @@ export async function initEnvLessBridgeCore(
|
||||
`[remote-bridge] Capped initial flush: ${eligible.length} -> ${capped.length} (cap=${initialHistoryCap})`,
|
||||
)
|
||||
}
|
||||
const events = toSDKMessages(capped).map(m => ({
|
||||
const events: TransportMessage[] = toSDKMessages(capped).map(m => ({
|
||||
...m,
|
||||
session_id: sessionId,
|
||||
}))
|
||||
})) as TransportMessage[]
|
||||
if (events.length === 0) return
|
||||
// Mid-turn init: if Remote Control is enabled while a query is running,
|
||||
// the last eligible message is a user prompt or tool_result (both 'user'
|
||||
@@ -652,7 +665,7 @@ export async function initEnvLessBridgeCore(
|
||||
transport.reportState('running')
|
||||
}
|
||||
logForDebugging(`[remote-bridge] Flushing ${events.length} history events`)
|
||||
await transport.writeBatch(events)
|
||||
await transport.writeBatch(events as StdoutMessage[])
|
||||
}
|
||||
|
||||
// ── 9. Teardown ───────────────────────────────────────────────────────────
|
||||
@@ -675,8 +688,11 @@ export async function initEnvLessBridgeCore(
|
||||
// explicit sleep. close() sets closed=true which interrupts drain at the
|
||||
// next while-check, so close-before-archive drops the result.
|
||||
transport.reportState('idle')
|
||||
void transport.write(makeResultMessage(sessionId))
|
||||
|
||||
const resultMsg = {
|
||||
...makeResultMessage(sessionId),
|
||||
session_id: sessionId,
|
||||
} as unknown as TransportMessage
|
||||
void transport.write(resultMsg as StdoutMessage)
|
||||
let token = getAccessToken()
|
||||
let status = await archiveSession(
|
||||
sessionId,
|
||||
@@ -795,10 +811,10 @@ export async function initEnvLessBridgeCore(
|
||||
}
|
||||
|
||||
for (const msg of filtered) recentPostedUUIDs.add(msg.uuid)
|
||||
const events = toSDKMessages(filtered).map(m => ({
|
||||
const events: TransportMessage[] = toSDKMessages(filtered).map(m => ({
|
||||
...m,
|
||||
session_id: sessionId,
|
||||
}))
|
||||
})) as TransportMessage[]
|
||||
// v2 does not derive worker_status from events server-side (unlike v1
|
||||
// session-ingress session_status_updater.go). Push it from here so the
|
||||
// CCR web session list shows Running instead of stuck on Idle. A user
|
||||
@@ -808,7 +824,7 @@ export async function initEnvLessBridgeCore(
|
||||
transport.reportState('running')
|
||||
}
|
||||
logForDebugging(`[remote-bridge] Sending ${filtered.length} message(s)`)
|
||||
void transport.writeBatch(events)
|
||||
void transport.writeBatch(events as StdoutMessage[])
|
||||
},
|
||||
writeSdkMessages(messages: SDKMessage[]) {
|
||||
const filtered = messages.filter(
|
||||
@@ -818,7 +834,7 @@ export async function initEnvLessBridgeCore(
|
||||
for (const msg of filtered) {
|
||||
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
|
||||
}
|
||||
const events = filtered.map(m => ({ ...m, session_id: sessionId }))
|
||||
const events = filtered.map(m => ({ ...m, session_id: sessionId })) as StdoutMessage[]
|
||||
void transport.writeBatch(events)
|
||||
},
|
||||
sendControlRequest(request: SDKControlRequest) {
|
||||
@@ -828,11 +844,11 @@ export async function initEnvLessBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event = { ...request, session_id: sessionId }
|
||||
const event: TransportMessage = { ...request, session_id: sessionId } as TransportMessage
|
||||
if ((request as { request?: { subtype?: string } }).request?.subtype === 'can_use_tool') {
|
||||
transport.reportState('requires_action')
|
||||
}
|
||||
void transport.write(event)
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging(
|
||||
`[remote-bridge] Sent control_request request_id=${request.request_id}`,
|
||||
)
|
||||
@@ -844,9 +860,9 @@ export async function initEnvLessBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event = { ...response, session_id: sessionId }
|
||||
const event: TransportMessage = { ...response, session_id: sessionId } as TransportMessage
|
||||
transport.reportState('running')
|
||||
void transport.write(event)
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging('[remote-bridge] Sent control_response')
|
||||
},
|
||||
sendControlCancelRequest(requestId: string) {
|
||||
@@ -856,16 +872,16 @@ export async function initEnvLessBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event = {
|
||||
const event: TransportMessage = {
|
||||
type: 'control_cancel_request' as const,
|
||||
request_id: requestId,
|
||||
session_id: sessionId,
|
||||
}
|
||||
} as TransportMessage
|
||||
// Hook/classifier/channel/recheck resolved the permission locally —
|
||||
// interactiveHandler calls only cancelRequest (no sendResponse) on
|
||||
// those paths, so without this the server stays on requires_action.
|
||||
transport.reportState('running')
|
||||
void transport.write(event)
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging(
|
||||
`[remote-bridge] Sent control_cancel_request request_id=${requestId}`,
|
||||
)
|
||||
@@ -876,7 +892,11 @@ export async function initEnvLessBridgeCore(
|
||||
return
|
||||
}
|
||||
transport.reportState('idle')
|
||||
void transport.write(makeResultMessage(sessionId))
|
||||
const resultMsg = {
|
||||
...makeResultMessage(sessionId),
|
||||
session_id: sessionId,
|
||||
} as unknown as TransportMessage
|
||||
void transport.write(resultMsg as StdoutMessage)
|
||||
logForDebugging(`[remote-bridge] Sent result`)
|
||||
},
|
||||
async teardown() {
|
||||
|
||||
@@ -53,6 +53,19 @@ import type {
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
|
||||
/**
|
||||
* StdoutMessage with optional session_id. The transport layer accepts
|
||||
* StdoutMessage but we add session_id at runtime. Using optional because
|
||||
* the type system can't verify that adding session_id to a union type
|
||||
* is always valid, even though it is at runtime.
|
||||
*
|
||||
* We need to use 'as StdoutMessage' when passing to transport because
|
||||
* TypeScript can't verify that objects with session_id are valid StdoutMessage.
|
||||
*/
|
||||
type TransportMessage = StdoutMessage & { session_id?: string }
|
||||
import { createCapacityWake, type CapacitySignal } from './capacityWake.js'
|
||||
import { FlushGate } from './flushGate.js'
|
||||
import {
|
||||
@@ -865,14 +878,14 @@ export async function initBridgeCore(
|
||||
recentPostedUUIDs.add(msg.uuid)
|
||||
}
|
||||
const sdkMessages = toSDKMessages(msgs)
|
||||
const events = sdkMessages.map(sdkMsg => ({
|
||||
const events: TransportMessage[] = sdkMessages.map(sdkMsg => ({
|
||||
...sdkMsg,
|
||||
session_id: currentSessionId,
|
||||
}))
|
||||
})) as TransportMessage[]
|
||||
logForDebugging(
|
||||
`[bridge:repl] Drained ${msgs.length} pending message(s) after flush`,
|
||||
)
|
||||
void transport.writeBatch(events)
|
||||
void transport.writeBatch(events as StdoutMessage[])
|
||||
}
|
||||
|
||||
// Teardown reference — set after definition below. All callers are async
|
||||
@@ -1285,14 +1298,12 @@ export async function initBridgeCore(
|
||||
logForDebugging(
|
||||
`[bridge:repl] Flushing ${sdkMessages.length} initial message(s) via transport`,
|
||||
)
|
||||
const events = sdkMessages.map(sdkMsg => ({
|
||||
const events: TransportMessage[] = sdkMessages.map(sdkMsg => ({
|
||||
...sdkMsg,
|
||||
session_id: currentSessionId,
|
||||
}))
|
||||
})) as TransportMessage[]
|
||||
const dropsBefore = newTransport.droppedBatchCount
|
||||
void newTransport
|
||||
.writeBatch(events)
|
||||
.then(() => {
|
||||
void newTransport.writeBatch(events as StdoutMessage[]).then(() => {
|
||||
// If any batch was dropped during this flush (SI down for
|
||||
// maxConsecutiveFailures attempts), flush() still resolved
|
||||
// normally but the events were NOT delivered. Don't mark
|
||||
@@ -1655,7 +1666,11 @@ export async function initBridgeCore(
|
||||
transport = null
|
||||
flushGate.drop()
|
||||
if (teardownTransport) {
|
||||
void teardownTransport.write(makeResultMessage(currentSessionId))
|
||||
const resultMsg = {
|
||||
...makeResultMessage(currentSessionId),
|
||||
session_id: currentSessionId,
|
||||
} as unknown as TransportMessage
|
||||
void teardownTransport.write(resultMsg as StdoutMessage)
|
||||
}
|
||||
|
||||
const stopWorkP = currentWorkId
|
||||
@@ -1778,11 +1793,11 @@ export async function initBridgeCore(
|
||||
// Convert to SDK format and send via HTTP POST (HybridTransport).
|
||||
// The web UI receives them via the subscribe WebSocket.
|
||||
const sdkMessages = toSDKMessages(filtered)
|
||||
const events = sdkMessages.map(sdkMsg => ({
|
||||
const events: TransportMessage[] = sdkMessages.map(sdkMsg => ({
|
||||
...sdkMsg,
|
||||
session_id: currentSessionId,
|
||||
}))
|
||||
void transport.writeBatch(events)
|
||||
})) as TransportMessage[]
|
||||
void transport.writeBatch(events as StdoutMessage[])
|
||||
},
|
||||
writeSdkMessages(messages) {
|
||||
// Daemon path: query() already yields SDKMessage, skip conversion.
|
||||
@@ -1803,8 +1818,8 @@ export async function initBridgeCore(
|
||||
for (const msg of filtered) {
|
||||
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
|
||||
}
|
||||
const events = filtered.map(m => ({ ...m, session_id: currentSessionId }))
|
||||
void transport.writeBatch(events)
|
||||
const events: TransportMessage[] = filtered.map(m => ({ ...m, session_id: currentSessionId })) as TransportMessage[]
|
||||
void transport.writeBatch(events as StdoutMessage[])
|
||||
},
|
||||
sendControlRequest(request: SDKControlRequest) {
|
||||
if (!transport) {
|
||||
@@ -1813,8 +1828,8 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event = { ...request, session_id: currentSessionId }
|
||||
void transport.write(event)
|
||||
const event: TransportMessage = { ...request, session_id: currentSessionId } as TransportMessage
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent control_request request_id=${request.request_id}`,
|
||||
)
|
||||
@@ -1826,8 +1841,8 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event = { ...response, session_id: currentSessionId }
|
||||
void transport.write(event)
|
||||
const event: TransportMessage = { ...response, session_id: currentSessionId } as TransportMessage
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging('[bridge:repl] Sent control_response')
|
||||
},
|
||||
sendControlCancelRequest(requestId: string) {
|
||||
@@ -1837,12 +1852,12 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event = {
|
||||
const event: TransportMessage = {
|
||||
type: 'control_cancel_request' as const,
|
||||
request_id: requestId,
|
||||
session_id: currentSessionId,
|
||||
}
|
||||
void transport.write(event)
|
||||
} as TransportMessage
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent control_cancel_request request_id=${requestId}`,
|
||||
)
|
||||
@@ -1854,7 +1869,11 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
void transport.write(makeResultMessage(currentSessionId))
|
||||
const resultMsg = {
|
||||
...makeResultMessage(currentSessionId),
|
||||
session_id: currentSessionId,
|
||||
} as unknown as TransportMessage
|
||||
void transport.write(resultMsg as StdoutMessage)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent result for session=${currentSessionId}`,
|
||||
)
|
||||
|
||||
@@ -22,8 +22,8 @@ export function getCompanionIntroAttachment(
|
||||
// Skip if already announced for this companion.
|
||||
for (const msg of messages ?? []) {
|
||||
if (msg.type !== 'attachment') continue
|
||||
if (msg.attachment.type !== 'companion_intro') continue
|
||||
if (msg.attachment.name === companion.name) return []
|
||||
if (msg.attachment!.type !== 'companion_intro') continue
|
||||
if (msg.attachment!.name === companion.name) return []
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -222,9 +222,10 @@ export async function mcpListHandler(): Promise<void> {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} - ${status}`)
|
||||
} else if (!server.type || server.type === 'stdio') {
|
||||
const args = Array.isArray(server.args) ? server.args : []
|
||||
const stdioServer = server as { command: string; args: string[]; type?: string }
|
||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.command} ${args.join(' ')} - ${status}`)
|
||||
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
390
src/cli/print.ts
390
src/cli/print.ts
File diff suppressed because it is too large
Load Diff
@@ -355,7 +355,7 @@ export class StructuredIO {
|
||||
// Used by bridge session runner for auth token refresh
|
||||
// (CLAUDE_CODE_SESSION_ACCESS_TOKEN) which must be readable
|
||||
// by the REPL process itself, not just child Bash commands.
|
||||
const variables = message.variables as Record<string, string>
|
||||
const variables = message.variables ?? {}
|
||||
const keys = Object.keys(variables)
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
process.env[key] = value
|
||||
@@ -377,7 +377,8 @@ export class StructuredIO {
|
||||
if (uuid) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
}
|
||||
const request = this.pendingRequests.get(message.response.request_id)
|
||||
const resp = message.response as { request_id: string; subtype: string; response?: Record<string, unknown>; error?: string }
|
||||
const request = this.pendingRequests.get(resp.request_id)
|
||||
if (!request) {
|
||||
// Check if this tool_use was already resolved through the normal
|
||||
// permission flow. Duplicate control_response deliveries (e.g. from
|
||||
@@ -385,8 +386,8 @@ export class StructuredIO {
|
||||
// re-processing them would push duplicate assistant messages into
|
||||
// the conversation, causing API 400 errors.
|
||||
const responsePayload =
|
||||
message.response.subtype === 'success'
|
||||
? message.response.response
|
||||
resp.subtype === 'success'
|
||||
? resp.response
|
||||
: undefined
|
||||
const toolUseID = responsePayload?.toolUseID
|
||||
if (
|
||||
@@ -394,31 +395,31 @@ export class StructuredIO {
|
||||
this.resolvedToolUseIds.has(toolUseID)
|
||||
) {
|
||||
logForDebugging(
|
||||
`Ignoring duplicate control_response for already-resolved toolUseID=${toolUseID} request_id=${message.response.request_id}`,
|
||||
`Ignoring duplicate control_response for already-resolved toolUseID=${toolUseID} request_id=${resp.request_id}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (this.unexpectedResponseCallback) {
|
||||
await this.unexpectedResponseCallback(message)
|
||||
await this.unexpectedResponseCallback(message as SDKControlResponse & { uuid?: string })
|
||||
}
|
||||
return undefined // Ignore responses for requests we don't know about
|
||||
}
|
||||
this.trackResolvedToolUseId(request.request)
|
||||
this.pendingRequests.delete(message.response.request_id)
|
||||
this.pendingRequests.delete(resp.request_id)
|
||||
// Notify the bridge when the SDK consumer resolves a can_use_tool
|
||||
// request, so it can cancel the stale permission prompt on claude.ai.
|
||||
if (
|
||||
(request.request.request as { subtype?: string }).subtype === 'can_use_tool' &&
|
||||
this.onControlRequestResolved
|
||||
) {
|
||||
this.onControlRequestResolved(message.response.request_id)
|
||||
this.onControlRequestResolved(resp.request_id)
|
||||
}
|
||||
|
||||
if (message.response.subtype === 'error') {
|
||||
request.reject(new Error(message.response.error))
|
||||
if (resp.subtype === 'error') {
|
||||
request.reject(new Error(resp.error ?? 'Unknown error'))
|
||||
return undefined
|
||||
}
|
||||
const result = message.response.response
|
||||
const result = resp.response
|
||||
if (request.schema) {
|
||||
try {
|
||||
request.resolve(request.schema.parse(result))
|
||||
@@ -454,9 +455,9 @@ export class StructuredIO {
|
||||
if (message.type === 'assistant' || message.type === 'system') {
|
||||
return message
|
||||
}
|
||||
if (message.message.role !== 'user') {
|
||||
if ((message as { message?: { role?: string } }).message?.role !== 'user') {
|
||||
exitWithMessage(
|
||||
`Error: Expected message role 'user', got '${message.message.role}'`,
|
||||
`Error: Expected message role 'user', got '${(message as { message?: { role?: string } }).message?.role}'`,
|
||||
)
|
||||
}
|
||||
return message
|
||||
@@ -678,7 +679,7 @@ export class StructuredIO {
|
||||
{
|
||||
subtype: 'hook_callback',
|
||||
callback_id: callbackId,
|
||||
input,
|
||||
input: input as any,
|
||||
tool_use_id: toolUseID || undefined,
|
||||
},
|
||||
hookJSONOutputSchema(),
|
||||
|
||||
@@ -63,11 +63,15 @@ export function parseSSEFrames(buffer: string): {
|
||||
const frames: SSEFrame[] = []
|
||||
let pos = 0
|
||||
|
||||
// SSE frames are delimited by double newlines
|
||||
let idx: number
|
||||
while ((idx = buffer.indexOf('\n\n', pos)) !== -1) {
|
||||
const rawFrame = buffer.slice(pos, idx)
|
||||
pos = idx + 2
|
||||
// SSE frames are delimited by an empty line. Support LF and CRLF streams.
|
||||
const frameDelimiter = /\r?\n\r?\n/g
|
||||
frameDelimiter.lastIndex = pos
|
||||
|
||||
let delimiterMatch: RegExpExecArray | null
|
||||
while ((delimiterMatch = frameDelimiter.exec(buffer)) !== null) {
|
||||
const frameEnd = delimiterMatch.index
|
||||
const rawFrame = buffer.slice(pos, frameEnd)
|
||||
pos = frameEnd + delimiterMatch[0].length
|
||||
|
||||
// Skip empty frames
|
||||
if (!rawFrame.trim()) continue
|
||||
@@ -75,7 +79,13 @@ export function parseSSEFrames(buffer: string): {
|
||||
const frame: SSEFrame = {}
|
||||
let isComment = false
|
||||
|
||||
for (const line of rawFrame.split('\n')) {
|
||||
for (const rawLine of rawFrame.split('\n')) {
|
||||
// Normalize CRLF lines in mixed-line-ending streams.
|
||||
const line =
|
||||
rawLine[rawLine.length - 1] === '\r'
|
||||
? rawLine.slice(0, -1)
|
||||
: rawLine
|
||||
|
||||
if (line.startsWith(':')) {
|
||||
// SSE comment (e.g., `:keepalive`)
|
||||
isComment = true
|
||||
@@ -182,6 +192,7 @@ export class SSETransport implements Transport {
|
||||
|
||||
// Liveness detection
|
||||
private livenessTimer: NodeJS.Timeout | null = null
|
||||
private lastActivityTime = 0
|
||||
|
||||
// POST URL (derived from SSE URL)
|
||||
private postUrl: string
|
||||
|
||||
49
src/cli/transports/__tests__/SSETransport.test.ts
Normal file
49
src/cli/transports/__tests__/SSETransport.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseSSEFrames } from '../SSETransport.js'
|
||||
|
||||
describe('parseSSEFrames', () => {
|
||||
test('parses LF-delimited frames', () => {
|
||||
const input = 'event: client_event\ndata: {"ok":true}\n\n'
|
||||
const { frames, remaining } = parseSSEFrames(input)
|
||||
|
||||
expect(remaining).toBe('')
|
||||
expect(frames).toEqual([
|
||||
{
|
||||
event: 'client_event',
|
||||
data: '{"ok":true}',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('parses CRLF-delimited frames and strips trailing carriage returns', () => {
|
||||
const input =
|
||||
'event: client_event\r\ndata: {"ok":true}\r\nid: 7\r\n\r\nevent: keepalive\r\ndata: ping\r\n\r\n'
|
||||
const { frames, remaining } = parseSSEFrames(input)
|
||||
|
||||
expect(remaining).toBe('')
|
||||
expect(frames).toEqual([
|
||||
{
|
||||
event: 'client_event',
|
||||
data: '{"ok":true}',
|
||||
id: '7',
|
||||
},
|
||||
{
|
||||
event: 'keepalive',
|
||||
data: 'ping',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('keeps incomplete trailing frame in remaining buffer for CRLF streams', () => {
|
||||
const input = 'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n'
|
||||
const { frames, remaining } = parseSSEFrames(input)
|
||||
|
||||
expect(frames).toEqual([
|
||||
{
|
||||
event: 'client_event',
|
||||
data: '{"ok":true}',
|
||||
},
|
||||
])
|
||||
expect(remaining).toBe('data: {"tail":1}\r\n')
|
||||
})
|
||||
})
|
||||
@@ -119,7 +119,7 @@ export function createStreamAccumulator(): StreamAccumulatorState {
|
||||
|
||||
function scopeKey(m: {
|
||||
session_id: string
|
||||
parent_tool_use_id: string | null
|
||||
parent_tool_use_id?: string | null
|
||||
}): string {
|
||||
return `${m.session_id}:${m.parent_tool_use_id ?? ''}`
|
||||
}
|
||||
@@ -148,9 +148,10 @@ export function accumulateStreamEvents(
|
||||
// rewrite the same entry instead of emitting one event per delta.
|
||||
const touched = new Map<string[], CoalescedStreamEvent>()
|
||||
for (const msg of buffer) {
|
||||
switch (msg.event.type) {
|
||||
const evt = msg.event as Record<string, unknown>
|
||||
switch (evt.type) {
|
||||
case 'message_start': {
|
||||
const id = msg.event.message.id
|
||||
const id = (evt.message as { id: string }).id
|
||||
const prevId = state.scopeToMessage.get(scopeKey(msg))
|
||||
if (prevId) state.byMessage.delete(prevId)
|
||||
state.scopeToMessage.set(scopeKey(msg), id)
|
||||
@@ -159,7 +160,8 @@ export function accumulateStreamEvents(
|
||||
break
|
||||
}
|
||||
case 'content_block_delta': {
|
||||
if (msg.event.delta.type !== 'text_delta') {
|
||||
const delta = evt.delta as Record<string, unknown>
|
||||
if (delta.type !== 'text_delta') {
|
||||
out.push(msg)
|
||||
break
|
||||
}
|
||||
@@ -173,11 +175,12 @@ export function accumulateStreamEvents(
|
||||
out.push(msg)
|
||||
break
|
||||
}
|
||||
const chunks = (blocks[msg.event.index] ??= [])
|
||||
chunks.push(msg.event.delta.text)
|
||||
const idx = evt.index as number
|
||||
const chunks = (blocks[idx] ??= [])
|
||||
chunks.push(delta.text as string)
|
||||
const existing = touched.get(chunks)
|
||||
if (existing) {
|
||||
existing.event.delta.text = chunks.join('')
|
||||
;(existing.event as Record<string, unknown>).delta = { type: 'text_delta', text: chunks.join('') }
|
||||
break
|
||||
}
|
||||
const snapshot: CoalescedStreamEvent = {
|
||||
@@ -187,7 +190,7 @@ export function accumulateStreamEvents(
|
||||
parent_tool_use_id: msg.parent_tool_use_id,
|
||||
event: {
|
||||
type: 'content_block_delta',
|
||||
index: msg.event.index,
|
||||
index: idx,
|
||||
delta: { type: 'text_delta', text: chunks.join('') },
|
||||
},
|
||||
}
|
||||
@@ -745,7 +748,7 @@ export class CCRClient {
|
||||
}
|
||||
await this.flushStreamEventBuffer()
|
||||
if (message.type === 'assistant') {
|
||||
clearStreamAccumulatorForMessage(this.streamTextAccumulator, message)
|
||||
clearStreamAccumulatorForMessage(this.streamTextAccumulator, message as { session_id: string; parent_tool_use_id: string | null; message: { id: string } })
|
||||
}
|
||||
await this.eventUploader.enqueue(this.toClientEvent(message))
|
||||
}
|
||||
|
||||
@@ -80,6 +80,12 @@ const remoteControlServerCommand =
|
||||
const voiceCommand = feature('VOICE_MODE')
|
||||
? require('./commands/voice/index.js').default
|
||||
: null
|
||||
const monitorCmd = feature('MONITOR_TOOL')
|
||||
? require('./commands/monitor.js').default
|
||||
: null
|
||||
const coordinatorCmd = feature('COORDINATOR_MODE')
|
||||
? require('./commands/coordinator.js').default
|
||||
: null
|
||||
const forceSnip = feature('HISTORY_SNIP')
|
||||
? require('./commands/force-snip.js').default
|
||||
: null
|
||||
@@ -110,6 +116,27 @@ const peersCmd = feature('UDS_INBOX')
|
||||
require('./commands/peers/index.js') as typeof import('./commands/peers/index.js')
|
||||
).default
|
||||
: null
|
||||
const attachCmd = feature('UDS_INBOX')
|
||||
? require('./commands/attach/index.js').default
|
||||
: null
|
||||
const detachCmd = feature('UDS_INBOX')
|
||||
? require('./commands/detach/index.js').default
|
||||
: null
|
||||
const sendCmd = feature('UDS_INBOX')
|
||||
? require('./commands/send/index.js').default
|
||||
: null
|
||||
const pipesCmd = feature('UDS_INBOX')
|
||||
? require('./commands/pipes/index.js').default
|
||||
: null
|
||||
const pipeStatusCmd = feature('UDS_INBOX')
|
||||
? require('./commands/pipe-status/index.js').default
|
||||
: null
|
||||
const historyCmd = feature('UDS_INBOX')
|
||||
? require('./commands/history/index.js').default
|
||||
: null
|
||||
const claimMainCmd = feature('UDS_INBOX')
|
||||
? require('./commands/claim-main/index.js').default
|
||||
: null
|
||||
const forkCmd = feature('FORK_SUBAGENT')
|
||||
? (
|
||||
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
|
||||
@@ -120,6 +147,11 @@ const buddy = feature('BUDDY')
|
||||
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
|
||||
).default
|
||||
: null
|
||||
const poor = feature('POOR')
|
||||
? (
|
||||
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
|
||||
).default
|
||||
: null
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import thinkback from './commands/thinkback/index.js'
|
||||
import thinkbackPlay from './commands/thinkback-play/index.js'
|
||||
@@ -321,7 +353,10 @@ const COMMANDS = memoize((): Command[] => [
|
||||
...(webCmd ? [webCmd] : []),
|
||||
...(forkCmd ? [forkCmd] : []),
|
||||
...(buddy ? [buddy] : []),
|
||||
...(poor ? [poor] : []),
|
||||
...(proactive ? [proactive] : []),
|
||||
...(monitorCmd ? [monitorCmd] : []),
|
||||
...(coordinatorCmd ? [coordinatorCmd] : []),
|
||||
...(briefCommand ? [briefCommand] : []),
|
||||
...(assistantCommand ? [assistantCommand] : []),
|
||||
...(bridge ? [bridge] : []),
|
||||
@@ -338,6 +373,13 @@ const COMMANDS = memoize((): Command[] => [
|
||||
...(!isUsing3PServices() ? [logout, login()] : []),
|
||||
passes,
|
||||
...(peersCmd ? [peersCmd] : []),
|
||||
...(attachCmd ? [attachCmd] : []),
|
||||
...(detachCmd ? [detachCmd] : []),
|
||||
...(sendCmd ? [sendCmd] : []),
|
||||
...(pipesCmd ? [pipesCmd] : []),
|
||||
...(pipeStatusCmd ? [pipeStatusCmd] : []),
|
||||
...(historyCmd ? [historyCmd] : []),
|
||||
...(claimMainCmd ? [claimMainCmd] : []),
|
||||
tasks,
|
||||
...(workflowsCmd ? [workflowsCmd] : []),
|
||||
...(ultraplan ? [ultraplan] : []),
|
||||
@@ -463,8 +505,8 @@ const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
|
||||
...bundledSkills,
|
||||
...builtinPluginSkills,
|
||||
...skillDirCommands,
|
||||
...workflowCommands,
|
||||
...pluginCommands,
|
||||
...(workflowCommands as Command[]),
|
||||
...(pluginCommands as Command[]),
|
||||
...pluginSkills,
|
||||
...COMMANDS(),
|
||||
]
|
||||
|
||||
3
src/commands/ant-trace/index.d.ts
vendored
Normal file
3
src/commands/ant-trace/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
declare const _default: Command
|
||||
export default _default
|
||||
@@ -1,11 +1,53 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type React from 'react';
|
||||
import * as React from 'react'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
|
||||
export {};
|
||||
export const NewInstallWizard: React.FC<{
|
||||
defaultDir: string;
|
||||
onInstalled: (dir: string) => void;
|
||||
onCancel: () => void;
|
||||
onError: (message: string) => void;
|
||||
}> = (() => null);
|
||||
export const computeDefaultInstallDir: () => Promise<string> = (() => Promise.resolve(''));
|
||||
/** Stub — install wizard is not yet restored. */
|
||||
export async function computeDefaultInstallDir(): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Stub — install wizard is not yet restored. */
|
||||
export function NewInstallWizard(_props: {
|
||||
defaultDir: string
|
||||
onInstalled: (dir: string) => void
|
||||
onCancel: () => void
|
||||
onError: (message: string) => void
|
||||
}): React.ReactNode {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* /assistant command implementation.
|
||||
*
|
||||
* Opens the Kairos assistant panel. In the current build the panel is
|
||||
* rendered by the REPL layer when kairosActive is true; the slash command
|
||||
* simply toggles visibility and prints a confirmation line.
|
||||
*/
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
_args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const { setAppState, getAppState } = context
|
||||
|
||||
const current = getAppState()
|
||||
const isVisible = (current as Record<string, unknown>).assistantPanelVisible
|
||||
|
||||
if (isVisible) {
|
||||
setAppState((prev: AppState) => ({
|
||||
...prev,
|
||||
assistantPanelVisible: false,
|
||||
} as AppState))
|
||||
onDone('Assistant panel hidden.', { display: 'system' })
|
||||
} else {
|
||||
setAppState((prev: AppState) => ({
|
||||
...prev,
|
||||
assistantPanelVisible: true,
|
||||
} as AppState))
|
||||
onDone('Assistant panel opened.', { display: 'system' })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
25
src/commands/assistant/gate.ts
Normal file
25
src/commands/assistant/gate.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
* Runtime gate for the /assistant command.
|
||||
*
|
||||
* Build-time: feature('KAIROS') must be on (checked in commands.ts before
|
||||
* the module is even required).
|
||||
*
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
|
||||
* switch, and kairosActive state must be true (set during bootstrap when
|
||||
* the session qualifies for KAIROS features).
|
||||
*/
|
||||
export function isAssistantEnabled(): boolean {
|
||||
if (!feature('KAIROS')) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return getKairosActive()
|
||||
}
|
||||
16
src/commands/assistant/index.ts
Normal file
16
src/commands/assistant/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isAssistantEnabled } from './gate.js'
|
||||
|
||||
const assistant = {
|
||||
type: 'local-jsx',
|
||||
name: 'assistant',
|
||||
description: 'Open the Kairos assistant panel',
|
||||
isEnabled: isAssistantEnabled,
|
||||
get isHidden() {
|
||||
return !isAssistantEnabled()
|
||||
},
|
||||
immediate: true,
|
||||
load: () => import('./assistant.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default assistant
|
||||
137
src/commands/attach/attach.ts
Normal file
137
src/commands/attach/attach.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import {
|
||||
connectToPipe,
|
||||
getPipeIpc,
|
||||
isPipeControlled,
|
||||
type PipeClient,
|
||||
type PipeMessage,
|
||||
type TcpEndpoint,
|
||||
} from '../../utils/pipeTransport.js'
|
||||
import { addSlaveClient } from '../../hooks/useMasterMonitor.js'
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const targetName = args.trim()
|
||||
if (!targetName) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Usage: /attach <pipe-name>\nUse /pipes to list available pipes.',
|
||||
}
|
||||
}
|
||||
|
||||
const currentState = context.getAppState()
|
||||
|
||||
// Check if already attached to this slave
|
||||
if (getPipeIpc(currentState).slaves[targetName]) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Already attached to "${targetName}".`,
|
||||
}
|
||||
}
|
||||
|
||||
// Controlled sub sessions cannot attach to other sub sessions.
|
||||
if (isPipeControlled(getPipeIpc(currentState))) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Cannot attach: this sub is currently controlled by a master. Detach it from the master first.',
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve TCP endpoint for LAN peers
|
||||
let tcpEndpoint: TcpEndpoint | undefined
|
||||
if (feature('LAN_PIPES')) {
|
||||
const pipeState = getPipeIpc(currentState)
|
||||
const discoveredPeer = pipeState.discoveredPipes.find(
|
||||
(p: { pipeName: string }) => p.pipeName === targetName,
|
||||
)
|
||||
if (discoveredPeer) {
|
||||
// Check if this is a LAN peer by looking up beacon data
|
||||
const { getLanBeacon } =
|
||||
require('../../utils/lanBeacon.js') as typeof import('../../utils/lanBeacon.js')
|
||||
const beaconRef = getLanBeacon()
|
||||
if (beaconRef) {
|
||||
const lanPeers = beaconRef.getPeers()
|
||||
const lanPeer = lanPeers.get(targetName)
|
||||
if (lanPeer) {
|
||||
tcpEndpoint = { host: lanPeer.ip, port: lanPeer.tcpPort }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to the target pipe server (UDS or TCP)
|
||||
let client: PipeClient
|
||||
try {
|
||||
const myName =
|
||||
getPipeIpc(currentState).serverName ?? `master-${process.pid}`
|
||||
client = await connectToPipe(targetName, myName, undefined, tcpEndpoint)
|
||||
} catch (err) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Failed to connect to "${targetName}"${tcpEndpoint ? ` (TCP ${tcpEndpoint.host}:${tcpEndpoint.port})` : ''}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Send attach request and wait for response
|
||||
return new Promise(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.disconnect()
|
||||
resolve({
|
||||
type: 'text',
|
||||
value: `Attach to "${targetName}" timed out (no response within 5s).`,
|
||||
})
|
||||
}, 5000)
|
||||
|
||||
client.onMessage((msg: PipeMessage) => {
|
||||
if (msg.type === 'attach_accept') {
|
||||
clearTimeout(timeout)
|
||||
|
||||
// Register the slave client in the module-level registry
|
||||
addSlaveClient(targetName, client)
|
||||
|
||||
// Update AppState: add slave and switch to master role
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...getPipeIpc(prev),
|
||||
role: 'master',
|
||||
displayRole: 'master',
|
||||
slaves: {
|
||||
...getPipeIpc(prev).slaves,
|
||||
[targetName]: {
|
||||
name: targetName,
|
||||
connectedAt: new Date().toISOString(),
|
||||
status: 'idle' as const,
|
||||
unreadCount: 0,
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const slaveCount =
|
||||
Object.keys(getPipeIpc(currentState).slaves).length + 1
|
||||
resolve({
|
||||
type: 'text',
|
||||
value: `Attached to "${targetName}" as master. Now monitoring ${slaveCount} sub session(s).\nUse /send ${targetName} <message> to send tasks.\nUse /status to see all connected subs.\nUse /detach ${targetName} to disconnect.`,
|
||||
})
|
||||
} else if (msg.type === 'attach_reject') {
|
||||
clearTimeout(timeout)
|
||||
client.disconnect()
|
||||
|
||||
resolve({
|
||||
type: 'text',
|
||||
value: `Attach rejected by "${targetName}": ${msg.data ?? 'unknown reason'}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Include machineId so remote can distinguish LAN peers from local peers
|
||||
const pipeState = getPipeIpc(currentState)
|
||||
client.send({
|
||||
type: 'attach_request',
|
||||
meta: { machineId: pipeState.machineId },
|
||||
})
|
||||
})
|
||||
}
|
||||
11
src/commands/attach/index.ts
Normal file
11
src/commands/attach/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const attach = {
|
||||
type: 'local',
|
||||
name: 'attach',
|
||||
description: 'Attach to a sub Claude CLI instance via named pipe',
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./attach.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default attach
|
||||
3
src/commands/autofix-pr/index.d.ts
vendored
Normal file
3
src/commands/autofix-pr/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
declare const _default: Command
|
||||
export default _default
|
||||
3
src/commands/backfill-sessions/index.d.ts
vendored
Normal file
3
src/commands/backfill-sessions/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
declare const _default: Command
|
||||
export default _default
|
||||
@@ -44,7 +44,7 @@ export function deriveFirstPrompt(
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content.find(
|
||||
(block): block is { type: 'text'; text: string } =>
|
||||
(block: { type: string; text?: string }): block is { type: 'text'; text: string } =>
|
||||
block.type === 'text',
|
||||
)?.text
|
||||
if (!raw) return 'Branched conversation'
|
||||
|
||||
3
src/commands/break-cache/index.d.ts
vendored
Normal file
3
src/commands/break-cache/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
declare const _default: Command
|
||||
export default _default
|
||||
@@ -154,7 +154,7 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
type: 'utf8',
|
||||
errorCorrectionLevel: 'L',
|
||||
small: true,
|
||||
})
|
||||
} as Parameters<typeof qrToString>[1])
|
||||
.then(setQrText)
|
||||
.catch(() => setQrText(''))
|
||||
}, [showQR, displayUrl])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user