Compare commits

...

45 Commits

Author SHA1 Message Date
claude-code-best
58c3feb56a chore: 修复 publish 问题 2026-05-20 10:06:44 +08:00
claude-code-best
e2f4d558e1 Revert "fix: bun publish 通过 ~/.npmrc 配置 registry 认证"
This reverts commit 9afcb398ca.
2026-05-20 10:05:38 +08:00
claude-code-best
9afcb398ca fix: bun publish 通过 ~/.npmrc 配置 registry 认证 2026-05-20 09:34:59 +08:00
claude-code-best
c80a6d062b chore: 切换到 bun publish,修复 husky 路径问题,调整 diff 折叠距离,导出 VoiceContext
- publish-npm.yml: npm publish → bun publish,移除 setup-node,使用 BUN_CONFIG_TOKEN
- package.json: prepare 脚本 husky → bunx husky,版本 2.4.4 → 2.4.5
- Messages.tsx: DIFF_COLLAPSE_DISTANCE 从 0 改为 3,避免 diff 过度折叠
- voice.tsx: 导出 VoiceContext
2026-05-20 09:25:22 +08:00
claude-code-best
a05242cef0 fix: 明确告知 agent SearchExtraTools/ExecuteExtraTool 是核心工具,已在工具列表中
- prompts.ts: 核心工具列表显式加入 SearchExtraTools, ExecuteExtraTool
- claude.ts: 非 delta 路径提示强调这两个工具可直接调用
- messages.ts: delta 路径渲染强调这两个工具已在工具列表中
- SearchExtraToolsTool/prompt.ts: 加入完整两步工作流示例
- ExecuteTool/prompt.ts: 加入完整两步工作流示例
2026-05-19 23:03:46 +08:00
claude-code-best
27b334aceb fix: 防止 MCP 工具调用失败后的 SearchExtraTools/ExecuteExtraTool 死循环 2026-05-19 23:03:46 +08:00
xiaoFjun-eng
27b665ac79 Fix type (#1242)
* 完善所有用到的type对象,并添加中文注释

* 补充遗失的type

* 修复claude-for-chrome-mcp中的type和interface类型缺失

* 完善注释
2026-05-19 15:04:59 +08:00
xiaoFjun-eng
ea399f1862 Fix type (#1239)
* 完善所有用到的type对象,并添加中文注释

* 补充遗失的type
2026-05-19 09:05:04 +08:00
claude-code-best
c499bfb4ed fix: 修复 voice provider 的问题 2026-05-18 22:54:11 +08:00
18243133
b67e9f9d38 Fix/plan paste fixes (#1238)
* fix: 降低 paste 检测阈值,修复非 bracketed-paste 终端粘贴文本损坏

非 bracketed-paste 终端下,短粘贴(<800 chars)的 stdin chunk 作为独立
keystroke 走 useTextInput.onInput 路径,闭包中 cursor 未刷新导致多次插入
竞态。现将 ≥3 字符的非特殊键输入纳入 paste 累积模式,绕过逐 chunk 处理。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* @
fix: Plan模式三处缺陷修复 — ExploreAgent可用性 + 弹窗一致性 + 方案文件保护

1. areExplorePlanAgentsEnabled()移除GrowthBook A/B实验依赖(tengu_amber_stoat),
   始终返回true,确保Explore/Plan agent在BUILTIN_EXPLORE_PLAN_AGENTS开启时始终可用

2. ExitPlanMode clear-context路径补setNeedsPlanModeExitAttachment(true),
   确保清除上下文退出Plan模式后生成plan_mode_exit附件

3. Plan mode full/sparse指令强化Plan文件读取要求:
   "can read" -> "MUST use FileRead to read first before any changes",
   新增"do NOT overwrite"禁止覆盖,Phase 1指令强化并行Explore Agent引导

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@

---------

Co-authored-by: psj88520 <qq18243133@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:57:15 +08:00
claude-code-best
2bca31e525 docs: update contributors 2026-05-18 00:20:32 +00:00
claude-code-best
2cc9a7daef Revert "feat: 添加 /goal 命令,支持长时间运行任务的目标管理 (#1222)" (#1236)
This reverts commit d66a6f6124.
2026-05-17 10:06:09 +08:00
Fearless
d66a6f6124 feat: 添加 /goal 命令,支持长时间运行任务的目标管理 (#1222)
* feat: 添加 /goal 命令,支持长时间运行任务的目标管理

从 Codex 项目移植 /goal 命令到 Claude Code,实现:
- Goal 状态管理模块(active/paused/budget_limited/complete)
- /goal 斜杠命令(set/clear/pause/resume/complete)
- Goal 模型工具(get/set/complete)
- Continuation prompt 自动注入系统提示
- Token 用量自动追踪

Co-Authored-By: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>

* fix: goal 状态改为 session-scoped,避免多会话泄漏

将 currentGoal 单例替换为 Map<string, GoalState>,按 sessionId 隔离,
遵循 sessionIngress.ts 的模式。所有函数支持可选 sessionId 参数。

Co-Authored-By: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>

* fix: 对 goal 的 tokenBudget/tokensUsed 添加数值校验

setGoal 中 tokenBudget 非 finite 或负数时归零;
updateGoalTokens 中 usage 非 finite 或负数时归零。

Co-Authored-By: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>

* fix: 暂停期间 goal 时间不再继续计数

新增 pausedAt/accumulatedActiveMs 字段,pauseGoal 累积已活跃时间,
resumeGoal 重置 startTime,计时统一使用 getActiveElapsedMs()。

Co-Authored-By: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>

---------

Co-authored-by: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>
2026-05-17 10:05:46 +08:00
Cepvor
48a19b8a0d fix: isUsing3PServices 检查所有非 Anthropic provider (#1235)
原实现仅检查 Bedrock/Vertex/Foundry,遗漏了 OpenAI、Gemini、Grok
三个通过 CLAUDE_CODE_USE_* 环境变量切换的第三方 provider。

这导致:
- 命令可用性判定中 OpenAI/Gemini/Grok 用户被错误识别为 console 用户
  (/fast、/install-github-app 两个 Anthropic 专有命令误显示)
- auth status 显示中这些用户被误报为"未登录"

Co-authored-by: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-17 07:29:14 +08:00
Cepvor
5157b09743 feat: Grok 适配完善 — 防御性 usage 合并 + thinking 自动检测 (#1234)
* feat: Grok 适配完善 — 防御性 usage 合并 + thinking 自动检测

1. 提取 updateOpenAIUsage 到共享模块 openaiShared.ts,供 OpenAI 和
   Grok 两条路径复用,消除 Grok 中重复的 spread 漏洞。

2. 在 requestBody.ts 的 isOpenAIThinkingEnabled() 中增加 Grok 模型
   自动检测(模型名含 "grok"),与 DeepSeek/MiMo 并列。

3. messaging 层的 reasoning_content 回传(openaiConvertMessages.ts)
   和流解析(openaiStreamAdapter.ts)无需修改,Grok 与 DeepSeek/MiMo
   共用相同的 reasoning_content 字段协议。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

* fix: 回退 Grok 从 isOpenAIThinkingEnabled 的自动检测

Grok 推理模型(如 grok-4.20-reasoning)自动进行推理,不需要
thinking/enable_thinking 请求参数。发送这些参数虽大概率被忽略
(OpenAI SDK 透传 unknown keys),但属于不正确行为。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

---------

Co-authored-by: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-17 07:28:33 +08:00
Cepvor
ecd3f9d791 fix: Gemini 适配器补全 usage 字段映射 (#1233)
* fix: Gemini 适配器补全 usage 字段映射

Gemini API 的 usageMetadata 包含 cachedContentTokenCount 字段,
但此前未映射到 Anthropic 格式的 cache_read_input_tokens,导致
cache_creation_input_tokens 和 cache_read_input_tokens 始终为 0。

同时 message_delta 事件此前只携带 output_tokens,缺失
input_tokens、cache_creation_input_tokens、cache_read_input_tokens,
导致下游从 message_delta 读取最终 token 计数时获取不完整数据。

修复:
- 新增 cachedContentTokenCount → cache_read_input_tokens 映射
- message_start 和 message_delta 携带完整四个 usage 字段
- cache_creation_input_tokens 保持为 0(Gemini API 无等价概念)

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

* fix: 添加 cachedContentTokenCount 到 GeminiUsageMetadata 类型

streamAdapter.ts 使用 usage.cachedContentTokenCount 但该字段未
在 GeminiUsageMetadata 类型中声明。CodeRabbit 审查发现此问题。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

---------

Co-authored-by: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-17 07:28:16 +08:00
claude-code-best
5b941d4ad4 chore: 2.4.4 2026-05-16 09:12:11 +08:00
claude-code-best
ae7a4e5ae5 fix: CI 中跳过 AutofixProgress 测试(Ink waitUntilExit 在无 TTY 环境下挂起)
Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-16 09:07:08 +08:00
claude-code-best
e5f31afebd fix: ExecuteExtraTool 委托执行前增加 validateInput 校验,优化工具显示样式
- 在 call() 中 checkPermissions 之前调用目标工具的 validateInput(),防止模型传入不完整参数导致崩溃(如 TeamCreate 缺少 team_name → sanitizeName(undefined).replace() TypeError)
- renderToolUseMessage 从 "Executing TeamCreate..." 简化为 "TeamCreate",与其他工具样式一致

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-16 08:47:05 +08:00
claude-code-best
fc8d531a7d fix: 将 ExecuteExtraTool 加入 ASYNC_AGENT_ALLOWED_TOOLS 允许子代理执行延迟工具 2026-05-16 08:28:43 +08:00
Cepvor
835dd2d804 fix: 为 sessionStorage existingSessionFiles Map 添加容量上限 (#1227)
* fix: 修复子代理 token 消耗在主 spinner 中始终显示为 0

Spinner.tsx 的 token 聚合循环仅统计 in_process_teammate 类型任务,
漏掉了 local_agent(后台代理/verification agent)类型。当后台代理
运行时,主界面 spinner 一直显示 "↓ 0 tokens",因为 background agent
的 token 消耗未被纳入 teammateTokens 聚合。

同时在 inProcessRunner.ts 中,进程内队友完成时计算并设置 result
(含 totalTokens/totalToolUseCount/content/usage),使详情弹窗可以
正确展示累计 token 消耗,不再仅依赖 progress.tokenCount 间歇更新。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

* fix: 为 cacheWarningStateBySource Map 设置上限防止内存泄漏

Map 以 querySource 为 key 存储每个来源的缓存命中率历史状态,
但 querySource 类型为 `any`,长时间会话中可能产生大量唯一值,
Map 持续增长永不清理。

新增 MAX_SOURCE_ENTRIES = 50 上限,新增条目时若达到上限则
逐出最早插入的条目(Map 按插入顺序迭代)。

同时也新增 _resetCacheWarningStateForTest() 用于测试隔离。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

* fix: 为 sessionStorage existingSessionFiles Map 添加容量上限

existingSessionFiles Map 缓存 sessionId → 文件路径映射以避免重复
stat 调用,但在 coordinator/swarm 模式下,每个子代理产生独立
sessionId,长时间运行的 daemon 会话可能累积数千条目。

新增 MAX_CACHED_SESSION_FILES = 200 上限,新增条目时若达到上限则
逐出最早插入的条目。同时在 _resetFlushState() 中清除此缓存以保证
测试隔离。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

---------

Co-authored-by: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-16 08:09:40 +08:00
claude-code-best
0face46fbe Merge pull request #1228 from Evsdrg/fix/spinner-tree-local-agent-tokens
fix: showSpinnerTree 模式下保留 local-agent token 显示
2026-05-14 23:00:43 +08:00
claude-code-best
d451e30741 Merge pull request #1226 from Evsdrg/feat/mimo-thinking-support
feat: 添加 MiMo 模型 thinking mode 自动检测与兼容
2026-05-14 23:00:01 +08:00
cepvor
e7070e072f fix: showSpinnerTree 模式下保留 local-agent token 显示
PR #1226 的 CodeRabbit 审查指出:当 spinner-tree 模式开启时,
local-agent(后台代理)的 token 消耗完全不可见,因为它们没有
在树中有独立行,但被 showSpinnerTree 的 guard 排除了。

修复:将 guard 从循环外移到循环内,仅对 in_process_teammate
任务在 tree 模式下跳过(它们有独立树行),local-agent 任务
始终计入 teammateTokens。

Closes: review comment from PR #1226 (originally belongs to PR #1225)

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-14 20:45:24 +08:00
cepvor
833181e025 feat: 添加 MiMo 模型 thinking mode 自动检测与兼容
isOpenAIThinkingEnabled() 现在自动检测模型名包含 "mimo" 的模型
(与 DeepSeek 并列),因为 MiMo 同样使用 reasoning_content 字段
且支持 thinking mode。

buildOpenAIRequestBody() 在 chat_template_kwargs 中同时发送
thinking: true 和 enable_thinking: true,兼容 DeepSeek 自托管和
MiMo 的 thinking 启用格式。

已有 reasoning_content 回传逻辑(openaiConvertMessages.ts)和流
解析逻辑(openaiStreamAdapter.ts)无需修改,MiMo 与 DeepSeek 共用
相同的 reasoning_content 字段协议。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-14 20:17:48 +08:00
claude-code-best
80b46d2221 Merge pull request #1225 from Evsdrg/main
fix: 修复子代理 token 显示为 0 + cacheWarningStateBySource Map 内存泄漏
2026-05-14 20:05:26 +08:00
claude-code-best
78d46aa233 fix: 替换 extractMemories 的 require() 为动态 import() 修复 Vite 构建崩溃
Vite/Rollup 构建时将 require() 通过 __toCommonJS() 包装 ESM 导出,
导致命名导出被包裹在 { default: namespace } 中,访问
extractMemoriesModule.initExtractMemories 为 undefined 触发 React
error boundary 崩溃。改用标准 ESM 动态 import() 绕过 CJS interop。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-14 17:44:47 +08:00
cepvor
b3d28bcdf1 fix: 为 cacheWarningStateBySource Map 设置上限防止内存泄漏
Map 以 querySource 为 key 存储每个来源的缓存命中率历史状态,
但 querySource 类型为 `any`,长时间会话中可能产生大量唯一值,
Map 持续增长永不清理。

新增 MAX_SOURCE_ENTRIES = 50 上限,新增条目时若达到上限则
逐出最早插入的条目(Map 按插入顺序迭代)。

同时也新增 _resetCacheWarningStateForTest() 用于测试隔离。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-14 16:05:16 +08:00
cepvor
1f80043928 fix: 修复子代理 token 消耗在主 spinner 中始终显示为 0
Spinner.tsx 的 token 聚合循环仅统计 in_process_teammate 类型任务,
漏掉了 local_agent(后台代理/verification agent)类型。当后台代理
运行时,主界面 spinner 一直显示 "↓ 0 tokens",因为 background agent
的 token 消耗未被纳入 teammateTokens 聚合。

同时在 inProcessRunner.ts 中,进程内队友完成时计算并设置 result
(含 totalTokens/totalToolUseCount/content/usage),使详情弹窗可以
正确展示累计 token 消耗,不再仅依赖 progress.tokenCount 间歇更新。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-14 15:40:28 +08:00
claude-code-best
3d7b32f52e Merge pull request #1117 from xuzhongpeng/fix/acp-session-id-alignment
fix(acp): 对齐 ACP session ID 与全局会话状态
2026-05-12 19:28:27 +08:00
xuzhongpeng.xzp
2c8a22d4b3 fix(acp): 对齐 ACP session ID 与全局会话状态
在 newSession/resumeSession/loadSession 中调用 switchSession,
确保 transcript 持久化、analytics 与 cost tracking 使用 ACP session ID,
而非内部默认 session ID。

- newSession 生成 sessionId 后立即对齐全局状态
- resumeSession 命中 fingerprint 缓存路径也对齐
- loadSession 在 sessionIdExists() 检查前对齐(lookup 依赖 getSessionId)
- 补充 5 个测试覆盖上述路径,以及 prompt 不触发额外 switchSession
2026-05-12 19:03:27 +08:00
claude-code-best
ea5147420d fix: 删除 issues 测试用例导致真提交了 2026-05-12 17:47:08 +08:00
claude-code-best
3d0f1acfb7 docs: 添加 GitHub Issue 模板,规范 Issue 提交流程
禁用空白 issue,添加 Bug 报告和功能建议两个中文模板,
引导不相关问题前往 Discussions。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-12 16:39:45 +08:00
claude-code-best
478091567d chore: 2.4.3 2026-05-12 16:29:47 +08:00
claude-code-best
b4e52d0c9e fix: 拦截 ExecuteExtraTool 直接调用未搜索的延迟工具
模型在未通过 SearchExtraTools 发现工具的情况下直接调用 ExecuteExtraTool,
因不知道工具 schema 导致参数错误(如 libraryName: undefined)。

双重修复:
1. ExecuteTool.call() 添加服务端拦截:检查目标 deferred 工具是否已被发现
2. 更新 <available-deferred-tools> 系统提示:要求先搜索再执行

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-12 16:28:34 +08:00
claude-code-best
d11b35e023 chore: 2.4.2 2026-05-11 20:37:53 +08:00
claude-code-best
8570b6ba01 chore: 2.4.1 2026-05-11 10:27:53 +08:00
claude-code-best
db606b5589 docs: update contributors 2026-05-11 01:54:30 +00:00
claude-code-best
27a01113e4 fix: 修复 CI 中 10 个测试的 Bun mock.module 跨文件污染
- thinking.test.ts: 补 envUtils.js 防御性 mock,提供 isEnvDefinedFalsy
  和 isEnvTruthy 正确实现,覆盖 6 个其他测试文件不完整 mock 导致的污染
- launchLocalVault.test.ts: 补 keychain.js 防御性 mock,强制抛
  KeychainUnavailableError 使 store 走文件回退路径,覆盖 store.test.ts
  和 keychain.test.ts 的 mock 残留

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-11 09:38:13 +08:00
claude-code-best
4a39fd74b1 fix: 修复 CI test 阶段测试失败时不退出的 bug
将 `|| true` 替换为 `set -o pipefail`,使管道中 bun test 的非零退出码能正确传播,CI 在测试失败时正确报错。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-11 09:18:09 +08:00
claude-code-best
5486d3c02c fix: 修复 Bun mock.module 跨文件污染导致 87 个测试失败
- 重写 setupAxiosMock 使其完全 per-file 独立,消除共享 handles 数组的竞态
- 将 launchSchedule/launchMemoryStores/launchAgentsPlatform 从直接 mock
  源 API 模块改为 mock axios 底层 HTTP 层,避免污染同目录 api.test.ts
- 删除两个 Ink waitUntilExit 超时测试文件
- 修复 hostGuard/keychain 跨文件 mock 污染
- 清理 api.test.ts 中的 require() workaround
- 在 CLAUDE.md 记录 mock 污染排查经验

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-11 08:50:03 +08:00
claude-code-best
aaabf0c168 Revert "feat: 添加 GBK 编码自动检测支持,文件读写工具透明处理非 UTF-8 文件"
This reverts commit 0ce8f7a1cb.
2026-05-10 22:57:30 +08:00
claude-code-best
43c20a43c2 Revert "fix: 修复非 UTF-8 编码文件读写 round-trip 字节损坏"
This reverts commit 17c06690d8.
2026-05-10 22:57:25 +08:00
claude-code-best
17c06690d8 fix: 修复非 UTF-8 编码文件读写 round-trip 字节损坏
GBK 文件编辑后被错误写入为 UTF-8(Buffer.from 不支持 gbk 编码,
encodeString 静默 fallback),latin1/ANSI 文件 0x80-0x9F 范围字节因
TextDecoder('latin1') 与 Buffer.from('latin1') 编解码不对称而被篡改。

修复:latin1 解码改用严格 ISO-8859-1 映射保证与 Buffer.from 对称;
GBK 编码通过 TextDecoder 反向构建查找表实现零依赖编码器。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-10 22:08:52 +08:00
claude-code-best
89800137b6 fix: 修复 issue-template 测试误删 .github/workflows 目录
afterEach 清理时 rmSync 误删了整个 .github/ 目录(含 workflows),
改为只删测试创建的 ISSUE_TEMPLATE 子目录。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-10 21:11:50 +08:00
156 changed files with 2685 additions and 4212 deletions

52
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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
View 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 和相关文档,你的问题可能已经有答案了。

View 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)。
---
## 要解决的问题
<!-- 这个功能解决什么问题?为什么需要它? -->
## 建议方案
<!-- 描述你建议的实现方式,尽量简洁具体。 -->
## 考虑过的替代方案
<!-- 还有没有想到的其他实现思路? -->
## 补充信息
<!-- 截图、草图、参考资料,或其他有助于说明需求的内容。 -->

View File

@@ -42,7 +42,8 @@ jobs:
run: | run: |
# Tolerate pre-existing flaky tests (Bun mock pollution / order-dependent state). # Tolerate pre-existing flaky tests (Bun mock pollution / order-dependent state).
# We still require lcov.info to be generated and contain real coverage data. # 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 test -s coverage/lcov.info
grep -q '^SF:' coverage/lcov.info grep -q '^SF:' coverage/lcov.info

View File

@@ -3,11 +3,11 @@ name: Publish to npm
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: '版本号 (例如: v1.9.0)' description: "版本号 (例如: v1.9.0)"
required: true required: true
type: string type: string
@@ -24,11 +24,6 @@ jobs:
with: with:
ref: ${{ github.event.inputs.version || github.ref }} ref: ${{ github.event.inputs.version || github.ref }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6, 2026-04-25
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
with: with:
@@ -43,9 +38,9 @@ jobs:
run: bun test run: bun test
- name: Publish to npm - name: Publish to npm
run: npm publish --provenance --access public run: bun publish -p --access public
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Generate changelog - name: Generate changelog
id: changelog id: changelog

View File

@@ -119,11 +119,6 @@ bun run docs:dev
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。 - **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
- Provider selection in `src/utils/model/providers.ts`。优先级modelType 参数 > 环境变量 > 默认 firstParty。 - Provider selection in `src/utils/model/providers.ts`。优先级modelType 参数 > 环境变量 > 默认 firstParty。
### Encoding Detection
- **`src/utils/encoding.ts`** — 文件编码检测的唯一入口。提供 `detectEncoding`三层检测BOM → UTF-8 fatal → ICU 回退链)和 `decodeBuffer`/`encodeString` 函数。检测基于文件头部 4KB零外部依赖仅使用 TextDecoder API。ISO-8859-1 作为最终兜底编码(单字节编码永远成功)。`FileEncoding` 类型扩展了 `BufferEncoding`,覆盖 gbk/gb18030/shift_jis/euc-kr/euc-jp/big5/iso-8859-1。
- `fs.readFileSync(path, { encoding })``encoding` 选项只接受 `BufferEncoding`,不支持 `gbk`/`shift_jis` 等 ICU 编码名。读取非 UTF-8 文件时必须先 `fs.readFileSync(path)` 读 Buffer再用 `TextDecoder` 解码。项目中所有文件读取路径fileRead.ts、fileReadCache.ts、file.ts已统一使用 `decodeBuffer` 函数处理此逻辑。
### Tool System ### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`). - **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
@@ -319,6 +314,48 @@ mock.module("src/utils/debug.ts", debugMock);
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。 路径规则:统一用 `.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 必须零错误**。每次修改后运行: 项目使用 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

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "2.4.0", "version": "2.4.5",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module", "type": "module",
"author": "claude-code-best <claude-code-best@proton.me>", "author": "claude-code-best <claude-code-best@proton.me>",
@@ -53,7 +53,7 @@
"format": "biome format --write .", "format": "biome format --write .",
"check": "biome check .", "check": "biome check .",
"check:fix": "biome check --fix .", "check:fix": "biome check --fix .",
"prepare": "husky", "prepare": "bunx husky",
"test": "bun test", "test": "bun test",
"test:production": "bun run scripts/production-test.ts", "test:production": "bun run scripts/production-test.ts",
"test:production:offline": "bun run scripts/production-test.ts --offline", "test:production:offline": "bun run scripts/production-test.ts --offline",

View File

@@ -9,6 +9,7 @@ import { SocketConnectionError } from './mcpSocketClient.js'
import { import {
localPlatformLabel, localPlatformLabel,
type BridgePermissionRequest, type BridgePermissionRequest,
toLoggerDetail,
type ChromeExtensionInfo, type ChromeExtensionInfo,
type ClaudeForChromeContext, type ClaudeForChromeContext,
type PermissionMode, type PermissionMode,
@@ -578,7 +579,7 @@ export class BridgeClient implements SocketClient {
const durationMs = Date.now() - this.connectionStartTime const durationMs = Date.now() - this.connectionStartTime
logger.error( logger.error(
`[${serverName}] Failed to create WebSocket after ${durationMs}ms:`, `[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
error, toLoggerDetail(error),
) )
trackEvent?.('chrome_bridge_connection_failed', { trackEvent?.('chrome_bridge_connection_failed', {
duration_ms: durationMs, duration_ms: durationMs,
@@ -618,7 +619,10 @@ export class BridgeClient implements SocketClient {
) )
this.handleMessage(message) this.handleMessage(message)
} catch (error) { } 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) const allowed = await pending.onPermissionRequest(request)
this.sendPermissionResponse(requestId, allowed) this.sendPermissionResponse(requestId, allowed)
} catch (error) { } catch (error) {
logger.error(`[${serverName}] Error handling permission request:`, error) logger.error(
`[${serverName}] Error handling permission request:`,
toLoggerDetail(error),
)
this.sendPermissionResponse(requestId, false) this.sendPermissionResponse(requestId, false)
} }
} }

View File

@@ -8,8 +8,11 @@ export { localPlatformLabel } from './types.js'
export type { export type {
BridgeConfig, BridgeConfig,
ChromeExtensionInfo, ChromeExtensionInfo,
ChromeBridgeTrackEventMetadata,
ClaudeForChromeContext, ClaudeForChromeContext,
Logger, Logger,
LoggerDetail,
PermissionMode, PermissionMode,
SocketClient, SocketClient,
} from './types.js' } from './types.js'
export { toLoggerDetail } from './types.js'

View File

@@ -9,6 +9,7 @@ import type {
PermissionMode, PermissionMode,
PermissionOverrides, PermissionOverrides,
} from './types.js' } from './types.js'
import { toLoggerDetail } from './types.js'
export class SocketConnectionError extends Error { export class SocketConnectionError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -87,7 +88,10 @@ class McpSocketClient {
await this.validateSocketSecurity(socketPath) await this.validateSocketSecurity(socketPath)
} catch (error) { } catch (error) {
this.connecting = false 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 // Don't retry on security failures (wrong perms/owner) - those won't
// self-resolve. Only the error handler retries on transient errors. // self-resolve. Only the error handler retries on transient errors.
return return
@@ -145,14 +149,20 @@ class McpSocketClient {
logger.info(`[${serverName}] Received unknown message: ${message}`) logger.info(`[${serverName}] Received unknown message: ${message}`)
} }
} catch (error) { } 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 }) => { this.socket.on('error', (error: Error & { code?: string }) => {
clearTimeout(connectTimeout) 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.connected = false
this.connecting = false this.connecting = false

View File

@@ -7,6 +7,7 @@ import type {
PermissionOverrides, PermissionOverrides,
SocketClient, SocketClient,
} from './types.js' } from './types.js'
import { toLoggerDetail } from './types.js'
export const handleToolCall = async ( export const handleToolCall = async (
context: ClaudeForChromeContext, context: ClaudeForChromeContext,
@@ -44,7 +45,10 @@ export const handleToolCall = async (
return handleToolCallDisconnected(context) return handleToolCallDisconnected(context)
} catch (error) { } 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) { if (error instanceof SocketConnectionError) {
return handleToolCallDisconnected(context) return handleToolCallDisconnected(context)
@@ -165,8 +169,7 @@ async function handleToolCallConnected(
// Fallback for unexpected result format // Fallback for unexpected result format
context.logger.warn( context.logger.warn(
`[${context.serverName}] Unexpected result format from socket bridge`, `[${context.serverName}] Unexpected result format from socket bridge: ${JSON.stringify(response)}`,
response,
) )
return { return {

View File

@@ -1,11 +1,84 @@
export interface Logger { /**
info: (message: string, ...args: unknown[]) => void * Logger 第二参数的可选类型。
error: (message: string, ...args: unknown[]) => void * 调用方通过 util.format 追加详情,实践中多为 catch 到的异常对象。
warn: (message: string, ...args: unknown[]) => void */
debug: (message: string, ...args: unknown[]) => void export type LoggerDetail = Error | NodeJS.ErrnoException
silly: (message: string, ...args: unknown[]) => void
/** 将 unknown 收窄为 LoggerDetail供 catch 块传给 logger 使用。 */
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
return detail instanceof Error ? detail : undefined
} }
/** 宿主注入的日志接口,与 DebugLoggerutil.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 = export type PermissionMode =
| 'ask' | 'ask'
| 'skip_all_permission_checks' | 'skip_all_permission_checks'
@@ -48,10 +121,10 @@ export interface ClaudeForChromeContext {
bridgeConfig?: BridgeConfig bridgeConfig?: BridgeConfig
/** If set, permission mode is sent to the extension immediately on bridge connection. */ /** If set, permission mode is sent to the extension immediately on bridge connection. */
initialPermissionMode?: PermissionMode initialPermissionMode?: PermissionMode
/** Optional callback to track telemetry events for bridge connections */ /** Bridge 遥测回调eventName 为 chrome_bridge_* 事件名 */
trackEvent?: <K extends string>( trackEvent?: (
eventName: K, eventName: string, // 事件名
metadata: Record<string, unknown> | null, metadata: ChromeBridgeTrackEventMetadata, // 事件元数据
) => void ) => void
/** Called when user pairs with an extension via the browser pairing flow. */ /** Called when user pairs with an extension via the browser pairing flow. */
onExtensionPaired?: (deviceId: string, name: string) => void onExtensionPaired?: (deviceId: string, name: string) => void

View File

@@ -20,7 +20,7 @@
*/ */
import type { ScreenshotResult } from './executor.js' 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`. */ /** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
export type CropRawPatchFn = ( export type CropRawPatchFn = (
@@ -165,7 +165,10 @@ export async function validateClickTarget(
} catch (err) { } catch (err) {
// Skip validation on technical errors, execute action anyway. // Skip validation on technical errors, execute action anyway.
// Battle-tested: validation failure must never block the click. // 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 } return { valid: true, skipped: true }
} }
} }

View File

@@ -91,6 +91,7 @@ import type {
ResolvedAppRequest, ResolvedAppRequest,
TeachStepRequest, TeachStepRequest,
} from './types.js' } from './types.js'
import { toLoggerDetail } from './types.js'
/** /**
* Finder is never hidden by the hide loop (hiding Finder kills the Desktop), * 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 — // For ungated tools, the executor may have been mid-call; that's fine —
// the result is still a tool error, never an implicit success. // the result is still a tool error, never an implicit success.
const msg = err instanceof Error ? err.message : String(err) 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') return errorResult(`Tool "${name}" failed: ${msg}`, 'executor_threw')
} }
} }

View File

@@ -8,13 +8,24 @@ import type {
* cross-respawn `scaleCoord` survival. */ * cross-respawn `scaleCoord` survival. */
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'> 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 { export interface Logger {
info: (message: string, ...args: unknown[]) => void info: (message: string, detail?: LoggerDetail) => void // 信息
error: (message: string, ...args: unknown[]) => void error: (message: string, detail?: LoggerDetail) => void // 错误
warn: (message: string, ...args: unknown[]) => void warn: (message: string, detail?: LoggerDetail) => void // 警告
debug: (message: string, ...args: unknown[]) => void debug: (message: string, detail?: LoggerDetail) => void // 调试
silly: (message: string, ...args: unknown[]) => void silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
} }
/** /**

View File

@@ -1,2 +1,6 @@
// Auto-generated stub — replace with real implementation /** 渲染帧中虚拟终端光标的状态(列/行坐标与是否绘制),供 diff 与光标 preamble 使用。 */
export type Cursor = any export type Cursor = {
x: number // 光标所在列,从 0 开始计
y: number // 光标所在行,从 0 开始计
visible: boolean // 本帧是否应在终端绘制光标(隐藏时不发射光标移动序列)
}

View File

@@ -1,3 +1,4 @@
import type { EventHandlerProps } from './events/event-handlers.js'
import type { FocusManager } from './focus.js' import type { FocusManager } from './focus.js'
import { createLayoutNode } from './layout/engine.js' import { createLayoutNode } from './layout/engine.js'
import type { LayoutNode } from './layout/node.js' import type { LayoutNode } from './layout/node.js'
@@ -45,10 +46,9 @@ export type DOMElement = {
dirty: boolean dirty: boolean
// Set by the reconciler's hideInstance/unhideInstance; survives style updates. // Set by the reconciler's hideInstance/unhideInstance; survives style updates.
isHidden?: boolean isHidden?: boolean
// Event handlers set by the reconciler for the capture/bubble dispatcher. // 协调器写入的事件处理器(捕获/冒泡分发用)。
// Stored separately from attributes so handler identity changes don't // attributes 分离,避免 handler 引用变化触发 dirty 破坏 blit 优化。
// mark dirty and defeat the blit optimization. _eventHandlers?: Partial<EventHandlerProps> // 见 event-handlers.ts EventHandlerProps
_eventHandlers?: Record<string, unknown>
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of // Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight // rows the content is scrolled down by. scrollHeight/scrollViewportHeight

View File

@@ -1,2 +1,4 @@
// Auto-generated stub — replace with real implementation /** Box 等组件上 `onPaste` / `onPasteCapture` 收到的粘贴事件形状(与括号粘贴解析结果对齐的占位约定)。 */
export type PasteEvent = any export type PasteEvent = {
pastedText: string // 终端括号粘贴模式下解析出的 UTF-8 文本;允许为空字符串以表示空粘贴
}

View File

@@ -1,2 +1,5 @@
// Auto-generated stub — replace with real implementation /** 终端尺寸变化时 `onResize` 回调收到的事件载荷(与 `stdout.columns` / `stdout.rows` 一致)。 */
export type ResizeEvent = any export type ResizeEvent = {
columns: number // 当前终端列数(宽度)
rows: number // 当前终端行数(高度)
}

View File

@@ -101,7 +101,10 @@ export class TerminalEvent extends Event {
_prepareForTarget(_target: EventTarget): void {} _prepareForTarget(_target: EventTarget): void {}
} }
import type { EventHandlerProps } from './event-handlers.js'
/** 终端事件系统的目标节点DOM 树节点或根节点)。 */
export type EventTarget = { export type EventTarget = {
parentNode: EventTarget | undefined parentNode: EventTarget | undefined // 父节点,根节点为 undefined
_eventHandlers?: Record<string, unknown> _eventHandlers?: Partial<EventHandlerProps> // 事件处理器,与 dom.ts DOMElement 同构
} }

View File

@@ -20,7 +20,10 @@ import {
type TextNode, type TextNode,
} from './dom.js' } from './dom.js'
import { Dispatcher } from './events/dispatcher.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 { getFocusManager, getRootNode } from './focus.js'
import { LayoutDisplay } from './layout/node.js' import { LayoutDisplay } from './layout/node.js'
import applyStyles, { type Styles, type TextStyles } from './styles.js' import applyStyles, { type Styles, type TextStyles } from './styles.js'
@@ -111,7 +114,11 @@ type HostContext = {
isInsideText: boolean 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) { if (!node._eventHandlers) {
node._eventHandlers = {} node._eventHandlers = {}
} }
@@ -135,7 +142,11 @@ function applyProp(node: DOMElement, key: string, value: unknown): void {
} }
if (EVENT_HANDLER_PROPS.has(key)) { if (EVENT_HANDLER_PROPS.has(key)) {
setEventHandler(node, key, value) setEventHandler(
node,
key as keyof EventHandlerProps,
value as EventHandlerProps[keyof EventHandlerProps],
)
return return
} }
@@ -441,7 +452,11 @@ const reconciler = createReconciler<
} }
if (EVENT_HANDLER_PROPS.has(key)) { if (EVENT_HANDLER_PROPS.has(key)) {
setEventHandler(node, key, value) setEventHandler(
node,
key as keyof EventHandlerProps,
value as EventHandlerProps[keyof EventHandlerProps],
)
continue continue
} }

View File

@@ -16,6 +16,7 @@ export async function* adaptGeminiStreamToAnthropic(
let finishReason: string | undefined let finishReason: string | undefined
let inputTokens = 0 let inputTokens = 0
let outputTokens = 0 let outputTokens = 0
let cachedReadTokens = 0
for await (const chunk of stream) { for await (const chunk of stream) {
const usage = chunk.usageMetadata const usage = chunk.usageMetadata
@@ -23,6 +24,7 @@ export async function* adaptGeminiStreamToAnthropic(
inputTokens = usage.promptTokenCount ?? inputTokens inputTokens = usage.promptTokenCount ?? inputTokens
outputTokens = outputTokens =
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0) (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
cachedReadTokens = usage.cachedContentTokenCount ?? cachedReadTokens
} }
if (!started) { if (!started) {
@@ -41,7 +43,7 @@ export async function* adaptGeminiStreamToAnthropic(
input_tokens: inputTokens, input_tokens: inputTokens,
output_tokens: 0, output_tokens: 0,
cache_creation_input_tokens: 0, cache_creation_input_tokens: 0,
cache_read_input_tokens: 0, cache_read_input_tokens: cachedReadTokens,
}, },
}, },
} as unknown as BetaRawMessageStreamEvent } as unknown as BetaRawMessageStreamEvent
@@ -204,7 +206,10 @@ export async function* adaptGeminiStreamToAnthropic(
stop_sequence: null, stop_sequence: null,
}, },
usage: { usage: {
input_tokens: inputTokens,
output_tokens: outputTokens, output_tokens: outputTokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: cachedReadTokens,
}, },
} as BetaRawMessageStreamEvent } as BetaRawMessageStreamEvent

View File

@@ -68,6 +68,7 @@ export type GeminiUsageMetadata = {
candidatesTokenCount?: number candidatesTokenCount?: number
thoughtsTokenCount?: number thoughtsTokenCount?: number
totalTokenCount?: number totalTokenCount?: number
cachedContentTokenCount?: number
} }
export type GeminiCandidate = { export type GeminiCandidate = {

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** Bash 工具在 API 与 Agent 提示串中的注册名称字面量(与 `@claude-code-best/builtin-tools` 中 `BASH_TOOL_NAME` 常量一致)。 */
export type BASH_TOOL_NAME = any export type BASH_TOOL_NAME = 'Bash'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** ExitPlanMode 工具在 API 中的注册名称字面量(与内置 ExitPlanMode 工具 `name` 一致)。 */
export type EXIT_PLAN_MODE_TOOL_NAME = any export type EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** Edit文件编辑工具在 API 中的注册名称字面量(与 `FILE_EDIT_TOOL_NAME` 常量 `'Edit'` 一致)。 */
export type FILE_EDIT_TOOL_NAME = any export type FILE_EDIT_TOOL_NAME = 'Edit'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** Read文件读取工具在 API 中的注册名称字面量(与 `FILE_READ_TOOL_NAME` 常量 `'Read'` 一致)。 */
export type FILE_READ_TOOL_NAME = any export type FILE_READ_TOOL_NAME = 'Read'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** Write文件写入工具在 API 中的注册名称字面量(与 `FILE_WRITE_TOOL_NAME` 常量 `'Write'` 一致)。 */
export type FILE_WRITE_TOOL_NAME = any export type FILE_WRITE_TOOL_NAME = 'Write'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** Glob文件名模式匹配工具在 API 中的注册名称字面量(与 `GLOB_TOOL_NAME` 常量 `'Glob'` 一致)。 */
export type GLOB_TOOL_NAME = any export type GLOB_TOOL_NAME = 'Glob'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** Grep内容搜索工具在 API 中的注册名称字面量(与 `GREP_TOOL_NAME` 常量 `'Grep'` 一致)。 */
export type GREP_TOOL_NAME = any export type GREP_TOOL_NAME = 'Grep'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** NotebookEdit笔记本单元格编辑工具在 API 中的注册名称字面量(与 `NOTEBOOK_EDIT_TOOL_NAME` 常量一致)。 */
export type NOTEBOOK_EDIT_TOOL_NAME = any export type NOTEBOOK_EDIT_TOOL_NAME = 'NotebookEdit'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** SendMessage向用户/通道发消息)工具在 API 中的注册名称字面量(与 `SEND_MESSAGE_TOOL_NAME` 常量一致)。 */
export type SEND_MESSAGE_TOOL_NAME = any export type SEND_MESSAGE_TOOL_NAME = 'SendMessage'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** WebFetch拉取并处理 URL 内容)工具在 API 中的注册名称字面量(与 `WEB_FETCH_TOOL_NAME` 常量一致)。 */
export type WEB_FETCH_TOOL_NAME = any export type WEB_FETCH_TOOL_NAME = 'WebFetch'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** WebSearch联网搜索工具在 API 中的注册名称字面量(与 `WEB_SEARCH_TOOL_NAME` 常量一致)。 */
export type WEB_SEARCH_TOOL_NAME = any export type WEB_SEARCH_TOOL_NAME = 'WebSearch'

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** 是否正在使用第三方(非 Anthropic 直连API 或服务;与仓库根 `src/utils/auth.ts` 中 `isUsing3PServices` 签名一致。 */
export type isUsing3PServices = any export type isUsing3PServices = () => boolean // 返回 true 表示当前配置走兼容层或第三方模型端点

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** 当前构建是否将 Glob/Grep 嵌入其它工具而不单独注册;与仓库根 `src/utils/embeddedTools.ts` 中 `hasEmbeddedSearchTools` 一致。 */
export type hasEmbeddedSearchTools = any export type hasEmbeddedSearchTools = () => boolean // 返回 true 时工具列表不包含独立的 Glob/Grep 工具名

View File

@@ -1,2 +1,4 @@
// Auto-generated type stub — replace with real implementation import type { SettingsJson } from 'src/utils/settings/types.js'
export type getSettings_DEPRECATED = any
/** 返回各设置来源合并后的快照(已废弃函数名,行为同 `getInitialSettings`);与 `src/utils/settings/settings.ts` 一致。 */
export type getSettings_DEPRECATED = () => SettingsJson // 无参数;至少得到可空字段填充后的合并设置对象

View File

@@ -12,9 +12,7 @@ import type { AgentDefinition } from './loadAgentsDir.js'
export function areExplorePlanAgentsEnabled(): boolean { export function areExplorePlanAgentsEnabled(): boolean {
if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) { if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) {
// 3P default: true — Bedrock/Vertex keep agents enabled (matches pre-experiment return true
// external behavior). A/B test treatment sets false to measure impact of removal.
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
} }
return false return false
} }

View File

@@ -1,4 +1,8 @@
// Auto-generated type stub — replace with real implementation /** 根据工具定义装配宿主侧可调用 `Tool` 实例的工厂函数类型。 */
export type buildTool = any export type buildTool = typeof import('src/Tool.js').buildTool
export type ToolDef = any
export type toolMatchesName = any /** 工具定义泛型(输入 Schema、权限、进度等与宿主 `ToolDef` 一致。 */
export type ToolDef = import('src/Tool.js').ToolDef
/** 判断工具主名称或别名是否与查询名称相等;与宿主 `toolMatchesName` 一致。 */
export type toolMatchesName = typeof import('src/Tool.js').toolMatchesName

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 可配置快捷键提示组件(从 keybindings 解析展示文案);与宿主 `ConfigurableShortcutHint` 组件类型一致。 */
export type ConfigurableShortcutHint = any export type ConfigurableShortcutHint =
typeof import('src/components/ConfigurableShortcutHint.js').ConfigurableShortcutHint

View File

@@ -1,3 +1,7 @@
// Auto-generated type stub — replace with real implementation /** 「Ctrl+O 展开」提示组件;与宿主 `src/components/CtrlOToExpand.tsx` 中 `CtrlOToExpand` 一致。 */
export type CtrlOToExpand = any export type CtrlOToExpand =
export type SubAgentProvider = any typeof import('src/components/CtrlOToExpand.js').CtrlOToExpand
/** 标记子 Agent 输出上下文,用于抑制重复的展开提示;与宿主 `SubAgentProvider` 一致。 */
export type SubAgentProvider =
typeof import('src/components/CtrlOToExpand.js').SubAgentProvider

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** Ink 底部快捷键说明行容器组件;与 `@anthropic/ink` 导出的 `Byline` 一致。 */
export type Byline = any export type Byline = typeof import('@anthropic/ink').Byline

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** Ink 快捷键「按键 + 动作」展示组件;与 `@anthropic/ink` 导出的 `KeyboardShortcutHint` 一致。 */
export type KeyboardShortcutHint = any export type KeyboardShortcutHint =
typeof import('@anthropic/ink').KeyboardShortcutHint

View File

@@ -1,3 +1,6 @@
// Auto-generated type stub — replace with real implementation /** 对话消息联合类型(含用户/助手/系统等);与宿主 `src/types/message.js` 重导出一致。 */
export type Message = any export type Message = import('src/types/message.js').Message
export type NormalizedUserMessage = any
/** 归一化后的用户消息形状;与宿主 `src/types/message.js` 中 `NormalizedUserMessage` 一致。 */
export type NormalizedUserMessage =
import('src/types/message.js').NormalizedUserMessage

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 写入调试日志文件(受日志级别与过滤规则约束);与宿主 `src/utils/debug.js` 中 `logForDebugging` 一致。 */
export type logForDebugging = any export type logForDebugging =
typeof import('src/utils/debug.js').logForDebugging

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 按内置/自定义 Agent 类型解析用于遥测或分类的 `QuerySource`;与宿主 `getQuerySourceForAgent` 一致。 */
export type getQuerySourceForAgent = any export type getQuerySourceForAgent =
typeof import('src/utils/promptCategory.js').getQuerySourceForAgent

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 设置文件来源层级标识(用户/项目/本地等);与宿主 `src/utils/settings/constants.js` 中 `SettingSource` 一致。 */
export type SettingSource = any export type SettingSource =
import('src/utils/settings/constants.js').SettingSource

View File

@@ -1,3 +1,7 @@
// Auto-generated type stub — replace with real implementation /** 返回当前允许展示的通道列表(含名称、连接状态等);与宿主 `src/bootstrap/state.js` 中 `getAllowedChannels` 一致。 */
export type getAllowedChannels = any export type getAllowedChannels =
export type getQuestionPreviewFormat = any typeof import('src/bootstrap/state.js').getAllowedChannels
/** 返回问题预览渲染格式Markdown/HTML或未配置与宿主 `getQuestionPreviewFormat` 一致。 */
export type getQuestionPreviewFormat =
typeof import('src/bootstrap/state.js').getQuestionPreviewFormat

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 工具结果在消息流中的外层布局组件;与宿主 `src/components/MessageResponse.js` 中 `MessageResponse` 一致。 */
export type MessageResponse = any export type MessageResponse =
typeof import('src/components/MessageResponse.js').MessageResponse

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 列表/状态行中使用的实心圆点字符(平台相关);与宿主 `src/constants/figures.js` 中 `BLACK_CIRCLE` 常量类型一致。 */
export type BLACK_CIRCLE = any export type BLACK_CIRCLE =
typeof import('src/constants/figures.js').BLACK_CIRCLE

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 将权限模式映射为 Ink 主题颜色键,用于状态行等 UI与宿主 `getModeColor` 一致。 */
export type getModeColor = any export type getModeColor =
typeof import('src/utils/permissions/PermissionMode.js').getModeColor

View File

@@ -29,7 +29,6 @@ import { extractClaudeCodeHints } from 'src/utils/claudeCodeHints.js';
import { detectCodeIndexingFromCommand } from 'src/utils/codeIndexing.js'; import { detectCodeIndexingFromCommand } from 'src/utils/codeIndexing.js';
import { isEnvTruthy } from 'src/utils/envUtils.js'; import { isEnvTruthy } from 'src/utils/envUtils.js';
import { isENOENT, ShellError } from 'src/utils/errors.js'; import { isENOENT, ShellError } from 'src/utils/errors.js';
import { decodeBuffer } from 'src/utils/encoding.js';
import { detectFileEncoding, detectLineEndings, getFileModificationTime, writeTextContent } from 'src/utils/file.js'; import { detectFileEncoding, detectLineEndings, getFileModificationTime, writeTextContent } from 'src/utils/file.js';
import { fileHistoryEnabled, fileHistoryTrackEdit } from 'src/utils/fileHistory.js'; import { fileHistoryEnabled, fileHistoryTrackEdit } from 'src/utils/fileHistory.js';
import { truncate } from 'src/utils/format.js'; import { truncate } from 'src/utils/format.js';
@@ -512,8 +511,7 @@ async function applySedEdit(
const encoding = detectFileEncoding(absoluteFilePath); const encoding = detectFileEncoding(absoluteFilePath);
let originalContent: string; let originalContent: string;
try { try {
const rawBuffer = await fs.readFileBytes(absoluteFilePath); originalContent = await fs.readFile(absoluteFilePath, { encoding });
originalContent = decodeBuffer(rawBuffer, encoding);
} catch (e) { } catch (e) {
if (isENOENT(e)) { if (isENOENT(e)) {
return { return {

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** 工具权限检查用的不可变上下文快照;与宿主 `src/Tool.js` 中 `ToolPermissionContext` 一致。 */
export type ToolPermissionContext = any export type ToolPermissionContext = import('src/Tool.js').ToolPermissionContext

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 返回进程启动时的原始工作目录(不受中途切换工作区影响);与宿主 `getOriginalCwd` 一致。 */
export type getOriginalCwd = any export type getOriginalCwd =
typeof import('src/bootstrap/state.js').getOriginalCwd

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** 工具调用权限判定回调(交互/自动模式分支);与宿主 `src/hooks/useCanUseTool.tsx` 中 `CanUseToolFn` 一致。 */
export type CanUseToolFn = any export type CanUseToolFn = import('src/hooks/useCanUseTool.js').CanUseToolFn

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 从磁盘缓存读取 GrowthBook/门控配置(可能略旧);与宿主 `getFeatureValue_CACHED_MAY_BE_STALE` 一致。 */
export type getFeatureValue_CACHED_MAY_BE_STALE = any export type getFeatureValue_CACHED_MAY_BE_STALE =
typeof import('src/services/analytics/growthbook.js').getFeatureValue_CACHED_MAY_BE_STALE

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** 同步记录分析事件(未附加 sink 时入队);与宿主 `src/services/analytics/index.js` 中 `logEvent` 一致。 */
export type logEvent = any export type logEvent = typeof import('src/services/analytics/index.js').logEvent

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** REPL 全局 UI 与权限等状态快照类型;与宿主 `src/state/AppStateStore.js` 中 `AppState` 一致。 */
export type AppState = any export type AppState = import('src/state/AppStateStore.js').AppState

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** 将 Shell 会话当前目录设为解析后的物理路径;与宿主 `src/utils/Shell.js` 中 `setCwd` 一致。 */
export type setCwd = any export type setCwd = typeof import('src/utils/Shell.js').setCwd

View File

@@ -1,2 +1,2 @@
// Auto-generated type stub — replace with real implementation /** 返回当前 Shell/会话逻辑工作目录字符串;与宿主 `src/utils/cwd.js` 中 `getCwd` 一致。 */
export type getCwd = any export type getCwd = typeof import('src/utils/cwd.js').getCwd

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 判断路径是否落在当前工具允许的合并工作目录内;与宿主 `pathInAllowedWorkingPath` 一致。 */
export type pathInAllowedWorkingPath = any export type pathInAllowedWorkingPath =
typeof import('src/utils/permissions/filesystem.js').pathInAllowedWorkingPath

View File

@@ -1,2 +1,3 @@
// Auto-generated type stub — replace with real implementation /** 从展示文本中剥离沙箱违规相关的标记标签,避免 UI 噪音;与宿主 `removeSandboxViolationTags` 一致。 */
export type removeSandboxViolationTags = any export type removeSandboxViolationTags =
typeof import('src/utils/sandbox/sandbox-ui-utils.js').removeSandboxViolationTags

View File

@@ -10,8 +10,14 @@ import {
} from 'src/Tool.js' } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js' import { lazySchema } from 'src/utils/lazySchema.js'
import { createUserMessage } from 'src/utils/messages.js' import { createUserMessage } from 'src/utils/messages.js'
import {
extractDiscoveredToolNames,
isSearchExtraToolsEnabledOptimistic,
isSearchExtraToolsToolAvailable,
} from 'src/utils/searchExtraTools.js'
import { DESCRIPTION, getPrompt } from './prompt.js' import { DESCRIPTION, getPrompt } from './prompt.js'
import { EXECUTE_TOOL_NAME } from './constants.js' import { EXECUTE_TOOL_NAME } from './constants.js'
import { isDeferredTool } from '../SearchExtraToolsTool/prompt.js'
export const inputSchema = lazySchema(() => export const inputSchema = lazySchema(() =>
z.object({ 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 // Check if the target tool is currently enabled
if (!targetTool.isEnabled()) { if (!targetTool.isEnabled()) {
return { 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 // Check permissions on the target tool
const permResult = await targetTool.checkPermissions?.( const permResult = await targetTool.checkPermissions?.(
input.params as Record<string, unknown>, input.params as Record<string, unknown>,
@@ -132,7 +187,7 @@ export const ExecuteTool = buildTool({
} }
}, },
renderToolUseMessage(input) { renderToolUseMessage(input) {
return `Executing ${input.tool_name}...` return `${input.tool_name}`
}, },
userFacingName() { userFacingName() {
return 'ExecuteExtraTool' return 'ExecuteExtraTool'

View File

@@ -33,10 +33,10 @@ mock.module('src/utils/searchExtraTools.js', () => ({
isSearchExtraToolsEnabledOptimistic: () => true, isSearchExtraToolsEnabledOptimistic: () => true,
getAutoSearchExtraToolsCharThreshold: () => 100, getAutoSearchExtraToolsCharThreshold: () => 100,
getSearchExtraToolsMode: () => 'tst' as const, getSearchExtraToolsMode: () => 'tst' as const,
isSearchExtraToolsToolAvailable: async () => true, isSearchExtraToolsToolAvailable: () => true,
isSearchExtraToolsEnabled: async () => true, isSearchExtraToolsEnabled: async () => true,
isToolReferenceBlock: () => false, isToolReferenceBlock: () => false,
extractDiscoveredToolNames: () => new Set(), extractDiscoveredToolNames: () => new Set(['TestTool', 'SecretTool']),
isDeferredToolsDeltaEnabled: () => false, isDeferredToolsDeltaEnabled: () => false,
getDeferredToolsDelta: () => null, getDeferredToolsDelta: () => null,
})) }))
@@ -154,6 +154,26 @@ describe('ExecuteTool', () => {
expect(result.newMessages).toBeDefined() 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', () => { test('has correct name', () => {
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME) expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
}) })

View File

@@ -1,12 +1,10 @@
/** /**
* ExecuteTool.test.ts * ExecuteTool.test.ts
* *
* Thin subprocess wrapper that runs the actual tests in an isolated bun:test * 薄层子进程包装器,在独立的 bun:test 进程中运行实际测试。
* process. This prevents mock.module() leaks from other test files * 这样可以防止其他测试文件的 mock.module() 漏出(例如 agentToolUtils.test.ts
* (e.g., agentToolUtils.test.ts mocking src/Tool.js) from affecting * 对 src/Tool.js 的 mock影响 ExecuteTool 的测试。
* ExecuteTool's tests.
*/ */
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { resolve, relative } from 'path' import { resolve, relative } from 'path'

View File

@@ -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.' '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 { 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 to use
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. 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: ## How to call — two-step workflow
- tool_name: The exact name of the target tool (string)
- params: The parameters to pass to the target tool (object)
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.`
} }

View File

@@ -34,11 +34,6 @@ import {
type LineEndingType, type LineEndingType,
readFileSyncWithMetadata, readFileSyncWithMetadata,
} from 'src/utils/fileRead.js' } from 'src/utils/fileRead.js'
import {
detectEncoding,
decodeBuffer,
type FileEncoding,
} from 'src/utils/encoding.js'
import { formatFileSize } from 'src/utils/format.js' import { formatFileSize } from 'src/utils/format.js'
import { getFsImplementation } from 'src/utils/fsOperations.js' import { getFsImplementation } from 'src/utils/fsOperations.js'
import { fetchSingleFileGitDiff, type ToolUseDiff } from 'src/utils/gitDiff.js' import { fetchSingleFileGitDiff, type ToolUseDiff } from 'src/utils/gitDiff.js'
@@ -207,8 +202,13 @@ export const FileEditTool = buildTool({
let fileContent: string | null let fileContent: string | null
try { try {
const fileBuffer = await fs.readFileBytes(fullFilePath) const fileBuffer = await fs.readFileBytes(fullFilePath)
const encoding: FileEncoding = detectEncoding(fileBuffer) const encoding: BufferEncoding =
fileContent = decodeBuffer(fileBuffer, encoding).replaceAll('\r\n', '\n') fileBuffer.length >= 2 &&
fileBuffer[0] === 0xff &&
fileBuffer[1] === 0xfe
? 'utf16le'
: 'utf8'
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
} catch (e) { } catch (e) {
if (isENOENT(e)) { if (isENOENT(e)) {
fileContent = null fileContent = null
@@ -584,7 +584,7 @@ export const FileEditTool = buildTool({
function readFileForEdit(absoluteFilePath: string): { function readFileForEdit(absoluteFilePath: string): {
content: string content: string
fileExists: boolean fileExists: boolean
encoding: FileEncoding encoding: BufferEncoding
lineEndings: LineEndingType lineEndings: LineEndingType
} { } {
try { try {

View File

@@ -383,8 +383,8 @@ export const NotebookEditTool = buildTool({
const language = notebook.metadata.language_info?.name ?? 'python' const language = notebook.metadata.language_info?.name ?? 'python'
let new_cell_id let new_cell_id
if ( if (
notebook.nbformat > 4 || (notebook.nbformat ?? 4) > 4 ||
(notebook.nbformat === 4 && notebook.nbformat_minor >= 5) ((notebook.nbformat ?? 4) === 4 && (notebook.nbformat_minor ?? 0) >= 5)
) { ) {
if (edit_mode === 'insert') { if (edit_mode === 'insert') {
new_cell_id = Math.random().toString(36).substring(2, 15) new_cell_id = Math.random().toString(36).substring(2, 15)

View File

@@ -25,13 +25,39 @@ function getToolLocationHint(): string {
const PROMPT_TAIL = ` Returns matching tool names. 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: Deferred tools CANNOT be called directly. You MUST use this two-step pattern:
- "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. 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 - "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). * Check if a tool should be deferred (requires SearchExtraTools to load).

View File

@@ -59,7 +59,7 @@ export const DEFAULT_BUILD_FEATURES = [
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker非 GB 级主因) 'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker非 GB 级主因)
'ACP', // ACP 代理协议,支持外部 agent 接入 'ACP', // ACP 代理协议,支持外部 agent 接入
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD 'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口 // 'HISTORY_SNIP', // 已禁用snip 功能暂时关闭
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub启用后会抑制 auto compact 导致上下文管理完全失效 // 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub启用后会抑制 auto compact 导致上下文管理完全失效
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出 'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
// 'FORK_SUBAGENT', // 已禁用:通过 Agent tool 的特殊方式实现了等效功能,无需再开 // 'FORK_SUBAGENT', // 已禁用:通过 Agent tool 的特殊方式实现了等效功能,无需再开

View File

@@ -1,179 +0,0 @@
# Feature: 20260510_F001 - multi-encoding-file-tools
## 需求背景
当前文件读写工具FileReadTool、FileWriteTool、FileEditTool的编码检测非常简单——仅通过 BOM 头识别 UTF-8 和 UTF-16LE其他所有情况默认按 UTF-8 处理。对于 GBK/GB2312 等非 BOM 编码文件,读取时会产生乱码,导致 AI 模型无法正确理解和编辑这些文件。
这在中文 Windows 用户场景中尤其常见:许多旧项目、日志文件、配置文件使用 GBK 编码,当前工具链无法处理。
## 目标
- 文件读取时自动检测编码并正确解码,对 AI 模型完全透明(不增加 encoding 参数)
- 文件写入时保持原文件编码,不改变用户的编码习惯
- 覆盖 GBK 编码(最常见非 UTF-8 CJK 编码latin1 作为最终兜底
- 零外部依赖,仅使用 Node.js/Bun 内置的 TextDecoder/TextEncoder
## 范围变更
**仅保留 GBK 编码支持**。Shift_JIS、EUC-JP、EUC-KR、Big5、GB18030、ISO-8859-1 已移出范围。原因:多编码回退链存在字节序列歧义(如 GBK 和 Shift_JIS 共享大量有效字节范围导致误检测。GBK 覆盖了最核心的中文 Windows 用户场景。
## 方案设计
### 架构概述
新增一个独立的编码工具模块 `src/utils/encoding.ts`,提供编码检测和解码/编码函数。现有文件读写路径通过调用此模块实现对非 UTF-8 编码的支持。
```
┌─────────────────────────┐
│ src/utils/encoding.ts │
│ detectEncoding(buffer) │
│ decodeBuffer(buf, enc) │
│ encodeString(str, enc) │
└─────────┬───────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
fileRead.ts readFileInRange.ts file.ts
(readFileSync (异步读取路径) (writeTextContent)
WithMetadata)
```
### 编码检测算法(三层检测)
检测基于文件头部 4KB 数据,分三层依次判断:
**第一层BOM 检测(现有逻辑保留)**
- `FF FE` → UTF-16LE
- `EF BB BF` → UTF-8带 BOM
**第二层UTF-8 验证**
-`new TextDecoder('utf-8', { fatal: true })` 对头部 4KB 做解码
- 成功 → 文件为 UTF-8覆盖绝大多数现代源码文件
- 失败(抛出 TypeError→ 进入第三层
**第三层GBK 回退**
-`new TextDecoder('gbk', { fatal: true })` 尝试解码头部 4KB
- 成功 → 文件为 GBK覆盖中文 Windows 用户最常见的非 UTF-8 编码)
- 失败 → `latin1`(单字节编码,永远成功,作为最终兜底)
```typescript
// src/utils/encoding.ts 核心逻辑
export type FileEncoding = BufferEncoding | 'gbk'
export type DetectedEncoding = string
export function detectEncoding(buffer: Buffer): FileEncoding {
// Layer 1: BOM
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
return 'utf-16le'
}
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
return 'utf-8'
}
// Layer 2: UTF-8 validation
try {
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
return 'utf-8'
} catch {}
// Layer 3: GBK fallback
try {
new TextDecoder('gbk', { fatal: true }).decode(buffer)
return 'gbk'
} catch {}
return 'latin1'
}
```
### 读取路径改造
#### `src/utils/fileRead.ts` — `detectEncodingForResolvedPath`
将现有的 BOM-only 检测替换为调用 `encoding.ts``detectEncoding` 函数。返回值从 `BufferEncoding` 改为 `FileEncoding``BufferEncoding | 'gbk'`)。
`readFileSyncWithMetadata` 函数先读 raw Buffer再用 `decodeBuffer` 解码,而非使用 `fs.readFileSync` 的 encoding 选项(该选项只接受 `BufferEncoding`,不支持 `gbk`)。
#### `src/utils/readFileInRange.ts` — 异步读取
当前两个路径fast path 和 streaming path都硬编码 `encoding: 'utf8'`
**Fast path 改造**
- `readFile` 改为读取 Buffer去掉 encoding 参数)
- 读取后调用 `detectEncoding(buffer)` 检测编码
-`decodeBuffer` 解码为字符串
- 后续行处理逻辑不变
**Streaming path 改造**
- `createReadStream` 去掉 `encoding: 'utf8'`,改为 Buffer 模式
- 第一个 chunk 做编码检测(同时保留 BOM 剥离逻辑)
- 后续 chunk 拼接后用 `TextDecoder` 解码
- 注意streaming 路径需要特殊处理——先收集足够字节做检测,再逐行扫描
**Streaming 编码处理策略**
streaming 路径改为两阶段:
1. **检测阶段**:前 4KB 数据到达后立即检测编码
2. **解码阶段**:用检测到的编码创建一个 `TextDecoder``{ stream: true }` 模式),逐 chunk 解码
### 写入路径改造
#### 编码回写策略
写入时需要将内部 UTF-8 字符串编码回原文件编码。由于 `TextEncoder` 只支持 UTF-8 输出,需要使用 `TextDecoder` 的反向操作。
**最终决定**:对于非 UTF-8 文件的写回,尝试使用 `Buffer.from(content, encoding)` 编码,失败则自动转换为 UTF-8 并在结果消息中注明。这样既满足了零依赖约束,也避免了数据损坏。
#### `src/utils/file.ts` — `writeTextContent`
现有函数签名 `writeTextContent(filePath, content, encoding, lineEndings)` 已接受 encoding 参数。需要:
- 扩展类型,接受 `FileEncoding` 而非仅 `BufferEncoding`
- 对于 UTF-8 和 UTF-16LE行为不变
- 对于 GBK使用 `encodeString` 函数尝试编码,失败则回退为 UTF-8 写入
#### `FileWriteTool` 和 `FileEditTool`
这两个工具的 `call` 方法中,`writeTextContent` 调用已传递 `encoding`(来自 `readFileSyncWithMetadata` 的返回值)。改动很小——只需确保类型系统接受新编码名。
### 类型扩展
```typescript
// 扩展编码类型 — 仅添加 GBK
export type FileEncoding = BufferEncoding | 'gbk'
```
`readFileSyncWithMetadata` 返回类型中将 `encoding``BufferEncoding` 改为 `FileEncoding`
## 实现要点
### 关键技术决策
1. **检测只用头部 4KB**:避免全文件扫描,性能开销极小(多几次 TextDecoder 调用,每次 ~1μs
2. **GBK 作为唯一回退**:中文 Windows 用户最多,且避免了多编码回退链的字节序列歧义问题
3. **TextDecoder fatal 模式**`{ fatal: true }` 是检测的关键——如果字节序列不符合编码规范会抛异常,借此区分不同编码
4. **streaming 路径的两阶段设计**:先攒够检测数据再开始行扫描,避免半字符解码问题
5. **latin1 最终兜底**:单字节编码永远成功,确保任何文件都能被读取
### 难点
1. **Streaming 编码解码**`TextDecoder` 支持 `{ stream: true }` 模式处理多字节字符的 chunk 边界,但需要在检测完成前缓冲数据
2. **编码回写的零依赖方案**`TextEncoder` 只输出 UTF-8非 UTF-8 编码回写需要额外处理。务实方案是 UTF-8 写入 + 消息提示
3. **混合编码文件**:极少见,不在本次覆盖范围内
### 依赖
- 零外部依赖,仅使用 `TextDecoder`Node.js 13+ / Bun 内置 full-icu
- Bun 运行时对 GBK 的 TextDecoder 支持已验证可用Bun 1.3.13
## 验收标准
- [x] FileReadTool 能正确读取 GBK 编码的中文文本文件,显示正确的中文内容
- [x] FileReadTool 能正确读取 UTF-8 文件(行为不变,回归测试通过)
- [x] FileReadTool 能正确读取 UTF-16LE 文件(行为不变)
- [x] FileEditTool 能编辑 GBK 文件并写回,内容不乱码
- [x] FileWriteTool 编辑 GBK 文件后写回,编码保持或合理转换
- [x] readFileInRange 的 fast path 路径支持非 UTF-8 编码
- [x] readFileInRange 的 streaming path 支持非 UTF-8 编码
- [x] 编码检测性能4KB 数据检测耗时 < 1ms
- [x] `bun run precheck` typecheck + lint + 相关测试零错误
- [x] 新增编码相关单元测试覆盖检测和解码逻辑

View File

@@ -1,161 +0,0 @@
# 多编码文件工具 人工验收清单
**生成时间:** 2026-05-10
**关联计划:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-plan.md
**关联设计:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-design.md
---
所有验收项均可通过 Shell 命令自动化验证,无需人类参与。仍将生成清单用于自动执行。
**范围变更:** 仅保留 GBK 编码支持Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030 已移除。
---
## 验收前准备
### 环境要求
- [x] [AUTO] 检查 Bun 运行时版本: `bun --version`
- [x] [AUTO] 安装依赖: `bun install`
### 测试数据准备
- [x] [AUTO] 创建 GBK 编码测试文件: `bun -e "const fs = require('fs'); const b = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3, 0xCA, 0xC0, 0xBD, 0xE7, 0x0A]); fs.writeFileSync('/tmp/test-gbk.txt', b)"`
- [x] [AUTO] 创建 UTF-8 测试文件: `bun -e "require('fs').writeFileSync('/tmp/test-utf8.txt', 'Hello 世界\n')"`
- [x] [AUTO] 创建 UTF-16LE 测试文件: `bun -e "const fs = require('fs'); const b = Buffer.from('Hello','utf16le'); fs.writeFileSync('/tmp/test-utf16le.txt', b)"`
---
## 验收项目
### 场景 1读取 GBK 编码文件(中文场景)
**用户目标:** 用户有一个 GBK 编码的中文文件,通过 FileReadTool 读取后看到正确的中文内容
**触发路径:**
1. 系统检测到非 UTF-8 字节序列
2. 编码回退识别为 GBK
3. 用 GBK 解码输出中文文本
#### - [x] 1.1 GBK 文件同步读取
- **来源:** spec-plan-acceptance.md §2 / spec-design.md §验收标准
- **目的:** 确认 GBK 文件读取解码正确
- **操作步骤:**
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-gbk.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `你好世界`
2. [A] 上条命令输出 encoding 字段 → 期望包含: `gbk`
#### - [x] 1.2 GBK 文件异步路径读取
- **来源:** spec-plan-acceptance.md §6 / spec-design.md §验收标准
- **目的:** 确认 readFileInRange fast path 支持 GBK
- **操作步骤:**
1. [A] `bun -e "import { readFileInRange } from './src/utils/readFileInRange.js'; const r = await readFileInRange('/tmp/test-gbk.txt', 0); console.log('content:', r.content); console.log('totalLines:', r.totalLines)"` → 期望包含: `你好世界`
2. [A] 上条命令输出 totalLines → 期望包含: `1`
---
### 场景 3写入非 UTF-8 编码文件
**用户目标:** 用户通过 FileEditTool/FileWriteTool 编辑 GBK 文件后写回,内容不损坏
**触发路径:**
1. 系统检测原文件编码
2. 编辑内容后写回
3. 非标准编码回退为 UTF-8 写入(零依赖约束)
#### - [x] 3.1 GBK 文件写入UTF-8 回退)
- **来源:** spec-plan-acceptance.md §7 / spec-design.md §写入路径改造
- **目的:** 确认非 UTF-8 编码写入不损坏内容
- **操作步骤:**
1. [A] `bun -e "import { writeTextContent } from './src/utils/file.js'; writeTextContent('/tmp/test-gbk-write.txt', '测试写入', 'gbk', 'LF'); const fs = require('fs'); const content = fs.readFileSync('/tmp/test-gbk-write.txt', 'utf8'); console.log('written:', content)"` → 期望包含: `测试写入`
---
### 场景 4UTF-8 文件读取回归
**用户目标:** 用户读取 UTF-8 文件,行为与改动前完全一致
**触发路径:**
1. UTF-8 fatal 验证通过
2. 内容正常输出
#### - [x] 4.1 UTF-8 文件读取回归
- **来源:** spec-plan-acceptance.md §4 / spec-design.md §验收标准
- **目的:** 确认 UTF-8 读取无回归
- **操作步骤:**
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf8.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `Hello 世界`
2. [A] 上条命令输出 encoding 字段 → 期望包含: `utf`
---
### 场景 5UTF-16LE 文件读取回归
**用户目标:** 用户读取 UTF-16LEBOM文件行为与改动前完全一致
**触发路径:**
1. BOM 检测层识别 FF FE 标记
2. 用 UTF-16LE 解码
#### - [x] 5.1 UTF-16LE 文件读取回归
- **来源:** spec-plan-acceptance.md §5 / spec-design.md §验收标准
- **目的:** 确认 UTF-16LE BOM 读取无回归
- **操作步骤:**
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf16le.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `utf-16le`
2. [A] 上条命令输出 content 字段 → 期望包含: `Hello`
---
### 场景 6编码检测性能
**用户目标:** 编码检测不应影响文件读取的响应速度
**触发路径:**
1. 对 4KB 数据执行 1000 次检测
2. 验证平均耗时 < 1ms
#### - [x] 6.1 检测性能基准
- **来源:** spec-plan-acceptance.md §8 / spec-design.md §实现要点
- **目的:** 确认编码检测性能达标
- **操作步骤:**
1. [A] `bun -e "import { detectEncoding } from './src/utils/encoding.js'; const buf = Buffer.alloc(4096, 0x41); const start = performance.now(); for (let i = 0; i < 1000; i++) detectEncoding(buf); const avg = (performance.now() - start) / 1000; console.log('avg:', avg, 'ms'); process.exit(avg < 1 ? 0 : 1)"` → 期望包含: `avg:`
---
### 场景 7构建和测试完整性
**用户目标:** 整体代码质量无退化,所有测试通过
**触发路径:**
1. 执行完整 prechecktypecheck + lint + test
2. 确认零错误
#### - [x] 7.1 编码相关单元测试
- **来源:** spec-plan.md Task 1-4 检查步骤 / spec-design.md §验收标准
- **目的:** 确认编码相关测试全部通过
- **操作步骤:**
1. [A] `bun test src/utils/__tests__/encoding.test.ts` → 期望包含: `0 fail`
2. [A] `bun test src/utils/__tests__/fileRead.test.ts` → 期望包含: `0 fail`
3. [A] `bun test src/utils/__tests__/readFileInRange.test.ts` → 期望包含: `0 fail`
4. [A] `bun test src/utils/__tests__/file.test.ts` → 期望包含: `0 fail`
---
## 验收后清理
- [x] [AUTO] 清理临时测试文件: `rm -f /tmp/test-gbk.txt /tmp/test-utf8.txt /tmp/test-utf16le.txt /tmp/test-gbk-write.txt`
---
## 验收结果汇总
| 场景 | 序号 | 验收项 | [A] | [H] | 结果 |
|------|------|--------|-----|-----|------|
| 场景 1 | 1.1 | GBK 同步读取 | 2 | 0 | ✅ |
| 场景 1 | 1.2 | GBK 异步路径读取 | 2 | 0 | ✅ |
| 场景 3 | 3.1 | GBK 写入(回退) | 1 | 0 | ✅ |
| 场景 4 | 4.1 | UTF-8 回归 | 2 | 0 | ✅ |
| 场景 5 | 5.1 | UTF-16LE 回归 | 2 | 0 | ✅ |
| 场景 6 | 6.1 | 检测性能 | 1 | 0 | ✅ |
| 场景 7 | 7.1 | 编码单元测试 | 4 | 0 | ✅ |
**验收结论:** ✅ 全部通过

View File

@@ -1,47 +0,0 @@
### Acceptance Task: 多编码文件工具验收
**前置条件:**
- 所有 Task 0-4 已执行完毕
- 运行环境: 当前开发环境Bun
**范围变更:** 仅保留 GBK 编码支持Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030/ISO-8859-1 已移除。
**端到端验证:**
1. 运行完整测试套件确保无回归
- `bun run precheck`
- 预期: typecheck + lint fix + test 全部零错误通过
- 失败排查: 检查各 Task 的测试步骤,特别是 Task 1 的编码检测测试和 Task 3 的 readFileInRange 测试
2. 验证 GBK 文件读取正确性
- 创建 GBK 编码测试文件:`bun -e "const fs = require('fs'); const b = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3, 0xCA, 0xC0, 0xBD, 0xE7, 0x0A]); fs.writeFileSync('/tmp/test-gbk.txt', b)"`
- 读取并验证:`bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-gbk.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
- 预期: encoding 为 `gbk`content 为 "你好世界"
- 失败排查: 检查 Task 1 的 detectEncoding 逻辑、Task 2 的 readFileSyncWithMetadata 集成
3. 验证 UTF-8 文件读取回归
- `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const fs = require('fs'); fs.writeFileSync('/tmp/test-utf8.txt', 'Hello 世界\n'); const r = readFileSyncWithMetadata('/tmp/test-utf8.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
- 预期: encoding 为 `utf-8`content 为 "Hello 世界"
- 失败排查: 检查 Task 1 的 UTF-8 fatal 验证逻辑
4. 验证 UTF-16LE 文件读取回归
- `bun -e "const fs = require('fs'); const b = Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from('Hello', 'utf16le')]); fs.writeFileSync('/tmp/test-utf16le.txt', b); import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf16le.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
- 预期: encoding 为 `utf-16le`content 为 "Hello"
- 失败排查: 检查 Task 1 的 BOM 检测层、Task 2 的集成
5. 验证 readFileInRange 异步路径的 GBK 支持
- `bun -e "import { readFileInRange } from './src/utils/readFileInRange.js'; const r = await readFileInRange('/tmp/test-gbk.txt', 0); console.log('content:', r.content); console.log('totalLines:', r.totalLines)"`
- 预期: content 为 "你好世界"totalLines 为 1
- 失败排查: 检查 Task 3 的 fast path 改造
6. 验证 GBK 文件写入UTF-8 回退)
- `bun -e "import { writeTextContent } from './src/utils/file.js'; writeTextContent('/tmp/test-gbk-write.txt', '测试写入', 'gbk', 'LF'); const fs = require('fs'); const content = fs.readFileSync('/tmp/test-gbk-write.txt', 'utf8'); console.log('written:', content)"`
- 预期: 文件成功写入,内容为 "测试写入"UTF-8 回退或 GBK 编码均可接受)
- 失败排查: 检查 Task 4 的 writeTextContent 改造和 encodeString 函数
7. 验证编码检测性能
- `bun -e "import { detectEncoding } from './src/utils/encoding.js'; const buf = Buffer.alloc(4096, 0x41); const start = performance.now(); for (let i = 0; i < 1000; i++) detectEncoding(buf); console.log('avg:', (performance.now() - start) / 1000, 'ms')"`
- 预期: 平均检测耗时 < 1ms
- 失败排查: 检查 Task 1 的检测逻辑是否有不必要的重复操作
---

View File

@@ -1,34 +0,0 @@
### Task 0: 环境准备
**背景:**
确保构建和测试工具链在当前开发环境中可用,验证 Bun 运行时对 GBK 编码的 TextDecoder 支持情况。
**涉及文件:**
- 无文件修改,仅验证环境
**执行步骤:**
- [x] 验证 Bun 运行时可用
- 运行命令: `bun --version`
- 预期: 输出 Bun 版本号
- [x] 验证 TypeScript 编译无错误
- 运行命令: `bunx tsc --noEmit 2>&1 | tail -5`
- 预期: 无错误输出(或仅有已知的 pre-existing 错误)
- [x] 验证 Bun 对 GBK 编码的 TextDecoder 支持
- 运行命令: `bun -e "const d = new TextDecoder('gbk', { fatal: true }); const buf = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3]); console.log(d.decode(buf))"`
- 预期: 输出 "你好"GBK 编码的中文字符)
- [x] 验证测试框架可用
- 运行命令: `bun test src/utils/__tests__/hash.test.ts 2>&1 | tail -3`
- 预期: 测试运行成功,无框架错误
**检查步骤:**
- [x] Bun 版本确认
- `bun --version`
- 预期: 输出有效版本号
- [x] GBK 编码支持确认
- `bun -e "console.log(new TextDecoder('gbk').decode(Buffer.from([0xC4, 0xE3, 0xBA, 0xC3])))"`
- 预期: 输出 "你好"
- [x] 现有测试通过
- `bun test src/utils/__tests__/file.test.ts 2>&1 | tail -3`
- 预期: 所有测试通过
---

View File

@@ -1,141 +0,0 @@
### Task 1: 编码检测核心模块
**背景:**
当前 `src/utils/fileRead.ts``detectEncodingForResolvedPath` 仅通过 BOM 头识别 UTF-8 和 UTF-16LE其他所有文件一律返回 `utf8`,导致 GBK 等非 UTF-8 编码文件读取乱码。本 Task 新建独立的编码检测工具模块 `src/utils/encoding.ts`实现三层编码检测算法BOM → UTF-8 fatal 验证 → GBK 回退),为后续 Task 2/3/4 的读写路径改造提供统一的编码检测和解码能力。本 Task 无前置依赖,是后续所有 Task 的基础。
**涉及文件:**
- 新建: `src/utils/encoding.ts`
- 新建: `src/utils/__tests__/encoding.test.ts`
**执行步骤:**
- [x] 创建 `src/utils/encoding.ts`,定义类型
- 位置: 文件顶部
- 导出以下类型:
```typescript
/** 扩展编码类型,覆盖最常见的非 UTF-8 CJK 编码 */
export type FileEncoding = BufferEncoding | 'gbk'
/** TextDecoder 接受的编码名string比 FileEncoding 更宽泛 */
export type DetectedEncoding = string
```
- 原因: 后续 Task 2/3/4 需要这些类型来做编码标注和类型收窄
- [x] 实现 `detectEncoding(buffer: Buffer): FileEncoding` 函数
- 位置: `src/utils/encoding.ts`,类型定义之后
- 三层检测逻辑:
```typescript
export function detectEncoding(buffer: Buffer): FileEncoding {
// Layer 1: BOM 检测(与现有 fileRead.ts 逻辑一致)
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
return 'utf-16le'
}
if (
buffer.length >= 3 &&
buffer[0] === 0xef &&
buffer[1] === 0xbb &&
buffer[2] === 0xbf
) {
return 'utf-8'
}
// Layer 2: UTF-8 fatal 验证
// fatal: true 模式下,无效 UTF-8 字节序列会抛出 TypeError
try {
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
return 'utf-8'
} catch {
// 不是合法 UTF-8进入 Layer 3
}
// Layer 3: GBK 回退
try {
new TextDecoder('gbk', { fatal: true }).decode(buffer)
return 'gbk'
} catch {
// 不是合法 GBKlatin1 作为最终兜底
}
return 'latin1'
}
```
- 原因: BOM 必须优先于 fatal 验证GBK 作为唯一回退避免了多编码链的字节歧义问题latin1 单字节编码永远成功
- [x] 实现 `decodeBuffer(buffer: Buffer, encoding: DetectedEncoding): string` 函数
- 位置: `src/utils/encoding.ts``detectEncoding` 之后
- 逻辑:
```typescript
export function decodeBuffer(
buffer: Buffer,
encoding: DetectedEncoding,
): string {
return new TextDecoder(encoding).decode(buffer)
}
```
- 原因: 统一解码入口,后续 Task 2/3 的读取路径都调用此函数
- [x] 实现 `encodeString(content: string, encoding: DetectedEncoding): { buffer: Buffer; converted: boolean }` 函数
- 位置: `src/utils/encoding.ts``decodeBuffer` 之后
- 逻辑:
```typescript
export function encodeString(
content: string,
encoding: DetectedEncoding,
): { buffer: Buffer; converted: boolean } {
if (encoding === 'utf-8' || encoding === 'utf8') {
return { buffer: Buffer.from(content, 'utf-8'), converted: false }
}
if (encoding === 'utf-16le') {
return { buffer: Buffer.from(content, 'utf-16le'), converted: false }
}
// 其他编码(如 gbk尝试 Buffer.from失败则回退为 UTF-8
try {
const buf = Buffer.from(content, encoding as BufferEncoding)
return { buffer: buf, converted: false }
} catch {
return { buffer: Buffer.from(content, 'utf-8'), converted: true }
}
}
```
- 原因: `Buffer.from` 在 Bun 中可能支持 GBK 编码名,但 Node.js 不支持。try-catch 策略兼容两种运行时;`converted` 标志让 Task 4 的写入路径能向用户报告编码转换
- [x] 为编码检测和解码函数编写单元测试
- 测试文件: `src/utils/__tests__/encoding.test.ts`
- 测试场景:
- **BOM 检测 — UTF-16LE**: 输入 `Buffer.from([0xff, 0xfe, 0x48, 0x00])` → 预期返回 `'utf-16le'`
- **BOM 检测 — UTF-8 BOM**: 输入 `Buffer.from([0xef, 0xbb, 0xbf, 0x48, 0x65])` → 预期返回 `'utf-8'`
- **UTF-8 验证**: 输入 `Buffer.from('Hello, 世界', 'utf-8')` → 预期返回 `'utf-8'`
- **GBK 检测**: 输入 `Buffer.from([0xc4, 0xe3, 0xba, 0xc3])` → 预期返回 `'gbk'`
- **空 buffer**: 输入 `Buffer.alloc(0)` → 预期返回 `'utf-8'`
- **latin1 兜底**: 输入随机字节 `Buffer.from([0x80, 0x81, 0x82, 0x83, 0x84, 0x85])` → 预期返回 `'latin1'`
- **BOM 优先于内容分析**: 输入带 UTF-8 BOM 的数据 → 预期返回 `'utf-8'`
- **decodeBuffer — UTF-8**: 输入 UTF-8 编码的 buffer + encoding `'utf-8'` → 预期返回正确的中文字符串
- **decodeBuffer — GBK**: 输入 GBK 编码的 buffer + encoding `'gbk'` → 预期返回正确的中文字符串
- **decodeBuffer — UTF-16LE**: 输入 UTF-16LE 编码的 buffer + encoding `'utf-16le'` → 预期返回正确字符串
- **decodeBuffer — 空 buffer**: 输入空 buffer → 预期返回空字符串
- **encodeString — UTF-8**: 输入字符串 + encoding `'utf-8'` → 预期 `{ converted: false }`
- **encodeString — utf8 别名**: 输入字符串 + encoding `'utf8'` → 预期 `{ converted: false }`
- **encodeString — UTF-16LE**: 输入字符串 + encoding `'utf-16le'` → 预期 `{ converted: false }`
- **encodeString — GBK**: 输入字符串 + encoding `'gbk'` → 预期返回有效的 Bufferconverted 视运行时而定)
- 运行命令: `bun test src/utils/__tests__/encoding.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `encoding.ts` 文件存在且导出正确
- `grep -c "export" src/utils/encoding.ts`
- 预期: 输出 >= 4至少导出 FileEncoding, DetectedEncoding, detectEncoding, decodeBuffer, encodeString 共 5 个导出)
- [x] 验证类型检查通过
- `bunx tsc --noEmit src/utils/encoding.ts 2>&1 | head -5`
- 预期: 无类型错误输出
- [x] 运行编码检测单元测试
- `bun test src/utils/__tests__/encoding.test.ts`
- 预期: 所有测试通过,无失败用例
**认知变更:**
- [x] [CLAUDE.md] `src/utils/encoding.ts` 是文件编码检测的唯一入口,提供 `detectEncoding`三层检测BOM → UTF-8 fatal → GBK 回退)和 `decodeBuffer`/`encodeString` 函数。检测基于文件头部 4KB零外部依赖仅使用 TextDecoder API。`FileEncoding` 类型为 `BufferEncoding | 'gbk'`,覆盖最常见非 UTF-8 CJK 编码。latin1 作为最终兜底编码(单字节编码永远成功)。
---

View File

@@ -1,163 +0,0 @@
### Task 2: 同步读取路径集成
**背景:**
当前同步读取路径(`fileRead.ts``file.ts``fileReadCache.ts`)的编码检测仅通过 BOM 头识别 UTF-8 和 UTF-16LE非 BOM 编码文件一律按 UTF-8 读取导致乱码。本 Task 将 `detectEncodingForResolvedPath` 的内部实现从 BOM-only 升级为调用 Task 1 创建的 `encoding.ts` 三层检测,并将返回类型从 `BufferEncoding` 扩展为 `FileEncoding`。同时将所有 `fs.readFileSync(path, { encoding })` 调用改为先读 Buffer 再用 `decodeBuffer` 解码,以支持 `gbk` 等非 `BufferEncoding` 编码。本 Task 依赖 Task 1`src/utils/encoding.ts`),输出被 Task 4写入路径适配依赖。
**涉及文件:**
- 修改: `src/utils/fileRead.ts`
- 修改: `src/utils/file.ts`
- 修改: `src/utils/fileReadCache.ts`
- 新建: `src/utils/__tests__/fileRead.test.ts`
**执行步骤:**
- [x]`fileRead.ts` 中导入 `encoding.ts` 的类型和函数
- 位置: `src/utils/fileRead.ts` 文件顶部 import 区域,在 `import { getFsImplementation, safeResolvePath } from './fsOperations.js'` 之后
- 添加导入:
```typescript
import { type FileEncoding, decodeBuffer, detectEncoding } from './encoding.js'
```
- 原因: 后续步骤需要 `FileEncoding` 类型、`detectEncoding` 检测函数和 `decodeBuffer` 解码函数
- [x] 改造 `detectEncodingForResolvedPath` 函数,使用 `encoding.ts` 的三层检测
- 位置: `src/utils/fileRead.ts` 的 `detectEncodingForResolvedPath` 函数
- 将函数体替换为以下逻辑:
```typescript
export function detectEncodingForResolvedPath(
resolvedPath: string,
): FileEncoding {
const { buffer, bytesRead } = getFsImplementation().readSync(resolvedPath, {
length: 4096,
})
// Empty files default to utf8 — nothing to detect
if (bytesRead === 0) {
return 'utf8'
}
return detectEncoding(buffer.subarray(0, bytesRead))
}
```
- 关键变更:
- 返回类型从 `BufferEncoding` 改为 `FileEncoding`
- 删除内联的 BOM 检测逻辑,改为调用 `detectEncoding(buffer.subarray(0, bytesRead))`
- 使用 `buffer.subarray(0, bytesRead)` 截取实际读取的字节,避免尾部零字节干扰检测
- 原因: 将检测逻辑委托给 `encoding.ts` 的三层算法,消除代码重复
- [x] 改造 `readFileSyncWithMetadata` 函数,支持非 `BufferEncoding` 解码
- 位置: `src/utils/fileRead.ts` 的 `readFileSyncWithMetadata` 函数
- 将函数签名和内部逻辑改为:
```typescript
export function readFileSyncWithMetadata(filePath: string): {
content: string
encoding: FileEncoding
lineEndings: LineEndingType
} {
const fs = getFsImplementation()
const { resolvedPath, isSymlink } = safeResolvePath(fs, filePath)
if (isSymlink) {
logForDebugging(`Reading through symlink: ${filePath} -> ${resolvedPath}`)
}
const encoding = detectEncodingForResolvedPath(resolvedPath)
// Read raw Buffer first — fs.readFileSync encoding option only accepts
// BufferEncoding, not gbk etc.
const rawBuffer = fs.readFileBytesSync(resolvedPath)
const raw = decodeBuffer(rawBuffer, encoding)
const lineEndings = detectLineEndingsForString(raw.slice(0, 4096))
return {
content: raw.replaceAll('\r\n', '\n'),
encoding,
lineEndings,
}
}
```
- 关键变更:
- 返回类型中 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`
- `fs.readFileSync(resolvedPath, { encoding })` 改为 `fs.readFileBytesSync(resolvedPath)` 读取 Buffer
- 新增 `decodeBuffer(rawBuffer, encoding)` 解码为字符串
- 原因: `fs.readFileSync` 的 `encoding` 选项只接受 `BufferEncoding`utf8/utf16le/latin1 等),传入 `'gbk'` 会在运行时报错
- [x] 更新 `file.ts` 中 `detectFileEncoding` 的返回类型
- 位置: `src/utils/file.ts` 的 `detectFileEncoding` 函数签名
- 将 `): BufferEncoding {` 改为 `): FileEncoding {`
- 在文件顶部 import 区域添加:
```typescript
import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'
```
- 原因: `detectFileEncoding` 调用 `detectEncodingForResolvedPath`,返回类型已改为 `FileEncoding`
- [x] 更新 `file.ts` 中 `detectLineEndings` 的 encoding 参数类型和解码逻辑
- 位置: `src/utils/file.ts` 的 `detectLineEndings` 函数
- 将函数签名改为:
```typescript
export function detectLineEndings(
filePath: string,
encoding: FileEncoding = 'utf8',
): LineEndingType {
```
- 将内部 `buffer.toString(encoding, 0, bytesRead)` 改为:
```typescript
const content = decodeBuffer(buffer.subarray(0, bytesRead), encoding)
```
- 原因: `buffer.toString('gbk')` 不可靠,统一使用 `decodeBuffer` 通过 `TextDecoder` 解码
- [x] 更新 `fileReadCache.ts` 的类型和解码逻辑
- 位置: `src/utils/fileReadCache.ts`
- 在文件顶部 import 区域添加:
```typescript
import { type FileEncoding, decodeBuffer } from './encoding.js'
```
- 将 `CachedFileData` 类型中 `encoding: BufferEncoding` 改为 `encoding: FileEncoding`
- 将 `readFile` 方法返回类型改为 `{ content: string; encoding: FileEncoding }`
- 将缓存未命中读取逻辑改为:
```typescript
const encoding = detectFileEncoding(filePath)
const rawBuffer = fs.readFileBytesSync(filePath)
const content = decodeBuffer(rawBuffer, encoding).replaceAll('\r\n', '\n')
```
- 原因: 与 `fileRead.ts` 相同——必须改为 Buffer 读取 + `decodeBuffer` 解码
- [x] 为改造后的 `detectEncodingForResolvedPath` 和 `readFileSyncWithMetadata` 编写单元测试
- 测试文件: `src/utils/__tests__/fileRead.test.ts`
- 测试场景:
- **UTF-8 文件读取**: 创建临时 UTF-8 文件 → 返回 `encoding: 'utf-8'`content 与写入内容一致
- **GBK 文件读取**: 创建临时 GBK 编码文件 → 返回 `encoding: 'gbk'`content 包含正确的中文字符
- **空文件读取**: 创建空文件 → 返回 `encoding: 'utf8'`content 为空字符串
- **UTF-16LE BOM 文件读取**: 创建带 BOM 的 UTF-16LE 文件 → 返回 `encoding: 'utf-16le'`
- **detectEncodingForResolvedPath 返回类型**: 验证返回值为 `FileEncoding` 类型
- Mock 策略: 使用 `tests/mocks/debug.ts` mock `debug.ts`,使用 `tests/mocks/log.ts` mock `log.ts`
- 运行命令: `bun test src/utils/__tests__/fileRead.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `fileRead.ts` 的导入和返回类型已更新
- `grep -n "FileEncoding\|decodeBuffer\|detectEncoding" src/utils/fileRead.ts`
- 预期: 输出包含 import 行中的 `FileEncoding`、`decodeBuffer`,以及函数体中的 `detectEncoding` 调用
- [x] 验证 `file.ts` 的类型已更新
- `grep -n "FileEncoding\|decodeBuffer" src/utils/file.ts`
- 预期: `detectFileEncoding` 返回 `FileEncoding``detectLineEndings` 参数类型为 `FileEncoding`
- [x] 验证 `fileReadCache.ts` 的类型已更新
- `grep -n "FileEncoding\|decodeBuffer" src/utils/fileReadCache.ts`
- 预期: `CachedFileData` 和 `readFile` 返回类型使用 `FileEncoding`
- [x] 验证 `fileRead.ts` 中不再有内联 BOM 检测逻辑
- `grep -c "0xff\|0xfe\|0xef\|0xbb\|0xbf" src/utils/fileRead.ts`
- 预期: 输出为 0
- [x] 运行 fileRead 单元测试
- `bun test src/utils/__tests__/fileRead.test.ts`
- 预期: 所有测试通过
- [x] 运行 precheck 确认无类型/lint/测试错误
- `bun run precheck`
- 预期: 零错误通过
**认知变更:**
- [x] [CLAUDE.md] `fs.readFileSync(path, { encoding })` 的 `encoding` 选项只接受 `BufferEncoding`utf8/utf16le/latin1/ascii/binary/hex/base64/ucs2/utf16le不支持 `gbk` 等 ICU 编码名。读取非 UTF-8 文件时必须先 `fs.readFileSync(path)` 读 Buffer再用 `TextDecoder` 解码。项目中所有文件读取路径fileRead.ts、fileReadCache.ts、file.ts已统一使用 `decodeBuffer` 函数处理此逻辑。
---

View File

@@ -1,161 +0,0 @@
### Task 3: 异步读取路径改造
**背景:**
当前 `src/utils/readFileInRange.ts` 是 FileReadTool 的核心异步读取函数,提供 fast path小文件整体读入和 streaming path大文件逐块扫描两条路径两者均硬编码 `encoding: 'utf8'`,导致非 UTF-8 编码文件读取乱码。本 Task 将两条路径改造为 Buffer 读取 + 编码检测 + TextDecoder 解码模式。fast path 改造简单(整体读 Buffer 后检测解码streaming path 需要两阶段设计(先收集前 4KB 做编码检测,再用 `TextDecoder({ stream: true })` 逐 chunk 解码)。本 Task 依赖 Task 1`src/utils/encoding.ts``detectEncoding``decodeBuffer`),输出被 Task 4 依赖(通过 `readFileInRange` 的返回值间接影响)。
**涉及文件:**
- 修改: `src/utils/readFileInRange.ts`
- 新建: `src/utils/__tests__/readFileInRange.test.ts`
**执行步骤:**
- [x]`readFileInRange.ts` 中导入 `encoding.ts` 的函数
- 位置: `src/utils/readFileInRange.ts` 文件顶部 import 区域,在 `import { formatFileSize } from './format.js'` 之后
- 添加导入:
```typescript
import { detectEncoding, decodeBuffer } from './encoding.js'
```
- 原因: fast path 和 streaming path 都需要 `detectEncoding` 做编码检测fast path 需要 `decodeBuffer` 做一次性解码
- [x] 改造 fast path — 将 `readFile` 从 UTF-8 字符串读取改为 Buffer 读取 + 检测 + 解码
- 位置: `src/utils/readFileInRange.ts` 的 `readFileInRange` 函数内 fast path 分支
- 将以下代码:
```typescript
const text = await readFile(filePath, { encoding: 'utf8', signal })
return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
```
替换为:
```typescript
const rawBuffer = await readFile(filePath, { signal })
const encoding = detectEncoding(rawBuffer)
const text = decodeBuffer(rawBuffer, encoding)
return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
```
- 关键变更: `readFile` 去掉 `encoding: 'utf8'` 选项,返回 `Buffer`;调用 `detectEncoding(rawBuffer)` 检测编码;调用 `decodeBuffer(rawBuffer, encoding)` 解码为字符串。
- 原因: `readFile` 的 `encoding` 选项只支持 `BufferEncoding`,不支持 `gbk` 等 ICU 编码名
- [x] 改造 streaming path — 扩展 `StreamState` 类型,增加编码检测和解码相关字段
- 位置: `src/utils/readFileInRange.ts` 的 `StreamState` 类型定义
- 在现有字段之后添加以下字段:
```typescript
type StreamState = {
// ... 现有字段保持不变 ...
/** 编码检测状态null 表示尚未检测string 表示已检测完成 */
encoding: string | null
/** TextDecoder 实例:检测完成后创建,用于逐 chunk 流式解码 */
decoder: TextDecoder | null
/** 检测阶段缓冲区:收集原始字节直到满 4KB 或 stream 结束 */
detectionBuffer: number[]
}
```
- 原因: streaming 模式下 chunk 是增量到达的,需要缓冲阶段收集足够字节来调用 `detectEncoding`
- [x] 改造 `streamOnData` — 处理 Buffer chunk实现两阶段检测阶段 + 解码阶段)
- 位置: `src/utils/readFileInRange.ts` 的 `streamOnData` 函数
- 将函数签名从 `streamOnData(this: StreamState, chunk: string): void` 改为 `streamOnData(this: StreamState, chunk: Buffer): void`
- 替换函数体为两阶段逻辑:
```typescript
function streamOnData(this: StreamState, chunk: Buffer): void {
this.totalBytesRead += chunk.length
// ... maxBytes 检查保持不变 ...
// Phase 1: 编码检测阶段
if (this.encoding === null) {
for (let i = 0; i < chunk.length; i++) {
this.detectionBuffer.push(chunk[i])
}
if (this.detectionBuffer.length >= 4096) {
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
this.decoder = new TextDecoder(this.encoding, { stream: true })
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
this.detectionBuffer = []
processTextChunk(this, decoded)
}
return
}
// Phase 2: 解码阶段
const decoded = this.decoder!.decode(chunk, { stream: true })
processTextChunk(this, decoded)
}
```
- 原因: 两阶段设计确保编码检测在足够数据上执行(至少 4KB检测完成后用 `TextDecoder({ stream: true })` 逐 chunk 解码
- [x] 提取行扫描逻辑为独立的 `processTextChunk` 辅助函数
- 位置: `src/utils/readFileInRange.ts`,在 `streamOnData` 函数定义之前
- 从原 `streamOnData` 提取行扫描逻辑到独立函数 `processTextChunk(state: StreamState, text: string): void`
- 行扫描逻辑与原实现完全一致,仅变量名从 `this.` 改为 `state.`
- 原因: 检测阶段和解码阶段复用同一段行扫描逻辑
- [x] 改造 `streamOnEnd` — 处理检测阶段缓冲区残留和最终 fragment
- 位置: `src/utils/readFileInRange.ts` 的 `streamOnEnd` 函数
- 在函数体开头插入检测阶段完成逻辑:
```typescript
if (this.encoding === null) {
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
this.decoder = new TextDecoder(this.encoding, { stream: true })
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
this.detectionBuffer = []
processTextChunk(this, decoded)
}
```
- 原因: 小文件可能 < 4KBstream 在检测缓冲区未满时就结束。必须在 `streamOnEnd` 中完成检测和解码
- [x] 改造 `readFileInRangeStreaming` — 创建 Buffer 模式的 stream初始化新增字段
- 位置: `src/utils/readFileInRange.ts` 的 `readFileInRangeStreaming` 函数
- 将 `createReadStream` 调用去掉 `encoding: 'utf8'` 选项
- 在 `state` 对象初始化中添加新字段: `encoding: null, decoder: null, detectionBuffer: []`
- 原因: 去掉 `encoding: 'utf8'` 后,`data` 事件回调接收 `Buffer` 对象
- [x] 更新文件顶部注释,反映编码检测能力
- 位置: `src/utils/readFileInRange.ts` 文件顶部注释
- 注释已更新为: `Both paths auto-detect encoding via encoding.ts (BOM → UTF-8 fatal → fallback chain), decode with TextDecoder, and strip BOM and \r (CRLF → LF).`
- [x] 为改造后的 `readFileInRange` 编写单元测试
- 测试文件: `src/utils/__tests__/readFileInRange.test.ts`
- 测试场景:
- **Fast path — UTF-8 文件**: 创建临时 UTF-8 文件 → 返回正确的 `content`、`lineCount`、`totalLines`
- **Fast path — GBK 文件**: 创建临时 GBK 编码文件 → 返回正确的中文内容(非乱码),`totalBytes` 正确
- **Fast path — 带行范围读取 GBK 文件**: 创建包含多行的 GBK 文件 → 返回指定行范围,内容正确
- **Streaming path — 大 UTF-8 文件**: 创建超过 10MB 阈值的 UTF-8 文件 → 返回正确内容
- **Streaming path — 大 GBK 文件**: 创建超过 10MB 阈值的 GBK 编码文件 → 返回正确的中文内容
- **BOM 剥离**: 创建带 UTF-8 BOM 的文件 → `content` 不包含 BOM 字符
- **空文件**: 创建空文件 → `content` 为空字符串,`totalLines` 为 1`totalBytes` 为 0
- 运行命令: `bun test src/utils/__tests__/readFileInRange.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `readFileInRange.ts` 已导入 `encoding.ts` 的函数
- `grep -n "detectEncoding\|decodeBuffer" src/utils/readFileInRange.ts`
- 预期: import 行包含 `detectEncoding` 和 `decodeBuffer`,函数体中包含调用
- [x] 验证 streaming path 不再硬编码 `encoding: 'utf8'`
- `grep -n "encoding: 'utf8'\|encoding: \"utf8\"" src/utils/readFileInRange.ts`
- 预期: 无匹配结果
- [x] 验证 `createReadStream` 调用无 encoding 选项
- `grep -A3 "createReadStream" src/utils/readFileInRange.ts`
- 预期: `createReadStream` 的选项对象中不包含 `encoding` 属性
- [x] 验证 `StreamState` 类型包含编码检测新字段
- `grep -n "encoding:\|decoder:\|detectionBuffer:" src/utils/readFileInRange.ts`
- 预期: `StreamState` 类型定义中包含 `encoding`、`decoder`、`detectionBuffer` 字段
- [x] 验证 `processTextChunk` 函数存在
- `grep -n "function processTextChunk" src/utils/readFileInRange.ts`
- 预期: 函数定义存在
- [x] 运行 readFileInRange 单元测试
- `bun test src/utils/__tests__/readFileInRange.test.ts`
- 预期: 所有测试通过
- [x] 运行 precheck 确认无类型/lint/测试错误
- `bun run precheck`
- 预期: 零错误通过
**认知变更:**
- [x] [CLAUDE.md] `readFileInRange.ts` 的 streaming path 使用两阶段编码检测:先收集前 4KB 字节调用 `detectEncoding`,再用 `TextDecoder({ stream: true })` 逐 chunk 流式解码。`TextDecoder` 的 `{ stream: true }` 模式会自动处理多字节字符跨 chunk 边界问题。对于 < 4KB 的小文件,检测在 `streamOnEnd` 中完成。
---

View File

@@ -1,155 +0,0 @@
### Task 4: 写入路径和工具层适配
**背景:**
[业务语境] — 当用户通过 FileEditTool 或 FileWriteTool 编辑非 UTF-8 编码文件(如 GBK写入操作需要将内部 UTF-8 字符串编码回原文件编码,否则写入的内容会乱码。当前 `writeTextContent` 只接受 `BufferEncoding` 类型,无法处理 gbk 等编码。
[修改原因] — `writeTextContent``encoding` 参数类型为 `BufferEncoding``writeFileSyncAndFlush_DEPRECATED` 内部直接将 encoding 传给 `fs.writeFileSync`(只接受标准 BufferEncoding`FileEditTool.validateInput` 中硬编码了 BOM-only 编码检测,无法识别 GBK 文件。
[上下游影响] — 本 Task 依赖 Task 1 创建的 `encodeString` 函数和 `FileEncoding` 类型。`FileEditTool``FileWriteTool` 通过 `writeTextContent` 间接依赖本 Task 的改造。BashTool 和 NotebookEditTool 也调用 `writeTextContent`签名变更后它们无需额外改动encoding 参数类型由上游传入,自动兼容)。
**涉及文件:**
- 修改: `src/utils/file.ts`
- 修改: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
**执行步骤:**
- [x]`src/utils/file.ts` 中合并 `encodeString` 到 Task 2 已创建的 `encoding.js` 导入
- 位置: 文件导入区域Task 2 已添加的 `import { type FileEncoding, decodeBuffer } from './encoding.js'`
- 将该行改为: `import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'`
- 原因: 避免对同一模块创建两个 import 语句
- [x]`writeTextContent``encoding` 参数类型从 `BufferEncoding` 改为 `FileEncoding`
- 位置: `src/utils/file.ts:writeTextContent()`
- 修改函数签名:
```typescript
export function writeTextContent(
filePath: string,
content: string,
encoding: FileEncoding,
endings: LineEndingType,
): void
```
- 修改函数体,在行尾处理之后、调用 `writeFileSyncAndFlush_DEPRECATED` 之前,增加编码判断逻辑:
```typescript
const BUFFER_ENCODINGS = new Set<string>([
'utf8', 'utf-8', 'utf16le', 'ucs2', 'ucs-2',
'ascii', 'latin1', 'binary', 'base64', 'hex',
])
if (BUFFER_ENCODINGS.has(encoding)) {
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding: encoding as BufferEncoding })
} else {
// 非 BufferEncoding如 gbk使用 encodeString 获取 Buffer
const { buffer, converted } = encodeString(toWrite, encoding)
writeFileSyncAndFlush_DEPRECATED(filePath, buffer, { buffer })
if (converted) {
logForDebugging(
`writeTextContent: encoding '${encoding}' unsupported for write, fell back to UTF-8 for ${filePath}`,
{ level: 'warn' },
)
}
}
```
- 原因: `fs.writeFileSync` 只接受标准 BufferEncoding对于 gbk 等编码必须先转为 Buffer 再写入
- [x] 扩展 `writeFileSyncAndFlush_DEPRECATED` 支持 Buffer 写入
- 位置: `src/utils/file.ts:writeFileSyncAndFlush_DEPRECATED()`
- 修改函数签名中 `content` 参数类型和 `options` 类型:
```typescript
export function writeFileSyncAndFlush_DEPRECATED(
filePath: string,
content: string | Buffer,
options: { encoding?: BufferEncoding; mode?: number; buffer?: Buffer } = {},
): void
```
- 修改原子写入路径的 `writeOptions` 构建逻辑:
```typescript
const isBufferWrite = Buffer.isBuffer(content) || options.buffer !== undefined
const writeData = options.buffer ?? content
const writeOptions: {
encoding?: BufferEncoding
flush: boolean
mode?: number
} = {
flush: true,
...(isBufferWrite ? {} : { encoding: options.encoding ?? 'utf-8' }),
}
```
- 修改非原子回退路径,使用相同的 `isBufferWrite` / `writeData` / `writeOptions` 模式
- 原因: `fs.writeFileSync(path, buffer)` 可以直接写入 Buffer不需要 encoding 参数
- [x] 在 `FileEditTool.ts` 中导入 `FileEncoding` 和 `detectEncoding` / `decodeBuffer`
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts` 导入区域
- 添加: `import { detectEncoding, decodeBuffer, type FileEncoding } from 'src/utils/encoding.js'`
- 原因: `validateInput` 编码检测和 `readFileForEdit` 返回类型需要 `FileEncoding` 类型
- [x] 将 `readFileForEdit` 返回类型中的 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts:readFileForEdit()`
- 修改返回类型声明:
```typescript
function readFileForEdit(absoluteFilePath: string): {
content: string
fileExists: boolean
encoding: FileEncoding
lineEndings: LineEndingType
}
```
- 原因: `readFileSyncWithMetadata` 返回的 `encoding` 类型已由 Task 2 改为 `FileEncoding`
- [x] 改造 `FileEditTool.validateInput` 中的编码检测逻辑
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts:validateInput()`
- 将现有的 BOM-only 编码检测:
```typescript
const encoding: BufferEncoding =
fileBuffer.length >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe
? 'utf16le'
: 'utf8'
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
```
- 替换为:
```typescript
const encoding: FileEncoding = detectEncoding(fileBuffer)
fileContent = decodeBuffer(fileBuffer, encoding).replaceAll('\r\n', '\n')
```
- 原因: 使 validateInput 也能正确识别 GBK 文件,避免编辑时因编码检测不一致导致 old_string 匹配失败
- [x] 为 `writeTextContent` 的多编码写入能力编写单元测试
- 测试文件: `src/utils/__tests__/file.test.ts`
- 在现有测试 describe 块之后追加新的 describe('writeTextContent with multi-encoding') 块
- 测试场景:
- UTF-8 写入: 写入 UTF-8 内容 → 文件内容正确,无回退警告
- UTF-16LE 写入: 写入 UTF-16LE 内容(含 BOM → 文件二进制内容与预期一致
- GBK 写入回退: 对 gbk 编码调用 `writeTextContent` → 文件以 UTF-8 写入(`encodeString` 回退行为),内容不损坏
- CRLF 行尾 + GBK: `endings: 'CRLF'` + gbk 编码 → 行尾正确转换为 `\r\n`,编码回退为 UTF-8
- 注意: 需要 mock `src/utils/debug.ts`(使用共享 mock `tests/mocks/debug.ts`
- 运行命令: `bun test src/utils/__tests__/file.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `writeTextContent` 签名使用 `FileEncoding` 类型
- `grep -n 'encoding: FileEncoding' src/utils/file.ts`
- 预期: 输出包含 `writeTextContent` 函数定义行
- [x] 验证 `writeFileSyncAndFlush_DEPRECATED` 支持 Buffer 写入
- `grep -n 'content: string | Buffer' src/utils/file.ts`
- 预期: 输出包含 `writeFileSyncAndFlush_DEPRECATED` 函数定义行
- [x] 验证 `FileEditTool.readFileForEdit` 返回类型已更新
- `grep -n 'encoding: FileEncoding' packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
- 预期: 输出包含 `readFileForEdit` 函数的返回类型声明
- [x] 验证 `FileEditTool.validateInput` 使用 `detectEncoding`
- `grep -n 'detectEncoding' packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
- 预期: 输出包含 validateInput 内部的调用
- [x] 运行 file.ts 单元测试
- `bun test src/utils/__tests__/file.test.ts`
- 预期: 所有测试通过,无新增失败
- [x] 运行 FileEditTool 工具函数测试
- `bun test packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts`
- 预期: 所有现有测试通过
- [x] 运行完整 precheck
- `bun run precheck`
- 预期: typecheck + lint + test 零错误通过
---

View File

@@ -1,49 +0,0 @@
# 多编码文件工具 执行计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**目标:** 为文件读写工具添加自动编码检测,支持 GBK 编码的透明读写latin1 作为最终兜底)。
**技术栈:** TextDecoder/TextEncoder零外部依赖、Bun test 框架、TypeScript strict mode
**设计文档:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-design.md
**范围变更:** 仅保留 GBK 编码支持Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030/ISO-8859-1 已移除。
## 改动总览
新建编码检测核心模块 `src/utils/encoding.ts`提供三层检测BOM → UTF-8 fatal 验证 → GBK 回退 → latin1 兜底和解码工具函数。同步读取路径fileRead.ts → file.ts → fileReadCache.ts集成新检测逻辑异步读取路径readFileInRange.ts改造为 Buffer 读取 + 检测后解码。写入路径writeTextContent扩展类型支持新编码名非标准编码回退为 UTF-8 写入。FileEditTool 和 FileWriteTool 仅需类型适配。
---
## 任务索引
### Task 0: 环境准备
📄 详情见: `spec-plan-task-0.md`
验证构建工具链和测试环境是否就绪,确认 Bun 运行时对 GBK 编码的 TextDecoder 支持。
### Task 1: 编码检测核心模块
📄 详情见: `spec-plan-task-1.md`
新建 `src/utils/encoding.ts`实现三层编码检测算法BOM → UTF-8 fatal 验证 → GBK 回退)和 Buffer 解码/编码函数。
### Task 2: 同步读取路径集成
📄 详情见: `spec-plan-task-2.md`
改造 `fileRead.ts``file.ts` 的编码检测,集成新模块,更新类型定义。
### Task 3: 异步读取路径改造
📄 详情见: `spec-plan-task-3.md`
改造 `readFileInRange.ts` 的 fast path 和 streaming path支持非 UTF-8 编码。
### Task 4: 写入路径和工具层适配
📄 详情见: `spec-plan-task-4.md`
扩展写入路径类型,更新 FileEditTool/FileWriteTool 的类型注解。
### Acceptance Task
📄 详情见: `spec-plan-acceptance.md`
端到端验证所有功能是否正确实现。

View File

@@ -377,9 +377,6 @@ const cronJitterConfigModule =
require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js') require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
const cronGate = const cronGate =
require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') 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 */ /* eslint-enable @typescript-eslint/no-require-imports */
const SHUTDOWN_TEAM_PROMPT = `<system-reminder> const SHUTDOWN_TEAM_PROMPT = `<system-reminder>
@@ -985,7 +982,14 @@ export async function runHeadless(
// the forked agent mid-flight. Gated by isExtractModeActive so the // the forked agent mid-flight. Gated by isExtractModeActive so the
// tengu_slate_thimble flag controls non-interactive extraction end-to-end. // tengu_slate_thimble flag controls non-interactive extraction end-to-end.
if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) { 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( gracefulShutdownSync(

View File

@@ -1,2 +1,10 @@
// Auto-generated stub — replace with real implementation import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
export type Transport = any
/** 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 // 主动关闭并释放资源
}

View File

@@ -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');
});
});

View File

@@ -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 { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.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/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock) mock.module('src/utils/debug.ts', debugMock)
@@ -9,42 +27,40 @@ mock.module('bun:bundle', () => ({
})) }))
// ── Analytics mock ────────────────────────────────────────────────────────── // ── Analytics mock ──────────────────────────────────────────────────────────
const realAnalytics = await import('src/services/analytics/index.js')
const logEventMock = mock(() => {}) const logEventMock = mock(() => {})
mock.module('src/services/analytics/index.js', () => ({ mock.module('src/services/analytics/index.js', () => ({
...realAnalytics,
logEvent: logEventMock, logEvent: logEventMock,
logEventAsync: mock(() => Promise.resolve()),
_resetForTesting: mock(() => {}),
attachAnalyticsSink: mock(() => {}),
stripProtoFields: mock((v: unknown) => v),
})) }))
// ── agentsApi mock ────────────────────────────────────────────────────────── // ── Auth / OAuth mocks ──────────────────────────────────────────────────────
const listMock = mock(async () => [ const realAuth = await import('src/utils/auth.js')
{ mock.module('src/utils/auth.js', () => ({
id: 'agt_1', ...realAuth,
cron_expr: '0 9 * * 1', getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ap' }),
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,
})) }))
const deleteMock = mock(async () => undefined) mock.module('src/services/oauth/client.js', () => ({
const runMock = mock(async () => ({ run_id: 'run_123' })) getOrganizationUUID: async () => 'org-uuid-ap',
}))
mock.module('src/commands/agents-platform/agentsApi.js', () => ({ mock.module('src/constants/oauth.js', () => ({
listAgents: listMock, getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
createAgent: createMock, }))
deleteAgent: deleteMock, const realTeleportApi = await import('src/utils/teleport/api.js')
runAgent: runMock, 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 ─────────────────────────────────────────────────────────────── // ── cron mock ───────────────────────────────────────────────────────────────
@@ -57,19 +73,42 @@ mock.module('src/utils/cron.js', () => ({
computeNextCronRun: () => null, 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 let callAgentsPlatform: typeof import('../launchAgentsPlatform.js').callAgentsPlatform
beforeAll(async () => { beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../launchAgentsPlatform.js') const mod = await import('../launchAgentsPlatform.js')
callAgentsPlatform = mod.callAgentsPlatform callAgentsPlatform = mod.callAgentsPlatform
}) })
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => { beforeEach(() => {
logEventMock.mockClear() logEventMock.mockClear()
listMock.mockClear() axiosGetMock.mockClear()
createMock.mockClear() axiosPostMock.mockClear()
deleteMock.mockClear() axiosDeleteMock.mockClear()
runMock.mockClear()
}) })
function makeContext() { function makeContext() {
@@ -79,8 +118,23 @@ function makeContext() {
describe('callAgentsPlatform', () => { describe('callAgentsPlatform', () => {
test('list (empty args) calls listAgents and returns element', async () => { test('list (empty args) calls listAgents and returns element', async () => {
const onDone = mock(() => {}) 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(), '') const result = await callAgentsPlatform(onDone, makeContext(), '')
expect(listMock).toHaveBeenCalledTimes(1) expect(axiosGetMock).toHaveBeenCalledTimes(1)
expect(onDone).toHaveBeenCalledTimes(1) expect(onDone).toHaveBeenCalledTimes(1)
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith( expect(logEventMock).toHaveBeenCalledWith(
@@ -91,21 +145,43 @@ describe('callAgentsPlatform', () => {
test('list sub-command calls listAgents', async () => { test('list sub-command calls listAgents', async () => {
const onDone = mock(() => {}) const onDone = mock(() => {})
axiosGetMock.mockResolvedValueOnce({
data: { data: [] },
status: 200,
})
await callAgentsPlatform(onDone, makeContext(), 'list') await callAgentsPlatform(onDone, makeContext(), 'list')
expect(listMock).toHaveBeenCalledTimes(1) expect(axiosGetMock).toHaveBeenCalledTimes(1)
}) })
test('create with valid cron calls createAgent', async () => { test('create with valid cron calls createAgent', async () => {
const onDone = mock(() => {}) 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( const result = await callAgentsPlatform(
onDone, onDone,
makeContext(), makeContext(),
'create 0 9 * * 1 Run standup', 'create 0 9 * * 1 Run standup',
) )
expect(createMock).toHaveBeenCalledTimes(1) expect(axiosPostMock).toHaveBeenCalledTimes(1)
const [cron, prompt] = createMock.mock.calls[0] as [string, string] const callArgs = axiosPostMock.mock.calls[0] as unknown as [
expect(cron).toBe('0 9 * * 1') string,
expect(prompt).toBe('Run standup') 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(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith( expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_create', 'tengu_agents_platform_create',
@@ -122,7 +198,7 @@ describe('callAgentsPlatform', () => {
'create INVALID INVALID * * * my prompt', 'create INVALID INVALID * * * my prompt',
) )
// cron = 'INVALID INVALID * * *', mock returns null → no API call // cron = 'INVALID INVALID * * *', mock returns null → no API call
expect(createMock).not.toHaveBeenCalled() expect(axiosPostMock).not.toHaveBeenCalled()
expect(logEventMock).toHaveBeenCalledWith( expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed', 'tengu_agents_platform_failed',
expect.anything(), expect.anything(),
@@ -131,12 +207,18 @@ describe('callAgentsPlatform', () => {
test('delete with id calls deleteAgent', async () => { test('delete with id calls deleteAgent', async () => {
const onDone = mock(() => {}) const onDone = mock(() => {})
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
const result = await callAgentsPlatform( const result = await callAgentsPlatform(
onDone, onDone,
makeContext(), makeContext(),
'delete agt_abc', '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(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith( expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_delete', 'tengu_agents_platform_delete',
@@ -146,12 +228,23 @@ describe('callAgentsPlatform', () => {
test('run with id calls runAgent', async () => { test('run with id calls runAgent', async () => {
const onDone = mock(() => {}) const onDone = mock(() => {})
axiosPostMock.mockResolvedValueOnce({
data: { run_id: 'run_123' },
status: 200,
})
const result = await callAgentsPlatform( const result = await callAgentsPlatform(
onDone, onDone,
makeContext(), makeContext(),
'run agt_xyz', '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(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith( expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_run', 'tengu_agents_platform_run',
@@ -167,11 +260,11 @@ describe('callAgentsPlatform', () => {
'tengu_agents_platform_failed', 'tengu_agents_platform_failed',
expect.anything(), expect.anything(),
) )
expect(listMock).not.toHaveBeenCalled() expect(axiosGetMock).not.toHaveBeenCalled()
}) })
test('listAgents API error → error view returned', async () => { test('listAgents API error → error view returned', async () => {
listMock.mockRejectedValueOnce(new Error('network error')) axiosGetMock.mockRejectedValueOnce(new Error('network error'))
const onDone = mock(() => {}) const onDone = mock(() => {})
const result = await callAgentsPlatform(onDone, makeContext(), 'list') const result = await callAgentsPlatform(onDone, makeContext(), 'list')
expect(result).not.toBeNull() expect(result).not.toBeNull()
@@ -183,6 +276,10 @@ describe('callAgentsPlatform', () => {
test('started event fires on every call', async () => { test('started event fires on every call', async () => {
const onDone = mock(() => {}) const onDone = mock(() => {})
axiosGetMock.mockResolvedValueOnce({
data: { data: [] },
status: 200,
})
await callAgentsPlatform(onDone, makeContext(), '') await callAgentsPlatform(onDone, makeContext(), '')
expect(logEventMock).toHaveBeenCalledWith( expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_started', '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 () => { test('createAgent API error → error view returned', async () => {
createMock.mockRejectedValueOnce(new Error('subscription required')) axiosPostMock.mockRejectedValueOnce(new Error('subscription required'))
const onDone = mock(() => {}) const onDone = mock(() => {})
const result = await callAgentsPlatform( const result = await callAgentsPlatform(
onDone, onDone,
@@ -212,7 +309,7 @@ describe('callAgentsPlatform', () => {
}) })
test('deleteAgent API error → error view returned', async () => { test('deleteAgent API error → error view returned', async () => {
deleteMock.mockRejectedValueOnce(new Error('not found')) axiosDeleteMock.mockRejectedValueOnce(new Error('not found'))
const onDone = mock(() => {}) const onDone = mock(() => {})
const result = await callAgentsPlatform( const result = await callAgentsPlatform(
onDone, onDone,
@@ -231,7 +328,7 @@ describe('callAgentsPlatform', () => {
}) })
test('runAgent API error → error view returned', async () => { test('runAgent API error → error view returned', async () => {
runMock.mockRejectedValueOnce(new Error('run failed')) axiosPostMock.mockRejectedValueOnce(new Error('run failed'))
const onDone = mock(() => {}) const onDone = mock(() => {})
const result = await callAgentsPlatform( const result = await callAgentsPlatform(
onDone, onDone,
@@ -253,7 +350,7 @@ describe('callAgentsPlatform', () => {
const onDone = mock(() => {}) const onDone = mock(() => {})
// Only 4 cron fields — parseArgs returns invalid // Only 4 cron fields — parseArgs returns invalid
await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *') await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *')
expect(createMock).not.toHaveBeenCalled() expect(axiosPostMock).not.toHaveBeenCalled()
expect(logEventMock).toHaveBeenCalledWith( expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed', 'tengu_agents_platform_failed',
expect.anything(), expect.anything(),

View File

@@ -8,7 +8,7 @@ import * as React from 'react';
import { renderToString } from '../../../utils/staticRender.js'; import { renderToString } from '../../../utils/staticRender.js';
import { AutofixProgress } from '../AutofixProgress.js'; import { AutofixProgress } from '../AutofixProgress.js';
describe('AutofixProgress', () => { describe.skipIf(!!process.env.CI)('AutofixProgress', () => {
test('renders target in header', async () => { test('renders target in header', async () => {
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />); const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
expect(out).toContain('acme/myrepo#42'); expect(out).toContain('acme/myrepo#42');

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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
}
})
})

View File

@@ -7,9 +7,30 @@ import { logMock } from '../../../../tests/mocks/log.js'
mock.module('src/utils/log.ts', logMock) mock.module('src/utils/log.ts', logMock)
mock.module('bun:bundle', () => ({ feature: () => false })) mock.module('bun:bundle', () => ({ feature: () => false }))
// No keychain mock here — the real store falls back to encrypted file when // Re-register ../keychain.js to override pollution from store.test.ts (which
// @napi-rs/keyring is not installed (which it is not in this environment). // mocks keychain as always-throwing) and keychain.test.ts (which mocks it with
// This exercises the full file-fallback path without cross-test module pollution. // 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 let callLocalVault: typeof import('../launchLocalVault.js').callLocalVault

View File

@@ -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.
});

View File

@@ -11,6 +11,7 @@
* - Third-party API key values are NEVER included; only boolean presence flags. * - 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 { getClaudeAIOAuthTokens } from '../../utils/auth.js'
import { getGlobalConfig } from '../../utils/config.js' import { getGlobalConfig } from '../../utils/config.js'
@@ -107,7 +108,10 @@ export function getAuthStatus(): AuthStatus {
let plan: AuthStatus['subscription']['plan'] = null let plan: AuthStatus['subscription']['plan'] = null
if (subscriptionActive && oauthTokens) { if (subscriptionActive && oauthTokens) {
const raw = oauthTokens.subscriptionType // 本地持久化或历史 token 中可能出现 'free' 等未纳入 SubscriptionType 的字符串
const raw = oauthTokens.subscriptionType as
| (SubscriptionType | 'free')
| null
if ( if (
raw === 'free' || raw === 'free' ||
raw === 'pro' || raw === 'pro' ||

View File

@@ -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 { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.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/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock) mock.module('src/utils/debug.ts', debugMock)
// ── Analytics mock ────────────────────────────────────────────────────────── // ── Analytics mock ──────────────────────────────────────────────────────────
const realAnalytics = await import('src/services/analytics/index.js')
const logEventMock = mock(() => {}) const logEventMock = mock(() => {})
mock.module('src/services/analytics/index.js', () => ({ mock.module('src/services/analytics/index.js', () => ({
...realAnalytics,
logEvent: logEventMock, 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 ─────────────────────────────────────────────────── // ── MemoryStoresView mock ───────────────────────────────────────────────────
const memoryStoresViewMock = mock((_props: unknown) => null) const memoryStoresViewMock = mock((_props: unknown) => null)
mock.module('src/commands/memory-stores/MemoryStoresView.js', () => ({ mock.module('src/commands/memory-stores/MemoryStoresView.js', () => ({
MemoryStoresView: memoryStoresViewMock, MemoryStoresView: memoryStoresViewMock,
})) }))
// ── memoryStoresApi mock ────────────────────────────────────────────────── // ── Axios mock ──────────────────────────────────────────────────────────────
const listStoresMock = mock(async () => [] as unknown) const axiosGetMock = mock(async () => ({}))
const getStoreMock = mock(async () => ({}) as unknown) const axiosPostMock = mock(async () => ({}))
const createStoreMock = mock(async () => ({}) as unknown) const axiosPatchMock = mock(async () => ({}))
const archiveStoreMock = mock(async () => ({}) as unknown) const axiosDeleteMock = mock(async () => ({}))
const listMemoriesMock = mock(async () => [] as unknown) const axiosIsAxiosError = mock((err: unknown) => {
const createMemoryMock = mock(async () => ({}) as unknown) return (
const getMemoryMock = mock(async () => ({}) as unknown) typeof err === 'object' &&
const updateMemoryMock = mock(async () => ({}) as unknown) err !== null &&
const deleteMemoryMock = mock(async () => undefined) 'isAxiosError' in err &&
const listVersionsMock = mock(async () => [] as unknown) (err as { isAxiosError: boolean }).isAxiosError === true
const redactVersionMock = mock(async () => ({}) as unknown) )
})
mock.module('src/commands/memory-stores/memoryStoresApi.js', () => ({ const axiosHandle = setupAxiosMock()
listStores: listStoresMock, axiosHandle.stubs.get = axiosGetMock
getStore: getStoreMock, axiosHandle.stubs.post = axiosPostMock
createStore: createStoreMock, axiosHandle.stubs.patch = axiosPatchMock
archiveStore: archiveStoreMock, axiosHandle.stubs.delete = axiosDeleteMock
listMemories: listMemoriesMock, axiosHandle.stubs.isAxiosError = axiosIsAxiosError
createMemory: createMemoryMock,
getMemory: getMemoryMock,
updateMemory: updateMemoryMock,
deleteMemory: deleteMemoryMock,
listVersions: listVersionsMock,
redactVersion: redactVersionMock,
}))
// ── Lazy imports ─────────────────────────────────────────────────────────────
let callMemoryStores: typeof import('../launchMemoryStores.js').callMemoryStores let callMemoryStores: typeof import('../launchMemoryStores.js').callMemoryStores
beforeAll(async () => { beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../launchMemoryStores.js') const mod = await import('../launchMemoryStores.js')
callMemoryStores = mod.callMemoryStores callMemoryStores = mod.callMemoryStores
}) })
afterAll(() => {
axiosHandle.useStubs = false
})
// ── Helper ────────────────────────────────────────────────────────────────────
function makeOnDone() { function makeOnDone() {
return mock(() => {}) const calls: [string | undefined, unknown][] = []
const onDone = (msg?: string, opts?: unknown) => calls.push([msg, opts])
return { onDone, calls }
} }
beforeEach(() => { beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()
axiosPatchMock.mockClear()
axiosDeleteMock.mockClear()
logEventMock.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() memoryStoresViewMock.mockClear()
}) })
// ── invalid args ──────────────────────────────────────────────────────────────
describe('callMemoryStores: invalid args', () => { describe('callMemoryStores: invalid args', () => {
test('invalid subcommand → onDone with usage + null', async () => { test('invalid subcommand → onDone with usage + null', async () => {
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
const result = await callMemoryStores(onDone, {} as never, 'badcmd') const result = await callMemoryStores(onDone, {} as never, 'badcmd')
expect(result).toBeNull() expect(result).toBeNull()
expect(onDone).toHaveBeenCalledTimes(1) expect(calls[0]?.[0]).toMatch(/Usage/i)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/Usage/i)
}) })
}) })
// ── list ──────────────────────────────────────────────────────────────────────
describe('callMemoryStores: list', () => { describe('callMemoryStores: list', () => {
test('list returns empty stores', async () => { test('list returns empty stores', async () => {
listStoresMock.mockResolvedValueOnce([]) axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'list') await callMemoryStores(onDone, {} as never, 'list')
expect(listStoresMock).toHaveBeenCalledTimes(1) expect(axiosGetMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/no memory stores/i)
expect(msg).toMatch(/no memory stores/i)
}) })
test('list with stores reports count', async () => { test('list with stores reports count', async () => {
const stores = [ const stores = [
{ memory_store_id: 'ms_1', name: 'Work', namespace: 'work' }, { memory_store_id: 'ms_1', name: 'Work', namespace: 'work' },
] ]
listStoresMock.mockResolvedValueOnce(stores) axiosGetMock.mockResolvedValueOnce({ data: { data: stores }, status: 200 })
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, '') await callMemoryStores(onDone, {} as never, '')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/1 memory store/)
expect(msg).toMatch(/1 memory store/)
}) })
test('list API error → error view', async () => { test('list API error → error view', async () => {
listStoresMock.mockRejectedValueOnce(new Error('Network error')) axiosGetMock.mockRejectedValueOnce(new Error('Network error'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'list') await callMemoryStores(onDone, {} as never, 'list')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to list memory stores/i)
expect(msg).toMatch(/failed to list memory stores/i)
}) })
}) })
// ── get ───────────────────────────────────────────────────────────────────────
describe('callMemoryStores: 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' } const store = { memory_store_id: 'ms_get', name: 'Work Store' }
getStoreMock.mockResolvedValueOnce(store) axiosGetMock.mockResolvedValueOnce({ data: store, status: 200 })
const onDone = makeOnDone() const { onDone } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get ms_get') await callMemoryStores(onDone, {} as never, 'get ms_get')
expect(getStoreMock).toHaveBeenCalledTimes(1) expect(axiosGetMock).toHaveBeenCalledTimes(1)
const calls = getStoreMock.mock.calls as unknown as [string][] const getCall = axiosGetMock.mock.calls[0] as unknown as [string]
expect(calls[0]?.[0]).toBe('ms_get') expect(getCall[0]).toContain('ms_get')
}) })
test('get API error → error message', async () => { test('get API error → error message', async () => {
getStoreMock.mockRejectedValueOnce(new Error('Not found')) axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get ms_missing') await callMemoryStores(onDone, {} as never, 'get ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to get memory store/i)
expect(msg).toMatch(/failed to get memory store/i)
}) })
}) })
// ── create ────────────────────────────────────────────────────────────────────
describe('callMemoryStores: 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' } const store = { memory_store_id: 'ms_new', name: 'New Store' }
createStoreMock.mockResolvedValueOnce(store) axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'create New Store') await callMemoryStores(onDone, {} as never, 'create New Store')
expect(createStoreMock).toHaveBeenCalledTimes(1) expect(axiosPostMock).toHaveBeenCalledTimes(1)
const calls = createStoreMock.mock.calls as unknown as [string][] const postCall = axiosPostMock.mock.calls[0] as unknown as [
expect(calls[0]?.[0]).toBe('New Store') string,
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] Record<string, string>,
expect(msg).toMatch(/memory store created/i) ]
expect(postCall[1]).toEqual({ name: 'New Store' })
expect(calls[0]?.[0]).toMatch(/memory store created/i)
}) })
test('create API error → error message', async () => { test('create API error → error message', async () => {
createStoreMock.mockRejectedValueOnce(new Error('Subscription required')) axiosPostMock.mockRejectedValueOnce(new Error('Subscription required'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'create My Store') await callMemoryStores(onDone, {} as never, 'create My Store')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to create memory store/i)
expect(msg).toMatch(/failed to create memory store/i)
}) })
}) })
// ── archive ───────────────────────────────────────────────────────────────────
describe('callMemoryStores: archive', () => { describe('callMemoryStores: archive', () => {
test('archive calls archiveStore with id', async () => { test('archive calls axios.post with id in URL', async () => {
const store = { const store = {
memory_store_id: 'ms_arc', memory_store_id: 'ms_arc',
name: 'Old Store', name: 'Old Store',
archived_at: '2026-01-01', archived_at: '2026-01-01',
} }
archiveStoreMock.mockResolvedValueOnce(store) axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'archive ms_arc') await callMemoryStores(onDone, {} as never, 'archive ms_arc')
expect(archiveStoreMock).toHaveBeenCalledTimes(1) expect(axiosPostMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const postCall = axiosPostMock.mock.calls[0] as unknown as [string]
expect(msg).toMatch(/archived/i) 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 () => { test('archive API error → error message', async () => {
archiveStoreMock.mockRejectedValueOnce(new Error('Not found')) axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'archive ms_missing') await callMemoryStores(onDone, {} as never, 'archive ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to archive memory store/i)
expect(msg).toMatch(/failed to archive memory store/i)
}) })
}) })
// ── memories ──────────────────────────────────────────────────────────────────
describe('callMemoryStores: memories', () => { describe('callMemoryStores: memories', () => {
test('memories lists memories in store', async () => { test('memories lists memories in store', async () => {
const memories = [ const memories = [
{ memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test' }, { memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test' },
] ]
listMemoriesMock.mockResolvedValueOnce(memories) axiosGetMock.mockResolvedValueOnce({
const onDone = makeOnDone() data: { data: memories },
status: 200,
})
const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'memories ms_1') await callMemoryStores(onDone, {} as never, 'memories ms_1')
expect(listMemoriesMock).toHaveBeenCalledTimes(1) expect(axiosGetMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/1 memory/)
expect(msg).toMatch(/1 memory/)
}) })
test('memories API error → error message', async () => { test('memories API error → error message', async () => {
listMemoriesMock.mockRejectedValueOnce(new Error('Not found')) axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'memories ms_missing') await callMemoryStores(onDone, {} as never, 'memories ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to list memories/i)
expect(msg).toMatch(/failed to list memories/i)
}) })
}) })
// ── create-memory ─────────────────────────────────────────────────────────────
describe('callMemoryStores: 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 = { const memory = {
memory_id: 'mem_new', memory_id: 'mem_new',
memory_store_id: 'ms_1', memory_store_id: 'ms_1',
content: 'hello world', content: 'hello world',
} }
createMemoryMock.mockResolvedValueOnce(memory) axiosPostMock.mockResolvedValueOnce({ data: memory, status: 200 })
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores( await callMemoryStores(
onDone, onDone,
{} as never, {} as never,
'create-memory ms_1 hello world', 'create-memory ms_1 hello world',
) )
expect(createMemoryMock).toHaveBeenCalledTimes(1) expect(axiosPostMock).toHaveBeenCalledTimes(1)
const calls = createMemoryMock.mock.calls as unknown as [string, string][] const postCall = axiosPostMock.mock.calls[0] as unknown as [
expect(calls[0]?.[0]).toBe('ms_1') string,
expect(calls[0]?.[1]).toBe('hello world') Record<string, string>,
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] ]
expect(msg).toMatch(/memory created/i) 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 () => { test('create-memory API error → error message', async () => {
createMemoryMock.mockRejectedValueOnce(new Error('Forbidden')) axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores( await callMemoryStores(
onDone, onDone,
{} as never, {} as never,
'create-memory ms_1 test content', 'create-memory ms_1 test content',
) )
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to create memory/i)
expect(msg).toMatch(/failed to create memory/i)
}) })
}) })
// ── get-memory ────────────────────────────────────────────────────────────────
describe('callMemoryStores: 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 = { const memory = {
memory_id: 'mem_get', memory_id: 'mem_get',
memory_store_id: 'ms_1', memory_store_id: 'ms_1',
content: 'Test', content: 'Test',
} }
getMemoryMock.mockResolvedValueOnce(memory) axiosGetMock.mockResolvedValueOnce({ data: memory, status: 200 })
const onDone = makeOnDone() const { onDone } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_get') await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_get')
expect(getMemoryMock).toHaveBeenCalledTimes(1) expect(axiosGetMock).toHaveBeenCalledTimes(1)
const calls = getMemoryMock.mock.calls as unknown as [string, string][] const getCall = axiosGetMock.mock.calls[0] as unknown as [string]
expect(calls[0]?.[0]).toBe('ms_1') expect(getCall[0]).toContain('ms_1')
expect(calls[0]?.[1]).toBe('mem_get') expect(getCall[0]).toContain('mem_get')
}) })
test('get-memory API error → error message', async () => { test('get-memory API error → error message', async () => {
getMemoryMock.mockRejectedValueOnce(new Error('Not found')) axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_missing') await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to get memory/i)
expect(msg).toMatch(/failed to get memory/i)
}) })
}) })
// ── update-memory ─────────────────────────────────────────────────────────────
describe('callMemoryStores: 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 = { const memory = {
memory_id: 'mem_upd', memory_id: 'mem_upd',
memory_store_id: 'ms_1', memory_store_id: 'ms_1',
content: 'new content', content: 'new content',
} }
updateMemoryMock.mockResolvedValueOnce(memory) axiosPatchMock.mockResolvedValueOnce({ data: memory, status: 200 })
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores( await callMemoryStores(
onDone, onDone,
{} as never, {} as never,
'update-memory ms_1 mem_upd new content', 'update-memory ms_1 mem_upd new content',
) )
expect(updateMemoryMock).toHaveBeenCalledTimes(1) expect(axiosPatchMock).toHaveBeenCalledTimes(1)
const calls = updateMemoryMock.mock.calls as unknown as [ const patchCall = axiosPatchMock.mock.calls[0] as unknown as [
string, string,
string, Record<string, string>,
string, ]
][] expect(patchCall[0]).toContain('ms_1')
expect(calls[0]?.[0]).toBe('ms_1') expect(patchCall[0]).toContain('mem_upd')
expect(calls[0]?.[1]).toBe('mem_upd') expect(patchCall[1]).toEqual({ content: 'new content' })
expect(calls[0]?.[2]).toBe('new content') expect(calls[0]?.[0]).toMatch(/updated/i)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/updated/i)
}) })
test('update-memory API error → error message', async () => { test('update-memory API error → error message', async () => {
updateMemoryMock.mockRejectedValueOnce(new Error('Not found')) axiosPatchMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores( await callMemoryStores(
onDone, onDone,
{} as never, {} as never,
'update-memory ms_1 mem_missing new content', 'update-memory ms_1 mem_missing new content',
) )
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to update memory/i)
expect(msg).toMatch(/failed to update memory/i)
}) })
}) })
// ── delete-memory ─────────────────────────────────────────────────────────────
describe('callMemoryStores: delete-memory', () => { describe('callMemoryStores: delete-memory', () => {
test('delete-memory calls deleteMemory', async () => { test('delete-memory calls axios.delete with storeId and memoryId in URL', async () => {
deleteMemoryMock.mockResolvedValueOnce(undefined) axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'delete-memory ms_1 mem_del') await callMemoryStores(onDone, {} as never, 'delete-memory ms_1 mem_del')
expect(deleteMemoryMock).toHaveBeenCalledTimes(1) expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
const calls = deleteMemoryMock.mock.calls as unknown as [string, string][] const deleteCall = axiosDeleteMock.mock.calls[0] as unknown as [string]
expect(calls[0]?.[0]).toBe('ms_1') expect(deleteCall[0]).toContain('ms_1')
expect(calls[0]?.[1]).toBe('mem_del') expect(deleteCall[0]).toContain('mem_del')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/deleted/i)
expect(msg).toMatch(/deleted/i)
}) })
test('delete-memory API error → error message', async () => { test('delete-memory API error → error message', async () => {
deleteMemoryMock.mockRejectedValueOnce(new Error('Not found')) axiosDeleteMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores( await callMemoryStores(
onDone, onDone,
{} as never, {} as never,
'delete-memory ms_1 mem_missing', 'delete-memory ms_1 mem_missing',
) )
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to delete memory/i)
expect(msg).toMatch(/failed to delete memory/i)
}) })
}) })
// ── versions ──────────────────────────────────────────────────────────────────
describe('callMemoryStores: versions', () => { describe('callMemoryStores: versions', () => {
test('versions lists memory versions', async () => { test('versions lists memory versions', async () => {
const versions = [ const versions = [
@@ -335,46 +387,47 @@ describe('callMemoryStores: versions', () => {
created_at: '2026-01-01', created_at: '2026-01-01',
}, },
] ]
listVersionsMock.mockResolvedValueOnce(versions) axiosGetMock.mockResolvedValueOnce({
const onDone = makeOnDone() data: { data: versions },
status: 200,
})
const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'versions ms_1') await callMemoryStores(onDone, {} as never, 'versions ms_1')
expect(listVersionsMock).toHaveBeenCalledTimes(1) expect(axiosGetMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/1 version/)
expect(msg).toMatch(/1 version/)
}) })
test('versions API error → error message', async () => { test('versions API error → error message', async () => {
listVersionsMock.mockRejectedValueOnce(new Error('Not found')) axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'versions ms_missing') await callMemoryStores(onDone, {} as never, 'versions ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to list versions/i)
expect(msg).toMatch(/failed to list versions/i)
}) })
}) })
// ── redact ────────────────────────────────────────────────────────────────────
describe('callMemoryStores: 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 = { const version = {
version_id: 'ver_red', version_id: 'ver_red',
memory_store_id: 'ms_1', memory_store_id: 'ms_1',
redacted_at: '2026-01-01', redacted_at: '2026-01-01',
} }
redactVersionMock.mockResolvedValueOnce(version) axiosPostMock.mockResolvedValueOnce({ data: version, status: 200 })
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_red') await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_red')
expect(redactVersionMock).toHaveBeenCalledTimes(1) expect(axiosPostMock).toHaveBeenCalledTimes(1)
const calls = redactVersionMock.mock.calls as unknown as [string, string][] const postCall = axiosPostMock.mock.calls[0] as unknown as [string]
expect(calls[0]?.[0]).toBe('ms_1') expect(postCall[0]).toContain('ms_1')
expect(calls[0]?.[1]).toBe('ver_red') expect(postCall[0]).toContain('ver_red')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(postCall[0]).toContain('redact')
expect(msg).toMatch(/redacted/i) expect(calls[0]?.[0]).toMatch(/redacted/i)
}) })
test('redact API error → error message', async () => { test('redact API error → error message', async () => {
redactVersionMock.mockRejectedValueOnce(new Error('Forbidden')) axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
const onDone = makeOnDone() const { onDone, calls } = makeOnDone()
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_missing') await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] expect(calls[0]?.[0]).toMatch(/failed to redact version/i)
expect(msg).toMatch(/failed to redact version/i)
}) })
}) })

View File

@@ -35,6 +35,7 @@ import {
isPluginEnabledAtProjectScope, isPluginEnabledAtProjectScope,
uninstallPluginOp, uninstallPluginOp,
updatePluginOp, updatePluginOp,
type InstallableScope,
} from '../../services/plugins/pluginOperations.js'; } from '../../services/plugins/pluginOperations.js';
import { useAppState } from '../../state/AppState.js'; import { useAppState } from '../../state/AppState.js';
import type { Tool } from '../../Tool.js'; import type { Tool } from '../../Tool.js';
@@ -76,7 +77,7 @@ import { PluginOptionsDialog } from './PluginOptionsDialog.js';
import { PluginOptionsFlow } from './PluginOptionsFlow.js'; import { PluginOptionsFlow } from './PluginOptionsFlow.js';
import type { ViewState as ParentViewState } from './types.js'; import type { ViewState as ParentViewState } from './types.js';
import { UnifiedInstalledCell } from './UnifiedInstalledCell.js'; import { UnifiedInstalledCell } from './UnifiedInstalledCell.js';
import type { UnifiedInstalledItem } from './unifiedTypes.js'; import type { UnifiedInstalledItem, UnifiedInstalledScope } from './unifiedTypes.js';
import { usePagination } from './usePagination.js'; import { usePagination } from './usePagination.js';
type Props = { type Props = {
@@ -103,7 +104,7 @@ type FailedPluginInfo = {
name: string; name: string;
marketplace: string; marketplace: string;
errors: PluginError[]; errors: PluginError[];
scope: PersistablePluginScope; scope: UnifiedInstalledScope;
}; };
type ViewState = type ViewState =
@@ -1253,7 +1254,7 @@ export function ManagePlugins({
const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false; const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false;
const pluginScope = item.scope; const pluginScope = item.scope;
const isBuiltin = pluginScope === 'builtin'; const isBuiltin = pluginScope === 'builtin';
if (isBuiltin || isInstallableScope(pluginScope)) { if (isBuiltin || isInstallableScope(pluginScope as PersistablePluginScope)) {
const newPending = new Map(pendingToggles); const newPending = new Map(pendingToggles);
// Omit scope — see handleSingleOperation's enable/disable comment. // Omit scope — see handleSingleOperation's enable/disable comment.
if (currentPending) { if (currentPending) {
@@ -1579,8 +1580,8 @@ export function ManagePlugins({
// is a recovery path for a plugin that failed to load — it may // is a recovery path for a plugin that failed to load — it may
// be reinstallable, so don't nuke ${CLAUDE_PLUGIN_DATA} silently. // be reinstallable, so don't nuke ${CLAUDE_PLUGIN_DATA} silently.
// The normal uninstall path prompts; this one preserves. // The normal uninstall path prompts; this one preserves.
const result = isInstallableScope(pluginScope) const result = isInstallableScope(pluginScope as PersistablePluginScope)
? await uninstallPluginOp(pluginId, pluginScope, false) ? await uninstallPluginOp(pluginId, pluginScope as InstallableScope, false)
: await uninstallPluginOp(pluginId, 'user', false); : await uninstallPluginOp(pluginId, 'user', false);
let success = result.success; let success = result.success;
if (!success) { if (!success) {

View File

@@ -1,3 +1,37 @@
// Auto-generated stub — replace with real implementation import type { LocalJSXCommandOnDone } from 'src/types/command.js'
export type ViewState = any
export type PluginSettingsProps = any /**
* `/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 相关提示
}

View File

@@ -1,2 +1,68 @@
// Auto-generated stub — replace with real implementation import type {
export type UnifiedInstalledItem = any 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
}

View File

@@ -78,9 +78,6 @@ axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ───────────────────────────────────────────────── // ── 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 listTriggers: typeof import('../triggersApi.js').listTriggers
let getTrigger: typeof import('../triggersApi.js').getTrigger let getTrigger: typeof import('../triggersApi.js').getTrigger
let createTrigger: typeof import('../triggersApi.js').createTrigger let createTrigger: typeof import('../triggersApi.js').createTrigger

View File

@@ -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 { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.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/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock) mock.module('src/utils/debug.ts', debugMock)
@@ -12,8 +31,6 @@ mock.module('src/services/analytics/index.js', () => ({
})) }))
// ── Cron utility mock ─────────────────────────────────────────────────────── // ── 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', () => ({ mock.module('src/utils/cron.js', () => ({
parseCronExpression: (cron: string) => { parseCronExpression: (cron: string) => {
const fields = cron.trim().split(/\s+/) const fields = cron.trim().split(/\s+/)
@@ -38,43 +55,76 @@ mock.module('src/commands/schedule/ScheduleView.js', () => ({
ScheduleView: scheduleViewMock, ScheduleView: scheduleViewMock,
})) }))
// ── triggersApi mock ────────────────────────────────────────────────────── // ── Auth / OAuth mocks ──────────────────────────────────────────────────────
// Use `as unknown as` casts to keep mock type flexible while satisfying strict TS mock.module('src/utils/auth.js', () => ({
const listTriggersMock = mock(async () => [] as unknown) getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-schedule' }),
const getTriggerMock = mock(async () => ({}) as unknown) }))
const createTriggerMock = mock(async () => ({}) as unknown) mock.module('src/services/oauth/client.js', () => ({
const updateTriggerMock = mock(async () => ({}) as unknown) getOrganizationUUID: async () => 'org-uuid-schedule',
const deleteTriggerMock = mock(async () => undefined) }))
const runTriggerMock = mock(async () => ({ run_id: 'run_mock' }) as unknown) mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
mock.module('src/commands/schedule/triggersApi.js', () => ({ }))
listTriggers: listTriggersMock, mock.module('src/utils/teleport/api.js', () => ({
getTrigger: getTriggerMock, getOAuthHeaders: (token: string) => ({
createTrigger: createTriggerMock, Authorization: `Bearer ${token}`,
updateTrigger: updateTriggerMock, 'anthropic-version': '2023-06-01',
deleteTrigger: deleteTriggerMock, }),
runTrigger: runTriggerMock, 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 let callSchedule: typeof import('../launchSchedule.js').callSchedule
beforeAll(async () => { beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../launchSchedule.js') const mod = await import('../launchSchedule.js')
callSchedule = mod.callSchedule callSchedule = mod.callSchedule
}) })
afterAll(() => {
axiosHandle.useStubs = false
})
function makeOnDone() { function makeOnDone() {
return mock(() => {}) return mock(() => {})
} }
beforeEach(() => { beforeEach(() => {
logEventMock.mockClear() logEventMock.mockClear()
listTriggersMock.mockClear() axiosGetMock.mockClear()
getTriggerMock.mockClear() axiosPostMock.mockClear()
createTriggerMock.mockClear() axiosDeleteMock.mockClear()
updateTriggerMock.mockClear()
deleteTriggerMock.mockClear()
runTriggerMock.mockClear()
scheduleViewMock.mockClear() scheduleViewMock.mockClear()
}) })
@@ -91,10 +141,10 @@ describe('callSchedule: invalid args', () => {
describe('callSchedule: list', () => { describe('callSchedule: list', () => {
test('list returns empty triggers', async () => { test('list returns empty triggers', async () => {
listTriggersMock.mockResolvedValueOnce([]) axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'list') 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] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/no scheduled triggers/i) expect(msg).toMatch(/no scheduled triggers/i)
}) })
@@ -108,7 +158,10 @@ describe('callSchedule: list', () => {
prompt: 'daily', prompt: 'daily',
}, },
] ]
listTriggersMock.mockResolvedValueOnce(triggers) axiosGetMock.mockResolvedValueOnce({
data: { data: triggers },
status: 200,
})
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, '') await callSchedule(onDone, {} as never, '')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] 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 () => { test('list API error → error view', async () => {
listTriggersMock.mockRejectedValueOnce(new Error('Network error')) axiosGetMock.mockRejectedValueOnce(new Error('Network error'))
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'list') await callSchedule(onDone, {} as never, 'list')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
@@ -132,16 +185,16 @@ describe('callSchedule: get', () => {
enabled: true, enabled: true,
prompt: 'test', prompt: 'test',
} }
getTriggerMock.mockResolvedValueOnce(trigger) axiosGetMock.mockResolvedValueOnce({ data: trigger, status: 200 })
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'get trg_get') await callSchedule(onDone, {} as never, 'get trg_get')
expect(getTriggerMock).toHaveBeenCalledTimes(1) expect(axiosGetMock).toHaveBeenCalledTimes(1)
const calls = getTriggerMock.mock.calls as unknown as [string][] const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toBe('trg_get') expect(calls[0]?.[0] as string).toContain('trg_get')
}) })
test('get API error → error message', async () => { test('get API error → error message', async () => {
getTriggerMock.mockRejectedValueOnce(new Error('Not found')) axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'get trg_missing') await callSchedule(onDone, {} as never, 'get trg_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
@@ -157,10 +210,10 @@ describe('callSchedule: create', () => {
enabled: true, enabled: true,
prompt: 'daily report', prompt: 'daily report',
} }
createTriggerMock.mockResolvedValueOnce(trigger) axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'create 0 9 * * * daily report') 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] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/trigger created/i) expect(msg).toMatch(/trigger created/i)
}) })
@@ -169,12 +222,12 @@ describe('callSchedule: create', () => {
const onDone = makeOnDone() const onDone = makeOnDone()
// 4 fields only — invalid // 4 fields only — invalid
await callSchedule(onDone, {} as never, 'create 0 9 * * report only') await callSchedule(onDone, {} as never, 'create 0 9 * * report only')
// createTrigger should not be called // axios.post should not be called
expect(createTriggerMock).not.toHaveBeenCalled() expect(axiosPostMock).not.toHaveBeenCalled()
}) })
test('create API error → error message', async () => { test('create API error → error message', async () => {
createTriggerMock.mockRejectedValueOnce(new Error('Subscription required')) axiosPostMock.mockRejectedValueOnce(new Error('Subscription required'))
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'create 0 9 * * * test prompt') await callSchedule(onDone, {} as never, 'create 0 9 * * * test prompt')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
@@ -190,14 +243,16 @@ describe('callSchedule: update', () => {
enabled: false, enabled: false,
prompt: 'test', prompt: 'test',
} }
updateTriggerMock.mockResolvedValueOnce(trigger) axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'update trg_upd enabled false') await callSchedule(onDone, {} as never, 'update trg_upd enabled false')
expect(updateTriggerMock).toHaveBeenCalledTimes(1) expect(axiosPostMock).toHaveBeenCalledTimes(1)
const calls = updateTriggerMock.mock.calls as unknown as [ const calls = axiosPostMock.mock.calls as unknown as [
string, string,
Record<string, unknown>, Record<string, unknown>,
unknown,
][] ][]
expect(calls[0]?.[0]).toContain('trg_upd')
expect(calls[0]?.[1]).toEqual({ enabled: false }) expect(calls[0]?.[1]).toEqual({ enabled: false })
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/updated/i) expect(msg).toMatch(/updated/i)
@@ -206,7 +261,7 @@ describe('callSchedule: update', () => {
test('update with unknown field → error without API call', async () => { test('update with unknown field → error without API call', async () => {
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'update trg_upd foofield bar') 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] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/unknown field/i) expect(msg).toMatch(/unknown field/i)
}) })
@@ -214,16 +269,16 @@ describe('callSchedule: update', () => {
describe('callSchedule: delete', () => { describe('callSchedule: delete', () => {
test('delete calls deleteTrigger', async () => { test('delete calls deleteTrigger', async () => {
deleteTriggerMock.mockResolvedValueOnce(undefined) axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'delete trg_del') 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] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/deleted/i) expect(msg).toMatch(/deleted/i)
}) })
test('delete API error → error message', async () => { test('delete API error → error message', async () => {
deleteTriggerMock.mockRejectedValueOnce(new Error('Not found')) axiosDeleteMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'delete trg_missing') await callSchedule(onDone, {} as never, 'delete trg_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
@@ -233,16 +288,21 @@ describe('callSchedule: delete', () => {
describe('callSchedule: run', () => { describe('callSchedule: run', () => {
test('run fires trigger and returns run_id', async () => { 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() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'run trg_fire') 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] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/run_xyz/) expect(msg).toMatch(/run_xyz/)
}) })
test('run API error → error message', async () => { test('run API error → error message', async () => {
runTriggerMock.mockRejectedValueOnce(new Error('Forbidden')) axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'run trg_fire') await callSchedule(onDone, {} as never, 'run trg_fire')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
@@ -258,12 +318,13 @@ describe('callSchedule: enable / disable', () => {
enabled: true, enabled: true,
prompt: 'test', prompt: 'test',
} }
updateTriggerMock.mockResolvedValueOnce(trigger) axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'enable trg_en') 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, string,
Record<string, unknown>, Record<string, unknown>,
unknown,
][] ][]
expect(calls[0]?.[1]).toEqual({ enabled: true }) expect(calls[0]?.[1]).toEqual({ enabled: true })
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
@@ -277,12 +338,13 @@ describe('callSchedule: enable / disable', () => {
enabled: false, enabled: false,
prompt: 'test', prompt: 'test',
} }
updateTriggerMock.mockResolvedValueOnce(trigger) axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'disable trg_dis') 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, string,
Record<string, unknown>, Record<string, unknown>,
unknown,
][] ][]
expect(calls[0]?.[1]).toEqual({ enabled: false }) expect(calls[0]?.[1]).toEqual({ enabled: false })
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] 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 () => { test('enable API error → error message', async () => {
updateTriggerMock.mockRejectedValueOnce(new Error('Not found')) axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'enable trg_missing') await callSchedule(onDone, {} as never, 'enable trg_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] 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 () => { test('disable API error → error message', async () => {
updateTriggerMock.mockRejectedValueOnce(new Error('Not found')) axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone() const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'disable trg_missing') await callSchedule(onDone, {} as never, 'disable trg_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? [] const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []

View File

@@ -1,3 +1,9 @@
// Auto-generated stub — replace with real implementation /** 会话内满意度调查的选项(与数字键 03 映射一致)。 */
export type FeedbackSurveyResponse = any export type FeedbackSurveyResponse =
export type FeedbackSurveyType = any | 'dismissed' // 0关闭不反馈
| 'bad' // 1不满意
| 'fine' // 2一般
| 'good' // 3满意
/** 调查场景;当前仅实现会话级提示。 */
export type FeedbackSurveyType = 'session' // 主会话 Spinner/流程内触发

View File

@@ -798,7 +798,9 @@ const MessagesImpl = ({
// Collapse diffs for messages beyond the latest N messages. // Collapse diffs for messages beyond the latest N messages.
// verbose (ctrl+o) overrides and always shows full diffs. // verbose (ctrl+o) overrides and always shows full diffs.
const DIFF_COLLAPSE_DISTANCE = 0; // 0 was too aggressive — tool results are never the last message (assistant
// text follows), so diffs were always collapsed. 3 keeps recent edits visible.
const DIFF_COLLAPSE_DISTANCE = 3;
const shouldCollapseDiffs = renderableMessages.length - 1 - index > DIFF_COLLAPSE_DISTANCE; const shouldCollapseDiffs = renderableMessages.length - 1 - index > DIFF_COLLAPSE_DISTANCE;
const k = messageKey(msg); const k = messageKey(msg);

View File

@@ -23,6 +23,7 @@ import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js';
import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js'; import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js';
import { useSettings } from '../hooks/useSettings.js'; import { useSettings } from '../hooks/useSettings.js';
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'; import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js';
import { isLocalAgentTask } from '../tasks/LocalAgentTask/LocalAgentTask.js';
import { isBackgroundTask } from '../tasks/types.js'; import { isBackgroundTask } from '../tasks/types.js';
import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
import { getEffortSuffix } from '../utils/effort.js'; import { getEffortSuffix } from '../utils/effort.js';
@@ -209,15 +210,22 @@ function SpinnerWithVerbInner({
const hasRunningTeammates = runningTeammates.length > 0; const hasRunningTeammates = runningTeammates.length > 0;
const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle); const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle);
// Gather aggregate token stats from all running swarm teammates // Gather aggregate token stats from all running agents.
// In spinner-tree mode, skip aggregation (teammates have their own lines in the tree) // 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; let teammateTokens = 0;
if (!showSpinnerTree) { for (const task of Object.values(tasks)) {
for (const task of Object.values(tasks)) { if (task.status !== 'running') continue;
if (isInProcessTeammateTask(task) && task.status === 'running') { if (isInProcessTeammateTask(task)) {
if (task.progress?.tokenCount) { if (!showSpinnerTree && task.progress?.tokenCount) {
teammateTokens += task.progress.tokenCount; teammateTokens += task.progress.tokenCount;
} }
continue;
}
if (isLocalAgentTask(task)) {
if (task.progress?.tokenCount) {
teammateTokens += task.progress.tokenCount;
} }
} }
} }

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