mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
Compare commits
26 Commits
lint/previ
...
v1.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e9aaf4993 | ||
|
|
34154ee3f5 | ||
|
|
29cc74a170 | ||
|
|
d2b66d9d2c | ||
|
|
d70e7f7f05 | ||
|
|
72a2093cd6 | ||
|
|
b5c299f5d2 | ||
|
|
ac42ce2d67 | ||
|
|
c659912517 | ||
|
|
a14b7f352b | ||
|
|
c5ab83a3fc | ||
|
|
03b7f9b453 | ||
|
|
bddd146f25 | ||
|
|
c8d08d235b | ||
|
|
a02dc0bded | ||
|
|
3cb1e50b25 | ||
|
|
cfab161e28 | ||
|
|
90027279e6 | ||
|
|
3470783ced | ||
|
|
8169b96250 | ||
|
|
fe08cacf8d | ||
|
|
5a4c820e1d | ||
|
|
1a4e9702c2 | ||
|
|
2273a0bcfe | ||
|
|
b80483c23e | ||
|
|
8442aaadd2 |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: macos-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -23,8 +23,16 @@ jobs:
|
|||||||
- name: Type check
|
- name: Type check
|
||||||
run: bunx tsc --noEmit
|
run: bunx tsc --noEmit
|
||||||
|
|
||||||
- name: Test
|
- name: Test with Coverage
|
||||||
run: bun test
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
file: ./coverage/lcov.info
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build:vite
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -13,7 +13,6 @@ src/utils/vendor/
|
|||||||
# AI tool runtime directories
|
# AI tool runtime directories
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
.codex/
|
|
||||||
.omx/
|
.omx/
|
||||||
.docs/task/
|
.docs/task/
|
||||||
# Binary / screenshot files (root only)
|
# Binary / screenshot files (root only)
|
||||||
@@ -30,3 +29,12 @@ __pycache__/
|
|||||||
logs
|
logs
|
||||||
|
|
||||||
data
|
data
|
||||||
|
.omc
|
||||||
|
.codex/*
|
||||||
|
!.codex/agents/
|
||||||
|
!.codex/agents/**
|
||||||
|
!.codex/skills/
|
||||||
|
!.codex/skills/**
|
||||||
|
.codex/skills/.system/**
|
||||||
|
!.codex/prompts/
|
||||||
|
!.codex/prompts/**
|
||||||
|
|||||||
78
.impeccable.md
Normal file
78
.impeccable.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Impeccable Design Context
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
**Primary**: Technical teams and enterprises using AI-assisted coding in production workflows.
|
||||||
|
- DevOps engineers managing remote agents via RCS dashboard
|
||||||
|
- Development teams collaborating through shared sessions
|
||||||
|
- Individual developers using terminal CLI daily
|
||||||
|
|
||||||
|
**Context**: Used during focused work sessions — debugging, code review, agent orchestration. Users are in "get things done" mode, not browsing. They value efficiency but also appreciate warmth and personality.
|
||||||
|
|
||||||
|
**Job to be done**: Make advanced AI coding tools accessible and controllable, especially features that normally require enterprise accounts or Anthropic OAuth.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
**3 words**: Warm, Considered, Human
|
||||||
|
|
||||||
|
**Voice**: Like a knowledgeable colleague who's genuinely enthusiastic about the craft — not a corporate product manager. Community-first, open, slightly playful. Chinese developer community culture (贴吧/discord 温暖氛围).
|
||||||
|
|
||||||
|
**Emotional goals**: Confidence (this tool is solid), Warmth (this community is welcoming), Delight (small moments of personality make the difference).
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- **Anthropic's own design language** — their clean, considered aesthetic with warm undertones. The terra cotta/burnt orange as a human accent. Lots of breathing room. Typography-forward.
|
||||||
|
- **NOT**: Generic AI product (no ChatGPT blue, no gradient text, no "AI slop"). NOT corporate SaaS (no Salesforce-blue dashboards, no enterprise sterility).
|
||||||
|
|
||||||
|
**Anti-references**: Corporate enterprise dashboards, generic AI product pages, anything that looks like it was "designed by committee."
|
||||||
|
|
||||||
|
## Aesthetic Direction
|
||||||
|
|
||||||
|
**Theme**: Light + Dark dual mode (user/system preference switch)
|
||||||
|
|
||||||
|
**Tone**: Anthropomorphic warmth meets terminal precision. The brand orange (Claude's terra cotta) is the thread that ties everything together — it's the human element in a technical world.
|
||||||
|
|
||||||
|
**Typography**: Clean, considered, with good hierarchy. Terminal-native for CLI; modern web fonts for Web UI (RCS dashboard, docs). Favor readability and personality.
|
||||||
|
|
||||||
|
**Color**:
|
||||||
|
- Primary: Claude orange family (`#D77757` / terra cotta)
|
||||||
|
- Accent: Warm neutrals tinted toward orange
|
||||||
|
- Semantic: Success/Error/Warning following Anthropic's established palette
|
||||||
|
- Dark mode: Warm dark surfaces (not cold blue-black)
|
||||||
|
|
||||||
|
**Differentiation**: The CCB brand sits at the intersection of "serious tool" and "community project." It should feel like Anthropic's design principles applied to an open-source context — less corporate polish, more human craft. The mascot "Clawd" and the playful "踩踩背" naming hint at personality that the design should honor.
|
||||||
|
|
||||||
|
**Scope**: All Web UI — RCS control panel, documentation site, landing pages.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **Considered over clever** — Every design choice should feel intentional, not trendy. If it doesn't serve the user, it doesn't ship.
|
||||||
|
2. **Warmth through subtlety** — Orange tints on neutrals, breathing room in layouts, personality in copy. Not giant emoji or aggressive color.
|
||||||
|
3. **Density with clarity** — Technical users need information density, but not chaos. Every pixel earns its place.
|
||||||
|
4. **Community voice** — The design should feel like it was made by people who use it, not by a distant design team. Slightly rough edges are fine if they're honest.
|
||||||
|
5. **Anthropic's shadow** — When in doubt, follow Anthropic's design instincts — the clean layouts, the generous spacing, the warm color temperature. Then add the community touch.
|
||||||
|
|
||||||
|
## Existing Design Assets
|
||||||
|
|
||||||
|
### Brand Colors (from theme system)
|
||||||
|
- Claude Orange: `rgb(215,119,87)` / `#D77757`
|
||||||
|
- Claude Blue: `rgb(87,105,247)` / `#5769F7`
|
||||||
|
- Permission Blue: `rgb(87,105,247)`
|
||||||
|
- Auto Accept Violet: `rgb(135,0,255)`
|
||||||
|
- Plan Mode Teal: `rgb(0,102,102)`
|
||||||
|
- Success: `rgb(78,186,101)`
|
||||||
|
- Error: `rgb(255,107,128)`
|
||||||
|
- Warning: `rgb(255,193,7)`
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
- CCB text + orange play button icon
|
||||||
|
- Dark/Light SVG variants in `docs/logo/`
|
||||||
|
- Favicon: Orange circle `#D97706` with white play triangle
|
||||||
|
|
||||||
|
### Mascot
|
||||||
|
- "Clawd" — terminal-art character with multiple poses
|
||||||
|
- Theme-aware coloring
|
||||||
|
|
||||||
|
### Theme System
|
||||||
|
- 7 variants: dark, light, dark-ansi, light-ansi, dark-daltonized, light-daltonized, auto
|
||||||
|
- 89+ semantic color tokens
|
||||||
|
- Full documentation in `packages/@ant/ink/docs/04-theme-system.md`
|
||||||
204
02-kairos (1).md
204
02-kairos (1).md
@@ -1,204 +0,0 @@
|
|||||||
# KAIROS — 永不关机的 Claude
|
|
||||||
|
|
||||||
> 源码位置:`src/assistant/`、`src/proactive/`、`src/services/autoDream/`
|
|
||||||
> 编译开关:`feature('KAIROS')`、`feature('KAIROS_BRIEF')`、`feature('KAIROS_CHANNELS')`
|
|
||||||
> 远程开关:GrowthBook `tengu_kairos`
|
|
||||||
|
|
||||||
关掉终端 Claude 还在运行的持久助手模式。KAIROS 是 Claude Code 中最复杂的隐藏功能之一。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心概念
|
|
||||||
|
|
||||||
KAIROS 让 Claude 从"一次性对话工具"变成"持久运行的 AI 助手":
|
|
||||||
|
|
||||||
- 关闭终端后 Claude 仍在后台运行
|
|
||||||
- 每天自动写日志
|
|
||||||
- 晚上自动"做梦"整理记忆
|
|
||||||
- 没人说话时自己找活干
|
|
||||||
- 命令超 15 秒自动丢后台
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 激活流程
|
|
||||||
|
|
||||||
定义在 `src/main.tsx`(约第 1054-1092 行),需要通过五层检查:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. feature('KAIROS') ← 编译时 flag
|
|
||||||
2. settings.assistant: true ← .claude/settings.json
|
|
||||||
3. 目录信任状态检查 ← 防恶意仓库劫持
|
|
||||||
4. tengu_kairos ← GrowthBook 远程开关
|
|
||||||
5. setKairosActive(true) ← 全局状态激活
|
|
||||||
```
|
|
||||||
|
|
||||||
`--assistant` CLI 参数可跳过远程开关检查(用于 Agent SDK daemon 模式)。
|
|
||||||
|
|
||||||
全局状态存储在 `src/bootstrap/state.ts`:
|
|
||||||
- `kairosActive: boolean`(默认 `false`)
|
|
||||||
- `getKairosActive()` / `setKairosActive(true)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 跨会话持久运行
|
|
||||||
|
|
||||||
### 会话恢复
|
|
||||||
|
|
||||||
`src/utils/conversationRecovery.ts` 中使用 `feature('KAIROS')` 条件导入 `BriefTool` 和 `SendUserFileTool`。在反序列化会话时识别这些工具的结果为"终端工具结果",判断 turn 是正常完成还是被中断。
|
|
||||||
|
|
||||||
### 持久 Cron 任务
|
|
||||||
|
|
||||||
关键在 `.claude/scheduled_tasks.json`。标记为 `permanent: true` 的任务不受 7 天自动过期限制:
|
|
||||||
|
|
||||||
- `catch-up`:恢复中断的工作
|
|
||||||
- `morning-checkin`:每日早间签到
|
|
||||||
- `dream`:记忆整合
|
|
||||||
|
|
||||||
### 会话历史 API
|
|
||||||
|
|
||||||
`src/assistant/sessionHistory.ts` 通过 OAuth API 加载远程会话历史,使用 `v1/sessions/{sessionId}/events` 端点,支持分页拉取。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 做梦机制(Dream)
|
|
||||||
|
|
||||||
KAIROS 最精巧的子系统——后台运行的子代理,将分散的会话记忆整合为持久的结构化知识。
|
|
||||||
|
|
||||||
### 触发条件(三层门控,由廉到贵)
|
|
||||||
|
|
||||||
定义在 `src/services/autoDream/autoDream.ts`:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 时间门控:距上次整合超过 24 小时(minHours)
|
|
||||||
2. 会话门控:至少 5 个新会话(minSessions)
|
|
||||||
3. 锁门控:没有其他进程正在整合
|
|
||||||
```
|
|
||||||
|
|
||||||
阈值通过 GrowthBook `tengu_onyx_plover` 远程配置动态控制。
|
|
||||||
|
|
||||||
### 四阶段整合流程
|
|
||||||
|
|
||||||
定义在 `src/services/autoDream/consolidationPrompt.ts`:
|
|
||||||
|
|
||||||
| 阶段 | 动作 |
|
|
||||||
|------|------|
|
|
||||||
| **Orient** | 列出记忆目录、读取 `MEMORY.md` 索引、浏览已有主题文件 |
|
|
||||||
| **Gather** | 从每日日志、已有记忆、JSONL transcript 中搜集新信号 |
|
|
||||||
| **Consolidate** | 合并新信号到主题文件,转换相对日期为绝对日期,删除过时事实 |
|
|
||||||
| **Prune** | 更新 `MEMORY.md` 索引,保持在行数和大小限制内 |
|
|
||||||
|
|
||||||
### 锁机制
|
|
||||||
|
|
||||||
`src/services/autoDream/consolidationLock.ts`:
|
|
||||||
|
|
||||||
- 使用 `.consolidate-lock` 文件
|
|
||||||
- 文件 mtime = `lastConsolidatedAt`
|
|
||||||
- 文件内容 = 持有者 PID
|
|
||||||
- 支持 PID 存活检查(1 小时超时)
|
|
||||||
- double-write 后 re-read 验证防竞争
|
|
||||||
|
|
||||||
### 每日日志
|
|
||||||
|
|
||||||
路径由 `src/memdir/paths.ts` 的 `getAutoMemDailyLogPath()` 计算:
|
|
||||||
|
|
||||||
```
|
|
||||||
<autoMemPath>/logs/YYYY/MM/YYYY-MM-DD.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### UI 呈现
|
|
||||||
|
|
||||||
- Footer pill 标签显示 **"dreaming"**
|
|
||||||
- `src/components/tasks/DreamDetailDialog.tsx` 提供专门的详情对话框
|
|
||||||
- 支持查看实时进度和手动中止
|
|
||||||
- `Shift+Down` 打开后台任务对话框
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 主动模式(Proactive Mode)
|
|
||||||
|
|
||||||
没人说话时 Claude 自己找活干。
|
|
||||||
|
|
||||||
### 核心状态
|
|
||||||
|
|
||||||
`src/proactive/index.ts` 维护三个状态:
|
|
||||||
|
|
||||||
| 状态 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `active` | 是否激活 |
|
|
||||||
| `paused` | 是否暂停(用户按 Esc 取消时暂停,下次输入恢复) |
|
|
||||||
| `contextBlocked` | API 错误时阻塞 tick,防止 tick-error-tick 死循环 |
|
|
||||||
|
|
||||||
### 激活方式
|
|
||||||
|
|
||||||
- `--proactive` CLI 参数
|
|
||||||
- `CLAUDE_CODE_PROACTIVE` 环境变量
|
|
||||||
- 受 `feature('PROACTIVE') || feature('KAIROS')` 保护
|
|
||||||
|
|
||||||
### 系统提示
|
|
||||||
|
|
||||||
激活后追加:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Proactive Mode
|
|
||||||
|
|
||||||
You are in proactive mode. Take initiative -- explore, act, and make progress
|
|
||||||
without waiting for instructions.
|
|
||||||
|
|
||||||
Start by briefly greeting the user.
|
|
||||||
|
|
||||||
You will receive periodic <tick> prompts. These are check-ins. Do whatever
|
|
||||||
seems most useful, or call Sleep if there's nothing to do.
|
|
||||||
```
|
|
||||||
|
|
||||||
### SleepTool 集成
|
|
||||||
|
|
||||||
设置中的 `minSleepDurationMs` 和 `maxSleepDurationMs` 控制 Sleep 持续时间范围,节流 proactive tick 频率。没活干就 Sleep 等着。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 后台任务管理
|
|
||||||
|
|
||||||
### Cron 调度器
|
|
||||||
|
|
||||||
`src/utils/cronScheduler.ts`:
|
|
||||||
|
|
||||||
- 每 1 秒 tick 一次(`CHECK_INTERVAL_MS = 1000`)
|
|
||||||
- 使用 chokidar 监视 `.claude/scheduled_tasks.json`
|
|
||||||
- 支持调度器锁(`src/utils/cronTasksLock.ts`),防止多实例重复触发
|
|
||||||
- 锁探测间隔 5 秒,持有者崩溃时自动接管
|
|
||||||
|
|
||||||
### 任务类型
|
|
||||||
|
|
||||||
| 类型 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 一次性(`recurring: false`) | 触发后自动删除,支持错过任务检测 |
|
|
||||||
| 循环(`recurring: true`) | 触发后重新调度,默认 7 天过期 |
|
|
||||||
| 永久(`permanent: true`) | 不受过期限制(KAIROS 专用) |
|
|
||||||
| 会话级(`durable: false`) | 仅内存中,进程退出即消失 |
|
|
||||||
|
|
||||||
### Jitter 防雷群机制
|
|
||||||
|
|
||||||
`src/utils/cronJitterConfig.ts`:
|
|
||||||
|
|
||||||
- 循环任务:基于 taskId 的确定性延迟(interval 的 10%,上限 15 分钟)
|
|
||||||
- 一次性任务:在 :00 和 :30 施加最多 90 秒提前量
|
|
||||||
- 运维可在事故期间推送配置变更,60 秒内全客户端生效
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键源码文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `src/bootstrap/state.ts` | KAIROS 全局状态 |
|
|
||||||
| `src/assistant/index.ts` | 助手模式入口 |
|
|
||||||
| `src/assistant/sessionHistory.ts` | 远程会话历史 API |
|
|
||||||
| `src/proactive/index.ts` | 主动模式状态管理 |
|
|
||||||
| `src/services/autoDream/autoDream.ts` | Auto-Dream 引擎 |
|
|
||||||
| `src/services/autoDream/consolidationPrompt.ts` | 整合提示(四阶段) |
|
|
||||||
| `src/services/autoDream/consolidationLock.ts` | 整合锁 |
|
|
||||||
| `src/services/autoDream/config.ts` | Dream 配置 |
|
|
||||||
| `src/tasks/DreamTask/DreamTask.ts` | Dream 任务定义 |
|
|
||||||
| `src/utils/cronScheduler.ts` | Cron 调度器 |
|
|
||||||
| `src/utils/cronTasks.ts` | Cron 任务持久化 |
|
|
||||||
| `src/skills/bundled/dream.ts` | `/dream` Skill(存根) |
|
|
||||||
283
AGENTS.md
283
AGENTS.md
@@ -1,283 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
|
||||||
|
|
||||||
## Git Commit Message Convention
|
|
||||||
|
|
||||||
使用 **Conventional Commits** 规范:
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>: <描述>
|
|
||||||
```
|
|
||||||
|
|
||||||
常见 type:`feat`、`fix`、`docs`、`chore`、`refactor`
|
|
||||||
|
|
||||||
示例:
|
|
||||||
- `feat: 添加模型 1M 上下文切换`
|
|
||||||
- `fix: 修复初次登陆的校验问题`
|
|
||||||
- `chore: remove prefetchOfficialMcpUrls call on startup`
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Dev mode (runs cli.tsx with MACRO defines injected via -d flags)
|
|
||||||
bun run dev
|
|
||||||
|
|
||||||
# Dev mode with debugger (set BUN_INSPECT=9229 to pick port)
|
|
||||||
bun run dev:inspect
|
|
||||||
|
|
||||||
# Pipe mode
|
|
||||||
echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
|
||||||
|
|
||||||
# Build (code splitting, outputs dist/cli.js + chunk files)
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# Test
|
|
||||||
bun test # run all tests (2453 tests / 137 files / 0 fail)
|
|
||||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
|
||||||
bun test --coverage # with coverage report
|
|
||||||
|
|
||||||
# Lint & Format (Biome)
|
|
||||||
bun run lint # check only
|
|
||||||
bun run lint:fix # auto-fix
|
|
||||||
bun run format # format all src/
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
bun run health
|
|
||||||
|
|
||||||
# Check unused exports
|
|
||||||
bun run check:unused
|
|
||||||
|
|
||||||
# Remote Control Server
|
|
||||||
bun run rcs
|
|
||||||
|
|
||||||
# Docs dev server (Mintlify)
|
|
||||||
bun run docs:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`。
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Runtime & Build
|
|
||||||
|
|
||||||
- **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 都可运行)。
|
|
||||||
- **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 — 14 个 internal packages in `packages/` resolved via `workspace:*`。
|
|
||||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
|
||||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
|
||||||
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
|
||||||
|
|
||||||
### Entry & Bootstrap
|
|
||||||
|
|
||||||
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
|
||||||
- `--version` / `-v` — 零模块加载
|
|
||||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
|
||||||
- `--Codex-in-chrome-mcp` / `--chrome-native-host`
|
|
||||||
- `--computer-use-mcp` — 独立 MCP server 模式
|
|
||||||
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
|
|
||||||
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
|
|
||||||
- `daemon` [subcommand] — feature-gated (DAEMON)
|
|
||||||
- `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS)
|
|
||||||
- `new` / `list` / `reply` — Template job commands
|
|
||||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
|
||||||
- `--tmux` + `--worktree` 组合
|
|
||||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
|
||||||
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
|
||||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
|
||||||
|
|
||||||
### Core Loop
|
|
||||||
|
|
||||||
- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
|
|
||||||
- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen.
|
|
||||||
- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts.
|
|
||||||
|
|
||||||
### API Layer
|
|
||||||
|
|
||||||
- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
|
|
||||||
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
|
|
||||||
- Provider selection in `src/utils/model/providers.ts`。优先级:modelType 参数 > 环境变量 > 默认 firstParty。
|
|
||||||
|
|
||||||
### Tool System
|
|
||||||
|
|
||||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
|
||||||
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
|
||||||
- **`src/tools/<ToolName>/`** — 55 个 tool 目录。主要分类:
|
|
||||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
|
||||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
|
||||||
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
|
||||||
- **规划**: EnterPlanModeTool, ExitPlanModeV2Tool, VerifyPlanExecutionTool
|
|
||||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
|
||||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
|
||||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
|
||||||
- **`src/tools/shared/`** — Tool 共享工具函数。
|
|
||||||
|
|
||||||
### UI Layer (Ink)
|
|
||||||
|
|
||||||
- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection.
|
|
||||||
- **`packages/@ant/ink/`** — Custom Ink framework(forked/internal),包含 components、core、hooks、keybindings、theme、utils。注意:不是 `src/ink/`。
|
|
||||||
- **`src/components/`** — 149 个组件目录/文件,渲染于终端 Ink 环境中。关键组件:
|
|
||||||
- `App.tsx` — Root provider (AppState, Stats, FpsMetrics)
|
|
||||||
- `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering
|
|
||||||
- `PromptInput/` — User input handling
|
|
||||||
- `permissions/` — Tool permission approval UI
|
|
||||||
- `design-system/` — 复用 UI 组件(Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等)
|
|
||||||
- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout.
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
|
|
||||||
- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc.
|
|
||||||
- **`src/state/AppStateStore.ts`** — Default state and store factory.
|
|
||||||
- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`).
|
|
||||||
- **`src/state/selectors.ts`** — State selectors.
|
|
||||||
- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode).
|
|
||||||
|
|
||||||
### Workspace Packages
|
|
||||||
|
|
||||||
| Package | 说明 |
|
|
||||||
|---------|------|
|
|
||||||
| `packages/@ant/ink/` | Forked Ink 框架(components、hooks、keybindings、theme) |
|
|
||||||
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server(截图/键鼠/剪贴板/应用管理) |
|
|
||||||
| `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) |
|
|
||||||
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) |
|
|
||||||
| `packages/@ant/Codex-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
|
||||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI) |
|
|
||||||
| `packages/swarm/` | Swarm 解耦模块 |
|
|
||||||
| `packages/shell/` | Shell 抽象 |
|
|
||||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
|
||||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
|
||||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
|
||||||
| `packages/modifiers-napi/` | 键盘修饰键检测(stub) |
|
|
||||||
| `packages/url-handler-napi/` | URL scheme 处理(stub) |
|
|
||||||
|
|
||||||
### Bridge / Remote Control
|
|
||||||
|
|
||||||
- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
|
||||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
|
|
||||||
- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`。
|
|
||||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
|
||||||
|
|
||||||
### Daemon Mode
|
|
||||||
|
|
||||||
- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。
|
|
||||||
|
|
||||||
### Context & System Prompt
|
|
||||||
|
|
||||||
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files).
|
|
||||||
- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy.
|
|
||||||
|
|
||||||
### Feature Flag System
|
|
||||||
|
|
||||||
Feature flags control which functionality is enabled at runtime. 代码中统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。
|
|
||||||
|
|
||||||
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`。
|
|
||||||
|
|
||||||
**Build 默认 features**(19 个,见 `build.ts`):
|
|
||||||
- 基础: `BUDDY`, `TRANSCRIPT_CLASSIFIER`, `BRIDGE_MODE`, `AGENT_TRIGGERS_REMOTE`, `CHICAGO_MCP`, `VOICE_MODE`
|
|
||||||
- 统计/缓存: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
|
|
||||||
- P0 本地: `AGENT_TRIGGERS`, `ULTRATHINK`, `BUILTIN_EXPLORE_PLAN_AGENTS`, `LODESTONE`
|
|
||||||
- P1 API 依赖: `EXTRACT_MEMORIES`, `VERIFICATION_AGENT`, `KAIROS_BRIEF`, `AWAY_SUMMARY`, `ULTRAPLAN`
|
|
||||||
- P2: `DAEMON`
|
|
||||||
|
|
||||||
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
|
|
||||||
|
|
||||||
**类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
|
|
||||||
|
|
||||||
**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。
|
|
||||||
|
|
||||||
### Multi-API 兼容层
|
|
||||||
|
|
||||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
|
|
||||||
|
|
||||||
#### OpenAI 兼容层
|
|
||||||
|
|
||||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
|
||||||
|
|
||||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
|
||||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
|
||||||
|
|
||||||
#### Gemini 兼容层
|
|
||||||
|
|
||||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
|
||||||
|
|
||||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
|
||||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
|
||||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
|
||||||
|
|
||||||
#### Grok 兼容层
|
|
||||||
|
|
||||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
|
||||||
|
|
||||||
- **`src/services/api/grok/`** — client、模型映射
|
|
||||||
|
|
||||||
详见各兼容层的 docs 文档。
|
|
||||||
|
|
||||||
### Stubbed/Deleted Modules
|
|
||||||
|
|
||||||
| Module | Status |
|
|
||||||
|--------|--------|
|
|
||||||
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux(后端完整度不一) |
|
|
||||||
| `*-napi` packages | `audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`、`url-handler-napi` 仍为 stub |
|
|
||||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
|
||||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
|
||||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
|
||||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
|
||||||
| Magic Docs / LSP Server | Removed |
|
|
||||||
| Plugins / Marketplace | Removed |
|
|
||||||
| MCP OAuth | Simplified |
|
|
||||||
|
|
||||||
### Key Type Files
|
|
||||||
|
|
||||||
- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers.
|
|
||||||
- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`.
|
|
||||||
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
|
|
||||||
- **`src/types/permissions.ts`** — Permission mode and result types.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- **框架**: `bun:test`(内置断言 + mock)
|
|
||||||
- **当前状态**: 2472 tests / 138 files / 0 fail
|
|
||||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
|
||||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
|
||||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
|
||||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
|
||||||
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
|
|
||||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
|
||||||
|
|
||||||
### 类型检查
|
|
||||||
|
|
||||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bunx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
**类型规范**:
|
|
||||||
- 生产代码禁止 `as any`;测试文件中 mock 数据可用 `as any`
|
|
||||||
- 类型不匹配优先用 `as unknown as SpecificType` 双重断言,或补充 interface
|
|
||||||
- 未知结构对象用 `Record<string, unknown>` 替代 `any`
|
|
||||||
- 联合类型用类型守卫(type guard)收窄,不要强转
|
|
||||||
- `msg.request` 属性访问:`const req = msg.request as Record<string, unknown>`
|
|
||||||
- Ink `color` prop:用 `as keyof Theme` 而非 `as any`
|
|
||||||
|
|
||||||
## Working with This Codebase
|
|
||||||
|
|
||||||
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
|
|
||||||
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
|
||||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
|
||||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
|
||||||
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
|
|
||||||
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
|
||||||
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
|
||||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
|
||||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
|
||||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
|
||||||
115
CLAUDE.md
115
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。
|
||||||
|
|
||||||
## Git Commit Message Convention
|
## Git Commit Message Convention
|
||||||
|
|
||||||
@@ -39,8 +39,11 @@ echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
|||||||
# Build (code splitting, outputs dist/cli.js + chunk files)
|
# Build (code splitting, outputs dist/cli.js + chunk files)
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
|
# Build with Vite (alternative build pipeline)
|
||||||
|
bun run build:vite
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
bun test # run all tests (2453 tests / 137 files / 0 fail)
|
bun test # run all tests (3175 tests / 207 files / 0 fail)
|
||||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||||
bun test --coverage # with coverage report
|
bun test --coverage # with coverage report
|
||||||
|
|
||||||
@@ -55,6 +58,8 @@ bun run health
|
|||||||
# Check unused exports
|
# Check unused exports
|
||||||
bun run check:unused
|
bun run check:unused
|
||||||
|
|
||||||
|
bun run typecheck
|
||||||
|
|
||||||
# Remote Control Server
|
# Remote Control Server
|
||||||
bun run rcs
|
bun run rcs
|
||||||
|
|
||||||
@@ -72,14 +77,14 @@ bun run docs:dev
|
|||||||
- **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 都可运行)。
|
- **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 都可运行)。
|
||||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
- **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.
|
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||||
- **Monorepo**: Bun workspaces — 14 个 internal packages in `packages/` resolved via `workspace:*`。
|
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||||
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||||
|
|
||||||
### Entry & Bootstrap
|
### Entry & Bootstrap
|
||||||
|
|
||||||
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||||
- `--version` / `-v` — 零模块加载
|
- `--version` / `-v` — 零模块加载
|
||||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||||
@@ -92,7 +97,7 @@ bun run docs:dev
|
|||||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
||||||
- `--tmux` + `--worktree` 组合
|
- `--tmux` + `--worktree` 组合
|
||||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
||||||
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
||||||
|
|
||||||
### Core Loop
|
### Core Loop
|
||||||
@@ -110,8 +115,8 @@ bun run docs:dev
|
|||||||
### Tool System
|
### Tool System
|
||||||
|
|
||||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||||
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||||
- **`src/tools/<ToolName>/`** — 55 个 tool 目录。主要分类:
|
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||||
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
||||||
@@ -119,7 +124,6 @@ bun run docs:dev
|
|||||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||||
- **`src/tools/shared/`** — Tool 共享工具函数。
|
|
||||||
|
|
||||||
### UI Layer (Ink)
|
### UI Layer (Ink)
|
||||||
|
|
||||||
@@ -150,9 +154,17 @@ bun run docs:dev
|
|||||||
| `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) |
|
| `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) |
|
||||||
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) |
|
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) |
|
||||||
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
||||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI) |
|
| `packages/@ant/model-provider/` | Model provider 抽象层 |
|
||||||
| `packages/swarm/` | Swarm 解耦模块 |
|
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||||
| `packages/shell/` | Shell 抽象 |
|
| `packages/agent-tools/` | Agent 工具集 |
|
||||||
|
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||||
|
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
|
||||||
|
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
|
||||||
|
| `packages/mcp-client/` | MCP 客户端库 |
|
||||||
|
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
|
||||||
|
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||||
|
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||||
|
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||||
@@ -161,11 +173,18 @@ bun run docs:dev
|
|||||||
|
|
||||||
### Bridge / Remote Control
|
### Bridge / Remote Control
|
||||||
|
|
||||||
- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
|
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||||
|
|
||||||
|
### ACP Protocol (Agent Client Protocol)
|
||||||
|
|
||||||
|
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
||||||
|
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成(REST 注册 + WS identify 两步流程)、权限模式透传(fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
|
||||||
|
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
|
||||||
|
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示(PlanView 组件,含进度条/状态图标/优先级标签)。
|
||||||
|
|
||||||
### Daemon Mode
|
### Daemon Mode
|
||||||
|
|
||||||
- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。
|
- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。
|
||||||
@@ -196,30 +215,13 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
|||||||
|
|
||||||
### Multi-API 兼容层
|
### Multi-API 兼容层
|
||||||
|
|
||||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
|
支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。
|
||||||
|
|
||||||
#### OpenAI 兼容层
|
### 穷鬼模式(Budget Mode)
|
||||||
|
|
||||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
- 通过 `/poor` 命令切换,持久化到 `settings.json`。
|
||||||
|
- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。
|
||||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
- 实现在 `src/commands/poor/poorMode.ts`。
|
||||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
|
||||||
|
|
||||||
#### Gemini 兼容层
|
|
||||||
|
|
||||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
|
||||||
|
|
||||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
|
||||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
|
||||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
|
||||||
|
|
||||||
#### Grok 兼容层
|
|
||||||
|
|
||||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
|
||||||
|
|
||||||
- **`src/services/api/grok/`** — client、模型映射
|
|
||||||
|
|
||||||
详见各兼容层的 docs 文档。
|
|
||||||
|
|
||||||
### Stubbed/Deleted Modules
|
### Stubbed/Deleted Modules
|
||||||
|
|
||||||
@@ -245,20 +247,29 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- **框架**: `bun:test`(内置断言 + mock)
|
- **框架**: `bun:test`(内置断言 + mock)
|
||||||
- **当前状态**: 2472 tests / 138 files / 0 fail
|
- **当前状态**: 3175 tests / 207 files / 0 fail
|
||||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||||
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
|
|
||||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||||
|
|
||||||
|
### Mock 使用规范
|
||||||
|
|
||||||
|
**只 mock 有副作用的依赖链,不 mock 纯函数/纯数据模块。**
|
||||||
|
|
||||||
|
被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。
|
||||||
|
|
||||||
|
不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
|
||||||
|
|
||||||
|
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||||
|
|
||||||
### 类型检查
|
### 类型检查
|
||||||
|
|
||||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bunx tsc --noEmit
|
bun run typecheck # equivalent to bun run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
**类型规范**:
|
**类型规范**:
|
||||||
@@ -271,7 +282,7 @@ bunx tsc --noEmit
|
|||||||
|
|
||||||
## Working with This Codebase
|
## Working with This Codebase
|
||||||
|
|
||||||
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
|
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
|
||||||
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
||||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
||||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
||||||
@@ -281,3 +292,29 @@ bunx tsc --noEmit
|
|||||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||||
|
|
||||||
|
## Design Context
|
||||||
|
|
||||||
|
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。
|
||||||
|
|
||||||
|
### 核心设计原则
|
||||||
|
|
||||||
|
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
|
||||||
|
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
|
||||||
|
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
|
||||||
|
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
|
||||||
|
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
|
||||||
|
|
||||||
|
### 品牌色
|
||||||
|
|
||||||
|
- 主色:Claude Orange `#D77757`(terra cotta)
|
||||||
|
- 辅色:Claude Blue `#5769F7`
|
||||||
|
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
|
||||||
|
|
||||||
|
### 目标用户
|
||||||
|
|
||||||
|
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
|
||||||
|
|
||||||
|
### 视觉参考
|
||||||
|
|
||||||
|
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -10,27 +10,25 @@
|
|||||||
|
|
||||||
> Which Claude do you like? The open source one is the best.
|
> 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(踩踩背)...
|
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||||
|
|
||||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
|
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
|
||||||
|
|
||||||
| 特性 | 说明 | 文档 |
|
| 特性 | 说明 | 文档 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||||
| Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||||
| Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||||
| 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||||
|
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
||||||
|
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
||||||
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||||
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
||||||
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [魔改版](docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
||||||
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
|
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
|
||||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||||
| Langfuse 监控 | LLM 调用/工具执行/多 Agent 全链路追踪 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||||
| Poor Mode | 穷鬼模式,关闭记忆提取和键入建议 | /poor 可以开关 |
|
|
||||||
|
|
||||||
|
|
||||||
- 🔮 [ ] V6 — 大规模重构石山代码,全面模块分包(全新分支,main 封存为历史版本)
|
|
||||||
|
|
||||||
- 🚀 [想要启动项目](#快速开始源码版)
|
- 🚀 [想要启动项目](#快速开始源码版)
|
||||||
- 🐛 [想要调试项目](#vs-code-调试)
|
- 🐛 [想要调试项目](#vs-code-调试)
|
||||||
|
|||||||
64
build.ts
64
build.ts
@@ -30,6 +30,8 @@ const DEFAULT_BUILD_FEATURES = [
|
|||||||
'ULTRAPLAN',
|
'ULTRAPLAN',
|
||||||
// P2: daemon + remote control server
|
// P2: daemon + remote control server
|
||||||
'DAEMON',
|
'DAEMON',
|
||||||
|
// ACP (Agent Client Protocol) agent mode
|
||||||
|
'ACP',
|
||||||
// PR-package restored features
|
// PR-package restored features
|
||||||
'WORKFLOW_SCRIPTS',
|
'WORKFLOW_SCRIPTS',
|
||||||
'HISTORY_SNIP',
|
'HISTORY_SNIP',
|
||||||
@@ -90,8 +92,27 @@ for (const file of files) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also patch unguarded globalThis.Bun destructuring from third-party deps
|
||||||
|
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
|
||||||
|
let bunPatched = 0
|
||||||
|
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
||||||
|
const BUN_DESTRUCTURE_SAFE = 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.js')) continue
|
||||||
|
const filePath = join(outdir, file)
|
||||||
|
const content = await readFile(filePath, 'utf-8')
|
||||||
|
if (BUN_DESTRUCTURE.test(content)) {
|
||||||
|
await writeFile(
|
||||||
|
filePath,
|
||||||
|
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||||
|
)
|
||||||
|
bunPatched++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BUN_DESTRUCTURE.lastIndex = 0
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`,
|
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for import.meta.require, ${bunPatched} for Bun destructure)`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 4: Copy native .node addon files (audio-capture)
|
// Step 4: Copy native .node addon files (audio-capture)
|
||||||
@@ -121,46 +142,7 @@ const cliNode = join(outdir, 'cli-node.js')
|
|||||||
|
|
||||||
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n')
|
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n')
|
||||||
|
|
||||||
// Node.js entry needs a Bun API polyfill because Bun.build({ target: 'bun' })
|
await writeFile(cliNode, '#!/usr/bin/env node\nimport "./cli.js"\n')
|
||||||
// emits globalThis.Bun references (e.g. Bun.$ shell tag in computer-use-input,
|
|
||||||
// Bun.which in chunk-ys6smqg9) that crash at import time under plain Node.js.
|
|
||||||
const NODE_BUN_POLYFILL = `#!/usr/bin/env node
|
|
||||||
// Bun API polyfill for Node.js runtime
|
|
||||||
if (typeof globalThis.Bun === "undefined") {
|
|
||||||
const { execFileSync } = await import("child_process");
|
|
||||||
const { resolve, delimiter } = await import("path");
|
|
||||||
const { accessSync, constants: { X_OK } } = await import("fs");
|
|
||||||
function which(bin) {
|
|
||||||
const isWin = process.platform === "win32";
|
|
||||||
const pathExt = isWin ? (process.env.PATHEXT || ".EXE").split(";") : [""];
|
|
||||||
for (const dir of (process.env.PATH || "").split(delimiter)) {
|
|
||||||
for (const ext of pathExt) {
|
|
||||||
const candidate = resolve(dir, bin + ext);
|
|
||||||
try { accessSync(candidate, X_OK); return candidate; } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Bun.$ is the shell template tag (e.g. $\`osascript ...\`). Only used by
|
|
||||||
// computer-use-input/darwin — stub it so the top-level destructuring
|
|
||||||
// \`var { $ } = globalThis.Bun\` doesn't crash.
|
|
||||||
function $(parts, ...args) {
|
|
||||||
throw new Error("Bun.$ shell API is not available in Node.js. Use Bun runtime for this feature.");
|
|
||||||
}
|
|
||||||
function hash(data, seed) {
|
|
||||||
let h = ((seed || 0) ^ 0x811c9dc5) >>> 0;
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
h ^= data.charCodeAt(i);
|
|
||||||
h = Math.imul(h, 0x01000193) >>> 0;
|
|
||||||
}
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
globalThis.Bun = { which, $, hash };
|
|
||||||
}
|
|
||||||
import "./cli.js"
|
|
||||||
`
|
|
||||||
await writeFile(cliNode, NODE_BUN_POLYFILL)
|
|
||||||
// NOTE: when new Bun-specific globals appear in bundled output, add them here.
|
|
||||||
|
|
||||||
// Make both executable
|
// Make both executable
|
||||||
const { chmodSync } = await import('fs')
|
const { chmodSync } = await import('fs')
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
17
docs/diagrams/agent-loop-simple.mmd
Normal file
17
docs/diagrams/agent-loop-simple.mmd
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
flowchart TB
|
||||||
|
START((输入)) --> CTX["Context 管理"]
|
||||||
|
CTX --> LLM["LLM 流式输出"]
|
||||||
|
LLM --> TC{tool_use?}
|
||||||
|
|
||||||
|
TC --> |是| EXEC["执行工具"]
|
||||||
|
EXEC --> CTX
|
||||||
|
|
||||||
|
TC --> |否| DONE((完成))
|
||||||
|
|
||||||
|
classDef proc fill:#eef,stroke:#66c,color:#224
|
||||||
|
classDef decision fill:#fee,stroke:#c66,color:#422
|
||||||
|
classDef io fill:#eff,stroke:#6cc,color:#244
|
||||||
|
|
||||||
|
class CTX,LLM,EXEC proc
|
||||||
|
class TC decision
|
||||||
|
class START,DONE io
|
||||||
40
docs/diagrams/agent-loop.mmd
Normal file
40
docs/diagrams/agent-loop.mmd
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
flowchart TB
|
||||||
|
START((输入)) --> CTX["Context 管理"]
|
||||||
|
CTX --> PRE["Pre-sampling Hook"]
|
||||||
|
PRE --> LLM["LLM 流式输出"]
|
||||||
|
LLM --> TC{tool_use?}
|
||||||
|
|
||||||
|
TC --> |是| PERM{需权限?}
|
||||||
|
PERM --> |是| USER["👤 用户审批"]
|
||||||
|
USER --> |allow| TOOL_PRE
|
||||||
|
USER --> |deny| DENIED["拒绝"]
|
||||||
|
PERM --> |否| TOOL_PRE["Pre-tool Hook"]
|
||||||
|
TOOL_PRE --> EXEC["并发执行工具"]
|
||||||
|
EXEC --> TOOL_POST["Post-tool Hook"]
|
||||||
|
TOOL_POST --> CTX
|
||||||
|
DENIED --> CTX
|
||||||
|
|
||||||
|
TC --> |否| POST["Post-sampling Hook"]
|
||||||
|
POST --> STOP{"Stop Hook"}
|
||||||
|
STOP --> |不通过| CTX
|
||||||
|
STOP --> |通过| BUDGET{"Token Budget"}
|
||||||
|
BUDGET --> |继续| CTX
|
||||||
|
BUDGET --> |完成| DONE((完成))
|
||||||
|
|
||||||
|
subgraph SUB["子 Agent"]
|
||||||
|
FORK["AgentTool"] --> RECURSE["递归调用"]
|
||||||
|
end
|
||||||
|
|
||||||
|
EXEC -.-> FORK
|
||||||
|
|
||||||
|
classDef proc fill:#eef,stroke:#66c,color:#224
|
||||||
|
classDef decision fill:#fee,stroke:#c66,color:#422
|
||||||
|
classDef hook fill:#ffe,stroke:#cc6,color:#442
|
||||||
|
classDef io fill:#eff,stroke:#6cc,color:#244
|
||||||
|
classDef sub fill:#efe,stroke:#6a6,color:#242
|
||||||
|
|
||||||
|
class CTX,LLM,EXEC proc
|
||||||
|
class TC,PERM,STOP,BUDGET decision
|
||||||
|
class PRE,TOOL_PRE,TOOL_POST,POST hook
|
||||||
|
class START,DONE,USER,DENIED io
|
||||||
|
class FORK,RECURSE sub
|
||||||
205
docs/features/acp-link.md
Normal file
205
docs/features/acp-link.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# acp-link — ACP 代理服务器
|
||||||
|
|
||||||
|
> 源码目录:`packages/acp-link/`
|
||||||
|
> PR: #292
|
||||||
|
> 新增时间:2026-04-18
|
||||||
|
|
||||||
|
## 一、功能概述
|
||||||
|
|
||||||
|
`acp-link` 是一个 ACP (Agent Client Protocol) 代理服务器,将 WebSocket 客户端桥接到 ACP agent 的 stdio 接口。它让 ACP agent(如 Claude Code)可以通过 WebSocket 远程访问,而不仅限于本地 stdio。
|
||||||
|
|
||||||
|
### 核心特性
|
||||||
|
|
||||||
|
- **WebSocket → stdio 桥接**:将浏览器/远程客户端的 WebSocket 连接转换为 ACP agent 的 stdin/stdout NDJSON 流
|
||||||
|
- **会话管理**:创建、加载、恢复、列出、关闭会话
|
||||||
|
- **权限审批流程**:客户端可远程审批 agent 的工具权限请求
|
||||||
|
- **RCS 集成**:可与 Remote Control Server (RCS) 连接,将 ACP agent 注册到 RCS 并通过 Web UI 交互
|
||||||
|
- **HTTPS 支持**:内置自签名证书生成,支持安全连接
|
||||||
|
- **Token 认证**:自动生成或通过环境变量配置认证 token
|
||||||
|
|
||||||
|
## 二、架构
|
||||||
|
|
||||||
|
### 独立模式
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐
|
||||||
|
│ 浏览器/客户端 │ ◄──────────────►│ acp-link │ ◄────────────────►│ ACP Agent │
|
||||||
|
│ (WS Client) │ ws://host:port │ (Proxy Server) │ spawn subprocess │ (Claude等) │
|
||||||
|
└──────────────────┘ └──────────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### RCS 集成模式
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐
|
||||||
|
│ RCS Web UI │ ◄──────────────►│ Remote Control │ ◄─────────────────►│ acp-link │
|
||||||
|
│ (/code/*) │ ACP Relay WS │ Server (RCS) │ ACP events │ + Agent │
|
||||||
|
└──────────────┘ └──────────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/acp-link/
|
||||||
|
├── src/
|
||||||
|
│ ├── server.ts # 主服务器:WS 连接管理、会话管理、权限处理、消息桥接
|
||||||
|
│ ├── rcs-upstream.ts # RCS 上游客户端:REST 注册 + WS identify 两步流程
|
||||||
|
│ ├── cert.ts # TLS 证书生成(自签名)
|
||||||
|
│ ├── logger.ts # 日志模块
|
||||||
|
│ ├── types.ts # JSON-RPC 和 ACP 协议类型定义
|
||||||
|
│ ├── cli/
|
||||||
|
│ │ ├── bin.ts # CLI 入口
|
||||||
|
│ │ ├── command.ts # 命令行参数解析
|
||||||
|
│ │ ├── app.ts # 应用启动
|
||||||
|
│ │ └── context.ts # 上下文配置
|
||||||
|
│ └── __tests__/ # 测试(cert, server, types)
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、安装与使用
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 直接运行(在 monorepo 中)
|
||||||
|
# 注意:claude 本身不支持 ACP,需要用 ccb-bun --acp 启动 ACP agent
|
||||||
|
bun packages/acp-link/src/cli/bin.ts ccb-bun -- --acp
|
||||||
|
|
||||||
|
# 指定端口和主机
|
||||||
|
acp-link --port 9000 --host 0.0.0.0 ccb-bun -- --acp
|
||||||
|
|
||||||
|
# 启用 HTTPS(自签名证书)
|
||||||
|
acp-link --https ccb-bun -- --acp
|
||||||
|
|
||||||
|
# 调试模式
|
||||||
|
acp-link --debug ccb-bun -- --acp
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI 参考
|
||||||
|
|
||||||
|
```
|
||||||
|
USAGE
|
||||||
|
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
|
||||||
|
acp-link --help
|
||||||
|
acp-link --version
|
||||||
|
|
||||||
|
FLAGS
|
||||||
|
[--port] Port to listen on [default = 9315]
|
||||||
|
[--host] Host to bind to [default = localhost]
|
||||||
|
[--debug] Enable debug logging to file
|
||||||
|
[--no-auth] Disable authentication (dangerous)
|
||||||
|
[--https] Enable HTTPS with self-signed cert
|
||||||
|
-h --help Print help information and exit
|
||||||
|
-v --version Print version information and exit
|
||||||
|
|
||||||
|
ARGUMENTS
|
||||||
|
command... Agent command followed by its arguments (e.g. "ccb-bun -- --acp")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、认证
|
||||||
|
|
||||||
|
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://localhost:9315/ws?token=<your-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
配置固定 token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ACP_AUTH_TOKEN=my-fixed-token acp-link ccb-bun -- --acp
|
||||||
|
```
|
||||||
|
|
||||||
|
禁用认证(不推荐,仅用于开发):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
acp-link --no-auth ccb-bun -- --acp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、RCS 集成
|
||||||
|
|
||||||
|
acp-link 支持将 ACP agent 注册到 Remote Control Server,通过 Web UI 远程操控。
|
||||||
|
|
||||||
|
### 连接方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 通过环境变量配置 RCS 连接
|
||||||
|
ACP_RCS_URL=http://localhost:3000 \
|
||||||
|
ACP_RCS_TOKEN=sk-rcs-your-key \
|
||||||
|
ACP_RCS_NAME=my-agent \
|
||||||
|
acp-link ccb-bun -- --acp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注册流程(两步)
|
||||||
|
|
||||||
|
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
|
||||||
|
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId),替代完整 `register`
|
||||||
|
|
||||||
|
```
|
||||||
|
acp-link RCS
|
||||||
|
│ │
|
||||||
|
│── POST /v1/environments/bridge ──►│ (REST 注册)
|
||||||
|
│◄── { agentId, sessionId } ───────│
|
||||||
|
│ │
|
||||||
|
│── WS connect ─────────────────►│ (WebSocket)
|
||||||
|
│── identify { agentId } ────────►│ (WS 标识)
|
||||||
|
│◄── registered ─────────────────│
|
||||||
|
│ │
|
||||||
|
│── ACP events ─────────────────►│ (双向消息转发)
|
||||||
|
│◄── user prompts/permissions ───│
|
||||||
|
```
|
||||||
|
|
||||||
|
## 六、权限模式
|
||||||
|
|
||||||
|
### permissionMode 传递链
|
||||||
|
|
||||||
|
权限模式通过整条链路传递:Web UI → RCS → acp-link → ACP agent。
|
||||||
|
|
||||||
|
支持的权限模式:
|
||||||
|
- `default` — 每次请求权限确认
|
||||||
|
- `auto` — 自动判断
|
||||||
|
- `acceptEdits` — 自动接受编辑
|
||||||
|
- `plan` — 规划模式
|
||||||
|
- `dontAsk` — 不询问
|
||||||
|
- `bypassPermissions` — 绕过权限(需 sandbox 环境)
|
||||||
|
|
||||||
|
### fallback 链
|
||||||
|
|
||||||
|
当客户端未显式传递 permissionMode 时,使用以下 fallback 链:
|
||||||
|
|
||||||
|
```
|
||||||
|
客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ACP_PERMISSION_MODE=auto acp-link ccb-bun -- --acp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 七、权限管道(2026-04-18 改进)
|
||||||
|
|
||||||
|
### 模式同步
|
||||||
|
|
||||||
|
`applySessionMode` 在 agent 切换权限模式时同步 `appState.toolPermissionContext.mode`,确保内部权限上下文与 ACP 客户端状态一致。
|
||||||
|
|
||||||
|
### 统一权限流水线
|
||||||
|
|
||||||
|
`createAcpCanUseTool` 接入 `hasPermissionsToUseTool` 统一权限流水线,替代原来分散的处理逻辑。支持 `onModeChange` 回调,模式变更时实时同步。
|
||||||
|
|
||||||
|
### bypass 检测
|
||||||
|
|
||||||
|
`bypassPermissions` 模式增加可用性检测 — 仅在非 root 或 sandbox 环境中允许启用,防止权限绕过的安全风险。
|
||||||
|
|
||||||
|
## 八、环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `ACP_AUTH_TOKEN` | 固定认证 token(默认自动生成) |
|
||||||
|
| `ACP_PERMISSION_MODE` | 默认权限模式 fallback |
|
||||||
|
| `ACP_RCS_URL` | RCS 服务器地址(启用 RCS 集成) |
|
||||||
|
| `ACP_RCS_TOKEN` | RCS API token |
|
||||||
|
| `ACP_RCS_NAME` | Agent 名称(在 RCS 中显示) |
|
||||||
|
| `ACP_RCS_CHANNEL_GROUP` | Channel group ID |
|
||||||
|
| `ACP_MAX_SESSIONS` | 最大会话数 |
|
||||||
189
docs/features/acp-zed.md
Normal file
189
docs/features/acp-zed.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# ACP (Agent Client Protocol) — Zed / IDE 集成
|
||||||
|
|
||||||
|
> Feature Flag: `FEATURE_ACP=1`(build 和 dev 模式默认启用)
|
||||||
|
> 实现状态:可用(支持 Zed、Cursor 等 ACP 客户端)
|
||||||
|
> 源码目录:`src/services/acp/`
|
||||||
|
|
||||||
|
## 一、功能概述
|
||||||
|
|
||||||
|
ACP (Agent Client Protocol) 是一种标准化的 stdio 协议,允许 IDE 和编辑器通过 stdin/stdout 的 NDJSON 流驱动 AI Agent。CCB 实现了完整的 ACP agent 端,可以被 Zed、Cursor 等支持 ACP 的客户端直接调用。
|
||||||
|
|
||||||
|
### 核心特性
|
||||||
|
|
||||||
|
- **会话管理**:新建 / 恢复 / 加载 / 分叉 / 关闭会话
|
||||||
|
- **历史回放**:恢复会话时自动加载并回放对话历史
|
||||||
|
- **权限桥接**:ACP 客户端的权限决策映射到 CCB 的工具权限系统
|
||||||
|
- **斜杠命令 & Skills**:加载真实命令列表,支持 `/commit`、`/review` 等 prompt 型 skill
|
||||||
|
- **Context Window 跟踪**:精确的 usage_update,含 model prefix matching
|
||||||
|
- **Prompt 排队**:支持连续发送多条 prompt,自动排队处理
|
||||||
|
- **模式切换**:auto / default / acceptEdits / plan / dontAsk / bypassPermissions
|
||||||
|
- **模型切换**:运行时切换 AI 模型
|
||||||
|
|
||||||
|
## 二、架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ NDJSON/stdio ┌──────────────────┐
|
||||||
|
│ Zed / IDE │ ◄────────────────► │ CCB ACP Agent │
|
||||||
|
│ (Client) │ stdin / stdout │ (Agent) │
|
||||||
|
└──────────────┘ │ │
|
||||||
|
│ entry.ts │ ← stdio → NDJSON stream
|
||||||
|
│ agent.ts │ ← ACP protocol handler
|
||||||
|
│ bridge.ts │ ← SDKMessage → ACP SessionUpdate
|
||||||
|
│ permissions.ts │ ← 权限桥接
|
||||||
|
│ utils.ts │ ← 通用工具
|
||||||
|
│ │
|
||||||
|
│ QueryEngine │ ← 内部查询引擎
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件职责
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `entry.ts` | 入口,创建 stdio → NDJSON stream,启动 `AgentSideConnection` |
|
||||||
|
| `agent.ts` | 实现 ACP `Agent` 接口:会话 CRUD、prompt、cancel、模式/模型切换 |
|
||||||
|
| `bridge.ts` | `SDKMessage` → ACP `SessionUpdate` 转换:文本/思考/工具/用量/编辑 diff |
|
||||||
|
| `permissions.ts` | ACP `requestPermission()` → CCB `CanUseToolFn` 桥接 |
|
||||||
|
| `utils.ts` | Pushable、流转换、权限模式解析、session fingerprint、路径显示 |
|
||||||
|
|
||||||
|
## 三、配置 Zed 编辑器
|
||||||
|
|
||||||
|
### 3.1 Zed settings.json 配置
|
||||||
|
|
||||||
|
打开 Zed 的 `settings.json`(`Cmd+,` → Open Settings),添加 `agent_servers` 配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"ccb": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "ccb",
|
||||||
|
"args": ["--acp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 API 认证配置
|
||||||
|
|
||||||
|
CCB 的 ACP agent 在启动时会自动加载 `settings.json` 中的环境变量(`ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN` 等)。确保已通过 `/login` 配置好 API 供应商。
|
||||||
|
|
||||||
|
也可通过环境变量传入:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"claude-code": {
|
||||||
|
"command": "ccb",
|
||||||
|
"args": ["--acp"],
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.example.com/v1",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 在 Zed 中使用
|
||||||
|
|
||||||
|
1. 配置完成后重启 Zed
|
||||||
|
2. 打开任意项目目录
|
||||||
|
3. 按 `Cmd+'`(macOS)或 `Ctrl+'`(Linux)打开 Agent Panel
|
||||||
|
4. 在 Agent Panel 顶部的下拉菜单中选择 **claude-code**
|
||||||
|
5. 开始对话
|
||||||
|
|
||||||
|
### 3.5 功能说明
|
||||||
|
|
||||||
|
| 功能 | 操作 |
|
||||||
|
|------|------|
|
||||||
|
| 对话 | 在 Agent Panel 中直接输入消息 |
|
||||||
|
| 斜杠命令 | 输入 `/` 查看可用 skills 列表(如 `/commit`、`/review`) |
|
||||||
|
| 工具权限 | 弹出权限请求时选择 Allow / Reject / Always Allow |
|
||||||
|
| 模式切换 | 通过 Agent Panel 的设置菜单切换 auto/default/plan 等模式 |
|
||||||
|
| 模型切换 | 通过 Agent Panel 的设置菜单切换 AI 模型 |
|
||||||
|
| 会话恢复 | 关闭重开 Zed 后,之前的会话可自动恢复(含历史消息) |
|
||||||
|
|
||||||
|
## 四、配置其他 ACP 客户端
|
||||||
|
|
||||||
|
ACP 是开放协议,任何支持 ACP 的客户端都可以连接 CCB。通用配置模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
命令: ccb --acp
|
||||||
|
参数: ["--acp"]
|
||||||
|
通信: stdin/stdout NDJSON
|
||||||
|
协议版本: ACP v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Cursor
|
||||||
|
|
||||||
|
在 Cursor 的设置中配置 MCP / Agent Server,使用同样的 `ccb --acp` 命令。
|
||||||
|
|
||||||
|
### 4.2 自定义客户端
|
||||||
|
|
||||||
|
使用 `@agentclientprotocol/sdk` 可以快速构建 ACP 客户端:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'
|
||||||
|
|
||||||
|
// 创建连接(将 ccb --acp 作为子进程启动)
|
||||||
|
const child = spawn('ccb', ['--acp'])
|
||||||
|
const stream = ndJsonStream(
|
||||||
|
Writable.toWeb(child.stdin),
|
||||||
|
Readable.toWeb(child.stdout),
|
||||||
|
)
|
||||||
|
|
||||||
|
const client = new ClientSideConnection(stream)
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
await client.initialize({ clientCapabilities: {} })
|
||||||
|
|
||||||
|
// 创建会话
|
||||||
|
const { sessionId } = await client.newSession({
|
||||||
|
cwd: '/path/to/project',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送 prompt
|
||||||
|
const response = await client.prompt({
|
||||||
|
sessionId,
|
||||||
|
prompt: [{ type: 'text', text: 'Hello, explain this project' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 session 更新
|
||||||
|
client.on('sessionUpdate', (update) => {
|
||||||
|
console.log('Update:', update)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、ACP 协议支持矩阵
|
||||||
|
|
||||||
|
| 方法 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `initialize` | ✅ | 返回 agent 信息和能力 |
|
||||||
|
| `authenticate` | ✅ | 无需认证(自托管) |
|
||||||
|
| `newSession` | ✅ | 创建新会话 |
|
||||||
|
| `resumeSession` | ✅ | 恢复已有会话(含历史回放) |
|
||||||
|
| `loadSession` | ✅ | 加载指定会话(含历史回放) |
|
||||||
|
| `listSessions` | ✅ | 列出可用会话 |
|
||||||
|
| `forkSession` | ✅ | 分叉会话 |
|
||||||
|
| `closeSession` | ✅ | 关闭会话 |
|
||||||
|
| `prompt` | ✅ | 发送消息,支持排队 |
|
||||||
|
| `cancel` | ✅ | 取消当前/排队的 prompt |
|
||||||
|
| `setSessionMode` | ✅ | 切换权限模式 |
|
||||||
|
| `setSessionModel` | ✅ | 切换 AI 模型 |
|
||||||
|
| `setSessionConfigOption` | ✅ | 动态修改配置 |
|
||||||
|
|
||||||
|
### SessionUpdate 类型
|
||||||
|
|
||||||
|
| 类型 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `agent_message_chunk` | ✅ | 助手文本消息 |
|
||||||
|
| `agent_thought_chunk` | ✅ | 思考/推理内容 |
|
||||||
|
| `user_message_chunk` | ✅ | 用户消息(历史回放) |
|
||||||
|
| `tool_call` | ✅ | 工具调用开始 |
|
||||||
|
| `tool_call_update` | ✅ | 工具调用结果/状态更新 |
|
||||||
|
| `usage_update` | ✅ | token 用量 + context window |
|
||||||
|
| `plan` | ✅ | TodoWrite → plan entries |
|
||||||
|
| `available_commands_update` | ✅ | 斜杠命令 & skills 列表 |
|
||||||
|
| `current_mode_update` | ✅ | 模式切换通知 |
|
||||||
|
| `config_option_update` | ✅ | 配置更新通知 |
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
### 第一步:安装 Chrome 扩展
|
### 第一步:安装 Chrome 扩展
|
||||||
|
|
||||||
1. 下载扩展:https://github.com/hangwin/mcp-chrome/releases(下载最新 zip)
|
1. 下载扩展:https://github.com/hangwin/mcp-chrome/releases
|
||||||
2. 解压 zip 文件
|
2. 解压 zip 文件
|
||||||
3. 打开 Chrome 访问 `chrome://extensions/`
|
3. 打开 Chrome 访问 `chrome://extensions/`
|
||||||
4. 开启右上角「开发者模式」
|
4. 开启右上角「开发者模式」
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# KAIROS — 常驻助手模式
|
# KAIROS — 常驻助手模式
|
||||||
|
|
||||||
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature)
|
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature)
|
||||||
> 实现状态:核心框架完整,部分子模块为 stub
|
> 实现状态:核心框架完整,部分子模块为 stub;proactive/sleep 节奏控制已可用
|
||||||
> 引用数:154(全库最大)
|
> 引用数:154(全库最大)
|
||||||
|
|
||||||
## 一、功能概述
|
## 一、功能概述
|
||||||
@@ -74,8 +74,9 @@ KAIROS 在系统提示中注入两大段落:
|
|||||||
|
|
||||||
SleepTool 是 KAIROS/Proactive 的节奏控制核心。工具描述让模型理解"休眠"概念:
|
SleepTool 是 KAIROS/Proactive 的节奏控制核心。工具描述让模型理解"休眠"概念:
|
||||||
- 工具名:`Sleep`
|
- 工具名:`Sleep`
|
||||||
- 功能:等待指定时间后响应 tick prompt
|
- 功能:等待指定时间后响应 tick prompt;若队列出现新工作或 proactive 被关闭,会提前唤醒
|
||||||
- 与 `<tick_tag>` 配合实现心跳式自主工作
|
- 与 `<tick_tag>` 配合实现心跳式自主工作
|
||||||
|
- 远程控制 surfaces 可通过 `automation_state` 看到 `standby` / `sleeping` 两种状态
|
||||||
|
|
||||||
### 3.3 Bridge 集成
|
### 3.3 Bridge 集成
|
||||||
|
|
||||||
@@ -172,8 +173,10 @@ FEATURE_KAIROS=1 FEATURE_TOKEN_BUDGET=1 bun run dev
|
|||||||
| `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UI(stub) |
|
| `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UI(stub) |
|
||||||
| `src/tools/BriefTool/` | — | BriefTool 实现(stub) |
|
| `src/tools/BriefTool/` | — | BriefTool 实现(stub) |
|
||||||
| `src/tools/SleepTool/prompt.ts` | ~30 | SleepTool 工具提示 |
|
| `src/tools/SleepTool/prompt.ts` | ~30 | SleepTool 工具提示 |
|
||||||
|
| `src/tools/SleepTool/SleepTool.ts` | ~200 | 休眠/唤醒与 automation metadata |
|
||||||
| `src/services/mcp/channelNotification.ts` | 5 | 频道消息接入(stub) |
|
| `src/services/mcp/channelNotification.ts` | 5 | 频道消息接入(stub) |
|
||||||
| `src/memdir/memdir.ts` | — | 记忆目录管理(stub) |
|
| `src/memdir/memdir.ts` | — | 记忆目录管理(stub) |
|
||||||
| `src/constants/prompts.ts:552-554,843-914` | 72 | 系统提示注入 |
|
| `src/constants/prompts.ts:552-554,843-914` | 72 | 系统提示注入 |
|
||||||
| `src/components/tasks/src/tasks/DreamTask/` | 3 | Dream 任务(stub) |
|
| `src/components/tasks/src/tasks/DreamTask/` | 3 | Dream 任务(stub) |
|
||||||
| `src/proactive/index.ts` | — | Proactive 核心(stub,KAIROS 共享) |
|
| `src/proactive/index.ts` | — | Proactive 核心(KAIROS 共享) |
|
||||||
|
| `src/utils/sessionState.ts` | — | 向 bridge/CCR 暴露 automation 状态 |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# PROACTIVE — 主动模式
|
# PROACTIVE — 主动模式
|
||||||
|
|
||||||
> Feature Flag: `FEATURE_PROACTIVE=1`(与 `FEATURE_KAIROS=1` 共享功能)
|
> Feature Flag: `FEATURE_PROACTIVE=1`(与 `FEATURE_KAIROS=1` 共享功能)
|
||||||
> 实现状态:核心模块全部 Stub,布线完整
|
> 实现状态:核心循环与 SleepTool 已落地,部分外围文档仍在补齐
|
||||||
> 引用数:37
|
> 引用数:37
|
||||||
|
|
||||||
## 一、功能概述
|
## 一、功能概述
|
||||||
@@ -21,13 +21,13 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
|
|||||||
|
|
||||||
| 模块 | 文件 | 状态 | 说明 |
|
| 模块 | 文件 | 状态 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| 核心逻辑 | `src/proactive/index.ts` | **Stub** | `activateProactive()`、`deactivateProactive()`、`isProactiveActive() => false` |
|
| 核心逻辑 | `src/proactive/index.ts` | **已实现** | `activateProactive()`、`deactivateProactive()`、`pause/resume`、`nextTickAt` 调度状态 |
|
||||||
| SleepTool 提示 | `src/tools/SleepTool/prompt.ts` | **完整** | 工具提示定义(工具名:`Sleep`) |
|
| SleepTool 提示 | `src/tools/SleepTool/prompt.ts` | **完整** | 工具提示定义(工具名:`Sleep`) |
|
||||||
| 命令注册 | `src/commands.ts:62-65` | **布线** | 动态加载 `./commands/proactive.js` |
|
| 命令注册 | `src/commands.ts:62-65` | **布线** | 动态加载 `./commands/proactive.js` |
|
||||||
| 工具注册 | `src/tools.ts:26-28` | **布线** | SleepTool 动态加载 |
|
| 工具注册 | `src/tools.ts:26-28` | **布线** | SleepTool 动态加载 |
|
||||||
| REPL 集成 | `src/screens/REPL.tsx` | **布线** | tick 驱动逻辑、占位符、页脚 UI |
|
| REPL 集成 | `src/screens/REPL.tsx` | **已实现** | tick 驱动、standby/sleeping 状态、页脚与 bridge automation metadata 上报 |
|
||||||
| 系统提示 | `src/constants/prompts.ts:860-914` | **完整** | 自主工作行为指令(~55 行详细 prompt) |
|
| 系统提示 | `src/constants/prompts.ts:860-914` | **完整** | 自主工作行为指令(~55 行详细 prompt) |
|
||||||
| 会话存储 | `src/utils/sessionStorage.ts:4892-4912` | **布线** | tick 消息注入对话流 |
|
| 远控状态镜像 | `src/utils/sessionState.ts` | **已实现** | 向 remote-control/CCR 暴露 `automation_state` 元数据 |
|
||||||
|
|
||||||
### 2.2 系统提示内容
|
### 2.2 系统提示内容
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
|
|||||||
### 2.3 数据流
|
### 2.3 数据流
|
||||||
|
|
||||||
```
|
```
|
||||||
activateProactive() [需要实现]
|
activateProactive()
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Tick 调度器启动
|
Tick 调度器启动
|
||||||
@@ -62,20 +62,22 @@ Tick 调度器启动
|
|||||||
└── 无事可做 → 必须调用 SleepTool
|
└── 无事可做 → 必须调用 SleepTool
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
SleepTool 等待 [需要实现]
|
SleepTool 等待
|
||||||
|
│
|
||||||
|
├── 用户插入新工作 / 队列中有命令 → 立即唤醒
|
||||||
|
├── proactive 被关闭 → 立即中断
|
||||||
|
└── 进入休眠时向远端 surfaces 上报 `automation_state = sleeping`
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
下一个 tick 到达
|
下一个 tick 到达
|
||||||
```
|
```
|
||||||
|
|
||||||
## 三、需要补全的内容
|
## 三、当前行为补充
|
||||||
|
|
||||||
| 优先级 | 模块 | 工作量 | 说明 |
|
- `standby`:proactive 已开启,当前没有执行中的 turn,且已调度下一个 tick。
|
||||||
|--------|------|--------|------|
|
- `sleeping`:模型显式调用 `SleepTool` 进入等待窗口。
|
||||||
| 1 | `src/proactive/index.ts` | 中 | Tick 调度器、activate/deactivate 状态机、pause/resume |
|
- remote-control/CCR 通过 `external_metadata.automation_state` 接收这两个状态,用于 Web UI 的 Autopilot 状态显示。
|
||||||
| 2 | `src/tools/SleepTool/SleepTool.ts` | 小 | 工具执行(等待指定时间后触发 tick) |
|
- `SleepTool` 现在不是纯定时器;它会在共享命令队列出现新工作时提前醒来。
|
||||||
| 3 | `src/commands/proactive.js` | 小 | `/proactive` 斜杠命令处理器 |
|
|
||||||
| 4 | `src/hooks/useProactive.ts` | 中 | React hook(REPL 引用但不存在) |
|
|
||||||
|
|
||||||
## 四、关键设计决策
|
## 四、关键设计决策
|
||||||
|
|
||||||
@@ -101,9 +103,11 @@ FEATURE_PROACTIVE=1 FEATURE_KAIROS=1 FEATURE_KAIROS_BRIEF=1 bun run dev
|
|||||||
|
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `src/proactive/index.ts` | 核心逻辑(stub) |
|
| `src/proactive/index.ts` | 核心逻辑与 next-tick 状态 |
|
||||||
| `src/tools/SleepTool/prompt.ts` | SleepTool 工具提示 |
|
| `src/tools/SleepTool/prompt.ts` | SleepTool 工具提示 |
|
||||||
|
| `src/tools/SleepTool/SleepTool.ts` | 休眠/唤醒执行逻辑 |
|
||||||
| `src/constants/prompts.ts:860-914` | 自主工作系统提示 |
|
| `src/constants/prompts.ts:860-914` | 自主工作系统提示 |
|
||||||
| `src/screens/REPL.tsx` | REPL tick 集成 |
|
| `src/screens/REPL.tsx` | REPL tick 集成与 automation 状态上报 |
|
||||||
| `src/utils/sessionStorage.ts:4892-4912` | Tick 消息注入 |
|
| `src/utils/sessionStorage.ts:4892-4912` | Tick 消息注入 |
|
||||||
|
| `src/utils/sessionState.ts` | bridge/CCR metadata 镜像 |
|
||||||
| `src/components/PromptInput/PromptInputFooterLeftSide.tsx` | 页脚 UI 状态 |
|
| `src/components/PromptInput/PromptInputFooterLeftSide.tsx` | 页脚 UI 状态 |
|
||||||
|
|||||||
@@ -13,17 +13,22 @@
|
|||||||
┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │
|
┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │
|
||||||
│ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │
|
│ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │
|
||||||
│ (/code/*) │ │ └──────────────┘ │
|
│ (/code/*) │ │ └──────────────┘ │
|
||||||
└──────────────────┘ │ ┌──────────────┐ │
|
│ (React + Vite) │ │ ┌──────────────┐ │
|
||||||
│ │ JWT Auth │ │
|
└──────────────────┘ │ │ JWT Auth │ │
|
||||||
│ └──────────────┘ │
|
│ └──────────────┘ │
|
||||||
└──────────────────────┘
|
┌──────────────────┐ │ ┌──────────────┐ │
|
||||||
|
│ acp-link │ ◄── ACP Relay ─── │ │ ACP Handler │ │
|
||||||
|
│ + ACP Agent │ WebSocket │ └──────────────┘ │
|
||||||
|
└──────────────────┘ └──────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**RCS 是一个纯内存的中间服务**,它的职责是:
|
**RCS 是一个纯内存的中间服务**,它的职责是:
|
||||||
- 接收 Claude Code CLI 的环境注册和工作轮询
|
- 接收 Claude Code CLI 的环境注册和工作轮询
|
||||||
|
- 接收 acp-link 的 ACP agent 注册,支持 WebSocket relay 桥接
|
||||||
- 提供 Web UI 供操作者远程监控和审批
|
- 提供 Web UI 供操作者远程监控和审批
|
||||||
- 通过 WebSocket/SSE 双向传输消息
|
- 通过 WebSocket/SSE 双向传输消息
|
||||||
- 管理会话、环境、权限请求
|
- 管理会话、环境、权限请求
|
||||||
|
- 提供 ACP SSE event stream 供外部消费者订阅 channel group 事件
|
||||||
|
|
||||||
## 前置条件
|
## 前置条件
|
||||||
|
|
||||||
@@ -138,13 +143,19 @@ bun run dist/cli.js
|
|||||||
/remote-control
|
/remote-control
|
||||||
```
|
```
|
||||||
|
|
||||||
CLI 会向 RCS 注册环境,注册成功后在终端显示连接 URL:
|
环境型 Remote Control(例如 `claude remote-control` 子命令)会向 RCS 注册环境,注册成功后在终端显示连接 URL:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://rcs.example.com/code?bridge=<environmentId>
|
https://rcs.example.com/code?bridge=<environmentId>
|
||||||
```
|
```
|
||||||
|
|
||||||
同时支持 QR 码扫码打开。该 URL 即 Web UI 控制面板入口,在浏览器中打开即可远程操控当前会话。
|
交互式 REPL 方式(`--remote-control` 或 `/remote-control`)在某些桥接模式下也可能直接给出会话 URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://rcs.example.com/code/session_<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
两种 URL 都可以直接在浏览器打开并远程操控当前会话;只有 environment 模式才会出现在 Web UI 的环境列表中。
|
||||||
|
|
||||||
若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项:
|
若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项:
|
||||||
- **Disconnect this session** — 断开远程连接
|
- **Disconnect this session** — 断开远程连接
|
||||||
@@ -163,15 +174,70 @@ claude bridge
|
|||||||
|
|
||||||
## Web UI 控制面板
|
## Web UI 控制面板
|
||||||
|
|
||||||
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
|
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。
|
||||||
|
|
||||||
- 查看已注册的运行环境
|
### 技术栈(v2,2026-04-18 重构)
|
||||||
|
|
||||||
|
Web UI 已从原生 JS 重构为 **React + Vite + Radix UI**:
|
||||||
|
|
||||||
|
- **框架**: React 19 + Vite 构建,TypeScript
|
||||||
|
- **UI 组件**: Radix UI primitives(Dialog、Tabs、Select、Popover 等)
|
||||||
|
- **聊天组件**: 完整的 ACP 聊天界面,支持 Plan 可视化、工具调用展示、权限审批
|
||||||
|
- **AI Elements**: 独立的 AI 交互组件库(message、reasoning、tool、code-block、prompt-input 等)
|
||||||
|
- **ACP 直连**: 支持 QR 码扫描自动跳转 ACP 直连视图(`ACPDirectView`)
|
||||||
|
- **主题系统**: 暗色/亮色主题切换,遵循 Impeccable 设计系统
|
||||||
|
|
||||||
|
### 功能
|
||||||
|
|
||||||
|
- 查看已注册的运行环境(environment 模式),区分 ACP Agent 和 Claude Code 类型
|
||||||
- 创建和管理会话
|
- 创建和管理会话
|
||||||
- 实时查看对话消息和工具调用
|
- 实时查看对话消息和工具调用
|
||||||
|
- 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示
|
||||||
|
- 查看 authoritative task snapshots 驱动的 Tasks 面板
|
||||||
- 审批 Claude Code 的工具权限请求
|
- 审批 Claude Code 的工具权限请求
|
||||||
|
- 权限模式选择器(6 种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
|
||||||
|
- 模型选择器(可选可用模型)
|
||||||
|
- Plan 可视化(进度条、状态图标、优先级标签)
|
||||||
|
- ACP QR 扫描自动跳转到 ACP 聊天界面
|
||||||
|
|
||||||
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
|
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
|
||||||
|
|
||||||
|
## ACP 支持
|
||||||
|
|
||||||
|
RCS 支持 ACP (Agent Client Protocol) agent 通过 `acp-link` 包接入。
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
acp-link ──REST注册──► RCS POST /v1/environments/bridge
|
||||||
|
acp-link ──WS identify──► RCS WebSocket (携带 agentId)
|
||||||
|
acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端组件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/routes/acp/index.ts` | ACP REST 路由:agents 列表、channel groups、relay |
|
||||||
|
| `src/transport/acp-ws-handler.ts` | ACP WebSocket 处理:agent 注册、心跳、消息转发 |
|
||||||
|
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
|
||||||
|
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
|
||||||
|
|
||||||
|
### acp-link 连接
|
||||||
|
|
||||||
|
详见 [acp-link 文档](./acp-link.md)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 RCS 环境中启动 acp-link
|
||||||
|
# 注意:claude 本身不支持 ACP,需要用 ccb-bun --acp
|
||||||
|
ACP_RCS_URL=http://localhost:3000 \
|
||||||
|
ACP_RCS_TOKEN=sk-rcs-your-key \
|
||||||
|
ACP_RCS_NAME=my-agent \
|
||||||
|
acp-link ccb-bun -- --acp
|
||||||
|
```
|
||||||
|
|
||||||
|
ACP session 在 Web UI 中显示紫色标签,与普通 Claude Code session 区分。
|
||||||
|
|
||||||
## 工作流程详解
|
## 工作流程详解
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -209,6 +275,7 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
|
|||||||
9. 双向通信
|
9. 双向通信
|
||||||
CLI ──消息/工具调用结果──► RCS ──► Browser
|
CLI ──消息/工具调用结果──► RCS ──► Browser
|
||||||
CLI ◄──权限审批/指令───── RCS ◄──── Browser
|
CLI ◄──权限审批/指令───── RCS ◄──── Browser
|
||||||
|
CLI ──automation_state / task_state──► RCS ──► Browser
|
||||||
|
|
||||||
10. 心跳保活(每 20 秒)
|
10. 心跳保活(每 20 秒)
|
||||||
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
|
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
|
||||||
@@ -218,6 +285,13 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
|
|||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|
||||||
|
### Web UI 看不到当前 Autopilot 状态
|
||||||
|
|
||||||
|
- `standby`:proactive 已开启,正在等待下一个 tick
|
||||||
|
- `sleeping`:模型正在 `SleepTool` 等待窗口中
|
||||||
|
|
||||||
|
这两个状态通过 worker `external_metadata.automation_state` 上报。如果页面只显示普通 working spinner,优先检查 CLI 和 RCS 之间的 worker metadata PUT 是否成功。
|
||||||
|
|
||||||
### CLI 无法连接
|
### CLI 无法连接
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -275,4 +349,3 @@ curl https://rcs.example.com/health
|
|||||||
| 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key |
|
| 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key |
|
||||||
|
|
||||||
自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。
|
自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"docs/features/voice-mode",
|
"docs/features/voice-mode",
|
||||||
"docs/features/bridge-mode",
|
"docs/features/bridge-mode",
|
||||||
"docs/features/remote-control-self-hosting",
|
"docs/features/remote-control-self-hosting",
|
||||||
|
"docs/features/acp-link",
|
||||||
"docs/features/proactive",
|
"docs/features/proactive",
|
||||||
"docs/features/ultraplan"
|
"docs/features/ultraplan"
|
||||||
]
|
]
|
||||||
|
|||||||
36
package.json
36
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.3.5",
|
"version": "1.4.2",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/@ant/*"
|
"packages/@ant/*",
|
||||||
|
"packages/@anthropic-ai/*"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -40,6 +41,9 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run build.ts",
|
"build": "bun run build.ts",
|
||||||
|
"build:vite": "vite build && bun run scripts/post-build.ts",
|
||||||
|
"build:vite:only": "vite build",
|
||||||
|
"build:bun": "bun run build.ts",
|
||||||
"dev": "bun run scripts/dev.ts",
|
"dev": "bun run scripts/dev.ts",
|
||||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||||
"prepublishOnly": "bun run build",
|
"prepublishOnly": "bun run build",
|
||||||
@@ -50,19 +54,19 @@
|
|||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"check:unused": "knip-bun",
|
"check:unused": "knip-bun",
|
||||||
"health": "bun run scripts/health-check.ts",
|
"health": "bun run scripts/health-check.ts",
|
||||||
"postinstall": "node scripts/postinstall.cjs && node scripts/setup-chrome-mcp.mjs",
|
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
||||||
"docs:dev": "npx mintlify dev",
|
"docs:dev": "npx mintlify dev",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"rcs": "bun run scripts/rcs.ts"
|
"rcs": "bun run scripts/rcs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.7"
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
|
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/he": "^1.2.3",
|
|
||||||
"@langfuse/otel": "^5.1.0",
|
|
||||||
"@langfuse/tracing": "^5.1.0",
|
|
||||||
"@types/lodash-es": "^4.17.12",
|
|
||||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||||
|
"@ant/model-provider": "workspace:*",
|
||||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||||
"@ant/computer-use-input": "workspace:*",
|
"@ant/computer-use-input": "workspace:*",
|
||||||
"@ant/computer-use-mcp": "workspace:*",
|
"@ant/computer-use-mcp": "workspace:*",
|
||||||
@@ -75,9 +79,6 @@
|
|||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||||
"@anthropic/ink": "workspace:*",
|
"@anthropic/ink": "workspace:*",
|
||||||
"@claude-code-best/builtin-tools": "workspace:*",
|
|
||||||
"@claude-code-best/agent-tools": "workspace:*",
|
|
||||||
"@claude-code-best/mcp-client": "workspace:*",
|
|
||||||
"@aws-sdk/client-bedrock": "^3.1020.0",
|
"@aws-sdk/client-bedrock": "^3.1020.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
||||||
"@aws-sdk/client-sts": "^3.1020.0",
|
"@aws-sdk/client-sts": "^3.1020.0",
|
||||||
@@ -85,8 +86,13 @@
|
|||||||
"@aws-sdk/credential-providers": "^3.1020.0",
|
"@aws-sdk/credential-providers": "^3.1020.0",
|
||||||
"@azure/identity": "^4.13.1",
|
"@azure/identity": "^4.13.1",
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.10",
|
||||||
|
"@claude-code-best/agent-tools": "workspace:*",
|
||||||
|
"@claude-code-best/builtin-tools": "workspace:*",
|
||||||
|
"@claude-code-best/mcp-client": "workspace:*",
|
||||||
"@commander-js/extra-typings": "^14.0.0",
|
"@commander-js/extra-typings": "^14.0.0",
|
||||||
"@growthbook/growthbook": "^1.6.5",
|
"@growthbook/growthbook": "^1.6.5",
|
||||||
|
"@langfuse/otel": "^5.1.0",
|
||||||
|
"@langfuse/tracing": "^5.1.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@opentelemetry/api": "^1.9.1",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/api-logs": "^0.214.0",
|
"@opentelemetry/api-logs": "^0.214.0",
|
||||||
@@ -109,8 +115,11 @@
|
|||||||
"@sentry/node": "^10.47.0",
|
"@sentry/node": "^10.47.0",
|
||||||
"@smithy/core": "^3.23.13",
|
"@smithy/core": "^3.23.13",
|
||||||
"@smithy/node-http-handler": "^4.5.1",
|
"@smithy/node-http-handler": "^4.5.1",
|
||||||
"@types/bun": "^1.3.11",
|
"@types/bun": "^1.3.12",
|
||||||
"@types/cacache": "^20.0.1",
|
"@types/cacache": "^20.0.1",
|
||||||
|
"@types/he": "^1.2.3",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"@types/picomatch": "^4.0.3",
|
"@types/picomatch": "^4.0.3",
|
||||||
"@types/plist": "^3.0.5",
|
"@types/plist": "^3.0.5",
|
||||||
"@types/proper-lockfile": "^4.1.4",
|
"@types/proper-lockfile": "^4.1.4",
|
||||||
@@ -166,6 +175,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-compiler-runtime": "^1.0.0",
|
"react-compiler-runtime": "^1.0.0",
|
||||||
"react-reconciler": "^0.33.0",
|
"react-reconciler": "^0.33.0",
|
||||||
|
"rollup": "^4.60.1",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
@@ -180,11 +190,11 @@
|
|||||||
"undici": "^7.24.6",
|
"undici": "^7.24.6",
|
||||||
"url-handler-napi": "workspace:*",
|
"url-handler-napi": "workspace:*",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
|
"vite": "^8.0.8",
|
||||||
"vscode-jsonrpc": "^8.2.1",
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
"vscode-languageserver-protocol": "^3.17.5",
|
"vscode-languageserver-protocol": "^3.17.5",
|
||||||
"vscode-languageserver-types": "^3.17.5",
|
"vscode-languageserver-types": "^3.17.5",
|
||||||
"wrap-ansi": "^10.0.0",
|
"wrap-ansi": "^10.0.0",
|
||||||
"ws": "^8.20.0",
|
|
||||||
"xss": "^1.0.15",
|
"xss": "^1.0.15",
|
||||||
"yaml": "^2.8.3",
|
"yaml": "^2.8.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|||||||
5
packages/@ant/claude-for-chrome-mcp/tsconfig.json
Normal file
5
packages/@ant/claude-for-chrome-mcp/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@
|
|||||||
* mouse and keyboard via CoreGraphics events and System Events.
|
* mouse and keyboard via CoreGraphics events and System Events.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { $ } from 'bun'
|
import { execFile, execFileSync } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
const KEY_MAP: Record<string, number> = {
|
const KEY_MAP: Record<string, number> = {
|
||||||
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
|
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
|
||||||
escape: 53, esc: 53,
|
escape: 53, esc: 53,
|
||||||
@@ -25,13 +28,17 @@ const MODIFIER_MAP: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function osascript(script: string): Promise<string> {
|
async function osascript(script: string): Promise<string> {
|
||||||
const result = await $`osascript -e ${script}`.quiet().nothrow().text()
|
const { stdout } = await execFileAsync('osascript', ['-e', script], {
|
||||||
return result.trim()
|
encoding: 'utf-8',
|
||||||
|
})
|
||||||
|
return stdout.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function jxa(script: string): Promise<string> {
|
async function jxa(script: string): Promise<string> {
|
||||||
const result = await $`osascript -l JavaScript -e ${script}`.quiet().nothrow().text()
|
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
|
||||||
return result.trim()
|
encoding: 'utf-8',
|
||||||
|
})
|
||||||
|
return stdout.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string {
|
function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string {
|
||||||
@@ -115,19 +122,14 @@ export const typeText: InputBackend['typeText'] = async (text) => {
|
|||||||
|
|
||||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||||
try {
|
try {
|
||||||
const result = Bun.spawnSync({
|
const output = execFileSync('osascript', ['-e', `
|
||||||
cmd: ['osascript', '-e', `
|
tell application "System Events"
|
||||||
tell application "System Events"
|
set frontApp to first application process whose frontmost is true
|
||||||
set frontApp to first application process whose frontmost is true
|
set appName to name of frontApp
|
||||||
set appName to name of frontApp
|
set bundleId to bundle identifier of frontApp
|
||||||
set bundleId to bundle identifier of frontApp
|
return bundleId & "|" & appName
|
||||||
return bundleId & "|" & appName
|
end tell
|
||||||
end tell
|
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
|
||||||
`],
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
})
|
|
||||||
const output = new TextDecoder().decode(result.stdout).trim()
|
|
||||||
if (!output || !output.includes('|')) return null
|
if (!output || !output.includes('|')) return null
|
||||||
const [bundleId, appName] = output.split('|', 2)
|
const [bundleId, appName] = output.split('|', 2)
|
||||||
return { bundleId: bundleId!, appName: appName! }
|
return { bundleId: bundleId!, appName: appName! }
|
||||||
|
|||||||
5
packages/@ant/computer-use-input/tsconfig.json
Normal file
5
packages/@ant/computer-use-input/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
5
packages/@ant/computer-use-mcp/tsconfig.json
Normal file
5
packages/@ant/computer-use-mcp/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -274,4 +274,9 @@ export const screenshot: ScreenshotAPI = {
|
|||||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||||
return captureScreenToBase64(args)
|
return captureScreenToBase64(args)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
|
||||||
|
// Window capture not supported on macOS via this backend
|
||||||
|
return null
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,4 +275,9 @@ export const screenshot: ScreenshotAPI = {
|
|||||||
return { base64: '', width: 0, height: 0 }
|
return { base64: '', width: 0, height: 0 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
|
||||||
|
// Window capture not supported on Linux via this backend
|
||||||
|
return null
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface ScreenshotAPI {
|
|||||||
x: number, y: number, w: number, h: number,
|
x: number, y: number, w: number, h: number,
|
||||||
outW: number, outH: number, quality: number, displayId?: number,
|
outW: number, outH: number, quality: number, displayId?: number,
|
||||||
): Promise<ScreenshotResult>
|
): Promise<ScreenshotResult>
|
||||||
|
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SwiftBackend {
|
export interface SwiftBackend {
|
||||||
|
|||||||
5
packages/@ant/computer-use-swift/tsconfig.json
Normal file
5
packages/@ant/computer-use-swift/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
5
packages/@ant/ink/tsconfig.json
Normal file
5
packages/@ant/ink/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
18
packages/@ant/model-provider/package.json
Normal file
18
packages/@ant/model-provider/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@ant/model-provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./types": "./src/types/index.ts",
|
||||||
|
"./hooks": "./src/hooks/index.ts",
|
||||||
|
"./client": "./src/client/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
|
"openai": "^6.33.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/@ant/model-provider/src/client/index.ts
Normal file
27
packages/@ant/model-provider/src/client/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ClientFactories } from './types.js'
|
||||||
|
|
||||||
|
let registeredFactories: ClientFactories | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register client factories from the main project.
|
||||||
|
* Call this during application initialization.
|
||||||
|
*/
|
||||||
|
export function registerClientFactories(factories: ClientFactories): void {
|
||||||
|
registeredFactories = factories
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get registered client factories.
|
||||||
|
* Throws if not registered (fail-fast).
|
||||||
|
*/
|
||||||
|
export function getClientFactories(): ClientFactories {
|
||||||
|
if (!registeredFactories) {
|
||||||
|
throw new Error(
|
||||||
|
'Client factories not registered. ' +
|
||||||
|
'Call registerClientFactories() during app initialization.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return registeredFactories
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ClientFactories }
|
||||||
35
packages/@ant/model-provider/src/client/types.ts
Normal file
35
packages/@ant/model-provider/src/client/types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Client factory interfaces.
|
||||||
|
* Authentication is handled externally — main project provides factory implementations.
|
||||||
|
*/
|
||||||
|
export interface ClientFactories {
|
||||||
|
/** Get Anthropic client (1st party, Bedrock, Foundry, Vertex) */
|
||||||
|
getAnthropicClient: (params: {
|
||||||
|
model?: string
|
||||||
|
maxRetries: number
|
||||||
|
fetchOverride?: unknown
|
||||||
|
source?: string
|
||||||
|
}) => Promise<unknown>
|
||||||
|
|
||||||
|
/** Get OpenAI-compatible client */
|
||||||
|
getOpenAIClient: (params: {
|
||||||
|
maxRetries: number
|
||||||
|
fetchOverride?: unknown
|
||||||
|
source?: string
|
||||||
|
}) => unknown
|
||||||
|
|
||||||
|
/** Stream Gemini generate content */
|
||||||
|
streamGeminiGenerateContent: (params: {
|
||||||
|
model: string
|
||||||
|
signal?: AbortSignal
|
||||||
|
fetchOverride?: unknown
|
||||||
|
body: Record<string, unknown>
|
||||||
|
}) => AsyncIterable<unknown>
|
||||||
|
|
||||||
|
/** Get Grok client (OpenAI-compatible) */
|
||||||
|
getGrokClient: (params: {
|
||||||
|
maxRetries: number
|
||||||
|
fetchOverride?: unknown
|
||||||
|
source?: string
|
||||||
|
}) => unknown
|
||||||
|
}
|
||||||
238
packages/@ant/model-provider/src/errorUtils.ts
Normal file
238
packages/@ant/model-provider/src/errorUtils.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import type { APIError } from '@anthropic-ai/sdk'
|
||||||
|
|
||||||
|
// SSL/TLS error codes from OpenSSL (used by both Node.js and Bun)
|
||||||
|
// See: https://www.openssl.org/docs/man3.1/man3/X509_STORE_CTX_get_error.html
|
||||||
|
const SSL_ERROR_CODES = new Set([
|
||||||
|
// Certificate verification errors
|
||||||
|
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||||
|
'UNABLE_TO_GET_ISSUER_CERT',
|
||||||
|
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||||
|
'CERT_SIGNATURE_FAILURE',
|
||||||
|
'CERT_NOT_YET_VALID',
|
||||||
|
'CERT_HAS_EXPIRED',
|
||||||
|
'CERT_REVOKED',
|
||||||
|
'CERT_REJECTED',
|
||||||
|
'CERT_UNTRUSTED',
|
||||||
|
// Self-signed certificate errors
|
||||||
|
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||||
|
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||||
|
// Chain errors
|
||||||
|
'CERT_CHAIN_TOO_LONG',
|
||||||
|
'PATH_LENGTH_EXCEEDED',
|
||||||
|
// Hostname/altname errors
|
||||||
|
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||||
|
'HOSTNAME_MISMATCH',
|
||||||
|
// TLS handshake errors
|
||||||
|
'ERR_TLS_HANDSHAKE_TIMEOUT',
|
||||||
|
'ERR_SSL_WRONG_VERSION_NUMBER',
|
||||||
|
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
||||||
|
])
|
||||||
|
|
||||||
|
export type ConnectionErrorDetails = {
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
isSSLError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts connection error details from the error cause chain.
|
||||||
|
* The Anthropic SDK wraps underlying errors in the `cause` property.
|
||||||
|
* This function walks the cause chain to find the root error code/message.
|
||||||
|
*/
|
||||||
|
export function extractConnectionErrorDetails(
|
||||||
|
error: unknown,
|
||||||
|
): ConnectionErrorDetails | null {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the cause chain to find the root error with a code
|
||||||
|
let current: unknown = error
|
||||||
|
const maxDepth = 5 // Prevent infinite loops
|
||||||
|
let depth = 0
|
||||||
|
|
||||||
|
while (current && depth < maxDepth) {
|
||||||
|
if (
|
||||||
|
current instanceof Error &&
|
||||||
|
'code' in current &&
|
||||||
|
typeof current.code === 'string'
|
||||||
|
) {
|
||||||
|
const code = current.code
|
||||||
|
const isSSLError = SSL_ERROR_CODES.has(code)
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: current.message,
|
||||||
|
isSSLError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the next cause in the chain
|
||||||
|
if (
|
||||||
|
current instanceof Error &&
|
||||||
|
'cause' in current &&
|
||||||
|
current.cause !== current
|
||||||
|
) {
|
||||||
|
current = current.cause
|
||||||
|
depth++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an actionable hint for SSL/TLS errors, intended for contexts outside
|
||||||
|
* the main API client (OAuth token exchange, preflight connectivity checks)
|
||||||
|
* where `formatAPIError` doesn't apply.
|
||||||
|
*/
|
||||||
|
export function getSSLErrorHint(error: unknown): string | null {
|
||||||
|
const details = extractConnectionErrorDetails(error)
|
||||||
|
if (!details?.isSSLError) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `SSL certificate error (${details.code}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips HTML content (e.g., CloudFlare error pages) from a message string,
|
||||||
|
* returning a user-friendly title or empty string if HTML is detected.
|
||||||
|
* Returns the original message unchanged if no HTML is found.
|
||||||
|
*/
|
||||||
|
function sanitizeMessageHTML(message: string): string {
|
||||||
|
if (message.includes('<!DOCTYPE html') || message.includes('<html')) {
|
||||||
|
const titleMatch = message.match(/<title>([^<]+)<\/title>/)
|
||||||
|
if (titleMatch && titleMatch[1]) {
|
||||||
|
return titleMatch[1].trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if an error message contains HTML content (e.g., CloudFlare error pages)
|
||||||
|
* and returns a user-friendly message instead
|
||||||
|
*/
|
||||||
|
export function sanitizeAPIError(apiError: APIError): string {
|
||||||
|
const message = apiError.message
|
||||||
|
if (!message) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return sanitizeMessageHTML(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes of deserialized API errors from session JSONL.
|
||||||
|
*/
|
||||||
|
type NestedAPIError = {
|
||||||
|
error?: {
|
||||||
|
message?: string
|
||||||
|
error?: { message?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNestedError(value: unknown): value is NestedAPIError {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'error' in value &&
|
||||||
|
typeof value.error === 'object' &&
|
||||||
|
value.error !== null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a human-readable message from a deserialized API error that lacks
|
||||||
|
* a top-level `.message`.
|
||||||
|
*/
|
||||||
|
function extractNestedErrorMessage(error: APIError): string | null {
|
||||||
|
if (!hasNestedError(error)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const narrowed: NestedAPIError = error
|
||||||
|
const nested = narrowed.error
|
||||||
|
|
||||||
|
// Standard Anthropic API shape: { error: { error: { message } } }
|
||||||
|
const deepMsg = nested?.error?.message
|
||||||
|
if (typeof deepMsg === 'string' && deepMsg.length > 0) {
|
||||||
|
const sanitized = sanitizeMessageHTML(deepMsg)
|
||||||
|
if (sanitized.length > 0) {
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock shape: { error: { message } }
|
||||||
|
const msg = nested?.message
|
||||||
|
if (typeof msg === 'string' && msg.length > 0) {
|
||||||
|
const sanitized = sanitizeMessageHTML(msg)
|
||||||
|
if (sanitized.length > 0) {
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAPIError(error: APIError): string {
|
||||||
|
// Extract connection error details from the cause chain
|
||||||
|
const connectionDetails = extractConnectionErrorDetails(error)
|
||||||
|
|
||||||
|
if (connectionDetails) {
|
||||||
|
const { code, isSSLError } = connectionDetails
|
||||||
|
|
||||||
|
// Handle timeout errors
|
||||||
|
if (code === 'ETIMEDOUT') {
|
||||||
|
return 'Request timed out. Check your internet connection and proxy settings'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SSL/TLS errors with specific messages
|
||||||
|
if (isSSLError) {
|
||||||
|
switch (code) {
|
||||||
|
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
|
||||||
|
case 'UNABLE_TO_GET_ISSUER_CERT':
|
||||||
|
case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
|
||||||
|
return 'Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates'
|
||||||
|
case 'CERT_HAS_EXPIRED':
|
||||||
|
return 'Unable to connect to API: SSL certificate has expired'
|
||||||
|
case 'CERT_REVOKED':
|
||||||
|
return 'Unable to connect to API: SSL certificate has been revoked'
|
||||||
|
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
||||||
|
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
||||||
|
return 'Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates'
|
||||||
|
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
||||||
|
case 'HOSTNAME_MISMATCH':
|
||||||
|
return 'Unable to connect to API: SSL certificate hostname mismatch'
|
||||||
|
case 'CERT_NOT_YET_VALID':
|
||||||
|
return 'Unable to connect to API: SSL certificate is not yet valid'
|
||||||
|
default:
|
||||||
|
return `Unable to connect to API: SSL error (${code})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === 'Connection error.') {
|
||||||
|
// If we have a code but it's not SSL, include it for debugging
|
||||||
|
if (connectionDetails?.code) {
|
||||||
|
return `Unable to connect to API (${connectionDetails.code})`
|
||||||
|
}
|
||||||
|
return 'Unable to connect to API. Check your internet connection'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: when deserialized from JSONL (e.g. --resume), the error object may
|
||||||
|
// be a plain object without a `.message` property.
|
||||||
|
if (!error.message) {
|
||||||
|
return (
|
||||||
|
extractNestedErrorMessage(error) ??
|
||||||
|
`API error (status ${error.status ?? 'unknown'})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedMessage = sanitizeAPIError(error)
|
||||||
|
// Use sanitized message if it's different from the original (i.e., HTML was sanitized)
|
||||||
|
return sanitizedMessage !== error.message && sanitizedMessage.length > 0
|
||||||
|
? sanitizedMessage
|
||||||
|
: error.message
|
||||||
|
}
|
||||||
27
packages/@ant/model-provider/src/hooks/index.ts
Normal file
27
packages/@ant/model-provider/src/hooks/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ModelProviderHooks } from './types.js'
|
||||||
|
|
||||||
|
let registeredHooks: ModelProviderHooks | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register hooks from the main project.
|
||||||
|
* Call this during application initialization.
|
||||||
|
*/
|
||||||
|
export function registerHooks(hooks: ModelProviderHooks): void {
|
||||||
|
registeredHooks = hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get registered hooks.
|
||||||
|
* Throws if hooks not registered (fail-fast).
|
||||||
|
*/
|
||||||
|
export function getHooks(): ModelProviderHooks {
|
||||||
|
if (!registeredHooks) {
|
||||||
|
throw new Error(
|
||||||
|
'ModelProvider hooks not registered. ' +
|
||||||
|
'Call registerHooks() during app initialization.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return registeredHooks
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ModelProviderHooks }
|
||||||
48
packages/@ant/model-provider/src/hooks/types.ts
Normal file
48
packages/@ant/model-provider/src/hooks/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Hooks for dependency injection.
|
||||||
|
* Main project provides implementations; model-provider calls them.
|
||||||
|
*
|
||||||
|
* This decouples the model-provider from main project specifics like
|
||||||
|
* analytics, cost tracking, feature flags, etc.
|
||||||
|
*/
|
||||||
|
export interface ModelProviderHooks {
|
||||||
|
/** Log an analytics event (replaces direct logEvent calls) */
|
||||||
|
logEvent: (eventName: string, metadata?: Record<string, unknown>) => void
|
||||||
|
|
||||||
|
/** Report API cost after each response */
|
||||||
|
reportCost: (params: {
|
||||||
|
costUSD: number
|
||||||
|
usage: Record<string, unknown>
|
||||||
|
model: string
|
||||||
|
}) => void
|
||||||
|
|
||||||
|
/** Get tool permission context */
|
||||||
|
getToolPermissionContext?: () => Promise<Record<string, unknown>>
|
||||||
|
|
||||||
|
/** Debug logging */
|
||||||
|
logForDebugging: (msg: string, opts?: { level?: string }) => void
|
||||||
|
|
||||||
|
/** Error logging */
|
||||||
|
logError: (error: Error) => void
|
||||||
|
|
||||||
|
/** Get feature flag value */
|
||||||
|
getFeatureFlag?: (flagName: string) => unknown
|
||||||
|
|
||||||
|
/** Get session ID */
|
||||||
|
getSessionId: () => string
|
||||||
|
|
||||||
|
/** Add a notification */
|
||||||
|
addNotification?: (notification: Record<string, unknown>) => void
|
||||||
|
|
||||||
|
/** Get API provider name */
|
||||||
|
getAPIProvider: () => string
|
||||||
|
|
||||||
|
/** Get user ID */
|
||||||
|
getOrCreateUserID: () => string
|
||||||
|
|
||||||
|
/** Check if non-interactive session */
|
||||||
|
isNonInteractiveSession: () => boolean
|
||||||
|
|
||||||
|
/** Get OAuth account info */
|
||||||
|
getOauthAccountInfo?: () => Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
63
packages/@ant/model-provider/src/index.ts
Normal file
63
packages/@ant/model-provider/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// @ant/model-provider
|
||||||
|
// Model provider abstraction layer for Claude Code
|
||||||
|
//
|
||||||
|
// This package owns the model calling logic and provides:
|
||||||
|
// - Core query functions (queryModelWithStreaming, etc.)
|
||||||
|
// - Provider implementations (Anthropic, OpenAI, Gemini, Grok)
|
||||||
|
// - Type definitions (Message, Tool, Usage, etc.)
|
||||||
|
// - Dependency injection hooks (analytics, cost tracking, etc.)
|
||||||
|
//
|
||||||
|
// Initialization:
|
||||||
|
// registerClientFactories({ ... }) // inject auth clients
|
||||||
|
// registerHooks({ ... }) // inject analytics/cost/logging
|
||||||
|
|
||||||
|
// Hooks (dependency injection)
|
||||||
|
export { registerHooks, getHooks } from './hooks/index.js'
|
||||||
|
export type { ModelProviderHooks } from './hooks/types.js'
|
||||||
|
|
||||||
|
// Client factories
|
||||||
|
export { registerClientFactories, getClientFactories } from './client/index.js'
|
||||||
|
export type { ClientFactories } from './client/types.js'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types/index.js'
|
||||||
|
|
||||||
|
// Provider model mappings
|
||||||
|
export { resolveOpenAIModel } from './providers/openai/modelMapping.js'
|
||||||
|
export { resolveGrokModel } from './providers/grok/modelMapping.js'
|
||||||
|
export { resolveGeminiModel } from './providers/gemini/modelMapping.js'
|
||||||
|
|
||||||
|
// Gemini provider utilities
|
||||||
|
export { anthropicMessagesToGemini } from './providers/gemini/convertMessages.js'
|
||||||
|
export { anthropicToolsToGemini, anthropicToolChoiceToGemini } from './providers/gemini/convertTools.js'
|
||||||
|
export { adaptGeminiStreamToAnthropic } from './providers/gemini/streamAdapter.js'
|
||||||
|
export {
|
||||||
|
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||||
|
type GeminiContent,
|
||||||
|
type GeminiGenerateContentRequest,
|
||||||
|
type GeminiPart,
|
||||||
|
type GeminiStreamChunk,
|
||||||
|
type GeminiTool,
|
||||||
|
type GeminiFunctionCallingConfig,
|
||||||
|
type GeminiFunctionDeclaration,
|
||||||
|
type GeminiFunctionCall,
|
||||||
|
type GeminiFunctionResponse,
|
||||||
|
type GeminiInlineData,
|
||||||
|
type GeminiUsageMetadata,
|
||||||
|
type GeminiCandidate,
|
||||||
|
} from './providers/gemini/types.js'
|
||||||
|
|
||||||
|
// Error utilities
|
||||||
|
export {
|
||||||
|
formatAPIError,
|
||||||
|
extractConnectionErrorDetails,
|
||||||
|
sanitizeAPIError,
|
||||||
|
getSSLErrorHint,
|
||||||
|
type ConnectionErrorDetails,
|
||||||
|
} from './errorUtils.js'
|
||||||
|
|
||||||
|
// Shared OpenAI conversion utilities
|
||||||
|
export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js'
|
||||||
|
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
|
||||||
|
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
|
||||||
|
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
|||||||
import type {
|
import type {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
} from '../../../../types/message.js'
|
} from '../../../types/message.js'
|
||||||
import { anthropicMessagesToGemini } from '../convertMessages.js'
|
import { anthropicMessagesToGemini } from '../convertMessages.js'
|
||||||
|
|
||||||
function makeUserMsg(content: string | any[]): UserMessage {
|
function makeUserMsg(content: string | any[]): UserMessage {
|
||||||
@@ -23,9 +23,10 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
|||||||
|
|
||||||
describe('anthropicMessagesToGemini', () => {
|
describe('anthropicMessagesToGemini', () => {
|
||||||
test('converts system prompt to systemInstruction', () => {
|
test('converts system prompt to systemInstruction', () => {
|
||||||
const result = anthropicMessagesToGemini([makeUserMsg('hello')], [
|
const result = anthropicMessagesToGemini(
|
||||||
'You are helpful.',
|
[makeUserMsg('hello')],
|
||||||
] as any)
|
['You are helpful.'] as any,
|
||||||
|
)
|
||||||
|
|
||||||
expect(result.systemInstruction).toEqual({
|
expect(result.systemInstruction).toEqual({
|
||||||
parts: [{ text: 'You are helpful.' }],
|
parts: [{ text: 'You are helpful.' }],
|
||||||
@@ -201,19 +202,17 @@ describe('anthropicMessagesToGemini', () => {
|
|||||||
|
|
||||||
test('converts base64 image to inlineData', () => {
|
test('converts base64 image to inlineData', () => {
|
||||||
const result = anthropicMessagesToGemini(
|
const result = anthropicMessagesToGemini(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{ type: 'text', text: 'describe this' },
|
||||||
{ type: 'text', text: 'describe this' },
|
{
|
||||||
{
|
type: 'image',
|
||||||
type: 'image',
|
source: {
|
||||||
source: {
|
type: 'base64',
|
||||||
type: 'base64',
|
media_type: 'image/png',
|
||||||
media_type: 'image/png',
|
data: 'iVBORw0KGgo=',
|
||||||
data: 'iVBORw0KGgo=',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
],
|
])],
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result.contents).toEqual([
|
expect(result.contents).toEqual([
|
||||||
@@ -229,17 +228,15 @@ describe('anthropicMessagesToGemini', () => {
|
|||||||
|
|
||||||
test('converts url image to text fallback', () => {
|
test('converts url image to text fallback', () => {
|
||||||
const result = anthropicMessagesToGemini(
|
const result = anthropicMessagesToGemini(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{
|
||||||
{
|
type: 'image',
|
||||||
type: 'image',
|
source: {
|
||||||
source: {
|
type: 'url',
|
||||||
type: 'url',
|
url: 'https://example.com/img.png',
|
||||||
url: 'https://example.com/img.png',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
],
|
])],
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result.contents).toEqual([
|
expect(result.contents).toEqual([
|
||||||
@@ -252,17 +249,15 @@ describe('anthropicMessagesToGemini', () => {
|
|||||||
|
|
||||||
test('defaults to image/png when media_type is missing', () => {
|
test('defaults to image/png when media_type is missing', () => {
|
||||||
const result = anthropicMessagesToGemini(
|
const result = anthropicMessagesToGemini(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{
|
||||||
{
|
type: 'image',
|
||||||
type: 'image',
|
source: {
|
||||||
source: {
|
type: 'base64',
|
||||||
type: 'base64',
|
data: 'ABC123',
|
||||||
data: 'ABC123',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
],
|
])],
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result.contents[0].parts[0]).toEqual({
|
expect(result.contents[0].parts[0]).toEqual({
|
||||||
@@ -120,11 +120,11 @@ describe('anthropicToolChoiceToGemini', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('maps explicit tool choice', () => {
|
test('maps explicit tool choice', () => {
|
||||||
expect(anthropicToolChoiceToGemini({ type: 'tool', name: 'bash' })).toEqual(
|
expect(
|
||||||
{
|
anthropicToolChoiceToGemini({ type: 'tool', name: 'bash' }),
|
||||||
mode: 'ANY',
|
).toEqual({
|
||||||
allowedFunctionNames: ['bash'],
|
mode: 'ANY',
|
||||||
},
|
allowedFunctionNames: ['bash'],
|
||||||
)
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -57,8 +57,7 @@ describe('adaptGeminiStreamToAnthropic', () => {
|
|||||||
|
|
||||||
const textDeltas = events.filter(
|
const textDeltas = events.filter(
|
||||||
event =>
|
event =>
|
||||||
event.type === 'content_block_delta' &&
|
event.type === 'content_block_delta' && event.delta.type === 'text_delta',
|
||||||
event.delta.type === 'text_delta',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(events[0].type).toBe('message_start')
|
expect(events[0].type).toBe('message_start')
|
||||||
@@ -93,9 +92,7 @@ describe('adaptGeminiStreamToAnthropic', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const blockStart = events.find(
|
const blockStart = events.find(event => event.type === 'content_block_start')
|
||||||
event => event.type === 'content_block_start',
|
|
||||||
)
|
|
||||||
expect(blockStart.content_block.type).toBe('thinking')
|
expect(blockStart.content_block.type).toBe('thinking')
|
||||||
|
|
||||||
const signatureDelta = events.find(
|
const signatureDelta = events.find(
|
||||||
@@ -128,9 +125,7 @@ describe('adaptGeminiStreamToAnthropic', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const blockStart = events.find(
|
const blockStart = events.find(event => event.type === 'content_block_start')
|
||||||
event => event.type === 'content_block_start',
|
|
||||||
)
|
|
||||||
expect(blockStart.content_block.type).toBe('tool_use')
|
expect(blockStart.content_block.type).toBe('tool_use')
|
||||||
expect(blockStart.content_block.name).toBe('bash')
|
expect(blockStart.content_block.name).toBe('bash')
|
||||||
|
|
||||||
@@ -2,9 +2,8 @@ import type {
|
|||||||
BetaToolResultBlockParam,
|
BetaToolResultBlockParam,
|
||||||
BetaToolUseBlock,
|
BetaToolUseBlock,
|
||||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
import type { AssistantMessage, UserMessage } from '../../types/message.js'
|
||||||
import { safeParseJSON } from '../../../utils/json.js'
|
import type { SystemPrompt } from '../../types/systemPrompt.js'
|
||||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
|
||||||
import {
|
import {
|
||||||
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||||
type GeminiContent,
|
type GeminiContent,
|
||||||
@@ -12,6 +11,16 @@ import {
|
|||||||
type GeminiPart,
|
type GeminiPart,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
// Simple JSON parse utility (replaces safeParseJSON from main project)
|
||||||
|
function safeParseJSON(json: string | null | undefined): unknown {
|
||||||
|
if (!json) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(json)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function anthropicMessagesToGemini(
|
export function anthropicMessagesToGemini(
|
||||||
messages: (UserMessage | AssistantMessage)[],
|
messages: (UserMessage | AssistantMessage)[],
|
||||||
systemPrompt: SystemPrompt,
|
systemPrompt: SystemPrompt,
|
||||||
@@ -84,10 +93,7 @@ function convertInternalUserMessage(
|
|||||||
return {
|
return {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: content.flatMap(block =>
|
parts: content.flatMap(block =>
|
||||||
convertUserContentBlockToGeminiParts(
|
convertUserContentBlockToGeminiParts(block as unknown as string | Record<string, unknown>, toolNamesById),
|
||||||
block as unknown as string | Record<string, unknown>,
|
|
||||||
toolNamesById,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,15 +115,14 @@ function convertUserContentBlockToGeminiParts(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
name:
|
name: toolNamesById.get(toolResult.tool_use_id) ?? toolResult.tool_use_id,
|
||||||
toolNamesById.get(toolResult.tool_use_id) ?? toolResult.tool_use_id,
|
|
||||||
response: toolResultToResponseObject(toolResult),
|
response: toolResultToResponseObject(toolResult),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 Anthropic image 块转换为 Gemini inlineData
|
// Convert Anthropic image blocks to Gemini inlineData
|
||||||
if (block.type === 'image') {
|
if (block.type === 'image') {
|
||||||
const source = block.source as Record<string, unknown> | undefined
|
const source = block.source as Record<string, unknown> | undefined
|
||||||
if (source?.type === 'base64' && typeof source.data === 'string') {
|
if (source?.type === 'base64' && typeof source.data === 'string') {
|
||||||
@@ -131,7 +136,7 @@ function convertUserContentBlockToGeminiParts(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
// url 类型的图片,Gemini 不直接支持,转为文本描述
|
// URL images not directly supported by Gemini, convert to text description
|
||||||
if (source?.type === 'url' && typeof source.url === 'string') {
|
if (source?.type === 'url' && typeof source.url === 'string') {
|
||||||
return createTextGeminiParts(`[image: ${source.url}]`)
|
return createTextGeminiParts(`[image: ${source.url}]`)
|
||||||
}
|
}
|
||||||
@@ -165,9 +170,7 @@ function convertInternalAssistantMessage(msg: AssistantMessage): GeminiContent {
|
|||||||
parts.push(
|
parts.push(
|
||||||
...createTextGeminiParts(
|
...createTextGeminiParts(
|
||||||
block.text,
|
block.text,
|
||||||
getGeminiThoughtSignature(
|
getGeminiThoughtSignature(block as unknown as Record<string, unknown>),
|
||||||
block as unknown as Record<string, unknown>,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -191,12 +194,8 @@ function convertInternalAssistantMessage(msg: AssistantMessage): GeminiContent {
|
|||||||
name: toolUse.name,
|
name: toolUse.name,
|
||||||
args: normalizeToolUseInput(toolUse.input),
|
args: normalizeToolUseInput(toolUse.input),
|
||||||
},
|
},
|
||||||
...(getGeminiThoughtSignature(
|
...(getGeminiThoughtSignature(block as unknown as Record<string, unknown>) && {
|
||||||
block as unknown as Record<string, unknown>,
|
thoughtSignature: getGeminiThoughtSignature(block as unknown as Record<string, unknown>),
|
||||||
) && {
|
|
||||||
thoughtSignature: getGeminiThoughtSignature(
|
|
||||||
block as unknown as Record<string, unknown>,
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -256,10 +255,12 @@ function toolResultToResponseObject(
|
|||||||
block: BetaToolResultBlockParam,
|
block: BetaToolResultBlockParam,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const result = normalizeToolResultContent(block.content)
|
const result = normalizeToolResultContent(block.content)
|
||||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
if (
|
||||||
return block.is_error
|
result &&
|
||||||
? { ...(result as Record<string, unknown>), is_error: true }
|
typeof result === 'object' &&
|
||||||
: (result as Record<string, unknown>)
|
!Array.isArray(result)
|
||||||
|
) {
|
||||||
|
return block.is_error ? { ...(result as Record<string, unknown>), is_error: true } : result as Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -298,9 +299,7 @@ function normalizeToolResultContent(content: unknown): unknown {
|
|||||||
return content ?? ''
|
return content ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGeminiThoughtSignature(
|
function getGeminiThoughtSignature(block: Record<string, unknown>): string | undefined {
|
||||||
block: Record<string, unknown>,
|
|
||||||
): string | undefined {
|
|
||||||
const signature = block[GEMINI_THOUGHT_SIGNATURE_FIELD]
|
const signature = block[GEMINI_THOUGHT_SIGNATURE_FIELD]
|
||||||
return typeof signature === 'string' && signature.length > 0
|
return typeof signature === 'string' && signature.length > 0
|
||||||
? signature
|
? signature
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
import type { GeminiFunctionCallingConfig, GeminiTool } from './types.js'
|
import type {
|
||||||
|
GeminiFunctionCallingConfig,
|
||||||
|
GeminiTool,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
const GEMINI_JSON_SCHEMA_TYPES = new Set([
|
const GEMINI_JSON_SCHEMA_TYPES = new Set([
|
||||||
'string',
|
'string',
|
||||||
@@ -31,9 +34,7 @@ function normalizeGeminiJsonSchemaType(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferGeminiJsonSchemaTypeFromValue(
|
function inferGeminiJsonSchemaTypeFromValue(value: unknown): string | undefined {
|
||||||
value: unknown,
|
|
||||||
): string | undefined {
|
|
||||||
if (value === null) return 'null'
|
if (value === null) return 'null'
|
||||||
if (Array.isArray(value)) return 'array'
|
if (Array.isArray(value)) return 'array'
|
||||||
if (typeof value === 'string') return 'string'
|
if (typeof value === 'string') return 'string'
|
||||||
@@ -96,7 +97,9 @@ function sanitizeGeminiJsonSchemaArray(
|
|||||||
return sanitized.length > 0 ? sanitized : undefined
|
return sanitized.length > 0 ? sanitized : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeGeminiJsonSchema(schema: unknown): Record<string, unknown> {
|
function sanitizeGeminiJsonSchema(
|
||||||
|
schema: unknown,
|
||||||
|
): Record<string, unknown> {
|
||||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -233,20 +236,17 @@ export function anthropicToolsToGemini(tools: BetaToolUnion[]): GeminiTool[] {
|
|||||||
const functionDeclarations = tools
|
const functionDeclarations = tools
|
||||||
.filter(tool => {
|
.filter(tool => {
|
||||||
const toolType = (tool as unknown as { type?: string }).type
|
const toolType = (tool as unknown as { type?: string }).type
|
||||||
return (
|
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||||
tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.map(tool => {
|
.map(tool => {
|
||||||
const anyTool = tool as unknown as Record<string, unknown>
|
const anyTool = tool as unknown as Record<string, unknown>
|
||||||
const name = (anyTool.name as string) || ''
|
const name = (anyTool.name as string) || ''
|
||||||
const description = (anyTool.description as string) || ''
|
const description = (anyTool.description as string) || ''
|
||||||
const inputSchema = (anyTool.input_schema as
|
const inputSchema =
|
||||||
| Record<string, unknown>
|
(anyTool.input_schema as Record<string, unknown> | undefined) ?? {
|
||||||
| undefined) ?? {
|
type: 'object',
|
||||||
type: 'object',
|
properties: {},
|
||||||
properties: {},
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -255,7 +255,9 @@ export function anthropicToolsToGemini(tools: BetaToolUnion[]): GeminiTool[] {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return functionDeclarations.length > 0 ? [{ functionDeclarations }] : []
|
return functionDeclarations.length > 0
|
||||||
|
? [{ functionDeclarations }]
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function anthropicToolChoiceToGemini(
|
export function anthropicToolChoiceToGemini(
|
||||||
@@ -17,14 +17,12 @@ export function resolveGeminiModel(anthropicModel: string): string {
|
|||||||
return cleanModel
|
return cleanModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, try Gemini-specific DEFAULT variables (separated from Anthropic)
|
|
||||||
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
|
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const geminiModel = process.env[geminiEnvVar]
|
const geminiModel = process.env[geminiEnvVar]
|
||||||
if (geminiModel) {
|
if (geminiModel) {
|
||||||
return geminiModel
|
return geminiModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to Anthropic DEFAULT variables for backward compatibility
|
|
||||||
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const resolvedModel = process.env[sharedEnvVar]
|
const resolvedModel = process.env[sharedEnvVar]
|
||||||
if (resolvedModel) {
|
if (resolvedModel) {
|
||||||
@@ -10,8 +10,9 @@ export async function* adaptGeminiStreamToAnthropic(
|
|||||||
let started = false
|
let started = false
|
||||||
let stopped = false
|
let stopped = false
|
||||||
let nextContentIndex = 0
|
let nextContentIndex = 0
|
||||||
let openTextLikeBlock: { index: number; type: 'text' | 'thinking' } | null =
|
let openTextLikeBlock:
|
||||||
null
|
| { index: number; type: 'text' | 'thinking' }
|
||||||
|
| null = null
|
||||||
let sawToolUse = false
|
let sawToolUse = false
|
||||||
let finishReason: string | undefined
|
let finishReason: string | undefined
|
||||||
let inputTokens = 0
|
let inputTokens = 0
|
||||||
@@ -84,10 +85,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
|||||||
} as BetaRawMessageStreamEvent
|
} as BetaRawMessageStreamEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (part.functionCall.args && Object.keys(part.functionCall.args).length > 0) {
|
||||||
part.functionCall.args &&
|
|
||||||
Object.keys(part.functionCall.args).length > 0
|
|
||||||
) {
|
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_delta',
|
type: 'content_block_delta',
|
||||||
index: toolIndex,
|
index: toolIndex,
|
||||||
@@ -215,7 +213,9 @@ export async function* adaptGeminiStreamToAnthropic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextLikeBlockType(part: GeminiPart): 'text' | 'thinking' | null {
|
function getTextLikeBlockType(
|
||||||
|
part: GeminiPart,
|
||||||
|
): 'text' | 'thinking' | null {
|
||||||
if (typeof part.text !== 'string') {
|
if (typeof part.text !== 'string') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -33,14 +33,11 @@ describe('resolveGrokModel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('maps haiku models to grok-3-mini-fast', () => {
|
test('maps haiku models to grok-3-mini-fast', () => {
|
||||||
expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe(
|
expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-3-mini-fast')
|
||||||
'grok-3-mini-fast',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GROK_MODEL_MAP overrides family mapping', () => {
|
test('GROK_MODEL_MAP overrides family mapping', () => {
|
||||||
process.env.GROK_MODEL_MAP =
|
process.env.GROK_MODEL_MAP = '{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-mini"}'
|
||||||
'{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-mini"}'
|
|
||||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4')
|
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4')
|
||||||
expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-3')
|
expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-3')
|
||||||
expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-mini')
|
expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-mini')
|
||||||
@@ -65,8 +62,6 @@ describe('resolveGrokModel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('falls back to family default for unlisted model', () => {
|
test('falls back to family default for unlisted model', () => {
|
||||||
expect(resolveGrokModel('claude-opus-99-20300101')).toBe(
|
expect(resolveGrokModel('claude-opus-99-20300101')).toBe('grok-4.20-reasoning')
|
||||||
'grok-4.20-reasoning',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
* Default mapping from Anthropic model names to Grok model names.
|
* Default mapping from Anthropic model names to Grok model names.
|
||||||
*
|
*
|
||||||
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
||||||
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string):
|
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string).
|
||||||
* GROK_MODEL_MAP='{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}'
|
|
||||||
*/
|
*/
|
||||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||||
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
||||||
@@ -19,9 +18,6 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
|
|||||||
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Family-level mapping defaults (used by GROK_MODEL_MAP).
|
|
||||||
*/
|
|
||||||
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
||||||
opus: 'grok-4.20-reasoning',
|
opus: 'grok-4.20-reasoning',
|
||||||
sonnet: 'grok-3-mini-fast',
|
sonnet: 'grok-3-mini-fast',
|
||||||
@@ -35,10 +31,6 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse user-provided model map from GROK_MODEL_MAP env var.
|
|
||||||
* Accepts JSON like: {"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}
|
|
||||||
*/
|
|
||||||
function getUserModelMap(): Record<string, string> | null {
|
function getUserModelMap(): Record<string, string> | null {
|
||||||
const raw = process.env.GROK_MODEL_MAP
|
const raw = process.env.GROK_MODEL_MAP
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
@@ -55,18 +47,8 @@ function getUserModelMap(): Record<string, string> | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the Grok model name for a given Anthropic model.
|
* Resolve the Grok model name for a given Anthropic model.
|
||||||
*
|
|
||||||
* Priority:
|
|
||||||
* 1. GROK_MODEL env var (override all)
|
|
||||||
* 2. GROK_MODEL_MAP env var — JSON family map (e.g. {"opus":"grok-4"})
|
|
||||||
* 3. GROK_DEFAULT_{FAMILY}_MODEL env var (e.g. GROK_DEFAULT_OPUS_MODEL)
|
|
||||||
* 4. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compat)
|
|
||||||
* 5. DEFAULT_MODEL_MAP lookup
|
|
||||||
* 6. Family-level default
|
|
||||||
* 7. Pass through original model name
|
|
||||||
*/
|
*/
|
||||||
export function resolveGrokModel(anthropicModel: string): string {
|
export function resolveGrokModel(anthropicModel: string): string {
|
||||||
// 1. Global override
|
|
||||||
if (process.env.GROK_MODEL) {
|
if (process.env.GROK_MODEL) {
|
||||||
return process.env.GROK_MODEL
|
return process.env.GROK_MODEL
|
||||||
}
|
}
|
||||||
@@ -74,34 +56,28 @@ export function resolveGrokModel(anthropicModel: string): string {
|
|||||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||||
const family = getModelFamily(cleanModel)
|
const family = getModelFamily(cleanModel)
|
||||||
|
|
||||||
// 2. User-provided model map
|
|
||||||
const userMap = getUserModelMap()
|
const userMap = getUserModelMap()
|
||||||
if (userMap && family && userMap[family]) {
|
if (userMap && family && userMap[family]) {
|
||||||
return userMap[family]
|
return userMap[family]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (family) {
|
if (family) {
|
||||||
// 3. Grok-specific family override
|
|
||||||
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const grokOverride = process.env[grokEnvVar]
|
const grokOverride = process.env[grokEnvVar]
|
||||||
if (grokOverride) return grokOverride
|
if (grokOverride) return grokOverride
|
||||||
|
|
||||||
// 4. Anthropic env var (backward compat)
|
|
||||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const anthropicOverride = process.env[anthropicEnvVar]
|
const anthropicOverride = process.env[anthropicEnvVar]
|
||||||
if (anthropicOverride) return anthropicOverride
|
if (anthropicOverride) return anthropicOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Exact model name lookup
|
|
||||||
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
||||||
return DEFAULT_MODEL_MAP[cleanModel]
|
return DEFAULT_MODEL_MAP[cleanModel]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Family-level default
|
|
||||||
if (family && DEFAULT_FAMILY_MAP[family]) {
|
if (family && DEFAULT_FAMILY_MAP[family]) {
|
||||||
return DEFAULT_FAMILY_MAP[family]
|
return DEFAULT_FAMILY_MAP[family]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Pass through
|
|
||||||
return cleanModel
|
return cleanModel
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,6 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
|
|||||||
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the model family (haiku / sonnet / opus) from an Anthropic model ID.
|
|
||||||
*/
|
|
||||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||||
if (/haiku/i.test(model)) return 'haiku'
|
if (/haiku/i.test(model)) return 'haiku'
|
||||||
if (/opus/i.test(model)) return 'opus'
|
if (/opus/i.test(model)) return 'opus'
|
||||||
@@ -37,23 +34,18 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
|||||||
* 5. Pass through original model name
|
* 5. Pass through original model name
|
||||||
*/
|
*/
|
||||||
export function resolveOpenAIModel(anthropicModel: string): string {
|
export function resolveOpenAIModel(anthropicModel: string): string {
|
||||||
// Highest priority: explicit override
|
|
||||||
if (process.env.OPENAI_MODEL) {
|
if (process.env.OPENAI_MODEL) {
|
||||||
return process.env.OPENAI_MODEL
|
return process.env.OPENAI_MODEL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip [1m] suffix if present (Claude-specific modifier)
|
|
||||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||||
|
|
||||||
// Check family-specific overrides
|
|
||||||
const family = getModelFamily(cleanModel)
|
const family = getModelFamily(cleanModel)
|
||||||
if (family) {
|
if (family) {
|
||||||
// OpenAI-specific family override (preferred for openai provider)
|
|
||||||
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const openaiOverride = process.env[openaiEnvVar]
|
const openaiOverride = process.env[openaiEnvVar]
|
||||||
if (openaiOverride) return openaiOverride
|
if (openaiOverride) return openaiOverride
|
||||||
|
|
||||||
// Anthropic env var (backward compatibility)
|
|
||||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const anthropicOverride = process.env[anthropicEnvVar]
|
const anthropicOverride = process.env[anthropicEnvVar]
|
||||||
if (anthropicOverride) return anthropicOverride
|
if (anthropicOverride) return anthropicOverride
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { anthropicMessagesToOpenAI } from '../convertMessages.js'
|
import { anthropicMessagesToOpenAI } from '../openaiConvertMessages.js'
|
||||||
import type {
|
import type { UserMessage, AssistantMessage } from '../../types/message.js'
|
||||||
UserMessage,
|
|
||||||
AssistantMessage,
|
|
||||||
} from '../../../../types/message.js'
|
|
||||||
|
|
||||||
// Helpers to create internal-format messages
|
// Helpers to create internal-format messages
|
||||||
function makeUserMsg(content: string | any[]): UserMessage {
|
function makeUserMsg(content: string | any[]): UserMessage {
|
||||||
@@ -24,22 +21,26 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
|||||||
|
|
||||||
describe('anthropicMessagesToOpenAI', () => {
|
describe('anthropicMessagesToOpenAI', () => {
|
||||||
test('converts system prompt to system message', () => {
|
test('converts system prompt to system message', () => {
|
||||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hello')], [
|
const result = anthropicMessagesToOpenAI(
|
||||||
'You are helpful.',
|
[makeUserMsg('hello')],
|
||||||
] as any)
|
['You are helpful.'] as any,
|
||||||
|
)
|
||||||
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('joins multiple system prompt strings', () => {
|
test('joins multiple system prompt strings', () => {
|
||||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [
|
const result = anthropicMessagesToOpenAI(
|
||||||
'Part 1',
|
[makeUserMsg('hi')],
|
||||||
'Part 2',
|
['Part 1', 'Part 2'] as any,
|
||||||
] as any)
|
)
|
||||||
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
|
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('skips empty system prompt', () => {
|
test('skips empty system prompt', () => {
|
||||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [] as any)
|
const result = anthropicMessagesToOpenAI(
|
||||||
|
[makeUserMsg('hi')],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
expect(result[0].role).toBe('user')
|
expect(result[0].role).toBe('user')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,12 +54,10 @@ describe('anthropicMessagesToOpenAI', () => {
|
|||||||
|
|
||||||
test('converts user message with content array', () => {
|
test('converts user message with content array', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{ type: 'text', text: 'line 1' },
|
||||||
{ type: 'text', text: 'line 1' },
|
{ type: 'text', text: 'line 2' },
|
||||||
{ type: 'text', text: 'line 2' },
|
])],
|
||||||
]),
|
|
||||||
],
|
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
|
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
|
||||||
@@ -74,64 +73,52 @@ describe('anthropicMessagesToOpenAI', () => {
|
|||||||
|
|
||||||
test('converts assistant message with tool_use', () => {
|
test('converts assistant message with tool_use', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeAssistantMsg([
|
||||||
makeAssistantMsg([
|
{ type: 'text', text: 'Let me help.' },
|
||||||
{ type: 'text', text: 'Let me help.' },
|
{
|
||||||
{
|
type: 'tool_use' as const,
|
||||||
type: 'tool_use' as const,
|
id: 'toolu_123',
|
||||||
id: 'toolu_123',
|
name: 'bash',
|
||||||
name: 'bash',
|
input: { command: 'ls' },
|
||||||
input: { command: 'ls' },
|
},
|
||||||
},
|
])],
|
||||||
]),
|
|
||||||
],
|
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([{
|
||||||
{
|
role: 'assistant',
|
||||||
role: 'assistant',
|
content: 'Let me help.',
|
||||||
content: 'Let me help.',
|
tool_calls: [{
|
||||||
tool_calls: [
|
id: 'toolu_123',
|
||||||
{
|
type: 'function',
|
||||||
id: 'toolu_123',
|
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||||
type: 'function',
|
}],
|
||||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
}])
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('converts tool_result to tool message', () => {
|
test('converts tool_result to tool message', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{
|
||||||
{
|
type: 'tool_result' as const,
|
||||||
type: 'tool_result' as const,
|
tool_use_id: 'toolu_123',
|
||||||
tool_use_id: 'toolu_123',
|
content: 'file1.txt\nfile2.txt',
|
||||||
content: 'file1.txt\nfile2.txt',
|
},
|
||||||
},
|
])],
|
||||||
]),
|
|
||||||
],
|
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([{
|
||||||
{
|
role: 'tool',
|
||||||
role: 'tool',
|
tool_call_id: 'toolu_123',
|
||||||
tool_call_id: 'toolu_123',
|
content: 'file1.txt\nfile2.txt',
|
||||||
content: 'file1.txt\nfile2.txt',
|
}])
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('strips thinking blocks', () => {
|
test('strips thinking blocks', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeAssistantMsg([
|
||||||
makeAssistantMsg([
|
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
{ type: 'text', text: 'visible response' },
|
||||||
{ type: 'text', text: 'visible response' },
|
])],
|
||||||
]),
|
|
||||||
],
|
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
|
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
|
||||||
@@ -170,105 +157,91 @@ describe('anthropicMessagesToOpenAI', () => {
|
|||||||
|
|
||||||
test('converts base64 image to image_url', () => {
|
test('converts base64 image to image_url', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{ type: 'text', text: 'what is this?' },
|
||||||
{ type: 'text', text: 'what is this?' },
|
{
|
||||||
{
|
type: 'image' as const,
|
||||||
type: 'image' as const,
|
source: {
|
||||||
source: {
|
type: 'base64',
|
||||||
type: 'base64',
|
media_type: 'image/png',
|
||||||
media_type: 'image/png',
|
data: 'iVBORw0KGgo=',
|
||||||
data: 'iVBORw0KGgo=',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
],
|
])],
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([{
|
||||||
{
|
role: 'user',
|
||||||
role: 'user',
|
content: [
|
||||||
content: [
|
{ type: 'text', text: 'what is this?' },
|
||||||
{ type: 'text', text: 'what is this?' },
|
{
|
||||||
{
|
type: 'image_url',
|
||||||
type: 'image_url',
|
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
},
|
||||||
},
|
],
|
||||||
],
|
}])
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('converts url image to image_url', () => {
|
test('converts url image to image_url', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{
|
||||||
{
|
type: 'image' as const,
|
||||||
type: 'image' as const,
|
source: {
|
||||||
source: {
|
type: 'url',
|
||||||
type: 'url',
|
url: 'https://example.com/img.png',
|
||||||
url: 'https://example.com/img.png',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
],
|
])],
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([{
|
||||||
{
|
role: 'user',
|
||||||
role: 'user',
|
content: [
|
||||||
content: [
|
{
|
||||||
{
|
type: 'image_url',
|
||||||
type: 'image_url',
|
image_url: { url: 'https://example.com/img.png' },
|
||||||
image_url: { url: 'https://example.com/img.png' },
|
},
|
||||||
},
|
],
|
||||||
],
|
}])
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('converts image-only message without text', () => {
|
test('converts image-only message without text', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{
|
||||||
{
|
type: 'image' as const,
|
||||||
type: 'image' as const,
|
source: {
|
||||||
source: {
|
type: 'base64',
|
||||||
type: 'base64',
|
media_type: 'image/jpeg',
|
||||||
media_type: 'image/jpeg',
|
data: '/9j/4AAQ',
|
||||||
data: '/9j/4AAQ',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
],
|
])],
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([{
|
||||||
{
|
role: 'user',
|
||||||
role: 'user',
|
content: [
|
||||||
content: [
|
{
|
||||||
{
|
type: 'image_url',
|
||||||
type: 'image_url',
|
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
||||||
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
},
|
||||||
},
|
],
|
||||||
],
|
}])
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('defaults to image/png when media_type is missing', () => {
|
test('defaults to image/png when media_type is missing', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg([
|
||||||
makeUserMsg([
|
{
|
||||||
{
|
type: 'image' as const,
|
||||||
type: 'image' as const,
|
source: {
|
||||||
source: {
|
type: 'base64',
|
||||||
type: 'base64',
|
data: 'ABC123',
|
||||||
data: 'ABC123',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
],
|
])],
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
expect((result[0].content as any[])[0].image_url.url).toBe(
|
expect((result[0].content as any[])[0].image_url.url).toBe(
|
||||||
@@ -280,16 +253,10 @@ describe('anthropicMessagesToOpenAI', () => {
|
|||||||
describe('DeepSeek thinking mode (enableThinking)', () => {
|
describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||||
test('preserves thinking block as reasoning_content when enabled', () => {
|
test('preserves thinking block as reasoning_content when enabled', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg('question'), makeAssistantMsg([
|
||||||
makeUserMsg('question'),
|
{ type: 'thinking' as const, thinking: 'Let me reason about this...' },
|
||||||
makeAssistantMsg([
|
{ type: 'text', text: 'The answer is 42.' },
|
||||||
{
|
])],
|
||||||
type: 'thinking' as const,
|
|
||||||
thinking: 'Let me reason about this...',
|
|
||||||
},
|
|
||||||
{ type: 'text', text: 'The answer is 42.' },
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
[] as any,
|
[] as any,
|
||||||
{ enableThinking: true },
|
{ enableThinking: true },
|
||||||
)
|
)
|
||||||
@@ -304,12 +271,10 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
|||||||
|
|
||||||
test('drops thinking block when enableThinking is false (default)', () => {
|
test('drops thinking block when enableThinking is false (default)', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeAssistantMsg([
|
||||||
makeAssistantMsg([
|
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
{ type: 'text', text: 'visible response' },
|
||||||
{ type: 'text', text: 'visible response' },
|
])],
|
||||||
]),
|
|
||||||
],
|
|
||||||
[] as any,
|
[] as any,
|
||||||
)
|
)
|
||||||
const assistant = result[0] as any
|
const assistant = result[0] as any
|
||||||
@@ -322,10 +287,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
|||||||
[
|
[
|
||||||
makeUserMsg('what is the weather?'),
|
makeUserMsg('what is the weather?'),
|
||||||
makeAssistantMsg([
|
makeAssistantMsg([
|
||||||
{
|
{ type: 'thinking' as const, thinking: 'I need to call the weather tool.' },
|
||||||
type: 'thinking' as const,
|
|
||||||
thinking: 'I need to call the weather tool.',
|
|
||||||
},
|
|
||||||
{ type: 'text', text: '' },
|
{ type: 'text', text: '' },
|
||||||
{
|
{
|
||||||
type: 'tool_use' as const,
|
type: 'tool_use' as const,
|
||||||
@@ -434,34 +396,21 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
|||||||
{ enableThinking: true },
|
{ enableThinking: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// All 3 assistant messages are in the current turn (after last user msg is the last tool_result,
|
|
||||||
// but the "last user message" boundary logic finds the last user-typed message).
|
|
||||||
// Actually, tool_result messages are also UserMessage type, so the last user message
|
|
||||||
// is the one with tool_result for toolu_002. All assistant messages after that should have reasoning.
|
|
||||||
const assistants = result.filter(m => m.role === 'assistant')
|
const assistants = result.filter(m => m.role === 'assistant')
|
||||||
expect(assistants.length).toBe(3)
|
expect(assistants.length).toBe(3)
|
||||||
// All iterations within the same turn preserve reasoning
|
// All iterations within the same turn preserve reasoning
|
||||||
expect((assistants[0] as any).reasoning_content).toBe(
|
expect((assistants[0] as any).reasoning_content).toBe('I need the date first.')
|
||||||
'I need the date first.',
|
expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.')
|
||||||
)
|
expect((assistants[2] as any).reasoning_content).toBe('I have the info now.')
|
||||||
expect((assistants[1] as any).reasoning_content).toBe(
|
|
||||||
'Now I can get the weather.',
|
|
||||||
)
|
|
||||||
expect((assistants[2] as any).reasoning_content).toBe(
|
|
||||||
'I have the info now.',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('handles multiple thinking blocks in single assistant message', () => {
|
test('handles multiple thinking blocks in single assistant message', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg('question'), makeAssistantMsg([
|
||||||
makeUserMsg('question'),
|
{ type: 'thinking' as const, thinking: 'First thought.' },
|
||||||
makeAssistantMsg([
|
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
||||||
{ type: 'thinking' as const, thinking: 'First thought.' },
|
{ type: 'text', text: 'Final answer.' },
|
||||||
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
])],
|
||||||
{ type: 'text', text: 'Final answer.' },
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
[] as any,
|
[] as any,
|
||||||
{ enableThinking: true },
|
{ enableThinking: true },
|
||||||
)
|
)
|
||||||
@@ -471,13 +420,10 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
|||||||
|
|
||||||
test('skips empty thinking blocks', () => {
|
test('skips empty thinking blocks', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[makeUserMsg('question'), makeAssistantMsg([
|
||||||
makeUserMsg('question'),
|
{ type: 'thinking' as const, thinking: '' },
|
||||||
makeAssistantMsg([
|
{ type: 'text', text: 'Answer.' },
|
||||||
{ type: 'thinking' as const, thinking: '' },
|
])],
|
||||||
{ type: 'text', text: 'Answer.' },
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
[] as any,
|
[] as any,
|
||||||
{ enableThinking: true },
|
{ enableThinking: true },
|
||||||
)
|
)
|
||||||
@@ -485,21 +431,66 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
|||||||
expect(assistant.reasoning_content).toBeUndefined()
|
expect(assistant.reasoning_content).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sets content to null when only thinking and tool_calls present', () => {
|
// ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ──
|
||||||
|
|
||||||
|
test('tool messages come BEFORE user text when mixed in same turn', () => {
|
||||||
|
// OpenAI requires: assistant(tool_calls) → tool → user
|
||||||
|
// Bug: previously user text was emitted before tool messages
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[
|
[
|
||||||
makeUserMsg('question'),
|
makeUserMsg('run ls'),
|
||||||
makeAssistantMsg([
|
makeAssistantMsg([
|
||||||
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
{ type: 'tool_use' as const, id: 'toolu_1', name: 'bash', input: { command: 'ls' } },
|
||||||
{
|
]),
|
||||||
type: 'tool_use' as const,
|
makeUserMsg([
|
||||||
id: 'toolu_001',
|
{ type: 'tool_result' as const, tool_use_id: 'toolu_1', content: 'file.txt' },
|
||||||
name: 'bash',
|
{ type: 'text' as const, text: 'looks good' },
|
||||||
input: { command: 'ls' },
|
|
||||||
},
|
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
[] as any,
|
[] as any,
|
||||||
|
)
|
||||||
|
// Find the tool message and the user text message
|
||||||
|
const toolIdx = result.findIndex(m => m.role === 'tool')
|
||||||
|
const userTextIdx = result.findIndex(
|
||||||
|
m => m.role === 'user' && typeof m.content === 'string' && m.content.includes('looks good'),
|
||||||
|
)
|
||||||
|
expect(toolIdx).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(userTextIdx).toBeGreaterThanOrEqual(0)
|
||||||
|
// Tool MUST come before user text
|
||||||
|
expect(toolIdx).toBeLessThan(userTextIdx)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tool message immediately follows assistant tool_calls (no user message in between)', () => {
|
||||||
|
const result = anthropicMessagesToOpenAI(
|
||||||
|
[
|
||||||
|
makeUserMsg('do something'),
|
||||||
|
makeAssistantMsg([
|
||||||
|
{ type: 'tool_use' as const, id: 'toolu_2', name: 'bash', input: { command: 'pwd' } },
|
||||||
|
]),
|
||||||
|
makeUserMsg([
|
||||||
|
{ type: 'tool_result' as const, tool_use_id: 'toolu_2', content: '/home/user' },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
|
const assistantIdx = result.findIndex(m => m.role === 'assistant' && (m as any).tool_calls)
|
||||||
|
const toolIdx = result.findIndex(m => m.role === 'tool')
|
||||||
|
expect(assistantIdx).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(toolIdx).toBe(assistantIdx + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sets content to null when only thinking and tool_calls present', () => {
|
||||||
|
const result = anthropicMessagesToOpenAI(
|
||||||
|
[makeUserMsg('question'), makeAssistantMsg([
|
||||||
|
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
||||||
|
{
|
||||||
|
type: 'tool_use' as const,
|
||||||
|
id: 'toolu_001',
|
||||||
|
name: 'bash',
|
||||||
|
input: { command: 'ls' },
|
||||||
|
},
|
||||||
|
])],
|
||||||
|
[] as any,
|
||||||
{ enableThinking: true },
|
{ enableThinking: true },
|
||||||
)
|
)
|
||||||
const assistant = result.filter(m => m.role === 'assistant')[0] as any
|
const assistant = result.filter(m => m.role === 'assistant')[0] as any
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import {
|
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../openaiConvertTools.js'
|
||||||
anthropicToolsToOpenAI,
|
|
||||||
anthropicToolChoiceToOpenAI,
|
|
||||||
} from '../convertTools.js'
|
|
||||||
|
|
||||||
describe('anthropicToolsToOpenAI', () => {
|
describe('anthropicToolsToOpenAI', () => {
|
||||||
test('converts basic tool', () => {
|
test('converts basic tool', () => {
|
||||||
@@ -21,29 +18,25 @@ describe('anthropicToolsToOpenAI', () => {
|
|||||||
|
|
||||||
const result = anthropicToolsToOpenAI(tools as any)
|
const result = anthropicToolsToOpenAI(tools as any)
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([{
|
||||||
{
|
type: 'function',
|
||||||
type: 'function',
|
function: {
|
||||||
function: {
|
name: 'bash',
|
||||||
name: 'bash',
|
description: 'Run a bash command',
|
||||||
description: 'Run a bash command',
|
parameters: {
|
||||||
parameters: {
|
type: 'object',
|
||||||
type: 'object',
|
properties: { command: { type: 'string' } },
|
||||||
properties: { command: { type: 'string' } },
|
required: ['command'],
|
||||||
required: ['command'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('uses empty schema when input_schema missing', () => {
|
test('uses empty schema when input_schema missing', () => {
|
||||||
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
|
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
|
||||||
const result = anthropicToolsToOpenAI(tools as any)
|
const result = anthropicToolsToOpenAI(tools as any)
|
||||||
|
|
||||||
expect(
|
expect((result[0] as { function: { parameters: unknown } }).function.parameters).toEqual({ type: 'object', properties: {} })
|
||||||
(result[0] as { function: { parameters: unknown } }).function.parameters,
|
|
||||||
).toEqual({ type: 'object', properties: {} })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('strips Anthropic-specific fields', () => {
|
test('strips Anthropic-specific fields', () => {
|
||||||
@@ -83,8 +76,7 @@ describe('anthropicToolsToOpenAI', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
const result = anthropicToolsToOpenAI(tools as any)
|
const result = anthropicToolsToOpenAI(tools as any)
|
||||||
const props = (result[0] as { function: { parameters: any } }).function
|
const props = (result[0] as { function: { parameters: any } }).function.parameters as any
|
||||||
.parameters as any
|
|
||||||
expect(props.properties.mode).toEqual({ enum: ['read'] })
|
expect(props.properties.mode).toEqual({ enum: ['read'] })
|
||||||
expect(props.properties.mode.const).toBeUndefined()
|
expect(props.properties.mode.const).toBeUndefined()
|
||||||
expect(props.properties.name).toEqual({ type: 'string' })
|
expect(props.properties.name).toEqual({ type: 'string' })
|
||||||
@@ -118,11 +110,8 @@ describe('anthropicToolsToOpenAI', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
const result = anthropicToolsToOpenAI(tools as any)
|
const result = anthropicToolsToOpenAI(tools as any)
|
||||||
const params = (result[0] as { function: { parameters: any } }).function
|
const params = (result[0] as { function: { parameters: any } }).function.parameters as any
|
||||||
.parameters as any
|
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
|
||||||
expect(params.properties.outer.properties.inner).toEqual({
|
|
||||||
enum: ['fixed'],
|
|
||||||
})
|
|
||||||
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
|
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,17 +125,18 @@ describe('anthropicToolsToOpenAI', () => {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
val: {
|
val: {
|
||||||
anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }],
|
anyOf: [
|
||||||
|
{ const: 'a' },
|
||||||
|
{ const: 'b' },
|
||||||
|
{ type: 'string' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const result = anthropicToolsToOpenAI(tools as any)
|
const result = anthropicToolsToOpenAI(tools as any)
|
||||||
const anyOf = (
|
const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf
|
||||||
(result[0] as { function: { parameters: any } }).function
|
|
||||||
.parameters as any
|
|
||||||
).properties.val.anyOf
|
|
||||||
expect(anyOf[0]).toEqual({ enum: ['a'] })
|
expect(anyOf[0]).toEqual({ enum: ['a'] })
|
||||||
expect(anyOf[1]).toEqual({ enum: ['b'] })
|
expect(anyOf[1]).toEqual({ enum: ['b'] })
|
||||||
expect(anyOf[2]).toEqual({ type: 'string' })
|
expect(anyOf[2]).toEqual({ type: 'string' })
|
||||||
@@ -1,29 +1,9 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
||||||
import { join, dirname } from 'path'
|
import { adaptOpenAIStreamToAnthropic } from '../openaiStreamAdapter.js'
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
||||||
import { tmpdir } from 'os'
|
|
||||||
|
|
||||||
// Guard against mock pollution from queryModelOpenAI.test.ts which replaces
|
|
||||||
// ../streamAdapter.js process-wide via mock.module (bun has no un-mock API).
|
|
||||||
// We copy the source to a unique temp path so the import bypasses bun's
|
|
||||||
// module mock cache completely.
|
|
||||||
const _testDir = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const _realSource = readFileSync(
|
|
||||||
join(_testDir, '..', 'streamAdapter.ts'),
|
|
||||||
'utf-8',
|
|
||||||
)
|
|
||||||
const _tempDir = join(tmpdir(), `stream-adapter-test-${Date.now()}`)
|
|
||||||
mkdirSync(_tempDir, { recursive: true })
|
|
||||||
const _tempFile = join(_tempDir, 'streamAdapter.ts')
|
|
||||||
writeFileSync(_tempFile, _realSource, 'utf-8')
|
|
||||||
const { adaptOpenAIStreamToAnthropic } = await import(_tempFile)
|
|
||||||
|
|
||||||
/** Helper to create a mock async iterable from chunk array */
|
/** Helper to create a mock async iterable from chunk array */
|
||||||
function mockStream(
|
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {
|
||||||
chunks: ChatCompletionChunk[],
|
|
||||||
): AsyncIterable<ChatCompletionChunk> {
|
|
||||||
return {
|
return {
|
||||||
[Symbol.asyncIterator]() {
|
[Symbol.asyncIterator]() {
|
||||||
let i = 0
|
let i = 0
|
||||||
@@ -38,9 +18,7 @@ function mockStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create a minimal ChatCompletionChunk */
|
/** Create a minimal ChatCompletionChunk */
|
||||||
function makeChunk(
|
function makeChunk(overrides: Partial<ChatCompletionChunk> & any = {}): ChatCompletionChunk {
|
||||||
overrides: Partial<ChatCompletionChunk> & any = {},
|
|
||||||
): ChatCompletionChunk {
|
|
||||||
return {
|
return {
|
||||||
id: 'chatcmpl-test',
|
id: 'chatcmpl-test',
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -53,16 +31,8 @@ function makeChunk(
|
|||||||
|
|
||||||
/** Collect all emitted Anthropic events from the stream adapter for assertion */
|
/** Collect all emitted Anthropic events from the stream adapter for assertion */
|
||||||
async function collectEvents(chunks: ChatCompletionChunk[]) {
|
async function collectEvents(chunks: ChatCompletionChunk[]) {
|
||||||
const realModuleUrl = new URL(
|
|
||||||
`../streamAdapter.js?real=${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
import.meta.url,
|
|
||||||
).href
|
|
||||||
const { adaptOpenAIStreamToAnthropic } = await import(realModuleUrl)
|
|
||||||
const events: any[] = []
|
const events: any[] = []
|
||||||
for await (const event of adaptOpenAIStreamToAnthropic(
|
for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) {
|
||||||
mockStream(chunks),
|
|
||||||
'gpt-4o',
|
|
||||||
)) {
|
|
||||||
events.push(event)
|
events.push(event)
|
||||||
}
|
}
|
||||||
return events
|
return events
|
||||||
@@ -72,31 +42,25 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
test('emits message_start on first chunk', async () => {
|
test('emits message_start on first chunk', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { role: 'assistant', content: '' },
|
||||||
delta: { role: 'assistant', content: '' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { content: 'hello' },
|
||||||
delta: { content: 'hello' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {},
|
||||||
delta: {},
|
finish_reason: 'stop',
|
||||||
finish_reason: 'stop',
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -109,14 +73,10 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
test('converts text content stream', async () => {
|
test('converts text content stream', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{ index: 0, delta: { content: 'Hello' }, finish_reason: null }],
|
||||||
{ index: 0, delta: { content: 'Hello' }, finish_reason: null },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{ index: 0, delta: { content: ' world' }, finish_reason: null }],
|
||||||
{ index: 0, delta: { content: ' world' }, finish_reason: null },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
@@ -131,9 +91,7 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
expect(types).toContain('message_delta')
|
expect(types).toContain('message_delta')
|
||||||
expect(types).toContain('message_stop')
|
expect(types).toContain('message_stop')
|
||||||
|
|
||||||
const textDeltas = events.filter(
|
const textDeltas = events.filter(e => e.type === 'content_block_delta') as any[]
|
||||||
e => e.type === 'content_block_delta',
|
|
||||||
) as any[]
|
|
||||||
expect(textDeltas[0].delta.text).toBe('Hello')
|
expect(textDeltas[0].delta.text).toBe('Hello')
|
||||||
expect(textDeltas[1].delta.text).toBe(' world')
|
expect(textDeltas[1].delta.text).toBe(' world')
|
||||||
})
|
})
|
||||||
@@ -141,54 +99,42 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
test('converts tool_calls stream', async () => {
|
test('converts tool_calls stream', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {
|
||||||
delta: {
|
tool_calls: [{
|
||||||
tool_calls: [
|
index: 0,
|
||||||
{
|
id: 'call_abc',
|
||||||
index: 0,
|
type: 'function',
|
||||||
id: 'call_abc',
|
function: { name: 'bash', arguments: '' },
|
||||||
type: 'function',
|
}],
|
||||||
function: { name: 'bash', arguments: '' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
},
|
||||||
],
|
finish_reason: null,
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {
|
||||||
delta: {
|
tool_calls: [{
|
||||||
tool_calls: [
|
index: 0,
|
||||||
{
|
function: { arguments: '{"comm' },
|
||||||
index: 0,
|
}],
|
||||||
function: { arguments: '{"comm' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
},
|
||||||
],
|
finish_reason: null,
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {
|
||||||
delta: {
|
tool_calls: [{
|
||||||
tool_calls: [
|
index: 0,
|
||||||
{
|
function: { arguments: 'and":"ls"}' },
|
||||||
index: 0,
|
}],
|
||||||
function: { arguments: 'and":"ls"}' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
},
|
||||||
],
|
finish_reason: null,
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||||
@@ -200,8 +146,7 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
expect(blockStart.content_block.name).toBe('bash')
|
expect(blockStart.content_block.name).toBe('bash')
|
||||||
|
|
||||||
const jsonDeltas = events.filter(
|
const jsonDeltas = events.filter(
|
||||||
e =>
|
e => e.type === 'content_block_delta' && e.delta.type === 'input_json_delta',
|
||||||
e.type === 'content_block_delta' && e.delta.type === 'input_json_delta',
|
|
||||||
) as any[]
|
) as any[]
|
||||||
const fullArgs = jsonDeltas.map(d => d.delta.partial_json).join('')
|
const fullArgs = jsonDeltas.map(d => d.delta.partial_json).join('')
|
||||||
expect(fullArgs).toBe('{"command":"ls"}')
|
expect(fullArgs).toBe('{"command":"ls"}')
|
||||||
@@ -226,21 +171,13 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
// return finish_reason "stop" when they actually made tool calls.
|
// return finish_reason "stop" when they actually made tool calls.
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {
|
||||||
delta: {
|
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
id: 'call_1',
|
|
||||||
function: { name: 'bash', arguments: '{"cmd":"ls"}' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
},
|
||||||
],
|
finish_reason: null,
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
@@ -254,21 +191,13 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
test('maps finish_reason tool_calls to tool_use', async () => {
|
test('maps finish_reason tool_calls to tool_use', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {
|
||||||
delta: {
|
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{}' } }],
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
id: 'call_1',
|
|
||||||
function: { name: 'bash', arguments: '{}' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
},
|
||||||
],
|
finish_reason: null,
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||||
@@ -282,9 +211,7 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
test('maps finish_reason length to max_tokens', async () => {
|
test('maps finish_reason length to max_tokens', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{ index: 0, delta: { content: 'truncated' }, finish_reason: null }],
|
||||||
{ index: 0, delta: { content: 'truncated' }, finish_reason: null },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'length' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'length' }],
|
||||||
@@ -298,35 +225,23 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
test('handles mixed text and tool_calls', async () => {
|
test('handles mixed text and tool_calls', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{ index: 0, delta: { content: 'Thinking...' }, finish_reason: null }],
|
||||||
{ index: 0, delta: { content: 'Thinking...' }, finish_reason: null },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {
|
||||||
delta: {
|
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'grep', arguments: '{"p":"test"}' } }],
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
id: 'call_1',
|
|
||||||
function: { name: 'grep', arguments: '{"p":"test"}' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
},
|
||||||
],
|
finish_reason: null,
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const blockStarts = events.filter(
|
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||||
e => e.type === 'content_block_start',
|
|
||||||
) as any[]
|
|
||||||
expect(blockStarts.length).toBe(2)
|
expect(blockStarts.length).toBe(2)
|
||||||
expect(blockStarts[0].content_block.type).toBe('text')
|
expect(blockStarts[0].content_block.type).toBe('text')
|
||||||
expect(blockStarts[1].content_block.type).toBe('tool_use')
|
expect(blockStarts[1].content_block.type).toBe('tool_use')
|
||||||
@@ -337,22 +252,18 @@ describe('thinking support (reasoning_content)', () => {
|
|||||||
test('converts reasoning_content to thinking block', async () => {
|
test('converts reasoning_content to thinking block', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { reasoning_content: 'Let me analyze this...' },
|
||||||
delta: { reasoning_content: 'Let me analyze this...' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { reasoning_content: ' step by step.' },
|
||||||
delta: { reasoning_content: ' step by step.' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
@@ -366,8 +277,7 @@ describe('thinking support (reasoning_content)', () => {
|
|||||||
|
|
||||||
// Should have thinking_delta events
|
// Should have thinking_delta events
|
||||||
const thinkingDeltas = events.filter(
|
const thinkingDeltas = events.filter(
|
||||||
e =>
|
e => e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
|
||||||
e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
|
|
||||||
) as any[]
|
) as any[]
|
||||||
expect(thinkingDeltas.length).toBe(2)
|
expect(thinkingDeltas.length).toBe(2)
|
||||||
expect(thinkingDeltas[0].delta.thinking).toBe('Let me analyze this...')
|
expect(thinkingDeltas[0].delta.thinking).toBe('Let me analyze this...')
|
||||||
@@ -377,22 +287,18 @@ describe('thinking support (reasoning_content)', () => {
|
|||||||
test('converts reasoning then content (DeepSeek-style)', async () => {
|
test('converts reasoning then content (DeepSeek-style)', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { reasoning_content: 'Thinking about the answer...' },
|
||||||
delta: { reasoning_content: 'Thinking about the answer...' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { content: 'Here is my answer.' },
|
||||||
delta: { content: 'Here is my answer.' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
@@ -400,17 +306,13 @@ describe('thinking support (reasoning_content)', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Should have two content blocks: thinking + text
|
// Should have two content blocks: thinking + text
|
||||||
const blockStarts = events.filter(
|
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||||
e => e.type === 'content_block_start',
|
|
||||||
) as any[]
|
|
||||||
expect(blockStarts.length).toBe(2)
|
expect(blockStarts.length).toBe(2)
|
||||||
expect(blockStarts[0].content_block.type).toBe('thinking')
|
expect(blockStarts[0].content_block.type).toBe('thinking')
|
||||||
expect(blockStarts[1].content_block.type).toBe('text')
|
expect(blockStarts[1].content_block.type).toBe('text')
|
||||||
|
|
||||||
// Thinking block should be closed before text block starts
|
// Thinking block should be closed before text block starts
|
||||||
const blockStops = events.filter(
|
const blockStops = events.filter(e => e.type === 'content_block_stop') as any[]
|
||||||
e => e.type === 'content_block_stop',
|
|
||||||
) as any[]
|
|
||||||
expect(blockStops[0].index).toBe(0) // thinking block closed at index 0
|
expect(blockStops[0].index).toBe(0) // thinking block closed at index 0
|
||||||
expect(blockStarts[1].index).toBe(1) // text block starts at index 1
|
expect(blockStarts[1].index).toBe(1) // text block starts at index 1
|
||||||
|
|
||||||
@@ -424,39 +326,27 @@ describe('thinking support (reasoning_content)', () => {
|
|||||||
test('handles reasoning then tool_calls', async () => {
|
test('handles reasoning then tool_calls', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { reasoning_content: 'I need to run a command.' },
|
||||||
delta: { reasoning_content: 'I need to run a command.' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {
|
||||||
delta: {
|
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"c":"ls"}' } }],
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
id: 'call_1',
|
|
||||||
function: { name: 'bash', arguments: '{"c":"ls"}' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
},
|
||||||
],
|
finish_reason: null,
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const blockStarts = events.filter(
|
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||||
e => e.type === 'content_block_start',
|
|
||||||
) as any[]
|
|
||||||
expect(blockStarts.length).toBe(2)
|
expect(blockStarts.length).toBe(2)
|
||||||
expect(blockStarts[0].content_block.type).toBe('thinking')
|
expect(blockStarts[0].content_block.type).toBe('thinking')
|
||||||
expect(blockStarts[1].content_block.type).toBe('tool_use')
|
expect(blockStarts[1].content_block.type).toBe('tool_use')
|
||||||
@@ -465,31 +355,25 @@ describe('thinking support (reasoning_content)', () => {
|
|||||||
test('thinking block index is 0, text block index is 1', async () => {
|
test('thinking block index is 0, text block index is 1', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { reasoning_content: 'reason' },
|
||||||
delta: { reasoning_content: 'reason' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { content: 'answer' },
|
||||||
delta: { content: 'answer' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const blockStarts = events.filter(
|
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||||
e => e.type === 'content_block_start',
|
|
||||||
) as any[]
|
|
||||||
expect(blockStarts[0].index).toBe(0)
|
expect(blockStarts[0].index).toBe(0)
|
||||||
expect(blockStarts[1].index).toBe(1)
|
expect(blockStarts[1].index).toBe(1)
|
||||||
})
|
})
|
||||||
@@ -499,13 +383,11 @@ describe('prompt caching support', () => {
|
|||||||
test('maps cached_tokens to cache_read_input_tokens', async () => {
|
test('maps cached_tokens to cache_read_input_tokens', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: { content: 'hi' },
|
||||||
delta: { content: 'hi' },
|
finish_reason: null,
|
||||||
finish_reason: null,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
usage: {
|
usage: {
|
||||||
prompt_tokens: 1000,
|
prompt_tokens: 1000,
|
||||||
completion_tokens: 0,
|
completion_tokens: 0,
|
||||||
@@ -581,9 +463,7 @@ describe('prompt caching support', () => {
|
|||||||
// emitted before the trailing chunk and always has input_tokens=0.
|
// emitted before the trailing chunk and always has input_tokens=0.
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{ index: 0, delta: { content: 'hello' }, finish_reason: null }],
|
||||||
{ index: 0, delta: { content: 'hello' }, finish_reason: null },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
// finish_reason chunk — usage not yet available
|
// finish_reason chunk — usage not yet available
|
||||||
makeChunk({
|
makeChunk({
|
||||||
@@ -613,20 +493,14 @@ describe('prompt caching support', () => {
|
|||||||
// the autocompact threshold (~33k), so compaction never fires.
|
// the autocompact threshold (~33k), so compaction never fires.
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }],
|
||||||
{ index: 0, delta: { content: 'answer' }, finish_reason: null },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [],
|
choices: [],
|
||||||
usage: {
|
usage: { prompt_tokens: 800, completion_tokens: 200, total_tokens: 1000 },
|
||||||
prompt_tokens: 800,
|
|
||||||
completion_tokens: 200,
|
|
||||||
total_tokens: 1000,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -640,21 +514,13 @@ describe('prompt caching support', () => {
|
|||||||
// when the model made tool calls and usage arrives in a trailing chunk.
|
// when the model made tool calls and usage arrives in a trailing chunk.
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{
|
||||||
{
|
index: 0,
|
||||||
index: 0,
|
delta: {
|
||||||
delta: {
|
tool_calls: [{ index: 0, id: 'call_x', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
id: 'call_x',
|
|
||||||
function: { name: 'bash', arguments: '{"cmd":"ls"}' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
},
|
||||||
],
|
finish_reason: null,
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||||
@@ -674,14 +540,9 @@ describe('prompt caching support', () => {
|
|||||||
test('message_delta always comes before message_stop', async () => {
|
test('message_delta always comes before message_stop', async () => {
|
||||||
// Verifies event ordering is preserved after deferring to post-loop emission.
|
// Verifies event ordering is preserved after deferring to post-loop emission.
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({ choices: [{ index: 0, delta: { content: 'x' }, finish_reason: null }] }),
|
||||||
choices: [{ index: 0, delta: { content: 'x' }, finish_reason: null }],
|
|
||||||
}),
|
|
||||||
makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }),
|
makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }),
|
||||||
makeChunk({
|
makeChunk({ choices: [], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }),
|
||||||
choices: [],
|
|
||||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const types = events.map(e => e.type)
|
const types = events.map(e => e.type)
|
||||||
@@ -700,9 +561,7 @@ describe('prompt caching support', () => {
|
|||||||
// queryModelOpenAI's spread — even though cachedTokens was captured internally.
|
// queryModelOpenAI's spread — even though cachedTokens was captured internally.
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }],
|
||||||
{ index: 0, delta: { content: 'answer' }, finish_reason: null },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
@@ -779,9 +638,7 @@ describe('prompt caching support', () => {
|
|||||||
// Some endpoints send usage in the finish_reason chunk instead of a trailing chunk.
|
// Some endpoints send usage in the finish_reason chunk instead of a trailing chunk.
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [
|
choices: [{ index: 0, delta: { content: 'result' }, finish_reason: null }],
|
||||||
{ index: 0, delta: { content: 'result' }, finish_reason: null },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
makeChunk({
|
makeChunk({
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
@@ -10,8 +10,8 @@ import type {
|
|||||||
ChatCompletionToolMessageParam,
|
ChatCompletionToolMessageParam,
|
||||||
ChatCompletionUserMessageParam,
|
ChatCompletionUserMessageParam,
|
||||||
} from 'openai/resources/chat/completions/completions.mjs'
|
} from 'openai/resources/chat/completions/completions.mjs'
|
||||||
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
import type { AssistantMessage, UserMessage } from '../types/message.js'
|
||||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
import type { SystemPrompt } from '../types/systemPrompt.js'
|
||||||
|
|
||||||
export interface ConvertMessagesOptions {
|
export interface ConvertMessagesOptions {
|
||||||
/** When true, preserve thinking blocks as reasoning_content on assistant messages
|
/** When true, preserve thinking blocks as reasoning_content on assistant messages
|
||||||
@@ -62,18 +62,16 @@ export function anthropicMessagesToOpenAI(
|
|||||||
// A user message starts a new turn if it contains any non-tool_result content
|
// A user message starts a new turn if it contains any non-tool_result content
|
||||||
// (text, image, or other media). Tool results alone do NOT start a new turn
|
// (text, image, or other media). Tool results alone do NOT start a new turn
|
||||||
// because they are continuations of the previous assistant tool call.
|
// because they are continuations of the previous assistant tool call.
|
||||||
const startsNewUserTurn =
|
const startsNewUserTurn = typeof content === 'string'
|
||||||
typeof content === 'string'
|
? content.length > 0
|
||||||
? content.length > 0
|
: Array.isArray(content) && content.some(
|
||||||
: Array.isArray(content) &&
|
(b: any) =>
|
||||||
content.some(
|
typeof b === 'string' ||
|
||||||
(b: any) =>
|
(b &&
|
||||||
typeof b === 'string' ||
|
typeof b === 'object' &&
|
||||||
(b &&
|
'type' in b &&
|
||||||
typeof b === 'object' &&
|
b.type !== 'tool_result'),
|
||||||
'type' in b &&
|
)
|
||||||
b.type !== 'tool_result'),
|
|
||||||
)
|
|
||||||
if (startsNewUserTurn) {
|
if (startsNewUserTurn) {
|
||||||
turnBoundaries.add(i)
|
turnBoundaries.add(i)
|
||||||
}
|
}
|
||||||
@@ -90,8 +88,7 @@ export function anthropicMessagesToOpenAI(
|
|||||||
case 'assistant':
|
case 'assistant':
|
||||||
// Preserve reasoning_content unless we're before a turn boundary
|
// Preserve reasoning_content unless we're before a turn boundary
|
||||||
// (i.e., from a previous user Q&A round)
|
// (i.e., from a previous user Q&A round)
|
||||||
const preserveReasoning =
|
const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
||||||
enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
|
||||||
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
|
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -104,7 +101,9 @@ export function anthropicMessagesToOpenAI(
|
|||||||
|
|
||||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||||
return systemPrompt.filter(Boolean).join('\n\n')
|
return systemPrompt
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,8 +131,7 @@ function convertInternalUserMessage(
|
|||||||
} else if (Array.isArray(content)) {
|
} else if (Array.isArray(content)) {
|
||||||
const textParts: string[] = []
|
const textParts: string[] = []
|
||||||
const toolResults: BetaToolResultBlockParam[] = []
|
const toolResults: BetaToolResultBlockParam[] = []
|
||||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> =
|
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
|
||||||
[]
|
|
||||||
|
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (typeof block === 'string') {
|
if (typeof block === 'string') {
|
||||||
@@ -143,9 +141,7 @@ function convertInternalUserMessage(
|
|||||||
} else if (block.type === 'tool_result') {
|
} else if (block.type === 'tool_result') {
|
||||||
toolResults.push(block as BetaToolResultBlockParam)
|
toolResults.push(block as BetaToolResultBlockParam)
|
||||||
} else if (block.type === 'image') {
|
} else if (block.type === 'image') {
|
||||||
const imagePart = convertImageBlockToOpenAI(
|
const imagePart = convertImageBlockToOpenAI(block as unknown as Record<string, unknown>)
|
||||||
block as unknown as Record<string, unknown>,
|
|
||||||
)
|
|
||||||
if (imagePart) {
|
if (imagePart) {
|
||||||
imageParts.push(imagePart)
|
imageParts.push(imagePart)
|
||||||
}
|
}
|
||||||
@@ -156,17 +152,13 @@ function convertInternalUserMessage(
|
|||||||
// OpenAI API requires that a tool message immediately follows the assistant
|
// OpenAI API requires that a tool message immediately follows the assistant
|
||||||
// message with tool_calls. If we emit a user message first, the API will
|
// message with tool_calls. If we emit a user message first, the API will
|
||||||
// reject the request with "insufficient tool messages following tool_calls".
|
// reject the request with "insufficient tool messages following tool_calls".
|
||||||
// See: https://github.com/anthropics/claude-code/issues/xxx
|
|
||||||
for (const tr of toolResults) {
|
for (const tr of toolResults) {
|
||||||
result.push(convertToolResult(tr))
|
result.push(convertToolResult(tr))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有图片,构建多模态 content 数组
|
// 如果有图片,构建多模态 content 数组
|
||||||
if (imageParts.length > 0) {
|
if (imageParts.length > 0) {
|
||||||
const multiContent: Array<
|
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
|
||||||
| { type: 'text'; text: string }
|
|
||||||
| { type: 'image_url'; image_url: { url: string } }
|
|
||||||
> = []
|
|
||||||
if (textParts.length > 0) {
|
if (textParts.length > 0) {
|
||||||
multiContent.push({ type: 'text', text: textParts.join('\n') })
|
multiContent.push({ type: 'text', text: textParts.join('\n') })
|
||||||
}
|
}
|
||||||
@@ -237,9 +229,7 @@ function convertInternalAssistantMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const textParts: string[] = []
|
const textParts: string[] = []
|
||||||
const toolCalls: NonNullable<
|
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
|
||||||
ChatCompletionAssistantMessageParam['tool_calls']
|
|
||||||
> = []
|
|
||||||
const reasoningParts: string[] = []
|
const reasoningParts: string[] = []
|
||||||
|
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
@@ -260,8 +250,7 @@ function convertInternalAssistantMessage(
|
|||||||
})
|
})
|
||||||
} else if (block.type === 'thinking' && preserveReasoning) {
|
} else if (block.type === 'thinking' && preserveReasoning) {
|
||||||
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
|
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
|
||||||
const thinkingText = (block as unknown as Record<string, unknown>)
|
const thinkingText = (block as unknown as Record<string, unknown>).thinking
|
||||||
.thinking
|
|
||||||
if (typeof thinkingText === 'string' && thinkingText) {
|
if (typeof thinkingText === 'string' && thinkingText) {
|
||||||
reasoningParts.push(thinkingText)
|
reasoningParts.push(thinkingText)
|
||||||
}
|
}
|
||||||
@@ -273,9 +262,7 @@ function convertInternalAssistantMessage(
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: textParts.length > 0 ? textParts.join('\n') : null,
|
content: textParts.length > 0 ? textParts.join('\n') : null,
|
||||||
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
||||||
...(reasoningParts.length > 0 && {
|
...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }),
|
||||||
reasoning_content: reasoningParts.join('\n'),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [result]
|
return [result]
|
||||||
@@ -16,27 +16,21 @@ export function anthropicToolsToOpenAI(
|
|||||||
.filter(tool => {
|
.filter(tool => {
|
||||||
// Only convert standard tools (skip server tools like computer_use, etc.)
|
// Only convert standard tools (skip server tools like computer_use, etc.)
|
||||||
const toolType = (tool as unknown as { type?: string }).type
|
const toolType = (tool as unknown as { type?: string }).type
|
||||||
return (
|
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||||
tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.map(tool => {
|
.map(tool => {
|
||||||
// Handle the various tool shapes from Anthropic SDK
|
// Handle the various tool shapes from Anthropic SDK
|
||||||
const anyTool = tool as unknown as Record<string, unknown>
|
const anyTool = tool as unknown as Record<string, unknown>
|
||||||
const name = (anyTool.name as string) || ''
|
const name = (anyTool.name as string) || ''
|
||||||
const description = (anyTool.description as string) || ''
|
const description = (anyTool.description as string) || ''
|
||||||
const inputSchema = anyTool.input_schema as
|
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
|
||||||
| Record<string, unknown>
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'function' as const,
|
type: 'function' as const,
|
||||||
function: {
|
function: {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
parameters: sanitizeJsonSchema(
|
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
|
||||||
inputSchema || { type: 'object', properties: {} },
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
} satisfies ChatCompletionTool
|
} satisfies ChatCompletionTool
|
||||||
})
|
})
|
||||||
@@ -49,9 +43,7 @@ export function anthropicToolsToOpenAI(
|
|||||||
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
|
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
|
||||||
* single-element array, which is semantically equivalent.
|
* single-element array, which is semantically equivalent.
|
||||||
*/
|
*/
|
||||||
function sanitizeJsonSchema(
|
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
||||||
schema: Record<string, unknown>,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
if (!schema || typeof schema !== 'object') return schema
|
if (!schema || typeof schema !== 'object') return schema
|
||||||
|
|
||||||
const result = { ...schema }
|
const result = { ...schema }
|
||||||
@@ -63,37 +55,20 @@ function sanitizeJsonSchema(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recursively process nested schemas
|
// Recursively process nested schemas
|
||||||
const objectKeys = [
|
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
|
||||||
'properties',
|
|
||||||
'definitions',
|
|
||||||
'$defs',
|
|
||||||
'patternProperties',
|
|
||||||
] as const
|
|
||||||
for (const key of objectKeys) {
|
for (const key of objectKeys) {
|
||||||
const nested = result[key]
|
const nested = result[key]
|
||||||
if (nested && typeof nested === 'object') {
|
if (nested && typeof nested === 'object') {
|
||||||
const sanitized: Record<string, unknown> = {}
|
const sanitized: Record<string, unknown> = {}
|
||||||
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
|
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
|
||||||
sanitized[k] =
|
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
|
||||||
v && typeof v === 'object'
|
|
||||||
? sanitizeJsonSchema(v as Record<string, unknown>)
|
|
||||||
: v
|
|
||||||
}
|
}
|
||||||
result[key] = sanitized
|
result[key] = sanitized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively process single-schema keys
|
// Recursively process single-schema keys
|
||||||
const singleKeys = [
|
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
|
||||||
'items',
|
|
||||||
'additionalProperties',
|
|
||||||
'not',
|
|
||||||
'if',
|
|
||||||
'then',
|
|
||||||
'else',
|
|
||||||
'contains',
|
|
||||||
'propertyNames',
|
|
||||||
] as const
|
|
||||||
for (const key of singleKeys) {
|
for (const key of singleKeys) {
|
||||||
const nested = result[key]
|
const nested = result[key]
|
||||||
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||||
@@ -107,9 +82,7 @@ function sanitizeJsonSchema(
|
|||||||
const nested = result[key]
|
const nested = result[key]
|
||||||
if (Array.isArray(nested)) {
|
if (Array.isArray(nested)) {
|
||||||
result[key] = nested.map(item =>
|
result[key] = nested.map(item =>
|
||||||
item && typeof item === 'object'
|
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
|
||||||
? sanitizeJsonSchema(item as Record<string, unknown>)
|
|
||||||
: item,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,10 +42,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
let currentContentIndex = -1
|
let currentContentIndex = -1
|
||||||
|
|
||||||
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
||||||
const toolBlocks = new Map<
|
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
|
||||||
number,
|
|
||||||
{ contentIndex: number; id: string; name: string; arguments: string }
|
|
||||||
>()
|
|
||||||
|
|
||||||
// Track thinking block state
|
// Track thinking block state
|
||||||
let thinkingBlockOpen = false
|
let thinkingBlockOpen = false
|
||||||
@@ -54,10 +51,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
let textBlockOpen = false
|
let textBlockOpen = false
|
||||||
|
|
||||||
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
||||||
// prompt_tokens → input_tokens
|
|
||||||
// completion_tokens → output_tokens
|
|
||||||
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
|
||||||
// (no standard OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
|
||||||
let inputTokens = 0
|
let inputTokens = 0
|
||||||
let outputTokens = 0
|
let outputTokens = 0
|
||||||
let cachedReadTokens = 0
|
let cachedReadTokens = 0
|
||||||
@@ -65,10 +58,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
// Track all open content block indices (for cleanup)
|
// Track all open content block indices (for cleanup)
|
||||||
const openBlockIndices = new Set<number>()
|
const openBlockIndices = new Set<number>()
|
||||||
|
|
||||||
// Deferred finish state: populated when finish_reason is encountered so that
|
// Deferred finish state
|
||||||
// message_delta / message_stop are emitted AFTER the stream loop ends.
|
|
||||||
// This ensures usage chunks that arrive after the finish_reason chunk are
|
|
||||||
// captured before we emit the final token counts.
|
|
||||||
let pendingFinishReason: string | null = null
|
let pendingFinishReason: string | null = null
|
||||||
let pendingHasToolCalls = false
|
let pendingHasToolCalls = false
|
||||||
|
|
||||||
@@ -77,16 +67,9 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
const delta = choice?.delta
|
const delta = choice?.delta
|
||||||
|
|
||||||
// Extract usage from any chunk that carries it.
|
// Extract usage from any chunk that carries it.
|
||||||
// Many OpenAI-compatible endpoints (e.g. DeepSeek) send usage in a separate
|
|
||||||
// final chunk that arrives AFTER the finish_reason chunk. Reading it here
|
|
||||||
// (before emitting message_delta) ensures the token counts are available
|
|
||||||
// when we later emit message_delta.
|
|
||||||
if (chunk.usage) {
|
if (chunk.usage) {
|
||||||
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
||||||
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
||||||
// OpenAI prompt caching: prompt_tokens_details.cached_tokens
|
|
||||||
// → Anthropic cache_read_input_tokens
|
|
||||||
// Note: OpenAI has no equivalent for cache_creation_input_tokens.
|
|
||||||
const details = (chunk.usage as any).prompt_tokens_details
|
const details = (chunk.usage as any).prompt_tokens_details
|
||||||
if (details?.cached_tokens != null) {
|
if (details?.cached_tokens != null) {
|
||||||
cachedReadTokens = details.cached_tokens
|
cachedReadTokens = details.cached_tokens
|
||||||
@@ -121,7 +104,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
if (!delta) continue
|
if (!delta) continue
|
||||||
|
|
||||||
// Handle reasoning_content → Anthropic thinking block
|
// Handle reasoning_content → Anthropic thinking block
|
||||||
// DeepSeek and compatible providers send delta.reasoning_content
|
|
||||||
const reasoningContent = (delta as any).reasoning_content
|
const reasoningContent = (delta as any).reasoning_content
|
||||||
if (reasoningContent != null && reasoningContent !== '') {
|
if (reasoningContent != null && reasoningContent !== '') {
|
||||||
if (!thinkingBlockOpen) {
|
if (!thinkingBlockOpen) {
|
||||||
@@ -153,7 +135,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
// Handle text content
|
// Handle text content
|
||||||
if (delta.content != null && delta.content !== '') {
|
if (delta.content != null && delta.content !== '') {
|
||||||
if (!textBlockOpen) {
|
if (!textBlockOpen) {
|
||||||
// Close thinking block if still open (reasoning done, now generating answer)
|
// Close thinking block if still open
|
||||||
if (thinkingBlockOpen) {
|
if (thinkingBlockOpen) {
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
@@ -215,8 +197,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
|
|
||||||
// Start new tool_use block
|
// Start new tool_use block
|
||||||
currentContentIndex++
|
currentContentIndex++
|
||||||
const toolId =
|
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||||
tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
|
||||||
const toolName = tc.function?.name || ''
|
const toolName = tc.function?.name || ''
|
||||||
|
|
||||||
toolBlocks.set(tcIndex, {
|
toolBlocks.set(tcIndex, {
|
||||||
@@ -255,12 +236,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle finish: close all open content blocks and record the finish_reason.
|
// Handle finish
|
||||||
// message_delta + message_stop are emitted AFTER the stream loop so that any
|
|
||||||
// trailing usage chunk (sent after the finish chunk by some endpoints)
|
|
||||||
// is captured first — ensuring token counts are non-zero.
|
|
||||||
if (choice?.finish_reason) {
|
if (choice?.finish_reason) {
|
||||||
// Close thinking block if still open
|
|
||||||
if (thinkingBlockOpen) {
|
if (thinkingBlockOpen) {
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
@@ -270,7 +247,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
thinkingBlockOpen = false
|
thinkingBlockOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close text block if still open
|
|
||||||
if (textBlockOpen) {
|
if (textBlockOpen) {
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
@@ -280,7 +256,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
textBlockOpen = false
|
textBlockOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close all tool blocks that haven't been closed yet
|
|
||||||
for (const [, block] of toolBlocks) {
|
for (const [, block] of toolBlocks) {
|
||||||
if (openBlockIndices.has(block.contentIndex)) {
|
if (openBlockIndices.has(block.contentIndex)) {
|
||||||
yield {
|
yield {
|
||||||
@@ -291,14 +266,12 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defer message_delta / message_stop until after the loop so that any
|
|
||||||
// trailing usage chunk is processed before we emit the final token counts.
|
|
||||||
pendingFinishReason = choice.finish_reason
|
pendingFinishReason = choice.finish_reason
|
||||||
pendingHasToolCalls = toolBlocks.size > 0
|
pendingHasToolCalls = toolBlocks.size > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety: close any remaining open blocks if stream ended without finish_reason
|
// Safety: close any remaining open blocks
|
||||||
for (const idx of openBlockIndices) {
|
for (const idx of openBlockIndices) {
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
@@ -306,15 +279,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
} as BetaRawMessageStreamEvent
|
} as BetaRawMessageStreamEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit message_delta + message_stop now that the stream is fully consumed.
|
// Emit message_delta + message_stop
|
||||||
// Usage values (inputTokens / outputTokens) reflect all chunks including any
|
|
||||||
// trailing usage-only chunk sent after the finish_reason chunk.
|
|
||||||
if (pendingFinishReason !== null) {
|
if (pendingFinishReason !== null) {
|
||||||
// Map finish_reason to Anthropic stop_reason.
|
|
||||||
// CRITICAL: When finish_reason is 'length' (token budget exhausted), always
|
|
||||||
// report 'max_tokens' regardless of whether partial tool calls were received.
|
|
||||||
// Otherwise the query loop would try to execute tool calls with incomplete
|
|
||||||
// JSON arguments instead of triggering the max_tokens retry/recovery path.
|
|
||||||
const stopReason =
|
const stopReason =
|
||||||
pendingFinishReason === 'length'
|
pendingFinishReason === 'length'
|
||||||
? 'max_tokens'
|
? 'max_tokens'
|
||||||
@@ -328,19 +294,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
stop_reason: stopReason,
|
stop_reason: stopReason,
|
||||||
stop_sequence: null,
|
stop_sequence: null,
|
||||||
},
|
},
|
||||||
// Carry all four Anthropic usage fields so queryModelOpenAI's message_delta
|
|
||||||
// handler (which spreads this into the accumulated usage object) can override
|
|
||||||
// every field that message_start emitted as 0. For endpoints that send usage
|
|
||||||
// in a trailing chunk (e.g. DeepSeek), message_start is emitted on the first
|
|
||||||
// content chunk before the trailing usage chunk arrives, so all four fields
|
|
||||||
// start at 0. By the time we reach here (post-loop) the trailing chunk has
|
|
||||||
// been processed and all values reflect the real counts.
|
|
||||||
//
|
|
||||||
// OpenAI → Anthropic field mapping:
|
|
||||||
// prompt_tokens → input_tokens
|
|
||||||
// completion_tokens → output_tokens
|
|
||||||
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
|
||||||
// (no OpenAI equivalent) → cache_creation_input_tokens (stays 0)
|
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: inputTokens,
|
input_tokens: inputTokens,
|
||||||
output_tokens: outputTokens,
|
output_tokens: outputTokens,
|
||||||
@@ -357,11 +310,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Map OpenAI finish_reason to Anthropic stop_reason.
|
* Map OpenAI finish_reason to Anthropic stop_reason.
|
||||||
*
|
|
||||||
* stop → end_turn
|
|
||||||
* tool_calls → tool_use
|
|
||||||
* length → max_tokens
|
|
||||||
* content_filter → end_turn
|
|
||||||
*/
|
*/
|
||||||
function mapFinishReason(reason: string): string {
|
function mapFinishReason(reason: string): string {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
54
packages/@ant/model-provider/src/types/errors.ts
Normal file
54
packages/@ant/model-provider/src/types/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Error type constants for the model provider package.
|
||||||
|
// Error string constants extracted from src/services/api/errors.ts.
|
||||||
|
// The full error handling functions remain in the main project (Phase 4).
|
||||||
|
|
||||||
|
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
|
||||||
|
|
||||||
|
export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'Prompt is too long'
|
||||||
|
|
||||||
|
export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low'
|
||||||
|
export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login'
|
||||||
|
export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL =
|
||||||
|
'Invalid API key · Fix external API key'
|
||||||
|
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH =
|
||||||
|
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead'
|
||||||
|
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY =
|
||||||
|
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable'
|
||||||
|
export const TOKEN_REVOKED_ERROR_MESSAGE =
|
||||||
|
'OAuth token revoked · Please run /login'
|
||||||
|
export const CCR_AUTH_ERROR_MESSAGE =
|
||||||
|
'Authentication error · This may be a temporary network issue, please try again'
|
||||||
|
export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors'
|
||||||
|
export const CUSTOM_OFF_SWITCH_MESSAGE =
|
||||||
|
'Opus is experiencing high load, please use /model to switch to Sonnet'
|
||||||
|
export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out'
|
||||||
|
export const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE =
|
||||||
|
'Your account does not have access to Claude Code. Please run /login.'
|
||||||
|
|
||||||
|
/** Error classification types returned by classifyAPIError */
|
||||||
|
export type APIErrorClassification =
|
||||||
|
| 'aborted'
|
||||||
|
| 'api_timeout'
|
||||||
|
| 'repeated_529'
|
||||||
|
| 'capacity_off_switch'
|
||||||
|
| 'rate_limit'
|
||||||
|
| 'server_overload'
|
||||||
|
| 'prompt_too_long'
|
||||||
|
| 'pdf_too_large'
|
||||||
|
| 'pdf_password_protected'
|
||||||
|
| 'image_too_large'
|
||||||
|
| 'tool_use_mismatch'
|
||||||
|
| 'unexpected_tool_result'
|
||||||
|
| 'duplicate_tool_use_id'
|
||||||
|
| 'invalid_model'
|
||||||
|
| 'credit_balance_low'
|
||||||
|
| 'invalid_api_key'
|
||||||
|
| 'token_revoked'
|
||||||
|
| 'oauth_org_not_allowed'
|
||||||
|
| 'auth_error'
|
||||||
|
| 'bedrock_model_access'
|
||||||
|
| 'server_error'
|
||||||
|
| 'client_error'
|
||||||
|
| 'ssl_cert_error'
|
||||||
|
| 'connection_error'
|
||||||
|
| 'unknown'
|
||||||
6
packages/@ant/model-provider/src/types/index.ts
Normal file
6
packages/@ant/model-provider/src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Type definitions for @ant/model-provider
|
||||||
|
|
||||||
|
export * from './message.js'
|
||||||
|
export * from './usage.js'
|
||||||
|
export * from './errors.js'
|
||||||
|
export * from './systemPrompt.js'
|
||||||
129
packages/@ant/model-provider/src/types/message.ts
Normal file
129
packages/@ant/model-provider/src/types/message.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Core message types for the model provider package.
|
||||||
|
// Moved from src/types/message.ts to decouple the API layer from the main project.
|
||||||
|
|
||||||
|
import type { UUID } from 'crypto'
|
||||||
|
import type {
|
||||||
|
ContentBlockParam,
|
||||||
|
ContentBlock,
|
||||||
|
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||||
|
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base message type with discriminant `type` field and common properties.
|
||||||
|
* Individual message subtypes (UserMessage, AssistantMessage, etc.) extend
|
||||||
|
* this with narrower `type` literals and additional fields.
|
||||||
|
*/
|
||||||
|
export type MessageType = 'user' | 'assistant' | 'system' | 'attachment' | 'progress' | 'grouped_tool_use' | 'collapsed_read_search'
|
||||||
|
|
||||||
|
/** A single content element inside message.content arrays. */
|
||||||
|
export type ContentItem = ContentBlockParam | ContentBlock
|
||||||
|
|
||||||
|
export type MessageContent = string | ContentBlockParam[] | ContentBlock[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed content array — used in narrowed message subtypes so that
|
||||||
|
* `message.content[0]` resolves to `ContentItem` instead of
|
||||||
|
* `string | ContentBlockParam | ContentBlock`.
|
||||||
|
*/
|
||||||
|
export type TypedMessageContent = ContentItem[]
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
type: MessageType
|
||||||
|
uuid: UUID
|
||||||
|
isMeta?: boolean
|
||||||
|
isCompactSummary?: boolean
|
||||||
|
toolUseResult?: unknown
|
||||||
|
isVisibleInTranscriptOnly?: boolean
|
||||||
|
attachment?: { type: string; toolUseID?: string; [key: string]: unknown; addedNames: string[]; addedLines: string[]; removedNames: string[] }
|
||||||
|
message?: {
|
||||||
|
role?: string
|
||||||
|
id?: string
|
||||||
|
content?: MessageContent
|
||||||
|
usage?: BetaUsage | Record<string, unknown>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssistantMessage = Message & {
|
||||||
|
type: 'assistant'
|
||||||
|
message: NonNullable<Message['message']>
|
||||||
|
}
|
||||||
|
export type AttachmentMessage<T = { type: string; [key: string]: unknown }> = Message & { type: 'attachment'; attachment: T }
|
||||||
|
export type ProgressMessage<T = unknown> = Message & { type: 'progress'; data: T }
|
||||||
|
export type SystemLocalCommandMessage = Message & { type: 'system' }
|
||||||
|
export type SystemMessage = Message & { type: 'system' }
|
||||||
|
export type UserMessage = Message & {
|
||||||
|
type: 'user'
|
||||||
|
message: NonNullable<Message['message']>
|
||||||
|
imagePasteIds?: number[]
|
||||||
|
}
|
||||||
|
export type NormalizedUserMessage = UserMessage
|
||||||
|
export type RequestStartEvent = { type: string; [key: string]: unknown }
|
||||||
|
export type StreamEvent = { type: string; [key: string]: unknown }
|
||||||
|
export type SystemCompactBoundaryMessage = Message & {
|
||||||
|
type: 'system'
|
||||||
|
compactMetadata: {
|
||||||
|
preservedSegment?: {
|
||||||
|
headUuid: UUID
|
||||||
|
tailUuid: UUID
|
||||||
|
anchorUuid: UUID
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type TombstoneMessage = Message
|
||||||
|
export type ToolUseSummaryMessage = Message
|
||||||
|
export type MessageOrigin = string
|
||||||
|
export type CompactMetadata = Record<string, unknown>
|
||||||
|
export type SystemAPIErrorMessage = Message & { type: 'system' }
|
||||||
|
export type SystemFileSnapshotMessage = Message & { type: 'system' }
|
||||||
|
export type NormalizedAssistantMessage<T = unknown> = AssistantMessage
|
||||||
|
export type NormalizedMessage = Message
|
||||||
|
export type PartialCompactDirection = string
|
||||||
|
|
||||||
|
export type StopHookInfo = {
|
||||||
|
command?: string
|
||||||
|
durationMs?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SystemAgentsKilledMessage = Message & { type: 'system' }
|
||||||
|
export type SystemApiMetricsMessage = Message & { type: 'system' }
|
||||||
|
export type SystemAwaySummaryMessage = Message & { type: 'system' }
|
||||||
|
export type SystemBridgeStatusMessage = Message & { type: 'system' }
|
||||||
|
export type SystemInformationalMessage = Message & { type: 'system' }
|
||||||
|
export type SystemMemorySavedMessage = Message & { type: 'system' }
|
||||||
|
export type SystemMessageLevel = string
|
||||||
|
export type SystemMicrocompactBoundaryMessage = Message & { type: 'system' }
|
||||||
|
export type SystemPermissionRetryMessage = Message & { type: 'system' }
|
||||||
|
export type SystemScheduledTaskFireMessage = Message & { type: 'system' }
|
||||||
|
|
||||||
|
export type SystemStopHookSummaryMessage = Message & {
|
||||||
|
type: 'system'
|
||||||
|
subtype: string
|
||||||
|
hookLabel: string
|
||||||
|
hookCount: number
|
||||||
|
totalDurationMs?: number
|
||||||
|
hookInfos: StopHookInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SystemTurnDurationMessage = Message & { type: 'system' }
|
||||||
|
|
||||||
|
export type GroupedToolUseMessage = Message & {
|
||||||
|
type: 'grouped_tool_use'
|
||||||
|
toolName: string
|
||||||
|
messages: NormalizedAssistantMessage[]
|
||||||
|
results: NormalizedUserMessage[]
|
||||||
|
displayMessage: NormalizedAssistantMessage | NormalizedUserMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollapsibleMessage is used by the main project's CollapsedReadSearchGroup
|
||||||
|
export type CollapsibleMessage =
|
||||||
|
| AssistantMessage
|
||||||
|
| UserMessage
|
||||||
|
| GroupedToolUseMessage
|
||||||
|
|
||||||
|
export type HookResultMessage = Message
|
||||||
|
export type SystemThinkingMessage = Message & { type: 'system' }
|
||||||
10
packages/@ant/model-provider/src/types/systemPrompt.ts
Normal file
10
packages/@ant/model-provider/src/types/systemPrompt.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// System prompt branded type.
|
||||||
|
// Dependency-free so it can be imported from anywhere without circular imports.
|
||||||
|
|
||||||
|
export type SystemPrompt = readonly string[] & {
|
||||||
|
readonly __brand: 'SystemPrompt'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
|
||||||
|
return value as SystemPrompt
|
||||||
|
}
|
||||||
49
packages/@ant/model-provider/src/types/usage.ts
Normal file
49
packages/@ant/model-provider/src/types/usage.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Usage types for the model provider package.
|
||||||
|
// Moved from src/entrypoints/sdk/sdkUtilityTypes.ts and src/services/api/emptyUsage.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-nullable usage object representing token consumption from an API response.
|
||||||
|
* Moved from src/entrypoints/sdk/sdkUtilityTypes.ts
|
||||||
|
*/
|
||||||
|
export type NonNullableUsage = {
|
||||||
|
inputTokens?: number
|
||||||
|
outputTokens?: number
|
||||||
|
cacheReadInputTokens?: number
|
||||||
|
cacheCreationInputTokens?: number
|
||||||
|
input_tokens: number
|
||||||
|
cache_creation_input_tokens: number
|
||||||
|
cache_read_input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
server_tool_use: { web_search_requests: number; web_fetch_requests: number }
|
||||||
|
service_tier: string
|
||||||
|
cache_creation: {
|
||||||
|
ephemeral_1h_input_tokens: number
|
||||||
|
ephemeral_5m_input_tokens: number
|
||||||
|
}
|
||||||
|
inference_geo: string
|
||||||
|
iterations: unknown[]
|
||||||
|
speed: string
|
||||||
|
cache_deleted_input_tokens?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero-initialized usage object. Extracted from logging.ts so that
|
||||||
|
* bridge/replBridge.ts can import it without transitively pulling in
|
||||||
|
* api/errors.ts → utils/messages.ts → BashTool.tsx → the world.
|
||||||
|
*/
|
||||||
|
export const EMPTY_USAGE: Readonly<NonNullableUsage> = {
|
||||||
|
input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
|
||||||
|
service_tier: 'standard',
|
||||||
|
cache_creation: {
|
||||||
|
ephemeral_1h_input_tokens: 0,
|
||||||
|
ephemeral_5m_input_tokens: 0,
|
||||||
|
},
|
||||||
|
inference_geo: '',
|
||||||
|
iterations: [],
|
||||||
|
speed: 'standard',
|
||||||
|
}
|
||||||
7
packages/@ant/model-provider/tsconfig.json
Normal file
7
packages/@ant/model-provider/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
34
packages/acp-link/.gitignore
vendored
Normal file
34
packages/acp-link/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
89
packages/acp-link/README.md
Normal file
89
packages/acp-link/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# acp-link
|
||||||
|
|
||||||
|
ACP proxy server that bridges WebSocket clients to ACP (Agent Client Protocol) agents.
|
||||||
|
|
||||||
|
> Source code adapted from [chrome-acp](https://github.com/Areo-Joe/chrome-acp).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From monorepo root
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via global install
|
||||||
|
acp-link /path/to/agent
|
||||||
|
|
||||||
|
# Via source
|
||||||
|
bun src/cli/bin.ts /path/to/agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage
|
||||||
|
acp-link /path/to/agent
|
||||||
|
|
||||||
|
# With custom port and host
|
||||||
|
acp-link --port 9000 --host 0.0.0.0 /path/to/agent
|
||||||
|
|
||||||
|
# With debug logging
|
||||||
|
acp-link --debug /path/to/agent
|
||||||
|
|
||||||
|
# Enable HTTPS with self-signed certificate
|
||||||
|
acp-link --https /path/to/agent
|
||||||
|
|
||||||
|
# Disable authentication (dangerous)
|
||||||
|
acp-link --no-auth /path/to/agent
|
||||||
|
|
||||||
|
# Pass arguments to the agent (use -- to separate)
|
||||||
|
acp-link /path/to/agent -- --verbose --model gpt-4
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
USAGE
|
||||||
|
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
|
||||||
|
acp-link --help
|
||||||
|
acp-link --version
|
||||||
|
|
||||||
|
FLAGS
|
||||||
|
[--port] Port to listen on [default = 9315]
|
||||||
|
[--host] Host to bind to [default = localhost]
|
||||||
|
[--debug] Enable debug logging to file
|
||||||
|
[--no-auth] Disable authentication (dangerous)
|
||||||
|
[--https] Enable HTTPS with self-signed cert
|
||||||
|
-h --help Print help information and exit
|
||||||
|
-v --version Print version information and exit
|
||||||
|
|
||||||
|
ARGUMENTS
|
||||||
|
command... Agent command followed by its arguments
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Listens for WebSocket connections from clients
|
||||||
|
2. When a "connect" message is received, spawns the configured ACP agent as a subprocess
|
||||||
|
3. Bridges messages between the WebSocket (client) and stdin/stdout (agent via ACP protocol)
|
||||||
|
4. Supports session management: create, load, resume, list sessions
|
||||||
|
5. Handles permission approval flow and heartbeat keepalive
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
By default, a random token is auto-generated on startup. Pass it as a query parameter:
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://localhost:9315/ws?token=<your-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
39
packages/acp-link/package.json
Normal file
39
packages/acp-link/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "acp-link",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
||||||
|
"author": "claude-code-best",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/server.js",
|
||||||
|
"types": "./dist/server.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"acp-link": "dist/cli/bin.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "bun run src/cli/bin.ts",
|
||||||
|
"prepublishOnly": "bun run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/selfsigned": "^2.0.4",
|
||||||
|
"@types/ws": "^8.18.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
|
"@hono/node-server": "^1.13.8",
|
||||||
|
"@hono/node-ws": "^1.0.5",
|
||||||
|
"@stricli/auto-complete": "^1.2.4",
|
||||||
|
"@stricli/core": "^1.2.4",
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"pino": "^10.3.0",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
|
"selfsigned": "^5.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { getLanIPs } from "../cert.js";
|
||||||
|
|
||||||
|
describe("getLanIPs", () => {
|
||||||
|
test("returns an array", () => {
|
||||||
|
const ips = getLanIPs();
|
||||||
|
expect(Array.isArray(ips)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns only IPv4 addresses", () => {
|
||||||
|
const ips = getLanIPs();
|
||||||
|
for (const ip of ips) {
|
||||||
|
// IPv4 format: x.x.x.x
|
||||||
|
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not include loopback addresses", () => {
|
||||||
|
const ips = getLanIPs();
|
||||||
|
expect(ips).not.toContain("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("may be empty in isolated environments", () => {
|
||||||
|
// This test just ensures it doesn't throw
|
||||||
|
const ips = getLanIPs();
|
||||||
|
expect(ips.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
packages/acp-link/src/__tests__/server.test.ts
Normal file
75
packages/acp-link/src/__tests__/server.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import type { ServerConfig } from "../server.js";
|
||||||
|
|
||||||
|
describe("Server HTTP endpoints", () => {
|
||||||
|
test("package.json has correct bin and main entries", async () => {
|
||||||
|
const pkg = await import("../../package.json", { with: { type: "json" } });
|
||||||
|
expect(pkg.default.name).toBe("acp-link");
|
||||||
|
expect(pkg.default.main).toBe("./dist/server.js");
|
||||||
|
expect(pkg.default.bin).toBeDefined();
|
||||||
|
expect(pkg.default.bin["acp-link"]).toBe("dist/cli/bin.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ServerConfig interface accepts all expected fields", () => {
|
||||||
|
const config: ServerConfig = {
|
||||||
|
port: 9315,
|
||||||
|
host: "localhost",
|
||||||
|
command: "echo",
|
||||||
|
args: [],
|
||||||
|
cwd: "/tmp",
|
||||||
|
debug: false,
|
||||||
|
token: "test-token",
|
||||||
|
https: false,
|
||||||
|
};
|
||||||
|
expect(config.port).toBe(9315);
|
||||||
|
expect(config.token).toBe("test-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ServerConfig allows optional fields to be omitted", () => {
|
||||||
|
const config: ServerConfig = {
|
||||||
|
port: 9315,
|
||||||
|
host: "localhost",
|
||||||
|
command: "echo",
|
||||||
|
args: [],
|
||||||
|
cwd: "/tmp",
|
||||||
|
};
|
||||||
|
expect(config.debug).toBeUndefined();
|
||||||
|
expect(config.token).toBeUndefined();
|
||||||
|
expect(config.https).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WebSocket message types", () => {
|
||||||
|
const clientMessageTypes = [
|
||||||
|
"connect",
|
||||||
|
"disconnect",
|
||||||
|
"new_session",
|
||||||
|
"prompt",
|
||||||
|
"permission_response",
|
||||||
|
"cancel",
|
||||||
|
"set_session_model",
|
||||||
|
"list_sessions",
|
||||||
|
"load_session",
|
||||||
|
"resume_session",
|
||||||
|
"ping",
|
||||||
|
];
|
||||||
|
|
||||||
|
test("all client message types are recognized", () => {
|
||||||
|
expect(clientMessageTypes.length).toBe(11);
|
||||||
|
expect(clientMessageTypes).toContain("ping");
|
||||||
|
expect(clientMessageTypes).toContain("connect");
|
||||||
|
expect(clientMessageTypes).toContain("cancel");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Heartbeat constants", () => {
|
||||||
|
test("PERMISSION_TIMEOUT_MS is 5 minutes", () => {
|
||||||
|
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
expect(PERMISSION_TIMEOUT_MS).toBe(300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HEARTBEAT_INTERVAL_MS is 30 seconds", () => {
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||||
|
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { isRequest, isResponse, isNotification } from "../types.js";
|
||||||
|
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from "../types.js";
|
||||||
|
|
||||||
|
describe("isRequest", () => {
|
||||||
|
test("returns true for a valid JSON-RPC request", () => {
|
||||||
|
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||||
|
expect(isRequest(msg)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true for request with params", () => {
|
||||||
|
const msg = { jsonrpc: "2.0" as const, id: "abc", method: "test", params: { x: 1 } };
|
||||||
|
expect(isRequest(msg)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for response (no method)", () => {
|
||||||
|
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: {} };
|
||||||
|
expect(isRequest(msg)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for notification (no id)", () => {
|
||||||
|
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
|
||||||
|
expect(isRequest(msg)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isResponse", () => {
|
||||||
|
test("returns true for a valid JSON-RPC response with result", () => {
|
||||||
|
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: "ok" };
|
||||||
|
expect(isResponse(msg)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true for a valid JSON-RPC error response", () => {
|
||||||
|
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 2, error: { code: -32600, message: "bad" } };
|
||||||
|
expect(isResponse(msg)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for request (has method)", () => {
|
||||||
|
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||||
|
expect(isResponse(msg)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for notification", () => {
|
||||||
|
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
|
||||||
|
expect(isResponse(msg)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isNotification", () => {
|
||||||
|
test("returns true for a valid JSON-RPC notification", () => {
|
||||||
|
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "update" };
|
||||||
|
expect(isNotification(msg)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true for notification with params", () => {
|
||||||
|
const msg = { jsonrpc: "2.0" as const, method: "progress", params: { pct: 50 } };
|
||||||
|
expect(isNotification(msg)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for request (has id)", () => {
|
||||||
|
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||||
|
expect(isNotification(msg)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for response (no method)", () => {
|
||||||
|
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: null };
|
||||||
|
expect(isNotification(msg)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
174
packages/acp-link/src/cert.ts
Normal file
174
packages/acp-link/src/cert.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Self-signed certificate generation for HTTPS support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { X509Certificate } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir, networkInterfaces } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { generate } from "selfsigned";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all LAN IPv4 addresses
|
||||||
|
*/
|
||||||
|
export function getLanIPs(): string[] {
|
||||||
|
const ips: string[] = [];
|
||||||
|
const nets = networkInterfaces();
|
||||||
|
for (const name of Object.keys(nets)) {
|
||||||
|
for (const net of nets[name] || []) {
|
||||||
|
// Skip internal (loopback) and non-IPv4 addresses
|
||||||
|
if (!net.internal && net.family === "IPv4") {
|
||||||
|
ips.push(net.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract IP addresses from certificate's Subject Alternative Name (SAN)
|
||||||
|
* SAN format: "IP Address:192.168.1.100, IP Address:127.0.0.1, DNS:localhost"
|
||||||
|
*/
|
||||||
|
function extractSanIPs(x509: X509Certificate): string[] {
|
||||||
|
const san = x509.subjectAltName;
|
||||||
|
if (!san) return [];
|
||||||
|
|
||||||
|
const ips: string[] = [];
|
||||||
|
// Parse "IP Address:x.x.x.x" entries from SAN string
|
||||||
|
const parts = san.split(", ");
|
||||||
|
for (const part of parts) {
|
||||||
|
const match = part.match(/^IP Address:(.+)$/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
ips.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CERT_DIR = join(homedir(), ".acp-proxy");
|
||||||
|
const KEY_PATH = join(CERT_DIR, "key.pem");
|
||||||
|
const CERT_PATH = join(CERT_DIR, "cert.pem");
|
||||||
|
|
||||||
|
// Certificate validity in days
|
||||||
|
const CERT_VALIDITY_DAYS = 365;
|
||||||
|
|
||||||
|
export interface TlsOptions {
|
||||||
|
key: string;
|
||||||
|
cert: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or generate self-signed certificate
|
||||||
|
* Certificates are cached in ~/.acp-proxy/
|
||||||
|
*/
|
||||||
|
export async function getOrCreateCertificate(): Promise<TlsOptions> {
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!existsSync(CERT_DIR)) {
|
||||||
|
mkdirSync(CERT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if certificates already exist and are still valid
|
||||||
|
if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) {
|
||||||
|
const certPem = readFileSync(CERT_PATH, "utf-8");
|
||||||
|
const keyPem = readFileSync(KEY_PATH, "utf-8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const x509 = new X509Certificate(certPem);
|
||||||
|
const validTo = new Date(x509.validTo);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Check if cert is expired or will expire within 7 days
|
||||||
|
const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysUntilExpiry <= 7) {
|
||||||
|
// Certificate expired or expiring soon
|
||||||
|
console.log(`⚠️ Certificate ${daysUntilExpiry <= 0 ? "expired" : `expires in ${daysUntilExpiry} days`}, regenerating...`);
|
||||||
|
} else {
|
||||||
|
// Check if current LAN IPs are in the certificate's SAN
|
||||||
|
const currentLanIPs = getLanIPs();
|
||||||
|
const certSanIPs = extractSanIPs(x509);
|
||||||
|
|
||||||
|
// Check if all current LAN IPs are covered by the certificate
|
||||||
|
const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip));
|
||||||
|
|
||||||
|
if (missingIPs.length === 0) {
|
||||||
|
console.log(`🔐 Using existing certificate from ${CERT_DIR}`);
|
||||||
|
console.log(` Valid for ${daysUntilExpiry} more days`);
|
||||||
|
return { key: keyPem, cert: certPem };
|
||||||
|
}
|
||||||
|
|
||||||
|
// LAN IP changed, regenerate
|
||||||
|
console.log(`⚠️ LAN IP changed (missing: ${missingIPs.join(", ")}), regenerating certificate...`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed to parse certificate, regenerate
|
||||||
|
console.log(`⚠️ Invalid certificate, regenerating...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new self-signed certificate
|
||||||
|
console.log(`🔐 Generating self-signed certificate...`);
|
||||||
|
|
||||||
|
const attrs = [{ name: "commonName", value: "ACP Proxy Server" }];
|
||||||
|
|
||||||
|
// Calculate expiry date
|
||||||
|
const notAfterDate = new Date();
|
||||||
|
notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS);
|
||||||
|
|
||||||
|
// Build altNames: localhost + loopback + all LAN IPs
|
||||||
|
const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = [
|
||||||
|
{ type: 2, value: "localhost" },
|
||||||
|
{ type: 7, ip: "127.0.0.1" },
|
||||||
|
{ type: 7, ip: "::1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add all current LAN IPs
|
||||||
|
const lanIPs = getLanIPs();
|
||||||
|
for (const ip of lanIPs) {
|
||||||
|
altNames.push({ type: 7, ip });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lanIPs.length > 0) {
|
||||||
|
console.log(` Including LAN IPs: ${lanIPs.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pems = await generate(attrs, {
|
||||||
|
keySize: 2048,
|
||||||
|
notAfterDate,
|
||||||
|
algorithm: "sha256",
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: "basicConstraints",
|
||||||
|
cA: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keyUsage",
|
||||||
|
keyCertSign: true,
|
||||||
|
digitalSignature: true,
|
||||||
|
keyEncipherment: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extKeyUsage",
|
||||||
|
serverAuth: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subjectAltName",
|
||||||
|
altNames,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save certificates
|
||||||
|
writeFileSync(KEY_PATH, pems.private);
|
||||||
|
writeFileSync(CERT_PATH, pems.cert);
|
||||||
|
|
||||||
|
console.log(`✅ Certificate saved to ${CERT_DIR}`);
|
||||||
|
console.log(` Valid for ${CERT_VALIDITY_DAYS} days`);
|
||||||
|
console.log(` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: pems.private,
|
||||||
|
cert: pems.cert,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
18
packages/acp-link/src/cli/app.ts
Normal file
18
packages/acp-link/src/cli/app.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { buildApplication } from "@stricli/core";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { command } from "./command.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const pkg = require("../../package.json") as { version: string };
|
||||||
|
|
||||||
|
export const app = buildApplication(command, {
|
||||||
|
name: "acp-link",
|
||||||
|
versionInfo: {
|
||||||
|
currentVersion: pkg.version,
|
||||||
|
},
|
||||||
|
scanner: {
|
||||||
|
caseStyle: "allow-kebab-for-camel",
|
||||||
|
allowArgumentEscapeSequence: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
7
packages/acp-link/src/cli/bin.ts
Normal file
7
packages/acp-link/src/cli/bin.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { run } from "@stricli/core";
|
||||||
|
import { app } from "./app.js";
|
||||||
|
import { buildContext } from "./context.js";
|
||||||
|
|
||||||
|
await run(app, process.argv.slice(2), buildContext());
|
||||||
|
|
||||||
90
packages/acp-link/src/cli/command.ts
Normal file
90
packages/acp-link/src/cli/command.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { buildCommand, numberParser } from "@stricli/core";
|
||||||
|
import type { LocalContext } from "./context.js";
|
||||||
|
|
||||||
|
export const command = buildCommand({
|
||||||
|
docs: {
|
||||||
|
brief: "Start the ACP proxy server",
|
||||||
|
fullDescription:
|
||||||
|
"Starts a WebSocket proxy server that bridges clients to ACP agents. " +
|
||||||
|
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
||||||
|
"Use -- to pass arguments to the agent:\n" +
|
||||||
|
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
||||||
|
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
flags: {
|
||||||
|
port: {
|
||||||
|
kind: "parsed",
|
||||||
|
parse: numberParser,
|
||||||
|
brief: "Port to listen on",
|
||||||
|
default: "9315",
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
kind: "parsed",
|
||||||
|
parse: String,
|
||||||
|
brief: "Host to bind to (use 0.0.0.0 for remote access)",
|
||||||
|
default: "localhost",
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
kind: "boolean",
|
||||||
|
brief: "Enable debug logging to file",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
"no-auth": {
|
||||||
|
kind: "boolean",
|
||||||
|
brief: "DANGEROUS: Disable authentication (not recommended)",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
https: {
|
||||||
|
kind: "boolean",
|
||||||
|
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
positional: {
|
||||||
|
kind: "array",
|
||||||
|
parameter: {
|
||||||
|
brief: "Agent command and arguments (use -- before agent flags)",
|
||||||
|
parse: String,
|
||||||
|
placeholder: "command",
|
||||||
|
},
|
||||||
|
minimum: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
func: async function (
|
||||||
|
this: LocalContext,
|
||||||
|
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
|
||||||
|
...args: readonly string[]
|
||||||
|
) {
|
||||||
|
const port = flags.port;
|
||||||
|
const host = flags.host;
|
||||||
|
const debug = flags.debug;
|
||||||
|
const noAuth = flags["no-auth"];
|
||||||
|
const https = flags.https;
|
||||||
|
const [command, ...agentArgs] = args;
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
// Determine auth token
|
||||||
|
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
|
||||||
|
let token: string | undefined;
|
||||||
|
if (noAuth) {
|
||||||
|
console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!");
|
||||||
|
token = undefined;
|
||||||
|
} else {
|
||||||
|
token = process.env.ACP_AUTH_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
// Auto-generate random token
|
||||||
|
const { randomBytes } = await import("node:crypto");
|
||||||
|
token = randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
const { initLogger } = await import("../logger.js");
|
||||||
|
initLogger({ debug });
|
||||||
|
|
||||||
|
// Import and run the server
|
||||||
|
const { startServer } = await import("../server.js");
|
||||||
|
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
|
||||||
|
},
|
||||||
|
});
|
||||||
10
packages/acp-link/src/cli/context.ts
Normal file
10
packages/acp-link/src/cli/context.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { CommandContext } from "@stricli/core";
|
||||||
|
|
||||||
|
export interface LocalContext extends CommandContext {}
|
||||||
|
|
||||||
|
export function buildContext(): LocalContext {
|
||||||
|
return {
|
||||||
|
process,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
83
packages/acp-link/src/logger.ts
Normal file
83
packages/acp-link/src/logger.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { mkdirSync, existsSync } from "node:fs";
|
||||||
|
|
||||||
|
let rootLogger: pino.Logger;
|
||||||
|
|
||||||
|
export interface LoggerConfig {
|
||||||
|
debug: boolean;
|
||||||
|
logDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pretty-print config for console output */
|
||||||
|
const PRETTY_CONFIG = {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: "SYS:HH:MM:ss.l",
|
||||||
|
ignore: "pid,hostname",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function initLogger(config: LoggerConfig): pino.Logger {
|
||||||
|
const { debug, logDir } = config;
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
const dir = logDir || join(process.cwd(), ".acp-proxy");
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.toISOString()
|
||||||
|
.replace(/T/, "_")
|
||||||
|
.replace(/:/g, "-")
|
||||||
|
.replace(/\..+/, "");
|
||||||
|
const logFile = join(dir, `acp-proxy-${timestamp}.log`);
|
||||||
|
|
||||||
|
// Debug mode: JSON to file + pretty to console (multistream)
|
||||||
|
rootLogger = pino(
|
||||||
|
{
|
||||||
|
level: "trace",
|
||||||
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
|
},
|
||||||
|
pino.transport({
|
||||||
|
targets: [
|
||||||
|
{ target: "pino/file", options: { destination: logFile } },
|
||||||
|
{ target: "pino-pretty", options: { ...PRETTY_CONFIG, destination: 1 } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`📝 Debug logging enabled: ${logFile}`);
|
||||||
|
} else {
|
||||||
|
rootLogger = pino(
|
||||||
|
{ level: "info", timestamp: pino.stdTimeFunctions.isoTime },
|
||||||
|
pino.transport({
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: { ...PRETTY_CONFIG, destination: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the root logger (auto-creates a default one if not initialized). */
|
||||||
|
export function getLogger(): pino.Logger {
|
||||||
|
if (!rootLogger) {
|
||||||
|
rootLogger = pino(
|
||||||
|
{ level: "info" },
|
||||||
|
pino.transport({
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: { ...PRETTY_CONFIG, destination: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rootLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a child logger scoped to a module.
|
||||||
|
* Usage: `const log = createLogger("agent"); log.info({ pid }, "spawned")`
|
||||||
|
*/
|
||||||
|
export function createLogger(module: string): pino.Logger {
|
||||||
|
return getLogger().child({ module });
|
||||||
|
}
|
||||||
258
packages/acp-link/src/rcs-upstream.ts
Normal file
258
packages/acp-link/src/rcs-upstream.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { createLogger } from "./logger.js";
|
||||||
|
|
||||||
|
export interface RcsUpstreamConfig {
|
||||||
|
rcsUrl: string; // e.g. "http://localhost:3000"
|
||||||
|
apiToken: string;
|
||||||
|
agentName: string;
|
||||||
|
channelGroupId?: string;
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
maxSessions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RCS upstream client — connects acp-link to a Remote Control Server.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* 1. connect() — opens WS to RCS
|
||||||
|
* 2. Sends register message
|
||||||
|
* 3. Waits for registered response
|
||||||
|
* 4. Forwards all ACP events via send()
|
||||||
|
* 5. Reconnects with exponential backoff on failure
|
||||||
|
*/
|
||||||
|
export class RcsUpstreamClient {
|
||||||
|
private static log = createLogger("rcs-upstream");
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private registered = false;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private closed = false;
|
||||||
|
private readonly maxReconnectDelay = 30_000;
|
||||||
|
private readonly baseReconnectDelay = 1_000;
|
||||||
|
/** Agent ID obtained from REST registration */
|
||||||
|
private agentId: string | null = null;
|
||||||
|
/** Session ID from REST registration (ACP agents auto-create a session) */
|
||||||
|
private sessionId: string | undefined;
|
||||||
|
|
||||||
|
/** Handler for incoming ACP messages from RCS relay */
|
||||||
|
private messageHandler: ((message: Record<string, unknown>) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(private config: RcsUpstreamConfig) {}
|
||||||
|
|
||||||
|
/** Get the agent ID from REST registration */
|
||||||
|
getAgentId(): string | null {
|
||||||
|
return this.agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set handler for incoming ACP messages from RCS relay */
|
||||||
|
setMessageHandler(handler: (message: Record<string, unknown>) => void): void {
|
||||||
|
this.messageHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register via REST API before establishing WS connection */
|
||||||
|
private async registerViaRest(): Promise<string> {
|
||||||
|
const baseUrl = this.config.rcsUrl
|
||||||
|
.replace(/^ws:\/\//, "http://")
|
||||||
|
.replace(/^wss:\/\//, "https://")
|
||||||
|
.replace(/\/acp\/ws.*$/, "")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
|
||||||
|
const url = `${baseUrl}/v1/environments/bridge`;
|
||||||
|
RcsUpstreamClient.log.info({ url }, "REST register");
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${this.config.apiToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
machine_name: this.config.agentName,
|
||||||
|
worker_type: "acp",
|
||||||
|
bridge_id: this.config.channelGroupId || undefined,
|
||||||
|
max_sessions: this.config.maxSessions,
|
||||||
|
capabilities: this.config.capabilities,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`REST register failed (${resp.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json() as { environment_id: string; environment_secret: string; status: string; session_id?: string };
|
||||||
|
this.agentId = data.environment_id;
|
||||||
|
this.sessionId = data.session_id;
|
||||||
|
RcsUpstreamClient.log.info({ agentId: this.agentId, sessionId: this.sessionId }, "REST register success");
|
||||||
|
return data.environment_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||||
|
private buildWsUrl(): string {
|
||||||
|
let raw = this.config.rcsUrl;
|
||||||
|
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||||
|
const url = new URL(raw);
|
||||||
|
const path = url.pathname.replace(/\/+$/, "");
|
||||||
|
if (!path || path === "/") {
|
||||||
|
url.pathname = "/acp/ws";
|
||||||
|
}
|
||||||
|
if (this.config.apiToken) {
|
||||||
|
url.searchParams.set("token", this.config.apiToken);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open connection to RCS: REST register → WS identify */
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.closed) return;
|
||||||
|
|
||||||
|
// Step 1: REST registration
|
||||||
|
try {
|
||||||
|
await this.registerViaRest();
|
||||||
|
} catch (err) {
|
||||||
|
RcsUpstreamClient.log.error({ err }, "REST registration failed");
|
||||||
|
if (!this.closed) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: WebSocket connection with identify
|
||||||
|
const wsUrl = this.buildWsUrl();
|
||||||
|
RcsUpstreamClient.log.info({ url: wsUrl }, "connecting WS");
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||||
|
this.ws!.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "identify",
|
||||||
|
agent_id: this.agentId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
let data: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.data as string);
|
||||||
|
} catch {
|
||||||
|
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "identified") {
|
||||||
|
RcsUpstreamClient.log.info({ agent_id: data.agent_id, channel_group_id: data.channel_group_id }, "identified");
|
||||||
|
this.registered = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
const webBase = this.config.rcsUrl
|
||||||
|
.replace(/^ws:\/\//, "http://")
|
||||||
|
.replace(/^wss:\/\//, "https://")
|
||||||
|
.replace(/\/acp\/ws.*$/, "")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
console.log();
|
||||||
|
if (this.sessionId) {
|
||||||
|
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
|
||||||
|
} else {
|
||||||
|
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||||
|
}
|
||||||
|
if (this.agentId) {
|
||||||
|
console.log(` Agent ID: ${this.agentId}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
resolve();
|
||||||
|
} else if (data.type === "registered") {
|
||||||
|
// Legacy fallback: server still uses old register flow
|
||||||
|
RcsUpstreamClient.log.info({ agent_id: data.agent_id }, "registered (legacy)");
|
||||||
|
this.agentId = (data.agent_id as string) || this.agentId;
|
||||||
|
this.registered = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
resolve();
|
||||||
|
} else if (data.type === "error") {
|
||||||
|
RcsUpstreamClient.log.error({ message: data.message }, "server error");
|
||||||
|
if (!this.registered) {
|
||||||
|
reject(new Error(data.message as string));
|
||||||
|
}
|
||||||
|
} else if (data.type === "keep_alive") {
|
||||||
|
// ignore keepalive
|
||||||
|
} else {
|
||||||
|
// Forward ACP protocol messages to handler (for RCS relay support)
|
||||||
|
RcsUpstreamClient.log.debug({ type: data.type }, "forwarding to relay handler");
|
||||||
|
this.messageHandler?.(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
// onclose fires after onerror with the actual close code, so we log there
|
||||||
|
if (!this.registered) {
|
||||||
|
reject(new Error("WebSocket connection failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
RcsUpstreamClient.log.info({ code: event.code, reason: event.reason || undefined }, "ws closed");
|
||||||
|
this.registered = false;
|
||||||
|
this.ws = null;
|
||||||
|
if (!this.closed) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
RcsUpstreamClient.log.error({ err }, "connect threw");
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send an ACP message to RCS for broadcast */
|
||||||
|
send(message: object): void {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
} catch (err) {
|
||||||
|
RcsUpstreamClient.log.error({ err }, "send failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if registered with RCS */
|
||||||
|
isRegistered(): boolean {
|
||||||
|
return this.registered && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the RCS connection permanently */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.closed = true;
|
||||||
|
this.registered = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close(1000, "client shutdown");
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
RcsUpstreamClient.log.info("closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
this.baseReconnectDelay * 2 ** this.reconnectAttempts,
|
||||||
|
this.maxReconnectDelay,
|
||||||
|
);
|
||||||
|
const jitter = delay * Math.random() * 0.2;
|
||||||
|
const actualDelay = delay + jitter;
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
|
RcsUpstreamClient.log.warn({ attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) }, "reconnecting");
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (this.closed) return;
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
} catch {
|
||||||
|
// connect() itself logs the error; nothing to add here
|
||||||
|
}
|
||||||
|
}, actualDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
895
packages/acp-link/src/server.ts
Normal file
895
packages/acp-link/src/server.ts
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { createServer as createHttpsServer } from "node:https";
|
||||||
|
import { Writable, Readable } from "node:stream";
|
||||||
|
import * as acp from "@agentclientprotocol/sdk";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { createNodeWebSocket } from "@hono/node-ws";
|
||||||
|
import type { WSContext } from "hono/ws";
|
||||||
|
import type { WebSocket as RawWebSocket } from "ws";
|
||||||
|
import { createLogger } from "./logger.js";
|
||||||
|
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
|
||||||
|
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js";
|
||||||
|
|
||||||
|
export interface ServerConfig {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
cwd: string;
|
||||||
|
debug?: boolean;
|
||||||
|
token?: string;
|
||||||
|
https?: boolean;
|
||||||
|
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
|
||||||
|
permissionMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending permission request
|
||||||
|
interface PendingPermission {
|
||||||
|
resolve: (outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string }) => void;
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptCapabilities from ACP protocol
|
||||||
|
// Reference: Zed's prompt_capabilities to check image support
|
||||||
|
interface PromptCapabilities {
|
||||||
|
audio?: boolean;
|
||||||
|
embeddedContext?: boolean;
|
||||||
|
image?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionModelState from ACP protocol
|
||||||
|
// Reference: Zed's AgentModelSelector reads from state.available_models
|
||||||
|
interface SessionModelState {
|
||||||
|
availableModels: Array<{
|
||||||
|
modelId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}>;
|
||||||
|
currentModelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentCapabilities from ACP protocol
|
||||||
|
// Reference: Zed's AcpConnection.agent_capabilities
|
||||||
|
// Matches SDK's AgentCapabilities exactly
|
||||||
|
interface AgentCapabilities {
|
||||||
|
_meta?: Record<string, unknown> | null;
|
||||||
|
loadSession?: boolean;
|
||||||
|
mcpCapabilities?: {
|
||||||
|
_meta?: Record<string, unknown> | null;
|
||||||
|
clientServers?: boolean;
|
||||||
|
};
|
||||||
|
promptCapabilities?: PromptCapabilities;
|
||||||
|
sessionCapabilities?: {
|
||||||
|
_meta?: Record<string, unknown> | null;
|
||||||
|
fork?: Record<string, unknown> | null;
|
||||||
|
list?: Record<string, unknown> | null;
|
||||||
|
resume?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track connected clients and their agent connections
|
||||||
|
interface ClientState {
|
||||||
|
process: ChildProcess | null;
|
||||||
|
connection: acp.ClientSideConnection | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
|
agentCapabilities: AgentCapabilities | null;
|
||||||
|
promptCapabilities: PromptCapabilities | null;
|
||||||
|
modelState: SessionModelState | null;
|
||||||
|
isAlive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level state (set when server starts)
|
||||||
|
let AGENT_COMMAND: string;
|
||||||
|
let AGENT_ARGS: string[];
|
||||||
|
let AGENT_CWD: string;
|
||||||
|
let SERVER_PORT: number;
|
||||||
|
let SERVER_HOST: string;
|
||||||
|
let AUTH_TOKEN: string | undefined;
|
||||||
|
let DEFAULT_PERMISSION_MODE: string | undefined;
|
||||||
|
|
||||||
|
const clients = new Map<WSContext, ClientState>();
|
||||||
|
|
||||||
|
// Module-scoped child loggers
|
||||||
|
const logWs = createLogger("ws");
|
||||||
|
const logAgent = createLogger("agent");
|
||||||
|
const logSession = createLogger("session");
|
||||||
|
const logPrompt = createLogger("prompt");
|
||||||
|
const logPerm = createLogger("perm");
|
||||||
|
const logRelay = createLogger("relay");
|
||||||
|
const logServer = createLogger("server");
|
||||||
|
|
||||||
|
// RCS upstream client (optional — enabled via ACP_RCS_URL env var)
|
||||||
|
let rcsUpstream: RcsUpstreamClient | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a virtual WSContext for RCS relay messages.
|
||||||
|
* Responses via send() go to RCS upstream (not a local WS).
|
||||||
|
*/
|
||||||
|
function createRelayWs(): WSContext {
|
||||||
|
return {
|
||||||
|
get readyState() { return 1; }, // always OPEN
|
||||||
|
send: () => {}, // no-op — responses go through rcsUpstream.send()
|
||||||
|
close: () => {},
|
||||||
|
raw: null,
|
||||||
|
isInner: false,
|
||||||
|
url: "",
|
||||||
|
origin: "",
|
||||||
|
protocol: "",
|
||||||
|
} as unknown as WSContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission request timeout (5 minutes)
|
||||||
|
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Heartbeat interval for WebSocket ping/pong (30 seconds)
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
// Generate unique request ID
|
||||||
|
function generateRequestId(): string {
|
||||||
|
return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message to the WebSocket client (and optionally forward to RCS upstream)
|
||||||
|
function send(ws: WSContext, type: string, payload?: unknown): void {
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
// WebSocket.OPEN
|
||||||
|
ws.send(JSON.stringify({ type, payload }));
|
||||||
|
}
|
||||||
|
// Forward to RCS upstream if connected
|
||||||
|
if (rcsUpstream?.isRegistered()) {
|
||||||
|
rcsUpstream.send({ type, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a Client implementation that forwards events to WebSocket
|
||||||
|
function createClient(ws: WSContext, clientState: ClientState): acp.Client {
|
||||||
|
return {
|
||||||
|
async requestPermission(params) {
|
||||||
|
const requestId = generateRequestId();
|
||||||
|
logPerm.debug({ requestId, title: params.toolCall.title }, "requested");
|
||||||
|
|
||||||
|
const outcomePromise = new Promise<{ outcome: "cancelled" } | { outcome: "selected"; optionId: string }>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
logPerm.warn({ requestId }, "timed out");
|
||||||
|
clientState.pendingPermissions.delete(requestId);
|
||||||
|
resolve({ outcome: "cancelled" });
|
||||||
|
}, PERMISSION_TIMEOUT_MS);
|
||||||
|
|
||||||
|
clientState.pendingPermissions.set(requestId, { resolve, timeout });
|
||||||
|
});
|
||||||
|
|
||||||
|
send(ws, "permission_request", {
|
||||||
|
requestId,
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
options: params.options,
|
||||||
|
toolCall: params.toolCall,
|
||||||
|
});
|
||||||
|
|
||||||
|
const outcome = await outcomePromise;
|
||||||
|
logPerm.debug({ requestId, outcome: outcome.outcome }, "resolved");
|
||||||
|
|
||||||
|
return { outcome };
|
||||||
|
},
|
||||||
|
|
||||||
|
async sessionUpdate(params) {
|
||||||
|
send(ws, "session_update", params);
|
||||||
|
},
|
||||||
|
|
||||||
|
async readTextFile(params) {
|
||||||
|
logWs.debug({ path: params.path }, "readTextFile");
|
||||||
|
return { content: "" };
|
||||||
|
},
|
||||||
|
|
||||||
|
async writeTextFile(params) {
|
||||||
|
logWs.debug({ path: params.path }, "writeTextFile");
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle permission response from client
|
||||||
|
function handlePermissionResponse(ws: WSContext, payload: { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } }): void {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state) {
|
||||||
|
logPerm.warn("response from unknown client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = state.pendingPermissions.get(payload.requestId);
|
||||||
|
if (!pending) {
|
||||||
|
logPerm.warn({ requestId: payload.requestId }, "response for unknown request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
state.pendingPermissions.delete(payload.requestId);
|
||||||
|
pending.resolve(payload.outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel all pending permissions for a client (called on disconnect)
|
||||||
|
function cancelPendingPermissions(clientState: ClientState): void {
|
||||||
|
for (const [requestId, pending] of clientState.pendingPermissions) {
|
||||||
|
logPerm.debug({ requestId }, "cancelled on disconnect");
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pending.resolve({ outcome: "cancelled" });
|
||||||
|
}
|
||||||
|
clientState.pendingPermissions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnect(ws: WSContext): Promise<void> {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
// If already connected to a running agent, just resend status
|
||||||
|
// This handles frontend reconnections without restarting the agent process
|
||||||
|
// Check both .killed and .exitCode to detect crashed processes
|
||||||
|
if (state.connection && state.process && !state.process.killed && state.process.exitCode === null) {
|
||||||
|
logAgent.info("already connected, resending status");
|
||||||
|
send(ws, "status", {
|
||||||
|
connected: true,
|
||||||
|
agentInfo: { name: AGENT_COMMAND },
|
||||||
|
capabilities: state.agentCapabilities,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill existing process if any (only if not healthy)
|
||||||
|
if (state.process) {
|
||||||
|
cancelPendingPermissions(state);
|
||||||
|
state.process.kill();
|
||||||
|
state.process = null;
|
||||||
|
state.connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, "spawning");
|
||||||
|
|
||||||
|
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
||||||
|
cwd: AGENT_CWD,
|
||||||
|
stdio: ["pipe", "pipe", "inherit"],
|
||||||
|
});
|
||||||
|
|
||||||
|
state.process = agentProcess;
|
||||||
|
|
||||||
|
// Clean up state when agent process exits unexpectedly
|
||||||
|
agentProcess.on("exit", (code) => {
|
||||||
|
logAgent.info({ exitCode: code }, "agent process exited");
|
||||||
|
// Only clear if this is still the current process
|
||||||
|
if (state.process === agentProcess) {
|
||||||
|
state.process = null;
|
||||||
|
state.connection = null;
|
||||||
|
state.sessionId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = Writable.toWeb(agentProcess.stdin!) as unknown as WritableStream<Uint8Array>;
|
||||||
|
const output = Readable.toWeb(agentProcess.stdout!) as unknown as ReadableStream<Uint8Array>;
|
||||||
|
|
||||||
|
const stream = acp.ndJsonStream(input, output);
|
||||||
|
const connection = new acp.ClientSideConnection(
|
||||||
|
(_agent) => createClient(ws, state),
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
|
||||||
|
state.connection = connection;
|
||||||
|
|
||||||
|
const initResult = await connection.initialize({
|
||||||
|
protocolVersion: acp.PROTOCOL_VERSION,
|
||||||
|
clientInfo: { name: "zed", version: "1.0.0" },
|
||||||
|
clientCapabilities: {
|
||||||
|
fs: { readTextFile: true, writeTextFile: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentCaps = initResult.agentCapabilities;
|
||||||
|
state.agentCapabilities = agentCaps ? {
|
||||||
|
_meta: agentCaps._meta,
|
||||||
|
loadSession: agentCaps.loadSession,
|
||||||
|
mcpCapabilities: agentCaps.mcpCapabilities,
|
||||||
|
promptCapabilities: agentCaps.promptCapabilities,
|
||||||
|
sessionCapabilities: agentCaps.sessionCapabilities,
|
||||||
|
} : null;
|
||||||
|
state.promptCapabilities = agentCaps?.promptCapabilities ?? null;
|
||||||
|
|
||||||
|
logAgent.info({
|
||||||
|
protocolVersion: initResult.protocolVersion,
|
||||||
|
loadSession: !!state.agentCapabilities?.loadSession,
|
||||||
|
sessionList: !!state.agentCapabilities?.sessionCapabilities?.list,
|
||||||
|
sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume,
|
||||||
|
hasMcp: !!state.agentCapabilities?.mcpCapabilities,
|
||||||
|
}, "initialized");
|
||||||
|
|
||||||
|
send(ws, "status", {
|
||||||
|
connected: true,
|
||||||
|
agentInfo: initResult.agentInfo,
|
||||||
|
capabilities: state.agentCapabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.closed.then(() => {
|
||||||
|
logAgent.info("connection closed");
|
||||||
|
state.connection = null;
|
||||||
|
state.sessionId = null;
|
||||||
|
send(ws, "status", { connected: false });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logAgent.error({ error: (error as Error).message }, "connect failed");
|
||||||
|
send(ws, "error", { message: `Failed to connect: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewSession(
|
||||||
|
ws: WSContext,
|
||||||
|
params: { cwd?: string; permissionMode?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state?.connection) {
|
||||||
|
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleNewSession: not connected to agent");
|
||||||
|
send(ws, "error", { message: "Not connected to agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionCwd = params.cwd || AGENT_CWD;
|
||||||
|
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
|
||||||
|
const result = await state.connection.newSession({
|
||||||
|
cwd: sessionCwd,
|
||||||
|
mcpServers: [],
|
||||||
|
...(permissionMode ? { _meta: { permissionMode } } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.sessionId = result.sessionId;
|
||||||
|
state.modelState = result.models ?? null;
|
||||||
|
logSession.info({ sessionId: result.sessionId, cwd: sessionCwd, hasModels: !!result.models }, "created");
|
||||||
|
|
||||||
|
send(ws, "session_created", {
|
||||||
|
...result,
|
||||||
|
promptCapabilities: state.promptCapabilities,
|
||||||
|
models: state.modelState,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logSession.error({ error: (error as Error).message }, "create failed");
|
||||||
|
send(ws, "error", { message: `Failed to create session: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session History Operations
|
||||||
|
// Reference: Zed's AgentConnection trait - list_sessions, load_session, resume_session
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function handleListSessions(
|
||||||
|
ws: WSContext,
|
||||||
|
params: { cwd?: string; cursor?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state?.connection) {
|
||||||
|
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleListSessions: not connected to agent");
|
||||||
|
send(ws, "error", { message: "Not connected to agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.agentCapabilities?.sessionCapabilities?.list) {
|
||||||
|
send(ws, "error", { message: "Listing sessions is not supported by this agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await state.connection.listSessions({
|
||||||
|
cwd: params.cwd,
|
||||||
|
cursor: params.cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_SESSIONS = 20;
|
||||||
|
const sessions = result.sessions.slice(0, MAX_SESSIONS);
|
||||||
|
logSession.info({ total: result.sessions.length, returned: sessions.length, hasMore: !!result.nextCursor }, "listed");
|
||||||
|
|
||||||
|
send(ws, "session_list", {
|
||||||
|
sessions: sessions.map((s: acp.SessionInfo) => ({
|
||||||
|
_meta: s._meta,
|
||||||
|
cwd: s.cwd,
|
||||||
|
sessionId: s.sessionId,
|
||||||
|
title: s.title,
|
||||||
|
updatedAt: s.updatedAt,
|
||||||
|
})),
|
||||||
|
nextCursor: result.nextCursor,
|
||||||
|
_meta: result._meta,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logSession.error({ error: (error as Error).message }, "list failed");
|
||||||
|
send(ws, "error", { message: `Failed to list sessions: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoadSession(
|
||||||
|
ws: WSContext,
|
||||||
|
params: { sessionId: string; cwd?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state?.connection) {
|
||||||
|
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleLoadSession: not connected to agent");
|
||||||
|
send(ws, "error", { message: "Not connected to agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.agentCapabilities?.loadSession) {
|
||||||
|
send(ws, "error", { message: "Loading sessions is not supported by this agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionCwd = params.cwd || AGENT_CWD;
|
||||||
|
const sessionId = params.sessionId;
|
||||||
|
const result = await state.connection.loadSession({
|
||||||
|
sessionId,
|
||||||
|
cwd: sessionCwd,
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
state.sessionId = sessionId;
|
||||||
|
state.modelState = result.models ?? null;
|
||||||
|
logSession.info({ sessionId, cwd: sessionCwd }, "loaded");
|
||||||
|
|
||||||
|
send(ws, "session_loaded", {
|
||||||
|
sessionId,
|
||||||
|
promptCapabilities: state.promptCapabilities,
|
||||||
|
models: state.modelState,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logSession.error({ error: (error as Error).message }, "load failed");
|
||||||
|
send(ws, "error", { message: `Failed to load session: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResumeSession(
|
||||||
|
ws: WSContext,
|
||||||
|
params: { sessionId: string; cwd?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state?.connection) {
|
||||||
|
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleResumeSession: not connected to agent");
|
||||||
|
send(ws, "error", { message: "Not connected to agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.agentCapabilities?.sessionCapabilities?.resume) {
|
||||||
|
send(ws, "error", { message: "Resuming sessions is not supported by this agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionCwd = params.cwd || AGENT_CWD;
|
||||||
|
const sessionId = params.sessionId;
|
||||||
|
const result = await state.connection.unstable_resumeSession({
|
||||||
|
sessionId,
|
||||||
|
cwd: sessionCwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.sessionId = sessionId;
|
||||||
|
state.modelState = result.models ?? null;
|
||||||
|
logSession.info({ sessionId, cwd: sessionCwd }, "resumed");
|
||||||
|
|
||||||
|
send(ws, "session_resumed", {
|
||||||
|
sessionId,
|
||||||
|
promptCapabilities: state.promptCapabilities,
|
||||||
|
models: state.modelState,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logSession.error({ error: (error as Error).message }, "resume failed");
|
||||||
|
send(ws, "error", { message: `Failed to resume session: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference: Zed's AcpThread.send() forwards Vec<acp::ContentBlock> to agent
|
||||||
|
async function handlePrompt(
|
||||||
|
ws: WSContext,
|
||||||
|
params: { content: ContentBlock[] },
|
||||||
|
): Promise<void> {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state?.connection || !state.sessionId) {
|
||||||
|
send(ws, "error", { message: "No active session" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstText = params.content.find(b => b.type === "text")?.text;
|
||||||
|
const images = params.content.filter(b => b.type === "image");
|
||||||
|
logPrompt.debug({
|
||||||
|
text: firstText?.slice(0, 100),
|
||||||
|
imageCount: images.length,
|
||||||
|
blockCount: params.content.length,
|
||||||
|
}, "sending");
|
||||||
|
|
||||||
|
const result = await state.connection.prompt({
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
prompt: params.content as acp.ContentBlock[],
|
||||||
|
});
|
||||||
|
|
||||||
|
logPrompt.info({ stopReason: result.stopReason }, "completed");
|
||||||
|
send(ws, "prompt_complete", result);
|
||||||
|
} catch (error) {
|
||||||
|
logPrompt.error({ error: (error as Error).message }, "failed");
|
||||||
|
send(ws, "error", { message: `Prompt failed: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect(ws: WSContext): void {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
if (state.process) {
|
||||||
|
state.process.kill();
|
||||||
|
state.process = null;
|
||||||
|
}
|
||||||
|
state.connection = null;
|
||||||
|
state.sessionId = null;
|
||||||
|
|
||||||
|
send(ws, "status", { connected: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel request from client
|
||||||
|
async function handleCancel(ws: WSContext): Promise<void> {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state?.connection || !state.sessionId) {
|
||||||
|
logWs.warn("cancel requested but no active session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSession.info({ sessionId: state.sessionId }, "cancel requested");
|
||||||
|
cancelPendingPermissions(state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await state.connection.cancel({ sessionId: state.sessionId });
|
||||||
|
logSession.info({ sessionId: state.sessionId }, "cancel sent");
|
||||||
|
} catch (error) {
|
||||||
|
logSession.error({ error: (error as Error).message }, "cancel failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model()
|
||||||
|
async function handleSetSessionModel(
|
||||||
|
ws: WSContext,
|
||||||
|
params: { modelId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (!state?.connection || !state.sessionId) {
|
||||||
|
send(ws, "error", { message: "No active session" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.modelState) {
|
||||||
|
send(ws, "error", { message: "Model selection not supported by this agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logSession.info({ sessionId: state.sessionId, modelId: params.modelId }, "setting model");
|
||||||
|
await state.connection.unstable_setSessionModel({
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
modelId: params.modelId,
|
||||||
|
});
|
||||||
|
state.modelState = { ...state.modelState, currentModelId: params.modelId };
|
||||||
|
send(ws, "model_changed", { modelId: params.modelId });
|
||||||
|
logSession.info({ modelId: params.modelId }, "model changed");
|
||||||
|
} catch (error) {
|
||||||
|
logSession.error({ error: (error as Error).message }, "set model failed");
|
||||||
|
send(ws, "error", { message: `Failed to set model: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlock type matching @agentclientprotocol/sdk
|
||||||
|
interface ContentBlock {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
data?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
uri?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProxyMessage {
|
||||||
|
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
|
||||||
|
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServer(config: ServerConfig): Promise<void> {
|
||||||
|
const { port, host, command, args, cwd, token, https } = config;
|
||||||
|
|
||||||
|
// Set module-level config
|
||||||
|
AGENT_COMMAND = command;
|
||||||
|
AGENT_ARGS = args;
|
||||||
|
AGENT_CWD = cwd;
|
||||||
|
SERVER_PORT = port;
|
||||||
|
SERVER_HOST = host;
|
||||||
|
AUTH_TOKEN = token;
|
||||||
|
DEFAULT_PERMISSION_MODE = config.permissionMode || process.env.ACP_PERMISSION_MODE;
|
||||||
|
|
||||||
|
// Initialize RCS upstream client if configured
|
||||||
|
const rcsUrl = process.env.ACP_RCS_URL;
|
||||||
|
const rcsToken = process.env.ACP_RCS_TOKEN;
|
||||||
|
if (rcsUrl) {
|
||||||
|
rcsUpstream = new RcsUpstreamClient({
|
||||||
|
rcsUrl,
|
||||||
|
apiToken: rcsToken || "",
|
||||||
|
agentName: command,
|
||||||
|
maxSessions: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const relayWs = createRelayWs();
|
||||||
|
const relayState: ClientState = {
|
||||||
|
process: null,
|
||||||
|
connection: null,
|
||||||
|
sessionId: null,
|
||||||
|
pendingPermissions: new Map(),
|
||||||
|
agentCapabilities: null,
|
||||||
|
promptCapabilities: null,
|
||||||
|
modelState: null,
|
||||||
|
isAlive: true,
|
||||||
|
};
|
||||||
|
clients.set(relayWs, relayState);
|
||||||
|
|
||||||
|
rcsUpstream.setMessageHandler(async (msg) => {
|
||||||
|
try {
|
||||||
|
logRelay.debug({ type: msg.type }, "processing");
|
||||||
|
switch (msg.type) {
|
||||||
|
case "connect":
|
||||||
|
await handleConnect(relayWs);
|
||||||
|
break;
|
||||||
|
case "disconnect":
|
||||||
|
handleDisconnect(relayWs);
|
||||||
|
break;
|
||||||
|
case "new_session":
|
||||||
|
await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||||
|
break;
|
||||||
|
case "prompt":
|
||||||
|
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
|
||||||
|
break;
|
||||||
|
case "permission_response":
|
||||||
|
handlePermissionResponse(relayWs, msg.payload as { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } });
|
||||||
|
break;
|
||||||
|
case "cancel":
|
||||||
|
await handleCancel(relayWs);
|
||||||
|
break;
|
||||||
|
case "set_session_model":
|
||||||
|
await handleSetSessionModel(relayWs, msg.payload as { modelId: string });
|
||||||
|
break;
|
||||||
|
case "list_sessions":
|
||||||
|
await handleListSessions(relayWs, (msg.payload as { cwd?: string; cursor?: string }) || {});
|
||||||
|
break;
|
||||||
|
case "load_session":
|
||||||
|
await handleLoadSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||||
|
break;
|
||||||
|
case "resume_session":
|
||||||
|
await handleResumeSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||||
|
break;
|
||||||
|
case "ping":
|
||||||
|
send(relayWs, "pong");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logRelay.warn({ type: msg.type }, "unknown message type");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logRelay.error({ error: (error as Error).message }, "handler error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rcsUpstream.connect().catch((err) => {
|
||||||
|
logRelay.warn({ error: (err as Error).message }, "initial connection failed");
|
||||||
|
});
|
||||||
|
logRelay.info({ url: rcsUrl }, "upstream enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get("/health", (c) => {
|
||||||
|
return c.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket endpoint with token validation
|
||||||
|
app.get(
|
||||||
|
"/ws",
|
||||||
|
upgradeWebSocket((c) => {
|
||||||
|
if (AUTH_TOKEN) {
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const providedToken = url.searchParams.get("token");
|
||||||
|
if (providedToken !== AUTH_TOKEN) {
|
||||||
|
logWs.warn("connection rejected: invalid token");
|
||||||
|
return {
|
||||||
|
onOpen(_event, ws) {
|
||||||
|
ws.close(4001, "Unauthorized: Invalid token");
|
||||||
|
},
|
||||||
|
onMessage() {},
|
||||||
|
onClose() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onOpen(_event, ws) {
|
||||||
|
logWs.info("client connected");
|
||||||
|
const state: ClientState = {
|
||||||
|
process: null,
|
||||||
|
connection: null,
|
||||||
|
sessionId: null,
|
||||||
|
pendingPermissions: new Map(),
|
||||||
|
agentCapabilities: null,
|
||||||
|
promptCapabilities: null,
|
||||||
|
modelState: null,
|
||||||
|
isAlive: true,
|
||||||
|
};
|
||||||
|
clients.set(ws, state);
|
||||||
|
|
||||||
|
const rawWs = ws.raw as RawWebSocket;
|
||||||
|
rawWs.on("pong", () => {
|
||||||
|
state.isAlive = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async onMessage(event, ws) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data.toString());
|
||||||
|
logWs.debug({ type: data.type }, "received");
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case "connect":
|
||||||
|
await handleConnect(ws);
|
||||||
|
break;
|
||||||
|
case "disconnect":
|
||||||
|
handleDisconnect(ws);
|
||||||
|
break;
|
||||||
|
case "new_session":
|
||||||
|
await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||||
|
break;
|
||||||
|
case "prompt":
|
||||||
|
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
|
||||||
|
break;
|
||||||
|
case "permission_response":
|
||||||
|
handlePermissionResponse(ws, data.payload);
|
||||||
|
break;
|
||||||
|
case "cancel":
|
||||||
|
await handleCancel(ws);
|
||||||
|
break;
|
||||||
|
case "set_session_model":
|
||||||
|
await handleSetSessionModel(ws, data.payload as { modelId: string });
|
||||||
|
break;
|
||||||
|
case "list_sessions":
|
||||||
|
await handleListSessions(ws, (data.payload as { cwd?: string; cursor?: string }) || {});
|
||||||
|
break;
|
||||||
|
case "load_session":
|
||||||
|
await handleLoadSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||||
|
break;
|
||||||
|
case "resume_session":
|
||||||
|
await handleResumeSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||||
|
break;
|
||||||
|
case "ping":
|
||||||
|
send(ws, "pong");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
send(ws, "error", { message: `Unknown message type: ${data.type}` });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logWs.error({ error: (error as Error).message }, "message error");
|
||||||
|
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClose(_event, ws) {
|
||||||
|
logWs.info("client disconnected");
|
||||||
|
const state = clients.get(ws);
|
||||||
|
if (state) {
|
||||||
|
cancelPendingPermissions(state);
|
||||||
|
}
|
||||||
|
handleDisconnect(ws);
|
||||||
|
clients.delete(ws);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create server with optional HTTPS
|
||||||
|
let server;
|
||||||
|
if (https) {
|
||||||
|
const tlsOptions = await getOrCreateCertificate();
|
||||||
|
server = serve({
|
||||||
|
fetch: app.fetch,
|
||||||
|
port,
|
||||||
|
hostname: host,
|
||||||
|
createServer: createHttpsServer,
|
||||||
|
serverOptions: tlsOptions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
server = serve({ fetch: app.fetch, port, hostname: host });
|
||||||
|
}
|
||||||
|
injectWebSocket(server);
|
||||||
|
|
||||||
|
// Heartbeat: periodically ping all connected clients
|
||||||
|
setInterval(() => {
|
||||||
|
for (const [ws, state] of clients) {
|
||||||
|
// Skip virtual relay connections (no raw socket, always alive)
|
||||||
|
if (!ws.raw && state.isAlive) continue;
|
||||||
|
if (!ws.raw) {
|
||||||
|
// Connection already closed, clean up
|
||||||
|
clients.delete(ws);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!state.isAlive) {
|
||||||
|
logWs.info("heartbeat timeout, terminating");
|
||||||
|
(ws.raw as RawWebSocket).terminate();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
state.isAlive = false;
|
||||||
|
(ws.raw as RawWebSocket).ping();
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Protocol strings based on HTTPS mode
|
||||||
|
const wsProtocol = https ? "wss" : "ws";
|
||||||
|
|
||||||
|
// Get actual LAN IP when binding to 0.0.0.0
|
||||||
|
let displayHost = host;
|
||||||
|
if (host === "0.0.0.0") {
|
||||||
|
const lanIPs = getLanIPs();
|
||||||
|
displayHost = lanIPs[0] || "localhost";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URLs
|
||||||
|
const localWsUrl = `${wsProtocol}://localhost:${port}/ws`;
|
||||||
|
const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws`;
|
||||||
|
|
||||||
|
// Print startup banner
|
||||||
|
console.log();
|
||||||
|
console.log(` 🚀 ACP Proxy Server${https ? " (HTTPS)" : ""}`);
|
||||||
|
console.log();
|
||||||
|
console.log(` Connection:`);
|
||||||
|
if (host === "0.0.0.0") {
|
||||||
|
console.log(` URL: ${networkWsUrl}`);
|
||||||
|
} else {
|
||||||
|
console.log(` URL: ${localWsUrl}`);
|
||||||
|
}
|
||||||
|
if (AUTH_TOKEN) {
|
||||||
|
console.log(` Token: ${AUTH_TOKEN}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
if (!AUTH_TOKEN) {
|
||||||
|
console.log(` ⚠️ Authentication disabled (--no-auth)`);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentDisplay = AGENT_ARGS.length > 0
|
||||||
|
? `${AGENT_COMMAND} ${AGENT_ARGS.join(" ")}`
|
||||||
|
: AGENT_COMMAND;
|
||||||
|
console.log(` 📦 Agent: ${agentDisplay}`);
|
||||||
|
console.log(` CWD: ${AGENT_CWD}`);
|
||||||
|
console.log();
|
||||||
|
console.log(` Press Ctrl+C to stop`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
logServer.info({
|
||||||
|
port,
|
||||||
|
host,
|
||||||
|
https,
|
||||||
|
wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`,
|
||||||
|
agent: AGENT_COMMAND,
|
||||||
|
agentArgs: AGENT_ARGS,
|
||||||
|
cwd: AGENT_CWD,
|
||||||
|
authEnabled: !!AUTH_TOKEN,
|
||||||
|
}, "started");
|
||||||
|
|
||||||
|
// Keep the server running
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown — close RCS upstream on process exit
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
if (rcsUpstream) {
|
||||||
|
await rcsUpstream.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
if (rcsUpstream) {
|
||||||
|
await rcsUpstream.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
150
packages/acp-link/src/types.ts
Normal file
150
packages/acp-link/src/types.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// JSON-RPC 2.0 Types
|
||||||
|
export interface JsonRpcRequest {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id: string | number;
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id: string | number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: JsonRpcError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonRpcNotification {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonRpcError {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonRpcMessage =
|
||||||
|
| JsonRpcRequest
|
||||||
|
| JsonRpcResponse
|
||||||
|
| JsonRpcNotification;
|
||||||
|
|
||||||
|
// Helper to check message types
|
||||||
|
export function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
|
||||||
|
return "method" in msg && "id" in msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
|
||||||
|
return "id" in msg && !("method" in msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNotification(
|
||||||
|
msg: JsonRpcMessage,
|
||||||
|
): msg is JsonRpcNotification {
|
||||||
|
return "method" in msg && !("id" in msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACP Protocol Types
|
||||||
|
|
||||||
|
// Client -> Server messages (from extension to proxy)
|
||||||
|
export interface ProxyConnectParams {
|
||||||
|
command: string; // Command to launch the agent (e.g., "claude-agent")
|
||||||
|
args?: string[]; // Optional arguments
|
||||||
|
cwd?: string; // Working directory for the agent
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyMessage {
|
||||||
|
type: "connect" | "disconnect" | "message";
|
||||||
|
payload?: ProxyConnectParams | JsonRpcMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server -> Client messages (from proxy to extension)
|
||||||
|
export interface ProxyStatus {
|
||||||
|
type: "status";
|
||||||
|
connected: boolean;
|
||||||
|
agentInfo?: {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyAgentMessage {
|
||||||
|
type: "agent_message";
|
||||||
|
payload: JsonRpcMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyError {
|
||||||
|
type: "error";
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError;
|
||||||
|
|
||||||
|
// ACP Initialization
|
||||||
|
export interface InitializeParams {
|
||||||
|
protocolVersion: string;
|
||||||
|
clientInfo: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
capabilities?: ClientCapabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientCapabilities {
|
||||||
|
streaming?: boolean;
|
||||||
|
toolApproval?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitializeResult {
|
||||||
|
protocolVersion: string;
|
||||||
|
serverInfo: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
capabilities?: ServerCapabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerCapabilities {
|
||||||
|
streaming?: boolean;
|
||||||
|
tools?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACP Session
|
||||||
|
export interface SessionSetupParams {
|
||||||
|
sessionId?: string;
|
||||||
|
context?: SessionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionContext {
|
||||||
|
workingDirectory?: string;
|
||||||
|
files?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACP Prompt
|
||||||
|
export interface PromptParams {
|
||||||
|
sessionId: string;
|
||||||
|
messages: PromptMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string | ContentPart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentPart {
|
||||||
|
type: "text" | "image" | "file";
|
||||||
|
text?: string;
|
||||||
|
data?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content streaming notification
|
||||||
|
export interface ContentNotification {
|
||||||
|
sessionId: string;
|
||||||
|
content: string;
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
37
packages/acp-link/tsconfig.json
Normal file
37
packages/acp-link/tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Node.js module resolution
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
|
||||||
|
// Output
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools'
|
import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools'
|
||||||
import type { Tool as HostTool } from '../../src/Tool.js'
|
import type { Tool as HostTool } from '../../../../src/Tool.js'
|
||||||
|
|
||||||
describe('agent-tools compatibility', () => {
|
describe('agent-tools compatibility', () => {
|
||||||
test('CoreTool structural compatibility with host Tool', () => {
|
test('CoreTool structural compatibility with host Tool', () => {
|
||||||
@@ -27,7 +27,7 @@ describe('agent-tools compatibility', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This assignment should work if HostTool structurally extends CoreTool
|
// This assignment should work if HostTool structurally extends CoreTool
|
||||||
const coreTool: CoreTool = mockHostTool as CoreTool
|
const coreTool: CoreTool = mockHostTool as unknown as CoreTool
|
||||||
expect(coreTool.name).toBe('test')
|
expect(coreTool.name).toBe('test')
|
||||||
expect(coreTool.isEnabled()).toBe(true)
|
expect(coreTool.isEnabled()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
5
packages/agent-tools/tsconfig.json
Normal file
5
packages/agent-tools/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
5
packages/audio-capture-napi/tsconfig.json
Normal file
5
packages/audio-capture-napi/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -7,6 +7,17 @@ mock.module("src/utils/model/agent.js", () => ({
|
|||||||
|
|
||||||
mock.module("src/utils/settings/constants.js", () => ({
|
mock.module("src/utils/settings/constants.js", () => ({
|
||||||
getSourceDisplayName: (source: string) => source,
|
getSourceDisplayName: (source: string) => source,
|
||||||
|
getSourceDisplayNameLowercase: (source: string) => source,
|
||||||
|
getSourceDisplayNameCapitalized: (source: string) => source,
|
||||||
|
getSettingSourceName: (source: string) => source,
|
||||||
|
getSettingSourceDisplayNameLowercase: (source: string) => source,
|
||||||
|
getSettingSourceDisplayNameCapitalized: (source: string) => source,
|
||||||
|
parseSettingSourcesFlag: () => [],
|
||||||
|
getEnabledSettingSources: () => [],
|
||||||
|
isSettingSourceEnabled: () => true,
|
||||||
|
SETTING_SOURCES: ["localSettings", "userSettings", "projectSettings"],
|
||||||
|
SOURCES: ["localSettings", "userSettings", "projectSettings"],
|
||||||
|
CLAUDE_CODE_SETTINGS_SCHEMA_URL: "https://json.schemastore.org/claude-code-settings.json",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
|
|||||||
updateProgressFromMessage: noop,
|
updateProgressFromMessage: noop,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("src/utils/debug.js", () => ({
|
mock.module("src/utils/debug.ts", () => ({
|
||||||
getMinDebugLogLevel: () => "warn",
|
getMinDebugLogLevel: () => "warn",
|
||||||
isDebugMode: () => false,
|
isDebugMode: () => false,
|
||||||
enableDebugLogging: () => false,
|
enableDebugLogging: () => false,
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import { mock, describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
// Mock commands.ts to cut the heavy shell/prefix.ts → analytics → api chain
|
|
||||||
mock.module("src/utils/bash/commands.ts", () => ({
|
|
||||||
splitCommand_DEPRECATED: (cmd: string) =>
|
|
||||||
cmd.split(/\s*(?:[|;&]+)\s*/).filter(Boolean),
|
|
||||||
quote: (args: string[]) => args.join(" "),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { interpretCommandResult } = await import("../commandSemantics");
|
const { interpretCommandResult } = await import("../commandSemantics");
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import { mock, describe, expect, test } from "bun:test";
|
import { mock, describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
mock.module("src/utils/debug.js", () => ({
|
mock.module("src/utils/debug.ts", () => ({
|
||||||
logForDebugging: () => {},
|
logForDebugging: () => {},
|
||||||
isDebugMode: () => false,
|
isDebugMode: () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("src/utils/errors.js", () => ({
|
|
||||||
errorMessage: (e: unknown) => String(e),
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module("src/utils/stringUtils.js", () => ({
|
|
||||||
plural: (n: number, singular: string, plural?: string) =>
|
|
||||||
n === 1 ? singular : (plural ?? singular + "s"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formatGoToDefinitionResult,
|
formatGoToDefinitionResult,
|
||||||
formatFindReferencesResult,
|
formatFindReferencesResult,
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ mock.module("src/utils/cwd.js", () => ({
|
|||||||
getCwd: () => mockCwd,
|
getCwd: () => mockCwd,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
|
||||||
|
mock.module("src/utils/powershell/parser.js", () => ({
|
||||||
|
PS_TOKENIZER_DASH_CHARS: new Set(['-', '\u2013', '\u2014', '\u2015']),
|
||||||
|
COMMON_ALIASES: {},
|
||||||
|
commandHasArgAbbreviation: () => false,
|
||||||
|
deriveSecurityFlags: () => ({}),
|
||||||
|
getAllCommands: () => [],
|
||||||
|
getVariablesByScope: () => [],
|
||||||
|
hasCommandNamed: () => false,
|
||||||
|
parsePowerShellCommandCached: () => ({ valid: false, errors: [], statements: [], variables: [], hasStopParsing: false, originalCommand: "" }),
|
||||||
|
}))
|
||||||
|
|
||||||
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
|
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
|
||||||
|
|
||||||
describe("isGitInternalPathPS", () => {
|
describe("isGitInternalPathPS", () => {
|
||||||
|
|||||||
@@ -32,6 +32,58 @@ mock.module("src/utils/powershell/dangerousCmdlets.js", () => ({
|
|||||||
]),
|
]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
|
||||||
|
// Provide parser stubs so powershellSecurity.ts loads without the alias.
|
||||||
|
// The tests build ParsedPowerShellCommand objects manually via makeParsed(),
|
||||||
|
// so the real parser implementations are not needed for these specific tests.
|
||||||
|
const MOCK_COMMON_ALIASES: Record<string, string> = {
|
||||||
|
iex: "Invoke-Expression",
|
||||||
|
ii: "Invoke-Item",
|
||||||
|
sal: "Set-Alias",
|
||||||
|
ipmo: "Import-Module",
|
||||||
|
iwmi: "Invoke-WmiMethod",
|
||||||
|
saps: "Start-Process",
|
||||||
|
start: "Start-Process",
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("src/utils/powershell/parser.js", () => ({
|
||||||
|
COMMON_ALIASES: MOCK_COMMON_ALIASES,
|
||||||
|
commandHasArgAbbreviation: (cmd: any, fullParam: string, minPrefix: string) => {
|
||||||
|
const fullLower = fullParam.toLowerCase()
|
||||||
|
const prefixLower = minPrefix.toLowerCase()
|
||||||
|
return cmd.args.some((a: string) => {
|
||||||
|
const lower = a.toLowerCase()
|
||||||
|
const colonIdx = lower.indexOf(':')
|
||||||
|
const paramPart = colonIdx > 0 ? lower.slice(0, colonIdx) : lower
|
||||||
|
return paramPart.startsWith(prefixLower) && fullLower.startsWith(paramPart)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deriveSecurityFlags: () => ({ hasRedirectToVariable: false, hasPipelineVariable: false, hasFormatHex: false, hasScriptBlocks: false, hasSubExpressions: false, hasExpandableStrings: false, hasSplatting: false, hasStopParsing: false, hasMemberInvocations: false, hasAssignments: false }),
|
||||||
|
getAllCommands: (parsed: any) => parsed.statements.flatMap((s: any) => s.commands || []),
|
||||||
|
getVariablesByScope: () => [],
|
||||||
|
hasCommandNamed: (parsed: any, name: string) => {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
const canonicalFromAlias = MOCK_COMMON_ALIASES[lower]?.toLowerCase()
|
||||||
|
return parsed.statements.some((s: any) => (s.commands || []).some((c: any) => {
|
||||||
|
const cmdLower = c.name.toLowerCase()
|
||||||
|
if (cmdLower === lower) return true
|
||||||
|
const canonical = MOCK_COMMON_ALIASES[cmdLower]?.toLowerCase()
|
||||||
|
if (canonical === lower) return true
|
||||||
|
if (canonicalFromAlias && cmdLower === canonicalFromAlias) return true
|
||||||
|
return false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
parsePowerShellCommandCached: () => ({ valid: false, errors: [], statements: [], variables: [], hasStopParsing: false, originalCommand: "" }),
|
||||||
|
PARSE_SCRIPT_BODY: "",
|
||||||
|
WINDOWS_MAX_COMMAND_LENGTH: 32000,
|
||||||
|
MAX_COMMAND_LENGTH: 32000,
|
||||||
|
PS_TOKENIZER_DASH_CHARS: new Set(['-', '\u2013', '\u2014', '\u2015']),
|
||||||
|
mapStatementType: (t: string) => t,
|
||||||
|
mapElementType: (t: string) => t,
|
||||||
|
classifyCommandName: () => ({ type: 'external', name: '' }),
|
||||||
|
stripModulePrefix: (n: string) => n,
|
||||||
|
}));
|
||||||
|
|
||||||
// Real parser functions work without mocks since they're pure
|
// Real parser functions work without mocks since they're pure
|
||||||
const { powershellCommandIsSafe } = await import("../powershellSecurity.js");
|
const { powershellCommandIsSafe } = await import("../powershellSecurity.js");
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { z } from 'zod/v4'
|
|||||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||||
import { buildTool } from 'src/Tool.js'
|
import { buildTool } from 'src/Tool.js'
|
||||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||||
|
import { notifyAutomationStateChanged } from 'src/utils/sessionState.js'
|
||||||
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
|
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
|
||||||
|
|
||||||
|
const SLEEP_WAKE_CHECK_INTERVAL_MS = 500
|
||||||
|
|
||||||
const inputSchema = lazySchema(() =>
|
const inputSchema = lazySchema(() =>
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
duration_seconds: z
|
duration_seconds: z
|
||||||
@@ -19,6 +22,36 @@ type SleepInput = z.infer<InputSchema>
|
|||||||
|
|
||||||
type SleepOutput = { slept_seconds: number; interrupted: boolean }
|
type SleepOutput = { slept_seconds: number; interrupted: boolean }
|
||||||
|
|
||||||
|
function isProactiveAutomationEnabled(): boolean {
|
||||||
|
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod =
|
||||||
|
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||||
|
return mod.isProactiveActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProactiveSleepAllowed(): boolean {
|
||||||
|
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod =
|
||||||
|
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||||
|
return mod.isProactiveActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasQueuedWakeSignal(): boolean {
|
||||||
|
const queue =
|
||||||
|
require('src/utils/messageQueueManager.js') as typeof import('src/utils/messageQueueManager.js')
|
||||||
|
return queue.hasCommandsInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldInterruptSleep(): boolean {
|
||||||
|
return !isProactiveSleepAllowed() || hasQueuedWakeSignal()
|
||||||
|
}
|
||||||
|
|
||||||
export const SleepTool = buildTool({
|
export const SleepTool = buildTool({
|
||||||
name: SLEEP_TOOL_NAME,
|
name: SLEEP_TOOL_NAME,
|
||||||
searchHint: 'wait pause sleep rest idle duration timer',
|
searchHint: 'wait pause sleep rest idle duration timer',
|
||||||
@@ -42,6 +75,9 @@ export const SleepTool = buildTool({
|
|||||||
isReadOnly() {
|
isReadOnly() {
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
interruptBehavior() {
|
||||||
|
return 'cancel'
|
||||||
|
},
|
||||||
|
|
||||||
userFacingName() {
|
userFacingName() {
|
||||||
return SLEEP_TOOL_NAME
|
return SLEEP_TOOL_NAME
|
||||||
@@ -67,53 +103,84 @@ export const SleepTool = buildTool({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async call(input: SleepInput, context) {
|
async call(input: SleepInput, context) {
|
||||||
// Refuse to sleep when proactive mode is off — prevents the model from
|
// Don't enter sleep if proactive was disabled or new work arrived while
|
||||||
// re-issuing Sleep after an interruption caused by /proactive disable.
|
// the model was deciding to wait.
|
||||||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
if (shouldInterruptSleep()) {
|
||||||
const mod =
|
return {
|
||||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
data: {
|
||||||
if (!mod.isProactiveActive()) {
|
slept_seconds: 0,
|
||||||
return {
|
interrupted: true,
|
||||||
data: {
|
},
|
||||||
slept_seconds: 0,
|
|
||||||
interrupted: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { duration_seconds } = input
|
const { duration_seconds } = input
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
const sleepUntil = startTime + duration_seconds * 1000
|
||||||
|
|
||||||
|
if (isProactiveAutomationEnabled()) {
|
||||||
|
notifyAutomationStateChanged({
|
||||||
|
enabled: true,
|
||||||
|
phase: 'sleeping',
|
||||||
|
next_tick_at: null,
|
||||||
|
sleep_until: sleepUntil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timer = setTimeout(resolve, duration_seconds * 1000)
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let wakeCheck: ReturnType<typeof setInterval> | null = null
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
if (wakeCheck !== null) {
|
||||||
|
clearInterval(wakeCheck)
|
||||||
|
wakeCheck = null
|
||||||
|
}
|
||||||
|
context.abortController.signal.removeEventListener('abort', onAbort)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
cleanup()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const interrupt = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
cleanup()
|
||||||
|
reject(new Error('interrupted'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
interrupt()
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(finish, duration_seconds * 1000)
|
||||||
|
|
||||||
// Abort via user interrupt
|
// Abort via user interrupt
|
||||||
context.abortController.signal.addEventListener(
|
if (context.abortController.signal.aborted) {
|
||||||
'abort',
|
interrupt()
|
||||||
() => {
|
return
|
||||||
clearTimeout(timer)
|
}
|
||||||
clearInterval(proactiveCheck)
|
context.abortController.signal.addEventListener('abort', onAbort, {
|
||||||
reject(new Error('interrupted'))
|
once: true,
|
||||||
},
|
})
|
||||||
{ once: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Poll proactive state — if deactivated mid-sleep, interrupt early
|
// Poll proactive state and the shared command queue so new work can
|
||||||
// so the user doesn't have to wait for the full duration.
|
// wake Sleep without waiting for the full duration.
|
||||||
const proactiveCheck =
|
wakeCheck = setInterval(() => {
|
||||||
feature('PROACTIVE') || feature('KAIROS')
|
if (shouldInterruptSleep()) {
|
||||||
? setInterval(() => {
|
interrupt()
|
||||||
const mod =
|
}
|
||||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
}, SLEEP_WAKE_CHECK_INTERVAL_MS)
|
||||||
if (!mod.isProactiveActive()) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
clearInterval(proactiveCheck)
|
|
||||||
reject(new Error('interrupted'))
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
: (null as unknown as ReturnType<typeof setInterval>)
|
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -129,6 +196,17 @@ export const SleepTool = buildTool({
|
|||||||
interrupted: true,
|
interrupted: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
notifyAutomationStateChanged(
|
||||||
|
isProactiveAutomationEnabled()
|
||||||
|
? {
|
||||||
|
enabled: true,
|
||||||
|
phase: null,
|
||||||
|
next_tick_at: null,
|
||||||
|
sleep_until: null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { SleepTool } from '../SleepTool'
|
||||||
|
import {
|
||||||
|
enqueue,
|
||||||
|
getCommandQueue,
|
||||||
|
resetCommandQueue,
|
||||||
|
} from 'src/utils/messageQueueManager.js'
|
||||||
|
|
||||||
|
describe('SleepTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCommandQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declares cancel interrupt behavior', () => {
|
||||||
|
expect(SleepTool.interruptBehavior()).toBe('cancel')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wakes early when queued work arrives', async () => {
|
||||||
|
const sleepPromise = SleepTool.call(
|
||||||
|
{ duration_seconds: 10 },
|
||||||
|
{ abortController: new AbortController() } as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
enqueue({
|
||||||
|
value: 'wake up',
|
||||||
|
mode: 'prompt',
|
||||||
|
})
|
||||||
|
}, 20)
|
||||||
|
|
||||||
|
const result = await sleepPromise
|
||||||
|
|
||||||
|
expect(result.data.interrupted).toBe(true)
|
||||||
|
expect(result.data.slept_seconds).toBeLessThan(10)
|
||||||
|
expect(getCommandQueue()).toHaveLength(1)
|
||||||
|
expect(getCommandQueue()[0]).toMatchObject({
|
||||||
|
value: 'wake up',
|
||||||
|
mode: 'prompt',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,8 @@ let isFirstPartyBaseUrl = true
|
|||||||
// Only mock the external dependency that controls adapter selection
|
// Only mock the external dependency that controls adapter selection
|
||||||
mock.module('src/utils/model/providers.js', () => ({
|
mock.module('src/utils/model/providers.js', () => ({
|
||||||
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
|
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
|
||||||
|
getAPIProvider: () => 'firstParty',
|
||||||
|
getAPIProviderForStatsig: () => 'firstParty',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { createAdapter } = await import('../adapters/index')
|
const { createAdapter } = await import('../adapters/index')
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test'
|
import { describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
const _abortMock = () => ({
|
||||||
|
AbortError: class AbortError extends Error {
|
||||||
|
constructor(message?: string) { super(message); this.name = 'AbortError' }
|
||||||
|
},
|
||||||
|
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
|
||||||
|
})
|
||||||
|
mock.module('src/utils/errors.js', _abortMock)
|
||||||
|
mock.module('src/utils/errors', _abortMock)
|
||||||
|
|
||||||
import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter'
|
import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
// Defensive mock: agent.test.ts mocks config.js which can corrupt Bun's
|
||||||
|
// src/* path alias resolution. Provide AbortError directly so the dynamic
|
||||||
|
// import in createAdapter() never needs to resolve the alias at runtime.
|
||||||
|
const _abortMock = () => ({
|
||||||
|
AbortError: class AbortError extends Error {
|
||||||
|
constructor(message?: string) { super(message); this.name = 'AbortError' }
|
||||||
|
},
|
||||||
|
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
|
||||||
|
})
|
||||||
|
mock.module('src/utils/errors.js', _abortMock)
|
||||||
|
mock.module('src/utils/errors', _abortMock)
|
||||||
|
|
||||||
const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
|
const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
|
||||||
const originalBraveApiKey = process.env.BRAVE_API_KEY
|
const originalBraveApiKey = process.env.BRAVE_API_KEY
|
||||||
|
|
||||||
|
|||||||
5
packages/builtin-tools/tsconfig.json
Normal file
5
packages/builtin-tools/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -72,18 +72,18 @@ describe("detectColorMode", () => {
|
|||||||
|
|
||||||
describe("detectLanguage", () => {
|
describe("detectLanguage", () => {
|
||||||
test("detects language from file extension", () => {
|
test("detects language from file extension", () => {
|
||||||
expect(detectLanguage("index.ts")).toBe("ts");
|
expect(detectLanguage("index.ts", null)).toBe("ts");
|
||||||
expect(detectLanguage("main.py")).toBe("py");
|
expect(detectLanguage("main.py", null)).toBe("py");
|
||||||
expect(detectLanguage("style.css")).toBe("css");
|
expect(detectLanguage("style.css", null)).toBe("css");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("detects language from known filenames", () => {
|
test("detects language from known filenames", () => {
|
||||||
expect(detectLanguage("Makefile")).toBe("makefile");
|
expect(detectLanguage("Makefile", null)).toBe("makefile");
|
||||||
expect(detectLanguage("Dockerfile")).toBe("dockerfile");
|
expect(detectLanguage("Dockerfile", null)).toBe("dockerfile");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for unknown extensions", () => {
|
test("returns null for unknown extensions", () => {
|
||||||
expect(detectLanguage("file.xyz123")).toBeNull();
|
expect(detectLanguage("file.xyz123", null)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
5
packages/color-diff-napi/tsconfig.json
Normal file
5
packages/color-diff-napi/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
5
packages/image-processor-napi/tsconfig.json
Normal file
5
packages/image-processor-napi/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ describe('InProcessTransport', () => {
|
|||||||
let received: JSONRPCMessage | null = null
|
let received: JSONRPCMessage | null = null
|
||||||
client.onmessage = (msg) => { received = msg }
|
client.onmessage = (msg) => { received = msg }
|
||||||
|
|
||||||
await server.send({ jsonrpc: '2.0', result: 42, id: 1 })
|
await server.send({ jsonrpc: '2.0', result: 42, id: 1 } as any)
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10))
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ describe('discoverTools', () => {
|
|||||||
expect(tool.name).toBe('mcp__my-server__search')
|
expect(tool.name).toBe('mcp__my-server__search')
|
||||||
expect(tool.mcpInfo).toEqual({ serverName: 'my-server', toolName: 'search' })
|
expect(tool.mcpInfo).toEqual({ serverName: 'my-server', toolName: 'search' })
|
||||||
expect(tool.isMcp).toBe(true)
|
expect(tool.isMcp).toBe(true)
|
||||||
expect(tool.isReadOnly()).toBe(true)
|
expect(tool.isReadOnly({} as any)).toBe(true)
|
||||||
expect(tool.userFacingName()).toBe('Search Items')
|
expect(tool.userFacingName(undefined)).toBe('Search Items')
|
||||||
expect(await tool.description()).toBe('Search for items')
|
expect(await tool.description({} as any, { isNonInteractiveSession: false, toolPermissionContext: {}, tools: [] })).toBe('Search for items')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('respects skipPrefix option', async () => {
|
test('respects skipPrefix option', async () => {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe('createMcpManager', () => {
|
|||||||
|
|
||||||
const result = await manager.connect('test-server', { command: 'npx', args: [] })
|
const result = await manager.connect('test-server', { command: 'npx', args: [] })
|
||||||
expect(result.type).toBe('connected')
|
expect(result.type).toBe('connected')
|
||||||
expect(connectedEvent).toBe('test-server')
|
expect(connectedEvent as unknown as string).toBe('test-server')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('disconnect calls cleanup and emits disconnected', async () => {
|
test('disconnect calls cleanup and emits disconnected', async () => {
|
||||||
|
|||||||
5
packages/mcp-client/tsconfig.json
Normal file
5
packages/mcp-client/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
5
packages/modifiers-napi/tsconfig.json
Normal file
5
packages/modifiers-napi/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user