Compare commits

...

25 Commits

Author SHA1 Message Date
claude-code-best
9bd8622d84 Revert "feat: 添加 /goal 命令,支持长时间运行任务的目标管理 (#1222)"
This reverts commit d66a6f6124.
2026-05-17 10:05:54 +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
30 changed files with 490 additions and 1497 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

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "2.4.1",
"version": "2.4.4",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",

View File

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

View File

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

View File

@@ -10,8 +10,14 @@ import {
} from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { createUserMessage } from 'src/utils/messages.js'
import {
extractDiscoveredToolNames,
isSearchExtraToolsEnabledOptimistic,
isSearchExtraToolsToolAvailable,
} from 'src/utils/searchExtraTools.js'
import { DESCRIPTION, getPrompt } from './prompt.js'
import { EXECUTE_TOOL_NAME } from './constants.js'
import { isDeferredTool } from '../SearchExtraToolsTool/prompt.js'
export const inputSchema = lazySchema(() =>
z.object({
@@ -74,6 +80,32 @@ export const ExecuteTool = buildTool({
}
}
// Guard: block execution of undiscovered deferred tools.
// When tool search is active, deferred tools must be discovered via
// SearchExtraTools first so the model has seen their schemas and knows
// the correct parameters. Executing an undiscovered tool almost always
// fails with parameter validation errors.
if (
isSearchExtraToolsEnabledOptimistic() &&
isSearchExtraToolsToolAvailable(tools) &&
isDeferredTool(targetTool)
) {
const discovered = extractDiscoveredToolNames(context.messages)
if (!discovered.has(input.tool_name)) {
return {
data: {
result: null,
tool_name: input.tool_name,
},
newMessages: [
createUserMessage({
content: `Tool "${input.tool_name}" has not been discovered yet. You must first use SearchExtraTools to discover this tool before executing it.\n\nUsage: SearchExtraTools("select:${input.tool_name}")`,
}),
],
}
}
}
// Check if the target tool is currently enabled
if (!targetTool.isEnabled()) {
return {
@@ -89,6 +121,29 @@ export const ExecuteTool = buildTool({
}
}
// Validate input before delegating — prevents crashes when the model
// omits required params (e.g. TeamCreate without team_name →
// sanitizeName(undefined).replace() TypeError).
if (targetTool.validateInput) {
const validation = await targetTool.validateInput(
input.params as Record<string, unknown>,
context,
)
if (!validation.result) {
return {
data: {
result: null,
tool_name: input.tool_name,
},
newMessages: [
createUserMessage({
content: `Invalid parameters for tool "${input.tool_name}": ${validation.message}`,
}),
],
}
}
}
// Check permissions on the target tool
const permResult = await targetTool.checkPermissions?.(
input.params as Record<string, unknown>,
@@ -132,7 +187,7 @@ export const ExecuteTool = buildTool({
}
},
renderToolUseMessage(input) {
return `Executing ${input.tool_name}...`
return `${input.tool_name}`
},
userFacingName() {
return 'ExecuteExtraTool'

View File

@@ -33,10 +33,10 @@ mock.module('src/utils/searchExtraTools.js', () => ({
isSearchExtraToolsEnabledOptimistic: () => true,
getAutoSearchExtraToolsCharThreshold: () => 100,
getSearchExtraToolsMode: () => 'tst' as const,
isSearchExtraToolsToolAvailable: async () => true,
isSearchExtraToolsToolAvailable: () => true,
isSearchExtraToolsEnabled: async () => true,
isToolReferenceBlock: () => false,
extractDiscoveredToolNames: () => new Set(),
extractDiscoveredToolNames: () => new Set(['TestTool', 'SecretTool']),
isDeferredToolsDeltaEnabled: () => false,
getDeferredToolsDelta: () => null,
}))
@@ -154,6 +154,26 @@ describe('ExecuteTool', () => {
expect(result.newMessages).toBeDefined()
})
test('returns error when deferred tool has not been discovered via SearchExtraTools', async () => {
const mockTarget = makeMockTool('UndiscoveredTool', 'result')
const ctx = makeContext([mockTarget])
const result = await ExecuteTool.call(
{ tool_name: 'UndiscoveredTool', params: {} },
ctx,
async () => ({ behavior: 'allow' }),
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
undefined,
)
expect(result.data).toEqual({
result: null,
tool_name: 'UndiscoveredTool',
})
expect(result.newMessages).toBeDefined()
expect(result.newMessages![0].content).toContain('has not been discovered')
})
test('has correct name', () => {
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
})

View File

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

View File

@@ -377,9 +377,6 @@ const cronJitterConfigModule =
require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
const cronGate =
require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
const SHUTDOWN_TEAM_PROMPT = `<system-reminder>
@@ -985,7 +982,14 @@ export async function runHeadless(
// the forked agent mid-flight. Gated by isExtractModeActive so the
// tengu_slate_thimble flag controls non-interactive extraction end-to-end.
if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) {
await extractMemoriesModule!.drainPendingExtraction()
try {
const { drainPendingExtraction } = await import(
'../services/extractMemories/extractMemories.js'
)
await drainPendingExtraction()
} catch {
// Module load failure — non-critical at shutdown
}
}
gracefulShutdownSync(

View File

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

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 ISSUE_TEMPLATE dir for cleanup — never delete the whole .github/
// as it may contain workflows, settings, or other project config.
createdTemplatePath = templateDir
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

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

View File

@@ -82,6 +82,7 @@ export const ASYNC_AGENT_ALLOWED_TOOLS = new Set([
SKILL_TOOL_NAME,
SYNTHETIC_OUTPUT_TOOL_NAME,
SEARCH_EXTRA_TOOLS_TOOL_NAME,
EXECUTE_TOOL_NAME,
ENTER_WORKTREE_TOOL_NAME,
EXIT_WORKTREE_TOOL_NAME,
])

View File

@@ -39,9 +39,6 @@ import { getTaskListId, listTasks } from '../utils/tasks.js'
import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null
const jobClassifierModule = feature('TEMPLATES')
? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js'))
: null
@@ -154,12 +151,16 @@ export async function* handleStopHooks(
// Fire-and-forget in both interactive and non-interactive. For -p/SDK,
// print.ts drains the in-flight promise after flushing the response
// but before gracefulShutdownSync (see drainPendingExtraction).
void extractMemoriesModule!.executeExtractMemories(
stopHookContext,
toolUseContext.appendSystemMessage as
| ((msg: import('../types/message.js').SystemMessage) => void)
| undefined,
)
void import('../services/extractMemories/extractMemories.js')
.then(({ executeExtractMemories }) =>
executeExtractMemories(
stopHookContext,
toolUseContext.appendSystemMessage as
| ((msg: import('../types/message.js').SystemMessage) => void)
| undefined,
),
)
.catch(() => {})
}
if (!toolUseContext.agentId && !poorMode) {
void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)

View File

@@ -69,8 +69,11 @@ mockModulePreservingExports('../../../utils/config.ts', {
enableConfigs: mock(() => {}),
})
const mockSwitchSession = mock(() => {})
mockModulePreservingExports('../../../bootstrap/state.ts', {
setOriginalCwd: mock(() => {}),
switchSession: mockSwitchSession,
addSlowOperation: mock(() => {}),
})
@@ -222,6 +225,7 @@ describe('AcpAgent', () => {
delete process.env.ACP_PERMISSION_MODE
delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
mockSetModel.mockClear()
mockSwitchSession.mockClear()
mockSubmitMessage.mockReset()
mockSubmitMessage.mockImplementation(async function* (_input: string) {})
mockGetMainLoopModel.mockClear()
@@ -1157,4 +1161,66 @@ describe('AcpAgent', () => {
expect(commit.input).toEqual({ hint: '[message]' })
})
})
describe('sessionId alignment with global state', () => {
test('newSession calls switchSession with the generated sessionId', async () => {
const agent = new AcpAgent(makeConn())
const res = await agent.newSession({ cwd: '/tmp' } as any)
expect(mockSwitchSession).toHaveBeenCalledWith(res.sessionId)
})
test('resumeSession calls switchSession with the requested sessionId', async () => {
const agent = new AcpAgent(makeConn())
const requestedId = 'resume-test-session-id'
await agent.unstable_resumeSession({
sessionId: requestedId,
cwd: '/tmp',
mcpServers: [],
} as any)
expect(mockSwitchSession).toHaveBeenCalledWith(requestedId)
})
test('loadSession calls switchSession with the requested sessionId', async () => {
const agent = new AcpAgent(makeConn())
const requestedId = 'load-test-session-id'
await agent.loadSession({
sessionId: requestedId,
cwd: '/tmp',
mcpServers: [],
} as any)
expect(mockSwitchSession).toHaveBeenCalledWith(requestedId)
})
test('resumeSession with existing session still calls switchSession', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
mockSwitchSession.mockClear()
// Resume the same session — should still align global state
await agent.unstable_resumeSession({
sessionId,
cwd: '/tmp',
mcpServers: [],
} as any)
expect(mockSwitchSession).toHaveBeenCalledWith(sessionId)
})
test('prompt does not trigger additional switchSession for multi-session', async () => {
const agent = new AcpAgent(makeConn())
await agent.newSession({ cwd: '/tmp' } as any)
await agent.newSession({ cwd: '/tmp' } as any)
mockSwitchSession.mockClear()
// Prompts should not call switchSession — alignment happens at session creation
const s1 = agent.sessions.keys().next().value
await agent.prompt({
sessionId: s1,
prompt: [{ type: 'text', text: 'hello' }],
} as any)
expect(mockSwitchSession).not.toHaveBeenCalled()
})
})
})

View File

@@ -53,7 +53,8 @@ import { getEmptyToolPermissionContext } from '../../Tool.js'
import type { PermissionMode } from '../../types/permissions.js'
import type { Command } from '../../types/command.js'
import { getCommands } from '../../commands.js'
import { setOriginalCwd } from '../../bootstrap/state.js'
import { setOriginalCwd, switchSession } from '../../bootstrap/state.js'
import type { SessionId } from '../../types/ids.js'
import { enableConfigs } from '../../utils/config.js'
import { FileStateCache } from '../../utils/fileStateCache.js'
import { getDefaultAppState } from '../../state/AppStateStore.js'
@@ -471,6 +472,10 @@ export class AcpAgent implements Agent {
const sessionId = opts.sessionId ?? randomUUID()
const cwd = params.cwd
// Align the global session state so that transcript persistence,
// analytics, and cost tracking use the ACP session ID.
switchSession(sessionId as SessionId)
// Set CWD for the session
setOriginalCwd(cwd)
const previousProcessCwd = process.cwd()
@@ -675,6 +680,8 @@ export class AcpAgent implements Agent {
| undefined,
})
if (fingerprint === existingSession.sessionFingerprint) {
// Align global state so subsequent operations use the correct session
switchSession(params.sessionId as SessionId)
return {
sessionId: params.sessionId,
modes: existingSession.modes,
@@ -687,6 +694,10 @@ export class AcpAgent implements Agent {
await this.teardownSession(params.sessionId)
}
// Align global state BEFORE sessionIdExists() check — the lookup uses
// getSessionId() internally when resolving project-scoped paths.
switchSession(params.sessionId as SessionId)
// Set CWD early so session file lookup can find the right project directory
setOriginalCwd(params.cwd)

View File

@@ -1396,7 +1396,7 @@ async function* queryModel(
messagesForAPI = [
...messagesForAPI,
createUserMessage({
content: `<system-reminder>\n<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>\nTo invoke any tool listed above, use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}}. This is the ONLY way to call deferred tools — do not read source code or analyze implementation, just call ExecuteExtraTool directly.\n</system-reminder>`,
content: `<system-reminder>\n<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>\nIMPORTANT: These tools are deferred-loading. You MUST first discover a tool via SearchExtraTools before invoking it with ExecuteExtraTool. Do NOT call ExecuteExtraTool directly — it will fail if the tool has not been discovered.\n\nSteps:\n1. SearchExtraTools("select:<tool_name>") — discover the tool and its schema\n2. ExecuteExtraTool({"tool_name": "<name>", "params": {...}}) — invoke it with correct parameters\n</system-reminder>`,
isMeta: true,
}),
]

View File

@@ -12,6 +12,7 @@ import type {
ChatCompletionCreateParamsStreaming,
} from 'openai/resources/chat/completions/completions.mjs'
import { getGrokClient } from './client.js'
import { updateOpenAIUsage } from '../openai/openaiShared.js'
import {
anthropicMessagesToOpenAI,
anthropicToolsToOpenAI,
@@ -136,7 +137,7 @@ export async function* queryModelGrok(
partialMessage = (event as any).message
ttftMs = Date.now() - start
if ((event as any).message?.usage) {
usage = { ...usage, ...(event as any).message.usage }
usage = updateOpenAIUsage(usage, (event as any).message.usage)
}
break
}
@@ -192,7 +193,7 @@ export async function* queryModelGrok(
case 'message_delta': {
const deltaUsage = (event as any).usage
if (deltaUsage) {
usage = { ...usage, ...deltaUsage }
usage = updateOpenAIUsage(usage, deltaUsage)
}
break
}

View File

@@ -147,6 +147,22 @@ describe('isOpenAIThinkingEnabled', () => {
expect(isOpenAIThinkingEnabled('deepseek-coder')).toBe(true)
})
test('returns true when model name is "mimo-v2-flash"', () => {
expect(isOpenAIThinkingEnabled('mimo-v2-flash')).toBe(true)
})
test('returns true when model name is "mimo-v2-pro"', () => {
expect(isOpenAIThinkingEnabled('mimo-v2-pro')).toBe(true)
})
test('returns true when model name is "mimo-v2.5-pro"', () => {
expect(isOpenAIThinkingEnabled('mimo-v2.5-pro')).toBe(true)
})
test('returns true when model name contains "mimo"', () => {
expect(isOpenAIThinkingEnabled('MiMo-V2-Omni')).toBe(true)
})
test('returns false when model name is "gpt-4o"', () => {
expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(false)
})
@@ -197,7 +213,10 @@ describe('buildOpenAIRequestBody — thinking params', () => {
test('includes vLLM/self-hosted thinking format when enabled', () => {
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true })
expect(body.enable_thinking).toBe(true)
expect(body.chat_template_kwargs).toEqual({ thinking: true })
expect(body.chat_template_kwargs).toEqual({
thinking: true,
enable_thinking: true,
})
})
test('includes both formats simultaneously when enabled', () => {

View File

@@ -10,6 +10,7 @@ import type {
import type { AgentId } from '../../../types/ids.js'
import type { Tools } from '../../../Tool.js'
import { getOpenAIClient } from './client.js'
import { updateOpenAIUsage } from './openaiShared.js'
import {
anthropicMessagesToOpenAI,
resolveOpenAIModel,
@@ -449,7 +450,7 @@ export async function* queryModelOpenAI(
case 'message_delta': {
const deltaUsage = (event as any).usage
if (deltaUsage) {
usage = { ...usage, ...deltaUsage }
usage = updateOpenAIUsage(usage, deltaUsage)
}
if ((event as any).delta?.stop_reason != null) {
stopReason = (event as any).delta.stop_reason

View File

@@ -0,0 +1,46 @@
/**
* Shared utilities for OpenAI-compatible API paths.
*
* Both the OpenAI path (queryModelOpenAI) and Grok path (queryModelGrok) use
* the same adapters (openaiStreamAdapter, openaiConvertMessages), so the event
* processing logic should be shared rather than duplicated.
*/
/**
* Merge a delta usage into the accumulated usage, preserving cache-related
* fields from previous values when the delta carries explicit zeroes or
* undefined values.
*
* Mirrors updateUsage() in claude.ts: a future adapter change that omits
* cache fields from certain streaming events should not silently zero the
* accumulated counters.
*/
export function updateOpenAIUsage(
current: {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
},
delta: {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
},
): typeof current {
return {
input_tokens: delta.input_tokens ?? current.input_tokens,
output_tokens: delta.output_tokens ?? current.output_tokens,
cache_creation_input_tokens:
delta.cache_creation_input_tokens !== undefined &&
delta.cache_creation_input_tokens > 0
? delta.cache_creation_input_tokens
: current.cache_creation_input_tokens,
cache_read_input_tokens:
delta.cache_read_input_tokens !== undefined &&
delta.cache_read_input_tokens > 0
? delta.cache_read_input_tokens
: current.cache_read_input_tokens,
}
}

View File

@@ -7,11 +7,11 @@ import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/
import { isEnvTruthy, isEnvDefinedFalsy } from '../../../utils/envUtils.js'
/**
* Detect whether DeepSeek-style thinking mode should be enabled.
* Detect whether thinking mode should be enabled for this model.
*
* Enabled when:
* 1. OPENAI_ENABLE_THINKING=1 is set (explicit enable), OR
* 2. Model name contains "deepseek-reasoner" OR "DeepSeek-V3.2" (auto-detect, case-insensitive)
* 2. Model name contains "deepseek" or "mimo" (auto-detect, case-insensitive)
*
* Disabled when:
* - OPENAI_ENABLE_THINKING=0/false/no/off is explicitly set (overrides model detection)
@@ -23,9 +23,11 @@ export function isOpenAIThinkingEnabled(model: string): boolean {
if (isEnvDefinedFalsy(process.env.OPENAI_ENABLE_THINKING)) return false
// Explicit enable
if (isEnvTruthy(process.env.OPENAI_ENABLE_THINKING)) return true
// Auto-detect from model name (all DeepSeek models support thinking mode)
// Auto-detect from model name (DeepSeek and MiMo models support thinking mode).
// Grok is intentionally excluded — Grok reasoning models reason automatically
// and do NOT require thinking/enable_thinking request body parameters.
const modelLower = model.toLowerCase()
return modelLower.includes('deepseek')
return modelLower.includes('deepseek') || modelLower.includes('mimo')
}
/**
@@ -58,12 +60,12 @@ export function resolveOpenAIMaxTokens(
* Build the request body for OpenAI chat.completions.create().
* Extracted for testability — the thinking mode params are injected here.
*
* DeepSeek thinking mode: inject thinking params via request body.
* Two formats are added simultaneously to support different deployments:
* - Official DeepSeek API: `thinking: { type: 'enabled' }`
* - Self-hosted DeepSeek-V3.2: `enable_thinking: true` + `chat_template_kwargs: { thinking: true }`
* Three thinking-mode formats are sent simultaneously; each endpoint uses the
* format it recognizes and ignores the others:
* - Official DeepSeek API: `thinking: { type: 'enabled' }`
* - Self-hosted DeepSeek: `enable_thinking: true` + `chat_template_kwargs: { thinking: true }`
* - MiMo (Xiaomi): `chat_template_kwargs: { enable_thinking: true }`
* OpenAI SDK passes unknown keys through to the HTTP body.
* Each endpoint will use the format it recognizes and ignore the others.
*/
export function buildOpenAIRequestBody(params: {
model: string
@@ -76,7 +78,7 @@ export function buildOpenAIRequestBody(params: {
}): ChatCompletionCreateParamsStreaming & {
thinking?: { type: string }
enable_thinking?: boolean
chat_template_kwargs?: { thinking: boolean }
chat_template_kwargs?: { thinking: boolean; enable_thinking: boolean }
} {
const {
model,
@@ -97,14 +99,15 @@ export function buildOpenAIRequestBody(params: {
}),
stream: true,
stream_options: { include_usage: true },
// DeepSeek thinking mode: enable chain-of-thought output.
// When active, temperature/top_p/presence_penalty/frequency_penalty are ignored by DeepSeek.
// Enable chain-of-thought output for DeepSeek and MiMo models.
// When active, temperature/top_p/presence_penalty/frequency_penalty are ignored.
...(enableThinking && {
// Official DeepSeek API format
thinking: { type: 'enabled' },
// Self-hosted DeepSeek-V3.2 format
enable_thinking: true,
chat_template_kwargs: { thinking: true },
// Both DeepSeek self-hosted and MiMo formats in chat_template_kwargs
chat_template_kwargs: { thinking: true, enable_thinking: true },
}),
// Only send temperature when thinking mode is off (DeepSeek ignores it anyway,
// but other providers may respect it)

View File

@@ -1724,12 +1724,29 @@ export function getSubscriptionName(): string {
}
}
/** Check if using third-party services (Bedrock or Vertex or Foundry) */
/**
* Check if using third-party services (non-Anthropic providers).
*
* This function gates several behaviours that should only apply when the user
* is NOT calling the first-party Anthropic API directly:
* - auth status display (authStatus handler)
* - command visibility (login/logout shown for non-3P)
* - command availability checks (meetsAvailabilityRequirement)
*
* KEEP IN SYNC with providers.ts — when a new CLAUDE_CODE_USE_* env var is
* added to getAPIProvider(), the corresponding check MUST be added here.
* Providers whose selection is controlled purely via settings.modelType
* (rather than env vars) are NOT covered by this function and may need
* separate handling in the call sites above.
*/
export function isUsing3PServices(): boolean {
return !!(
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GROK)
)
}

View File

@@ -4,9 +4,6 @@ import { initMagicDocs } from '../services/MagicDocs/magicDocs.js'
import { initSkillImprovement } from './hooks/skillImprovement.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null
const registerProtocolModule = feature('LODESTONE')
? (require('./deepLink/registerProtocol.js') as typeof import('./deepLink/registerProtocol.js'))
: null
@@ -32,7 +29,13 @@ export function startBackgroundHousekeeping(): void {
void initMagicDocs()
void initSkillImprovement()
if (feature('EXTRACT_MEMORIES')) {
extractMemoriesModule!.initExtractMemories()
void import('../services/extractMemories/extractMemories.js')
.then(({ initExtractMemories }) => {
initExtractMemories()
})
.catch(() => {
// Module load failure — non-critical, memory extraction just won't run
})
}
initAutoDream()
void autoUpdateMarketplacesAndPluginsInBackground()

View File

@@ -24,6 +24,12 @@ interface CacheWarningState {
// 模块级状态,每个 querySource 独立跟踪
const cacheWarningStateBySource = new Map<string, CacheWarningState>()
// Limit the number of tracked sources to prevent unbounded Map growth.
// querySource strings are effectively unbounded (typed as `any`), so a
// long-running session that spawns many subagents could leak memory.
// Evict the oldest entry (by insertion order) when the limit is exceeded.
const MAX_SOURCE_ENTRIES = 50
const DEFAULT_CACHE_THRESHOLD = 80
/**
@@ -81,6 +87,13 @@ export function shouldShowCacheWarning(
let state = cacheWarningStateBySource.get(querySource)
if (!state) {
state = { lastHitRate: null, lastTimestamp: null }
// Evict oldest entry when at capacity so the Map stays bounded
if (cacheWarningStateBySource.size >= MAX_SOURCE_ENTRIES) {
const oldestKey = cacheWarningStateBySource.keys().next().value
if (oldestKey !== undefined) {
cacheWarningStateBySource.delete(oldestKey)
}
}
cacheWarningStateBySource.set(querySource, state)
}
@@ -132,3 +145,10 @@ export function createCacheWarningMessage(info: CacheHitRateInfo): Message {
isMeta: false,
} as Message
}
/**
* Reset the per-source tracking state — only used in tests.
*/
export function _resetCacheWarningStateForTest(): void {
cacheWarningStateBySource.clear()
}

View File

@@ -529,6 +529,10 @@ export function setRemoteIngressUrlForTesting(url: string): void {
const REMOTE_FLUSH_INTERVAL_MS = 10
// Limit the number of cached session-file lookups to prevent unbounded Map growth
// in long-running daemon / swarm sessions that spawn many sub-agents.
const MAX_CACHED_SESSION_FILES = 200
class Project {
// Minimal cache for current session only (not all sessions)
currentSessionTag: string | undefined
@@ -577,6 +581,7 @@ class Project {
this.flushTimer = null
this.activeDrain = null
this.writeQueues = new Map()
this.existingSessionFiles = new Map()
}
private incrementPendingWrites(): void {
@@ -1288,6 +1293,9 @@ class Project {
* Returns the session file path if it exists, null otherwise.
* Used for writing to sessions other than the current one.
* Caches positive results so we only stat once per session.
*
* The cache is bounded at MAX_CACHED_SESSION_FILES to prevent unbounded
* growth in long-running daemon / swarm sessions that spawn many agents.
*/
private existingSessionFiles = new Map<string, string>()
private async getExistingSessionFile(
@@ -1299,6 +1307,13 @@ class Project {
const targetFile = getTranscriptPathForSession(sessionId)
try {
await stat(targetFile)
// Evict oldest entry when at capacity so the Map stays bounded
if (this.existingSessionFiles.size >= MAX_CACHED_SESSION_FILES) {
const oldestKey = this.existingSessionFiles.keys().next().value
if (oldestKey !== undefined) {
this.existingSessionFiles.delete(oldestKey)
}
}
this.existingSessionFiles.set(sessionId, targetFile)
return targetFile
} catch (e) {

View File

@@ -47,6 +47,7 @@ import {
import type { CustomAgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js'
import { awaitClassifierAutoApproval } from '@claude-code-best/builtin-tools/tools/BashTool/bashPermissions.js'
import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
import { SEND_MESSAGE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SendMessageTool/constants.js'
import { TASK_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/constants.js'
@@ -63,7 +64,10 @@ import {
} from '../../utils/messages.js'
import { evictTaskOutput } from '../../utils/task/diskOutput.js'
import { evictTerminalTask } from '../../utils/task/framework.js'
import { tokenCountWithEstimation } from '../../utils/tokens.js'
import {
tokenCountWithEstimation,
getTokenCountFromUsage,
} from '../../utils/tokens.js'
import { createAbortController } from '../abortController.js'
import { type AgentContext, runWithAgentContext } from '../agentContext.js'
import {
@@ -915,6 +919,7 @@ export async function runInProcessTeammate(
invokingRequestId,
} = config
const { setAppState } = toolUseContext
const startTime = Date.now()
logForDebugging(
`[inProcessRunner] Starting agent loop for ${identity.agentId}`,
@@ -1463,6 +1468,48 @@ export async function runInProcessTeammate(
// Mark as completed when exiting the loop
let alreadyTerminal = false
let toolUseId: string | undefined
// Compute result so the detail dialog can show token usage.
// Walk backwards for the last API usage (cumulative input_tokens from the
// Anthropic API already includes all prior context).
let completionTokens = 0
let completionToolUseCount = 0
let lastAssistantContent: AgentToolResult['content'] = []
let lastUsage: AgentToolResult['usage'] | undefined
for (let i = allMessages.length - 1; i >= 0; i--) {
const m = allMessages[i]!
if (m.type === 'assistant') {
const blocks = (m.message?.content ?? []) as any[]
for (const b of blocks) {
if (b?.type === 'tool_use') completionToolUseCount++
}
const textBlocks = blocks.filter((b: any) => b?.type === 'text')
if (textBlocks.length > 0 && lastAssistantContent.length === 0) {
lastAssistantContent = textBlocks.map((b: any) => ({
type: 'text' as const,
text: b.text,
}))
}
if (!lastUsage && m.message?.usage) {
lastUsage = m.message.usage as AgentToolResult['usage']
completionTokens = getTokenCountFromUsage(
m.message.usage as Parameters<typeof getTokenCountFromUsage>[0],
)
}
if (completionTokens > 0 && lastAssistantContent.length > 0) break
}
}
const teammateResult: AgentToolResult = {
agentId: identity.agentId,
agentType: 'teammate',
content: lastAssistantContent,
totalToolUseCount: completionToolUseCount,
totalDurationMs: Date.now() - startTime,
totalTokens: completionTokens,
usage: lastUsage as AgentToolResult['usage'],
} as unknown as AgentToolResult
updateTaskState(
taskId,
task => {
@@ -1481,6 +1528,7 @@ export async function runInProcessTeammate(
status: 'completed' as const,
notified: true,
endTime: Date.now(),
result: teammateResult,
messages: task.messages?.length ? [task.messages.at(-1)!] : undefined,
pendingUserMessages: [],
inProgressToolUseIDs: undefined,