mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
25 Commits
v2.4.1
...
revert-122
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd8622d84 | ||
|
|
d66a6f6124 | ||
|
|
48a19b8a0d | ||
|
|
5157b09743 | ||
|
|
ecd3f9d791 | ||
|
|
5b941d4ad4 | ||
|
|
ae7a4e5ae5 | ||
|
|
e5f31afebd | ||
|
|
fc8d531a7d | ||
|
|
835dd2d804 | ||
|
|
0face46fbe | ||
|
|
d451e30741 | ||
|
|
e7070e072f | ||
|
|
833181e025 | ||
|
|
80b46d2221 | ||
|
|
78d46aa233 | ||
|
|
b3d28bcdf1 | ||
|
|
1f80043928 | ||
|
|
3d7b32f52e | ||
|
|
2c8a22d4b3 | ||
|
|
ea5147420d | ||
|
|
3d0f1acfb7 | ||
|
|
478091567d | ||
|
|
b4e52d0c9e | ||
|
|
d11b35e023 |
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: Bug 报告
|
||||
description: 报告一个可复现的 bug
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
## 发帖前必读
|
||||
|
||||
- [ ] 我已经搜索过 [现有 Issues](https://github.com/claude-code-best/claude-code/issues),没有找到重复。
|
||||
- [ ] 我使用的是 **最新版本**(`bun run build` 或最新 release)。
|
||||
- [ ] 我已经阅读过 [README](https://github.com/claude-code-best/claude-code) 和相关文档。
|
||||
|
||||
**未完成以上检查的 Issue 将被直接关闭。**
|
||||
|
||||
---
|
||||
|
||||
## 运行环境
|
||||
|
||||
| 项目| 值|
|
||||
|---|---|
|
||||
| 操作系统| 例如 macOS 15.4、Ubuntu 24.04|
|
||||
| Bun 版本| 例如 `bun --version` 的输出|
|
||||
| Claude Code 版本| 例如 `2.4.3` 或 commit hash|
|
||||
| 安装方式| `bun run build` / npm / 其他|
|
||||
| 模型| 例如 claude-sonnet-4-6、claude-opus-4-7|
|
||||
|
||||
## 复现步骤
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 期望行为
|
||||
|
||||
<!-- 应该发生什么? -->
|
||||
|
||||
## 实际行为
|
||||
|
||||
<!-- 实际发生了什么?如有必要可附截图。 -->
|
||||
|
||||
## 相关日志
|
||||
|
||||
<!-- 粘贴终端输出或错误信息,请使用 triple backticks 代码块。 -->
|
||||
|
||||
```text
|
||||
```
|
||||
|
||||
## 补充信息
|
||||
|
||||
<!-- 其他上下文 — 配置、环境变量、尝试过的 workaround 等。 -->
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/claude-code-best/claude-code/discussions
|
||||
about: 使用问题、功能建议和一般讨论 — 请使用 Discussions 而非 Issues。
|
||||
- name: 📖 项目文档
|
||||
url: https://github.com/claude-code-best/claude-code
|
||||
about: 提交 issue 前,请先阅读 README 和相关文档,你的问题可能已经有答案了。
|
||||
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: 功能建议
|
||||
description: 提出新功能或改进建议
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
## 发帖前必读
|
||||
|
||||
- [ ] 我已经搜索过 [现有 Issues](https://github.com/claude-code-best/claude-code/issues),没有找到重复。
|
||||
- [ ] 这是功能建议,不是 Bug 报告或使用问题。
|
||||
- [ ] 使用问题请前往 [Discussions](https://github.com/claude-code-best/claude-code/discussions)。
|
||||
|
||||
---
|
||||
|
||||
## 要解决的问题
|
||||
|
||||
<!-- 这个功能解决什么问题?为什么需要它? -->
|
||||
|
||||
## 建议方案
|
||||
|
||||
<!-- 描述你建议的实现方式,尽量简洁具体。 -->
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
<!-- 还有没有想到的其他实现思路? -->
|
||||
|
||||
## 补充信息
|
||||
|
||||
<!-- 截图、草图、参考资料,或其他有助于说明需求的内容。 -->
|
||||
@@ -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>",
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
let finishReason: string | undefined
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedReadTokens = 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const usage = chunk.usageMetadata
|
||||
@@ -23,6 +24,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
inputTokens = usage.promptTokenCount ?? inputTokens
|
||||
outputTokens =
|
||||
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
|
||||
cachedReadTokens = usage.cachedContentTokenCount ?? cachedReadTokens
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
@@ -41,7 +43,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
},
|
||||
} as unknown as BetaRawMessageStreamEvent
|
||||
@@ -204,7 +206,10 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export type GeminiUsageMetadata = {
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
cachedContentTokenCount?: number
|
||||
}
|
||||
|
||||
export type GeminiCandidate = {
|
||||
|
||||
@@ -10,8 +10,14 @@ import {
|
||||
} from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import {
|
||||
extractDiscoveredToolNames,
|
||||
isSearchExtraToolsEnabledOptimistic,
|
||||
isSearchExtraToolsToolAvailable,
|
||||
} from 'src/utils/searchExtraTools.js'
|
||||
import { DESCRIPTION, getPrompt } from './prompt.js'
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
import { isDeferredTool } from '../SearchExtraToolsTool/prompt.js'
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
@@ -74,6 +80,32 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: block execution of undiscovered deferred tools.
|
||||
// When tool search is active, deferred tools must be discovered via
|
||||
// SearchExtraTools first so the model has seen their schemas and knows
|
||||
// the correct parameters. Executing an undiscovered tool almost always
|
||||
// fails with parameter validation errors.
|
||||
if (
|
||||
isSearchExtraToolsEnabledOptimistic() &&
|
||||
isSearchExtraToolsToolAvailable(tools) &&
|
||||
isDeferredTool(targetTool)
|
||||
) {
|
||||
const discovered = extractDiscoveredToolNames(context.messages)
|
||||
if (!discovered.has(input.tool_name)) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Tool "${input.tool_name}" has not been discovered yet. You must first use SearchExtraTools to discover this tool before executing it.\n\nUsage: SearchExtraTools("select:${input.tool_name}")`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the target tool is currently enabled
|
||||
if (!targetTool.isEnabled()) {
|
||||
return {
|
||||
@@ -89,6 +121,29 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// Validate input before delegating — prevents crashes when the model
|
||||
// omits required params (e.g. TeamCreate without team_name →
|
||||
// sanitizeName(undefined).replace() TypeError).
|
||||
if (targetTool.validateInput) {
|
||||
const validation = await targetTool.validateInput(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
)
|
||||
if (!validation.result) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Invalid parameters for tool "${input.tool_name}": ${validation.message}`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions on the target tool
|
||||
const permResult = await targetTool.checkPermissions?.(
|
||||
input.params as Record<string, unknown>,
|
||||
@@ -132,7 +187,7 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
},
|
||||
renderToolUseMessage(input) {
|
||||
return `Executing ${input.tool_name}...`
|
||||
return `${input.tool_name}`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'ExecuteExtraTool'
|
||||
|
||||
@@ -33,10 +33,10 @@ mock.module('src/utils/searchExtraTools.js', () => ({
|
||||
isSearchExtraToolsEnabledOptimistic: () => true,
|
||||
getAutoSearchExtraToolsCharThreshold: () => 100,
|
||||
getSearchExtraToolsMode: () => 'tst' as const,
|
||||
isSearchExtraToolsToolAvailable: async () => true,
|
||||
isSearchExtraToolsToolAvailable: () => true,
|
||||
isSearchExtraToolsEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
extractDiscoveredToolNames: () => new Set(['TestTool', 'SecretTool']),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
@@ -154,6 +154,26 @@ describe('ExecuteTool', () => {
|
||||
expect(result.newMessages).toBeDefined()
|
||||
})
|
||||
|
||||
test('returns error when deferred tool has not been discovered via SearchExtraTools', async () => {
|
||||
const mockTarget = makeMockTool('UndiscoveredTool', 'result')
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'UndiscoveredTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'UndiscoveredTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
expect(result.newMessages![0].content).toContain('has not been discovered')
|
||||
})
|
||||
|
||||
test('has correct name', () => {
|
||||
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
|
||||
})
|
||||
|
||||
@@ -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 的特殊方式实现了等效功能,无需再开
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
import { AutofixProgress } from '../AutofixProgress.js';
|
||||
|
||||
describe('AutofixProgress', () => {
|
||||
describe.skipIf(!!process.env.CI)('AutofixProgress', () => {
|
||||
test('renders target in header', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
|
||||
expect(out).toContain('acme/myrepo#42');
|
||||
|
||||
@@ -1,571 +0,0 @@
|
||||
/**
|
||||
* Coverage tests for issue/index.ts gh-CLI paths.
|
||||
*
|
||||
* issue/index.ts uses `import * as childProcess from 'node:child_process'`
|
||||
* with lazy promisify, so mock.module('node:child_process') is effective.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { promisify } from 'node:util'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// ── Mock control state ──
|
||||
let _execFileSyncImpl: (cmd: string, args: string[], opts?: unknown) => Buffer =
|
||||
() => Buffer.from('')
|
||||
|
||||
let _execFileImpl: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => void = (_cmd, _args, _opts, cb) => cb(null, '', '')
|
||||
|
||||
const execFileSyncMockCore = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
): Buffer => _execFileSyncImpl(cmd, args, opts)
|
||||
|
||||
const execFileMockCore = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => _execFileImpl(cmd, args, opts, cb)
|
||||
|
||||
;(execFileMockCore as unknown as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> =>
|
||||
new Promise((resolve, reject) =>
|
||||
_execFileImpl(cmd, args, opts, (err, stdout, stderr) => {
|
||||
if (err) reject(err)
|
||||
else resolve({ stdout, stderr })
|
||||
}),
|
||||
)
|
||||
|
||||
// Spread real child_process + flag-gated stub (see share-gh.test.ts for the
|
||||
// promisify.custom rationale).
|
||||
let useIssueGhCpStubs = false
|
||||
const wrappedIssueGhExecFile = ((...args: unknown[]) =>
|
||||
useIssueGhCpStubs
|
||||
? (execFileMockCore as (...a: unknown[]) => unknown)(...args)
|
||||
: // eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
(require('node:child_process').execFile as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as unknown as Record<symbol, unknown> & ((...a: unknown[]) => unknown)
|
||||
;(wrappedIssueGhExecFile as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (useIssueGhCpStubs) {
|
||||
return new Promise((resolve, reject) =>
|
||||
_execFileImpl(cmd, args, opts, (err, stdout, stderr) =>
|
||||
err ? reject(err) : resolve({ stdout, stderr }),
|
||||
),
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return promisify(real.execFile as never)(cmd, args, opts) as Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
mock.module('node:child_process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
execFile: wrappedIssueGhExecFile as typeof real.execFile,
|
||||
execFileSync: ((...args: unknown[]) =>
|
||||
useIssueGhCpStubs
|
||||
? (execFileSyncMockCore as (...a: unknown[]) => unknown)(...args)
|
||||
: (real.execFileSync as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as typeof real.execFileSync,
|
||||
}
|
||||
})
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
}))
|
||||
|
||||
// ── State ──
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'issue-gh-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
// Default: git remote fails (no GitHub remote), gh not available
|
||||
_execFileSyncImpl = (_cmd, _args, _opts) => {
|
||||
throw new Error('ENOENT: command not found')
|
||||
}
|
||||
_execFileImpl = (_cmd, _args, _opts, cb) =>
|
||||
cb(new Error('ENOENT: command not found'), '', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
// ── Helpers ──
|
||||
type CallFn = (args: string) => Promise<{ type: string; value: string }>
|
||||
|
||||
async function getCallFn(): Promise<CallFn> {
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as { load: () => Promise<{ call: CallFn }> }
|
||||
).load()
|
||||
return loaded.call.bind(loaded) as CallFn
|
||||
}
|
||||
|
||||
async function writeSessionLog(entries?: string[]): Promise<void> {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const content = entries ?? [
|
||||
JSON.stringify({ role: 'user', content: 'Fix the login bug' }),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'I will investigate' }],
|
||||
}),
|
||||
]
|
||||
writeFileSync(join(dir, `${sessionId}.jsonl`), content.join('\n') + '\n')
|
||||
}
|
||||
|
||||
// Create a .github/ISSUE_TEMPLATE dir in tmpDir
|
||||
function createIssueTemplate(
|
||||
content = '## Bug Report\n\nDescribe the bug.',
|
||||
): string {
|
||||
const templateDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
writeFileSync(join(templateDir, 'bug_report.md'), content)
|
||||
return templateDir
|
||||
}
|
||||
|
||||
// ── Sequence helpers ──
|
||||
type SeqBehavior =
|
||||
| { type: 'sync-ok'; stdout: string }
|
||||
| { type: 'sync-fail'; msg: string }
|
||||
| { type: 'async-ok'; stdout: string }
|
||||
| { type: 'async-fail'; msg: string }
|
||||
|
||||
/**
|
||||
* Sets sync/async behavior based on command name.
|
||||
* syncBehavior controls execFileSync (git, gh --version sync-check).
|
||||
* asyncBehaviors controls sequential async calls.
|
||||
*/
|
||||
function setupMocks(opts: {
|
||||
gitRemoteUrl?: string | null // null = git fails, string = succeeds with that URL
|
||||
ghCliAvailable?: boolean // whether gh --version sync call succeeds
|
||||
asyncSequence?: Array<
|
||||
{ ok: true; stdout: string } | { ok: false; msg: string }
|
||||
>
|
||||
}): void {
|
||||
const { gitRemoteUrl, ghCliAvailable = false, asyncSequence = [] } = opts
|
||||
|
||||
_execFileSyncImpl = (cmd, _args, _opts) => {
|
||||
if (cmd === 'git') {
|
||||
if (gitRemoteUrl !== null && gitRemoteUrl !== undefined) {
|
||||
return Buffer.from(gitRemoteUrl + '\n')
|
||||
}
|
||||
throw new Error('ENOENT: git not found or no remote')
|
||||
}
|
||||
if (cmd === 'gh') {
|
||||
if (ghCliAvailable) {
|
||||
return Buffer.from('gh version 2.0.0')
|
||||
}
|
||||
throw new Error('ENOENT: gh not found')
|
||||
}
|
||||
throw new Error(`Unexpected sync command: ${cmd}`)
|
||||
}
|
||||
|
||||
let asyncCallCount = 0
|
||||
_execFileImpl = (_cmd, _args, _opts, cb) => {
|
||||
const b = asyncSequence[asyncCallCount] ?? {
|
||||
ok: false,
|
||||
msg: 'unexpected async call',
|
||||
}
|
||||
asyncCallCount++
|
||||
if (b.ok) cb(null, b.stdout, '')
|
||||
else cb(new Error(b.msg), '', b.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Activate child_process stubs only for this suite.
|
||||
beforeAll(() => {
|
||||
useIssueGhCpStubs = true
|
||||
})
|
||||
afterAll(() => {
|
||||
useIssueGhCpStubs = false
|
||||
})
|
||||
|
||||
describe('issue command — tryDetectGitRemoteUrl catch path', () => {
|
||||
test('git fails → tryDetectGitRemoteUrl returns null → no remote detected', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
// No remote + no gh → fallback URL path
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — ghCliAvailable paths', () => {
|
||||
test('gh not available → falls back to browser URL (with GitHub remote)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: false,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('github.com/owner/repo')
|
||||
expect(result.value).toContain('Install')
|
||||
})
|
||||
|
||||
test('gh not available + no remote → shows no GitHub remote message', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
|
||||
test('gh available + no remote → falls back to browser (no URL)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: null,
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — parseOwnerRepo null path', () => {
|
||||
test('non-GitHub remote → parseOwnerRepo returns null → no gh URL', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://gitlab.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — repoHasIssuesEnabled paths', () => {
|
||||
test('gh available + GitHub remote → issues enabled (true) → creates issue', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' }, // gh api repos → has_issues = true
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/42' }, // gh issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
expect(result.value).toContain('Fix login bug')
|
||||
expect(result.value).toContain('https://github.com/owner/repo/issues/42')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → issues disabled (false) → discussions fallback', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'false\n' }, // gh api repos → has_issues = false
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issues are disabled')
|
||||
expect(result.value).toContain('discussions')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → repoHasIssuesEnabled returns null (unexpected output)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'null\n' }, // unexpected .has_issues value → null
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/99' }, // issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
// null → proceeds to create issue
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → repoHasIssuesEnabled throws → returns null → creates issue', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: false, msg: 'network error' }, // gh api fails → catch → null
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/101' }, // issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote + issue create fails → error message', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' }, // has_issues = true
|
||||
{ ok: false, msg: 'gh auth error' }, // issue create fails
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Failed to create issue')
|
||||
expect(result.value).toContain('gh auth error')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote + labels and assignees → issue created with labels', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/50' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label bug --assignee alice Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
expect(result.value).toContain('Labels: bug')
|
||||
expect(result.value).toContain('Assignees: alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — detectIssueTemplate paths', () => {
|
||||
test('no .github/ISSUE_TEMPLATE → no template used', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/1' },
|
||||
],
|
||||
})
|
||||
process.env.INIT_CWD = tmpDir
|
||||
// Ensure no ISSUE_TEMPLATE exists
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test no template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('.github/ISSUE_TEMPLATE with md file → template included in body', async () => {
|
||||
createIssueTemplate('---\nname: Bug Report\n---\n## Describe the bug')
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/2' },
|
||||
],
|
||||
})
|
||||
// Override getOriginalCwd to return tmpDir by setting env
|
||||
// detectIssueTemplate uses `cwd = getOriginalCwd()` from state
|
||||
// which returns the real process cwd. We create template relative to real cwd
|
||||
// This test just verifies the path doesn't crash.
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test with template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
|
||||
test('.github/ISSUE_TEMPLATE with only yml files → no md template', async () => {
|
||||
const templateDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
writeFileSync(join(templateDir, 'bug.yml'), 'name: Bug\ndescription: A bug')
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/3' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test yml template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — getTranscriptSummary paths', () => {
|
||||
test('session log exists + projectDir=null → reads from standard path', async () => {
|
||||
await writeSessionLog()
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/4' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('session log with tool_result errors → errors included in summary', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu1',
|
||||
is_error: true,
|
||||
content: 'Command failed with exit code 1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
JSON.stringify({ role: 'user', content: 'help me' }),
|
||||
JSON.stringify({ role: 'assistant', content: 'let me look' }),
|
||||
])
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/5' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix crash')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('session log with array content user message', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'What is the issue?' }],
|
||||
}),
|
||||
])
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/6' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test array content')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('no session log → getTranscriptSummary returns no session log found', async () => {
|
||||
// No log written → summary says "(no session log found)"
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/7' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix issue no log')
|
||||
expect(result.type).toBe('text')
|
||||
// Either creates issue successfully or fails, but passes the code paths
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — SSH GitHub remote', () => {
|
||||
test('SSH remote parsed correctly → issue created', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'git@github.com:owner/myrepo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/myrepo/issues/8' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix SSH issue')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — no title with remote present', () => {
|
||||
test('no title + GitHub remote + gh available → usage with repo info and gh message', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
expect(result.value).toContain('owner/repo')
|
||||
})
|
||||
|
||||
test('no title + no remote + gh not available → usage with no repo info', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
})
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Coverage tests for detectIssueTemplate paths.
|
||||
*
|
||||
* detectIssueTemplate uses getOriginalCwd() to find .github/ISSUE_TEMPLATE.
|
||||
* These tests create the template directory in the REAL project CWD and clean
|
||||
* up after each test.
|
||||
*
|
||||
* IMPORTANT: No state mock is used — this avoids global mock contamination.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// ── child_process mock ──
|
||||
let _execFileSyncImplT: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
) => Buffer = () => Buffer.from('')
|
||||
let _execFileImplT: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => void = (_cmd, _args, _opts, cb) => cb(null, '', '')
|
||||
|
||||
const execFileSyncMockT = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
): Buffer => _execFileSyncImplT(cmd, args, opts)
|
||||
const execFileMockT = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => _execFileImplT(cmd, args, opts, cb)
|
||||
|
||||
;(execFileMockT as unknown as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> =>
|
||||
new Promise((resolve, reject) =>
|
||||
_execFileImplT(cmd, args, opts, (err, stdout, stderr) => {
|
||||
if (err) reject(err)
|
||||
else resolve({ stdout, stderr })
|
||||
}),
|
||||
)
|
||||
|
||||
// Spread real child_process + flag-gated stub (see share-gh.test.ts for the
|
||||
// promisify.custom rationale).
|
||||
let useIssueTemplateCpStubs = false
|
||||
const wrappedIssueTemplateExecFile = ((...args: unknown[]) =>
|
||||
useIssueTemplateCpStubs
|
||||
? (execFileMockT as (...a: unknown[]) => unknown)(...args)
|
||||
: // eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
(require('node:child_process').execFile as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as unknown as Record<symbol, unknown> & ((...a: unknown[]) => unknown)
|
||||
;(wrappedIssueTemplateExecFile as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (useIssueTemplateCpStubs) {
|
||||
return new Promise((resolve, reject) =>
|
||||
_execFileImplT(cmd, args, opts, (err, stdout, stderr) =>
|
||||
err ? reject(err) : resolve({ stdout, stderr }),
|
||||
),
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return promisify(real.execFile as never)(cmd, args, opts) as Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
mock.module('node:child_process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
execFile: wrappedIssueTemplateExecFile as typeof real.execFile,
|
||||
execFileSync: ((...args: unknown[]) =>
|
||||
useIssueTemplateCpStubs
|
||||
? (execFileSyncMockT as (...a: unknown[]) => unknown)(...args)
|
||||
: (real.execFileSync as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as typeof real.execFileSync,
|
||||
}
|
||||
})
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
}))
|
||||
|
||||
// Re-mock bootstrap/state.js so getOriginalCwd points at the real process
|
||||
// cwd regardless of any prior test file's static state mock (e.g.
|
||||
// launchAutofixPr.test.ts pinning '/mock/cwd'). Without this override, in
|
||||
// the full suite detectIssueTemplate would see '/mock/cwd' and skip the
|
||||
// template loading body (lines 114-129).
|
||||
import { stateMock as _baseStateMockT } from '../../../../tests/mocks/state'
|
||||
let _dynamicCwdT: string = process.cwd()
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
..._baseStateMockT(),
|
||||
getSessionId: () => 'issue-tpl-session-id',
|
||||
getSessionProjectDir: () => null,
|
||||
getOriginalCwd: () => _dynamicCwdT,
|
||||
setOriginalCwd: (c: string) => {
|
||||
_dynamicCwdT = c
|
||||
},
|
||||
}))
|
||||
|
||||
// ── State ──
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
|
||||
// The real CWD where the issue command will look for .github/ISSUE_TEMPLATE
|
||||
// We determine this at import time (stable throughout test run)
|
||||
const realCwd = process.cwd()
|
||||
// We track whether we created the template dir so we can clean it up
|
||||
let createdTemplatePath: string | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'issue-tpl-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
createdTemplatePath = null
|
||||
|
||||
// Default: git → GitHub remote, gh → available, async → issues true + create OK
|
||||
let n = 0
|
||||
_execFileSyncImplT = (cmd, _args, _opts) => {
|
||||
if (cmd === 'git') return Buffer.from('https://github.com/owner/repo.git\n')
|
||||
if (cmd === 'gh') return Buffer.from('gh version 2.0.0')
|
||||
return Buffer.from('')
|
||||
}
|
||||
_execFileImplT = (_cmd, _args, _opts, cb) => {
|
||||
n++
|
||||
if (n === 1) cb(null, 'true\n', '')
|
||||
else cb(null, 'https://github.com/owner/repo/issues/20', '')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
// Clean up any template dir we created in the real CWD
|
||||
if (createdTemplatePath && existsSync(createdTemplatePath)) {
|
||||
rmSync(createdTemplatePath, { recursive: true, force: true })
|
||||
}
|
||||
createdTemplatePath = null
|
||||
})
|
||||
|
||||
// ── Helpers ──
|
||||
type CallFn = (args: string) => Promise<{ type: string; value: string }>
|
||||
|
||||
async function getCallFn(): Promise<CallFn> {
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as { load: () => Promise<{ call: CallFn }> }
|
||||
).load()
|
||||
return loaded.call.bind(loaded) as CallFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates .github/ISSUE_TEMPLATE in the REAL CWD.
|
||||
* Registers for cleanup in afterEach.
|
||||
*/
|
||||
function createTemplateInCwd(files: Record<string, string>): string {
|
||||
const templateDir = join(realCwd, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(templateDir, name), content)
|
||||
}
|
||||
// Track the 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
46
src/services/api/openai/openaiShared.ts
Normal file
46
src/services/api/openai/openaiShared.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user