mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
62 Commits
feature/ma
...
v2.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66c892521b | ||
|
|
dab04af7c9 | ||
|
|
5b5fbb2f47 | ||
|
|
9bfa868e61 | ||
|
|
f6dcf63902 | ||
|
|
5957e26d9b | ||
|
|
58c3feb56a | ||
|
|
e2f4d558e1 | ||
|
|
9afcb398ca | ||
|
|
c80a6d062b | ||
|
|
a05242cef0 | ||
|
|
27b334aceb | ||
|
|
27b665ac79 | ||
|
|
ea399f1862 | ||
|
|
c499bfb4ed | ||
|
|
b67e9f9d38 | ||
|
|
2bca31e525 | ||
|
|
2cc9a7daef | ||
|
|
d66a6f6124 | ||
|
|
48a19b8a0d | ||
|
|
5157b09743 | ||
|
|
ecd3f9d791 | ||
|
|
5b941d4ad4 | ||
|
|
ae7a4e5ae5 | ||
|
|
e5f31afebd | ||
|
|
fc8d531a7d | ||
|
|
835dd2d804 | ||
|
|
0face46fbe | ||
|
|
d451e30741 | ||
|
|
e7070e072f | ||
|
|
833181e025 | ||
|
|
80b46d2221 | ||
|
|
78d46aa233 | ||
|
|
b3d28bcdf1 | ||
|
|
1f80043928 | ||
|
|
3d7b32f52e | ||
|
|
2c8a22d4b3 | ||
|
|
ea5147420d | ||
|
|
3d0f1acfb7 | ||
|
|
478091567d | ||
|
|
b4e52d0c9e | ||
|
|
d11b35e023 | ||
|
|
8570b6ba01 | ||
|
|
db606b5589 | ||
|
|
27a01113e4 | ||
|
|
4a39fd74b1 | ||
|
|
5486d3c02c | ||
|
|
aaabf0c168 | ||
|
|
43c20a43c2 | ||
|
|
17c06690d8 | ||
|
|
89800137b6 | ||
|
|
ea5df0ab60 | ||
|
|
0ce8f7a1cb | ||
|
|
6e1d3d8f47 | ||
|
|
dc3d3e8839 | ||
|
|
998890b469 | ||
|
|
3f0f699ca4 | ||
|
|
66b49d70ab | ||
|
|
2006ab25ff | ||
|
|
0707284939 | ||
|
|
84f12f34bd | ||
|
|
2f86485d9c |
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: Bug 报告
|
||||
description: 报告一个可复现的 bug
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
## 发帖前必读
|
||||
|
||||
- [ ] 我已经搜索过 [现有 Issues](https://github.com/claude-code-best/claude-code/issues),没有找到重复。
|
||||
- [ ] 我使用的是 **最新版本**(`bun run build` 或最新 release)。
|
||||
- [ ] 我已经阅读过 [README](https://github.com/claude-code-best/claude-code) 和相关文档。
|
||||
|
||||
**未完成以上检查的 Issue 将被直接关闭。**
|
||||
|
||||
---
|
||||
|
||||
## 运行环境
|
||||
|
||||
| 项目| 值|
|
||||
|---|---|
|
||||
| 操作系统| 例如 macOS 15.4、Ubuntu 24.04|
|
||||
| Bun 版本| 例如 `bun --version` 的输出|
|
||||
| Claude Code 版本| 例如 `2.4.3` 或 commit hash|
|
||||
| 安装方式| `bun run build` / npm / 其他|
|
||||
| 模型| 例如 claude-sonnet-4-6、claude-opus-4-7|
|
||||
|
||||
## 复现步骤
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 期望行为
|
||||
|
||||
<!-- 应该发生什么? -->
|
||||
|
||||
## 实际行为
|
||||
|
||||
<!-- 实际发生了什么?如有必要可附截图。 -->
|
||||
|
||||
## 相关日志
|
||||
|
||||
<!-- 粘贴终端输出或错误信息,请使用 triple backticks 代码块。 -->
|
||||
|
||||
```text
|
||||
```
|
||||
|
||||
## 补充信息
|
||||
|
||||
<!-- 其他上下文 — 配置、环境变量、尝试过的 workaround 等。 -->
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/claude-code-best/claude-code/discussions
|
||||
about: 使用问题、功能建议和一般讨论 — 请使用 Discussions 而非 Issues。
|
||||
- name: 📖 项目文档
|
||||
url: https://github.com/claude-code-best/claude-code
|
||||
about: 提交 issue 前,请先阅读 README 和相关文档,你的问题可能已经有答案了。
|
||||
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: 功能建议
|
||||
description: 提出新功能或改进建议
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
## 发帖前必读
|
||||
|
||||
- [ ] 我已经搜索过 [现有 Issues](https://github.com/claude-code-best/claude-code/issues),没有找到重复。
|
||||
- [ ] 这是功能建议,不是 Bug 报告或使用问题。
|
||||
- [ ] 使用问题请前往 [Discussions](https://github.com/claude-code-best/claude-code/discussions)。
|
||||
|
||||
---
|
||||
|
||||
## 要解决的问题
|
||||
|
||||
<!-- 这个功能解决什么问题?为什么需要它? -->
|
||||
|
||||
## 建议方案
|
||||
|
||||
<!-- 描述你建议的实现方式,尽量简洁具体。 -->
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
<!-- 还有没有想到的其他实现思路? -->
|
||||
|
||||
## 补充信息
|
||||
|
||||
<!-- 截图、草图、参考资料,或其他有助于说明需求的内容。 -->
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -42,7 +42,8 @@ jobs:
|
||||
run: |
|
||||
# Tolerate pre-existing flaky tests (Bun mock pollution / order-dependent state).
|
||||
# We still require lcov.info to be generated and contain real coverage data.
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s || true
|
||||
set -o pipefail
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
test -s coverage/lcov.info
|
||||
grep -q '^SF:' coverage/lcov.info
|
||||
|
||||
|
||||
4
.github/workflows/publish-npm.yml
vendored
4
.github/workflows/publish-npm.yml
vendored
@@ -3,11 +3,11 @@ name: Publish to npm
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.9.0)'
|
||||
description: "版本号 (例如: v1.9.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
81
CLAUDE.md
81
CLAUDE.md
@@ -78,15 +78,16 @@ bun run docs:dev
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。构建时会将 `vendor/audio-capture/` 和 `src/utils/vendor/ripgrep/` 复制到 `dist/vendor/` 下。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,chunk 输出到 `dist/chunks/`。post-build 同样复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/ripgrep.ts` 和 `packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,代码分割模式,chunk 输出到 `dist/chunks/`。post-build 遍历 `dist/` 和 `dist/chunks/` 下所有 `.js` 文件做 `globalThis.Bun` 解构 patch,复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/distRoot.ts` 提供共享的 `distRoot` 函数,通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 或 `lastIndexOf('src')` 定位根目录。`ripgrep.ts`、`computerUse/setup.ts`、`claudeInChrome/setup.ts`、`updateCCB.ts` 均使用 `distRoot` 而非内联 `import.meta.url` 路径推算。`packages/audio-capture-napi/src/index.ts` 有独立的 `lastIndexOf('dist')` 逻辑,功能等价。
|
||||
- **为什么 Vite 必须代码分割**: Bun/JSC 会全量解析单个大 JS 文件的 bytecode 和 JIT,单文件 17MB 产物导致 RSS 暴涨至 ~1GB(Node/V8 懒解析仅需 ~220MB)。代码分割为 600+ 小 chunk 后 Bun 按需加载,`--version` RSS 从 966MB 降至 35MB,完整加载从 1GB+ 降至 ~500MB。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Monorepo**: Bun workspaces — 17 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/`、`scripts/`、`packages/` 全项目(含 `packages/@ant/`)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。
|
||||
- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`(TS/JS)和 `biome format --write`(JSON)。
|
||||
- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`,lint 或格式化不达标则 CI 失败。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.2.1`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(lint + 构建 + 测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
@@ -104,7 +105,7 @@ bun run docs:dev
|
||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
||||
- `--tmux` + `--worktree` 组合
|
||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
||||
2. **`src/main.tsx`** (~6981 行) — 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`** (~5674 行) — 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
|
||||
@@ -123,17 +124,18 @@ bun run docs:dev
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/constants/tools.ts`** — `CORE_TOOLS` 白名单常量(约 29 个核心工具名),用于 `isDeferredTool` 白名单制判定。
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **`src/constants/tools.ts`** — `CORE_TOOLS` 白名单常量(38 个核心工具名),用于 `isDeferredTool` 白名单制判定。
|
||||
- **`packages/builtin-tools/src/tools/`** — 60 个工具目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: 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
|
||||
- **工具发现**: SearchExtraToolsTool, ExecuteExtraTool, SyntheticOutput(CORE_TOOLS,用于延迟工具按需加载)
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
- **`src/services/searchExtraTools/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`SearchExtraToolsTool.mapToolResultToToolResultBlockParam` 新增可选第三个参数 `context?: { mainLoopModel?: string }`,用于判断当前模型是否支持 `tool_reference`。不支持时回退到文本输出,引导模型使用 ExecuteTool。调用方(`src/services/api/claude.ts` 的 tool_result 处理逻辑)需传入 context 参数。`prefetch.ts` 的 `extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
- **`src/services/searchExtraTools/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`prefetch.ts` 的 `extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -168,18 +170,16 @@ bun run docs:dev
|
||||
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||
| `packages/agent-tools/` | Agent 工具集 |
|
||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
|
||||
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
|
||||
| `packages/mcp-client/` | MCP 客户端库 |
|
||||
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
| `packages/weixin/` | 微信集成(非 workspace 包) |
|
||||
|
||||
辅助目录(无 package.json,非 workspace 包): `langfuse-dashboard`(Langfuse 面板)、`shared-web-ui`(共享 Web UI 组件)、`highlight-code`(代码高亮)、`claude-pencil`(编辑器)、`vscode-ide-bridge`(VS Code 桥接)、`pokemon`(示例/测试)。
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
@@ -210,12 +210,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`。
|
||||
|
||||
**Build 默认 features**(19 个,见 `build.ts`):
|
||||
**Build 默认 features**(65+ 个,见 `build.ts` 中 `DEFAULT_BUILD_FEATURES`):
|
||||
- 基础: `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`
|
||||
- P2: `DAEMON`, `ACP`
|
||||
- 工作流: `WORKFLOW_SCRIPTS`, `HISTORY_SNIP`, `MONITOR_TOOL`, `KAIROS`
|
||||
- 多 worker: `COORDINATOR_MODE`, `BG_SESSIONS`, `TEMPLATES`
|
||||
- 连接器: `CONNECTOR_TEXT`, `COMMIT_ATTRIBUTION`, `DIRECT_CONNECT`
|
||||
- 实验性: `EXPERIMENTAL_SKILL_SEARCH`, `EXPERIMENTAL_SEARCH_EXTRA_TOOLS`
|
||||
- 模式: `POOR`, `SSH_REMOTE`
|
||||
- 已禁用: `CONTEXT_COLLAPSE`, `FORK_SUBAGENT`, `UDS_INBOX`, `LAN_PIPES`, `REVIEW_ARTIFACT`, `TEAMMEM`, `SKILL_LEARNING`
|
||||
|
||||
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
|
||||
|
||||
@@ -265,6 +271,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
||||
| `packages/shell/`, `packages/swarm/`, `packages/mcp-server/`, `packages/cc-knowledge/` | Removed — 功能合并或废弃 |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
@@ -281,7 +288,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **集成测试**: `tests/integration/` — 6 个文件(cli-arguments, context-build, message-pipeline, tool-chain, autonomy-lifecycle-user-flow, dependency-overrides)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||
@@ -308,6 +315,48 @@ mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
|
||||
#### 跨文件 mock 污染(process-global `mock.module`)
|
||||
|
||||
**Bun 的 `mock.module` 是进程全局的(last-write-wins),不是 per-file 隔离的。** 一个测试文件的 `mock.module` 会污染同一进程中所有其他测试文件的 `require`/`import`。
|
||||
|
||||
**关键事实(Bun 1.x 实测验证):**
|
||||
- 测试文件执行顺序**不是严格字母序**,不要假设文件 A 一定在文件 B 之前执行。
|
||||
- `mock.module` 在 `beforeAll` 内部调用时**不会被提升**(hoist),但仍会污染后续加载的文件。
|
||||
- `require()` 和 `import()` 共享同一模块注册表,`mock.module` 对两者都生效。
|
||||
- 一个模块一旦被某个文件的 `mock.module` 替换,同一进程中所有后续 `require`/`import` 都会返回 mock 值,即使调用方使用不同的 specifier 路径。
|
||||
|
||||
**核心规则:不要 mock 被测模块的上层业务模块。**
|
||||
|
||||
错误做法(会污染同目录的 `api.test.ts`):
|
||||
```ts
|
||||
// launchSchedule.test.ts — 直接 mock 源 API 模块 ❌
|
||||
mock.module('src/commands/schedule/triggersApi.js', () => ({
|
||||
listTriggers: listTriggersMock,
|
||||
// ...
|
||||
}))
|
||||
```
|
||||
|
||||
正确做法(mock 底层 HTTP 层,不污染业务模块):参考 `launchSkillStore.test.ts`、`launchVault.test.ts` 的模式。
|
||||
```ts
|
||||
// launchSchedule.test.ts — mock axios 而非 triggersApi ✅
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
|
||||
beforeAll(() => { axiosHandle.useStubs = true })
|
||||
afterAll(() => { axiosHandle.useStubs = false })
|
||||
```
|
||||
|
||||
**判断标准:** 如果目录下同时有 `launch*.test.ts`(集成测试)和 `api.test.ts`(回归测试),`launch*.test.ts` 必须 mock axios 而非源 API 模块。`api.test.ts` 需要测试真实 API 模块的 HTTP 方法/URL/错误处理逻辑,被 mock 后就无法测试。
|
||||
|
||||
**排查 mock 污染的方法:**
|
||||
1. 单独运行可疑文件确认其通过:`bun test path/to/suspect.test.ts`
|
||||
2. 与同目录其他文件一起运行定位污染源:`bun test path/to/__tests__/`
|
||||
3. 在两个文件中各加 `console.error('[file] milestone')` 追踪实际执行顺序
|
||||
4. 检查 `mock.module` 的 specifier 是否与同目录其他测试的 `require`/`import` 路径解析到同一模块
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.3 MiB |
54
docs/performance-reporter.md
Normal file
54
docs/performance-reporter.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 内存占用 1G 调研报告
|
||||
|
||||
> 诊断 session `a3593062` RSS 达 1.09 GB,定位 Bun 运行时内存膨胀根因
|
||||
|
||||
## 数据收集
|
||||
|
||||
- **诊断数据**: RSS 1,118 MB,V8 heap 84 MB,原生内存缺口 1,034 MB(92%)
|
||||
- **构建方式**: `bun run build:vite` → Vite/Rollup 单文件构建,产物 17MB `dist/cli.js`
|
||||
- **Vite 配置**: `codeSplitting: false`(`vite.config.ts:97`),所有代码内联为单文件
|
||||
- **Node.js 对比**: 相同 17MB 产物,Node.js RSS 仅 223 MB(`--version`)/ 340 MB(完整加载)
|
||||
|
||||
## 探索与验证
|
||||
|
||||
### 已确认
|
||||
|
||||
| 问题 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| **根因: Vite 单文件构建 + Bun 解析大文件内存效率低** | `vite.config.ts:97` | `codeSplitting: false` 产出 17MB 单文件,Bun/JSC 解析时 RSS 暴涨至 966MB |
|
||||
| Node.js 对同等 17MB 文件仅需 223MB | 实测 | V8 对大文件解析的内存效率远优于 JSC |
|
||||
| Bun.build 代码分割可解决问题 | 实测 | `bun run build`(代码分割 → 627 chunk)Bun RSS 仅 30MB(`--version`)/ 318MB(完整加载) |
|
||||
|
||||
### 已否认
|
||||
|
||||
- 不是 feature flags 数量问题 — 全部 35 features 开启时,代码分割构建内存正常
|
||||
- 不是内存泄漏 — `detachedContexts: 0`,`activeHandles: 0`
|
||||
- 不是原生 addon 问题 — vendor 文件仅 2.7MB
|
||||
- 不是 TypeScript 源码体量问题 — `bun run dev`(直接加载 TS)完整路径仅 345MB
|
||||
|
||||
## 结论
|
||||
|
||||
**根因是 Vite 构建配置 `codeSplitting: false`,产出 17MB 单文件,Bun/JSC 解析单文件大 JS 时内存效率极差(966MB vs Node 的 223MB)。**
|
||||
|
||||
实测对比矩阵:
|
||||
|
||||
| 构建方式 | 产物结构 | Bun RSS | Node RSS | Bun/Node |
|
||||
|----------|----------|---------|----------|----------|
|
||||
| `build:vite` | 17MB 单文件 | **966 MB** | 223 MB | 4.3x |
|
||||
| `build:vite` pipe mode | 同上 | **1,088 MB** | 340 MB | 3.2x |
|
||||
| `build` (Bun) | 627 chunk | 30 MB | 42 MB | 0.7x |
|
||||
| `build` (Bun) pipe mode | 同上 | 318 MB | 253 MB | 1.3x |
|
||||
| `bun run dev` TS 源码 | 动态加载 | 42 MB | — | — |
|
||||
| `bun run dev` pipe mode | 动态加载 | 345 MB | — | — |
|
||||
|
||||
核心差异:
|
||||
- **Node/V8** 解析 17MB 文件只需 223MB — V8 的懒解析(lazy parsing)只编译入口需要的部分
|
||||
- **Bun/JSC** 解析 17MB 文件需要 966MB — JSC 对单文件做全量编译,bytecode + JIT 占用大量原生内存
|
||||
- 代码分割后(627 个小 chunk),Bun 按需加载,内存回到正常水平
|
||||
|
||||
## 建议
|
||||
|
||||
1. **开启 Vite 代码分割** — 在 `vite.config.ts` 中启用 `codeSplitting: true` 或使用 Rollup 的 `manualChunks` 配置。这是最直接的修复
|
||||
2. **或切换到 Bun.build** — `bun run build` 已默认启用代码分割(`splitting: true`),Bun RSS 仅 30-318MB
|
||||
3. **如果必须单文件** — 考虑用 Node.js 运行 Vite 产物(`node dist/cli-node.js`),代价是失去 Bun 特有 API
|
||||
4. **验证 `codeSplitting: false` 的存在理由** — 注释说"all dynamic imports inlined",可能是为了简化部署。评估是否真的需要单文件
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.2.1",
|
||||
"version": "2.6.0",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SocketConnectionError } from './mcpSocketClient.js'
|
||||
import {
|
||||
localPlatformLabel,
|
||||
type BridgePermissionRequest,
|
||||
toLoggerDetail,
|
||||
type ChromeExtensionInfo,
|
||||
type ClaudeForChromeContext,
|
||||
type PermissionMode,
|
||||
@@ -578,7 +579,7 @@ export class BridgeClient implements SocketClient {
|
||||
const durationMs = Date.now() - this.connectionStartTime
|
||||
logger.error(
|
||||
`[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
|
||||
error,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
trackEvent?.('chrome_bridge_connection_failed', {
|
||||
duration_ms: durationMs,
|
||||
@@ -618,7 +619,10 @@ export class BridgeClient implements SocketClient {
|
||||
)
|
||||
this.handleMessage(message)
|
||||
} catch (error) {
|
||||
logger.error(`[${serverName}] Failed to parse bridge message:`, error)
|
||||
logger.error(
|
||||
`[${serverName}] Failed to parse bridge message:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -862,7 +866,10 @@ export class BridgeClient implements SocketClient {
|
||||
const allowed = await pending.onPermissionRequest(request)
|
||||
this.sendPermissionResponse(requestId, allowed)
|
||||
} catch (error) {
|
||||
logger.error(`[${serverName}] Error handling permission request:`, error)
|
||||
logger.error(
|
||||
`[${serverName}] Error handling permission request:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
this.sendPermissionResponse(requestId, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ export { localPlatformLabel } from './types.js'
|
||||
export type {
|
||||
BridgeConfig,
|
||||
ChromeExtensionInfo,
|
||||
ChromeBridgeTrackEventMetadata,
|
||||
ClaudeForChromeContext,
|
||||
Logger,
|
||||
LoggerDetail,
|
||||
PermissionMode,
|
||||
SocketClient,
|
||||
} from './types.js'
|
||||
export { toLoggerDetail } from './types.js'
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
export class SocketConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -87,7 +88,10 @@ class McpSocketClient {
|
||||
await this.validateSocketSecurity(socketPath)
|
||||
} catch (error) {
|
||||
this.connecting = false
|
||||
logger.info(`[${serverName}] Security validation failed:`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Security validation failed:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
// Don't retry on security failures (wrong perms/owner) - those won't
|
||||
// self-resolve. Only the error handler retries on transient errors.
|
||||
return
|
||||
@@ -145,14 +149,20 @@ class McpSocketClient {
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Failed to parse message:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('error', (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Socket error (code: ${error.code}):`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PermissionOverrides,
|
||||
SocketClient,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
export const handleToolCall = async (
|
||||
context: ClaudeForChromeContext,
|
||||
@@ -44,7 +45,10 @@ export const handleToolCall = async (
|
||||
|
||||
return handleToolCallDisconnected(context)
|
||||
} catch (error) {
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
|
||||
context.logger.info(
|
||||
`[${context.serverName}] Error calling tool:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
|
||||
if (error instanceof SocketConnectionError) {
|
||||
return handleToolCallDisconnected(context)
|
||||
@@ -165,8 +169,7 @@ async function handleToolCallConnected(
|
||||
|
||||
// Fallback for unexpected result format
|
||||
context.logger.warn(
|
||||
`[${context.serverName}] Unexpected result format from socket bridge`,
|
||||
response,
|
||||
`[${context.serverName}] Unexpected result format from socket bridge: ${JSON.stringify(response)}`,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,84 @@
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
/**
|
||||
* Logger 第二参数的可选类型。
|
||||
* 调用方通过 util.format 追加详情,实践中多为 catch 到的异常对象。
|
||||
*/
|
||||
export type LoggerDetail = Error | NodeJS.ErrnoException
|
||||
|
||||
/** 将 unknown 收窄为 LoggerDetail,供 catch 块传给 logger 使用。 */
|
||||
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
|
||||
return detail instanceof Error ? detail : undefined
|
||||
}
|
||||
|
||||
/** 宿主注入的日志接口,与 DebugLogger(util.format)对齐。 */
|
||||
export interface Logger {
|
||||
info: (message: string, detail?: LoggerDetail) => void // 信息
|
||||
error: (message: string, detail?: LoggerDetail) => void // 错误
|
||||
warn: (message: string, detail?: LoggerDetail) => void // 警告
|
||||
debug: (message: string, detail?: LoggerDetail) => void // 调试
|
||||
silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge 连接失败时的 error_type 枚举。
|
||||
* 由 bridgeClient 在 getUserId / getOAuthToken / WebSocket 创建失败时上报。
|
||||
*/
|
||||
export type ChromeBridgeConnectionErrorType =
|
||||
| 'no_user_id' // 无法获取用户 UUID
|
||||
| 'no_oauth_token' // 无法获取 OAuth token
|
||||
| 'websocket_error' // WebSocket 创建或运行异常
|
||||
|
||||
/** 工具调用相关遥测元数据(started / completed / timeout / error)。 */
|
||||
export type ChromeBridgeToolCallMetadata = {
|
||||
tool_name: string // MCP 工具名
|
||||
tool_use_id: string // 本次调用的 UUID
|
||||
duration_ms?: number // 耗时(毫秒)
|
||||
timeout_ms?: number // 超时阈值(毫秒),仅 timeout 事件
|
||||
error_message?: string // 错误摘要(截断),仅 error 事件
|
||||
}
|
||||
|
||||
/** Bridge 连接失败遥测元数据。 */
|
||||
export type ChromeBridgeConnectionFailedMetadata = {
|
||||
duration_ms: number // 自连接开始到失败的耗时(毫秒)
|
||||
error_type: ChromeBridgeConnectionErrorType // 失败原因分类
|
||||
reconnect_attempt: number // 当前重连尝试次数
|
||||
}
|
||||
|
||||
/** Bridge 开始连接遥测元数据。 */
|
||||
export type ChromeBridgeConnectionStartedMetadata = {
|
||||
bridge_url: string // 目标 WebSocket URL(含用户路径)
|
||||
}
|
||||
|
||||
/** Bridge 断开连接遥测元数据。 */
|
||||
export type ChromeBridgeDisconnectedMetadata = {
|
||||
close_code: number // WebSocket 关闭码
|
||||
duration_since_connect_ms: number // 自连接成功到断开的时长(毫秒)
|
||||
reconnect_attempt: number // 即将进行的重连序号
|
||||
}
|
||||
|
||||
/** Bridge 连接成功遥测元数据。 */
|
||||
export type ChromeBridgeConnectionSucceededMetadata = {
|
||||
duration_ms: number // 自开始到连接就绪的耗时(毫秒)
|
||||
status: 'paired' | 'waiting' // paired=已配对扩展;waiting=等待扩展接入
|
||||
}
|
||||
|
||||
/** Bridge 重连次数耗尽遥测元数据。 */
|
||||
export type ChromeBridgeReconnectExhaustedMetadata = {
|
||||
total_attempts: number // 累计重连次数上限
|
||||
}
|
||||
|
||||
/**
|
||||
* trackEvent 回调的 metadata 联合类型。
|
||||
* 各变体对应 bridgeClient 内 chrome_bridge_* 事件;null 表示无附加字段。
|
||||
*/
|
||||
export type ChromeBridgeTrackEventMetadata =
|
||||
| ChromeBridgeToolCallMetadata
|
||||
| ChromeBridgeConnectionFailedMetadata
|
||||
| ChromeBridgeConnectionStartedMetadata
|
||||
| ChromeBridgeDisconnectedMetadata
|
||||
| ChromeBridgeConnectionSucceededMetadata
|
||||
| ChromeBridgeReconnectExhaustedMetadata
|
||||
| null // 无元数据(如 peer_connected / peer_disconnected)
|
||||
|
||||
export type PermissionMode =
|
||||
| 'ask'
|
||||
| 'skip_all_permission_checks'
|
||||
@@ -48,10 +121,10 @@ export interface ClaudeForChromeContext {
|
||||
bridgeConfig?: BridgeConfig
|
||||
/** If set, permission mode is sent to the extension immediately on bridge connection. */
|
||||
initialPermissionMode?: PermissionMode
|
||||
/** Optional callback to track telemetry events for bridge connections */
|
||||
trackEvent?: <K extends string>(
|
||||
eventName: K,
|
||||
metadata: Record<string, unknown> | null,
|
||||
/** Bridge 遥测回调;eventName 为 chrome_bridge_* 事件名 */
|
||||
trackEvent?: (
|
||||
eventName: string, // 事件名
|
||||
metadata: ChromeBridgeTrackEventMetadata, // 事件元数据
|
||||
) => void
|
||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import type { ScreenshotResult } from './executor.js'
|
||||
import type { Logger } from './types.js'
|
||||
import { type Logger, toLoggerDetail } from './types.js'
|
||||
|
||||
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
|
||||
export type CropRawPatchFn = (
|
||||
@@ -165,7 +165,10 @@ export async function validateClickTarget(
|
||||
} catch (err) {
|
||||
// Skip validation on technical errors, execute action anyway.
|
||||
// Battle-tested: validation failure must never block the click.
|
||||
logger.debug('[pixelCompare] validation error, skipping', err)
|
||||
logger.debug(
|
||||
'[pixelCompare] validation error, skipping',
|
||||
toLoggerDetail(err),
|
||||
)
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import type {
|
||||
ResolvedAppRequest,
|
||||
TeachStepRequest,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
/**
|
||||
* Finder is never hidden by the hide loop (hiding Finder kills the Desktop),
|
||||
@@ -4446,7 +4447,10 @@ export async function handleToolCall(
|
||||
// For ungated tools, the executor may have been mid-call; that's fine —
|
||||
// the result is still a tool error, never an implicit success.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`[${serverName}] tool=${name} threw: ${msg}`, err)
|
||||
logger.error(
|
||||
`[${serverName}] tool=${name} threw: ${msg}`,
|
||||
toLoggerDetail(err),
|
||||
)
|
||||
return errorResult(`Tool "${name}" failed: ${msg}`, 'executor_threw')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@ import type {
|
||||
* cross-respawn `scaleCoord` survival. */
|
||||
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'>
|
||||
|
||||
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
|
||||
/**
|
||||
* Logger 第二参数的可选类型(与 claude-for-chrome-mcp 对齐)。
|
||||
* 实践中多为 catch 到的 Error。
|
||||
*/
|
||||
export type LoggerDetail = Error | NodeJS.ErrnoException
|
||||
|
||||
/** 将 unknown 收窄为 LoggerDetail,供 catch 块传给 logger 使用。 */
|
||||
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
|
||||
return detail instanceof Error ? detail : undefined
|
||||
}
|
||||
|
||||
/** 宿主注入的日志接口(与 claude-for-chrome-mcp/src/types.ts 对齐)。 */
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
info: (message: string, detail?: LoggerDetail) => void // 信息
|
||||
error: (message: string, detail?: LoggerDetail) => void // 错误
|
||||
warn: (message: string, detail?: LoggerDetail) => void // 警告
|
||||
debug: (message: string, detail?: LoggerDetail) => void // 调试
|
||||
silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Cursor = any
|
||||
/** 渲染帧中虚拟终端光标的状态(列/行坐标与是否绘制),供 diff 与光标 preamble 使用。 */
|
||||
export type Cursor = {
|
||||
x: number // 光标所在列,从 0 开始计
|
||||
y: number // 光标所在行,从 0 开始计
|
||||
visible: boolean // 本帧是否应在终端绘制光标(隐藏时不发射光标移动序列)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||
import type { FocusManager } from './focus.js'
|
||||
import { createLayoutNode } from './layout/engine.js'
|
||||
import type { LayoutNode } from './layout/node.js'
|
||||
@@ -45,10 +46,9 @@ export type DOMElement = {
|
||||
dirty: boolean
|
||||
// Set by the reconciler's hideInstance/unhideInstance; survives style updates.
|
||||
isHidden?: boolean
|
||||
// Event handlers set by the reconciler for the capture/bubble dispatcher.
|
||||
// Stored separately from attributes so handler identity changes don't
|
||||
// mark dirty and defeat the blit optimization.
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
// 协调器写入的事件处理器(捕获/冒泡分发用)。
|
||||
// 与 attributes 分离,避免 handler 引用变化触发 dirty 破坏 blit 优化。
|
||||
_eventHandlers?: Partial<EventHandlerProps> // 见 event-handlers.ts EventHandlerProps
|
||||
|
||||
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
|
||||
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type PasteEvent = any
|
||||
/** Box 等组件上 `onPaste` / `onPasteCapture` 收到的粘贴事件形状(与括号粘贴解析结果对齐的占位约定)。 */
|
||||
export type PasteEvent = {
|
||||
pastedText: string // 终端括号粘贴模式下解析出的 UTF-8 文本;允许为空字符串以表示空粘贴
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ResizeEvent = any
|
||||
/** 终端尺寸变化时 `onResize` 回调收到的事件载荷(与 `stdout.columns` / `stdout.rows` 一致)。 */
|
||||
export type ResizeEvent = {
|
||||
columns: number // 当前终端列数(宽度)
|
||||
rows: number // 当前终端行数(高度)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,10 @@ export class TerminalEvent extends Event {
|
||||
_prepareForTarget(_target: EventTarget): void {}
|
||||
}
|
||||
|
||||
import type { EventHandlerProps } from './event-handlers.js'
|
||||
|
||||
/** 终端事件系统的目标节点(DOM 树节点或根节点)。 */
|
||||
export type EventTarget = {
|
||||
parentNode: EventTarget | undefined
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
parentNode: EventTarget | undefined // 父节点,根节点为 undefined
|
||||
_eventHandlers?: Partial<EventHandlerProps> // 事件处理器,与 dom.ts DOMElement 同构
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
type TextNode,
|
||||
} from './dom.js'
|
||||
import { Dispatcher } from './events/dispatcher.js'
|
||||
import { EVENT_HANDLER_PROPS } from './events/event-handlers.js'
|
||||
import {
|
||||
EVENT_HANDLER_PROPS,
|
||||
type EventHandlerProps,
|
||||
} from './events/event-handlers.js'
|
||||
import { getFocusManager, getRootNode } from './focus.js'
|
||||
import { LayoutDisplay } from './layout/node.js'
|
||||
import applyStyles, { type Styles, type TextStyles } from './styles.js'
|
||||
@@ -111,7 +114,11 @@ type HostContext = {
|
||||
isInsideText: boolean
|
||||
}
|
||||
|
||||
function setEventHandler(node: DOMElement, key: string, value: unknown): void {
|
||||
function setEventHandler<K extends keyof EventHandlerProps>(
|
||||
node: DOMElement,
|
||||
key: K,
|
||||
value: EventHandlerProps[K],
|
||||
): void {
|
||||
if (!node._eventHandlers) {
|
||||
node._eventHandlers = {}
|
||||
}
|
||||
@@ -135,7 +142,11 @@ function applyProp(node: DOMElement, key: string, value: unknown): void {
|
||||
}
|
||||
|
||||
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||
setEventHandler(node, key, value)
|
||||
setEventHandler(
|
||||
node,
|
||||
key as keyof EventHandlerProps,
|
||||
value as EventHandlerProps[keyof EventHandlerProps],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -441,7 +452,11 @@ const reconciler = createReconciler<
|
||||
}
|
||||
|
||||
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||
setEventHandler(node, key, value)
|
||||
setEventHandler(
|
||||
node,
|
||||
key as keyof EventHandlerProps,
|
||||
value as EventHandlerProps[keyof EventHandlerProps],
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
let finishReason: string | undefined
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedReadTokens = 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const usage = chunk.usageMetadata
|
||||
@@ -23,6 +24,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
inputTokens = usage.promptTokenCount ?? inputTokens
|
||||
outputTokens =
|
||||
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
|
||||
cachedReadTokens = usage.cachedContentTokenCount ?? cachedReadTokens
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
@@ -41,7 +43,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
},
|
||||
} as unknown as BetaRawMessageStreamEvent
|
||||
@@ -204,7 +206,10 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export type GeminiUsageMetadata = {
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
cachedContentTokenCount?: number
|
||||
}
|
||||
|
||||
export type GeminiCandidate = {
|
||||
|
||||
@@ -57,13 +57,4 @@ describe('prompt.ts fork-related text verification', () => {
|
||||
expect(bgCondition[0]).not.toContain('!forkEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
test('fork example includes fork: true parameter', () => {
|
||||
// The first fork example should have fork: true
|
||||
const forkExampleBlock = promptSource.match(
|
||||
/name: "ship-audit"[\s\S]*?Under 200 words/,
|
||||
)
|
||||
expect(forkExampleBlock).not.toBeNull()
|
||||
expect(forkExampleBlock![0]).toContain('fork: true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BASH_TOOL_NAME = any
|
||||
/** Bash 工具在 API 与 Agent 提示串中的注册名称字面量(与 `@claude-code-best/builtin-tools` 中 `BASH_TOOL_NAME` 常量一致)。 */
|
||||
export type BASH_TOOL_NAME = 'Bash'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = any
|
||||
/** ExitPlanMode 工具在 API 中的注册名称字面量(与内置 ExitPlanMode 工具 `name` 一致)。 */
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_EDIT_TOOL_NAME = any
|
||||
/** Edit(文件编辑)工具在 API 中的注册名称字面量(与 `FILE_EDIT_TOOL_NAME` 常量 `'Edit'` 一致)。 */
|
||||
export type FILE_EDIT_TOOL_NAME = 'Edit'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_READ_TOOL_NAME = any
|
||||
/** Read(文件读取)工具在 API 中的注册名称字面量(与 `FILE_READ_TOOL_NAME` 常量 `'Read'` 一致)。 */
|
||||
export type FILE_READ_TOOL_NAME = 'Read'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_WRITE_TOOL_NAME = any
|
||||
/** Write(文件写入)工具在 API 中的注册名称字面量(与 `FILE_WRITE_TOOL_NAME` 常量 `'Write'` 一致)。 */
|
||||
export type FILE_WRITE_TOOL_NAME = 'Write'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GLOB_TOOL_NAME = any
|
||||
/** Glob(文件名模式匹配)工具在 API 中的注册名称字面量(与 `GLOB_TOOL_NAME` 常量 `'Glob'` 一致)。 */
|
||||
export type GLOB_TOOL_NAME = 'Glob'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GREP_TOOL_NAME = any
|
||||
/** Grep(内容搜索)工具在 API 中的注册名称字面量(与 `GREP_TOOL_NAME` 常量 `'Grep'` 一致)。 */
|
||||
export type GREP_TOOL_NAME = 'Grep'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = any
|
||||
/** NotebookEdit(笔记本单元格编辑)工具在 API 中的注册名称字面量(与 `NOTEBOOK_EDIT_TOOL_NAME` 常量一致)。 */
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = 'NotebookEdit'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SEND_MESSAGE_TOOL_NAME = any
|
||||
/** SendMessage(向用户/通道发消息)工具在 API 中的注册名称字面量(与 `SEND_MESSAGE_TOOL_NAME` 常量一致)。 */
|
||||
export type SEND_MESSAGE_TOOL_NAME = 'SendMessage'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_FETCH_TOOL_NAME = any
|
||||
/** WebFetch(拉取并处理 URL 内容)工具在 API 中的注册名称字面量(与 `WEB_FETCH_TOOL_NAME` 常量一致)。 */
|
||||
export type WEB_FETCH_TOOL_NAME = 'WebFetch'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_SEARCH_TOOL_NAME = any
|
||||
/** WebSearch(联网搜索)工具在 API 中的注册名称字面量(与 `WEB_SEARCH_TOOL_NAME` 常量一致)。 */
|
||||
export type WEB_SEARCH_TOOL_NAME = 'WebSearch'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isUsing3PServices = any
|
||||
/** 是否正在使用第三方(非 Anthropic 直连)API 或服务;与仓库根 `src/utils/auth.ts` 中 `isUsing3PServices` 签名一致。 */
|
||||
export type isUsing3PServices = () => boolean // 返回 true 表示当前配置走兼容层或第三方模型端点
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type hasEmbeddedSearchTools = any
|
||||
/** 当前构建是否将 Glob/Grep 嵌入其它工具而不单独注册;与仓库根 `src/utils/embeddedTools.ts` 中 `hasEmbeddedSearchTools` 一致。 */
|
||||
export type hasEmbeddedSearchTools = () => boolean // 返回 true 时工具列表不包含独立的 Glob/Grep 工具名
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getSettings_DEPRECATED = any
|
||||
import type { SettingsJson } from 'src/utils/settings/types.js'
|
||||
|
||||
/** 返回各设置来源合并后的快照(已废弃函数名,行为同 `getInitialSettings`);与 `src/utils/settings/settings.ts` 一致。 */
|
||||
export type getSettings_DEPRECATED = () => SettingsJson // 无参数;至少得到可空字段填充后的合并设置对象
|
||||
|
||||
@@ -12,9 +12,7 @@ import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
export function areExplorePlanAgentsEnabled(): boolean {
|
||||
if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) {
|
||||
// 3P default: true — Bedrock/Vertex keep agents enabled (matches pre-experiment
|
||||
// external behavior). A/B test treatment sets false to measure impact of removal.
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { isEnvDefinedFalsy, isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { isTeammate } from 'src/utils/teammate.js'
|
||||
import { isInProcessTeammate } from 'src/utils/teammateContext.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
|
||||
import { SEND_MESSAGE_TOOL_NAME } from '../SendMessageTool/constants.js'
|
||||
import { AGENT_TOOL_NAME } from './constants.js'
|
||||
@@ -84,11 +83,11 @@ export async function getPrompt(
|
||||
|
||||
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use \`fork: true\`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
|
||||
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it.
|
||||
|
||||
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.
|
||||
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results. If the user asks a follow-up before the notification lands, tell them the fork is still running.
|
||||
|
||||
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope: what's in, what's out, what another agent is handling. Don't re-explain background.
|
||||
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope. Don't re-explain background.
|
||||
`
|
||||
: ''
|
||||
|
||||
@@ -97,91 +96,13 @@ When you need to delegate work that benefits from full conversation context (e.g
|
||||
## Writing the prompt
|
||||
|
||||
${forkEnabled ? 'When spawning an agent without `fork: true`, it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
||||
- Explain what you're trying to accomplish and why.
|
||||
- Describe what you've already learned or ruled out.
|
||||
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
||||
- Explain what you're trying to accomplish and why, what you've already learned or ruled out, and enough context for the agent to make judgment calls.
|
||||
- If you need a short response, say so ("report in under 200 words").
|
||||
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
||||
|
||||
${forkEnabled ? 'For non-fork agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
|
||||
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
|
||||
const forkExamples = `Example usage:
|
||||
|
||||
<example>
|
||||
user: "What's left on this branch before we can ship?"
|
||||
assistant: <thinking>Forking this \u2014 it's a survey question. I want the punch list, not the git output in my context.</thinking>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "ship-audit",
|
||||
description: "Branch ship-readiness audit",
|
||||
fork: true,
|
||||
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
|
||||
})
|
||||
assistant: Ship-readiness audit running.
|
||||
<commentary>
|
||||
Turn ends here. The coordinator knows nothing about the findings yet. What follows is a SEPARATE turn \u2014 the notification arrives from outside, as a user-role message. It is not something the coordinator writes.
|
||||
</commentary>
|
||||
[later turn \u2014 notification arrives as user message]
|
||||
assistant: Audit's back. Three blockers: no tests for the new prompt path, GrowthBook gate wired but not in build_flags.yaml, and one uncommitted file.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "so is the gate wired up or not"
|
||||
<commentary>
|
||||
User asks mid-wait. The audit fork was launched to answer exactly this, and it hasn't returned. The coordinator does not have this answer. Give status, not a fabricated result.
|
||||
</commentary>
|
||||
assistant: Still waiting on the audit \u2014 that's one of the things it's checking. Should land shortly.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Can you get a second opinion on whether this migration is safe?"
|
||||
assistant: <thinking>I'll ask the code-reviewer agent — it won't see my analysis, so it can give an independent read.</thinking>
|
||||
<commentary>
|
||||
A subagent_type is specified, so the agent starts fresh. It needs full context in the prompt. The briefing explains what to assess and why.
|
||||
</commentary>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "migration-review",
|
||||
description: "Independent migration review",
|
||||
subagent_type: "code-reviewer",
|
||||
prompt: "Review migration 0042_user_schema.sql for safety. Context: we're adding a NOT NULL column to a 50M-row table. Existing rows get a backfill default. I want a second opinion on whether the backfill approach is safe under concurrent writes — I've checked locking behavior but want independent verification. Report: is this safe, and if not, what specifically breaks?"
|
||||
})
|
||||
</example>
|
||||
`
|
||||
|
||||
const currentExamples = `Example usage:
|
||||
|
||||
<example_agent_descriptions>
|
||||
"test-runner": use this agent after you are done writing code to run tests
|
||||
"greeting-responder": use this agent to respond to user greetings with a friendly joke
|
||||
</example_agent_descriptions>
|
||||
|
||||
<example>
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: I'm going to use the ${FILE_WRITE_TOOL_NAME} tool to write the following code:
|
||||
<code>
|
||||
function isPrime(n) {
|
||||
if (n <= 1) return false
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</code>
|
||||
<commentary>
|
||||
Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests
|
||||
</commentary>
|
||||
assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the test-runner agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Hello"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
|
||||
</commentary>
|
||||
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent"
|
||||
</example>
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
|
||||
// When the gate is on, the agent list lives in an agent_listing_delta
|
||||
@@ -273,7 +194,5 @@ Usage notes:
|
||||
? `
|
||||
- The name, team_name, and mode parameters are not available in this context — teammates cannot spawn other teammates. Omit them to spawn a subagent.`
|
||||
: ''
|
||||
}${whenToForkSection}${writingThePromptSection}
|
||||
|
||||
${forkEnabled ? forkExamples : currentExamples}`
|
||||
}${whenToForkSection}${writingThePromptSection}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type buildTool = any
|
||||
export type ToolDef = any
|
||||
export type toolMatchesName = any
|
||||
/** 根据工具定义装配宿主侧可调用 `Tool` 实例的工厂函数类型。 */
|
||||
export type buildTool = typeof import('src/Tool.js').buildTool
|
||||
|
||||
/** 工具定义泛型(输入 Schema、权限、进度等);与宿主 `ToolDef` 一致。 */
|
||||
export type ToolDef = import('src/Tool.js').ToolDef
|
||||
|
||||
/** 判断工具主名称或别名是否与查询名称相等;与宿主 `toolMatchesName` 一致。 */
|
||||
export type toolMatchesName = typeof import('src/Tool.js').toolMatchesName
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ConfigurableShortcutHint = any
|
||||
/** 可配置快捷键提示组件(从 keybindings 解析展示文案);与宿主 `ConfigurableShortcutHint` 组件类型一致。 */
|
||||
export type ConfigurableShortcutHint =
|
||||
typeof import('src/components/ConfigurableShortcutHint.js').ConfigurableShortcutHint
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CtrlOToExpand = any
|
||||
export type SubAgentProvider = any
|
||||
/** 「Ctrl+O 展开」提示组件;与宿主 `src/components/CtrlOToExpand.tsx` 中 `CtrlOToExpand` 一致。 */
|
||||
export type CtrlOToExpand =
|
||||
typeof import('src/components/CtrlOToExpand.js').CtrlOToExpand
|
||||
|
||||
/** 标记子 Agent 输出上下文,用于抑制重复的展开提示;与宿主 `SubAgentProvider` 一致。 */
|
||||
export type SubAgentProvider =
|
||||
typeof import('src/components/CtrlOToExpand.js').SubAgentProvider
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Byline = any
|
||||
/** Ink 底部快捷键说明行容器组件;与 `@anthropic/ink` 导出的 `Byline` 一致。 */
|
||||
export type Byline = typeof import('@anthropic/ink').Byline
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type KeyboardShortcutHint = any
|
||||
/** Ink 快捷键「按键 + 动作」展示组件;与 `@anthropic/ink` 导出的 `KeyboardShortcutHint` 一致。 */
|
||||
export type KeyboardShortcutHint =
|
||||
typeof import('@anthropic/ink').KeyboardShortcutHint
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Message = any
|
||||
export type NormalizedUserMessage = any
|
||||
/** 对话消息联合类型(含用户/助手/系统等);与宿主 `src/types/message.js` 重导出一致。 */
|
||||
export type Message = import('src/types/message.js').Message
|
||||
|
||||
/** 归一化后的用户消息形状;与宿主 `src/types/message.js` 中 `NormalizedUserMessage` 一致。 */
|
||||
export type NormalizedUserMessage =
|
||||
import('src/types/message.js').NormalizedUserMessage
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logForDebugging = any
|
||||
/** 写入调试日志文件(受日志级别与过滤规则约束);与宿主 `src/utils/debug.js` 中 `logForDebugging` 一致。 */
|
||||
export type logForDebugging =
|
||||
typeof import('src/utils/debug.js').logForDebugging
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getQuerySourceForAgent = any
|
||||
/** 按内置/自定义 Agent 类型解析用于遥测或分类的 `QuerySource`;与宿主 `getQuerySourceForAgent` 一致。 */
|
||||
export type getQuerySourceForAgent =
|
||||
typeof import('src/utils/promptCategory.js').getQuerySourceForAgent
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SettingSource = any
|
||||
/** 设置文件来源层级标识(用户/项目/本地等);与宿主 `src/utils/settings/constants.js` 中 `SettingSource` 一致。 */
|
||||
export type SettingSource =
|
||||
import('src/utils/settings/constants.js').SettingSource
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getAllowedChannels = any
|
||||
export type getQuestionPreviewFormat = any
|
||||
/** 返回当前允许展示的通道列表(含名称、连接状态等);与宿主 `src/bootstrap/state.js` 中 `getAllowedChannels` 一致。 */
|
||||
export type getAllowedChannels =
|
||||
typeof import('src/bootstrap/state.js').getAllowedChannels
|
||||
|
||||
/** 返回问题预览渲染格式(Markdown/HTML)或未配置;与宿主 `getQuestionPreviewFormat` 一致。 */
|
||||
export type getQuestionPreviewFormat =
|
||||
typeof import('src/bootstrap/state.js').getQuestionPreviewFormat
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type MessageResponse = any
|
||||
/** 工具结果在消息流中的外层布局组件;与宿主 `src/components/MessageResponse.js` 中 `MessageResponse` 一致。 */
|
||||
export type MessageResponse =
|
||||
typeof import('src/components/MessageResponse.js').MessageResponse
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BLACK_CIRCLE = any
|
||||
/** 列表/状态行中使用的实心圆点字符(平台相关);与宿主 `src/constants/figures.js` 中 `BLACK_CIRCLE` 常量类型一致。 */
|
||||
export type BLACK_CIRCLE =
|
||||
typeof import('src/constants/figures.js').BLACK_CIRCLE
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getModeColor = any
|
||||
/** 将权限模式映射为 Ink 主题颜色键,用于状态行等 UI;与宿主 `getModeColor` 一致。 */
|
||||
export type getModeColor =
|
||||
typeof import('src/utils/permissions/PermissionMode.js').getModeColor
|
||||
|
||||
@@ -314,15 +314,13 @@ export function getSimplePrompt(): string {
|
||||
'Use the Monitor tool to stream events from a background process (each stdout line is a notification). For one-shot "wait until done," use Bash with run_in_background instead.',
|
||||
]
|
||||
: []),
|
||||
'If your command is long running and you would like to be notified when it finishes — use `run_in_background`. No sleep needed.',
|
||||
'For long-running commands, use `run_in_background` — you will be notified when it completes. Do not poll.',
|
||||
'Do not retry failing commands in a sleep loop — diagnose the root cause.',
|
||||
'If waiting for a background task you started with `run_in_background`, you will be notified when it completes — do not poll.',
|
||||
...(feature('MONITOR_TOOL')
|
||||
? [
|
||||
'`sleep N` as the first command with N ≥ 2 is blocked. If you need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.',
|
||||
]
|
||||
: [
|
||||
'If you must poll an external process, use a check command (e.g. `gh run view`) rather than sleeping first.',
|
||||
'If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.',
|
||||
]),
|
||||
]
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ToolPermissionContext = any
|
||||
/** 工具权限检查用的不可变上下文快照;与宿主 `src/Tool.js` 中 `ToolPermissionContext` 一致。 */
|
||||
export type ToolPermissionContext = import('src/Tool.js').ToolPermissionContext
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getOriginalCwd = any
|
||||
/** 返回进程启动时的原始工作目录(不受中途切换工作区影响);与宿主 `getOriginalCwd` 一致。 */
|
||||
export type getOriginalCwd =
|
||||
typeof import('src/bootstrap/state.js').getOriginalCwd
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CanUseToolFn = any
|
||||
/** 工具调用权限判定回调(交互/自动模式分支);与宿主 `src/hooks/useCanUseTool.tsx` 中 `CanUseToolFn` 一致。 */
|
||||
export type CanUseToolFn = import('src/hooks/useCanUseTool.js').CanUseToolFn
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any
|
||||
/** 从磁盘缓存读取 GrowthBook/门控配置(可能略旧);与宿主 `getFeatureValue_CACHED_MAY_BE_STALE` 一致。 */
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE =
|
||||
typeof import('src/services/analytics/growthbook.js').getFeatureValue_CACHED_MAY_BE_STALE
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logEvent = any
|
||||
/** 同步记录分析事件(未附加 sink 时入队);与宿主 `src/services/analytics/index.js` 中 `logEvent` 一致。 */
|
||||
export type logEvent = typeof import('src/services/analytics/index.js').logEvent
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type AppState = any
|
||||
/** REPL 全局 UI 与权限等状态快照类型;与宿主 `src/state/AppStateStore.js` 中 `AppState` 一致。 */
|
||||
export type AppState = import('src/state/AppStateStore.js').AppState
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setCwd = any
|
||||
/** 将 Shell 会话当前目录设为解析后的物理路径;与宿主 `src/utils/Shell.js` 中 `setCwd` 一致。 */
|
||||
export type setCwd = typeof import('src/utils/Shell.js').setCwd
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getCwd = any
|
||||
/** 返回当前 Shell/会话逻辑工作目录字符串;与宿主 `src/utils/cwd.js` 中 `getCwd` 一致。 */
|
||||
export type getCwd = typeof import('src/utils/cwd.js').getCwd
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type pathInAllowedWorkingPath = any
|
||||
/** 判断路径是否落在当前工具允许的合并工作目录内;与宿主 `pathInAllowedWorkingPath` 一致。 */
|
||||
export type pathInAllowedWorkingPath =
|
||||
typeof import('src/utils/permissions/filesystem.js').pathInAllowedWorkingPath
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type removeSandboxViolationTags = any
|
||||
/** 从展示文本中剥离沙箱违规相关的标记标签,避免 UI 噪音;与宿主 `removeSandboxViolationTags` 一致。 */
|
||||
export type removeSandboxViolationTags =
|
||||
typeof import('src/utils/sandbox/sandbox-ui-utils.js').removeSandboxViolationTags
|
||||
|
||||
@@ -26,33 +26,13 @@ function getEnterPlanModeToolPromptExternal(): string {
|
||||
|
||||
**Prefer using EnterPlanMode** for implementation tasks unless they're simple. Use it when ANY of these conditions apply:
|
||||
|
||||
1. **New Feature Implementation**: Adding meaningful new functionality
|
||||
- Example: "Add a logout button" - where should it go? What should happen on click?
|
||||
- Example: "Add form validation" - what rules? What error messages?
|
||||
|
||||
2. **Multiple Valid Approaches**: The task can be solved in several different ways
|
||||
- Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc.
|
||||
- Example: "Improve performance" - many optimization strategies possible
|
||||
|
||||
3. **Code Modifications**: Changes that affect existing behavior or structure
|
||||
- Example: "Update the login flow" - what exactly should change?
|
||||
- Example: "Refactor this component" - what's the target architecture?
|
||||
|
||||
4. **Architectural Decisions**: The task requires choosing between patterns or technologies
|
||||
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
|
||||
- Example: "Implement state management" - Redux vs Context vs custom solution
|
||||
|
||||
5. **Multi-File Changes**: The task will likely touch more than 2-3 files
|
||||
- Example: "Refactor the authentication system"
|
||||
- Example: "Add a new API endpoint with tests"
|
||||
|
||||
6. **Unclear Requirements**: You need to explore before understanding the full scope
|
||||
- Example: "Make the app faster" - need to profile and identify bottlenecks
|
||||
- Example: "Fix the bug in checkout" - need to investigate root cause
|
||||
|
||||
7. **User Preferences Matter**: The implementation could reasonably go multiple ways
|
||||
- If you would use ${ASK_USER_QUESTION_TOOL_NAME} to clarify the approach, use EnterPlanMode instead
|
||||
- Plan mode lets you explore first, then present options with context
|
||||
1. **New Feature Implementation** — Adding meaningful new functionality where the implementation path isn't obvious
|
||||
2. **Multiple Valid Approaches** — The task can be solved in several different ways
|
||||
3. **Code Modifications** — Changes that affect existing behavior or structure, where the user should approve the approach
|
||||
4. **Architectural Decisions** — The task requires choosing between patterns or technologies
|
||||
5. **Multi-File Changes** — The task will likely touch more than 2-3 files
|
||||
6. **Unclear Requirements** — You need to explore before understanding the full scope
|
||||
7. **User Preferences Matter** — If you would use ${ASK_USER_QUESTION_TOOL_NAME} to clarify the approach, use EnterPlanMode instead
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
@@ -62,35 +42,7 @@ Only skip EnterPlanMode for simple tasks:
|
||||
- Tasks where the user has given very specific, detailed instructions
|
||||
- Pure research/exploration tasks (use the Agent tool with explore agent instead)
|
||||
|
||||
${whatHappens}## Examples
|
||||
|
||||
### GOOD - Use EnterPlanMode:
|
||||
User: "Add user authentication to the app"
|
||||
- Requires architectural decisions (session vs JWT, where to store tokens, middleware structure)
|
||||
|
||||
User: "Optimize the database queries"
|
||||
- Multiple approaches possible, need to profile first, significant impact
|
||||
|
||||
User: "Implement dark mode"
|
||||
- Architectural decision on theme system, affects many components
|
||||
|
||||
User: "Add a delete button to the user profile"
|
||||
- Seems simple but involves: where to place it, confirmation dialog, API call, error handling, state updates
|
||||
|
||||
User: "Update the error handling in the API"
|
||||
- Affects multiple files, user should approve the approach
|
||||
|
||||
### BAD - Don't use EnterPlanMode:
|
||||
User: "Fix the typo in the README"
|
||||
- Straightforward, no planning needed
|
||||
|
||||
User: "Add a console.log to debug this function"
|
||||
- Simple, obvious implementation
|
||||
|
||||
User: "What files handle routing?"
|
||||
- Research task, not implementation planning
|
||||
|
||||
## Important Notes
|
||||
${whatHappens}## Important Notes
|
||||
|
||||
- This tool REQUIRES user approval - they must consent to entering plan mode
|
||||
- If unsure whether to use it, err on the side of planning - it's better to get alignment upfront than to redo work
|
||||
@@ -111,53 +63,23 @@ function getEnterPlanModeToolPromptAnt(): string {
|
||||
|
||||
Plan mode is valuable when the implementation approach is genuinely unclear. Use it when:
|
||||
|
||||
1. **Significant Architectural Ambiguity**: Multiple reasonable approaches exist and the choice meaningfully affects the codebase
|
||||
- Example: "Add caching to the API" - Redis vs in-memory vs file-based
|
||||
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
|
||||
|
||||
2. **Unclear Requirements**: You need to explore and clarify before you can make progress
|
||||
- Example: "Make the app faster" - need to profile and identify bottlenecks
|
||||
- Example: "Refactor this module" - need to understand what the target architecture should be
|
||||
|
||||
3. **High-Impact Restructuring**: The task will significantly restructure existing code and getting buy-in first reduces risk
|
||||
- Example: "Redesign the authentication system"
|
||||
- Example: "Migrate from one state management approach to another"
|
||||
1. **Significant Architectural Ambiguity** — Multiple reasonable approaches exist and the choice meaningfully affects the codebase
|
||||
2. **Unclear Requirements** — You need to explore and clarify before you can make progress
|
||||
3. **High-Impact Restructuring** — The task will significantly restructure existing code and getting buy-in first reduces risk
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
Skip plan mode when you can reasonably infer the right approach:
|
||||
- The task is straightforward even if it touches multiple files
|
||||
- The user's request is specific enough that the implementation path is clear
|
||||
- You're adding a feature with an obvious implementation pattern (e.g., adding a button, a new endpoint following existing conventions)
|
||||
- You're adding a feature with an obvious implementation pattern
|
||||
- Bug fixes where the fix is clear once you understand the bug
|
||||
- Research/exploration tasks (use the Agent tool instead)
|
||||
- The user says something like "can we work on X" or "let's do X" — just get started
|
||||
|
||||
When in doubt, prefer starting work and using ${ASK_USER_QUESTION_TOOL_NAME} for specific questions over entering a full planning phase.
|
||||
|
||||
${whatHappens}## Examples
|
||||
|
||||
### GOOD - Use EnterPlanMode:
|
||||
User: "Add user authentication to the app"
|
||||
- Genuinely ambiguous: session vs JWT, where to store tokens, middleware structure
|
||||
|
||||
User: "Redesign the data pipeline"
|
||||
- Major restructuring where the wrong approach wastes significant effort
|
||||
|
||||
### BAD - Don't use EnterPlanMode:
|
||||
User: "Add a delete button to the user profile"
|
||||
- Implementation path is clear; just do it
|
||||
|
||||
User: "Can we work on the search feature?"
|
||||
- User wants to get started, not plan
|
||||
|
||||
User: "Update the error handling in the API"
|
||||
- Start working; ask specific questions if needed
|
||||
|
||||
User: "Fix the typo in the README"
|
||||
- Straightforward, no planning needed
|
||||
|
||||
## Important Notes
|
||||
${whatHappens}## Important Notes
|
||||
|
||||
- This tool REQUIRES user approval - they must consent to entering plan mode
|
||||
`
|
||||
|
||||
@@ -10,8 +10,14 @@ import {
|
||||
} from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import {
|
||||
extractDiscoveredToolNames,
|
||||
isSearchExtraToolsEnabledOptimistic,
|
||||
isSearchExtraToolsToolAvailable,
|
||||
} from 'src/utils/searchExtraTools.js'
|
||||
import { DESCRIPTION, getPrompt } from './prompt.js'
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
import { isDeferredTool } from '../SearchExtraToolsTool/prompt.js'
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
@@ -74,6 +80,32 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: block execution of undiscovered deferred tools.
|
||||
// When tool search is active, deferred tools must be discovered via
|
||||
// SearchExtraTools first so the model has seen their schemas and knows
|
||||
// the correct parameters. Executing an undiscovered tool almost always
|
||||
// fails with parameter validation errors.
|
||||
if (
|
||||
isSearchExtraToolsEnabledOptimistic() &&
|
||||
isSearchExtraToolsToolAvailable(tools) &&
|
||||
isDeferredTool(targetTool)
|
||||
) {
|
||||
const discovered = extractDiscoveredToolNames(context.messages)
|
||||
if (!discovered.has(input.tool_name)) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Tool "${input.tool_name}" has not been discovered yet. You must first use SearchExtraTools to discover this tool before executing it.\n\nUsage: SearchExtraTools("select:${input.tool_name}")`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the target tool is currently enabled
|
||||
if (!targetTool.isEnabled()) {
|
||||
return {
|
||||
@@ -89,6 +121,29 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// Validate input before delegating — prevents crashes when the model
|
||||
// omits required params (e.g. TeamCreate without team_name →
|
||||
// sanitizeName(undefined).replace() TypeError).
|
||||
if (targetTool.validateInput) {
|
||||
const validation = await targetTool.validateInput(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
)
|
||||
if (!validation.result) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Invalid parameters for tool "${input.tool_name}": ${validation.message}`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions on the target tool
|
||||
const permResult = await targetTool.checkPermissions?.(
|
||||
input.params as Record<string, unknown>,
|
||||
@@ -132,7 +187,7 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
},
|
||||
renderToolUseMessage(input) {
|
||||
return `Executing ${input.tool_name}...`
|
||||
return `${input.tool_name}`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'ExecuteExtraTool'
|
||||
|
||||
@@ -33,10 +33,10 @@ mock.module('src/utils/searchExtraTools.js', () => ({
|
||||
isSearchExtraToolsEnabledOptimistic: () => true,
|
||||
getAutoSearchExtraToolsCharThreshold: () => 100,
|
||||
getSearchExtraToolsMode: () => 'tst' as const,
|
||||
isSearchExtraToolsToolAvailable: async () => true,
|
||||
isSearchExtraToolsToolAvailable: () => true,
|
||||
isSearchExtraToolsEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
extractDiscoveredToolNames: () => new Set(['TestTool', 'SecretTool']),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
@@ -154,6 +154,26 @@ describe('ExecuteTool', () => {
|
||||
expect(result.newMessages).toBeDefined()
|
||||
})
|
||||
|
||||
test('returns error when deferred tool has not been discovered via SearchExtraTools', async () => {
|
||||
const mockTarget = makeMockTool('UndiscoveredTool', 'result')
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'UndiscoveredTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'UndiscoveredTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
expect(result.newMessages![0].content).toContain('has not been discovered')
|
||||
})
|
||||
|
||||
test('has correct name', () => {
|
||||
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* ExecuteTool.test.ts
|
||||
*
|
||||
* Thin subprocess wrapper that runs the actual tests in an isolated bun:test
|
||||
* process. This prevents mock.module() leaks from other test files
|
||||
* (e.g., agentToolUtils.test.ts mocking src/Tool.js) from affecting
|
||||
* ExecuteTool's tests.
|
||||
* 薄层子进程包装器,在独立的 bun:test 进程中运行实际测试。
|
||||
* 这样可以防止其他测试文件的 mock.module() 漏出(例如 agentToolUtils.test.ts
|
||||
* 对 src/Tool.js 的 mock)影响 ExecuteTool 的测试。
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { resolve, relative } from 'path'
|
||||
|
||||
|
||||
@@ -4,16 +4,34 @@ export const DESCRIPTION =
|
||||
'ExecuteExtraTool — a first-class core tool that is always loaded and available. Execute any deferred tool by name with parameters. Use it after discovering a tool via SearchExtraTools. This is NOT a remote or external tool — it runs locally with full permissions.'
|
||||
|
||||
export function getPrompt(): string {
|
||||
return `ExecuteExtraTool — a first-class core tool, always loaded, always available in your tool list. Runs locally with full permissions — NOT a remote or external tool. You do NOT need to search for it.
|
||||
return `ExecuteExtraTool — always loaded, always available. Runs locally with full permissions — NOT a remote or external tool.
|
||||
|
||||
This tool accepts a tool_name and params object, looks up the target tool in the global tool registry, and delegates execution to it. The target tool runs with the same permissions and capabilities as if it were called directly.
|
||||
## What it does
|
||||
Accepts a tool_name and params, looks up the target tool in the registry, and delegates execution to it. The target tool runs with the same permissions as if called directly.
|
||||
|
||||
When to use: After SearchExtraTools discovers a deferred tool name, call this tool with {"tool_name": "<name>", "params": {...}} to invoke it immediately.
|
||||
When NOT to use: For core tools already in your tool list (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill, etc.) — call those directly.
|
||||
## When to use
|
||||
ONLY for deferred tools discovered via SearchExtraTools. Core tools (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill) are always in your tool list — call them directly, NOT through ExecuteExtraTool.
|
||||
|
||||
Inputs:
|
||||
- tool_name: The exact name of the target tool (string)
|
||||
- params: The parameters to pass to the target tool (object)
|
||||
## How to call — two-step workflow
|
||||
|
||||
If the tool is not found, an error message will be returned suggesting to use SearchExtraTools to discover available tools.`
|
||||
Step 1: SearchExtraTools discovers the tool name and schema.
|
||||
Step 2: This tool executes it.
|
||||
|
||||
Example — user asks to schedule a cron job:
|
||||
SearchExtraTools({"query": "select:CronCreate"})
|
||||
→ Response: "Found deferred tool(s): CronCreate"
|
||||
ExecuteExtraTool({"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check deploy"}})
|
||||
→ Response: Cron job created
|
||||
|
||||
Example — MCP tool:
|
||||
SearchExtraTools({"query": "select:mcp__slack__send_message"})
|
||||
→ Response: "Found deferred tool(s): mcp__slack__send_message"
|
||||
ExecuteExtraTool({"tool_name": "mcp__slack__send_message", "params": {"channel": "C123", "text": "hello"}})
|
||||
|
||||
## Inputs
|
||||
- tool_name: Exact name of the target tool (string, e.g. "CronCreate", "mcp__slack__send_message")
|
||||
- params: Object with the target tool's parameters. Check the tool's schema from SearchExtraTools discover: response.
|
||||
|
||||
## Failure handling
|
||||
If this tool returns an error, do NOT retry or re-search. Tell the user what failed and suggest alternatives.`
|
||||
}
|
||||
|
||||
@@ -20,10 +20,4 @@ Ensure your plan is complete and unambiguous:
|
||||
- Once your plan is finalized, use THIS tool to request approval
|
||||
|
||||
**Important:** Do NOT use ${ASK_USER_QUESTION_TOOL_NAME} to ask "Is this plan okay?" or "Should I proceed?" - that's exactly what THIS tool does. ExitPlanMode inherently requests user approval of your plan.
|
||||
|
||||
## Examples
|
||||
|
||||
1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task.
|
||||
2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task.
|
||||
3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use ${ASK_USER_QUESTION_TOOL_NAME} first, then use exit plan mode tool after clarifying the approach.
|
||||
`
|
||||
|
||||
@@ -383,8 +383,8 @@ export const NotebookEditTool = buildTool({
|
||||
const language = notebook.metadata.language_info?.name ?? 'python'
|
||||
let new_cell_id
|
||||
if (
|
||||
notebook.nbformat > 4 ||
|
||||
(notebook.nbformat === 4 && notebook.nbformat_minor >= 5)
|
||||
(notebook.nbformat ?? 4) > 4 ||
|
||||
((notebook.nbformat ?? 4) === 4 && (notebook.nbformat_minor ?? 0) >= 5)
|
||||
) {
|
||||
if (edit_mode === 'insert') {
|
||||
new_cell_id = Math.random().toString(36).substring(2, 15)
|
||||
|
||||
@@ -25,13 +25,39 @@ function getToolLocationHint(): string {
|
||||
|
||||
const PROMPT_TAIL = ` Returns matching tool names.
|
||||
|
||||
IMPORTANT: ExecuteExtraTool is always available in your tool list. After this search returns tool names, you MUST call ExecuteExtraTool with {"tool_name": "<returned_name>", "params": {...}} to invoke the deferred tool. This is the ONLY way to execute deferred tools — do not read source code or analyze whether the tool is callable, just use ExecuteExtraTool directly.
|
||||
## Two-step workflow (MUST follow exactly)
|
||||
|
||||
Query forms:
|
||||
- "select:CronCreate,Snip" — fetch these exact tools by name
|
||||
- "discover:schedule cron job" — pure discovery, returns tool info (name, description) without loading. Use when you want to understand available tools before deciding which to invoke.
|
||||
Deferred tools CANNOT be called directly. You MUST use this two-step pattern:
|
||||
|
||||
Step 1 — Search: Call this tool (SearchExtraTools) to discover the target tool.
|
||||
Input: {"query": "select:CronCreate"}
|
||||
Response: "Found 1 deferred tool(s): CronCreate. Use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}} to invoke."
|
||||
|
||||
Step 2 — Execute: Call ExecuteExtraTool to run the discovered tool.
|
||||
Input: {"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check the deploy"}}
|
||||
Response: the actual tool result.
|
||||
|
||||
## Example: user asks "schedule a cron to check deploy every 5 minutes"
|
||||
|
||||
1. SearchExtraTools({"query": "select:CronCreate"})
|
||||
→ Response: Found deferred tool CronCreate
|
||||
2. ExecuteExtraTool({"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check the deploy"}})
|
||||
→ Response: Cron job created successfully
|
||||
|
||||
If you don't know the exact tool name, use keyword search first:
|
||||
1. SearchExtraTools({"query": "cron schedule"})
|
||||
→ Response: Found deferred tool(s): CronCreate
|
||||
2. ExecuteExtraTool({"tool_name": "CronCreate", "params": {...}})
|
||||
|
||||
## Query forms
|
||||
- "select:CronCreate" — exact tool name (fastest, preferred when you know the name from <available-deferred-tools>)
|
||||
- "select:CronCreate,CronList" — comma-separated multi-select
|
||||
- "discover:schedule cron job" — returns tool name + description + schema without loading. Use to understand a tool before calling it.
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms
|
||||
|
||||
## Failure policy
|
||||
If ExecuteExtraTool fails, do NOT re-search for the same tool — it will loop. Stop and tell the user what failed.`
|
||||
|
||||
/**
|
||||
* Check if a tool should be deferred (requires SearchExtraTools to load).
|
||||
|
||||
@@ -59,7 +59,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
||||
'ACP', // ACP 代理协议,支持外部 agent 接入
|
||||
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
// 'HISTORY_SNIP', // 已禁用:snip 功能暂时关闭
|
||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
// 'FORK_SUBAGENT', // 已禁用:通过 Agent tool 的特殊方式实现了等效功能,无需再开
|
||||
|
||||
@@ -9,28 +9,52 @@
|
||||
import { readdir, readFile, writeFile, cp } from 'node:fs/promises'
|
||||
import { chmodSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { execSync } from 'node:child_process'
|
||||
|
||||
const outdir = 'dist'
|
||||
|
||||
async function postBuild() {
|
||||
// Step 1: Patch globalThis.Bun destructuring in the single bundled file
|
||||
const cliPath = join(outdir, 'cli.js')
|
||||
// Step 1: Patch globalThis.Bun destructuring in ALL output files
|
||||
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
||||
const BUN_DESTRUCTURE_SAFE =
|
||||
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
|
||||
let bunPatched = 0
|
||||
{
|
||||
const content = await readFile(cliPath, 'utf-8')
|
||||
const files = await readdir(outdir)
|
||||
const jsFiles = files.filter(f => f.endsWith('.js'))
|
||||
|
||||
for (const file of jsFiles) {
|
||||
const filePath = join(outdir, file)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
BUN_DESTRUCTURE.lastIndex = 0
|
||||
if (BUN_DESTRUCTURE.test(content)) {
|
||||
await writeFile(
|
||||
cliPath,
|
||||
filePath,
|
||||
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||
)
|
||||
bunPatched++
|
||||
}
|
||||
}
|
||||
|
||||
// Also patch chunk files in dist/chunks/
|
||||
const chunksDir = join(outdir, 'chunks')
|
||||
let chunkFiles: string[] = []
|
||||
try {
|
||||
chunkFiles = (await readdir(chunksDir)).filter(f => f.endsWith('.js'))
|
||||
} catch {
|
||||
// No chunks directory — single-file build fallback
|
||||
}
|
||||
|
||||
for (const file of chunkFiles) {
|
||||
const filePath = join(chunksDir, file)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
BUN_DESTRUCTURE.lastIndex = 0
|
||||
if (BUN_DESTRUCTURE.test(content)) {
|
||||
await writeFile(
|
||||
filePath,
|
||||
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||
)
|
||||
bunPatched++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Copy native addon files
|
||||
@@ -55,7 +79,7 @@ async function postBuild() {
|
||||
chmodSync(cliNode, 0o755)
|
||||
|
||||
console.log(
|
||||
`Post-build complete: patched ${bunPatched} Bun destructure, generated entry points`,
|
||||
`Post-build complete: patched ${bunPatched} Bun destructure across ${jsFiles.length + chunkFiles.length} files, generated entry points`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -377,9 +377,6 @@ const cronJitterConfigModule =
|
||||
require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
|
||||
const cronGate =
|
||||
require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
|
||||
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
|
||||
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
|
||||
: null
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
const SHUTDOWN_TEAM_PROMPT = `<system-reminder>
|
||||
@@ -985,7 +982,14 @@ export async function runHeadless(
|
||||
// the forked agent mid-flight. Gated by isExtractModeActive so the
|
||||
// tengu_slate_thimble flag controls non-interactive extraction end-to-end.
|
||||
if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) {
|
||||
await extractMemoriesModule!.drainPendingExtraction()
|
||||
try {
|
||||
const { drainPendingExtraction } = await import(
|
||||
'../services/extractMemories/extractMemories.js'
|
||||
)
|
||||
await drainPendingExtraction()
|
||||
} catch {
|
||||
// Module load failure — non-critical at shutdown
|
||||
}
|
||||
}
|
||||
|
||||
gracefulShutdownSync(
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Transport = any
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
|
||||
/** WebSocket / SSE+POST / Hybrid 等会话上行传输的共有接口。 */
|
||||
export type Transport = {
|
||||
setOnData(callback: (data: string) => void): void // 注册下行数据回调(按行文本)
|
||||
setOnClose(callback: (closeCode?: number) => void): void // 连接关闭时回调(可选关闭码)
|
||||
connect(): void | Promise<void> // 建立或重连传输
|
||||
write(message: StdoutMessage): void | Promise<void> // 向上游发送一条控制/流式消息
|
||||
close(): void // 主动关闭并释放资源
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import chalk from 'chalk'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { distRoot } from '../utils/distRoot.js'
|
||||
import { execFileNoThrowWithCwd } from '../utils/execFileNoThrow.js'
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||
import { writeToStdout } from '../utils/process.js'
|
||||
@@ -19,12 +19,9 @@ import { writeToStdout } from '../utils/process.js'
|
||||
const PACKAGE_NAME = 'claude-code-best'
|
||||
|
||||
function getCurrentVersion(): string {
|
||||
// Read version from the nearest package.json (walks up from this file)
|
||||
// Read version from the nearest package.json (walks up from dist root)
|
||||
try {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
// In dev: src/cli/updateCCB.ts → ../../package.json
|
||||
// In build: dist/chunks/xxx.js → ../../package.json (may not exist)
|
||||
const pkgPath = join(__dirname, '..', '..', 'package.json')
|
||||
const pkgPath = join(distRoot, '..', 'package.json')
|
||||
if (existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
if (pkg.version) return pkg.version
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Tests for AgentsPlatformView.tsx
|
||||
* Covers all 5 modes: list (empty), list (with agents), created, deleted, ran, error
|
||||
*/
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
|
||||
// Mock cron utility before importing AgentsPlatformView
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
cronToHuman: (expr: string) => `HumanCron(${expr})`,
|
||||
parseCronExpression: () => null,
|
||||
computeNextCronRun: () => null,
|
||||
}));
|
||||
|
||||
const { AgentsPlatformView } = await import('../AgentsPlatformView.js');
|
||||
|
||||
const sampleAgent = {
|
||||
id: 'agt_abc123',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup report',
|
||||
status: 'active' as const,
|
||||
timezone: 'UTC',
|
||||
next_run: '2026-05-05T09:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('AgentsPlatformView list mode', () => {
|
||||
test('empty list shows placeholder message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[]} />);
|
||||
expect(out).toContain('No scheduled agents');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent count', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Scheduled Agents (1)');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent status', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('active');
|
||||
});
|
||||
|
||||
test('non-empty list shows human-readable schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('list shows agent prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
|
||||
test('list shows next run date', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
// next_run is formatted via toLocaleString — just check it's rendered
|
||||
expect(out).toContain('Next run');
|
||||
});
|
||||
|
||||
test('list with null next_run shows em dash', async () => {
|
||||
const agentNoNextRun = { ...sampleAgent, next_run: null };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[agentNoNextRun]} />);
|
||||
expect(out).toContain('—');
|
||||
});
|
||||
|
||||
test('multiple agents rendered', async () => {
|
||||
const agent2 = { ...sampleAgent, id: 'agt_xyz', cron_expr: '0 10 * * 2' };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent, agent2]} />);
|
||||
expect(out).toContain('Scheduled Agents (2)');
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('agt_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView created mode', () => {
|
||||
test('shows Agent created', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Agent created');
|
||||
});
|
||||
|
||||
test('shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('shows schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('shows prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView deleted mode', () => {
|
||||
test('shows deleted confirmation with id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="deleted" id="agt_abc123" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView ran mode', () => {
|
||||
test('shows triggered with agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('triggered');
|
||||
});
|
||||
|
||||
test('shows run id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('run_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView error mode', () => {
|
||||
test('shows error message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="error" message="Network failure" />);
|
||||
expect(out).toContain('Network failure');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,24 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchAgentsPlatform.tsx
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock agentsApi.ts itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Let real agentsApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
@@ -9,42 +27,40 @@ mock.module('bun:bundle', () => ({
|
||||
}))
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const realAnalytics = await import('src/services/analytics/index.js')
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
...realAnalytics,
|
||||
logEvent: logEventMock,
|
||||
logEventAsync: mock(() => Promise.resolve()),
|
||||
_resetForTesting: mock(() => {}),
|
||||
attachAnalyticsSink: mock(() => {}),
|
||||
stripProtoFields: mock((v: unknown) => v),
|
||||
}))
|
||||
|
||||
// ── agentsApi mock ──────────────────────────────────────────────────────────
|
||||
const listMock = mock(async () => [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
])
|
||||
const createMock = mock(async (cron: string, prompt: string) => ({
|
||||
id: 'agt_new',
|
||||
cron_expr: cron,
|
||||
prompt,
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const realAuth = await import('src/utils/auth.js')
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
...realAuth,
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ap' }),
|
||||
}))
|
||||
const deleteMock = mock(async () => undefined)
|
||||
const runMock = mock(async () => ({ run_id: 'run_123' }))
|
||||
|
||||
mock.module('src/commands/agents-platform/agentsApi.js', () => ({
|
||||
listAgents: listMock,
|
||||
createAgent: createMock,
|
||||
deleteAgent: deleteMock,
|
||||
runAgent: runMock,
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-ap',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
const realTeleportApi = await import('src/utils/teleport/api.js')
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
...realTeleportApi,
|
||||
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key-ap',
|
||||
}),
|
||||
prepareApiRequest: async () => ({
|
||||
apiKey: 'test-api-key-ap',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── cron mock ───────────────────────────────────────────────────────────────
|
||||
@@ -57,19 +73,42 @@ mock.module('src/utils/cron.js', () => ({
|
||||
computeNextCronRun: () => null,
|
||||
}))
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
let callAgentsPlatform: typeof import('../launchAgentsPlatform.js').callAgentsPlatform
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchAgentsPlatform.js')
|
||||
callAgentsPlatform = mod.callAgentsPlatform
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listMock.mockClear()
|
||||
createMock.mockClear()
|
||||
deleteMock.mockClear()
|
||||
runMock.mockClear()
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
})
|
||||
|
||||
function makeContext() {
|
||||
@@ -79,8 +118,23 @@ function makeContext() {
|
||||
describe('callAgentsPlatform', () => {
|
||||
test('list (empty args) calls listAgents and returns element', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
@@ -91,21 +145,43 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('list sub-command calls listAgents', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: [] },
|
||||
status: 200,
|
||||
})
|
||||
await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('create with valid cron calls createAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'agt_new',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
status: 201,
|
||||
})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'create 0 9 * * 1 Run standup',
|
||||
)
|
||||
expect(createMock).toHaveBeenCalledTimes(1)
|
||||
const [cron, prompt] = createMock.mock.calls[0] as [string, string]
|
||||
expect(cron).toBe('0 9 * * 1')
|
||||
expect(prompt).toBe('Run standup')
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
]
|
||||
const url = callArgs[0]
|
||||
const body = callArgs[1] as Record<string, unknown>
|
||||
expect(url).toContain('/v1/agents')
|
||||
expect(body.cron_expr).toBe('0 9 * * 1')
|
||||
expect(body.prompt).toBe('Run standup')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_create',
|
||||
@@ -122,7 +198,7 @@ describe('callAgentsPlatform', () => {
|
||||
'create INVALID INVALID * * * my prompt',
|
||||
)
|
||||
// cron = 'INVALID INVALID * * *', mock returns null → no API call
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
@@ -131,12 +207,18 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('delete with id calls deleteAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'delete agt_abc',
|
||||
)
|
||||
expect(deleteMock).toHaveBeenCalledWith('agt_abc')
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosDeleteMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
]
|
||||
expect(callArgs[0]).toContain('agt_abc')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_delete',
|
||||
@@ -146,12 +228,23 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('run with id calls runAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_123' },
|
||||
status: 200,
|
||||
})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'run agt_xyz',
|
||||
)
|
||||
expect(runMock).toHaveBeenCalledWith('agt_xyz')
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
]
|
||||
expect(callArgs[0]).toContain('agt_xyz')
|
||||
expect(callArgs[0]).toContain('/run')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_run',
|
||||
@@ -167,11 +260,11 @@ describe('callAgentsPlatform', () => {
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
expect(axiosGetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('listAgents API error → error view returned', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('network error'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('network error'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(result).not.toBeNull()
|
||||
@@ -183,6 +276,10 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('started event fires on every call', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: [] },
|
||||
status: 200,
|
||||
})
|
||||
await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_started',
|
||||
@@ -190,10 +287,10 @@ describe('callAgentsPlatform', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ── Error-path branches (lines 77-86, 100-109, 128-136) ──────────────────
|
||||
// ── Error-path branches ──────────────────────────────────────────────────
|
||||
|
||||
test('createAgent API error → error view returned', async () => {
|
||||
createMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -212,7 +309,7 @@ describe('callAgentsPlatform', () => {
|
||||
})
|
||||
|
||||
test('deleteAgent API error → error view returned', async () => {
|
||||
deleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -231,7 +328,7 @@ describe('callAgentsPlatform', () => {
|
||||
})
|
||||
|
||||
test('runAgent API error → error view returned', async () => {
|
||||
runMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -253,7 +350,7 @@ describe('callAgentsPlatform', () => {
|
||||
const onDone = mock(() => {})
|
||||
// Only 4 cron fields — parseArgs returns invalid
|
||||
await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *')
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
import { AutofixProgress } from '../AutofixProgress.js';
|
||||
|
||||
describe('AutofixProgress', () => {
|
||||
describe.skipIf(!!process.env.CI)('AutofixProgress', () => {
|
||||
test('renders target in header', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
|
||||
expect(out).toContain('acme/myrepo#42');
|
||||
|
||||
@@ -1,571 +0,0 @@
|
||||
/**
|
||||
* Coverage tests for issue/index.ts gh-CLI paths.
|
||||
*
|
||||
* issue/index.ts uses `import * as childProcess from 'node:child_process'`
|
||||
* with lazy promisify, so mock.module('node:child_process') is effective.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { promisify } from 'node:util'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// ── Mock control state ──
|
||||
let _execFileSyncImpl: (cmd: string, args: string[], opts?: unknown) => Buffer =
|
||||
() => Buffer.from('')
|
||||
|
||||
let _execFileImpl: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => void = (_cmd, _args, _opts, cb) => cb(null, '', '')
|
||||
|
||||
const execFileSyncMockCore = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
): Buffer => _execFileSyncImpl(cmd, args, opts)
|
||||
|
||||
const execFileMockCore = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => _execFileImpl(cmd, args, opts, cb)
|
||||
|
||||
;(execFileMockCore as unknown as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> =>
|
||||
new Promise((resolve, reject) =>
|
||||
_execFileImpl(cmd, args, opts, (err, stdout, stderr) => {
|
||||
if (err) reject(err)
|
||||
else resolve({ stdout, stderr })
|
||||
}),
|
||||
)
|
||||
|
||||
// Spread real child_process + flag-gated stub (see share-gh.test.ts for the
|
||||
// promisify.custom rationale).
|
||||
let useIssueGhCpStubs = false
|
||||
const wrappedIssueGhExecFile = ((...args: unknown[]) =>
|
||||
useIssueGhCpStubs
|
||||
? (execFileMockCore as (...a: unknown[]) => unknown)(...args)
|
||||
: // eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
(require('node:child_process').execFile as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as unknown as Record<symbol, unknown> & ((...a: unknown[]) => unknown)
|
||||
;(wrappedIssueGhExecFile as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (useIssueGhCpStubs) {
|
||||
return new Promise((resolve, reject) =>
|
||||
_execFileImpl(cmd, args, opts, (err, stdout, stderr) =>
|
||||
err ? reject(err) : resolve({ stdout, stderr }),
|
||||
),
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return promisify(real.execFile as never)(cmd, args, opts) as Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
mock.module('node:child_process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
execFile: wrappedIssueGhExecFile as typeof real.execFile,
|
||||
execFileSync: ((...args: unknown[]) =>
|
||||
useIssueGhCpStubs
|
||||
? (execFileSyncMockCore as (...a: unknown[]) => unknown)(...args)
|
||||
: (real.execFileSync as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as typeof real.execFileSync,
|
||||
}
|
||||
})
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
}))
|
||||
|
||||
// ── State ──
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'issue-gh-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
// Default: git remote fails (no GitHub remote), gh not available
|
||||
_execFileSyncImpl = (_cmd, _args, _opts) => {
|
||||
throw new Error('ENOENT: command not found')
|
||||
}
|
||||
_execFileImpl = (_cmd, _args, _opts, cb) =>
|
||||
cb(new Error('ENOENT: command not found'), '', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
// ── Helpers ──
|
||||
type CallFn = (args: string) => Promise<{ type: string; value: string }>
|
||||
|
||||
async function getCallFn(): Promise<CallFn> {
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as { load: () => Promise<{ call: CallFn }> }
|
||||
).load()
|
||||
return loaded.call.bind(loaded) as CallFn
|
||||
}
|
||||
|
||||
async function writeSessionLog(entries?: string[]): Promise<void> {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const content = entries ?? [
|
||||
JSON.stringify({ role: 'user', content: 'Fix the login bug' }),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'I will investigate' }],
|
||||
}),
|
||||
]
|
||||
writeFileSync(join(dir, `${sessionId}.jsonl`), content.join('\n') + '\n')
|
||||
}
|
||||
|
||||
// Create a .github/ISSUE_TEMPLATE dir in tmpDir
|
||||
function createIssueTemplate(
|
||||
content = '## Bug Report\n\nDescribe the bug.',
|
||||
): string {
|
||||
const templateDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
writeFileSync(join(templateDir, 'bug_report.md'), content)
|
||||
return templateDir
|
||||
}
|
||||
|
||||
// ── Sequence helpers ──
|
||||
type SeqBehavior =
|
||||
| { type: 'sync-ok'; stdout: string }
|
||||
| { type: 'sync-fail'; msg: string }
|
||||
| { type: 'async-ok'; stdout: string }
|
||||
| { type: 'async-fail'; msg: string }
|
||||
|
||||
/**
|
||||
* Sets sync/async behavior based on command name.
|
||||
* syncBehavior controls execFileSync (git, gh --version sync-check).
|
||||
* asyncBehaviors controls sequential async calls.
|
||||
*/
|
||||
function setupMocks(opts: {
|
||||
gitRemoteUrl?: string | null // null = git fails, string = succeeds with that URL
|
||||
ghCliAvailable?: boolean // whether gh --version sync call succeeds
|
||||
asyncSequence?: Array<
|
||||
{ ok: true; stdout: string } | { ok: false; msg: string }
|
||||
>
|
||||
}): void {
|
||||
const { gitRemoteUrl, ghCliAvailable = false, asyncSequence = [] } = opts
|
||||
|
||||
_execFileSyncImpl = (cmd, _args, _opts) => {
|
||||
if (cmd === 'git') {
|
||||
if (gitRemoteUrl !== null && gitRemoteUrl !== undefined) {
|
||||
return Buffer.from(gitRemoteUrl + '\n')
|
||||
}
|
||||
throw new Error('ENOENT: git not found or no remote')
|
||||
}
|
||||
if (cmd === 'gh') {
|
||||
if (ghCliAvailable) {
|
||||
return Buffer.from('gh version 2.0.0')
|
||||
}
|
||||
throw new Error('ENOENT: gh not found')
|
||||
}
|
||||
throw new Error(`Unexpected sync command: ${cmd}`)
|
||||
}
|
||||
|
||||
let asyncCallCount = 0
|
||||
_execFileImpl = (_cmd, _args, _opts, cb) => {
|
||||
const b = asyncSequence[asyncCallCount] ?? {
|
||||
ok: false,
|
||||
msg: 'unexpected async call',
|
||||
}
|
||||
asyncCallCount++
|
||||
if (b.ok) cb(null, b.stdout, '')
|
||||
else cb(new Error(b.msg), '', b.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Activate child_process stubs only for this suite.
|
||||
beforeAll(() => {
|
||||
useIssueGhCpStubs = true
|
||||
})
|
||||
afterAll(() => {
|
||||
useIssueGhCpStubs = false
|
||||
})
|
||||
|
||||
describe('issue command — tryDetectGitRemoteUrl catch path', () => {
|
||||
test('git fails → tryDetectGitRemoteUrl returns null → no remote detected', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
// No remote + no gh → fallback URL path
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — ghCliAvailable paths', () => {
|
||||
test('gh not available → falls back to browser URL (with GitHub remote)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: false,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('github.com/owner/repo')
|
||||
expect(result.value).toContain('Install')
|
||||
})
|
||||
|
||||
test('gh not available + no remote → shows no GitHub remote message', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
|
||||
test('gh available + no remote → falls back to browser (no URL)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: null,
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — parseOwnerRepo null path', () => {
|
||||
test('non-GitHub remote → parseOwnerRepo returns null → no gh URL', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://gitlab.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — repoHasIssuesEnabled paths', () => {
|
||||
test('gh available + GitHub remote → issues enabled (true) → creates issue', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' }, // gh api repos → has_issues = true
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/42' }, // gh issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
expect(result.value).toContain('Fix login bug')
|
||||
expect(result.value).toContain('https://github.com/owner/repo/issues/42')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → issues disabled (false) → discussions fallback', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'false\n' }, // gh api repos → has_issues = false
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issues are disabled')
|
||||
expect(result.value).toContain('discussions')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → repoHasIssuesEnabled returns null (unexpected output)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'null\n' }, // unexpected .has_issues value → null
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/99' }, // issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
// null → proceeds to create issue
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → repoHasIssuesEnabled throws → returns null → creates issue', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: false, msg: 'network error' }, // gh api fails → catch → null
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/101' }, // issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote + issue create fails → error message', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' }, // has_issues = true
|
||||
{ ok: false, msg: 'gh auth error' }, // issue create fails
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Failed to create issue')
|
||||
expect(result.value).toContain('gh auth error')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote + labels and assignees → issue created with labels', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/50' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label bug --assignee alice Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
expect(result.value).toContain('Labels: bug')
|
||||
expect(result.value).toContain('Assignees: alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — detectIssueTemplate paths', () => {
|
||||
test('no .github/ISSUE_TEMPLATE → no template used', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/1' },
|
||||
],
|
||||
})
|
||||
process.env.INIT_CWD = tmpDir
|
||||
// Ensure no ISSUE_TEMPLATE exists
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test no template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('.github/ISSUE_TEMPLATE with md file → template included in body', async () => {
|
||||
createIssueTemplate('---\nname: Bug Report\n---\n## Describe the bug')
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/2' },
|
||||
],
|
||||
})
|
||||
// Override getOriginalCwd to return tmpDir by setting env
|
||||
// detectIssueTemplate uses `cwd = getOriginalCwd()` from state
|
||||
// which returns the real process cwd. We create template relative to real cwd
|
||||
// This test just verifies the path doesn't crash.
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test with template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
|
||||
test('.github/ISSUE_TEMPLATE with only yml files → no md template', async () => {
|
||||
const templateDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
writeFileSync(join(templateDir, 'bug.yml'), 'name: Bug\ndescription: A bug')
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/3' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test yml template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — getTranscriptSummary paths', () => {
|
||||
test('session log exists + projectDir=null → reads from standard path', async () => {
|
||||
await writeSessionLog()
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/4' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('session log with tool_result errors → errors included in summary', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu1',
|
||||
is_error: true,
|
||||
content: 'Command failed with exit code 1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
JSON.stringify({ role: 'user', content: 'help me' }),
|
||||
JSON.stringify({ role: 'assistant', content: 'let me look' }),
|
||||
])
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/5' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix crash')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('session log with array content user message', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'What is the issue?' }],
|
||||
}),
|
||||
])
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/6' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test array content')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('no session log → getTranscriptSummary returns no session log found', async () => {
|
||||
// No log written → summary says "(no session log found)"
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/7' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix issue no log')
|
||||
expect(result.type).toBe('text')
|
||||
// Either creates issue successfully or fails, but passes the code paths
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — SSH GitHub remote', () => {
|
||||
test('SSH remote parsed correctly → issue created', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'git@github.com:owner/myrepo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/myrepo/issues/8' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix SSH issue')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — no title with remote present', () => {
|
||||
test('no title + GitHub remote + gh available → usage with repo info and gh message', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
expect(result.value).toContain('owner/repo')
|
||||
})
|
||||
|
||||
test('no title + no remote + gh not available → usage with no repo info', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
})
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Coverage tests for detectIssueTemplate paths.
|
||||
*
|
||||
* detectIssueTemplate uses getOriginalCwd() to find .github/ISSUE_TEMPLATE.
|
||||
* These tests create the template directory in the REAL project CWD and clean
|
||||
* up after each test.
|
||||
*
|
||||
* IMPORTANT: No state mock is used — this avoids global mock contamination.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// ── child_process mock ──
|
||||
let _execFileSyncImplT: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
) => Buffer = () => Buffer.from('')
|
||||
let _execFileImplT: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => void = (_cmd, _args, _opts, cb) => cb(null, '', '')
|
||||
|
||||
const execFileSyncMockT = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
): Buffer => _execFileSyncImplT(cmd, args, opts)
|
||||
const execFileMockT = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => _execFileImplT(cmd, args, opts, cb)
|
||||
|
||||
;(execFileMockT as unknown as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> =>
|
||||
new Promise((resolve, reject) =>
|
||||
_execFileImplT(cmd, args, opts, (err, stdout, stderr) => {
|
||||
if (err) reject(err)
|
||||
else resolve({ stdout, stderr })
|
||||
}),
|
||||
)
|
||||
|
||||
// Spread real child_process + flag-gated stub (see share-gh.test.ts for the
|
||||
// promisify.custom rationale).
|
||||
let useIssueTemplateCpStubs = false
|
||||
const wrappedIssueTemplateExecFile = ((...args: unknown[]) =>
|
||||
useIssueTemplateCpStubs
|
||||
? (execFileMockT as (...a: unknown[]) => unknown)(...args)
|
||||
: // eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
(require('node:child_process').execFile as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as unknown as Record<symbol, unknown> & ((...a: unknown[]) => unknown)
|
||||
;(wrappedIssueTemplateExecFile as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (useIssueTemplateCpStubs) {
|
||||
return new Promise((resolve, reject) =>
|
||||
_execFileImplT(cmd, args, opts, (err, stdout, stderr) =>
|
||||
err ? reject(err) : resolve({ stdout, stderr }),
|
||||
),
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return promisify(real.execFile as never)(cmd, args, opts) as Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
mock.module('node:child_process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
execFile: wrappedIssueTemplateExecFile as typeof real.execFile,
|
||||
execFileSync: ((...args: unknown[]) =>
|
||||
useIssueTemplateCpStubs
|
||||
? (execFileSyncMockT as (...a: unknown[]) => unknown)(...args)
|
||||
: (real.execFileSync as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as typeof real.execFileSync,
|
||||
}
|
||||
})
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
}))
|
||||
|
||||
// Re-mock bootstrap/state.js so getOriginalCwd points at the real process
|
||||
// cwd regardless of any prior test file's static state mock (e.g.
|
||||
// launchAutofixPr.test.ts pinning '/mock/cwd'). Without this override, in
|
||||
// the full suite detectIssueTemplate would see '/mock/cwd' and skip the
|
||||
// template loading body (lines 114-129).
|
||||
import { stateMock as _baseStateMockT } from '../../../../tests/mocks/state'
|
||||
let _dynamicCwdT: string = process.cwd()
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
..._baseStateMockT(),
|
||||
getSessionId: () => 'issue-tpl-session-id',
|
||||
getSessionProjectDir: () => null,
|
||||
getOriginalCwd: () => _dynamicCwdT,
|
||||
setOriginalCwd: (c: string) => {
|
||||
_dynamicCwdT = c
|
||||
},
|
||||
}))
|
||||
|
||||
// ── State ──
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
|
||||
// The real CWD where the issue command will look for .github/ISSUE_TEMPLATE
|
||||
// We determine this at import time (stable throughout test run)
|
||||
const realCwd = process.cwd()
|
||||
// We track whether we created the template dir so we can clean it up
|
||||
let createdTemplatePath: string | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'issue-tpl-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
createdTemplatePath = null
|
||||
|
||||
// Default: git → GitHub remote, gh → available, async → issues true + create OK
|
||||
let n = 0
|
||||
_execFileSyncImplT = (cmd, _args, _opts) => {
|
||||
if (cmd === 'git') return Buffer.from('https://github.com/owner/repo.git\n')
|
||||
if (cmd === 'gh') return Buffer.from('gh version 2.0.0')
|
||||
return Buffer.from('')
|
||||
}
|
||||
_execFileImplT = (_cmd, _args, _opts, cb) => {
|
||||
n++
|
||||
if (n === 1) cb(null, 'true\n', '')
|
||||
else cb(null, 'https://github.com/owner/repo/issues/20', '')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
// Clean up any template dir we created in the real CWD
|
||||
if (createdTemplatePath && existsSync(createdTemplatePath)) {
|
||||
rmSync(createdTemplatePath, { recursive: true, force: true })
|
||||
}
|
||||
createdTemplatePath = null
|
||||
})
|
||||
|
||||
// ── Helpers ──
|
||||
type CallFn = (args: string) => Promise<{ type: string; value: string }>
|
||||
|
||||
async function getCallFn(): Promise<CallFn> {
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as { load: () => Promise<{ call: CallFn }> }
|
||||
).load()
|
||||
return loaded.call.bind(loaded) as CallFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates .github/ISSUE_TEMPLATE in the REAL CWD.
|
||||
* Registers for cleanup in afterEach.
|
||||
*/
|
||||
function createTemplateInCwd(files: Record<string, string>): string {
|
||||
const templateDir = join(realCwd, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(templateDir, name), content)
|
||||
}
|
||||
// Track the .github dir for cleanup (remove whole .github if it didn't exist)
|
||||
const githubDir = join(realCwd, '.github')
|
||||
createdTemplatePath = githubDir
|
||||
return templateDir
|
||||
}
|
||||
|
||||
// Activate child_process stubs only for this suite.
|
||||
beforeAll(() => {
|
||||
useIssueTemplateCpStubs = true
|
||||
})
|
||||
afterAll(() => {
|
||||
useIssueTemplateCpStubs = false
|
||||
})
|
||||
|
||||
describe('issue command — detectIssueTemplate template paths', () => {
|
||||
test('md template with front-matter → front-matter stripped', async () => {
|
||||
createTemplateInCwd({
|
||||
'bug.md':
|
||||
'---\nname: Bug Report\nabout: A bug\n---\n## Describe the bug\n\nDetails.',
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix bug with template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('md template without front-matter → content returned as-is', async () => {
|
||||
createTemplateInCwd({
|
||||
'feature.md': '## Feature Request\n\nDescribe the feature.',
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Add feature')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('yml file only → mdFile not found → no template (null)', async () => {
|
||||
createTemplateInCwd({
|
||||
'bug.yml': 'name: Bug\ndescription: Describe the bug.',
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix yml-only template issue')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('md template stripped to empty → null (stripped || null)', async () => {
|
||||
// Front-matter only, empty body after stripping
|
||||
createTemplateInCwd({
|
||||
'empty.md': '---\nname: Empty\nabout: empty\n---',
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Empty template test')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
})
|
||||
@@ -1,611 +0,0 @@
|
||||
/**
|
||||
* Tests for issue/index.ts
|
||||
*
|
||||
* NOTE: issue/index.ts calls execFileSync at module-function level (not top-level).
|
||||
* The child_process functions are imported by reference and cannot be reliably
|
||||
* mocked after module load with Bun's mock.module. Tests here cover what's
|
||||
* testable without child_process control: parseIssueArgs, metadata, and
|
||||
* environment-agnostic paths.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
logEventAsync: () => Promise.resolve(),
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
_resetForTesting: () => {},
|
||||
attachAnalyticsSink: () => {},
|
||||
}))
|
||||
|
||||
// Re-mock bootstrap/state.js with a dynamic getOriginalCwd / setOriginalCwd
|
||||
// pair so this suite can drive cwd values regardless of any earlier test
|
||||
// file's static mock (e.g. launchAutofixPr.test.ts which sets a fixed
|
||||
// '/mock/cwd'). We start from the shared stateMock helper, then override
|
||||
// the four exports issue/index.ts cares about with closure-driven impls.
|
||||
//
|
||||
// Bun's mock.module is global / last-write-wins. After this suite finishes
|
||||
// we set `useIssueDynamicState=false` so launchAutofixPr's tests (which run
|
||||
// in the same process) see the values their suite originally expected.
|
||||
import { stateMock } from '../../../../tests/mocks/state'
|
||||
let _dynamicCwd = process.cwd()
|
||||
let _dynamicSessionId = `issue-test-${randomUUID()}`
|
||||
// Default OFF — autofix-pr/__tests__/launchAutofixPr.test.ts runs FIRST in
|
||||
// the combined suite (alphabetical: 'autofix-pr' < 'issue') and expects
|
||||
// '/mock/cwd'. Issue's beforeAll switches this on, afterAll switches off.
|
||||
let useIssueDynamicState = false
|
||||
// Default OFF — the long-body draft-save test below flips this on for its
|
||||
// body (so execFile/execFileSync return ENOENT + a fake GitHub remote URL)
|
||||
// then flips off in finally. Without the flag the child_process stub leaked
|
||||
// process-globally into every later test file via Bun's mock.module cache.
|
||||
let useIssueLongBodyCpStubs = false
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
...stateMock(),
|
||||
getSessionId: () =>
|
||||
useIssueDynamicState ? _dynamicSessionId : 'parent-session-id',
|
||||
getParentSessionId: () => undefined,
|
||||
getCwdState: () => (useIssueDynamicState ? _dynamicCwd : '/mock/cwd'),
|
||||
getSessionProjectDir: () => null,
|
||||
getOriginalCwd: () => (useIssueDynamicState ? _dynamicCwd : '/mock/cwd'),
|
||||
getProjectRoot: () => (useIssueDynamicState ? _dynamicCwd : '/mock/project'),
|
||||
setCwdState: (c: string) => {
|
||||
if (useIssueDynamicState) _dynamicCwd = c
|
||||
},
|
||||
setOriginalCwd: (c: string) => {
|
||||
if (useIssueDynamicState) _dynamicCwd = c
|
||||
},
|
||||
setLastAPIRequestMessages: () => {},
|
||||
getIsNonInteractiveSession: () => false,
|
||||
addSlowOperation: () => {},
|
||||
}))
|
||||
|
||||
// ── State ──
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
// Snapshot HOME so per-test mutations (lines below set process.env.HOME =
|
||||
// tmpDir for child-process branches) can be restored. Otherwise the leaked
|
||||
// /tmp/issue-test-XXX HOME pollutes downstream tests like
|
||||
// src/services/langfuse/__tests__/langfuse.test.ts whose sanitize logic
|
||||
// substitutes the current process.env.HOME.
|
||||
const _originalHomeForIssueSuite = process.env.HOME
|
||||
|
||||
// Mock envUtils to read CLAUDE_CONFIG_DIR from process.env dynamically so
|
||||
// other test files (cacheStats, SessionMemory/prompts) that mock with static
|
||||
// paths don't pollute this test in the full suite. Reading process.env at
|
||||
// call time lets each test drive its own dir.
|
||||
mock.module('src/utils/envUtils.js', () => ({
|
||||
getClaudeConfigHomeDir: () =>
|
||||
process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`,
|
||||
isEnvTruthy: (v: unknown) => Boolean(v),
|
||||
getTeamsDir: () =>
|
||||
join(process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`, 'teams'),
|
||||
hasNodeOption: () => false,
|
||||
isEnvDefinedFalsy: () => false,
|
||||
isBareMode: () => false,
|
||||
parseEnvVars: (s: string) => s,
|
||||
getAWSRegion: () => 'us-east-1',
|
||||
getDefaultVertexRegion: () => 'us-central1',
|
||||
shouldMaintainProjectWorkingDir: () => false,
|
||||
}))
|
||||
|
||||
// Activate dynamic state mode for this suite only.
|
||||
beforeAll(() => {
|
||||
useIssueDynamicState = true
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'issue-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
// Reset dynamic cwd to a per-test deterministic default (the tmpDir).
|
||||
// Tests that need a different cwd call the mocked setOriginalCwd.
|
||||
_dynamicCwd = tmpDir
|
||||
_dynamicSessionId = `issue-test-${randomUUID()}`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
// Restore HOME — individual tests may have set it to tmpDir.
|
||||
if (_originalHomeForIssueSuite === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = _originalHomeForIssueSuite
|
||||
}
|
||||
})
|
||||
|
||||
// After this suite finishes, switch off our dynamic mode so any subsequent
|
||||
// test file (e.g. launchAutofixPr.test.ts) that imports bootstrap/state.js
|
||||
// gets the static values its suite expects. Bun's mock.module is global and
|
||||
// our mock won the registration race; this flag flips behavior post-suite.
|
||||
afterAll(() => {
|
||||
useIssueDynamicState = false
|
||||
})
|
||||
|
||||
// ── Helpers ──
|
||||
type CallFn = (
|
||||
args: string,
|
||||
ctx?: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
|
||||
async function getCallFn(): Promise<CallFn> {
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as { load: () => Promise<{ call: CallFn }> }
|
||||
).load()
|
||||
return loaded.call.bind(loaded) as CallFn
|
||||
}
|
||||
|
||||
async function writeSessionLog(entries?: string[]): Promise<void> {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const content = entries ?? [
|
||||
JSON.stringify({ role: 'user', content: 'Fix the login bug' }),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'I will investigate' }],
|
||||
}),
|
||||
]
|
||||
writeFileSync(join(dir, `${sessionId}.jsonl`), content.join('\n') + '\n')
|
||||
}
|
||||
|
||||
describe('issue command — metadata', () => {
|
||||
test('command has correct name and type', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.name).toBe('issue')
|
||||
expect(cmd.type).toBe('local')
|
||||
expect(
|
||||
(cmd as unknown as { supportsNonInteractive: boolean })
|
||||
.supportsNonInteractive,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('isEnabled returns true', async () => {
|
||||
const mod = await import('../index.js')
|
||||
expect(mod.default.isEnabled?.()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — parseIssueArgs', () => {
|
||||
test('--label without value → parse error message', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--label requires a value')
|
||||
})
|
||||
|
||||
test('--label with empty next flag → parse error', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label --public')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--label requires a value')
|
||||
})
|
||||
|
||||
test('--assignee without value → parse error message', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--assignee')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--assignee requires a value')
|
||||
})
|
||||
|
||||
test('-l without value → parse error', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('-l')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--label requires a value')
|
||||
})
|
||||
|
||||
test('-a without value → parse error', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('-a')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--assignee requires a value')
|
||||
})
|
||||
|
||||
test('unknown flag → parse error', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--unknown Fix bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Unknown flag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — no title', () => {
|
||||
test('empty args → usage hint', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
|
||||
test('whitespace-only args → usage hint', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call(' ')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — with title', () => {
|
||||
test('title only → returns some text result', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('title with --label → returns some text result', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label bug Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('title with --assignee → returns some text result', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--assignee alice Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('title with both --label and --assignee → returns some text result', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label bug --assignee alice Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('title with log file present → exercises transcript summary paths', async () => {
|
||||
await writeSessionLog()
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('transcript with array content → covers array branch in getTranscriptSummary', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'What is the issue?' }],
|
||||
}),
|
||||
// tool_result with is_error → covers error collection
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu1',
|
||||
is_error: true,
|
||||
content: 'Command failed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
// malformed line
|
||||
'NOT_JSON{{{',
|
||||
])
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test issue')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
|
||||
test('transcript with only system entries → no conversation content', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({ role: 'system', content: 'system prompt' }),
|
||||
])
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test issue empty summary')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
|
||||
// ── H5 regression: browser fallback URL body must be ≤ 4096 chars before encode ──
|
||||
test('H5: URL-encoded body is capped at 4096 chars when session summary is very long', async () => {
|
||||
// Write a log with a very long user message to ensure summary exceeds 4096 chars
|
||||
const longText = 'A'.repeat(6000)
|
||||
await writeSessionLog([
|
||||
JSON.stringify({ role: 'user', content: longText }),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: longText }],
|
||||
}),
|
||||
])
|
||||
const call = await getCallFn()
|
||||
// No gh, no remote → falls into browser fallback path
|
||||
const result = await call('Some Long Issue Title')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
// Extract the URL from the output (if present)
|
||||
const urlMatch = result.value.match(/https?:\/\/\S+/)
|
||||
if (urlMatch) {
|
||||
// The URL must be ≤ ~8KB after encoding. Check the body= parameter specifically.
|
||||
const bodyParam = urlMatch[0].match(/[?&]body=([^&]*)/)
|
||||
if (bodyParam) {
|
||||
// decoded body text must be ≤ 4096 chars (plus truncation suffix)
|
||||
const decoded = decodeURIComponent(bodyParam[1])
|
||||
expect(decoded.length).toBeLessThanOrEqual(4096 + 60) // 60 for truncation suffix
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('long body session log does not crash', async () => {
|
||||
// Long session log content exercises the body-formatting branches.
|
||||
const longText = 'x'.repeat(4500)
|
||||
const entries: string[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
entries.push(JSON.stringify({ role: 'user', content: longText }))
|
||||
entries.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: longText }],
|
||||
}),
|
||||
)
|
||||
}
|
||||
await writeSessionLog(entries)
|
||||
process.env.HOME = tmpDir
|
||||
const call = await getCallFn()
|
||||
const result = await call('Long body issue')
|
||||
expect(result.type).toBe('text')
|
||||
})
|
||||
|
||||
test('handles unreadable session log gracefully', async () => {
|
||||
// Write a corrupt log file that triggers parse errors but exists
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// Empty / whitespace-only file: should not crash, will produce empty session text
|
||||
writeFileSync(join(dir, `${sessionId}.jsonl`), '')
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue from empty session')
|
||||
expect(result.type).toBe('text')
|
||||
})
|
||||
|
||||
test('template directory unreadable returns null template (graceful)', async () => {
|
||||
// Create issue-templates directory with no .md files (only a non-readable subfile name)
|
||||
const templatesDir = join(claudeDir, 'issue-templates')
|
||||
mkdirSync(templatesDir, { recursive: true })
|
||||
writeFileSync(join(templatesDir, 'README.txt'), 'not a markdown template')
|
||||
await writeSessionLog()
|
||||
const call = await getCallFn()
|
||||
// Should still succeed without template — template loading is best-effort
|
||||
const result = await call('Issue without templates')
|
||||
expect(result.type).toBe('text')
|
||||
})
|
||||
|
||||
test('session log read failure caught (path is a directory)', async () => {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// Create a directory at the log path so readFileSync throws EISDIR.
|
||||
mkdirSync(join(dir, `${sessionId}.jsonl`), { recursive: true })
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue with broken log')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
// Should still produce output even when session log is unreadable
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('detectIssueTemplate picks up first .md template from .github/ISSUE_TEMPLATE', async () => {
|
||||
// Issue command uses getOriginalCwd() (NOT process.cwd) — override via
|
||||
// setOriginalCwd. Restore after to avoid polluting other tests.
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(githubDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(githubDir, 'bug.md'),
|
||||
'---\nname: Bug\nabout: Bug report\n---\n## Steps to reproduce\n\nSteps...\n',
|
||||
)
|
||||
writeFileSync(
|
||||
join(githubDir, 'config.yml'),
|
||||
'blank_issues_enabled: false\n',
|
||||
)
|
||||
await writeSessionLog()
|
||||
const origCwd = getOriginalCwd()
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue with bug template')
|
||||
expect(result.type).toBe('text')
|
||||
} finally {
|
||||
setOriginalCwd(origCwd)
|
||||
}
|
||||
})
|
||||
|
||||
test('detectIssueTemplate returns null when only non-md templates present', async () => {
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(githubDir, { recursive: true })
|
||||
writeFileSync(join(githubDir, 'bug.yml'), 'name: Bug')
|
||||
await writeSessionLog()
|
||||
const origCwd = getOriginalCwd()
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue YAML-only template')
|
||||
expect(result.type).toBe('text')
|
||||
} finally {
|
||||
setOriginalCwd(origCwd)
|
||||
}
|
||||
})
|
||||
|
||||
test('detectIssueTemplate returns null when ISSUE_TEMPLATE is empty', async () => {
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(githubDir, { recursive: true })
|
||||
await writeSessionLog()
|
||||
const origCwd = getOriginalCwd()
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue empty template dir')
|
||||
expect(result.type).toBe('text')
|
||||
} finally {
|
||||
setOriginalCwd(origCwd)
|
||||
}
|
||||
})
|
||||
|
||||
test('detectIssueTemplate readdir failure is caught (catch branch)', async () => {
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
// Create the ISSUE_TEMPLATE path as a regular file (not a directory) so
|
||||
// existsSync returns true but readdirSync throws ENOTDIR.
|
||||
const githubDir = join(tmpDir, '.github')
|
||||
mkdirSync(githubDir, { recursive: true })
|
||||
writeFileSync(join(githubDir, 'ISSUE_TEMPLATE'), 'not-a-directory')
|
||||
await writeSessionLog()
|
||||
const origCwd = getOriginalCwd()
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue with broken template path')
|
||||
expect(result.type).toBe('text')
|
||||
} finally {
|
||||
setOriginalCwd(origCwd)
|
||||
}
|
||||
})
|
||||
|
||||
test('long body triggers truncation + draft save', async () => {
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
// getTranscriptSummary clips each user/assistant text to 200 chars and
|
||||
// joins only the last 10 entries, so it can never organically exceed
|
||||
// ~2.7 KB. To exercise the >4096-char branch (lines 362-375), we
|
||||
// temporarily neutralise Array.prototype.slice for the `slice(-N)`
|
||||
// pattern (negative-only first arg, no second arg). String.slice and
|
||||
// positive Array.slice keep working, and we restore the original in
|
||||
// finally so no state leaks across tests.
|
||||
const longText = 'x'.repeat(200)
|
||||
const entries: string[] = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
entries.push(JSON.stringify({ role: 'user', content: longText }))
|
||||
entries.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: longText }],
|
||||
}),
|
||||
)
|
||||
}
|
||||
await writeSessionLog(entries)
|
||||
process.env.HOME = tmpDir
|
||||
const origCwd = getOriginalCwd()
|
||||
const origSlice = Array.prototype.slice
|
||||
// Force the fallback URL branch with a *parsed* GitHub remote so the
|
||||
// draft-path output (lines 392-393) is reached: git remote returns a
|
||||
// GitHub URL but `gh --version` fails so hasGh is false.
|
||||
//
|
||||
// Spread+flag pattern: the previous bare `mock.module(...)` here leaked
|
||||
// a stub child_process to every later test file in the same `bun test`
|
||||
// run (mock.module is process-global, last-write-wins). Now we register
|
||||
// a flag-gated mock that delegates to real child_process by default, and
|
||||
// only flips on for THIS test's body.
|
||||
mock.module('node:child_process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
execFile: ((...args: unknown[]) => {
|
||||
if (useIssueLongBodyCpStubs) {
|
||||
const cb = args[3] as
|
||||
| ((e: Error | null, s: string, e2: string) => void)
|
||||
| undefined
|
||||
if (cb) cb(new Error('ENOENT'), '', '')
|
||||
return
|
||||
}
|
||||
return (real.execFile as (...a: unknown[]) => unknown)(...args)
|
||||
}) as typeof real.execFile,
|
||||
execFileSync: ((...args: unknown[]) => {
|
||||
if (useIssueLongBodyCpStubs) {
|
||||
const cmd = args[0] as string
|
||||
if (cmd === 'git')
|
||||
return Buffer.from('https://github.com/owner/repo.git\n')
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
return (real.execFileSync as (...a: unknown[]) => unknown)(...args)
|
||||
}) as typeof real.execFileSync,
|
||||
}
|
||||
})
|
||||
useIssueLongBodyCpStubs = true
|
||||
Array.prototype.slice = function (
|
||||
this: unknown[],
|
||||
start?: number,
|
||||
end?: number,
|
||||
): unknown[] {
|
||||
// For `summaryParts.slice(-10)` and `errors.slice(-3)` (negative
|
||||
// start, no end) return the full array so summaryParts.length
|
||||
// determines the body size.
|
||||
if (typeof start === 'number' && start < 0 && end === undefined) {
|
||||
return Array.from(this)
|
||||
}
|
||||
return origSlice.call(this, start, end) as unknown[]
|
||||
} as typeof Array.prototype.slice
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Long body for draft save')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
// Draft path is reported when body > 4096 chars (line 393 branch).
|
||||
expect(result.value).toContain('Full issue body saved to')
|
||||
}
|
||||
} finally {
|
||||
Array.prototype.slice = origSlice
|
||||
setOriginalCwd(origCwd)
|
||||
useIssueLongBodyCpStubs = false
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,9 +7,30 @@ import { logMock } from '../../../../tests/mocks/log.js'
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
|
||||
// No keychain mock here — the real store falls back to encrypted file when
|
||||
// @napi-rs/keyring is not installed (which it is not in this environment).
|
||||
// This exercises the full file-fallback path without cross-test module pollution.
|
||||
// Re-register ../keychain.js to override pollution from store.test.ts (which
|
||||
// mocks keychain as always-throwing) and keychain.test.ts (which mocks it with
|
||||
// an in-memory MockEntry). Force KeychainUnavailableError so the store always
|
||||
// uses the encrypted-file fallback path.
|
||||
class KeychainUnavailableError extends Error {
|
||||
override name = 'KeychainUnavailableError'
|
||||
}
|
||||
|
||||
const keychainUnavailable = async (): Promise<never> => {
|
||||
throw new KeychainUnavailableError('test: keychain mocked as unavailable')
|
||||
}
|
||||
|
||||
mock.module('../../../services/localVault/keychain.js', () => ({
|
||||
KeychainUnavailableError,
|
||||
tryKeychain: {
|
||||
set: keychainUnavailable,
|
||||
get: keychainUnavailable,
|
||||
delete: keychainUnavailable,
|
||||
list: keychainUnavailable,
|
||||
_addToIndex: keychainUnavailable,
|
||||
_removeFromIndex: keychainUnavailable,
|
||||
},
|
||||
_resetKeychainModuleCache: () => {},
|
||||
}))
|
||||
|
||||
let callLocalVault: typeof import('../launchLocalVault.js').callLocalVault
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Tests for AuthPlaneSummary.tsx
|
||||
* Uses staticRender to render Ink components to strings.
|
||||
* Covers all 4 mode combinations + long provider list + key preview masking.
|
||||
*/
|
||||
import { describe, expect, test, mock } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { logMock } from '../../../../tests/mocks/log';
|
||||
import { debugMock } from '../../../../tests/mocks/debug';
|
||||
|
||||
mock.module('src/utils/log.ts', logMock);
|
||||
mock.module('src/utils/debug.ts', debugMock);
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }));
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getCachedOrDefaultSettings: () => ({}),
|
||||
getSettings: () => ({}),
|
||||
}));
|
||||
mock.module('src/utils/config.ts', () => ({
|
||||
isConfigEnabled: () => true,
|
||||
getGlobalConfig: () => ({ workspaceApiKey: undefined }),
|
||||
saveGlobalConfig: (_updater: unknown) => undefined,
|
||||
}));
|
||||
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
import type { AuthStatus } from '../getAuthStatus.js';
|
||||
|
||||
// Helper to build minimal AuthStatus fixtures
|
||||
function makeStatus(overrides: Partial<AuthStatus> = {}): AuthStatus {
|
||||
return {
|
||||
subscription: {
|
||||
active: false,
|
||||
plan: null,
|
||||
accountEmail: null,
|
||||
},
|
||||
workspaceKey: {
|
||||
set: false,
|
||||
prefixValid: false,
|
||||
keyPreview: null,
|
||||
source: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthPlaneSummary', () => {
|
||||
test('renders subscription as inactive (☐) when not logged in', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus();
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('Subscription');
|
||||
// Subscription inactive symbol or "not logged in" indicator
|
||||
expect(out.toLowerCase()).toMatch(/not logged in|☐/);
|
||||
});
|
||||
|
||||
test('renders subscription as active (☑) with plan label when subscribed', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
subscription: { active: true, plan: 'pro', accountEmail: null },
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('pro');
|
||||
// Active symbol present
|
||||
expect(out).toContain('☑');
|
||||
});
|
||||
|
||||
test('renders workspace key as set+valid (☑) when prefixValid=true', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
workspaceKey: {
|
||||
set: true,
|
||||
prefixValid: true,
|
||||
keyPreview: 'sk-a...67 (48 chars)',
|
||||
source: 'env',
|
||||
},
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
// Key preview may be word-wrapped across lines in terminal output
|
||||
expect(out).toContain('sk-a...67');
|
||||
expect(out).toContain('☑');
|
||||
});
|
||||
|
||||
test('renders workspace key warning (⚠) when set but prefix invalid', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
workspaceKey: {
|
||||
set: true,
|
||||
prefixValid: false,
|
||||
keyPreview: 'sk-w...ng (40 chars)',
|
||||
source: 'env',
|
||||
},
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
// Warning indicator present
|
||||
expect(out).toContain('⚠');
|
||||
expect(out.toLowerCase()).toContain('sk-ant-api03-');
|
||||
});
|
||||
|
||||
test('shows workspace key 4-step setup instructions when key not set and subscription active', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
subscription: { active: true, plan: 'pro', accountEmail: null },
|
||||
workspaceKey: { set: false, prefixValid: false, keyPreview: null, source: null },
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('console.anthropic.com');
|
||||
});
|
||||
|
||||
// Third-party provider rendering tests removed 2026-05-06 — that section
|
||||
// was deleted from AuthPlaneSummary to defer to fork's existing /login form
|
||||
// for OpenAI-compat configuration. See AuthPlaneSummary.tsx for the rationale.
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
* - Third-party API key values are NEVER included; only boolean presence flags.
|
||||
*/
|
||||
|
||||
import type { SubscriptionType } from '../../services/oauth/types.js'
|
||||
import { getClaudeAIOAuthTokens } from '../../utils/auth.js'
|
||||
import { getGlobalConfig } from '../../utils/config.js'
|
||||
|
||||
@@ -107,7 +108,10 @@ export function getAuthStatus(): AuthStatus {
|
||||
|
||||
let plan: AuthStatus['subscription']['plan'] = null
|
||||
if (subscriptionActive && oauthTokens) {
|
||||
const raw = oauthTokens.subscriptionType
|
||||
// 本地持久化或历史 token 中可能出现 'free' 等未纳入 SubscriptionType 的字符串
|
||||
const raw = oauthTokens.subscriptionType as
|
||||
| (SubscriptionType | 'free')
|
||||
| null
|
||||
if (
|
||||
raw === 'free' ||
|
||||
raw === 'pro' ||
|
||||
|
||||
@@ -1,331 +1,383 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchMemoryStores.ts
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock memoryStoresApi.js itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Let real memoryStoresApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const realAnalytics = await import('src/services/analytics/index.js')
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
...realAnalytics,
|
||||
logEvent: logEventMock,
|
||||
}))
|
||||
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const realAuth = await import('src/utils/auth.js')
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
...realAuth,
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ms' }),
|
||||
}))
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-ms',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
// Spread real teleport/api so any export not explicitly stubbed (like
|
||||
// prepareApiRequest, axiosGetWithRetry, type guards, schemas)
|
||||
// remains available to transitive importers.
|
||||
const realTeleportApi = await import('src/utils/teleport/api.js')
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
...realTeleportApi,
|
||||
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
||||
prepareApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── MemoryStoresView mock ───────────────────────────────────────────────────
|
||||
const memoryStoresViewMock = mock((_props: unknown) => null)
|
||||
mock.module('src/commands/memory-stores/MemoryStoresView.js', () => ({
|
||||
MemoryStoresView: memoryStoresViewMock,
|
||||
}))
|
||||
|
||||
// ── memoryStoresApi mock ──────────────────────────────────────────────────
|
||||
const listStoresMock = mock(async () => [] as unknown)
|
||||
const getStoreMock = mock(async () => ({}) as unknown)
|
||||
const createStoreMock = mock(async () => ({}) as unknown)
|
||||
const archiveStoreMock = mock(async () => ({}) as unknown)
|
||||
const listMemoriesMock = mock(async () => [] as unknown)
|
||||
const createMemoryMock = mock(async () => ({}) as unknown)
|
||||
const getMemoryMock = mock(async () => ({}) as unknown)
|
||||
const updateMemoryMock = mock(async () => ({}) as unknown)
|
||||
const deleteMemoryMock = mock(async () => undefined)
|
||||
const listVersionsMock = mock(async () => [] as unknown)
|
||||
const redactVersionMock = mock(async () => ({}) as unknown)
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosPatchMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
mock.module('src/commands/memory-stores/memoryStoresApi.js', () => ({
|
||||
listStores: listStoresMock,
|
||||
getStore: getStoreMock,
|
||||
createStore: createStoreMock,
|
||||
archiveStore: archiveStoreMock,
|
||||
listMemories: listMemoriesMock,
|
||||
createMemory: createMemoryMock,
|
||||
getMemory: getMemoryMock,
|
||||
updateMemory: updateMemoryMock,
|
||||
deleteMemory: deleteMemoryMock,
|
||||
listVersions: listVersionsMock,
|
||||
redactVersion: redactVersionMock,
|
||||
}))
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.patch = axiosPatchMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy imports ─────────────────────────────────────────────────────────────
|
||||
let callMemoryStores: typeof import('../launchMemoryStores.js').callMemoryStores
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchMemoryStores.js')
|
||||
callMemoryStores = mod.callMemoryStores
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
function makeOnDone() {
|
||||
return mock(() => {})
|
||||
const calls: [string | undefined, unknown][] = []
|
||||
const onDone = (msg?: string, opts?: unknown) => calls.push([msg, opts])
|
||||
return { onDone, calls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosPatchMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
logEventMock.mockClear()
|
||||
listStoresMock.mockClear()
|
||||
getStoreMock.mockClear()
|
||||
createStoreMock.mockClear()
|
||||
archiveStoreMock.mockClear()
|
||||
listMemoriesMock.mockClear()
|
||||
createMemoryMock.mockClear()
|
||||
getMemoryMock.mockClear()
|
||||
updateMemoryMock.mockClear()
|
||||
deleteMemoryMock.mockClear()
|
||||
listVersionsMock.mockClear()
|
||||
redactVersionMock.mockClear()
|
||||
memoryStoresViewMock.mockClear()
|
||||
})
|
||||
|
||||
// ── invalid args ──────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: invalid args', () => {
|
||||
test('invalid subcommand → onDone with usage + null', async () => {
|
||||
const onDone = makeOnDone()
|
||||
const { onDone, calls } = makeOnDone()
|
||||
const result = await callMemoryStores(onDone, {} as never, 'badcmd')
|
||||
expect(result).toBeNull()
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/Usage/i)
|
||||
expect(calls[0]?.[0]).toMatch(/Usage/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: list', () => {
|
||||
test('list returns empty stores', async () => {
|
||||
listStoresMock.mockResolvedValueOnce([])
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'list')
|
||||
expect(listStoresMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/no memory stores/i)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/no memory stores/i)
|
||||
})
|
||||
|
||||
test('list with stores reports count', async () => {
|
||||
const stores = [
|
||||
{ memory_store_id: 'ms_1', name: 'Work', namespace: 'work' },
|
||||
]
|
||||
listStoresMock.mockResolvedValueOnce(stores)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: stores }, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, '')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 memory store/)
|
||||
expect(calls[0]?.[0]).toMatch(/1 memory store/)
|
||||
})
|
||||
|
||||
test('list API error → error view', async () => {
|
||||
listStoresMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'list')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list memory stores/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list memory stores/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: get', () => {
|
||||
test('get calls getStore with id', async () => {
|
||||
test('get calls axios.get with id in URL', async () => {
|
||||
const store = { memory_store_id: 'ms_get', name: 'Work Store' }
|
||||
getStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get ms_get')
|
||||
expect(getStoreMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getStoreMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const getCall = axiosGetMock.mock.calls[0] as unknown as [string]
|
||||
expect(getCall[0]).toContain('ms_get')
|
||||
})
|
||||
|
||||
test('get API error → error message', async () => {
|
||||
getStoreMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to get memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to get memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: create', () => {
|
||||
test('create calls createStore with name', async () => {
|
||||
test('create calls axios.post with name in body', async () => {
|
||||
const store = { memory_store_id: 'ms_new', name: 'New Store' }
|
||||
createStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'create New Store')
|
||||
expect(createStoreMock).toHaveBeenCalledTimes(1)
|
||||
const calls = createStoreMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('New Store')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/memory store created/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(postCall[1]).toEqual({ name: 'New Store' })
|
||||
expect(calls[0]?.[0]).toMatch(/memory store created/i)
|
||||
})
|
||||
|
||||
test('create API error → error message', async () => {
|
||||
createStoreMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'create My Store')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to create memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to create memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── archive ───────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: archive', () => {
|
||||
test('archive calls archiveStore with id', async () => {
|
||||
test('archive calls axios.post with id in URL', async () => {
|
||||
const store = {
|
||||
memory_store_id: 'ms_arc',
|
||||
name: 'Old Store',
|
||||
archived_at: '2026-01-01',
|
||||
}
|
||||
archiveStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'archive ms_arc')
|
||||
expect(archiveStoreMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/archived/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [string]
|
||||
expect(postCall[0]).toContain('ms_arc')
|
||||
expect(postCall[0]).toContain('archive')
|
||||
expect(calls[0]?.[0]).toMatch(/archived/i)
|
||||
})
|
||||
|
||||
test('archive API error → error message', async () => {
|
||||
archiveStoreMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'archive ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to archive memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to archive memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── memories ──────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: memories', () => {
|
||||
test('memories lists memories in store', async () => {
|
||||
const memories = [
|
||||
{ memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test' },
|
||||
]
|
||||
listMemoriesMock.mockResolvedValueOnce(memories)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: memories },
|
||||
status: 200,
|
||||
})
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'memories ms_1')
|
||||
expect(listMemoriesMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 memory/)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/1 memory/)
|
||||
})
|
||||
|
||||
test('memories API error → error message', async () => {
|
||||
listMemoriesMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'memories ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list memories/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list memories/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: create-memory', () => {
|
||||
test('create-memory calls createMemory with storeId and content', async () => {
|
||||
test('create-memory calls axios.post with storeId in URL and content in body', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_new',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'hello world',
|
||||
}
|
||||
createMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'create-memory ms_1 hello world',
|
||||
)
|
||||
expect(createMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = createMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('hello world')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/memory created/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(postCall[0]).toContain('ms_1')
|
||||
expect(postCall[0]).toContain('memories')
|
||||
expect(postCall[1]).toEqual({ content: 'hello world' })
|
||||
expect(calls[0]?.[0]).toMatch(/memory created/i)
|
||||
})
|
||||
|
||||
test('create-memory API error → error message', async () => {
|
||||
createMemoryMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'create-memory ms_1 test content',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to create memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to create memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── get-memory ────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: get-memory', () => {
|
||||
test('get-memory calls getMemory', async () => {
|
||||
test('get-memory calls axios.get with storeId and memoryId in URL', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_get',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'Test',
|
||||
}
|
||||
getMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_get')
|
||||
expect(getMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const getCall = axiosGetMock.mock.calls[0] as unknown as [string]
|
||||
expect(getCall[0]).toContain('ms_1')
|
||||
expect(getCall[0]).toContain('mem_get')
|
||||
})
|
||||
|
||||
test('get-memory API error → error message', async () => {
|
||||
getMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to get memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to get memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── update-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: update-memory', () => {
|
||||
test('update-memory calls updateMemory with storeId, memoryId, and content', async () => {
|
||||
test('update-memory calls axios.patch with storeId, memoryId in URL and content in body', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_upd',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'new content',
|
||||
}
|
||||
updateMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosPatchMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'update-memory ms_1 mem_upd new content',
|
||||
)
|
||||
expect(updateMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = updateMemoryMock.mock.calls as unknown as [
|
||||
expect(axiosPatchMock).toHaveBeenCalledTimes(1)
|
||||
const patchCall = axiosPatchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_upd')
|
||||
expect(calls[0]?.[2]).toBe('new content')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/updated/i)
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(patchCall[0]).toContain('ms_1')
|
||||
expect(patchCall[0]).toContain('mem_upd')
|
||||
expect(patchCall[1]).toEqual({ content: 'new content' })
|
||||
expect(calls[0]?.[0]).toMatch(/updated/i)
|
||||
})
|
||||
|
||||
test('update-memory API error → error message', async () => {
|
||||
updateMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPatchMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'update-memory ms_1 mem_missing new content',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to update memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to update memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── delete-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: delete-memory', () => {
|
||||
test('delete-memory calls deleteMemory', async () => {
|
||||
deleteMemoryMock.mockResolvedValueOnce(undefined)
|
||||
const onDone = makeOnDone()
|
||||
test('delete-memory calls axios.delete with storeId and memoryId in URL', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'delete-memory ms_1 mem_del')
|
||||
expect(deleteMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = deleteMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_del')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/deleted/i)
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const deleteCall = axiosDeleteMock.mock.calls[0] as unknown as [string]
|
||||
expect(deleteCall[0]).toContain('ms_1')
|
||||
expect(deleteCall[0]).toContain('mem_del')
|
||||
expect(calls[0]?.[0]).toMatch(/deleted/i)
|
||||
})
|
||||
|
||||
test('delete-memory API error → error message', async () => {
|
||||
deleteMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'delete-memory ms_1 mem_missing',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to delete memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to delete memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── versions ──────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: versions', () => {
|
||||
test('versions lists memory versions', async () => {
|
||||
const versions = [
|
||||
@@ -335,46 +387,47 @@ describe('callMemoryStores: versions', () => {
|
||||
created_at: '2026-01-01',
|
||||
},
|
||||
]
|
||||
listVersionsMock.mockResolvedValueOnce(versions)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: versions },
|
||||
status: 200,
|
||||
})
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'versions ms_1')
|
||||
expect(listVersionsMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 version/)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/1 version/)
|
||||
})
|
||||
|
||||
test('versions API error → error message', async () => {
|
||||
listVersionsMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'versions ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list versions/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list versions/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── redact ────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: redact', () => {
|
||||
test('redact calls redactVersion with storeId and versionId', async () => {
|
||||
test('redact calls axios.post with storeId and versionId in URL', async () => {
|
||||
const version = {
|
||||
version_id: 'ver_red',
|
||||
memory_store_id: 'ms_1',
|
||||
redacted_at: '2026-01-01',
|
||||
}
|
||||
redactVersionMock.mockResolvedValueOnce(version)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: version, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_red')
|
||||
expect(redactVersionMock).toHaveBeenCalledTimes(1)
|
||||
const calls = redactVersionMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('ver_red')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/redacted/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [string]
|
||||
expect(postCall[0]).toContain('ms_1')
|
||||
expect(postCall[0]).toContain('ver_red')
|
||||
expect(postCall[0]).toContain('redact')
|
||||
expect(calls[0]?.[0]).toMatch(/redacted/i)
|
||||
})
|
||||
|
||||
test('redact API error → error message', async () => {
|
||||
redactVersionMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to redact version/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to redact version/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
isPluginEnabledAtProjectScope,
|
||||
uninstallPluginOp,
|
||||
updatePluginOp,
|
||||
type InstallableScope,
|
||||
} from '../../services/plugins/pluginOperations.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import type { Tool } from '../../Tool.js';
|
||||
@@ -76,7 +77,7 @@ import { PluginOptionsDialog } from './PluginOptionsDialog.js';
|
||||
import { PluginOptionsFlow } from './PluginOptionsFlow.js';
|
||||
import type { ViewState as ParentViewState } from './types.js';
|
||||
import { UnifiedInstalledCell } from './UnifiedInstalledCell.js';
|
||||
import type { UnifiedInstalledItem } from './unifiedTypes.js';
|
||||
import type { UnifiedInstalledItem, UnifiedInstalledScope } from './unifiedTypes.js';
|
||||
import { usePagination } from './usePagination.js';
|
||||
|
||||
type Props = {
|
||||
@@ -103,7 +104,7 @@ type FailedPluginInfo = {
|
||||
name: string;
|
||||
marketplace: string;
|
||||
errors: PluginError[];
|
||||
scope: PersistablePluginScope;
|
||||
scope: UnifiedInstalledScope;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -1253,7 +1254,7 @@ export function ManagePlugins({
|
||||
const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false;
|
||||
const pluginScope = item.scope;
|
||||
const isBuiltin = pluginScope === 'builtin';
|
||||
if (isBuiltin || isInstallableScope(pluginScope)) {
|
||||
if (isBuiltin || isInstallableScope(pluginScope as PersistablePluginScope)) {
|
||||
const newPending = new Map(pendingToggles);
|
||||
// Omit scope — see handleSingleOperation's enable/disable comment.
|
||||
if (currentPending) {
|
||||
@@ -1579,8 +1580,8 @@ export function ManagePlugins({
|
||||
// is a recovery path for a plugin that failed to load — it may
|
||||
// be reinstallable, so don't nuke ${CLAUDE_PLUGIN_DATA} silently.
|
||||
// The normal uninstall path prompts; this one preserves.
|
||||
const result = isInstallableScope(pluginScope)
|
||||
? await uninstallPluginOp(pluginId, pluginScope, false)
|
||||
const result = isInstallableScope(pluginScope as PersistablePluginScope)
|
||||
? await uninstallPluginOp(pluginId, pluginScope as InstallableScope, false)
|
||||
: await uninstallPluginOp(pluginId, 'user', false);
|
||||
let success = result.success;
|
||||
if (!success) {
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ViewState = any
|
||||
export type PluginSettingsProps = any
|
||||
import type { LocalJSXCommandOnDone } from 'src/types/command.js'
|
||||
|
||||
/**
|
||||
* `/plugin` 根视图在子面板之间的导航状态。
|
||||
* 各分支对应不同子界面或从 CLI 参数解析出的初始路由。
|
||||
*/
|
||||
export type ViewState =
|
||||
| { type: 'menu' } // 返回插件功能总菜单
|
||||
| { type: 'help' } // 展示帮助说明
|
||||
| { type: 'validate'; path?: string } // 校验指定路径下的插件包
|
||||
| {
|
||||
type: 'browse-marketplace' // 在指定市场中浏览/安装插件
|
||||
targetMarketplace: string // 目标市场标识
|
||||
targetPlugin?: string // 可选:预选插件名
|
||||
}
|
||||
| { type: 'discover-plugins'; targetPlugin?: string } // 发现页;可预选搜索插件名
|
||||
| {
|
||||
type: 'manage-plugins' // 已安装插件管理(启用/禁用/卸载)
|
||||
targetPlugin?: string // 可选:聚焦某插件
|
||||
targetMarketplace?: string // 可选:与 targetPlugin 联用的市场
|
||||
action?: 'uninstall' | 'enable' | 'disable' // 可选:打开时直接执行的操作
|
||||
}
|
||||
| { type: 'marketplace-list' } // 列出已配置市场
|
||||
| { type: 'marketplace-menu' } // 市场相关子菜单
|
||||
| { type: 'add-marketplace'; initialValue?: string } // 添加市场;可预填 URL/名称
|
||||
| {
|
||||
type: 'manage-marketplaces' // 管理已保存的市场源
|
||||
targetMarketplace?: string // 可选:聚焦某市场
|
||||
action?: 'remove' | 'update' // 可选:移除或刷新该市场
|
||||
}
|
||||
|
||||
/** `/plugin` Ink 命令入口的 props。 */
|
||||
export type PluginSettingsProps = {
|
||||
onComplete: LocalJSXCommandOnDone // 子流程结束回调(可带结果文案与展示方式)
|
||||
args?: string // CLI 透传的子命令参数字符串
|
||||
showMcpRedirectMessage?: boolean // 从 `/mcp` 跳转时展示 MCP 相关提示
|
||||
}
|
||||
|
||||
@@ -1,2 +1,68 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type UnifiedInstalledItem = any
|
||||
import type {
|
||||
ConfigScope,
|
||||
MCPServerConnection,
|
||||
} from '../../services/mcp/types.js'
|
||||
import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
|
||||
|
||||
import type { PersistablePluginScope } from '../../utils/plugins/pluginIdentifier.js'
|
||||
|
||||
/** 列表项作用域:含 MCP 的 `builtin` 与已下架插件的 `flagged`。 */
|
||||
export type UnifiedInstalledScope = ConfigScope | 'builtin' | 'flagged'
|
||||
|
||||
/** 插件管理列表中 MCP 连接行的连接状态摘要。 */
|
||||
export type McpRowStatus =
|
||||
| 'connected' // 已连接且可用
|
||||
| 'disabled' // 用户或策略禁用
|
||||
| 'pending' // 正在连接或重连
|
||||
| 'needs-auth' // 需 OAuth 等鉴权
|
||||
| 'failed' // 连接或握手失败
|
||||
|
||||
/**
|
||||
* 「已安装」统一列表中的一行:插件、失败占位、下架标记或 MCP 服务器。
|
||||
* 用于分页与键盘导航的同一数据源。
|
||||
*/
|
||||
export type UnifiedInstalledItem =
|
||||
| {
|
||||
type: 'plugin' // 正常加载的插件
|
||||
id: string // `name@marketplace` 唯一键
|
||||
name: string // 插件短名
|
||||
description: string | undefined // manifest 描述
|
||||
marketplace: string // 所属市场
|
||||
scope: PersistablePluginScope | 'builtin' // 安装/展示作用域(内置单独标)
|
||||
isEnabled: boolean // 是否在 merged settings 中启用
|
||||
errorCount: number // 与该插件关联的错误条数
|
||||
errors: PluginError[] // 结构化错误列表
|
||||
plugin: LoadedPlugin // 已解析的 manifest 与路径等
|
||||
pendingEnable?: boolean // UI:等待启用完成
|
||||
pendingUpdate?: boolean // UI:等待更新完成
|
||||
pendingToggle?: 'will-enable' | 'will-disable' // 用户已选、尚未落盘的启用切换
|
||||
}
|
||||
| {
|
||||
type: 'failed-plugin' // 未能加载的插件占位行
|
||||
id: string // 与错误 source 对齐的 id
|
||||
name: string // 展示用名称
|
||||
marketplace: string // 推断或 unknown
|
||||
scope: UnifiedInstalledScope // 推断的安装作用域
|
||||
errorCount: number
|
||||
errors: PluginError[]
|
||||
}
|
||||
| {
|
||||
type: 'flagged-plugin' // 市场已下架但仍出现在设置中的插件
|
||||
id: string
|
||||
name: string
|
||||
marketplace: string
|
||||
scope: 'flagged' // 固定为下架分组
|
||||
reason: string // 下架原因码(如 delisted)
|
||||
text: string // 面向用户的说明文案
|
||||
flaggedAt: string // 标记时间(ISO 等)
|
||||
}
|
||||
| {
|
||||
type: 'mcp' // 独立 MCP 或插件子 MCP 行
|
||||
id: string // 列表稳定 id(如 mcp:name)
|
||||
name: string // 展示名(子 MCP 可为 server 段)
|
||||
description: string | undefined // 可选副标题
|
||||
scope: UnifiedInstalledScope // 来自 server config 或父插件推导
|
||||
status: McpRowStatus // 连接态摘要
|
||||
client: MCPServerConnection // 底层连接对象(供详情/工具视图)
|
||||
indented?: boolean // true 表示挂在某插件下的子 MCP
|
||||
}
|
||||
|
||||
@@ -78,9 +78,6 @@ axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy import after mocks ─────────────────────────────────────────────────
|
||||
// Use the src/ alias path (same canonical key used in launchSchedule.test.ts mock)
|
||||
// so that if launchSchedule.test.ts runs first and replaces the mock, this file's
|
||||
// own beforeAll re-registers the real implementation under that same key.
|
||||
let listTriggers: typeof import('../triggersApi.js').listTriggers
|
||||
let getTrigger: typeof import('../triggersApi.js').getTrigger
|
||||
let createTrigger: typeof import('../triggersApi.js').createTrigger
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchSchedule.ts
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock triggersApi.ts itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Mock auth dependencies so real triggersApi functions can build headers
|
||||
* - Let real triggersApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
@@ -12,8 +31,6 @@ mock.module('src/services/analytics/index.js', () => ({
|
||||
}))
|
||||
|
||||
// ── Cron utility mock ───────────────────────────────────────────────────────
|
||||
// parseCronExpression: returns null if any field is non-numeric/non-wildcard
|
||||
// to simulate real validation; specifically reject expressions with word fields.
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
parseCronExpression: (cron: string) => {
|
||||
const fields = cron.trim().split(/\s+/)
|
||||
@@ -38,43 +55,76 @@ mock.module('src/commands/schedule/ScheduleView.js', () => ({
|
||||
ScheduleView: scheduleViewMock,
|
||||
}))
|
||||
|
||||
// ── triggersApi mock ──────────────────────────────────────────────────────
|
||||
// Use `as unknown as` casts to keep mock type flexible while satisfying strict TS
|
||||
const listTriggersMock = mock(async () => [] as unknown)
|
||||
const getTriggerMock = mock(async () => ({}) as unknown)
|
||||
const createTriggerMock = mock(async () => ({}) as unknown)
|
||||
const updateTriggerMock = mock(async () => ({}) as unknown)
|
||||
const deleteTriggerMock = mock(async () => undefined)
|
||||
const runTriggerMock = mock(async () => ({ run_id: 'run_mock' }) as unknown)
|
||||
|
||||
mock.module('src/commands/schedule/triggersApi.js', () => ({
|
||||
listTriggers: listTriggersMock,
|
||||
getTrigger: getTriggerMock,
|
||||
createTrigger: createTriggerMock,
|
||||
updateTrigger: updateTriggerMock,
|
||||
deleteTrigger: deleteTriggerMock,
|
||||
runTrigger: runTriggerMock,
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-schedule' }),
|
||||
}))
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-schedule',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
getOAuthHeaders: (token: string) => ({
|
||||
Authorization: `Bearer ${token}`,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}),
|
||||
prepareApiRequest: async () => ({
|
||||
accessToken: 'test-token-schedule',
|
||||
orgUUID: 'org-uuid-schedule',
|
||||
}),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy import ─────────────────────────────────────────────────────────────
|
||||
let callSchedule: typeof import('../launchSchedule.js').callSchedule
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchSchedule.js')
|
||||
callSchedule = mod.callSchedule
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
function makeOnDone() {
|
||||
return mock(() => {})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listTriggersMock.mockClear()
|
||||
getTriggerMock.mockClear()
|
||||
createTriggerMock.mockClear()
|
||||
updateTriggerMock.mockClear()
|
||||
deleteTriggerMock.mockClear()
|
||||
runTriggerMock.mockClear()
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
scheduleViewMock.mockClear()
|
||||
})
|
||||
|
||||
@@ -91,10 +141,10 @@ describe('callSchedule: invalid args', () => {
|
||||
|
||||
describe('callSchedule: list', () => {
|
||||
test('list returns empty triggers', async () => {
|
||||
listTriggersMock.mockResolvedValueOnce([])
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'list')
|
||||
expect(listTriggersMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/no scheduled triggers/i)
|
||||
})
|
||||
@@ -108,7 +158,10 @@ describe('callSchedule: list', () => {
|
||||
prompt: 'daily',
|
||||
},
|
||||
]
|
||||
listTriggersMock.mockResolvedValueOnce(triggers)
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: triggers },
|
||||
status: 200,
|
||||
})
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, '')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -116,7 +169,7 @@ describe('callSchedule: list', () => {
|
||||
})
|
||||
|
||||
test('list API error → error view', async () => {
|
||||
listTriggersMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'list')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -132,16 +185,16 @@ describe('callSchedule: get', () => {
|
||||
enabled: true,
|
||||
prompt: 'test',
|
||||
}
|
||||
getTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosGetMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'get trg_get')
|
||||
expect(getTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getTriggerMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('trg_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0] as string).toContain('trg_get')
|
||||
})
|
||||
|
||||
test('get API error → error message', async () => {
|
||||
getTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'get trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -157,10 +210,10 @@ describe('callSchedule: create', () => {
|
||||
enabled: true,
|
||||
prompt: 'daily report',
|
||||
}
|
||||
createTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * * daily report')
|
||||
expect(createTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/trigger created/i)
|
||||
})
|
||||
@@ -169,12 +222,12 @@ describe('callSchedule: create', () => {
|
||||
const onDone = makeOnDone()
|
||||
// 4 fields only — invalid
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * report only')
|
||||
// createTrigger should not be called
|
||||
expect(createTriggerMock).not.toHaveBeenCalled()
|
||||
// axios.post should not be called
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('create API error → error message', async () => {
|
||||
createTriggerMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * * test prompt')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -190,14 +243,16 @@ describe('callSchedule: update', () => {
|
||||
enabled: false,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'update trg_upd enabled false')
|
||||
expect(updateTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[0]).toContain('trg_upd')
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: false })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/updated/i)
|
||||
@@ -206,7 +261,7 @@ describe('callSchedule: update', () => {
|
||||
test('update with unknown field → error without API call', async () => {
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'update trg_upd foofield bar')
|
||||
expect(updateTriggerMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/unknown field/i)
|
||||
})
|
||||
@@ -214,16 +269,16 @@ describe('callSchedule: update', () => {
|
||||
|
||||
describe('callSchedule: delete', () => {
|
||||
test('delete calls deleteTrigger', async () => {
|
||||
deleteTriggerMock.mockResolvedValueOnce(undefined)
|
||||
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'delete trg_del')
|
||||
expect(deleteTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/deleted/i)
|
||||
})
|
||||
|
||||
test('delete API error → error message', async () => {
|
||||
deleteTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'delete trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -233,16 +288,21 @@ describe('callSchedule: delete', () => {
|
||||
|
||||
describe('callSchedule: run', () => {
|
||||
test('run fires trigger and returns run_id', async () => {
|
||||
runTriggerMock.mockResolvedValueOnce({ run_id: 'run_xyz' })
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_xyz' },
|
||||
status: 200,
|
||||
})
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'run trg_fire')
|
||||
expect(runTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosPostMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0] as string).toMatch(/\/run$/)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/run_xyz/)
|
||||
})
|
||||
|
||||
test('run API error → error message', async () => {
|
||||
runTriggerMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'run trg_fire')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -258,12 +318,13 @@ describe('callSchedule: enable / disable', () => {
|
||||
enabled: true,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'enable trg_en')
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: true })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -277,12 +338,13 @@ describe('callSchedule: enable / disable', () => {
|
||||
enabled: false,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'disable trg_dis')
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: false })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -290,7 +352,7 @@ describe('callSchedule: enable / disable', () => {
|
||||
})
|
||||
|
||||
test('enable API error → error message', async () => {
|
||||
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'enable trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -298,7 +360,7 @@ describe('callSchedule: enable / disable', () => {
|
||||
})
|
||||
|
||||
test('disable API error → error message', async () => {
|
||||
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'disable trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type FeedbackSurveyResponse = any
|
||||
export type FeedbackSurveyType = any
|
||||
/** 会话内满意度调查的选项(与数字键 0–3 映射一致)。 */
|
||||
export type FeedbackSurveyResponse =
|
||||
| 'dismissed' // 0:关闭不反馈
|
||||
| 'bad' // 1:不满意
|
||||
| 'fine' // 2:一般
|
||||
| 'good' // 3:满意
|
||||
|
||||
/** 调查场景;当前仅实现会话级提示。 */
|
||||
export type FeedbackSurveyType = 'session' // 主会话 Spinner/流程内触发
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import chalk from 'chalk';
|
||||
import { SentryErrorBoundary } from './SentryErrorBoundary.js';
|
||||
import type { UUID } from 'crypto';
|
||||
import type { RefObject } from 'react';
|
||||
import * as React from 'react';
|
||||
@@ -890,7 +891,7 @@ const MessagesImpl = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SentryErrorBoundary name="MessagesBoundary">
|
||||
{/* Logo */}
|
||||
{!hideLogo && !(renderRange && renderRange[0] > 0) && <LogoHeader agentDefinitions={agentDefinitions} />}
|
||||
|
||||
@@ -977,7 +978,7 @@ const MessagesImpl = ({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</SentryErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { captureException } from 'src/utils/sentry.js'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
/** Optional label for identifying which component boundary caught the error */
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
export class SentryErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
captureException(error, {
|
||||
componentBoundary: this.props.name || 'SentryErrorBoundary',
|
||||
componentStack: errorInfo.componentStack,
|
||||
})
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
62
src/components/SentryErrorBoundary.tsx
Normal file
62
src/components/SentryErrorBoundary.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { captureException } from 'src/utils/sentry.js';
|
||||
import { logError } from 'src/utils/log.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** Optional label for identifying which component boundary caught the error */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class SentryErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Pick<State, 'hasError' | 'error'> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
this.setState({ errorInfo });
|
||||
|
||||
// Log to stderr so the diagnostic info is visible even in production builds
|
||||
const boundary = this.props.name || 'SentryErrorBoundary';
|
||||
const lines = ['', `[ErrorBoundary:${boundary}] React rendering error caught`, ` Message: ${error.message}`];
|
||||
if (errorInfo.componentStack) {
|
||||
lines.push(` Component stack:\n${errorInfo.componentStack}`);
|
||||
}
|
||||
// eslint-disable-next-line no-console -- intentional stderr diagnostic output
|
||||
console.error(lines.join('\n'));
|
||||
|
||||
logError(error);
|
||||
captureException(error, {
|
||||
componentBoundary: boundary,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
<Text color="error" bold>
|
||||
React Rendering Error
|
||||
</Text>
|
||||
<Text color="error">{this.state.error?.message}</Text>
|
||||
{this.props.name && <Text dimColor>Boundary: {this.props.name}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js';
|
||||
import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js';
|
||||
import { useSettings } from '../hooks/useSettings.js';
|
||||
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js';
|
||||
import { isLocalAgentTask } from '../tasks/LocalAgentTask/LocalAgentTask.js';
|
||||
import { isBackgroundTask } from '../tasks/types.js';
|
||||
import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
|
||||
import { getEffortSuffix } from '../utils/effort.js';
|
||||
@@ -209,15 +210,22 @@ function SpinnerWithVerbInner({
|
||||
const hasRunningTeammates = runningTeammates.length > 0;
|
||||
const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle);
|
||||
|
||||
// Gather aggregate token stats from all running swarm teammates
|
||||
// In spinner-tree mode, skip aggregation (teammates have their own lines in the tree)
|
||||
// Gather aggregate token stats from all running agents.
|
||||
// In spinner-tree mode, skip in-process teammates (they have their own
|
||||
// per-teammate lines in the tree) but still count local-agent tasks
|
||||
// (background agents) which have no dedicated tree rows.
|
||||
let teammateTokens = 0;
|
||||
if (!showSpinnerTree) {
|
||||
for (const task of Object.values(tasks)) {
|
||||
if (isInProcessTeammateTask(task) && task.status === 'running') {
|
||||
if (task.progress?.tokenCount) {
|
||||
teammateTokens += task.progress.tokenCount;
|
||||
}
|
||||
for (const task of Object.values(tasks)) {
|
||||
if (task.status !== 'running') continue;
|
||||
if (isInProcessTeammateTask(task)) {
|
||||
if (!showSpinnerTree && task.progress?.tokenCount) {
|
||||
teammateTokens += task.progress.tokenCount;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isLocalAgentTask(task)) {
|
||||
if (task.progress?.tokenCount) {
|
||||
teammateTokens += task.progress.tokenCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type SpinnerMode = any
|
||||
export type RGBColor = any
|
||||
/** 主循环/流式输出旁路指示器所处的交互阶段。 */
|
||||
export type SpinnerMode =
|
||||
| 'tool-input' // 等待用户对工具输入的响应
|
||||
| 'tool-use' // 工具执行中
|
||||
| 'responding' // 模型正在输出回复
|
||||
| 'thinking' // 模型思考/规划(不区分 provider 细节)
|
||||
| 'requesting' // 请求已发出、等待首包(含 shimmer 较快节奏)
|
||||
|
||||
/** 终端 24 位色(与 Ink `RGBColor` 及渐变插值工具一致)。 */
|
||||
export type RGBColor = {
|
||||
r: number // 红 0–255
|
||||
g: number // 绿 0–255
|
||||
b: number // 蓝 0–255
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user