mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
39 Commits
revert-122
...
v2.6.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62b384e36 | ||
|
|
d7001b870f | ||
|
|
18437c20d2 | ||
|
|
02298cb199 | ||
|
|
b2b1981da3 | ||
|
|
33c52578a6 | ||
|
|
e33b17bde7 | ||
|
|
797424115d | ||
|
|
efc218d8a9 | ||
|
|
a91653a0dd | ||
|
|
c982104476 | ||
|
|
6dd378bf15 | ||
|
|
ed61932748 | ||
|
|
b1c4f40f90 | ||
|
|
f91060836f | ||
|
|
9d17597e58 | ||
|
|
f2b751f659 | ||
|
|
d4a601475f | ||
|
|
897c186f28 | ||
|
|
03598d3f84 | ||
|
|
7b52054ff5 | ||
|
|
66c892521b | ||
|
|
dab04af7c9 | ||
|
|
5b5fbb2f47 | ||
|
|
9bfa868e61 | ||
|
|
f6dcf63902 | ||
|
|
5957e26d9b | ||
|
|
58c3feb56a | ||
|
|
e2f4d558e1 | ||
|
|
9afcb398ca | ||
|
|
c80a6d062b | ||
|
|
a05242cef0 | ||
|
|
27b334aceb | ||
|
|
27b665ac79 | ||
|
|
ea399f1862 | ||
|
|
c499bfb4ed | ||
|
|
b67e9f9d38 | ||
|
|
2bca31e525 | ||
|
|
2cc9a7daef |
4
.github/workflows/publish-npm.yml
vendored
4
.github/workflows/publish-npm.yml
vendored
@@ -3,11 +3,11 @@ name: Publish to npm
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.9.0)'
|
||||
description: "版本号 (例如: v1.9.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
@@ -78,8 +78,9 @@ bun run docs:dev
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。构建时会将 `vendor/audio-capture/` 和 `src/utils/vendor/ripgrep/` 复制到 `dist/vendor/` 下。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,chunk 输出到 `dist/chunks/`。post-build 同样复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/ripgrep.ts` 和 `packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,代码分割模式,chunk 输出到 `dist/chunks/`。post-build 遍历 `dist/` 和 `dist/chunks/` 下所有 `.js` 文件做 `globalThis.Bun` 解构 patch,复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/distRoot.ts` 提供共享的 `distRoot` 函数,通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 或 `lastIndexOf('src')` 定位根目录。`ripgrep.ts`、`computerUse/setup.ts`、`claudeInChrome/setup.ts`、`updateCCB.ts` 均使用 `distRoot` 而非内联 `import.meta.url` 路径推算。`packages/audio-capture-napi/src/index.ts` 有独立的 `lastIndexOf('dist')` 逻辑,功能等价。
|
||||
- **为什么 Vite 必须代码分割**: Bun/JSC 会全量解析单个大 JS 文件的 bytecode 和 JIT,单文件 17MB 产物导致 RSS 暴涨至 ~1GB(Node/V8 懒解析仅需 ~220MB)。代码分割为 600+ 小 chunk 后 Bun 按需加载,`--version` RSS 从 966MB 降至 35MB,完整加载从 1GB+ 降至 ~500MB。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 17 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
|
||||
> Which Claude do you like? The open source one is the best.
|
||||
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 完整复原的工程化项目。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 并在此基础上扩展了更多好玩的特性。
|
||||
|
||||
> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
||||
[Peri Code](https://github.com/KonghaYao/peri):Claude Code 兼容的 Rust Agent,多年大模型经验匠心制作,国内大模型(DeepSeek/GLM)精调,CPU/内存极致优化,在开发版/树莓派上也能跑 CC 一样的体验。
|
||||
|
||||
[文档在这里](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组,群主在线答疑](https://discord.gg/uApuzJWGKX)
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -150,7 +149,6 @@ bun run build
|
||||
|
||||
需要填写的字段:
|
||||
|
||||
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
| ------------ | ------------- | ---------------------------- |
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.5 MiB |
54
docs/performance-reporter.md
Normal file
54
docs/performance-reporter.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 内存占用 1G 调研报告
|
||||
|
||||
> 诊断 session `a3593062` RSS 达 1.09 GB,定位 Bun 运行时内存膨胀根因
|
||||
|
||||
## 数据收集
|
||||
|
||||
- **诊断数据**: RSS 1,118 MB,V8 heap 84 MB,原生内存缺口 1,034 MB(92%)
|
||||
- **构建方式**: `bun run build:vite` → Vite/Rollup 单文件构建,产物 17MB `dist/cli.js`
|
||||
- **Vite 配置**: `codeSplitting: false`(`vite.config.ts:97`),所有代码内联为单文件
|
||||
- **Node.js 对比**: 相同 17MB 产物,Node.js RSS 仅 223 MB(`--version`)/ 340 MB(完整加载)
|
||||
|
||||
## 探索与验证
|
||||
|
||||
### 已确认
|
||||
|
||||
| 问题 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| **根因: Vite 单文件构建 + Bun 解析大文件内存效率低** | `vite.config.ts:97` | `codeSplitting: false` 产出 17MB 单文件,Bun/JSC 解析时 RSS 暴涨至 966MB |
|
||||
| Node.js 对同等 17MB 文件仅需 223MB | 实测 | V8 对大文件解析的内存效率远优于 JSC |
|
||||
| Bun.build 代码分割可解决问题 | 实测 | `bun run build`(代码分割 → 627 chunk)Bun RSS 仅 30MB(`--version`)/ 318MB(完整加载) |
|
||||
|
||||
### 已否认
|
||||
|
||||
- 不是 feature flags 数量问题 — 全部 35 features 开启时,代码分割构建内存正常
|
||||
- 不是内存泄漏 — `detachedContexts: 0`,`activeHandles: 0`
|
||||
- 不是原生 addon 问题 — vendor 文件仅 2.7MB
|
||||
- 不是 TypeScript 源码体量问题 — `bun run dev`(直接加载 TS)完整路径仅 345MB
|
||||
|
||||
## 结论
|
||||
|
||||
**根因是 Vite 构建配置 `codeSplitting: false`,产出 17MB 单文件,Bun/JSC 解析单文件大 JS 时内存效率极差(966MB vs Node 的 223MB)。**
|
||||
|
||||
实测对比矩阵:
|
||||
|
||||
| 构建方式 | 产物结构 | Bun RSS | Node RSS | Bun/Node |
|
||||
|----------|----------|---------|----------|----------|
|
||||
| `build:vite` | 17MB 单文件 | **966 MB** | 223 MB | 4.3x |
|
||||
| `build:vite` pipe mode | 同上 | **1,088 MB** | 340 MB | 3.2x |
|
||||
| `build` (Bun) | 627 chunk | 30 MB | 42 MB | 0.7x |
|
||||
| `build` (Bun) pipe mode | 同上 | 318 MB | 253 MB | 1.3x |
|
||||
| `bun run dev` TS 源码 | 动态加载 | 42 MB | — | — |
|
||||
| `bun run dev` pipe mode | 动态加载 | 345 MB | — | — |
|
||||
|
||||
核心差异:
|
||||
- **Node/V8** 解析 17MB 文件只需 223MB — V8 的懒解析(lazy parsing)只编译入口需要的部分
|
||||
- **Bun/JSC** 解析 17MB 文件需要 966MB — JSC 对单文件做全量编译,bytecode + JIT 占用大量原生内存
|
||||
- 代码分割后(627 个小 chunk),Bun 按需加载,内存回到正常水平
|
||||
|
||||
## 建议
|
||||
|
||||
1. **开启 Vite 代码分割** — 在 `vite.config.ts` 中启用 `codeSplitting: true` 或使用 Rollup 的 `manualChunks` 配置。这是最直接的修复
|
||||
2. **或切换到 Bun.build** — `bun run build` 已默认启用代码分割(`splitting: true`),Bun RSS 仅 30-318MB
|
||||
3. **如果必须单文件** — 考虑用 Node.js 运行 Vite 产物(`node dist/cli-node.js`),代价是失去 Bun 特有 API
|
||||
4. **验证 `codeSplitting: false` 的存在理由** — 注释说"all dynamic imports inlined",可能是为了简化部署。评估是否真的需要单文件
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.4.4",
|
||||
"version": "2.6.6",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SocketConnectionError } from './mcpSocketClient.js'
|
||||
import {
|
||||
localPlatformLabel,
|
||||
type BridgePermissionRequest,
|
||||
toLoggerDetail,
|
||||
type ChromeExtensionInfo,
|
||||
type ClaudeForChromeContext,
|
||||
type PermissionMode,
|
||||
@@ -578,7 +579,7 @@ export class BridgeClient implements SocketClient {
|
||||
const durationMs = Date.now() - this.connectionStartTime
|
||||
logger.error(
|
||||
`[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
|
||||
error,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
trackEvent?.('chrome_bridge_connection_failed', {
|
||||
duration_ms: durationMs,
|
||||
@@ -618,7 +619,10 @@ export class BridgeClient implements SocketClient {
|
||||
)
|
||||
this.handleMessage(message)
|
||||
} catch (error) {
|
||||
logger.error(`[${serverName}] Failed to parse bridge message:`, error)
|
||||
logger.error(
|
||||
`[${serverName}] Failed to parse bridge message:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -862,7 +866,10 @@ export class BridgeClient implements SocketClient {
|
||||
const allowed = await pending.onPermissionRequest(request)
|
||||
this.sendPermissionResponse(requestId, allowed)
|
||||
} catch (error) {
|
||||
logger.error(`[${serverName}] Error handling permission request:`, error)
|
||||
logger.error(
|
||||
`[${serverName}] Error handling permission request:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
this.sendPermissionResponse(requestId, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ export { localPlatformLabel } from './types.js'
|
||||
export type {
|
||||
BridgeConfig,
|
||||
ChromeExtensionInfo,
|
||||
ChromeBridgeTrackEventMetadata,
|
||||
ClaudeForChromeContext,
|
||||
Logger,
|
||||
LoggerDetail,
|
||||
PermissionMode,
|
||||
SocketClient,
|
||||
} from './types.js'
|
||||
export { toLoggerDetail } from './types.js'
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
export class SocketConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -87,7 +88,10 @@ class McpSocketClient {
|
||||
await this.validateSocketSecurity(socketPath)
|
||||
} catch (error) {
|
||||
this.connecting = false
|
||||
logger.info(`[${serverName}] Security validation failed:`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Security validation failed:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
// Don't retry on security failures (wrong perms/owner) - those won't
|
||||
// self-resolve. Only the error handler retries on transient errors.
|
||||
return
|
||||
@@ -145,14 +149,20 @@ class McpSocketClient {
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Failed to parse message:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('error', (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Socket error (code: ${error.code}):`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PermissionOverrides,
|
||||
SocketClient,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
export const handleToolCall = async (
|
||||
context: ClaudeForChromeContext,
|
||||
@@ -44,7 +45,10 @@ export const handleToolCall = async (
|
||||
|
||||
return handleToolCallDisconnected(context)
|
||||
} catch (error) {
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
|
||||
context.logger.info(
|
||||
`[${context.serverName}] Error calling tool:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
|
||||
if (error instanceof SocketConnectionError) {
|
||||
return handleToolCallDisconnected(context)
|
||||
@@ -165,8 +169,7 @@ async function handleToolCallConnected(
|
||||
|
||||
// Fallback for unexpected result format
|
||||
context.logger.warn(
|
||||
`[${context.serverName}] Unexpected result format from socket bridge`,
|
||||
response,
|
||||
`[${context.serverName}] Unexpected result format from socket bridge: ${JSON.stringify(response)}`,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,84 @@
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
/**
|
||||
* Logger 第二参数的可选类型。
|
||||
* 调用方通过 util.format 追加详情,实践中多为 catch 到的异常对象。
|
||||
*/
|
||||
export type LoggerDetail = Error | NodeJS.ErrnoException
|
||||
|
||||
/** 将 unknown 收窄为 LoggerDetail,供 catch 块传给 logger 使用。 */
|
||||
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
|
||||
return detail instanceof Error ? detail : undefined
|
||||
}
|
||||
|
||||
/** 宿主注入的日志接口,与 DebugLogger(util.format)对齐。 */
|
||||
export interface Logger {
|
||||
info: (message: string, detail?: LoggerDetail) => void // 信息
|
||||
error: (message: string, detail?: LoggerDetail) => void // 错误
|
||||
warn: (message: string, detail?: LoggerDetail) => void // 警告
|
||||
debug: (message: string, detail?: LoggerDetail) => void // 调试
|
||||
silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge 连接失败时的 error_type 枚举。
|
||||
* 由 bridgeClient 在 getUserId / getOAuthToken / WebSocket 创建失败时上报。
|
||||
*/
|
||||
export type ChromeBridgeConnectionErrorType =
|
||||
| 'no_user_id' // 无法获取用户 UUID
|
||||
| 'no_oauth_token' // 无法获取 OAuth token
|
||||
| 'websocket_error' // WebSocket 创建或运行异常
|
||||
|
||||
/** 工具调用相关遥测元数据(started / completed / timeout / error)。 */
|
||||
export type ChromeBridgeToolCallMetadata = {
|
||||
tool_name: string // MCP 工具名
|
||||
tool_use_id: string // 本次调用的 UUID
|
||||
duration_ms?: number // 耗时(毫秒)
|
||||
timeout_ms?: number // 超时阈值(毫秒),仅 timeout 事件
|
||||
error_message?: string // 错误摘要(截断),仅 error 事件
|
||||
}
|
||||
|
||||
/** Bridge 连接失败遥测元数据。 */
|
||||
export type ChromeBridgeConnectionFailedMetadata = {
|
||||
duration_ms: number // 自连接开始到失败的耗时(毫秒)
|
||||
error_type: ChromeBridgeConnectionErrorType // 失败原因分类
|
||||
reconnect_attempt: number // 当前重连尝试次数
|
||||
}
|
||||
|
||||
/** Bridge 开始连接遥测元数据。 */
|
||||
export type ChromeBridgeConnectionStartedMetadata = {
|
||||
bridge_url: string // 目标 WebSocket URL(含用户路径)
|
||||
}
|
||||
|
||||
/** Bridge 断开连接遥测元数据。 */
|
||||
export type ChromeBridgeDisconnectedMetadata = {
|
||||
close_code: number // WebSocket 关闭码
|
||||
duration_since_connect_ms: number // 自连接成功到断开的时长(毫秒)
|
||||
reconnect_attempt: number // 即将进行的重连序号
|
||||
}
|
||||
|
||||
/** Bridge 连接成功遥测元数据。 */
|
||||
export type ChromeBridgeConnectionSucceededMetadata = {
|
||||
duration_ms: number // 自开始到连接就绪的耗时(毫秒)
|
||||
status: 'paired' | 'waiting' // paired=已配对扩展;waiting=等待扩展接入
|
||||
}
|
||||
|
||||
/** Bridge 重连次数耗尽遥测元数据。 */
|
||||
export type ChromeBridgeReconnectExhaustedMetadata = {
|
||||
total_attempts: number // 累计重连次数上限
|
||||
}
|
||||
|
||||
/**
|
||||
* trackEvent 回调的 metadata 联合类型。
|
||||
* 各变体对应 bridgeClient 内 chrome_bridge_* 事件;null 表示无附加字段。
|
||||
*/
|
||||
export type ChromeBridgeTrackEventMetadata =
|
||||
| ChromeBridgeToolCallMetadata
|
||||
| ChromeBridgeConnectionFailedMetadata
|
||||
| ChromeBridgeConnectionStartedMetadata
|
||||
| ChromeBridgeDisconnectedMetadata
|
||||
| ChromeBridgeConnectionSucceededMetadata
|
||||
| ChromeBridgeReconnectExhaustedMetadata
|
||||
| null // 无元数据(如 peer_connected / peer_disconnected)
|
||||
|
||||
export type PermissionMode =
|
||||
| 'ask'
|
||||
| 'skip_all_permission_checks'
|
||||
@@ -48,10 +121,10 @@ export interface ClaudeForChromeContext {
|
||||
bridgeConfig?: BridgeConfig
|
||||
/** If set, permission mode is sent to the extension immediately on bridge connection. */
|
||||
initialPermissionMode?: PermissionMode
|
||||
/** Optional callback to track telemetry events for bridge connections */
|
||||
trackEvent?: <K extends string>(
|
||||
eventName: K,
|
||||
metadata: Record<string, unknown> | null,
|
||||
/** Bridge 遥测回调;eventName 为 chrome_bridge_* 事件名 */
|
||||
trackEvent?: (
|
||||
eventName: string, // 事件名
|
||||
metadata: ChromeBridgeTrackEventMetadata, // 事件元数据
|
||||
) => void
|
||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import type { ScreenshotResult } from './executor.js'
|
||||
import type { Logger } from './types.js'
|
||||
import { type Logger, toLoggerDetail } from './types.js'
|
||||
|
||||
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
|
||||
export type CropRawPatchFn = (
|
||||
@@ -165,7 +165,10 @@ export async function validateClickTarget(
|
||||
} catch (err) {
|
||||
// Skip validation on technical errors, execute action anyway.
|
||||
// Battle-tested: validation failure must never block the click.
|
||||
logger.debug('[pixelCompare] validation error, skipping', err)
|
||||
logger.debug(
|
||||
'[pixelCompare] validation error, skipping',
|
||||
toLoggerDetail(err),
|
||||
)
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import type {
|
||||
ResolvedAppRequest,
|
||||
TeachStepRequest,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
/**
|
||||
* Finder is never hidden by the hide loop (hiding Finder kills the Desktop),
|
||||
@@ -4446,7 +4447,10 @@ export async function handleToolCall(
|
||||
// For ungated tools, the executor may have been mid-call; that's fine —
|
||||
// the result is still a tool error, never an implicit success.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`[${serverName}] tool=${name} threw: ${msg}`, err)
|
||||
logger.error(
|
||||
`[${serverName}] tool=${name} threw: ${msg}`,
|
||||
toLoggerDetail(err),
|
||||
)
|
||||
return errorResult(`Tool "${name}" failed: ${msg}`, 'executor_threw')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@ import type {
|
||||
* cross-respawn `scaleCoord` survival. */
|
||||
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'>
|
||||
|
||||
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
|
||||
/**
|
||||
* Logger 第二参数的可选类型(与 claude-for-chrome-mcp 对齐)。
|
||||
* 实践中多为 catch 到的 Error。
|
||||
*/
|
||||
export type LoggerDetail = Error | NodeJS.ErrnoException
|
||||
|
||||
/** 将 unknown 收窄为 LoggerDetail,供 catch 块传给 logger 使用。 */
|
||||
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
|
||||
return detail instanceof Error ? detail : undefined
|
||||
}
|
||||
|
||||
/** 宿主注入的日志接口(与 claude-for-chrome-mcp/src/types.ts 对齐)。 */
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
info: (message: string, detail?: LoggerDetail) => void // 信息
|
||||
error: (message: string, detail?: LoggerDetail) => void // 错误
|
||||
warn: (message: string, detail?: LoggerDetail) => void // 警告
|
||||
debug: (message: string, detail?: LoggerDetail) => void // 调试
|
||||
silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Cursor = any
|
||||
/** 渲染帧中虚拟终端光标的状态(列/行坐标与是否绘制),供 diff 与光标 preamble 使用。 */
|
||||
export type Cursor = {
|
||||
x: number // 光标所在列,从 0 开始计
|
||||
y: number // 光标所在行,从 0 开始计
|
||||
visible: boolean // 本帧是否应在终端绘制光标(隐藏时不发射光标移动序列)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||
import type { FocusManager } from './focus.js'
|
||||
import { createLayoutNode } from './layout/engine.js'
|
||||
import type { LayoutNode } from './layout/node.js'
|
||||
@@ -45,10 +46,9 @@ export type DOMElement = {
|
||||
dirty: boolean
|
||||
// Set by the reconciler's hideInstance/unhideInstance; survives style updates.
|
||||
isHidden?: boolean
|
||||
// Event handlers set by the reconciler for the capture/bubble dispatcher.
|
||||
// Stored separately from attributes so handler identity changes don't
|
||||
// mark dirty and defeat the blit optimization.
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
// 协调器写入的事件处理器(捕获/冒泡分发用)。
|
||||
// 与 attributes 分离,避免 handler 引用变化触发 dirty 破坏 blit 优化。
|
||||
_eventHandlers?: Partial<EventHandlerProps> // 见 event-handlers.ts EventHandlerProps
|
||||
|
||||
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
|
||||
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type PasteEvent = any
|
||||
/** Box 等组件上 `onPaste` / `onPasteCapture` 收到的粘贴事件形状(与括号粘贴解析结果对齐的占位约定)。 */
|
||||
export type PasteEvent = {
|
||||
pastedText: string // 终端括号粘贴模式下解析出的 UTF-8 文本;允许为空字符串以表示空粘贴
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ResizeEvent = any
|
||||
/** 终端尺寸变化时 `onResize` 回调收到的事件载荷(与 `stdout.columns` / `stdout.rows` 一致)。 */
|
||||
export type ResizeEvent = {
|
||||
columns: number // 当前终端列数(宽度)
|
||||
rows: number // 当前终端行数(高度)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,10 @@ export class TerminalEvent extends Event {
|
||||
_prepareForTarget(_target: EventTarget): void {}
|
||||
}
|
||||
|
||||
import type { EventHandlerProps } from './event-handlers.js'
|
||||
|
||||
/** 终端事件系统的目标节点(DOM 树节点或根节点)。 */
|
||||
export type EventTarget = {
|
||||
parentNode: EventTarget | undefined
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
parentNode: EventTarget | undefined // 父节点,根节点为 undefined
|
||||
_eventHandlers?: Partial<EventHandlerProps> // 事件处理器,与 dom.ts DOMElement 同构
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
type TextNode,
|
||||
} from './dom.js'
|
||||
import { Dispatcher } from './events/dispatcher.js'
|
||||
import { EVENT_HANDLER_PROPS } from './events/event-handlers.js'
|
||||
import {
|
||||
EVENT_HANDLER_PROPS,
|
||||
type EventHandlerProps,
|
||||
} from './events/event-handlers.js'
|
||||
import { getFocusManager, getRootNode } from './focus.js'
|
||||
import { LayoutDisplay } from './layout/node.js'
|
||||
import applyStyles, { type Styles, type TextStyles } from './styles.js'
|
||||
@@ -111,7 +114,11 @@ type HostContext = {
|
||||
isInsideText: boolean
|
||||
}
|
||||
|
||||
function setEventHandler(node: DOMElement, key: string, value: unknown): void {
|
||||
function setEventHandler<K extends keyof EventHandlerProps>(
|
||||
node: DOMElement,
|
||||
key: K,
|
||||
value: EventHandlerProps[K],
|
||||
): void {
|
||||
if (!node._eventHandlers) {
|
||||
node._eventHandlers = {}
|
||||
}
|
||||
@@ -135,7 +142,11 @@ function applyProp(node: DOMElement, key: string, value: unknown): void {
|
||||
}
|
||||
|
||||
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||
setEventHandler(node, key, value)
|
||||
setEventHandler(
|
||||
node,
|
||||
key as keyof EventHandlerProps,
|
||||
value as EventHandlerProps[keyof EventHandlerProps],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -441,7 +452,11 @@ const reconciler = createReconciler<
|
||||
}
|
||||
|
||||
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||
setEventHandler(node, key, value)
|
||||
setEventHandler(
|
||||
node,
|
||||
key as keyof EventHandlerProps,
|
||||
value as EventHandlerProps[keyof EventHandlerProps],
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -551,7 +551,8 @@ describe('prompt caching support', () => {
|
||||
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(800)
|
||||
expect(msgStart.message.usage.input_tokens).toBe(1000)
|
||||
// input_tokens = prompt_tokens - cached_tokens = 1000 - 800 = 200
|
||||
expect(msgStart.message.usage.input_tokens).toBe(200)
|
||||
})
|
||||
|
||||
test('defaults cache_read_input_tokens to 0 when no cached_tokens', async () => {
|
||||
@@ -750,7 +751,8 @@ describe('prompt caching support', () => {
|
||||
|
||||
// message_delta carries the real values from the trailing chunk
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.input_tokens).toBe(30011)
|
||||
// input_tokens = prompt_tokens - cached_tokens = 30011 - 19904 = 10107
|
||||
expect(msgDelta.usage.input_tokens).toBe(10107)
|
||||
expect(msgDelta.usage.output_tokens).toBe(190)
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(19904)
|
||||
expect(msgDelta.usage.cache_creation_input_tokens).toBe(0)
|
||||
@@ -821,7 +823,34 @@ describe('prompt caching support', () => {
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(1500)
|
||||
expect(msgDelta.usage.input_tokens).toBe(2000)
|
||||
// input_tokens = prompt_tokens - cached_tokens = 2000 - 1500 = 500
|
||||
expect(msgDelta.usage.input_tokens).toBe(500)
|
||||
expect(msgDelta.usage.output_tokens).toBe(100)
|
||||
})
|
||||
|
||||
test('subtracts cached_tokens from input_tokens to match Anthropic semantic', async () => {
|
||||
// Anthropic's input_tokens = non-cached tokens only.
|
||||
// OpenAI's prompt_tokens = total input including cached.
|
||||
// The adapter must subtract: input_tokens = prompt_tokens - cached_tokens.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 34097,
|
||||
completion_tokens: 30,
|
||||
total_tokens: 34127,
|
||||
prompt_tokens_details: { cached_tokens: 34048 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
// input_tokens = 34097 - 34048 = 49 (non-cached input only)
|
||||
expect(msgDelta.usage.input_tokens).toBe(49)
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(34048)
|
||||
expect(msgDelta.usage.output_tokens).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,10 +13,10 @@ import { randomUUID } from 'crypto'
|
||||
* finish_reason → message_delta(stop_reason) + message_stop
|
||||
*
|
||||
* Usage field mapping (OpenAI → Anthropic):
|
||||
* prompt_tokens → input_tokens
|
||||
* completion_tokens → output_tokens
|
||||
* prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
||||
* (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
||||
* prompt_tokens - cached_tokens → input_tokens (non-cached input only)
|
||||
* completion_tokens → output_tokens
|
||||
* prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
||||
* (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
||||
*
|
||||
* All four fields are emitted in the post-loop message_delta (not message_start)
|
||||
* so that trailing usage chunks (sent after finish_reason by some
|
||||
@@ -54,6 +54,9 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
let textBlockOpen = false
|
||||
|
||||
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
||||
// rawInputTokens tracks the raw prompt_tokens (OpenAI total, including cached).
|
||||
// inputTokens is the derived Anthropic value (non-cached only = rawInputTokens - cachedReadTokens).
|
||||
let rawInputTokens = 0
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedReadTokens = 0
|
||||
@@ -71,12 +74,17 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
|
||||
// Extract usage from any chunk that carries it.
|
||||
if (chunk.usage) {
|
||||
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
||||
rawInputTokens = chunk.usage.prompt_tokens ?? rawInputTokens
|
||||
const rawCached =
|
||||
((chunk.usage as any).prompt_tokens_details?.cached_tokens as
|
||||
| number
|
||||
| undefined) ?? cachedReadTokens
|
||||
// Anthropic's input_tokens = non-cached input only. OpenAI's prompt_tokens
|
||||
// includes cached tokens, so subtract. Clamp to 0 in case cached > total
|
||||
// due to a streaming race.
|
||||
inputTokens = Math.max(0, rawInputTokens - rawCached)
|
||||
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
||||
const details = (chunk.usage as any).prompt_tokens_details
|
||||
if (details?.cached_tokens != null) {
|
||||
cachedReadTokens = details.cached_tokens
|
||||
}
|
||||
cachedReadTokens = rawCached
|
||||
}
|
||||
|
||||
// Emit message_start on first chunk
|
||||
|
||||
@@ -12,7 +12,6 @@ export { AskUserQuestionTool } from './tools/AskUserQuestionTool/AskUserQuestion
|
||||
export { BashTool } from './tools/BashTool/BashTool.js'
|
||||
export { BriefTool } from './tools/BriefTool/BriefTool.js'
|
||||
export { ConfigTool } from './tools/ConfigTool/ConfigTool.js'
|
||||
export { GoalTool } from './tools/GoalTool/GoalTool.js'
|
||||
export { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
|
||||
export { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
|
||||
export { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BASH_TOOL_NAME = any
|
||||
/** Bash 工具在 API 与 Agent 提示串中的注册名称字面量(与 `@claude-code-best/builtin-tools` 中 `BASH_TOOL_NAME` 常量一致)。 */
|
||||
export type BASH_TOOL_NAME = 'Bash'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = any
|
||||
/** ExitPlanMode 工具在 API 中的注册名称字面量(与内置 ExitPlanMode 工具 `name` 一致)。 */
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_EDIT_TOOL_NAME = any
|
||||
/** Edit(文件编辑)工具在 API 中的注册名称字面量(与 `FILE_EDIT_TOOL_NAME` 常量 `'Edit'` 一致)。 */
|
||||
export type FILE_EDIT_TOOL_NAME = 'Edit'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_READ_TOOL_NAME = any
|
||||
/** Read(文件读取)工具在 API 中的注册名称字面量(与 `FILE_READ_TOOL_NAME` 常量 `'Read'` 一致)。 */
|
||||
export type FILE_READ_TOOL_NAME = 'Read'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_WRITE_TOOL_NAME = any
|
||||
/** Write(文件写入)工具在 API 中的注册名称字面量(与 `FILE_WRITE_TOOL_NAME` 常量 `'Write'` 一致)。 */
|
||||
export type FILE_WRITE_TOOL_NAME = 'Write'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GLOB_TOOL_NAME = any
|
||||
/** Glob(文件名模式匹配)工具在 API 中的注册名称字面量(与 `GLOB_TOOL_NAME` 常量 `'Glob'` 一致)。 */
|
||||
export type GLOB_TOOL_NAME = 'Glob'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GREP_TOOL_NAME = any
|
||||
/** Grep(内容搜索)工具在 API 中的注册名称字面量(与 `GREP_TOOL_NAME` 常量 `'Grep'` 一致)。 */
|
||||
export type GREP_TOOL_NAME = 'Grep'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = any
|
||||
/** NotebookEdit(笔记本单元格编辑)工具在 API 中的注册名称字面量(与 `NOTEBOOK_EDIT_TOOL_NAME` 常量一致)。 */
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = 'NotebookEdit'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SEND_MESSAGE_TOOL_NAME = any
|
||||
/** SendMessage(向用户/通道发消息)工具在 API 中的注册名称字面量(与 `SEND_MESSAGE_TOOL_NAME` 常量一致)。 */
|
||||
export type SEND_MESSAGE_TOOL_NAME = 'SendMessage'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_FETCH_TOOL_NAME = any
|
||||
/** WebFetch(拉取并处理 URL 内容)工具在 API 中的注册名称字面量(与 `WEB_FETCH_TOOL_NAME` 常量一致)。 */
|
||||
export type WEB_FETCH_TOOL_NAME = 'WebFetch'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_SEARCH_TOOL_NAME = any
|
||||
/** WebSearch(联网搜索)工具在 API 中的注册名称字面量(与 `WEB_SEARCH_TOOL_NAME` 常量一致)。 */
|
||||
export type WEB_SEARCH_TOOL_NAME = 'WebSearch'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isUsing3PServices = any
|
||||
/** 是否正在使用第三方(非 Anthropic 直连)API 或服务;与仓库根 `src/utils/auth.ts` 中 `isUsing3PServices` 签名一致。 */
|
||||
export type isUsing3PServices = () => boolean // 返回 true 表示当前配置走兼容层或第三方模型端点
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type hasEmbeddedSearchTools = any
|
||||
/** 当前构建是否将 Glob/Grep 嵌入其它工具而不单独注册;与仓库根 `src/utils/embeddedTools.ts` 中 `hasEmbeddedSearchTools` 一致。 */
|
||||
export type hasEmbeddedSearchTools = () => boolean // 返回 true 时工具列表不包含独立的 Glob/Grep 工具名
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getSettings_DEPRECATED = any
|
||||
import type { SettingsJson } from 'src/utils/settings/types.js'
|
||||
|
||||
/** 返回各设置来源合并后的快照(已废弃函数名,行为同 `getInitialSettings`);与 `src/utils/settings/settings.ts` 一致。 */
|
||||
export type getSettings_DEPRECATED = () => SettingsJson // 无参数;至少得到可空字段填充后的合并设置对象
|
||||
|
||||
@@ -12,9 +12,7 @@ import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
export function areExplorePlanAgentsEnabled(): boolean {
|
||||
if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) {
|
||||
// 3P default: true — Bedrock/Vertex keep agents enabled (matches pre-experiment
|
||||
// external behavior). A/B test treatment sets false to measure impact of removal.
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type buildTool = any
|
||||
export type ToolDef = any
|
||||
export type toolMatchesName = any
|
||||
/** 根据工具定义装配宿主侧可调用 `Tool` 实例的工厂函数类型。 */
|
||||
export type buildTool = typeof import('src/Tool.js').buildTool
|
||||
|
||||
/** 工具定义泛型(输入 Schema、权限、进度等);与宿主 `ToolDef` 一致。 */
|
||||
export type ToolDef = import('src/Tool.js').ToolDef
|
||||
|
||||
/** 判断工具主名称或别名是否与查询名称相等;与宿主 `toolMatchesName` 一致。 */
|
||||
export type toolMatchesName = typeof import('src/Tool.js').toolMatchesName
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ConfigurableShortcutHint = any
|
||||
/** 可配置快捷键提示组件(从 keybindings 解析展示文案);与宿主 `ConfigurableShortcutHint` 组件类型一致。 */
|
||||
export type ConfigurableShortcutHint =
|
||||
typeof import('src/components/ConfigurableShortcutHint.js').ConfigurableShortcutHint
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CtrlOToExpand = any
|
||||
export type SubAgentProvider = any
|
||||
/** 「Ctrl+O 展开」提示组件;与宿主 `src/components/CtrlOToExpand.tsx` 中 `CtrlOToExpand` 一致。 */
|
||||
export type CtrlOToExpand =
|
||||
typeof import('src/components/CtrlOToExpand.js').CtrlOToExpand
|
||||
|
||||
/** 标记子 Agent 输出上下文,用于抑制重复的展开提示;与宿主 `SubAgentProvider` 一致。 */
|
||||
export type SubAgentProvider =
|
||||
typeof import('src/components/CtrlOToExpand.js').SubAgentProvider
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Byline = any
|
||||
/** Ink 底部快捷键说明行容器组件;与 `@anthropic/ink` 导出的 `Byline` 一致。 */
|
||||
export type Byline = typeof import('@anthropic/ink').Byline
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type KeyboardShortcutHint = any
|
||||
/** Ink 快捷键「按键 + 动作」展示组件;与 `@anthropic/ink` 导出的 `KeyboardShortcutHint` 一致。 */
|
||||
export type KeyboardShortcutHint =
|
||||
typeof import('@anthropic/ink').KeyboardShortcutHint
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Message = any
|
||||
export type NormalizedUserMessage = any
|
||||
/** 对话消息联合类型(含用户/助手/系统等);与宿主 `src/types/message.js` 重导出一致。 */
|
||||
export type Message = import('src/types/message.js').Message
|
||||
|
||||
/** 归一化后的用户消息形状;与宿主 `src/types/message.js` 中 `NormalizedUserMessage` 一致。 */
|
||||
export type NormalizedUserMessage =
|
||||
import('src/types/message.js').NormalizedUserMessage
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logForDebugging = any
|
||||
/** 写入调试日志文件(受日志级别与过滤规则约束);与宿主 `src/utils/debug.js` 中 `logForDebugging` 一致。 */
|
||||
export type logForDebugging =
|
||||
typeof import('src/utils/debug.js').logForDebugging
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getQuerySourceForAgent = any
|
||||
/** 按内置/自定义 Agent 类型解析用于遥测或分类的 `QuerySource`;与宿主 `getQuerySourceForAgent` 一致。 */
|
||||
export type getQuerySourceForAgent =
|
||||
typeof import('src/utils/promptCategory.js').getQuerySourceForAgent
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SettingSource = any
|
||||
/** 设置文件来源层级标识(用户/项目/本地等);与宿主 `src/utils/settings/constants.js` 中 `SettingSource` 一致。 */
|
||||
export type SettingSource =
|
||||
import('src/utils/settings/constants.js').SettingSource
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getAllowedChannels = any
|
||||
export type getQuestionPreviewFormat = any
|
||||
/** 返回当前允许展示的通道列表(含名称、连接状态等);与宿主 `src/bootstrap/state.js` 中 `getAllowedChannels` 一致。 */
|
||||
export type getAllowedChannels =
|
||||
typeof import('src/bootstrap/state.js').getAllowedChannels
|
||||
|
||||
/** 返回问题预览渲染格式(Markdown/HTML)或未配置;与宿主 `getQuestionPreviewFormat` 一致。 */
|
||||
export type getQuestionPreviewFormat =
|
||||
typeof import('src/bootstrap/state.js').getQuestionPreviewFormat
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type MessageResponse = any
|
||||
/** 工具结果在消息流中的外层布局组件;与宿主 `src/components/MessageResponse.js` 中 `MessageResponse` 一致。 */
|
||||
export type MessageResponse =
|
||||
typeof import('src/components/MessageResponse.js').MessageResponse
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BLACK_CIRCLE = any
|
||||
/** 列表/状态行中使用的实心圆点字符(平台相关);与宿主 `src/constants/figures.js` 中 `BLACK_CIRCLE` 常量类型一致。 */
|
||||
export type BLACK_CIRCLE =
|
||||
typeof import('src/constants/figures.js').BLACK_CIRCLE
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getModeColor = any
|
||||
/** 将权限模式映射为 Ink 主题颜色键,用于状态行等 UI;与宿主 `getModeColor` 一致。 */
|
||||
export type getModeColor =
|
||||
typeof import('src/utils/permissions/PermissionMode.js').getModeColor
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ToolPermissionContext = any
|
||||
/** 工具权限检查用的不可变上下文快照;与宿主 `src/Tool.js` 中 `ToolPermissionContext` 一致。 */
|
||||
export type ToolPermissionContext = import('src/Tool.js').ToolPermissionContext
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getOriginalCwd = any
|
||||
/** 返回进程启动时的原始工作目录(不受中途切换工作区影响);与宿主 `getOriginalCwd` 一致。 */
|
||||
export type getOriginalCwd =
|
||||
typeof import('src/bootstrap/state.js').getOriginalCwd
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CanUseToolFn = any
|
||||
/** 工具调用权限判定回调(交互/自动模式分支);与宿主 `src/hooks/useCanUseTool.tsx` 中 `CanUseToolFn` 一致。 */
|
||||
export type CanUseToolFn = import('src/hooks/useCanUseTool.js').CanUseToolFn
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any
|
||||
/** 从磁盘缓存读取 GrowthBook/门控配置(可能略旧);与宿主 `getFeatureValue_CACHED_MAY_BE_STALE` 一致。 */
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE =
|
||||
typeof import('src/services/analytics/growthbook.js').getFeatureValue_CACHED_MAY_BE_STALE
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logEvent = any
|
||||
/** 同步记录分析事件(未附加 sink 时入队);与宿主 `src/services/analytics/index.js` 中 `logEvent` 一致。 */
|
||||
export type logEvent = typeof import('src/services/analytics/index.js').logEvent
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type AppState = any
|
||||
/** REPL 全局 UI 与权限等状态快照类型;与宿主 `src/state/AppStateStore.js` 中 `AppState` 一致。 */
|
||||
export type AppState = import('src/state/AppStateStore.js').AppState
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setCwd = any
|
||||
/** 将 Shell 会话当前目录设为解析后的物理路径;与宿主 `src/utils/Shell.js` 中 `setCwd` 一致。 */
|
||||
export type setCwd = typeof import('src/utils/Shell.js').setCwd
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getCwd = any
|
||||
/** 返回当前 Shell/会话逻辑工作目录字符串;与宿主 `src/utils/cwd.js` 中 `getCwd` 一致。 */
|
||||
export type getCwd = typeof import('src/utils/cwd.js').getCwd
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type pathInAllowedWorkingPath = any
|
||||
/** 判断路径是否落在当前工具允许的合并工作目录内;与宿主 `pathInAllowedWorkingPath` 一致。 */
|
||||
export type pathInAllowedWorkingPath =
|
||||
typeof import('src/utils/permissions/filesystem.js').pathInAllowedWorkingPath
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type removeSandboxViolationTags = any
|
||||
/** 从展示文本中剥离沙箱违规相关的标记标签,避免 UI 噪音;与宿主 `removeSandboxViolationTags` 一致。 */
|
||||
export type removeSandboxViolationTags =
|
||||
typeof import('src/utils/sandbox/sandbox-ui-utils.js').removeSandboxViolationTags
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* ExecuteTool.test.ts
|
||||
*
|
||||
* Thin subprocess wrapper that runs the actual tests in an isolated bun:test
|
||||
* process. This prevents mock.module() leaks from other test files
|
||||
* (e.g., agentToolUtils.test.ts mocking src/Tool.js) from affecting
|
||||
* ExecuteTool's tests.
|
||||
* 薄层子进程包装器,在独立的 bun:test 进程中运行实际测试。
|
||||
* 这样可以防止其他测试文件的 mock.module() 漏出(例如 agentToolUtils.test.ts
|
||||
* 对 src/Tool.js 的 mock)影响 ExecuteTool 的测试。
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { resolve, relative } from 'path'
|
||||
|
||||
|
||||
@@ -4,16 +4,34 @@ export const DESCRIPTION =
|
||||
'ExecuteExtraTool — a first-class core tool that is always loaded and available. Execute any deferred tool by name with parameters. Use it after discovering a tool via SearchExtraTools. This is NOT a remote or external tool — it runs locally with full permissions.'
|
||||
|
||||
export function getPrompt(): string {
|
||||
return `ExecuteExtraTool — a first-class core tool, always loaded, always available in your tool list. Runs locally with full permissions — NOT a remote or external tool. You do NOT need to search for it.
|
||||
return `ExecuteExtraTool — always loaded, always available. Runs locally with full permissions — NOT a remote or external tool.
|
||||
|
||||
This tool accepts a tool_name and params object, looks up the target tool in the global tool registry, and delegates execution to it. The target tool runs with the same permissions and capabilities as if it were called directly.
|
||||
## What it does
|
||||
Accepts a tool_name and params, looks up the target tool in the registry, and delegates execution to it. The target tool runs with the same permissions as if called directly.
|
||||
|
||||
When to use: After SearchExtraTools discovers a deferred tool name, call this tool with {"tool_name": "<name>", "params": {...}} to invoke it immediately.
|
||||
When NOT to use: For core tools already in your tool list (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill, etc.) — call those directly.
|
||||
## When to use
|
||||
ONLY for deferred tools discovered via SearchExtraTools. Core tools (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill) are always in your tool list — call them directly, NOT through ExecuteExtraTool.
|
||||
|
||||
Inputs:
|
||||
- tool_name: The exact name of the target tool (string)
|
||||
- params: The parameters to pass to the target tool (object)
|
||||
## How to call — two-step workflow
|
||||
|
||||
If the tool is not found, an error message will be returned suggesting to use SearchExtraTools to discover available tools.`
|
||||
Step 1: SearchExtraTools discovers the tool name and schema.
|
||||
Step 2: This tool executes it.
|
||||
|
||||
Example — user asks to schedule a cron job:
|
||||
SearchExtraTools({"query": "select:CronCreate"})
|
||||
→ Response: "Found deferred tool(s): CronCreate"
|
||||
ExecuteExtraTool({"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check deploy"}})
|
||||
→ Response: Cron job created
|
||||
|
||||
Example — MCP tool:
|
||||
SearchExtraTools({"query": "select:mcp__slack__send_message"})
|
||||
→ Response: "Found deferred tool(s): mcp__slack__send_message"
|
||||
ExecuteExtraTool({"tool_name": "mcp__slack__send_message", "params": {"channel": "C123", "text": "hello"}})
|
||||
|
||||
## Inputs
|
||||
- tool_name: Exact name of the target tool (string, e.g. "CronCreate", "mcp__slack__send_message")
|
||||
- params: Object with the target tool's parameters. Check the tool's schema from SearchExtraTools discover: response.
|
||||
|
||||
## Failure handling
|
||||
If this tool returns an error, do NOT retry or re-search. Tell the user what failed and suggest alternatives.`
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
areFileEditsInputsEquivalent,
|
||||
findActualString,
|
||||
getPatchForEdit,
|
||||
preserveQuoteStyle,
|
||||
} from './utils.js'
|
||||
|
||||
// V8/Bun string length limit is ~2^30 characters (~1 billion). For typical
|
||||
@@ -297,7 +296,7 @@ export const FileEditTool = buildTool({
|
||||
|
||||
const file = fileContent
|
||||
|
||||
// Use findActualString to handle quote normalization
|
||||
// Use findActualString to find exact match
|
||||
const actualOldString = findActualString(file, old_string)
|
||||
if (!actualOldString) {
|
||||
return {
|
||||
@@ -452,23 +451,16 @@ export const FileEditTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use findActualString to handle quote normalization
|
||||
// 3. Find the exact string in file content
|
||||
const actualOldString =
|
||||
findActualString(originalFileContents, old_string) || old_string
|
||||
|
||||
// Preserve curly quotes in new_string when the file uses them
|
||||
const actualNewString = preserveQuoteStyle(
|
||||
old_string,
|
||||
actualOldString,
|
||||
new_string,
|
||||
)
|
||||
|
||||
// 4. Generate patch
|
||||
const { patch, updatedFile } = getPatchForEdit({
|
||||
filePath: absoluteFilePath,
|
||||
fileContents: originalFileContents,
|
||||
oldString: actualOldString,
|
||||
newString: actualNewString,
|
||||
newString: new_string,
|
||||
replaceAll: replace_all,
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { readEditContext } from 'src/utils/readEditContext.js';
|
||||
import { firstLineOf } from 'src/utils/stringUtils.js';
|
||||
import type { ThemeName } from 'src/utils/theme.js';
|
||||
import type { FileEditOutput } from './types.js';
|
||||
import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js';
|
||||
import { findActualString, getPatchForEdit } from './utils.js';
|
||||
|
||||
export function userFacingName(
|
||||
input:
|
||||
@@ -265,12 +265,11 @@ async function loadRejectionDiff(
|
||||
return { patch, firstLine: null, fileContent: undefined };
|
||||
}
|
||||
const actualOld = findActualString(ctx.content, oldString) || oldString;
|
||||
const actualNew = preserveQuoteStyle(oldString, actualOld, newString);
|
||||
const { patch } = getPatchForEdit({
|
||||
filePath,
|
||||
fileContents: ctx.content,
|
||||
oldString: actualOld,
|
||||
newString: actualNew,
|
||||
newString: newString,
|
||||
replaceAll,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -4,45 +4,8 @@ import { logMock } from '../../../../../../tests/mocks/log'
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
const {
|
||||
normalizeQuotes,
|
||||
stripTrailingWhitespace,
|
||||
findActualString,
|
||||
preserveQuoteStyle,
|
||||
applyEditToFile,
|
||||
LEFT_SINGLE_CURLY_QUOTE,
|
||||
RIGHT_SINGLE_CURLY_QUOTE,
|
||||
LEFT_DOUBLE_CURLY_QUOTE,
|
||||
RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
} = await import('../utils')
|
||||
|
||||
// ─── normalizeQuotes ────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeQuotes', () => {
|
||||
test('converts left single curly to straight', () => {
|
||||
expect(normalizeQuotes(`${LEFT_SINGLE_CURLY_QUOTE}hello`)).toBe("'hello")
|
||||
})
|
||||
|
||||
test('converts right single curly to straight', () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_SINGLE_CURLY_QUOTE}`)).toBe("hello'")
|
||||
})
|
||||
|
||||
test('converts left double curly to straight', () => {
|
||||
expect(normalizeQuotes(`${LEFT_DOUBLE_CURLY_QUOTE}hello`)).toBe('"hello')
|
||||
})
|
||||
|
||||
test('converts right double curly to straight', () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_DOUBLE_CURLY_QUOTE}`)).toBe('hello"')
|
||||
})
|
||||
|
||||
test('leaves straight quotes unchanged', () => {
|
||||
expect(normalizeQuotes('\'hello\' "world"')).toBe('\'hello\' "world"')
|
||||
})
|
||||
|
||||
test('handles empty string', () => {
|
||||
expect(normalizeQuotes('')).toBe('')
|
||||
})
|
||||
})
|
||||
const { stripTrailingWhitespace, findActualString, applyEditToFile } =
|
||||
await import('../utils')
|
||||
|
||||
// ─── stripTrailingWhitespace ────────────────────────────────────────────
|
||||
|
||||
@@ -91,12 +54,6 @@ describe('findActualString', () => {
|
||||
expect(findActualString('hello world', 'hello')).toBe('hello')
|
||||
})
|
||||
|
||||
test('finds match with curly quotes normalized', () => {
|
||||
const fileContent = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`
|
||||
const result = findActualString(fileContent, '"hello"')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when not found', () => {
|
||||
expect(findActualString('hello world', 'xyz')).toBeNull()
|
||||
})
|
||||
@@ -107,124 +64,13 @@ describe('findActualString', () => {
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
// ── Tab/space normalization (Bug #2 reproduction) ──
|
||||
|
||||
test('finds match when search uses spaces but file uses tabs', () => {
|
||||
// File content uses Tab indentation
|
||||
const fileContent = '\tif (x) {\n\t\treturn 1;\n\t}'
|
||||
// User copies from Read output which renders tabs as spaces
|
||||
const searchWithSpaces = ' if (x) {\n return 1;\n }'
|
||||
const result = findActualString(fileContent, searchWithSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
test('finds match when search mixes tabs and spaces inconsistently', () => {
|
||||
const fileContent = '\tconst x = 1; // comment'
|
||||
const searchMixed = ' const x = 1; // comment'
|
||||
const result = findActualString(fileContent, searchMixed)
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
test('finds match for single-line tab-to-space mismatch', () => {
|
||||
const fileContent = '\t\torder_price = NormalizeDouble(ask, digits);'
|
||||
const searchSpaces = ' order_price = NormalizeDouble(ask, digits);'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
// ── CJK / UTF-8 characters (Bug #1 reproduction) ──
|
||||
// ── CJK / UTF-8 characters ──
|
||||
|
||||
test('finds match with CJK characters in content', () => {
|
||||
const fileContent = 'input int x = 620; // 止盈点数(点) — 32个pip=320点'
|
||||
const result = findActualString(fileContent, fileContent)
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
test('finds match with CJK characters when tab/space differs', () => {
|
||||
const fileContent = '\t// 向上突破 → Sell Limit (逆方向做空)'
|
||||
const searchSpaces = ' // 向上突破 → Sell Limit (逆方向做空)'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
// ── Multiline with tabs + CJK (combined Bug #1 + #2) ──
|
||||
|
||||
test('finds multiline match with tabs and CJK characters', () => {
|
||||
const fileContent =
|
||||
'\tif(effective_dir == BREAKOUT_UP)\n\t\t{\n\t\t\t// 向上突破\n\t\t}'
|
||||
const searchSpaces =
|
||||
' if(effective_dir == BREAKOUT_UP)\n {\n // 向上突破\n }'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
// ── Returned string must be a valid substring of fileContent ──
|
||||
|
||||
test('returned string from tab match is a real substring of fileContent', () => {
|
||||
const fileContent = 'prefix\n\t\tindented code\nsuffix'
|
||||
const searchSpaces = 'prefix\n indented code\nsuffix'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
|
||||
test('returned string from partial tab match is a real substring', () => {
|
||||
const fileContent = 'line1\n\tif (x) {\n\t\tdoStuff();\n\t}\nline5'
|
||||
const searchSpaces = ' if (x) {\n doStuff();\n }'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
|
||||
test('tab match with mixed indentation levels', () => {
|
||||
const fileContent =
|
||||
'class Foo {\n\t\tmethod1() {\n\t\t\treturn 42;\n\t\t}\n}'
|
||||
const searchSpaces =
|
||||
'class Foo {\n method1() {\n return 42;\n }\n}'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── preserveQuoteStyle ─────────────────────────────────────────────────
|
||||
|
||||
describe('preserveQuoteStyle', () => {
|
||||
test('returns newString unchanged when no normalization happened', () => {
|
||||
expect(preserveQuoteStyle('hello', 'hello', 'world')).toBe('world')
|
||||
})
|
||||
|
||||
test('converts straight double quotes to curly in replacement', () => {
|
||||
const oldString = '"hello"'
|
||||
const actualOldString = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`
|
||||
const newString = '"world"'
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE)
|
||||
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE)
|
||||
})
|
||||
|
||||
test('converts straight single quotes to curly in replacement', () => {
|
||||
const oldString = "'hello'"
|
||||
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}hello${RIGHT_SINGLE_CURLY_QUOTE}`
|
||||
const newString = "'world'"
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
expect(result).toContain(LEFT_SINGLE_CURLY_QUOTE)
|
||||
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
})
|
||||
|
||||
test('treats apostrophe in contraction as right curly quote', () => {
|
||||
const oldString = "'it's a test'"
|
||||
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}it${RIGHT_SINGLE_CURLY_QUOTE}s a test${RIGHT_SINGLE_CURLY_QUOTE}`
|
||||
const newString = "'don't worry'"
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
// The leading ' at position 0 should be LEFT_SINGLE_CURLY_QUOTE
|
||||
expect(result[0]).toBe(LEFT_SINGLE_CURLY_QUOTE)
|
||||
// The apostrophe in "don't" (between n and t) should be RIGHT_SINGLE_CURLY_QUOTE
|
||||
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── applyEditToFile ────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,27 +15,6 @@ import {
|
||||
} from 'src/utils/file.js'
|
||||
import type { EditInput, FileEdit } from './types.js'
|
||||
|
||||
// Claude can't output curly quotes, so we define them as constants here for Claude to use
|
||||
// in the code. We do this because we normalize curly quotes to straight quotes
|
||||
// when applying edits.
|
||||
export const LEFT_SINGLE_CURLY_QUOTE = '‘'
|
||||
export const RIGHT_SINGLE_CURLY_QUOTE = '’'
|
||||
export const LEFT_DOUBLE_CURLY_QUOTE = '“'
|
||||
export const RIGHT_DOUBLE_CURLY_QUOTE = '”'
|
||||
|
||||
/**
|
||||
* Normalizes quotes in a string by converting curly quotes to straight quotes
|
||||
* @param str The string to normalize
|
||||
* @returns The string with all curly quotes replaced by straight quotes
|
||||
*/
|
||||
export function normalizeQuotes(str: string): string {
|
||||
return str
|
||||
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
|
||||
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
|
||||
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
|
||||
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips trailing whitespace from each line in a string while preserving line endings
|
||||
* @param str The string to process
|
||||
@@ -64,261 +43,22 @@ export function stripTrailingWhitespace(str: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes whitespace for fuzzy matching by converting tabs to spaces
|
||||
* and collapsing leading whitespace on each line to a canonical form.
|
||||
* This handles the case where Read tool output renders tabs as spaces,
|
||||
* so users copy spaces from the output but the file actually has tabs.
|
||||
*/
|
||||
function normalizeWhitespace(str: string): string {
|
||||
return str.replace(/\t/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the actual string in the file content that matches the search string,
|
||||
* accounting for quote normalization and tab/space differences.
|
||||
*
|
||||
* Matching cascade:
|
||||
* 1. Exact match
|
||||
* 2. Quote normalization (curly → straight quotes)
|
||||
* 3. Tab/space normalization (tabs ↔ spaces in leading whitespace)
|
||||
* 4. Quote + tab/space normalization combined
|
||||
* Finds the exact string in the file content.
|
||||
*
|
||||
* @param fileContent The file content to search in
|
||||
* @param searchString The string to search for
|
||||
* @returns The actual string found in the file, or null if not found
|
||||
* @returns The search string if found, or null if not found
|
||||
*/
|
||||
export function findActualString(
|
||||
fileContent: string,
|
||||
searchString: string,
|
||||
): string | null {
|
||||
// First try exact match
|
||||
if (fileContent.includes(searchString)) {
|
||||
return searchString
|
||||
}
|
||||
|
||||
// Try with normalized quotes
|
||||
const normalizedSearch = normalizeQuotes(searchString)
|
||||
const normalizedFile = normalizeQuotes(fileContent)
|
||||
|
||||
const searchIndex = normalizedFile.indexOf(normalizedSearch)
|
||||
if (searchIndex !== -1) {
|
||||
// Find the actual string in the file that matches
|
||||
return fileContent.substring(searchIndex, searchIndex + searchString.length)
|
||||
}
|
||||
|
||||
// Try with tab/space normalization — handles the case where Read output
|
||||
// renders tabs as spaces and the user copies the rendered version
|
||||
const wsNormalizedFile = normalizeWhitespace(fileContent)
|
||||
const wsNormalizedSearch = normalizeWhitespace(searchString)
|
||||
|
||||
const wsSearchIndex = wsNormalizedFile.indexOf(wsNormalizedSearch)
|
||||
if (wsSearchIndex !== -1) {
|
||||
// Map the match position back to the original file content.
|
||||
// We need to find the corresponding range in the original string.
|
||||
return mapNormalizedMatchBackToFile(
|
||||
fileContent,
|
||||
wsNormalizedFile,
|
||||
wsSearchIndex,
|
||||
wsNormalizedSearch.length,
|
||||
)
|
||||
}
|
||||
|
||||
// Try combined: quote normalization + tab/space normalization
|
||||
const combinedFile = normalizeWhitespace(normalizedFile)
|
||||
const combinedSearch = normalizeWhitespace(normalizedSearch)
|
||||
|
||||
const combinedIndex = combinedFile.indexOf(combinedSearch)
|
||||
if (combinedIndex !== -1) {
|
||||
return mapNormalizedMatchBackToFile(
|
||||
fileContent,
|
||||
combinedFile,
|
||||
combinedIndex,
|
||||
combinedSearch.length,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a match found in a normalized version of fileContent, map the match
|
||||
* position back to the original fileContent and extract the corresponding
|
||||
* substring.
|
||||
*
|
||||
* Strategy: walk through both strings character by character, building a
|
||||
* mapping from normalized offset to original offset. When a tab is expanded
|
||||
* to 4 spaces in the normalized version, the normalized offset advances by 4
|
||||
* while the original offset advances by 1.
|
||||
*/
|
||||
function mapNormalizedMatchBackToFile(
|
||||
fileContent: string,
|
||||
normalizedFile: string,
|
||||
normalizedStart: number,
|
||||
normalizedLength: number,
|
||||
): string {
|
||||
// Build a sparse mapping from normalized position → original position.
|
||||
// We only need to map the range [normalizedStart, normalizedStart + normalizedLength].
|
||||
let normPos = 0
|
||||
let origPos = 0
|
||||
let origStart = -1
|
||||
let origEnd = -1
|
||||
|
||||
while (
|
||||
origPos < fileContent.length &&
|
||||
normPos <= normalizedStart + normalizedLength
|
||||
) {
|
||||
if (normPos === normalizedStart) {
|
||||
origStart = origPos
|
||||
}
|
||||
if (normPos === normalizedStart + normalizedLength) {
|
||||
origEnd = origPos
|
||||
break
|
||||
}
|
||||
|
||||
const origChar = fileContent[origPos]!
|
||||
if (origChar === '\t') {
|
||||
// Tab expands to 4 spaces in normalized version
|
||||
const nextNormPos = normPos + 4
|
||||
// If normalizedStart falls within this expanded tab, snap to origPos
|
||||
if (
|
||||
normPos < normalizedStart &&
|
||||
nextNormPos > normalizedStart &&
|
||||
origStart === -1
|
||||
) {
|
||||
origStart = origPos
|
||||
}
|
||||
if (
|
||||
normPos < normalizedStart + normalizedLength &&
|
||||
nextNormPos > normalizedStart + normalizedLength &&
|
||||
origEnd === -1
|
||||
) {
|
||||
origEnd = origPos + 1
|
||||
}
|
||||
normPos = nextNormPos
|
||||
origPos++
|
||||
} else {
|
||||
normPos++
|
||||
origPos++
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't map precisely, use character-count heuristic
|
||||
if (origStart === -1) origStart = 0
|
||||
if (origEnd === -1) {
|
||||
// Approximate: use the ratio of original to normalized length
|
||||
const ratio = fileContent.length / normalizedFile.length
|
||||
origEnd = Math.round(origStart + normalizedLength * ratio)
|
||||
}
|
||||
|
||||
return fileContent.substring(origStart, origEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* When old_string matched via quote normalization (curly quotes in file,
|
||||
* straight quotes from model), apply the same curly quote style to new_string
|
||||
* so the edit preserves the file's typography.
|
||||
*
|
||||
* Uses a simple open/close heuristic: a quote character preceded by whitespace,
|
||||
* start of string, or opening punctuation is treated as an opening quote;
|
||||
* otherwise it's a closing quote.
|
||||
*/
|
||||
export function preserveQuoteStyle(
|
||||
oldString: string,
|
||||
actualOldString: string,
|
||||
newString: string,
|
||||
): string {
|
||||
// If they're the same, no normalization happened
|
||||
if (oldString === actualOldString) {
|
||||
return newString
|
||||
}
|
||||
|
||||
// Detect which curly quote types were in the file
|
||||
const hasDoubleQuotes =
|
||||
actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
|
||||
actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE)
|
||||
const hasSingleQuotes =
|
||||
actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
|
||||
actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
|
||||
if (!hasDoubleQuotes && !hasSingleQuotes) {
|
||||
return newString
|
||||
}
|
||||
|
||||
let result = newString
|
||||
|
||||
if (hasDoubleQuotes) {
|
||||
result = applyCurlyDoubleQuotes(result)
|
||||
}
|
||||
if (hasSingleQuotes) {
|
||||
result = applyCurlySingleQuotes(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isOpeningContext(chars: string[], index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true
|
||||
}
|
||||
const prev = chars[index - 1]
|
||||
return (
|
||||
prev === ' ' ||
|
||||
prev === '\t' ||
|
||||
prev === '\n' ||
|
||||
prev === '\r' ||
|
||||
prev === '(' ||
|
||||
prev === '[' ||
|
||||
prev === '{' ||
|
||||
prev === '\u2014' || // em dash
|
||||
prev === '\u2013' // en dash
|
||||
)
|
||||
}
|
||||
|
||||
function applyCurlyDoubleQuotes(str: string): string {
|
||||
const chars = [...str]
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] === '"') {
|
||||
result.push(
|
||||
isOpeningContext(chars, i)
|
||||
? LEFT_DOUBLE_CURLY_QUOTE
|
||||
: RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
)
|
||||
} else {
|
||||
result.push(chars[i]!)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
function applyCurlySingleQuotes(str: string): string {
|
||||
const chars = [...str]
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] === "'") {
|
||||
// Don't convert apostrophes in contractions (e.g., "don't", "it's")
|
||||
// An apostrophe between two letters is a contraction, not a quote
|
||||
const prev = i > 0 ? chars[i - 1] : undefined
|
||||
const next = i < chars.length - 1 ? chars[i + 1] : undefined
|
||||
const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)
|
||||
const nextIsLetter = next !== undefined && /\p{L}/u.test(next)
|
||||
if (prevIsLetter && nextIsLetter) {
|
||||
// Apostrophe in a contraction — use right single curly quote
|
||||
result.push(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
} else {
|
||||
result.push(
|
||||
isOpeningContext(chars, i)
|
||||
? LEFT_SINGLE_CURLY_QUOTE
|
||||
: RIGHT_SINGLE_CURLY_QUOTE,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result.push(chars[i]!)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform edits to ensure replace_all always has a boolean value
|
||||
* @param edits Array of edits with optional replace_all
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { z } from 'zod/v4'
|
||||
import { buildTool, type ToolDef } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import {
|
||||
completeGoal,
|
||||
formatGoalStatus,
|
||||
getActiveElapsedMs,
|
||||
getGoal,
|
||||
setGoal,
|
||||
} from 'src/services/goal/goalState.js'
|
||||
import { DESCRIPTION, generatePrompt } from './prompt.js'
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
action: z
|
||||
.enum(['get', 'set', 'complete'])
|
||||
.describe('The action to perform on the goal.'),
|
||||
objective: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The goal objective. Required for "set" action.'),
|
||||
message: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Completion message for "complete" action.'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
action: z.string(),
|
||||
goal: z
|
||||
.object({
|
||||
objective: z.string(),
|
||||
status: z.string(),
|
||||
tokensUsed: z.number(),
|
||||
tokenBudget: z.number().nullable(),
|
||||
elapsedSeconds: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
message: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
export type Input = z.infer<InputSchema>
|
||||
export type Output = z.infer<OutputSchema>
|
||||
|
||||
export const GoalTool = buildTool({
|
||||
name: 'goal',
|
||||
searchHint: 'manage long-running task goals',
|
||||
maxResultSizeChars: 10_000,
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return generatePrompt()
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
userFacingName() {
|
||||
return 'Goal'
|
||||
},
|
||||
shouldDefer: true,
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
isReadOnly(input: Input) {
|
||||
return input.action === 'get'
|
||||
},
|
||||
toAutoClassifierInput(input) {
|
||||
if (input.action === 'get') return 'get goal status'
|
||||
if (input.action === 'set') return `set goal: ${input.objective}`
|
||||
return `complete goal: ${input.message ?? ''}`
|
||||
},
|
||||
async checkPermissions(input: Input) {
|
||||
if (input.action === 'get') {
|
||||
return { behavior: 'allow' as const, updatedInput: input }
|
||||
}
|
||||
return {
|
||||
behavior: 'ask' as const,
|
||||
message:
|
||||
input.action === 'set'
|
||||
? `Set goal: ${input.objective}`
|
||||
: `Complete goal${input.message ? `: ${input.message}` : ''}`,
|
||||
}
|
||||
},
|
||||
async call({ action, objective, message }: Input): Promise<{ data: Output }> {
|
||||
if (action === 'get') {
|
||||
const goal = getGoal()
|
||||
if (!goal) {
|
||||
return { data: { success: true, action, message: 'No active goal.' } }
|
||||
}
|
||||
const elapsedSeconds = Math.floor(getActiveElapsedMs(goal) / 1000)
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
action,
|
||||
goal: {
|
||||
objective: goal.objective,
|
||||
status: goal.status,
|
||||
tokensUsed: goal.tokensUsed,
|
||||
tokenBudget: goal.tokenBudget,
|
||||
elapsedSeconds,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'set') {
|
||||
if (!objective) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
action,
|
||||
error: 'objective is required for set action.',
|
||||
},
|
||||
}
|
||||
}
|
||||
setGoal(objective)
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
action,
|
||||
message: `Goal set: ${objective}`,
|
||||
goal: {
|
||||
objective,
|
||||
status: 'active',
|
||||
tokensUsed: 0,
|
||||
tokenBudget: null,
|
||||
elapsedSeconds: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'complete') {
|
||||
if (!completeGoal()) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
action,
|
||||
error: 'No active goal to complete.',
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
action,
|
||||
message: message
|
||||
? `Goal completed: ${message}`
|
||||
: 'Goal marked as complete.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: { success: false, action, error: `Unknown action: ${action}` },
|
||||
}
|
||||
},
|
||||
renderToolUseMessage(input: Partial<Input>) {
|
||||
if (input.action === 'get') return 'Getting goal status'
|
||||
if (input.action === 'set') return `Setting goal: ${input.objective ?? ''}`
|
||||
if (input.action === 'complete') return 'Completing goal'
|
||||
return 'Managing goal'
|
||||
},
|
||||
renderToolResultMessage(content: Output) {
|
||||
if (!content.success) return `Error: ${content.error}`
|
||||
if (content.action === 'get' && content.goal) {
|
||||
const g = content.goal
|
||||
return `Goal: ${g.objective} [${g.status}]`
|
||||
}
|
||||
return content.message ?? 'Done.'
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: Output,
|
||||
toolUseID: string,
|
||||
): ToolResultBlockParam {
|
||||
if (!content.success) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result' as const,
|
||||
content: `Error: ${content.error}`,
|
||||
is_error: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (content.action === 'get' && content.goal) {
|
||||
const g = content.goal
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result' as const,
|
||||
content: `Goal: ${g.objective}\nStatus: ${g.status}\nTokens: ${g.tokensUsed}${g.tokenBudget !== null ? ` / ${g.tokenBudget}` : ''}\nElapsed: ${g.elapsedSeconds}s`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result' as const,
|
||||
content: content.message ?? 'Done.',
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
@@ -1,18 +0,0 @@
|
||||
export const DESCRIPTION = 'Manage the active goal for long-running tasks.'
|
||||
|
||||
export function generatePrompt(): string {
|
||||
return `Manage the active goal for long-running tasks.
|
||||
|
||||
Use this tool to get, set, or complete a goal. A goal is an objective that the system tracks across the session, injecting continuation prompts to keep working toward it.
|
||||
|
||||
## Actions
|
||||
- **get** — Get the current goal status
|
||||
- **set** — Set or update the goal objective
|
||||
- **complete** — Mark the goal as complete when the objective is achieved
|
||||
|
||||
## Examples
|
||||
- Get current goal: { "action": "get" }
|
||||
- Set a goal: { "action": "set", "objective": "Improve test coverage to 80%" }
|
||||
- Complete a goal: { "action": "complete", "message": "All tests now pass with 82% coverage." }
|
||||
`
|
||||
}
|
||||
@@ -383,8 +383,8 @@ export const NotebookEditTool = buildTool({
|
||||
const language = notebook.metadata.language_info?.name ?? 'python'
|
||||
let new_cell_id
|
||||
if (
|
||||
notebook.nbformat > 4 ||
|
||||
(notebook.nbformat === 4 && notebook.nbformat_minor >= 5)
|
||||
(notebook.nbformat ?? 4) > 4 ||
|
||||
((notebook.nbformat ?? 4) === 4 && (notebook.nbformat_minor ?? 0) >= 5)
|
||||
) {
|
||||
if (edit_mode === 'insert') {
|
||||
new_cell_id = Math.random().toString(36).substring(2, 15)
|
||||
|
||||
@@ -25,13 +25,39 @@ function getToolLocationHint(): string {
|
||||
|
||||
const PROMPT_TAIL = ` Returns matching tool names.
|
||||
|
||||
IMPORTANT: ExecuteExtraTool is always available in your tool list. After this search returns tool names, you MUST call ExecuteExtraTool with {"tool_name": "<returned_name>", "params": {...}} to invoke the deferred tool. This is the ONLY way to execute deferred tools — do not read source code or analyze whether the tool is callable, just use ExecuteExtraTool directly.
|
||||
## Two-step workflow (MUST follow exactly)
|
||||
|
||||
Query forms:
|
||||
- "select:CronCreate,Snip" — fetch these exact tools by name
|
||||
- "discover:schedule cron job" — pure discovery, returns tool info (name, description) without loading. Use when you want to understand available tools before deciding which to invoke.
|
||||
Deferred tools CANNOT be called directly. You MUST use this two-step pattern:
|
||||
|
||||
Step 1 — Search: Call this tool (SearchExtraTools) to discover the target tool.
|
||||
Input: {"query": "select:CronCreate"}
|
||||
Response: "Found 1 deferred tool(s): CronCreate. Use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}} to invoke."
|
||||
|
||||
Step 2 — Execute: Call ExecuteExtraTool to run the discovered tool.
|
||||
Input: {"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check the deploy"}}
|
||||
Response: the actual tool result.
|
||||
|
||||
## Example: user asks "schedule a cron to check deploy every 5 minutes"
|
||||
|
||||
1. SearchExtraTools({"query": "select:CronCreate"})
|
||||
→ Response: Found deferred tool CronCreate
|
||||
2. ExecuteExtraTool({"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check the deploy"}})
|
||||
→ Response: Cron job created successfully
|
||||
|
||||
If you don't know the exact tool name, use keyword search first:
|
||||
1. SearchExtraTools({"query": "cron schedule"})
|
||||
→ Response: Found deferred tool(s): CronCreate
|
||||
2. ExecuteExtraTool({"tool_name": "CronCreate", "params": {...}})
|
||||
|
||||
## Query forms
|
||||
- "select:CronCreate" — exact tool name (fastest, preferred when you know the name from <available-deferred-tools>)
|
||||
- "select:CronCreate,CronList" — comma-separated multi-select
|
||||
- "discover:schedule cron job" — returns tool name + description + schema without loading. Use to understand a tool before calling it.
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms
|
||||
|
||||
## Failure policy
|
||||
If ExecuteExtraTool fails, do NOT re-search for the same tool — it will loop. Stop and tell the user what failed.`
|
||||
|
||||
/**
|
||||
* Check if a tool should be deferred (requires SearchExtraTools to load).
|
||||
|
||||
@@ -9,28 +9,52 @@
|
||||
import { readdir, readFile, writeFile, cp } from 'node:fs/promises'
|
||||
import { chmodSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { execSync } from 'node:child_process'
|
||||
|
||||
const outdir = 'dist'
|
||||
|
||||
async function postBuild() {
|
||||
// Step 1: Patch globalThis.Bun destructuring in the single bundled file
|
||||
const cliPath = join(outdir, 'cli.js')
|
||||
// Step 1: Patch globalThis.Bun destructuring in ALL output files
|
||||
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
||||
const BUN_DESTRUCTURE_SAFE =
|
||||
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
|
||||
let bunPatched = 0
|
||||
{
|
||||
const content = await readFile(cliPath, 'utf-8')
|
||||
const files = await readdir(outdir)
|
||||
const jsFiles = files.filter(f => f.endsWith('.js'))
|
||||
|
||||
for (const file of jsFiles) {
|
||||
const filePath = join(outdir, file)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
BUN_DESTRUCTURE.lastIndex = 0
|
||||
if (BUN_DESTRUCTURE.test(content)) {
|
||||
await writeFile(
|
||||
cliPath,
|
||||
filePath,
|
||||
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||
)
|
||||
bunPatched++
|
||||
}
|
||||
}
|
||||
|
||||
// Also patch chunk files in dist/chunks/
|
||||
const chunksDir = join(outdir, 'chunks')
|
||||
let chunkFiles: string[] = []
|
||||
try {
|
||||
chunkFiles = (await readdir(chunksDir)).filter(f => f.endsWith('.js'))
|
||||
} catch {
|
||||
// No chunks directory — single-file build fallback
|
||||
}
|
||||
|
||||
for (const file of chunkFiles) {
|
||||
const filePath = join(chunksDir, file)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
BUN_DESTRUCTURE.lastIndex = 0
|
||||
if (BUN_DESTRUCTURE.test(content)) {
|
||||
await writeFile(
|
||||
filePath,
|
||||
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||
)
|
||||
bunPatched++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Copy native addon files
|
||||
@@ -55,7 +79,7 @@ async function postBuild() {
|
||||
chmodSync(cliNode, 0o755)
|
||||
|
||||
console.log(
|
||||
`Post-build complete: patched ${bunPatched} Bun destructure, generated entry points`,
|
||||
`Post-build complete: patched ${bunPatched} Bun destructure across ${jsFiles.length + chunkFiles.length} files, generated entry points`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4966,7 +4966,7 @@ function handleChannelEnable(
|
||||
// channel messages queue at priority 'next' and are seen by the model on
|
||||
// the turn after they arrive.
|
||||
connection.client.setNotificationHandler(
|
||||
ChannelMessageNotificationSchema(),
|
||||
ChannelMessageNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { content, meta } = notification.params
|
||||
logMCPDebug(
|
||||
@@ -5042,7 +5042,7 @@ function reregisterChannelHandlerAfterReconnect(
|
||||
'Channel notifications re-registered after reconnect',
|
||||
)
|
||||
connection.client.setNotificationHandler(
|
||||
ChannelMessageNotificationSchema(),
|
||||
ChannelMessageNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { content, meta } = notification.params
|
||||
logMCPDebug(
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Transport = any
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
|
||||
/** WebSocket / SSE+POST / Hybrid 等会话上行传输的共有接口。 */
|
||||
export type Transport = {
|
||||
setOnData(callback: (data: string) => void): void // 注册下行数据回调(按行文本)
|
||||
setOnClose(callback: (closeCode?: number) => void): void // 连接关闭时回调(可选关闭码)
|
||||
connect(): void | Promise<void> // 建立或重连传输
|
||||
write(message: StdoutMessage): void | Promise<void> // 向上游发送一条控制/流式消息
|
||||
close(): void // 主动关闭并释放资源
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import chalk from 'chalk'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { distRoot } from '../utils/distRoot.js'
|
||||
import { execFileNoThrowWithCwd } from '../utils/execFileNoThrow.js'
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||
import { writeToStdout } from '../utils/process.js'
|
||||
@@ -19,12 +19,9 @@ import { writeToStdout } from '../utils/process.js'
|
||||
const PACKAGE_NAME = 'claude-code-best'
|
||||
|
||||
function getCurrentVersion(): string {
|
||||
// Read version from the nearest package.json (walks up from this file)
|
||||
// Read version from the nearest package.json (walks up from dist root)
|
||||
try {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
// In dev: src/cli/updateCCB.ts → ../../package.json
|
||||
// In build: dist/chunks/xxx.js → ../../package.json (may not exist)
|
||||
const pkgPath = join(__dirname, '..', '..', 'package.json')
|
||||
const pkgPath = join(distRoot, '..', 'package.json')
|
||||
if (existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
if (pkg.version) return pkg.version
|
||||
|
||||
@@ -167,7 +167,6 @@ import thinkbackPlay from './commands/thinkback-play/index.js'
|
||||
import permissions from './commands/permissions/index.js'
|
||||
import plan from './commands/plan/index.js'
|
||||
import fast from './commands/fast/index.js'
|
||||
import goal from './commands/goal/index.js'
|
||||
import passes from './commands/passes/index.js'
|
||||
import privacySettings from './commands/privacy-settings/index.js'
|
||||
import hooks from './commands/hooks/index.js'
|
||||
@@ -317,7 +316,6 @@ const COMMANDS = memoize((): Command[] => [
|
||||
exit,
|
||||
fast,
|
||||
files,
|
||||
goal,
|
||||
heapDump,
|
||||
help,
|
||||
ide,
|
||||
|
||||
133
src/commands/autofix-pr/__tests__/extractAutofixResult.test.ts
Normal file
133
src/commands/autofix-pr/__tests__/extractAutofixResult.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { SDKMessage } from '../../../entrypoints/agentSdkTypes.js'
|
||||
import {
|
||||
AUTOFIX_RESULT_TAG,
|
||||
extractAutofixResultFromLog,
|
||||
} from '../extractAutofixResult.js'
|
||||
|
||||
function hookProgressMessage(stdout: string): SDKMessage {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'hook_progress',
|
||||
stdout,
|
||||
} as unknown as SDKMessage
|
||||
}
|
||||
|
||||
function assistantTextMessage(text: string): SDKMessage {
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text }],
|
||||
},
|
||||
} as unknown as SDKMessage
|
||||
}
|
||||
|
||||
const sampleTag = (summary: string): string =>
|
||||
`<${AUTOFIX_RESULT_TAG}>
|
||||
<pr-number>42</pr-number>
|
||||
<commits-pushed>
|
||||
<commit sha="abc123">${summary}</commit>
|
||||
</commits-pushed>
|
||||
<ci-status>green</ci-status>
|
||||
<summary>${summary}</summary>
|
||||
</${AUTOFIX_RESULT_TAG}>`
|
||||
|
||||
describe('extractAutofixResultFromLog', () => {
|
||||
test('returns null on empty log', () => {
|
||||
expect(extractAutofixResultFromLog([])).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when no tag present', () => {
|
||||
const log = [
|
||||
assistantTextMessage('just some normal text without the tag'),
|
||||
hookProgressMessage('hook output without tag'),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBeNull()
|
||||
})
|
||||
|
||||
test('extracts from hook stdout', () => {
|
||||
const tag = sampleTag('fixed lint error')
|
||||
const log = [hookProgressMessage(`prefix\n${tag}\nsuffix`)]
|
||||
const result = extractAutofixResultFromLog(log)
|
||||
expect(result).toBe(tag)
|
||||
})
|
||||
|
||||
test('extracts from assistant text', () => {
|
||||
const tag = sampleTag('typecheck fixed')
|
||||
const log = [assistantTextMessage(`Done!\n${tag}`)]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(tag)
|
||||
})
|
||||
|
||||
test('extracts from hook_response subtype too', () => {
|
||||
const tag = sampleTag('via hook_response')
|
||||
const log = [
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'hook_response',
|
||||
stdout: tag,
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(tag)
|
||||
})
|
||||
|
||||
test('returns the latest tag when multiple appear in different messages', () => {
|
||||
const older = sampleTag('older attempt')
|
||||
const newer = sampleTag('newer attempt')
|
||||
const log = [
|
||||
assistantTextMessage(`first try\n${older}`),
|
||||
assistantTextMessage(`retry\n${newer}`),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(newer)
|
||||
})
|
||||
|
||||
test('returns null when open tag exists but close tag is missing (truncated)', () => {
|
||||
const log = [
|
||||
assistantTextMessage(
|
||||
`<${AUTOFIX_RESULT_TAG}>\n<summary>got cut off mid-write...`,
|
||||
),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns earlier complete tag when latest open tag is truncated within the same block', () => {
|
||||
// Retry scenario: a full result was emitted, then a second result tag
|
||||
// started but got cut off. We should surface the earlier complete pair
|
||||
// rather than dropping the whole block.
|
||||
const complete = sampleTag('earlier complete result')
|
||||
const truncated = `<${AUTOFIX_RESULT_TAG}>\n<summary>truncated retry...`
|
||||
const log = [assistantTextMessage(`${complete}\n${truncated}`)]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(complete)
|
||||
})
|
||||
|
||||
test('walks backwards so hook stdout from later in log wins over earlier assistant text', () => {
|
||||
const earlier = sampleTag('via assistant first')
|
||||
const later = sampleTag('via hook later')
|
||||
const log = [
|
||||
assistantTextMessage(`some output\n${earlier}`),
|
||||
hookProgressMessage(later),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBe(later)
|
||||
})
|
||||
|
||||
test('ignores tag-shaped strings that span across messages (no concatenation)', () => {
|
||||
// Open tag in one message, close tag in another — should NOT be stitched.
|
||||
const log = [
|
||||
assistantTextMessage(`<${AUTOFIX_RESULT_TAG}>\n<summary>part 1`),
|
||||
assistantTextMessage(`part 2</summary>\n</${AUTOFIX_RESULT_TAG}>`),
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBeNull()
|
||||
})
|
||||
|
||||
test('extracts when assistant content is a string (not block array)', () => {
|
||||
// Some SDK paths emit assistant content as a raw string instead of
|
||||
// a content-block array. Current implementation skips those — verify
|
||||
// graceful no-op rather than crash.
|
||||
const log = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: { content: sampleTag('string content') },
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
expect(extractAutofixResultFromLog(log)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ mock.module('src/utils/teleport.js', () => ({
|
||||
}))
|
||||
|
||||
const registerMock = mock(() => ({
|
||||
taskId: 'task-abc',
|
||||
taskId: 'framework-task-id',
|
||||
sessionId: 'session-123',
|
||||
cleanup: () => {},
|
||||
}))
|
||||
@@ -56,14 +56,41 @@ const checkEligibilityMock = mock(() =>
|
||||
const getSessionUrlMock = mock(
|
||||
(id: string) => `https://claude.ai/session/${id}`,
|
||||
)
|
||||
const registerCompletionHookMock = mock<
|
||||
(taskType: string, hook: (taskId: string, metadata?: unknown) => void) => void
|
||||
>(() => {})
|
||||
const registerCompletionCheckerMock = mock<
|
||||
(
|
||||
taskType: string,
|
||||
checker: (metadata?: unknown) => Promise<string | null>,
|
||||
) => void
|
||||
>(() => {})
|
||||
const registerContentExtractorMock = mock<
|
||||
(taskType: string, extractor: (log: unknown[]) => string | null) => void
|
||||
>(() => {})
|
||||
|
||||
mock.module('src/tasks/RemoteAgentTask/RemoteAgentTask.js', () => ({
|
||||
checkRemoteAgentEligibility: checkEligibilityMock,
|
||||
registerRemoteAgentTask: registerMock,
|
||||
registerCompletionHook: registerCompletionHookMock,
|
||||
registerCompletionChecker: registerCompletionCheckerMock,
|
||||
registerContentExtractor: registerContentExtractorMock,
|
||||
getRemoteTaskSessionUrl: getSessionUrlMock,
|
||||
formatPreconditionError: (e: { type: string }) => e.type,
|
||||
}))
|
||||
|
||||
const fetchPrHeadShaMock = mock<
|
||||
(owner: string, repo: string, prNumber: number) => Promise<string | null>
|
||||
>(() => Promise.resolve('sha-baseline-abc123'))
|
||||
|
||||
// Mock prFetch.ts (gh CLI spawn layer) — keeping the pure decision matrix
|
||||
// in prOutcomeCheck.ts unmocked so its tests are unaffected by this file's
|
||||
// process-global mock.module pollution.
|
||||
mock.module('src/commands/autofix-pr/prFetch.js', () => ({
|
||||
fetchPrHeadSha: fetchPrHeadShaMock,
|
||||
checkPrAutofixOutcome: mock(() => Promise.resolve({ completed: false })),
|
||||
}))
|
||||
|
||||
const detectRepoMock = mock(() =>
|
||||
Promise.resolve({ host: 'github.com', owner: 'acme', name: 'myrepo' }),
|
||||
)
|
||||
@@ -375,6 +402,326 @@ describe('callAutofixPr', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Regression suite for the taskId-mismatch latent bug + completion hook wiring.
|
||||
// Before this fix, createAutofixTeammate generated a teammate UUID, that UUID
|
||||
// was used to acquire the singleton monitor lock, and registerRemoteAgentTask
|
||||
// generated a *different* framework taskId. When the framework eventually
|
||||
// called clearActiveMonitor(frameworkTaskId) on natural completion, the guard
|
||||
// failed (active.taskId !== frameworkTaskId) and the lock stayed acquired,
|
||||
// blocking any subsequent /autofix-pr invocations in the same process.
|
||||
describe('callAutofixPr · completion hook wiring (taskId mismatch regression)', () => {
|
||||
test('updateActiveMonitor swaps lock taskId to framework-assigned id after register', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const monitor = getActiveMonitor() as { taskId: string } | null
|
||||
expect(monitor).not.toBeNull()
|
||||
// registerMock returns 'framework-task-id'; before the fix this would be
|
||||
// a teammate-generated random UUID instead.
|
||||
expect(monitor?.taskId).toBe('framework-task-id')
|
||||
})
|
||||
|
||||
test('framework hook → clearActiveMonitor releases lock on natural completion', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(getActiveMonitor()).not.toBeNull()
|
||||
|
||||
// Find the hook the module registered at import time. We grab the last
|
||||
// call so re-imports across tests don't break this — only the most recent
|
||||
// registration is what the framework would invoke now.
|
||||
const calls = registerCompletionHookMock.mock.calls
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const lastCall = calls[calls.length - 1]
|
||||
expect(lastCall?.[0]).toBe('autofix-pr')
|
||||
const hook = lastCall?.[1] as (id: string, metadata?: unknown) => void
|
||||
expect(typeof hook).toBe('function')
|
||||
|
||||
// Simulate the framework invoking the hook with the framework taskId
|
||||
// after a terminal transition. Before the fix this would no-op against
|
||||
// a lock keyed by the teammate UUID.
|
||||
hook('framework-task-id', { owner: 'acme', repo: 'myrepo', prNumber: 42 })
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('subsequent /autofix-pr succeeds after framework hook clears the lock', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
// Simulate natural completion via the registered hook
|
||||
const calls = registerCompletionHookMock.mock.calls
|
||||
const hook = calls[calls.length - 1]?.[1] as (
|
||||
id: string,
|
||||
metadata?: unknown,
|
||||
) => void
|
||||
hook('framework-task-id', { owner: 'acme', repo: 'myrepo', prNumber: 42 })
|
||||
|
||||
onDone.mockClear()
|
||||
await callAutofixPr(onDone, makeContext(), '99')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
// Should be the success path, not "already monitoring"
|
||||
expect(firstArg).not.toMatch(/already monitoring/i)
|
||||
expect(firstArg).toMatch(/Autofix launched/)
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2: completionChecker wiring + initialHeadSha capture
|
||||
describe('callAutofixPr · Phase 2 completionChecker integration', () => {
|
||||
test('completionChecker is registered at module load with autofix-pr type', () => {
|
||||
// The registration happens during the beforeAll dynamic import; just
|
||||
// verify the mock recorded a call. Filter by task type so any future
|
||||
// additional registrations elsewhere don't break this assertion.
|
||||
const calls = registerCompletionCheckerMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const hook = calls[calls.length - 1]?.[1]
|
||||
expect(typeof hook).toBe('function')
|
||||
})
|
||||
|
||||
test('callAutofixPr captures initialHeadSha via fetchPrHeadSha', async () => {
|
||||
fetchPrHeadShaMock.mockClear()
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(fetchPrHeadShaMock).toHaveBeenCalledWith('acme', 'myrepo', 42)
|
||||
})
|
||||
|
||||
test('initialHeadSha is passed into remoteTaskMetadata on register', async () => {
|
||||
fetchPrHeadShaMock.mockImplementationOnce(() =>
|
||||
Promise.resolve('sha-from-launch'),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(registerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteTaskMetadata: expect.objectContaining({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 42,
|
||||
initialHeadSha: 'sha-from-launch',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPrHeadSha failure → metadata initialHeadSha undefined, launch still succeeds', async () => {
|
||||
fetchPrHeadShaMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('gh not installed')),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(registerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteTaskMetadata: expect.objectContaining({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 42,
|
||||
initialHeadSha: undefined,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
// Launch must NOT fail just because SHA capture failed
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix launched/)
|
||||
})
|
||||
|
||||
test('fetchPrHeadSha returning null → metadata initialHeadSha undefined', async () => {
|
||||
fetchPrHeadShaMock.mockImplementationOnce(() => Promise.resolve(null))
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(registerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteTaskMetadata: expect.objectContaining({
|
||||
initialHeadSha: undefined,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2 (cont.): exercise the registered completionChecker arrow body
|
||||
// directly. The earlier suite verifies it was registered but never invokes
|
||||
// the arrow itself, leaving the throttle / metadata-guard / gh-CLI dispatch
|
||||
// branches uncovered.
|
||||
describe('callAutofixPr · Phase 2 completionChecker arrow body', () => {
|
||||
// Pull the most recent registered checker — beforeAll registers once at
|
||||
// module load; nothing else re-registers across this file's tests.
|
||||
function getChecker(): (metadata?: unknown) => Promise<string | null> {
|
||||
const calls = registerCompletionCheckerMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
const fn = calls[calls.length - 1]?.[1]
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error('completionChecker not registered')
|
||||
}
|
||||
return fn
|
||||
}
|
||||
|
||||
test('returns null when metadata is undefined (early guard)', async () => {
|
||||
const checker = getChecker()
|
||||
expect(await checker(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when checkPrAutofixOutcome reports not completed', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
;(checkPrAutofixOutcome as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() => Promise.resolve({ completed: false }),
|
||||
)
|
||||
const checker = getChecker()
|
||||
// Distinct PR number to dodge the in-process throttle map carried over
|
||||
// from earlier tests.
|
||||
const result = await checker({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 1001,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns the summary string when checkPrAutofixOutcome reports completed', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
;(checkPrAutofixOutcome as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
completed: true,
|
||||
summary: 'acme/myrepo#1002 merged. Autofix monitoring complete.',
|
||||
}),
|
||||
)
|
||||
const checker = getChecker()
|
||||
const result = await checker({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 1002,
|
||||
})
|
||||
expect(result).toBe('acme/myrepo#1002 merged. Autofix monitoring complete.')
|
||||
})
|
||||
|
||||
test('passes initialHeadSha through to checkPrAutofixOutcome', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||
checkMock.mockClear()
|
||||
checkMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({ completed: false }),
|
||||
)
|
||||
const checker = getChecker()
|
||||
await checker({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 1003,
|
||||
initialHeadSha: 'sha-baseline-xyz',
|
||||
})
|
||||
expect(checkMock).toHaveBeenCalledWith({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 1003,
|
||||
initialHeadSha: 'sha-baseline-xyz',
|
||||
})
|
||||
})
|
||||
|
||||
test('throttles back-to-back calls for the same PR within CHECK_INTERVAL_MS', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||
checkMock.mockClear()
|
||||
checkMock.mockImplementation(() => Promise.resolve({ completed: false }))
|
||||
const checker = getChecker()
|
||||
const meta = { owner: 'acme', repo: 'myrepo', prNumber: 1004 }
|
||||
await checker(meta)
|
||||
// Second call within the 5s throttle window must short-circuit to null
|
||||
// without invoking the gh CLI layer again.
|
||||
const callCountAfterFirst = checkMock.mock.calls.length
|
||||
const result = await checker(meta)
|
||||
expect(result).toBeNull()
|
||||
expect(checkMock.mock.calls.length).toBe(callCountAfterFirst)
|
||||
})
|
||||
|
||||
test('completionHook with metadata clears the throttle entry (re-launch can re-check immediately)', async () => {
|
||||
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||
checkMock.mockClear()
|
||||
checkMock.mockImplementation(() => Promise.resolve({ completed: false }))
|
||||
const checker = getChecker()
|
||||
const meta = { owner: 'acme', repo: 'myrepo', prNumber: 1005 }
|
||||
await checker(meta) // populate throttle map
|
||||
|
||||
// Invoke the registered completion hook with the same metadata so the
|
||||
// throttle entry is wiped, then verify the next checker call dispatches
|
||||
// gh CLI again instead of short-circuiting.
|
||||
const hookCalls = registerCompletionHookMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
const hook = hookCalls[hookCalls.length - 1]?.[1] as (
|
||||
id: string,
|
||||
metadata?: unknown,
|
||||
) => void
|
||||
hook('any-task-id', meta)
|
||||
|
||||
const callCountBefore = checkMock.mock.calls.length
|
||||
await checker(meta)
|
||||
expect(checkMock.mock.calls.length).toBe(callCountBefore + 1)
|
||||
})
|
||||
|
||||
test('completionHook without metadata still clears the active monitor lock', async () => {
|
||||
// Lock is set via callAutofixPr; hook then invoked with undefined metadata
|
||||
// to exercise the `if (meta)` short-circuit branch (the lock-clear half
|
||||
// still has to run regardless of metadata presence).
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(getActiveMonitor()).not.toBeNull()
|
||||
const hookCalls = registerCompletionHookMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
const hook = hookCalls[hookCalls.length - 1]?.[1] as (
|
||||
id: string,
|
||||
metadata?: unknown,
|
||||
) => void
|
||||
hook('framework-task-id', undefined)
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 3: content extractor wiring + initialMessage tag instruction
|
||||
describe('callAutofixPr · Phase 3 content extractor integration', () => {
|
||||
test('registerContentExtractor is called at module load with autofix-pr type', () => {
|
||||
const calls = registerContentExtractorMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const extractor = calls[calls.length - 1]?.[1]
|
||||
expect(typeof extractor).toBe('function')
|
||||
})
|
||||
|
||||
test('initialMessage instructs the remote agent to emit an <autofix-result> tag', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
// teleportMock's typed signature has no args, so calls[0] is a
|
||||
// zero-length tuple. We know teleportToRemote is invoked with one
|
||||
// options object, so double-cast through unknown to read the args.
|
||||
const calls = teleportMock.mock.calls as unknown as Array<
|
||||
[{ initialMessage?: string }]
|
||||
>
|
||||
const teleportArgs = calls[0]?.[0]
|
||||
expect(teleportArgs?.initialMessage).toContain('<autofix-result>')
|
||||
expect(teleportArgs?.initialMessage).toContain('</autofix-result>')
|
||||
expect(teleportArgs?.initialMessage).toContain('<ci-status>')
|
||||
expect(teleportArgs?.initialMessage).toContain('<summary>')
|
||||
})
|
||||
|
||||
test('registered extractor returns string for valid log and null for empty', () => {
|
||||
const calls = registerContentExtractorMock.mock.calls.filter(
|
||||
c => c[0] === 'autofix-pr',
|
||||
)
|
||||
const extractor = calls[calls.length - 1]?.[1] as
|
||||
| ((log: unknown[]) => string | null)
|
||||
| undefined
|
||||
expect(extractor).toBeDefined()
|
||||
// Empty log → null
|
||||
expect(extractor?.([])).toBeNull()
|
||||
// Log with assistant text containing tag → returns it
|
||||
const logWithTag = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done\n<autofix-result><summary>x</summary></autofix-result>',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(extractor?.(logWithTag)).toContain('<autofix-result>')
|
||||
})
|
||||
})
|
||||
|
||||
// Cover ../index.ts load() — placed in this test file so all the heavy mocks
|
||||
// (teleport / detectRepository / RemoteAgentTask / bootstrap-state / analytics /
|
||||
// skillDetect) are already registered when load() dynamically imports
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isMonitoring,
|
||||
setActiveMonitor,
|
||||
trySetActiveMonitor,
|
||||
updateActiveMonitor,
|
||||
} from '../monitorState.js'
|
||||
|
||||
function makeState(
|
||||
@@ -76,4 +77,41 @@ describe('monitorState', () => {
|
||||
// First state remains
|
||||
expect(getActiveMonitor()?.prNumber).toBe(1)
|
||||
})
|
||||
|
||||
test('updateActiveMonitor returns false when no active monitor', () => {
|
||||
expect(updateActiveMonitor({ taskId: 'task-x' })).toBe(false)
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('updateActiveMonitor merges partial fields into the active monitor', () => {
|
||||
setActiveMonitor(makeState({ taskId: 'tentative-uuid' }))
|
||||
expect(updateActiveMonitor({ taskId: 'framework-task-id' })).toBe(true)
|
||||
const after = getActiveMonitor()
|
||||
expect(after?.taskId).toBe('framework-task-id')
|
||||
// Other fields untouched
|
||||
expect(after?.owner).toBe('acme')
|
||||
expect(after?.repo).toBe('myrepo')
|
||||
expect(after?.prNumber).toBe(42)
|
||||
})
|
||||
|
||||
test('updateActiveMonitor with new taskId makes clearActiveMonitor recognise framework taskId', () => {
|
||||
// Reproduce the latent bug scenario: lock acquired with one taskId,
|
||||
// framework assigns a different one. Before the fix, the framework's
|
||||
// clearActiveMonitor(frameworkTaskId) would no-op because guard fails.
|
||||
setActiveMonitor(makeState({ taskId: 'teammate-uuid' }))
|
||||
// Framework cleanup using its own taskId — would fail guard before the fix
|
||||
clearActiveMonitor('framework-uuid')
|
||||
expect(getActiveMonitor()).not.toBeNull()
|
||||
// After updateActiveMonitor swaps the taskId, framework cleanup works
|
||||
updateActiveMonitor({ taskId: 'framework-uuid' })
|
||||
clearActiveMonitor('framework-uuid')
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('updateActiveMonitor does not change abortController identity', () => {
|
||||
const ac = new AbortController()
|
||||
setActiveMonitor(makeState({ abortController: ac, taskId: 'tentative' }))
|
||||
updateActiveMonitor({ taskId: 'updated' })
|
||||
expect(getActiveMonitor()?.abortController).toBe(ac)
|
||||
})
|
||||
})
|
||||
|
||||
193
src/commands/autofix-pr/__tests__/prOutcomeCheck.test.ts
Normal file
193
src/commands/autofix-pr/__tests__/prOutcomeCheck.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type PrViewPayload,
|
||||
summariseAutofixOutcome,
|
||||
} from '../prOutcomeCheck.js'
|
||||
|
||||
function basePayload(overrides: Partial<PrViewPayload> = {}): PrViewPayload {
|
||||
return {
|
||||
headRefOid: 'sha-baseline',
|
||||
state: 'OPEN',
|
||||
statusCheckRollup: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const identity = (overrides: Partial<{ initialHeadSha: string }> = {}) => ({
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 42,
|
||||
initialHeadSha: 'sha-baseline',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('summariseAutofixOutcome · terminal PR states', () => {
|
||||
test('MERGED → completed regardless of head SHA / CI', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({ state: 'MERGED', headRefOid: 'sha-baseline' }),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
summary: 'acme/myrepo#42 merged. Autofix monitoring complete.',
|
||||
})
|
||||
})
|
||||
|
||||
test('CLOSED → completed regardless of head SHA / CI', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({ state: 'CLOSED' }),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
summary:
|
||||
'acme/myrepo#42 closed without merge. Autofix monitoring complete.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('summariseAutofixOutcome · OPEN PR without push', () => {
|
||||
test('no initialHeadSha baseline → not completed (cannot detect push)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({ state: 'OPEN' }),
|
||||
identity({ initialHeadSha: undefined as unknown as string }),
|
||||
)
|
||||
expect(result).toEqual({ completed: false })
|
||||
})
|
||||
|
||||
test('headRefOid unchanged → not completed (autofix has not pushed yet)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({ state: 'OPEN', headRefOid: 'sha-baseline' }),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({ completed: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('summariseAutofixOutcome · OPEN PR with push, CI variations', () => {
|
||||
test('push detected + no checks configured → completed (success)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
summary: 'Autofix pushed commits to acme/myrepo#42, CI green.',
|
||||
})
|
||||
})
|
||||
|
||||
test('push detected + CI pending → not completed (wait for CI)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{ status: 'IN_PROGRESS', conclusion: null, name: 'ci' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({ completed: false })
|
||||
})
|
||||
|
||||
test('push detected + CI all green → completed (success summary)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'ci' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result.completed).toBe(true)
|
||||
if (result.completed) {
|
||||
expect(result.summary).toContain('CI green')
|
||||
expect(result.summary).toContain('acme/myrepo#42')
|
||||
}
|
||||
})
|
||||
|
||||
test('push detected + CI red → completed (failure summary surfaces the red)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{ status: 'COMPLETED', conclusion: 'FAILURE', name: 'ci' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result.completed).toBe(true)
|
||||
if (result.completed) {
|
||||
expect(result.summary).toContain('CI is failing')
|
||||
expect(result.summary).toContain('1/2 checks failing')
|
||||
}
|
||||
})
|
||||
|
||||
test('statusCheckRollup undefined → treated as no checks configured (success)', () => {
|
||||
// Distinct from empty-array: GitHub omits the field entirely on PRs
|
||||
// without any configured checks. The !rollup branch covers undefined.
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: undefined,
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result.completed).toBe(true)
|
||||
if (result.completed) {
|
||||
expect(result.summary).toContain('CI green')
|
||||
}
|
||||
})
|
||||
|
||||
test('check with COMPLETED status but empty conclusion → counted as pending', () => {
|
||||
// Edge case: GitHub sometimes reports a check as COMPLETED with a null/
|
||||
// missing conclusion (in-flight result mid-write). The defensive branch
|
||||
// treats empty conclusion after a passed status check as pending.
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{ status: 'COMPLETED', conclusion: null, name: 'ci-in-flight' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result).toEqual({ completed: false })
|
||||
})
|
||||
|
||||
test('neutral / skipped conclusions count as success (not failure)', () => {
|
||||
const result = summariseAutofixOutcome(
|
||||
basePayload({
|
||||
state: 'OPEN',
|
||||
headRefOid: 'sha-new',
|
||||
statusCheckRollup: [
|
||||
{
|
||||
status: 'COMPLETED',
|
||||
conclusion: 'NEUTRAL',
|
||||
name: 'optional-check',
|
||||
},
|
||||
{ status: 'COMPLETED', conclusion: 'SKIPPED', name: 'docs-check' },
|
||||
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'ci' },
|
||||
],
|
||||
}),
|
||||
identity(),
|
||||
)
|
||||
expect(result.completed).toBe(true)
|
||||
if (result.completed) {
|
||||
expect(result.summary).toContain('CI green')
|
||||
}
|
||||
})
|
||||
})
|
||||
92
src/commands/autofix-pr/extractAutofixResult.ts
Normal file
92
src/commands/autofix-pr/extractAutofixResult.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Extract the <autofix-result> tag from a remote autofix-pr session log.
|
||||
//
|
||||
// The remote agent emits a structured XML block as its final message
|
||||
// (initialMessage in launchAutofixPr.ts instructs it to). The tag carries
|
||||
// PR-specific outcome data — commits pushed, files changed, CI status,
|
||||
// summary — that the framework's generic "task completed" notification
|
||||
// can't convey. We surface it to the local model by injecting the tag
|
||||
// verbatim into the message queue (analogous to <remote-review> handling).
|
||||
//
|
||||
// Resilient to two production realities:
|
||||
// 1. The tag may appear in either an assistant text block or a hook
|
||||
// stdout (some autofix skills wrap the final report in a hook).
|
||||
// 2. The tag may not appear at all (older agents, truncated runs) —
|
||||
// caller falls back to generic completion notification.
|
||||
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKMessage,
|
||||
} from '../../entrypoints/agentSdkTypes.js'
|
||||
|
||||
export const AUTOFIX_RESULT_TAG = 'autofix-result'
|
||||
|
||||
const TAG_OPEN = `<${AUTOFIX_RESULT_TAG}>`
|
||||
const TAG_CLOSE = `</${AUTOFIX_RESULT_TAG}>`
|
||||
|
||||
/**
|
||||
* Walk the session log for an <autofix-result> tag. Returns the full tag
|
||||
* (including delimiters) so the caller can inject it as-is into the
|
||||
* notification; returns null if no tag is present.
|
||||
*
|
||||
* Search order:
|
||||
* 1. Latest hook_progress / hook_response stdout (autofix skills that
|
||||
* use hooks to format the report write here first).
|
||||
* 2. Latest assistant text block (agents that don't use hooks write the
|
||||
* tag inline in their final message).
|
||||
*
|
||||
* Latest-wins so re-tries within the same session don't surface stale
|
||||
* earlier results.
|
||||
*/
|
||||
export function extractAutofixResultFromLog(log: SDKMessage[]): string | null {
|
||||
// Walk backwards so we hit the most recent tag first.
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const msg = log[i]
|
||||
if (!msg) continue
|
||||
|
||||
// Hook stdout (system messages of subtype hook_progress / hook_response).
|
||||
if (
|
||||
msg.type === 'system' &&
|
||||
(msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')
|
||||
) {
|
||||
const stdout = (msg as { stdout?: unknown }).stdout
|
||||
if (typeof stdout === 'string') {
|
||||
const extracted = extractBetween(stdout, TAG_OPEN, TAG_CLOSE)
|
||||
if (extracted) return extracted
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Assistant text blocks.
|
||||
if (msg.type === 'assistant') {
|
||||
const content = (msg as SDKAssistantMessage).message?.content
|
||||
if (!content || typeof content === 'string') continue
|
||||
for (const block of content as Array<{ type: string; text?: string }>) {
|
||||
if (block.type !== 'text' || typeof block.text !== 'string') continue
|
||||
if (!block.text.includes(TAG_OPEN)) continue
|
||||
const extracted = extractBetween(block.text, TAG_OPEN, TAG_CLOSE)
|
||||
if (extracted) return extracted
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Walks open tags from latest to earliest, returning the first complete
|
||||
// open/close pair. Guards against a truncated final tag shadowing an
|
||||
// earlier complete pair within the same text block (e.g., a retry wrote a
|
||||
// full result, then the model started a second tag that got cut off).
|
||||
function extractBetween(
|
||||
text: string,
|
||||
open: string,
|
||||
close: string,
|
||||
): string | null {
|
||||
let searchFrom = text.length
|
||||
while (searchFrom >= 0) {
|
||||
const start = text.lastIndexOf(open, searchFrom)
|
||||
if (start === -1) return null
|
||||
const end = text.indexOf(close, start + open.length)
|
||||
if (end !== -1) return text.slice(start, end + close.length)
|
||||
searchFrom = start - 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
checkRemoteAgentEligibility,
|
||||
formatPreconditionError,
|
||||
getRemoteTaskSessionUrl,
|
||||
registerCompletionChecker,
|
||||
registerCompletionHook,
|
||||
registerContentExtractor,
|
||||
registerRemoteAgentTask,
|
||||
type AutofixPrRemoteTaskMetadata,
|
||||
type BackgroundRemoteSessionPrecondition,
|
||||
} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
@@ -26,10 +30,66 @@ import {
|
||||
getActiveMonitor,
|
||||
isMonitoring,
|
||||
trySetActiveMonitor,
|
||||
updateActiveMonitor,
|
||||
} from './monitorState.js'
|
||||
import { extractAutofixResultFromLog } from './extractAutofixResult.js'
|
||||
import { parseAutofixArgs } from './parseArgs.js'
|
||||
import { checkPrAutofixOutcome, fetchPrHeadSha } from './prFetch.js'
|
||||
import { detectAutofixSkills, formatSkillsHint } from './skillDetect.js'
|
||||
|
||||
// Throttle map for the completionChecker: gh CLI is called at most once per
|
||||
// PR per CHECK_INTERVAL_MS, regardless of the framework's 1s poll cadence.
|
||||
// Key is `${owner}/${repo}#${prNumber}`. Cleared when the completion hook
|
||||
// fires so a re-launched monitor starts with a fresh budget.
|
||||
const lastCheckAt = new Map<string, number>()
|
||||
const CHECK_INTERVAL_MS = 5_000
|
||||
|
||||
function throttleKey(meta: AutofixPrRemoteTaskMetadata): string {
|
||||
return `${meta.owner}/${meta.repo}#${meta.prNumber}`
|
||||
}
|
||||
|
||||
// Register the completionChecker once at module load. The framework calls it
|
||||
// on every poll tick for tasks with remoteTaskType==='autofix-pr'; throttle
|
||||
// inside so we don't fire gh CLI 60×/min. Returns the summary string on
|
||||
// completion (becomes the task-notification body) or null to keep polling.
|
||||
registerCompletionChecker('autofix-pr', async metadata => {
|
||||
const meta = metadata as AutofixPrRemoteTaskMetadata | undefined
|
||||
if (!meta) return null
|
||||
|
||||
const key = throttleKey(meta)
|
||||
const now = Date.now()
|
||||
if (now - (lastCheckAt.get(key) ?? 0) < CHECK_INTERVAL_MS) return null
|
||||
lastCheckAt.set(key, now)
|
||||
|
||||
const result = await checkPrAutofixOutcome({
|
||||
owner: meta.owner,
|
||||
repo: meta.repo,
|
||||
prNumber: meta.prNumber,
|
||||
initialHeadSha: meta.initialHeadSha,
|
||||
})
|
||||
return result.completed ? result.summary : null
|
||||
})
|
||||
|
||||
// Release the singleton monitor lock when the framework transitions the
|
||||
// autofix task to a terminal state. Without this, the lock — keyed by the
|
||||
// framework-assigned taskId (after callAutofixPr's updateActiveMonitor swap)
|
||||
// — would dangle past natural completion, blocking subsequent /autofix-pr
|
||||
// invocations until the process restarts. Registered at module load; the
|
||||
// framework's runCompletionHook invokes it once per terminal transition.
|
||||
// Also clear the per-PR throttle entry so a re-launch starts fresh.
|
||||
registerCompletionHook('autofix-pr', (taskId, metadata) => {
|
||||
clearActiveMonitor(taskId)
|
||||
const meta = metadata as AutofixPrRemoteTaskMetadata | undefined
|
||||
if (meta) lastCheckAt.delete(throttleKey(meta))
|
||||
})
|
||||
|
||||
// Phase 3 content return: extract the <autofix-result> tag from the session
|
||||
// log so the local model sees the agent's structured outcome (commits
|
||||
// pushed, files changed, CI status) inline in the completion task-
|
||||
// notification — instead of just a file-path pointer. The framework falls
|
||||
// back to the generic notification if extraction returns null.
|
||||
registerContentExtractor('autofix-pr', log => extractAutofixResultFromLog(log))
|
||||
|
||||
function makeErrorText(message: string, code: string): string {
|
||||
logEvent('tengu_autofix_pr_result', {
|
||||
result:
|
||||
@@ -198,7 +258,23 @@ export const callAutofixPr: LocalJSXCommandCall = async (
|
||||
// 4.5 compose message
|
||||
const target = `${owner}/${repo}#${prNumber}`
|
||||
const branchName = `refs/pull/${prNumber}/head`
|
||||
const initialMessage = `Auto-fix failing CI checks on PR #${prNumber} in ${owner}/${repo}.${skillsHint}`
|
||||
const initialMessage = `Auto-fix failing CI checks on PR #${prNumber} in ${owner}/${repo}.${skillsHint}
|
||||
|
||||
When you finish (or hit a blocker you can't recover from), output the following XML tag as your final message so the local user gets a structured summary:
|
||||
|
||||
<autofix-result>
|
||||
<pr-number>${prNumber}</pr-number>
|
||||
<commits-pushed>
|
||||
<commit sha="...">commit message</commit>
|
||||
</commits-pushed>
|
||||
<files-changed>
|
||||
<file path="...">N changes</file>
|
||||
</files-changed>
|
||||
<ci-status>green | red | pending | unknown</ci-status>
|
||||
<summary>One-sentence summary of what was fixed or why it could not be fixed.</summary>
|
||||
</autofix-result>
|
||||
|
||||
If no fix was needed, omit <commits-pushed> and <files-changed> and explain in <summary>. If you only attempted partial work, list the commits you did push and explain the remainder in <summary>.`
|
||||
|
||||
// 4.6 in-process teammate
|
||||
const teammate = createAutofixTeammate(initialMessage, target)
|
||||
@@ -274,18 +350,35 @@ export const callAutofixPr: LocalJSXCommandCall = async (
|
||||
return null
|
||||
}
|
||||
|
||||
// 4.8b capture PR head SHA before registering so the completionChecker
|
||||
// can detect when the agent has pushed new commits. Best-effort — if gh
|
||||
// is unavailable or the call fails, leave initialHeadSha undefined and
|
||||
// the checker falls back to terminal-state-only completion (closed /
|
||||
// merged). Don't block on this; teleport succeeded already.
|
||||
const initialHeadSha =
|
||||
(await fetchPrHeadSha(owner, repo, prNumber).catch(() => null)) ??
|
||||
undefined
|
||||
|
||||
// 4.9 register task. If this throws, release the lock so the user can
|
||||
// retry — the remote CCR session is already created so we surface a
|
||||
// dedicated error code.
|
||||
//
|
||||
// After registration succeeds, swap the lock's taskId from the tentative
|
||||
// teammate UUID (used to acquire the lock atomically before teleport) to
|
||||
// the framework-assigned taskId. Without this swap, the framework's own
|
||||
// cleanup path (clearActiveMonitor(frameworkTaskId) on natural completion)
|
||||
// would no-op against a lock keyed by teammate.taskId, leaving the
|
||||
// singleton lock dangling and blocking future /autofix-pr invocations.
|
||||
try {
|
||||
registerRemoteAgentTask({
|
||||
const { taskId: frameworkTaskId } = registerRemoteAgentTask({
|
||||
remoteTaskType: 'autofix-pr',
|
||||
session,
|
||||
command: `/autofix-pr ${prNumber}`,
|
||||
context,
|
||||
isLongRunning: true,
|
||||
remoteTaskMetadata: { owner, repo, prNumber },
|
||||
remoteTaskMetadata: { owner, repo, prNumber, initialHeadSha },
|
||||
})
|
||||
updateActiveMonitor({ taskId: frameworkTaskId })
|
||||
} catch (regErr: unknown) {
|
||||
clearActiveMonitor(teammate.taskId)
|
||||
const regMsg = regErr instanceof Error ? regErr.message : String(regErr)
|
||||
|
||||
@@ -46,6 +46,20 @@ export function clearActiveMonitor(taskId?: string): void {
|
||||
active = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically merges partial updates into the active monitor. Returns true if
|
||||
* applied, false if no active monitor. Used when the caller needs to swap the
|
||||
* lock's taskId after the framework assigns a different one than the
|
||||
* tentative one used to acquire the lock — without this the framework's
|
||||
* cleanup (clearActiveMonitor with the framework taskId) would no-op against
|
||||
* a lock keyed by the caller's tentative id.
|
||||
*/
|
||||
export function updateActiveMonitor(partial: Partial<MonitorState>): boolean {
|
||||
if (!active) return false
|
||||
active = { ...active, ...partial }
|
||||
return true
|
||||
}
|
||||
|
||||
export function isMonitoring(
|
||||
owner: string,
|
||||
repo: string,
|
||||
|
||||
155
src/commands/autofix-pr/prFetch.ts
Normal file
155
src/commands/autofix-pr/prFetch.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// gh CLI integration for autofix-pr: fetches PR snapshots and feeds them
|
||||
// through the pure decision matrix in prOutcomeCheck.ts. Kept separate so
|
||||
// tests of the decision matrix never have to mock node:child_process — and
|
||||
// tests of callAutofixPr can mock this module without polluting the pure
|
||||
// decision matrix module (Bun mock.module is process-global).
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import {
|
||||
type AutofixOutcomeProbeResult,
|
||||
type PrViewPayload,
|
||||
summariseAutofixOutcome,
|
||||
} from './prOutcomeCheck.js'
|
||||
|
||||
export interface AutofixOutcomeProbeInput {
|
||||
owner: string
|
||||
repo: string
|
||||
prNumber: number
|
||||
/**
|
||||
* Head commit SHA captured at /autofix-pr launch. When this differs from
|
||||
* the current head, autofix has pushed at least one commit.
|
||||
*/
|
||||
initialHeadSha?: string
|
||||
/**
|
||||
* Timeout for the gh CLI invocation. Caller is the framework's per-tick
|
||||
* poller, so failures must be bounded — a hung gh process would stall
|
||||
* the entire poll loop.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* Fetch the PR's current head SHA, state, and CI rollup, and decide whether
|
||||
* autofix has finished. Returns `{ completed: true, summary }` if so;
|
||||
* otherwise `{ completed: false }`. Never throws.
|
||||
*/
|
||||
export async function checkPrAutofixOutcome(
|
||||
input: AutofixOutcomeProbeInput,
|
||||
): Promise<AutofixOutcomeProbeResult> {
|
||||
const { owner, repo, prNumber, initialHeadSha, timeoutMs } = input
|
||||
|
||||
let payload: PrViewPayload
|
||||
try {
|
||||
payload = await runGhPrView(
|
||||
owner,
|
||||
repo,
|
||||
prNumber,
|
||||
timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
)
|
||||
} catch {
|
||||
return { completed: false }
|
||||
}
|
||||
|
||||
return summariseAutofixOutcome(payload, {
|
||||
owner,
|
||||
repo,
|
||||
prNumber,
|
||||
initialHeadSha,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the PR's current head commit SHA. Used at /autofix-pr launch to
|
||||
* capture a baseline; later compared against the live SHA to detect pushes.
|
||||
* Returns null on any failure (network, missing gh, permissions) — the
|
||||
* caller treats null as "no baseline" and falls back to terminal-state-only
|
||||
* completion detection.
|
||||
*/
|
||||
export async function fetchPrHeadSha(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const payload = await runGhPrView(owner, repo, prNumber, timeoutMs)
|
||||
return payload.headRefOid || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface SpawnError extends Error {
|
||||
code?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn `gh pr view {n} --repo {owner}/{repo} --json ...` and parse the
|
||||
* result. Rejects on non-zero exit, timeout, or JSON parse failure.
|
||||
*/
|
||||
function runGhPrView(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
timeoutMs: number,
|
||||
): Promise<PrViewPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
'gh',
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
String(prNumber),
|
||||
'--repo',
|
||||
`${owner}/${repo}`,
|
||||
'--json',
|
||||
'headRefOid,state,statusCheckRollup',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
)
|
||||
const stdoutChunks: Buffer[] = []
|
||||
const stderrChunks: Buffer[] = []
|
||||
let settled = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
proc.kill('SIGKILL')
|
||||
reject(new Error(`gh pr view timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
proc.stdout.on('data', chunk => stdoutChunks.push(chunk as Buffer))
|
||||
proc.stderr.on('data', chunk => stderrChunks.push(chunk as Buffer))
|
||||
|
||||
proc.on('error', (err: SpawnError) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
proc.on('close', code => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (code !== 0) {
|
||||
const stderr = Buffer.concat(stderrChunks).toString('utf8').trim()
|
||||
reject(
|
||||
new Error(`gh pr view exited ${code}: ${stderr || '<no stderr>'}`),
|
||||
)
|
||||
return
|
||||
}
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf8').trim()
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as PrViewPayload
|
||||
resolve(parsed)
|
||||
} catch (e) {
|
||||
reject(
|
||||
new Error(`gh pr view JSON parse failed: ${(e as Error).message}`),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
123
src/commands/autofix-pr/prOutcomeCheck.ts
Normal file
123
src/commands/autofix-pr/prOutcomeCheck.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Pure decision matrix for autofix-pr completion detection.
|
||||
//
|
||||
// Given a snapshot of the PR (state, head SHA, CI rollup) and a baseline
|
||||
// head SHA captured at /autofix-pr launch, decide whether autofix has
|
||||
// finished. No side effects — extracted from the gh CLI invocation in
|
||||
// prFetch.ts so unit tests can exercise every branch without spawning
|
||||
// subprocesses.
|
||||
|
||||
export type AutofixOutcomeProbeResult =
|
||||
| { completed: true; summary: string }
|
||||
| { completed: false }
|
||||
|
||||
export interface PrViewPayload {
|
||||
headRefOid: string
|
||||
state: 'OPEN' | 'CLOSED' | 'MERGED'
|
||||
statusCheckRollup?: Array<{
|
||||
conclusion?: string | null
|
||||
status?: string | null
|
||||
name?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AutofixOutcomeIdentity {
|
||||
owner: string
|
||||
repo: string
|
||||
prNumber: number
|
||||
/**
|
||||
* Head commit SHA captured at /autofix-pr launch. When this differs from
|
||||
* the current head, autofix has pushed at least one commit. Optional —
|
||||
* absence means we can only finish on terminal PR states (merged/closed).
|
||||
*/
|
||||
initialHeadSha?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure judgement of whether autofix has finished, given a PR snapshot and
|
||||
* the baseline head SHA. Decision matrix:
|
||||
* - MERGED → done (merged)
|
||||
* - CLOSED (not merged) → done (closed without fix)
|
||||
* - OPEN, no baseline → keep polling
|
||||
* - OPEN, head unchanged → keep polling (agent hasn't pushed)
|
||||
* - OPEN, head changed, CI pending → keep polling (wait for CI)
|
||||
* - OPEN, head changed, CI failure → done (surface red so user can retry)
|
||||
* - OPEN, head changed, CI success → done (clean fix)
|
||||
*/
|
||||
export function summariseAutofixOutcome(
|
||||
payload: PrViewPayload,
|
||||
identity: AutofixOutcomeIdentity,
|
||||
): AutofixOutcomeProbeResult {
|
||||
const { owner, repo, prNumber, initialHeadSha } = identity
|
||||
|
||||
if (payload.state === 'MERGED') {
|
||||
return {
|
||||
completed: true,
|
||||
summary: `${owner}/${repo}#${prNumber} merged. Autofix monitoring complete.`,
|
||||
}
|
||||
}
|
||||
if (payload.state === 'CLOSED') {
|
||||
return {
|
||||
completed: true,
|
||||
summary: `${owner}/${repo}#${prNumber} closed without merge. Autofix monitoring complete.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialHeadSha) return { completed: false }
|
||||
if (payload.headRefOid === initialHeadSha) return { completed: false }
|
||||
|
||||
const ciState = summariseCiRollup(payload.statusCheckRollup)
|
||||
if (ciState.state === 'pending') return { completed: false }
|
||||
if (ciState.state === 'failure') {
|
||||
return {
|
||||
completed: true,
|
||||
summary: `Autofix pushed commits to ${owner}/${repo}#${prNumber} but CI is failing (${ciState.detail}).`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
completed: true,
|
||||
summary: `Autofix pushed commits to ${owner}/${repo}#${prNumber}, CI green.`,
|
||||
}
|
||||
}
|
||||
|
||||
interface CiSummary {
|
||||
state: 'success' | 'pending' | 'failure'
|
||||
detail: string
|
||||
}
|
||||
|
||||
function summariseCiRollup(
|
||||
rollup: PrViewPayload['statusCheckRollup'],
|
||||
): CiSummary {
|
||||
if (!rollup || rollup.length === 0) {
|
||||
// No checks configured on this repo — treat as success so completion
|
||||
// can fire on push alone. PRs without CI are perfectly valid.
|
||||
return { state: 'success', detail: 'no checks configured' }
|
||||
}
|
||||
let pending = 0
|
||||
let failed = 0
|
||||
const total = rollup.length
|
||||
for (const check of rollup) {
|
||||
const status = (check.status ?? '').toUpperCase()
|
||||
const conclusion = (check.conclusion ?? '').toUpperCase()
|
||||
if (status && status !== 'COMPLETED') {
|
||||
pending++
|
||||
continue
|
||||
}
|
||||
if (
|
||||
conclusion === 'SUCCESS' ||
|
||||
conclusion === 'NEUTRAL' ||
|
||||
conclusion === 'SKIPPED'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (conclusion === '') {
|
||||
pending++
|
||||
continue
|
||||
}
|
||||
failed++
|
||||
}
|
||||
if (pending > 0)
|
||||
return { state: 'pending', detail: `${pending}/${total} checks pending` }
|
||||
if (failed > 0)
|
||||
return { state: 'failure', detail: `${failed}/${total} checks failing` }
|
||||
return { state: 'success', detail: `${total}/${total} checks passing` }
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(args)) {
|
||||
onDone(
|
||||
'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extra high reasoning for supported models, including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning where supported (Opus 4.6/4.7, DeepSeek V4 Pro); maps to xhigh for ChatGPT Codex models\n- auto: Use the default effort level for your model',
|
||||
'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extended reasoning beyond high, short of max; including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning; maps to xhigh for ChatGPT Codex models\n- auto: Use the default effort level for your model',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import {
|
||||
clearGoal,
|
||||
completeGoal,
|
||||
formatGoalStatus,
|
||||
getGoal,
|
||||
pauseGoal,
|
||||
resumeGoal,
|
||||
setGoal,
|
||||
} from '../../services/goal/goalState.js'
|
||||
|
||||
export const call: LocalCommandCall = async args => {
|
||||
const trimmed = args.trim()
|
||||
|
||||
// No arguments — show current goal status
|
||||
if (!trimmed) {
|
||||
return { type: 'text', value: formatGoalStatus() }
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase()
|
||||
|
||||
// Control subcommands
|
||||
if (lower === 'clear') {
|
||||
const goal = getGoal()
|
||||
if (!goal) {
|
||||
return { type: 'text', value: 'No active goal to clear.' }
|
||||
}
|
||||
clearGoal()
|
||||
return { type: 'text', value: 'Goal cleared.' }
|
||||
}
|
||||
|
||||
if (lower === 'pause') {
|
||||
if (pauseGoal()) {
|
||||
return { type: 'text', value: 'Goal paused.' }
|
||||
}
|
||||
return { type: 'text', value: 'No active goal to pause.' }
|
||||
}
|
||||
|
||||
if (lower === 'resume') {
|
||||
if (resumeGoal()) {
|
||||
return { type: 'text', value: 'Goal resumed.' }
|
||||
}
|
||||
return { type: 'text', value: 'No paused goal to resume.' }
|
||||
}
|
||||
|
||||
if (lower === 'complete') {
|
||||
if (completeGoal()) {
|
||||
return { type: 'text', value: 'Goal marked as complete.' }
|
||||
}
|
||||
return { type: 'text', value: 'No active goal to complete.' }
|
||||
}
|
||||
|
||||
// Set a new goal
|
||||
const existing = getGoal()
|
||||
if (existing && existing.status === 'active') {
|
||||
// Replace existing active goal
|
||||
setGoal(trimmed)
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Goal replaced.\n\n${formatGoalStatus()}`,
|
||||
}
|
||||
}
|
||||
|
||||
setGoal(trimmed)
|
||||
return { type: 'text', value: `Goal set.\n\n${formatGoalStatus()}` }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const goal = {
|
||||
type: 'local',
|
||||
name: 'goal',
|
||||
description: 'Set or view the goal for a long-running task',
|
||||
supportsNonInteractive: true,
|
||||
argumentHint: '<objective> | clear | pause | resume',
|
||||
load: () => import('./goal.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default goal
|
||||
@@ -11,6 +11,7 @@
|
||||
* - Third-party API key values are NEVER included; only boolean presence flags.
|
||||
*/
|
||||
|
||||
import type { SubscriptionType } from '../../services/oauth/types.js'
|
||||
import { getClaudeAIOAuthTokens } from '../../utils/auth.js'
|
||||
import { getGlobalConfig } from '../../utils/config.js'
|
||||
|
||||
@@ -107,7 +108,10 @@ export function getAuthStatus(): AuthStatus {
|
||||
|
||||
let plan: AuthStatus['subscription']['plan'] = null
|
||||
if (subscriptionActive && oauthTokens) {
|
||||
const raw = oauthTokens.subscriptionType
|
||||
// 本地持久化或历史 token 中可能出现 'free' 等未纳入 SubscriptionType 的字符串
|
||||
const raw = oauthTokens.subscriptionType as
|
||||
| (SubscriptionType | 'free')
|
||||
| null
|
||||
if (
|
||||
raw === 'free' ||
|
||||
raw === 'pro' ||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
isPluginEnabledAtProjectScope,
|
||||
uninstallPluginOp,
|
||||
updatePluginOp,
|
||||
type InstallableScope,
|
||||
} from '../../services/plugins/pluginOperations.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import type { Tool } from '../../Tool.js';
|
||||
@@ -76,7 +77,7 @@ import { PluginOptionsDialog } from './PluginOptionsDialog.js';
|
||||
import { PluginOptionsFlow } from './PluginOptionsFlow.js';
|
||||
import type { ViewState as ParentViewState } from './types.js';
|
||||
import { UnifiedInstalledCell } from './UnifiedInstalledCell.js';
|
||||
import type { UnifiedInstalledItem } from './unifiedTypes.js';
|
||||
import type { UnifiedInstalledItem, UnifiedInstalledScope } from './unifiedTypes.js';
|
||||
import { usePagination } from './usePagination.js';
|
||||
|
||||
type Props = {
|
||||
@@ -103,7 +104,7 @@ type FailedPluginInfo = {
|
||||
name: string;
|
||||
marketplace: string;
|
||||
errors: PluginError[];
|
||||
scope: PersistablePluginScope;
|
||||
scope: UnifiedInstalledScope;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -1253,7 +1254,7 @@ export function ManagePlugins({
|
||||
const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false;
|
||||
const pluginScope = item.scope;
|
||||
const isBuiltin = pluginScope === 'builtin';
|
||||
if (isBuiltin || isInstallableScope(pluginScope)) {
|
||||
if (isBuiltin || isInstallableScope(pluginScope as PersistablePluginScope)) {
|
||||
const newPending = new Map(pendingToggles);
|
||||
// Omit scope — see handleSingleOperation's enable/disable comment.
|
||||
if (currentPending) {
|
||||
@@ -1579,8 +1580,8 @@ export function ManagePlugins({
|
||||
// is a recovery path for a plugin that failed to load — it may
|
||||
// be reinstallable, so don't nuke ${CLAUDE_PLUGIN_DATA} silently.
|
||||
// The normal uninstall path prompts; this one preserves.
|
||||
const result = isInstallableScope(pluginScope)
|
||||
? await uninstallPluginOp(pluginId, pluginScope, false)
|
||||
const result = isInstallableScope(pluginScope as PersistablePluginScope)
|
||||
? await uninstallPluginOp(pluginId, pluginScope as InstallableScope, false)
|
||||
: await uninstallPluginOp(pluginId, 'user', false);
|
||||
let success = result.success;
|
||||
if (!success) {
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ViewState = any
|
||||
export type PluginSettingsProps = any
|
||||
import type { LocalJSXCommandOnDone } from 'src/types/command.js'
|
||||
|
||||
/**
|
||||
* `/plugin` 根视图在子面板之间的导航状态。
|
||||
* 各分支对应不同子界面或从 CLI 参数解析出的初始路由。
|
||||
*/
|
||||
export type ViewState =
|
||||
| { type: 'menu' } // 返回插件功能总菜单
|
||||
| { type: 'help' } // 展示帮助说明
|
||||
| { type: 'validate'; path?: string } // 校验指定路径下的插件包
|
||||
| {
|
||||
type: 'browse-marketplace' // 在指定市场中浏览/安装插件
|
||||
targetMarketplace: string // 目标市场标识
|
||||
targetPlugin?: string // 可选:预选插件名
|
||||
}
|
||||
| { type: 'discover-plugins'; targetPlugin?: string } // 发现页;可预选搜索插件名
|
||||
| {
|
||||
type: 'manage-plugins' // 已安装插件管理(启用/禁用/卸载)
|
||||
targetPlugin?: string // 可选:聚焦某插件
|
||||
targetMarketplace?: string // 可选:与 targetPlugin 联用的市场
|
||||
action?: 'uninstall' | 'enable' | 'disable' // 可选:打开时直接执行的操作
|
||||
}
|
||||
| { type: 'marketplace-list' } // 列出已配置市场
|
||||
| { type: 'marketplace-menu' } // 市场相关子菜单
|
||||
| { type: 'add-marketplace'; initialValue?: string } // 添加市场;可预填 URL/名称
|
||||
| {
|
||||
type: 'manage-marketplaces' // 管理已保存的市场源
|
||||
targetMarketplace?: string // 可选:聚焦某市场
|
||||
action?: 'remove' | 'update' // 可选:移除或刷新该市场
|
||||
}
|
||||
|
||||
/** `/plugin` Ink 命令入口的 props。 */
|
||||
export type PluginSettingsProps = {
|
||||
onComplete: LocalJSXCommandOnDone // 子流程结束回调(可带结果文案与展示方式)
|
||||
args?: string // CLI 透传的子命令参数字符串
|
||||
showMcpRedirectMessage?: boolean // 从 `/mcp` 跳转时展示 MCP 相关提示
|
||||
}
|
||||
|
||||
@@ -1,2 +1,68 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type UnifiedInstalledItem = any
|
||||
import type {
|
||||
ConfigScope,
|
||||
MCPServerConnection,
|
||||
} from '../../services/mcp/types.js'
|
||||
import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
|
||||
|
||||
import type { PersistablePluginScope } from '../../utils/plugins/pluginIdentifier.js'
|
||||
|
||||
/** 列表项作用域:含 MCP 的 `builtin` 与已下架插件的 `flagged`。 */
|
||||
export type UnifiedInstalledScope = ConfigScope | 'builtin' | 'flagged'
|
||||
|
||||
/** 插件管理列表中 MCP 连接行的连接状态摘要。 */
|
||||
export type McpRowStatus =
|
||||
| 'connected' // 已连接且可用
|
||||
| 'disabled' // 用户或策略禁用
|
||||
| 'pending' // 正在连接或重连
|
||||
| 'needs-auth' // 需 OAuth 等鉴权
|
||||
| 'failed' // 连接或握手失败
|
||||
|
||||
/**
|
||||
* 「已安装」统一列表中的一行:插件、失败占位、下架标记或 MCP 服务器。
|
||||
* 用于分页与键盘导航的同一数据源。
|
||||
*/
|
||||
export type UnifiedInstalledItem =
|
||||
| {
|
||||
type: 'plugin' // 正常加载的插件
|
||||
id: string // `name@marketplace` 唯一键
|
||||
name: string // 插件短名
|
||||
description: string | undefined // manifest 描述
|
||||
marketplace: string // 所属市场
|
||||
scope: PersistablePluginScope | 'builtin' // 安装/展示作用域(内置单独标)
|
||||
isEnabled: boolean // 是否在 merged settings 中启用
|
||||
errorCount: number // 与该插件关联的错误条数
|
||||
errors: PluginError[] // 结构化错误列表
|
||||
plugin: LoadedPlugin // 已解析的 manifest 与路径等
|
||||
pendingEnable?: boolean // UI:等待启用完成
|
||||
pendingUpdate?: boolean // UI:等待更新完成
|
||||
pendingToggle?: 'will-enable' | 'will-disable' // 用户已选、尚未落盘的启用切换
|
||||
}
|
||||
| {
|
||||
type: 'failed-plugin' // 未能加载的插件占位行
|
||||
id: string // 与错误 source 对齐的 id
|
||||
name: string // 展示用名称
|
||||
marketplace: string // 推断或 unknown
|
||||
scope: UnifiedInstalledScope // 推断的安装作用域
|
||||
errorCount: number
|
||||
errors: PluginError[]
|
||||
}
|
||||
| {
|
||||
type: 'flagged-plugin' // 市场已下架但仍出现在设置中的插件
|
||||
id: string
|
||||
name: string
|
||||
marketplace: string
|
||||
scope: 'flagged' // 固定为下架分组
|
||||
reason: string // 下架原因码(如 delisted)
|
||||
text: string // 面向用户的说明文案
|
||||
flaggedAt: string // 标记时间(ISO 等)
|
||||
}
|
||||
| {
|
||||
type: 'mcp' // 独立 MCP 或插件子 MCP 行
|
||||
id: string // 列表稳定 id(如 mcp:name)
|
||||
name: string // 展示名(子 MCP 可为 server 段)
|
||||
description: string | undefined // 可选副标题
|
||||
scope: UnifiedInstalledScope // 来自 server config 或父插件推导
|
||||
status: McpRowStatus // 连接态摘要
|
||||
client: MCPServerConnection // 底层连接对象(供详情/工具视图)
|
||||
indented?: boolean // true 表示挂在某插件下的子 MCP
|
||||
}
|
||||
|
||||
@@ -11,6 +11,18 @@ type Props = {
|
||||
};
|
||||
|
||||
export function BypassPermissionsModeDialog({ onAccept }: Props): React.ReactNode {
|
||||
const [pendingExitCode, setPendingExitCode] = React.useState<number | null>(null);
|
||||
|
||||
// Clear screen before shutdown so residual dialog content doesn't leak
|
||||
// to the terminal. Deferred to next tick so Ink flushes the null render.
|
||||
React.useEffect(() => {
|
||||
if (pendingExitCode !== null) {
|
||||
const code = pendingExitCode;
|
||||
const timer = setTimeout(() => gracefulShutdownSync(code));
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pendingExitCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
logEvent('tengu_bypass_permissions_mode_dialog_shown', {});
|
||||
}, []);
|
||||
@@ -27,16 +39,20 @@ export function BypassPermissionsModeDialog({ onAccept }: Props): React.ReactNod
|
||||
break;
|
||||
}
|
||||
case 'decline': {
|
||||
gracefulShutdownSync(1);
|
||||
setPendingExitCode(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
gracefulShutdownSync(0);
|
||||
setPendingExitCode(0);
|
||||
}, []);
|
||||
|
||||
if (pendingExitCode !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="WARNING: Claude Code running in Bypass Permissions mode" color="error" onCancel={handleEscape}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
|
||||
@@ -10,21 +10,37 @@ type Props = {
|
||||
};
|
||||
|
||||
export function DevChannelsDialog({ channels, onAccept }: Props): React.ReactNode {
|
||||
const [pendingExitCode, setPendingExitCode] = React.useState<number | null>(null);
|
||||
|
||||
// Clear screen before shutdown so residual dialog content doesn't leak
|
||||
// to the terminal. Deferred to next tick so Ink flushes the null render.
|
||||
React.useEffect(() => {
|
||||
if (pendingExitCode !== null) {
|
||||
const code = pendingExitCode;
|
||||
const timer = setTimeout(() => gracefulShutdownSync(code));
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pendingExitCode]);
|
||||
|
||||
function onChange(value: 'accept' | 'exit') {
|
||||
switch (value) {
|
||||
case 'accept':
|
||||
onAccept();
|
||||
break;
|
||||
case 'exit':
|
||||
gracefulShutdownSync(1);
|
||||
setPendingExitCode(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
gracefulShutdownSync(0);
|
||||
setPendingExitCode(0);
|
||||
}, []);
|
||||
|
||||
if (pendingExitCode !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="WARNING: Loading development channels" color="error" onCancel={handleEscape}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type FeedbackSurveyResponse = any
|
||||
export type FeedbackSurveyType = any
|
||||
/** 会话内满意度调查的选项(与数字键 0–3 映射一致)。 */
|
||||
export type FeedbackSurveyResponse =
|
||||
| 'dismissed' // 0:关闭不反馈
|
||||
| 'bad' // 1:不满意
|
||||
| 'fine' // 2:一般
|
||||
| 'good' // 3:满意
|
||||
|
||||
/** 调查场景;当前仅实现会话级提示。 */
|
||||
export type FeedbackSurveyType = 'session' // 主会话 Spinner/流程内触发
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Suspense, use, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { FileEdit } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js';
|
||||
import { findActualString, preserveQuoteStyle } from '@claude-code-best/builtin-tools/tools/FileEditTool/utils.js';
|
||||
import { findActualString } from '@claude-code-best/builtin-tools/tools/FileEditTool/utils.js';
|
||||
import { adjustHunkLineNumbers, CONTEXT_LINES, getPatchForDisplay } from '../utils/diff.js';
|
||||
import { logError } from '../utils/log.js';
|
||||
import { CHUNK_SIZE, openForScan, readCapped, scanForContext } from '../utils/readEditContext.js';
|
||||
@@ -135,6 +135,5 @@ function diffToolInputsOnly(filePath: string, edits: FileEdit[]): DiffData {
|
||||
|
||||
function normalizeEdit(fileContent: string, edit: FileEdit): FileEdit {
|
||||
const actualOld = findActualString(fileContent, edit.old_string) || edit.old_string;
|
||||
const actualNew = preserveQuoteStyle(edit.old_string, actualOld, edit.new_string);
|
||||
return { ...edit, old_string: actualOld, new_string: actualNew };
|
||||
return { ...edit, old_string: actualOld };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type SpinnerMode = any
|
||||
export type RGBColor = any
|
||||
/** 主循环/流式输出旁路指示器所处的交互阶段。 */
|
||||
export type SpinnerMode =
|
||||
| 'tool-input' // 等待用户对工具输入的响应
|
||||
| 'tool-use' // 工具执行中
|
||||
| 'responding' // 模型正在输出回复
|
||||
| 'thinking' // 模型思考/规划(不区分 provider 细节)
|
||||
| 'requesting' // 请求已发出、等待首包(含 shimmer 较快节奏)
|
||||
|
||||
/** 终端 24 位色(与 Ink `RGBColor` 及渐变插值工具一致)。 */
|
||||
export type RGBColor = {
|
||||
r: number // 红 0–255
|
||||
g: number // 绿 0–255
|
||||
b: number // 蓝 0–255
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ function buildStatusLineCommandInput(
|
||||
const sessionId = getSessionId();
|
||||
const sessionName = getCurrentSessionTitle(sessionId);
|
||||
const rawUtil = getRawUtilization();
|
||||
const rateLimits: StatusLineCommandInput['rate_limits'] = {
|
||||
const rateLimits: NonNullable<StatusLineCommandInput['rate_limits']> = {
|
||||
...(rawUtil.five_hour && {
|
||||
five_hour: {
|
||||
used_percentage: rawUtil.five_hour.utilization * 100,
|
||||
|
||||
@@ -80,6 +80,21 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
|
||||
const hasAnyBashExecution = bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash;
|
||||
|
||||
const hasTrustDialogAccepted = checkHasTrustDialogAccepted();
|
||||
const [pendingExitCode, setPendingExitCode] = React.useState<number | null>(null);
|
||||
|
||||
// When a non-null exit code is set, render null (clear screen) first,
|
||||
// then trigger shutdown in the next tick so Ink has time to flush
|
||||
// the empty frame before cleanupTerminalModes() unmounts and exits
|
||||
// the alt screen. Without this deferral, gracefulShutdownSync starts
|
||||
// async cleanup immediately after React commit, racing the reconciler
|
||||
// and leaving residual TrustDialog output on the terminal.
|
||||
React.useEffect(() => {
|
||||
if (pendingExitCode !== null) {
|
||||
const code = pendingExitCode;
|
||||
const timer = setTimeout(() => gracefulShutdownSync(code));
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pendingExitCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isHomeDir = homedir() === getCwd();
|
||||
@@ -107,7 +122,12 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
|
||||
|
||||
function onChange(value: 'enable_all' | 'exit') {
|
||||
if (value === 'exit') {
|
||||
gracefulShutdownSync(1);
|
||||
// Set pendingExitCode to clear the screen before triggering shutdown.
|
||||
// The useEffect above defers gracefulShutdownSync to the next tick
|
||||
// so Ink can flush the empty frame first — otherwise
|
||||
// cleanupTerminalModes races React's re-render and leaves
|
||||
// residual TrustDialog content on the terminal.
|
||||
setPendingExitCode(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -151,17 +171,23 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
|
||||
// so the default would hang the await forever. With keybinding
|
||||
// customization enabled, the chokidar watcher (persistent: true) keeps the
|
||||
// event loop alive and the process freezes. Explicitly exit 1 like "No".
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(() => gracefulShutdownSync(1));
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(() => setPendingExitCode(1));
|
||||
|
||||
// Use configurable keybinding for ESC to cancel/exit
|
||||
useKeybinding(
|
||||
'confirm:no',
|
||||
() => {
|
||||
gracefulShutdownSync(0);
|
||||
setPendingExitCode(0);
|
||||
},
|
||||
{ context: 'Confirmation' },
|
||||
);
|
||||
|
||||
// When pendingExitCode is set, render nothing so the screen is cleared
|
||||
// before shutdown cleans up the alt screen. See the useEffect above.
|
||||
if (pendingExitCode !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Automatically resolve the trust dialog if there is nothing to be shown.
|
||||
if (hasTrustDialogAccepted) {
|
||||
setTimeout(onDone);
|
||||
|
||||
@@ -1,2 +1,28 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type AgentWizardData = any
|
||||
import type { CustomAgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import type { AgentMemoryScope } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js'
|
||||
import type { SettingSource } from '../../../utils/settings/constants.js'
|
||||
|
||||
/**
|
||||
* 「新建代理」向导在各步骤之间传递的可变状态。
|
||||
* 字段随步骤渐进填充;`finalAgent` 在确认前由 Color 步骤合成。
|
||||
*/
|
||||
export type AgentWizardData = {
|
||||
systemPrompt?: string // 系统提示词终稿
|
||||
agentType?: string // 代理类型 slug(目录名)
|
||||
generationPrompt?: string // 「生成模式」下用户输入的说明全文
|
||||
selectedTools?: string[] // 限制可用工具;undefined 表示全量
|
||||
whenToUse?: string // 「何时调用」描述(whenToUse)
|
||||
location?: SettingSource // 落盘位置:项目或个人 settings
|
||||
selectedModel?: string // 覆盖默认模型(可选)
|
||||
selectedColor?: string // 终端高亮色(可选)
|
||||
wasGenerated?: boolean // 是否经模型一键生成过配置
|
||||
method?: 'generate' | 'manual' // 创建路径:生成 vs 手工
|
||||
isGenerating?: boolean // 生成请求进行中(用于 UI 防抖)
|
||||
generatedAgent?: {
|
||||
identifier: string // 生成器返回的 agentType 候选
|
||||
whenToUse: string // 生成器返回的描述
|
||||
systemPrompt: string // 生成器返回的系统提示
|
||||
}
|
||||
selectedMemory?: AgentMemoryScope // Memory 步骤选择的记忆作用域
|
||||
finalAgent?: CustomAgentDefinition // 确认保存前的完整代理定义草稿
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user