mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c299f5d2 | ||
|
|
ac42ce2d67 | ||
|
|
c659912517 | ||
|
|
a14b7f352b | ||
|
|
c5ab83a3fc | ||
|
|
03b7f9b453 |
7
.github/workflows/ci.yml
vendored
7
.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
|
||||||
@@ -24,11 +24,14 @@ jobs:
|
|||||||
run: bunx tsc --noEmit
|
run: bunx tsc --noEmit
|
||||||
|
|
||||||
- name: Test with Coverage
|
- name: Test with Coverage
|
||||||
run: bun test --coverage --coverage-reporter=lcov
|
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
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
file: ./coverage/lcov.info
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -247,14 +247,23 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- **框架**: `bun:test`(内置断言 + mock)
|
- **框架**: `bun:test`(内置断言 + mock)
|
||||||
- **当前状态**: 2472 tests / 138 files / 0 fail
|
- **当前状态**: 2992 tests / 188 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 必须零错误**。每次修改后运行:
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -10,28 +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) |
|
||||||
| ACP 协议一等一支持 | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||||
| Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||||
| Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||||
| 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
| **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 | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/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-调试)
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -6,7 +6,7 @@
|
|||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.7",
|
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
|
|
||||||
"@claude-code-best/builtin-tools": ["@claude-code-best/builtin-tools@workspace:packages/builtin-tools"],
|
"@claude-code-best/builtin-tools": ["@claude-code-best/builtin-tools@workspace:packages/builtin-tools"],
|
||||||
|
|
||||||
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@2.0.7", "https://registry.npmmirror.com/@claude-code-best/mcp-chrome-bridge/-/mcp-chrome-bridge-2.0.7.tgz", { "dependencies": { "@fastify/cors": "^11.0.1", "@modelcontextprotocol/sdk": "^1.11.0", "chalk": "^5.4.1", "chrome-mcp-shared": "^1.0.2", "commander": "^13.1.0", "fastify": "^5.3.2", "is-admin": "^4.0.0", "pino": "^9.6.0", "uuid": "^11.1.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-gb64+Ga6li3A8Ll9NKV+ePBn5/U0fccCdrH43tGYveLKZIZxURz8cbY+Z3BdbTdYSPVdFXtfUlp3TMxu4OT5gg=="],
|
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@2.0.8", "https://registry.npmmirror.com/@claude-code-best/mcp-chrome-bridge/-/mcp-chrome-bridge-2.0.8.tgz", { "dependencies": { "@fastify/cors": "^11.0.1", "@modelcontextprotocol/sdk": "^1.11.0", "chalk": "^5.4.1", "chrome-mcp-shared": "^1.0.2", "commander": "^13.1.0", "fastify": "^5.3.2", "is-admin": "^4.0.0", "pino": "^9.6.0", "uuid": "^11.1.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-f7J1e4PQ6qxXzdHwL7QRrMZ4lPfD/L1MWxWDbyHmHY7jaW2GL6WcArKpk/fApg3V/q0racqUWzXHQdpE/HJZqg=="],
|
||||||
|
|
||||||
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
|
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"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>",
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"rcs": "bun run scripts/rcs.ts"
|
"rcs": "bun run scripts/rcs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.7",
|
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ function renderSystemMessage(text) {
|
|||||||
const LOADING_ID = "loading-indicator";
|
const LOADING_ID = "loading-indicator";
|
||||||
|
|
||||||
// TUI star spinner frames (same as Claude Code CLI)
|
// TUI star spinner frames (same as Claude Code CLI)
|
||||||
const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽"];
|
const SPINNER_FRAMES = ["·", "✢", "✱", "✶", "✻", "✽"];
|
||||||
const SPINNER_CYCLE = [...SPINNER_FRAMES, ...SPINNER_FRAMES.slice().reverse()];
|
const SPINNER_CYCLE = [...SPINNER_FRAMES, ...SPINNER_FRAMES.slice().reverse()];
|
||||||
|
|
||||||
// 204 verbs from TUI src/constants/spinnerVerbs.ts
|
// 204 verbs from TUI src/constants/spinnerVerbs.ts
|
||||||
|
|||||||
10
scripts/run-parallel.mjs
Normal file
10
scripts/run-parallel.mjs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { spawn } from "node:child_process"
|
||||||
|
|
||||||
|
const scripts = process.argv.slice(2)
|
||||||
|
if (scripts.length === 0) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const script of scripts) {
|
||||||
|
spawn(process.execPath, [script], { stdio: "inherit", shell: false })
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ import type { RGBColor as RGBColorType } from './types.js'
|
|||||||
|
|
||||||
export function getDefaultCharacters(): string[] {
|
export function getDefaultCharacters(): string[] {
|
||||||
if (process.env.TERM === 'xterm-ghostty') {
|
if (process.env.TERM === 'xterm-ghostty') {
|
||||||
return ['·', '✢', '✳', '✶', '✻', '*'] // Use * instead of ✽ for Ghostty because the latter renders in a way that's slightly offset
|
return ['·', '✢', '✱', '✶', '✻', '*'] // ✱ replaces ✳ (emoji, renders offset in Ghostty); * replaces ✽ (same)
|
||||||
}
|
}
|
||||||
|
// ✳ (U+2733) is matched by emoji-regex in Node.js → stringWidth returns 2 instead of 1,
|
||||||
|
// causing layout jitter when the spinner cycles frames. ✱ (U+2731) is visually similar but not emoji.
|
||||||
return process.platform === 'darwin'
|
return process.platform === 'darwin'
|
||||||
? ['·', '✢', '✳', '✶', '✻', '✽']
|
? ['·', '✢', '✱', '✶', '✻', '✽']
|
||||||
: ['·', '✢', '*', '✶', '✻', '✽']
|
: ['·', '✢', '✱', '✶', '✻', '✽']
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interpolate between two RGB colors
|
// Interpolate between two RGB colors
|
||||||
|
|||||||
@@ -26,14 +26,7 @@ mock.module('../../../Tool.js', () => ({
|
|||||||
buildTool: mock((def: any) => def),
|
buildTool: mock((def: any) => def),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../../../utils/config.js', () => ({
|
mock.module('src/utils/config.ts', () => ({
|
||||||
enableConfigs: mock(() => {}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Also mock via src/ alias to prevent alias resolution corruption for other test files.
|
|
||||||
// See: agent.test.ts's relative-path mock for config.js breaks Bun's src/* path
|
|
||||||
// alias for subsequent test files (Cannot find module 'src/utils/errors.js' etc.)
|
|
||||||
mock.module('src/utils/config.js', () => ({
|
|
||||||
enableConfigs: mock(() => {}),
|
enableConfigs: mock(() => {}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,702 +0,0 @@
|
|||||||
import { mock, describe, test, expect, beforeEach } from 'bun:test'
|
|
||||||
|
|
||||||
// Mock @langfuse/otel before any imports
|
|
||||||
const mockForceFlush = mock(() => Promise.resolve())
|
|
||||||
const mockShutdown = mock(() => Promise.resolve())
|
|
||||||
|
|
||||||
mock.module('@langfuse/otel', () => ({
|
|
||||||
LangfuseSpanProcessor: class MockLangfuseSpanProcessor {
|
|
||||||
forceFlush = mockForceFlush
|
|
||||||
shutdown = mockShutdown
|
|
||||||
onStart = mock(() => {})
|
|
||||||
onEnd = mock(() => {})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock @opentelemetry/sdk-trace-base
|
|
||||||
mock.module('@opentelemetry/sdk-trace-base', () => ({
|
|
||||||
BasicTracerProvider: class MockBasicTracerProvider {
|
|
||||||
constructor(_opts?: unknown) {}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock @langfuse/tracing
|
|
||||||
const mockChildUpdate = mock(() => {})
|
|
||||||
const mockChildEnd = mock(() => {})
|
|
||||||
const mockRootUpdate = mock(() => {})
|
|
||||||
const mockRootEnd = mock(() => {})
|
|
||||||
|
|
||||||
// Mock LangfuseOtelSpanAttributes (re-exported from @langfuse/core)
|
|
||||||
const mockLangfuseOtelSpanAttributes: Record<string, string> = {
|
|
||||||
TRACE_SESSION_ID: 'session.id',
|
|
||||||
TRACE_USER_ID: 'user.id',
|
|
||||||
OBSERVATION_TYPE: 'observation.type',
|
|
||||||
OBSERVATION_INPUT: 'observation.input',
|
|
||||||
OBSERVATION_OUTPUT: 'observation.output',
|
|
||||||
OBSERVATION_MODEL: 'observation.model',
|
|
||||||
OBSERVATION_COMPLETION_START_TIME: 'observation.completionStartTime',
|
|
||||||
OBSERVATION_USAGE_DETAILS: 'observation.usageDetails',
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockSpanContext = {
|
|
||||||
traceId: 'test-trace-id',
|
|
||||||
spanId: 'test-span-id',
|
|
||||||
traceFlags: 1,
|
|
||||||
}
|
|
||||||
const mockSetAttribute = mock(() => {})
|
|
||||||
|
|
||||||
// Child observation mock (returned by startObservation for tools/generations)
|
|
||||||
const mockStartObservation = mock(() => ({
|
|
||||||
id: 'test-span-id',
|
|
||||||
traceId: 'test-trace-id',
|
|
||||||
type: 'span',
|
|
||||||
otelSpan: {
|
|
||||||
spanContext: () => mockSpanContext,
|
|
||||||
setAttribute: mockSetAttribute,
|
|
||||||
},
|
|
||||||
update: mockRootUpdate,
|
|
||||||
end: mockRootEnd,
|
|
||||||
}))
|
|
||||||
const mockSetLangfuseTracerProvider = mock(() => {})
|
|
||||||
|
|
||||||
mock.module('@langfuse/tracing', () => ({
|
|
||||||
startObservation: mockStartObservation,
|
|
||||||
LangfuseOtelSpanAttributes: mockLangfuseOtelSpanAttributes,
|
|
||||||
propagateAttributes: mock((_params: unknown, fn?: () => void) => fn?.()),
|
|
||||||
setLangfuseTracerProvider: mockSetLangfuseTracerProvider,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock debug logger
|
|
||||||
mock.module('src/utils/debug.js', () => ({
|
|
||||||
logForDebugging: mock(() => {}),
|
|
||||||
logAntError: mock(() => {}),
|
|
||||||
isDebugToStdErr: () => false,
|
|
||||||
isDebugMode: () => false,
|
|
||||||
getDebugLogPath: () => '/tmp/debug.log',
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock user module to avoid heavy dependency chain (execa, config, cwd, env, etc.)
|
|
||||||
mock.module('src/utils/user.js', () => ({
|
|
||||||
getCoreUserData: () => ({
|
|
||||||
email: 'test@example.com',
|
|
||||||
deviceId: 'test-device',
|
|
||||||
}),
|
|
||||||
getUserDataForLogging: () => ({}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('Langfuse integration', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset env
|
|
||||||
process.env.HOME = '/Users/testuser'
|
|
||||||
delete process.env.LANGFUSE_PUBLIC_KEY
|
|
||||||
delete process.env.LANGFUSE_SECRET_KEY
|
|
||||||
delete process.env.LANGFUSE_BASE_URL
|
|
||||||
delete process.env.LANGFUSE_USER_ID
|
|
||||||
mockStartObservation.mockClear()
|
|
||||||
mockRootUpdate.mockClear()
|
|
||||||
mockRootEnd.mockClear()
|
|
||||||
mockForceFlush.mockClear()
|
|
||||||
mockShutdown.mockClear()
|
|
||||||
mockSetAttribute.mockClear()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── sanitize tests ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('sanitizeToolInput', () => {
|
|
||||||
test('replaces home dir in file_path', async () => {
|
|
||||||
const { sanitizeToolInput } = await import('../sanitize.js')
|
|
||||||
const home = process.env.HOME ?? '/Users/testuser'
|
|
||||||
const result = sanitizeToolInput('FileReadTool', {
|
|
||||||
file_path: `${home}/project/file.ts`,
|
|
||||||
}) as Record<string, string>
|
|
||||||
expect(result.file_path).toBe('~/project/file.ts')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('redacts sensitive keys', async () => {
|
|
||||||
const { sanitizeToolInput } = await import('../sanitize.js')
|
|
||||||
const result = sanitizeToolInput('MCPTool', {
|
|
||||||
api_key: 'secret123',
|
|
||||||
token: 'abc',
|
|
||||||
}) as Record<string, string>
|
|
||||||
expect(result.api_key).toBe('[REDACTED]')
|
|
||||||
expect(result.token).toBe('[REDACTED]')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns non-object input unchanged', async () => {
|
|
||||||
const { sanitizeToolInput } = await import('../sanitize.js')
|
|
||||||
expect(sanitizeToolInput('BashTool', 'raw string')).toBe('raw string')
|
|
||||||
expect(sanitizeToolInput('BashTool', null)).toBe(null)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('sanitizeToolOutput', () => {
|
|
||||||
test('redacts FileReadTool output', async () => {
|
|
||||||
const { sanitizeToolOutput } = await import('../sanitize.js')
|
|
||||||
const result = sanitizeToolOutput('FileReadTool', 'file content here')
|
|
||||||
expect(result).toBe('[file content redacted, 17 chars]')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('redacts FileWriteTool output', async () => {
|
|
||||||
const { sanitizeToolOutput } = await import('../sanitize.js')
|
|
||||||
const result = sanitizeToolOutput('FileWriteTool', 'written content')
|
|
||||||
expect(result).toBe('[file content redacted, 15 chars]')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('truncates BashTool output over 500 chars', async () => {
|
|
||||||
const { sanitizeToolOutput } = await import('../sanitize.js')
|
|
||||||
const longOutput = 'x'.repeat(600)
|
|
||||||
const result = sanitizeToolOutput('BashTool', longOutput)
|
|
||||||
expect(result).toContain('[truncated]')
|
|
||||||
expect(result.length).toBeLessThan(600)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('does not truncate BashTool output under 500 chars', async () => {
|
|
||||||
const { sanitizeToolOutput } = await import('../sanitize.js')
|
|
||||||
const shortOutput = 'hello world'
|
|
||||||
expect(sanitizeToolOutput('BashTool', shortOutput)).toBe('hello world')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('redacts ConfigTool output', async () => {
|
|
||||||
const { sanitizeToolOutput } = await import('../sanitize.js')
|
|
||||||
const result = sanitizeToolOutput('ConfigTool', 'config data')
|
|
||||||
expect(result).toBe('[ConfigTool output redacted, 11 chars]')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('redacts MCPTool output', async () => {
|
|
||||||
const { sanitizeToolOutput } = await import('../sanitize.js')
|
|
||||||
const result = sanitizeToolOutput('MCPTool', 'mcp data')
|
|
||||||
expect(result).toBe('[MCPTool output redacted, 8 chars]')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('sanitizeGlobal', () => {
|
|
||||||
test('replaces home dir in strings', async () => {
|
|
||||||
const { sanitizeGlobal } = await import('../sanitize.js')
|
|
||||||
const home = process.env.HOME ?? '/Users/testuser'
|
|
||||||
expect(sanitizeGlobal(`path: ${home}/file`)).toBe('path: ~/file')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('recursively sanitizes nested objects', async () => {
|
|
||||||
const { sanitizeGlobal } = await import('../sanitize.js')
|
|
||||||
const result = sanitizeGlobal({
|
|
||||||
nested: { api_key: 'secret', name: 'test' },
|
|
||||||
}) as Record<string, Record<string, string>>
|
|
||||||
expect(result.nested.api_key).toBe('[REDACTED]')
|
|
||||||
expect(result.nested.name).toBe('test')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns non-string/object values unchanged', async () => {
|
|
||||||
const { sanitizeGlobal } = await import('../sanitize.js')
|
|
||||||
expect(sanitizeGlobal(42)).toBe(42)
|
|
||||||
expect(sanitizeGlobal(true)).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── client tests ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('isLangfuseEnabled', () => {
|
|
||||||
test('returns false when keys not configured', async () => {
|
|
||||||
const { isLangfuseEnabled } = await import('../client.js')
|
|
||||||
expect(isLangfuseEnabled()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns true when both keys are set', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { isLangfuseEnabled } = await import('../client.js')
|
|
||||||
expect(isLangfuseEnabled()).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('initLangfuse', () => {
|
|
||||||
test('returns false when keys not configured', async () => {
|
|
||||||
const { initLangfuse } = await import('../client.js')
|
|
||||||
expect(initLangfuse()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns true when keys are configured', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { isLangfuseEnabled } = await import('../client.js')
|
|
||||||
expect(isLangfuseEnabled()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('is idempotent — multiple calls do not re-initialize', async () => {
|
|
||||||
const { initLangfuse } = await import('../client.js')
|
|
||||||
expect(() => {
|
|
||||||
initLangfuse()
|
|
||||||
initLangfuse()
|
|
||||||
}).not.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('shutdownLangfuse', () => {
|
|
||||||
test('calls forceFlush and shutdown on processor', async () => {
|
|
||||||
const { shutdownLangfuse } = await import('../client.js')
|
|
||||||
await expect(shutdownLangfuse()).resolves.toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── tracing tests ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('createTrace', () => {
|
|
||||||
test('returns null when langfuse not enabled', async () => {
|
|
||||||
const { createTrace } = await import('../tracing.js')
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
expect(span).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('creates root span when enabled', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace } = await import('../tracing.js')
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
input: [],
|
|
||||||
})
|
|
||||||
expect(span).not.toBeNull()
|
|
||||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
|
||||||
'agent-run',
|
|
||||||
expect.objectContaining({
|
|
||||||
metadata: expect.objectContaining({
|
|
||||||
provider: 'firstParty',
|
|
||||||
model: 'claude-3',
|
|
||||||
agentType: 'main',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
{ asType: 'agent' },
|
|
||||||
)
|
|
||||||
// Should set session.id attribute
|
|
||||||
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('recordLLMObservation', () => {
|
|
||||||
test('no-ops when rootSpan is null', async () => {
|
|
||||||
const { recordLLMObservation } = await import('../tracing.js')
|
|
||||||
recordLLMObservation(null, {
|
|
||||||
model: 'm',
|
|
||||||
provider: 'firstParty',
|
|
||||||
input: [],
|
|
||||||
output: [],
|
|
||||||
usage: { input_tokens: 10, output_tokens: 5 },
|
|
||||||
})
|
|
||||||
expect(mockStartObservation).toHaveBeenCalledTimes(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('records generation child observation via global startObservation', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, recordLLMObservation } = await import(
|
|
||||||
'../tracing.js'
|
|
||||||
)
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
mockStartObservation.mockClear()
|
|
||||||
mockRootUpdate.mockClear()
|
|
||||||
mockRootEnd.mockClear()
|
|
||||||
recordLLMObservation(span, {
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
input: [{ role: 'user', content: 'hello' }],
|
|
||||||
output: [{ role: 'assistant', content: 'hi' }],
|
|
||||||
usage: { input_tokens: 10, output_tokens: 5 },
|
|
||||||
})
|
|
||||||
// Should call the global startObservation with asType: 'generation' and parentSpanContext
|
|
||||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
|
||||||
'ChatAnthropic',
|
|
||||||
expect.objectContaining({
|
|
||||||
model: 'claude-3',
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
asType: 'generation',
|
|
||||||
parentSpanContext: mockSpanContext,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
usageDetails: { input: 10, output: 5 },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(mockRootEnd).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('recordToolObservation', () => {
|
|
||||||
test('no-ops when rootSpan is null', async () => {
|
|
||||||
const { recordToolObservation } = await import('../tracing.js')
|
|
||||||
recordToolObservation(null, {
|
|
||||||
toolName: 'BashTool',
|
|
||||||
toolUseId: 'id1',
|
|
||||||
input: {},
|
|
||||||
output: 'out',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('records tool child observation via global startObservation', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, recordToolObservation } = await import(
|
|
||||||
'../tracing.js'
|
|
||||||
)
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
mockStartObservation.mockClear()
|
|
||||||
mockRootUpdate.mockClear()
|
|
||||||
mockRootEnd.mockClear()
|
|
||||||
recordToolObservation(span, {
|
|
||||||
toolName: 'BashTool',
|
|
||||||
toolUseId: 'tu-1',
|
|
||||||
input: { command: 'ls' },
|
|
||||||
output: 'file.ts',
|
|
||||||
})
|
|
||||||
// Should call the global startObservation with asType: 'tool' and parentSpanContext
|
|
||||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
|
||||||
'BashTool',
|
|
||||||
expect.objectContaining({
|
|
||||||
input: expect.any(Object),
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
asType: 'tool',
|
|
||||||
parentSpanContext: mockSpanContext,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(mockRootUpdate).toHaveBeenCalled()
|
|
||||||
expect(mockRootEnd).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('passes startTime to global startObservation', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, recordToolObservation } = await import(
|
|
||||||
'../tracing.js'
|
|
||||||
)
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
mockStartObservation.mockClear()
|
|
||||||
const startTime = new Date('2026-01-01T00:00:00Z')
|
|
||||||
recordToolObservation(span, {
|
|
||||||
toolName: 'BashTool',
|
|
||||||
toolUseId: 'tu-2',
|
|
||||||
input: {},
|
|
||||||
output: 'out',
|
|
||||||
startTime,
|
|
||||||
})
|
|
||||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
|
||||||
'BashTool',
|
|
||||||
expect.any(Object),
|
|
||||||
expect.objectContaining({
|
|
||||||
startTime,
|
|
||||||
parentSpanContext: mockSpanContext,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('sanitizes FileReadTool output', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, recordToolObservation } = await import(
|
|
||||||
'../tracing.js'
|
|
||||||
)
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
mockRootUpdate.mockClear()
|
|
||||||
recordToolObservation(span, {
|
|
||||||
toolName: 'FileReadTool',
|
|
||||||
toolUseId: 'tu-2',
|
|
||||||
input: { file_path: '/tmp/file.ts' },
|
|
||||||
output: 'file content here',
|
|
||||||
})
|
|
||||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
output: '[file content redacted, 17 chars]',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('sets ERROR level for error observations', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, recordToolObservation } = await import(
|
|
||||||
'../tracing.js'
|
|
||||||
)
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
mockRootUpdate.mockClear()
|
|
||||||
recordToolObservation(span, {
|
|
||||||
toolName: 'BashTool',
|
|
||||||
toolUseId: 'tu-3',
|
|
||||||
input: {},
|
|
||||||
output: 'error occurred',
|
|
||||||
isError: true,
|
|
||||||
})
|
|
||||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ level: 'ERROR' }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('endTrace', () => {
|
|
||||||
test('no-ops when rootSpan is null', async () => {
|
|
||||||
const { endTrace } = await import('../tracing.js')
|
|
||||||
endTrace(null)
|
|
||||||
expect(mockRootEnd).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('calls span.end()', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, endTrace } = await import('../tracing.js')
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
mockRootEnd.mockClear()
|
|
||||||
endTrace(span)
|
|
||||||
expect(mockRootEnd).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('calls span.update() with output when provided', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, endTrace } = await import('../tracing.js')
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
mockRootUpdate.mockClear()
|
|
||||||
mockRootEnd.mockClear()
|
|
||||||
endTrace(span, 'final output')
|
|
||||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ output: 'final output' }),
|
|
||||||
)
|
|
||||||
expect(mockRootEnd).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('createSubagentTrace', () => {
|
|
||||||
test('returns null when langfuse not enabled', async () => {
|
|
||||||
const { createSubagentTrace } = await import('../tracing.js')
|
|
||||||
const span = createSubagentTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
agentType: 'Explore',
|
|
||||||
agentId: 'agent-1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
expect(span).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('creates trace with agentType and agentId metadata', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createSubagentTrace } = await import('../tracing.js')
|
|
||||||
const span = createSubagentTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
agentType: 'Explore',
|
|
||||||
agentId: 'agent-1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
input: [{ role: 'user', content: 'search for X' }],
|
|
||||||
})
|
|
||||||
expect(span).not.toBeNull()
|
|
||||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
|
||||||
'agent:Explore',
|
|
||||||
expect.objectContaining({
|
|
||||||
metadata: expect.objectContaining({
|
|
||||||
agentType: 'Explore',
|
|
||||||
agentId: 'agent-1',
|
|
||||||
provider: 'firstParty',
|
|
||||||
model: 'claude-3',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
{ asType: 'agent' },
|
|
||||||
)
|
|
||||||
// Verify session.id attribute is set
|
|
||||||
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns null on SDK error', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
mockStartObservation.mockImplementationOnce(() => {
|
|
||||||
throw new Error('SDK error')
|
|
||||||
})
|
|
||||||
const { createSubagentTrace } = await import('../tracing.js')
|
|
||||||
const span = createSubagentTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
agentType: 'Plan',
|
|
||||||
agentId: 'agent-2',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
expect(span).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('createTrace with querySource', () => {
|
|
||||||
test('includes querySource in metadata', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace } = await import('../tracing.js')
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
querySource: 'user',
|
|
||||||
})
|
|
||||||
expect(span).not.toBeNull()
|
|
||||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
|
||||||
'agent-run:user',
|
|
||||||
expect.objectContaining({
|
|
||||||
metadata: expect.objectContaining({
|
|
||||||
agentType: 'main',
|
|
||||||
querySource: 'user',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
{ asType: 'agent' },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('omits querySource when not provided', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
mockStartObservation.mockClear()
|
|
||||||
const { createTrace } = await import('../tracing.js')
|
|
||||||
createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
const calls = mockStartObservation.mock.calls as unknown[][]
|
|
||||||
const secondArg = calls[0]?.[1] as Record<string, unknown> | undefined
|
|
||||||
const metadata = (secondArg?.metadata ?? {}) as Record<string, unknown>
|
|
||||||
expect(metadata).not.toHaveProperty('querySource')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('nested agent scenario', () => {
|
|
||||||
test('sub-agent trace shares sessionId with parent', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, createSubagentTrace } = await import('../tracing.js')
|
|
||||||
mockSetAttribute.mockClear()
|
|
||||||
|
|
||||||
// Create parent trace
|
|
||||||
const parentSpan = createTrace({
|
|
||||||
sessionId: 'shared-session',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create sub-agent trace with same sessionId
|
|
||||||
const subSpan = createSubagentTrace({
|
|
||||||
sessionId: 'shared-session',
|
|
||||||
agentType: 'Explore',
|
|
||||||
agentId: 'agent-explore-1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(parentSpan).not.toBeNull()
|
|
||||||
expect(subSpan).not.toBeNull()
|
|
||||||
|
|
||||||
// Both should have set session.id attribute
|
|
||||||
const sessionAttributeCalls = mockSetAttribute.mock.calls.filter(
|
|
||||||
(call: unknown[]) =>
|
|
||||||
Array.isArray(call) &&
|
|
||||||
call[0] === 'session.id' &&
|
|
||||||
call[1] === 'shared-session',
|
|
||||||
)
|
|
||||||
expect(sessionAttributeCalls.length).toBeGreaterThanOrEqual(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('query reuses passed langfuseTrace instead of creating new one', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createSubagentTrace } = await import('../tracing.js')
|
|
||||||
|
|
||||||
const subTrace = createSubagentTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
agentType: 'Explore',
|
|
||||||
agentId: 'agent-1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
expect(subTrace).not.toBeNull()
|
|
||||||
|
|
||||||
// Simulate query.ts logic: if langfuseTrace already set, don't create new one
|
|
||||||
const ownsTrace = false
|
|
||||||
const langfuseTrace = subTrace
|
|
||||||
|
|
||||||
expect(ownsTrace).toBe(false)
|
|
||||||
expect(langfuseTrace).toBe(subTrace)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SDK exceptions do not affect main flow', () => {
|
|
||||||
test('createTrace returns null on SDK error', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
mockStartObservation.mockImplementationOnce(() => {
|
|
||||||
throw new Error('SDK error')
|
|
||||||
})
|
|
||||||
const { createTrace } = await import('../tracing.js')
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
expect(span).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('recordLLMObservation silently fails on SDK error', async () => {
|
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
|
||||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
|
||||||
const { createTrace, recordLLMObservation } = await import(
|
|
||||||
'../tracing.js'
|
|
||||||
)
|
|
||||||
const span = createTrace({
|
|
||||||
sessionId: 's1',
|
|
||||||
model: 'claude-3',
|
|
||||||
provider: 'firstParty',
|
|
||||||
})
|
|
||||||
// The next call to startObservation (for the generation) will throw
|
|
||||||
mockStartObservation.mockImplementationOnce(() => {
|
|
||||||
throw new Error('SDK error')
|
|
||||||
})
|
|
||||||
expect(() =>
|
|
||||||
recordLLMObservation(span, {
|
|
||||||
model: 'm',
|
|
||||||
provider: 'firstParty',
|
|
||||||
input: [],
|
|
||||||
output: [],
|
|
||||||
usage: { input_tokens: 1, output_tokens: 1 },
|
|
||||||
}),
|
|
||||||
).not.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -71,7 +71,7 @@ mock.module('@langfuse/tracing', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock debug logger
|
// Mock debug logger
|
||||||
mock.module('src/utils/debug.js', () => ({
|
mock.module('src/utils/debug.ts', () => ({
|
||||||
logForDebugging: mock(() => {}),
|
logForDebugging: mock(() => {}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { mock, describe, expect, test } from "bun:test";
|
import { mock, describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
mock.module("src/utils/slowOperations.js", () => ({
|
|
||||||
jsonStringify: (v: unknown) => JSON.stringify(v),
|
|
||||||
}));
|
|
||||||
mock.module("src/services/analytics/growthbook.js", () => ({
|
mock.module("src/services/analytics/growthbook.js", () => ({
|
||||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import { mock, describe, expect, test, afterEach } from "bun:test";
|
|||||||
mock.module("axios", () => ({
|
mock.module("axios", () => ({
|
||||||
default: { get: async () => ({ data: { servers: [] } }) },
|
default: { get: async () => ({ data: { servers: [] } }) },
|
||||||
}));
|
}));
|
||||||
mock.module("src/utils/debug.js", () => ({
|
mock.module("src/utils/debug.ts", () => ({
|
||||||
logForDebugging: () => {},
|
logForDebugging: () => {},
|
||||||
}));
|
}));
|
||||||
mock.module("src/utils/errors.js", () => ({
|
|
||||||
errorMessage: (e: any) => String(e),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(
|
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(
|
||||||
"../officialRegistry"
|
"../officialRegistry"
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import { mock, describe, expect, test, beforeEach } from "bun:test";
|
import { mock, describe, expect, test, beforeEach } from "bun:test";
|
||||||
|
|
||||||
// Mock heavy deps before importing memoize
|
// Mock log.ts to cut the bootstrap/state dependency chain
|
||||||
mock.module("src/utils/log.ts", () => ({
|
mock.module("src/utils/log.ts", () => ({
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
logToFile: () => {},
|
logToFile: () => {},
|
||||||
getLogDisplayTitle: () => "",
|
getLogDisplayTitle: () => "",
|
||||||
logEvent: () => {},
|
logEvent: () => {},
|
||||||
}));
|
}));
|
||||||
mock.module("src/utils/slowOperations.ts", () => ({
|
|
||||||
jsonStringify: JSON.stringify,
|
|
||||||
jsonParse: JSON.parse,
|
|
||||||
slowLogging: { enabled: false },
|
|
||||||
clone: (v: any) => structuredClone(v),
|
|
||||||
cloneDeep: (v: any) => structuredClone(v),
|
|
||||||
callerFrame: () => "",
|
|
||||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
|
||||||
writeFileSync_DEPRECATED: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { memoizeWithTTL, memoizeWithTTLAsync, memoizeWithLRU } = await import(
|
const { memoizeWithTTL, memoizeWithTTLAsync, memoizeWithLRU } = await import(
|
||||||
"../memoize"
|
"../memoize"
|
||||||
|
|||||||
@@ -1,27 +1,4 @@
|
|||||||
import { mock, describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
// Mock ink/stringWidth to avoid heavy Ink import chain
|
|
||||||
mock.module("src/ink/stringWidth.js", () => ({
|
|
||||||
stringWidth: (str: string) => {
|
|
||||||
// Simplified width calculation for test purposes
|
|
||||||
let width = 0;
|
|
||||||
for (const char of str) {
|
|
||||||
const code = char.codePointAt(0)!;
|
|
||||||
// CJK Unified Ideographs and common full-width ranges
|
|
||||||
if (
|
|
||||||
(code >= 0x4e00 && code <= 0x9fff) || // CJK
|
|
||||||
(code >= 0x3000 && code <= 0x303f) || // CJK Symbols
|
|
||||||
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
|
|
||||||
(code >= 0xf900 && code <= 0xfaff) // CJK Compatibility
|
|
||||||
) {
|
|
||||||
width += 2;
|
|
||||||
} else if (code > 0) {
|
|
||||||
width += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sliceAnsi = (await import("../sliceAnsi")).default;
|
const sliceAnsi = (await import("../sliceAnsi")).default;
|
||||||
|
|
||||||
|
|||||||
@@ -30,18 +30,6 @@ mock.module("src/services/tokenEstimation.ts", () => ({
|
|||||||
countTokensViaHaikuFallback: async () => 0,
|
countTokensViaHaikuFallback: async () => 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock slowOperations to avoid bun:bundle import
|
|
||||||
mock.module("src/utils/slowOperations.ts", () => ({
|
|
||||||
jsonStringify: JSON.stringify,
|
|
||||||
jsonParse: JSON.parse,
|
|
||||||
slowLogging: { enabled: false },
|
|
||||||
clone: (v: any) => structuredClone(v),
|
|
||||||
cloneDeep: (v: any) => structuredClone(v),
|
|
||||||
callerFrame: () => "",
|
|
||||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
|
||||||
writeFileSync_DEPRECATED: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getTokenCountFromUsage,
|
getTokenCountFromUsage,
|
||||||
getTokenUsage,
|
getTokenUsage,
|
||||||
|
|||||||
@@ -1,16 +1,4 @@
|
|||||||
import { mock, describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
mock.module("figures", () => ({
|
|
||||||
default: {
|
|
||||||
lineUpDownRight: "├",
|
|
||||||
lineUpRight: "└",
|
|
||||||
lineVertical: "│",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module("src/ink.js", () => ({
|
|
||||||
color: (colorKey: string, themeName: string) => (text: string) => text,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { treeify } = await import("../treeify");
|
const { treeify } = await import("../treeify");
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
mock.module("src/ink/stringWidth.js", () => ({
|
|
||||||
stringWidth: (str: string) => {
|
|
||||||
let width = 0;
|
|
||||||
for (const char of str) {
|
|
||||||
const code = char.codePointAt(0)!;
|
|
||||||
if (
|
|
||||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
||||||
(code >= 0x3000 && code <= 0x303f) ||
|
|
||||||
(code >= 0xff01 && code <= 0xff60) ||
|
|
||||||
(code >= 0xf900 && code <= 0xfaff)
|
|
||||||
) {
|
|
||||||
width += 2;
|
|
||||||
} else if (code >= 0x1f300 && code <= 0x1faff) {
|
|
||||||
width += 2;
|
|
||||||
} else if (code > 0) {
|
|
||||||
width += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
import {
|
import {
|
||||||
truncatePathMiddle,
|
truncatePathMiddle,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
import { mock, describe, expect, test, beforeEach, afterEach } from "bun:test";
|
import { mock, describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||||
|
|
||||||
// Mock slowOperations to cut bootstrap/state dependency chain
|
|
||||||
// (figures.js → env.js → fsOperations.js → slowOperations.js → bootstrap/state.js)
|
|
||||||
mock.module("src/utils/slowOperations.ts", () => ({
|
|
||||||
jsonStringify: JSON.stringify,
|
|
||||||
jsonParse: JSON.parse,
|
|
||||||
slowLogging: { enabled: false },
|
|
||||||
clone: (v: any) => structuredClone(v),
|
|
||||||
cloneDeep: (v: any) => structuredClone(v),
|
|
||||||
callerFrame: () => "",
|
|
||||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
|
||||||
writeFileSync_DEPRECATED: () => {},
|
|
||||||
}));
|
|
||||||
mock.module("src/utils/log.ts", () => ({
|
mock.module("src/utils/log.ts", () => ({
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
logToFile: () => {},
|
logToFile: () => {},
|
||||||
|
|||||||
@@ -18,18 +18,6 @@ mock.module("src/utils/log.ts", () => ({
|
|||||||
_resetErrorLogForTesting: () => {},
|
_resetErrorLogForTesting: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock slowOperations to avoid bun:bundle
|
|
||||||
mock.module("src/utils/slowOperations.ts", () => ({
|
|
||||||
jsonStringify: JSON.stringify,
|
|
||||||
jsonParse: JSON.parse,
|
|
||||||
slowLogging: { enabled: false },
|
|
||||||
clone: (v: any) => structuredClone(v),
|
|
||||||
cloneDeep: (v: any) => structuredClone(v),
|
|
||||||
callerFrame: () => "",
|
|
||||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
|
||||||
writeFileSync_DEPRECATED: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getDenyRuleForTool,
|
getDenyRuleForTool,
|
||||||
getAskRuleForTool,
|
getAskRuleForTool,
|
||||||
|
|||||||
Reference in New Issue
Block a user