chatccc 0.2.218 → 0.2.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -16
- package/config.sample.json +4 -1
- package/package.json +2 -1
- package/src/__tests__/builtin-config.test.ts +3 -0
- package/src/__tests__/builtin-file-tools.test.ts +44 -24
- package/src/__tests__/cards.test.ts +6 -6
- package/src/__tests__/config-reload.test.ts +19 -8
- package/src/__tests__/config-sample.test.ts +6 -2
- package/src/__tests__/orchestrator.test.ts +84 -5
- package/src/__tests__/sim-platform.test.ts +10 -0
- package/src/__tests__/web-ui.test.ts +40 -0
- package/src/builtin/file-tools.ts +279 -41
- package/src/cards.ts +7 -5
- package/src/config.ts +41 -10
- package/src/index.ts +13 -0
- package/src/orchestrator.ts +1553 -1488
- package/src/web-ui.ts +164 -46
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ChatCCC
|
|
2
2
|
|
|
3
|
-
**用飞书或微信聊天控制 Claude Code / Cursor / Codex。**
|
|
3
|
+
**用飞书或微信聊天控制 Claude Code / Cursor / Codex / CCC Agent。**
|
|
4
4
|
|
|
5
|
-
ChatCCC 把本地 AI 编程工具接入即时通讯软件。你可以在手机上发消息,让 Claude Code、Cursor Agent
|
|
5
|
+
ChatCCC 把本地 AI 编程工具接入即时通讯软件。你可以在手机上发消息,让 Claude Code、Cursor Agent、Codex 或内置 CCC Agent 继续写代码、查问题、跑命令;不用一直守在电脑前。
|
|
6
6
|
|
|
7
7
|
飞书是推荐入口:直接私聊机器人即可持续使用同一个专属会话,需要并行任务时再用 `/new` 创建独立会话群;卡片能流式更新,体验完整。微信 iLink 更适合快速试用或临时使用:扫码即可接入,但只能走私聊文本模式。
|
|
8
8
|
|
|
@@ -21,7 +21,7 @@ ChatCCC 把本地 AI 编程工具接入即时通讯软件。你可以在手机
|
|
|
21
21
|
- **手机上也能用 AI 编程工具**:在飞书或微信发消息,就像在终端给 Agent 下指令。
|
|
22
22
|
- **飞书体验更完整**:私聊可持续对话,`/new` 创建的一群一会话支持多任务并行,CardKit 卡片可流式更新。
|
|
23
23
|
- **微信接入更轻**:不用创建飞书应用,启动后扫码即可在微信私聊里使用。
|
|
24
|
-
- **多 Agent 切换**:`/new` 使用默认 Agent,也可以用 `/new claude`、`/new cursor`、`/new codex` 指定工具。
|
|
24
|
+
- **多 Agent 切换**:`/new` 使用默认 Agent,也可以用 `/new claude`、`/new cursor`、`/new codex`、`/new ccc` 指定工具。
|
|
25
25
|
- **群里能跑 git**:`/git status`、`/git pull`、`/git log` 会在当前会话工作目录执行,并把输出发回聊天窗口。
|
|
26
26
|
|
|
27
27
|
## 飞书和微信的差异
|
|
@@ -208,7 +208,7 @@ chatccc
|
|
|
208
208
|
|
|
209
209
|
### 3. AI 工具配置
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
Claude Code、Cursor 和 Codex 需要对应的本地工具;CCC Agent 内置于 ChatCCC,只需配置 DeepSeek 兼容 API。
|
|
212
212
|
|
|
213
213
|
#### Claude Code
|
|
214
214
|
|
|
@@ -237,7 +237,13 @@ codex login
|
|
|
237
237
|
codex --version
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
-
Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理,也可以在 `config.json` 中覆盖。
|
|
240
|
+
Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理,也可以在 `config.json` 中覆盖。
|
|
241
|
+
|
|
242
|
+
#### CCC Agent
|
|
243
|
+
|
|
244
|
+
CCC Agent 是 ChatCCC 内置的编程 Agent,不需要额外安装 CLI。在首次配置向导或 Web 管理页中启用后,填写 DeepSeek 兼容 API Key、Base URL 和模型即可使用;它可以设为 `/new` 的默认 Agent,也可以通过 `/new ccc` 显式创建会话。
|
|
245
|
+
|
|
246
|
+
`ccc.alternativeModel` 是单个备选模型,只会加入 `/model` 的人工切换列表,不会在请求失败时自动重试或切换,避免重复执行带副作用的工具调用。
|
|
241
247
|
|
|
242
248
|
#### 可选:Chrome CDP
|
|
243
249
|
|
|
@@ -285,22 +291,32 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
285
291
|
"baseUrl": "",
|
|
286
292
|
"maxTurn": 0
|
|
287
293
|
},
|
|
288
|
-
"cursor": {
|
|
294
|
+
"cursor": {
|
|
289
295
|
"enabled": false,
|
|
290
296
|
"defaultAgent": false,
|
|
291
|
-
"path": "",
|
|
292
|
-
"model": "",
|
|
293
|
-
"
|
|
297
|
+
"path": "",
|
|
298
|
+
"model": "",
|
|
299
|
+
"alternativeModel": "",
|
|
300
|
+
"avatarBatteryMode": "apiPercent",
|
|
294
301
|
"onDemandMonthlyBudget": 1000
|
|
295
302
|
},
|
|
296
|
-
"codex": {
|
|
303
|
+
"codex": {
|
|
297
304
|
"enabled": false,
|
|
298
305
|
"defaultAgent": false,
|
|
299
306
|
"path": "",
|
|
300
307
|
"model": "",
|
|
308
|
+
"alternativeModel": "",
|
|
301
309
|
"effort": "",
|
|
302
310
|
"fastMode": false
|
|
303
|
-
}
|
|
311
|
+
},
|
|
312
|
+
"ccc": {
|
|
313
|
+
"enabled": false,
|
|
314
|
+
"defaultAgent": false,
|
|
315
|
+
"DEEPSEEK_API_KEY": "",
|
|
316
|
+
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
|
317
|
+
"model": "deepseek-v4-pro",
|
|
318
|
+
"alternativeModel": ""
|
|
319
|
+
}
|
|
304
320
|
}
|
|
305
321
|
```
|
|
306
322
|
|
|
@@ -326,18 +342,23 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
326
342
|
| `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
|
|
327
343
|
| `claude.model` / `claude.subagentModel` / `claude.effort` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
|
|
328
344
|
| `claude.apiKey` / `claude.baseUrl` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
|
|
329
|
-
| `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
|
|
345
|
+
| `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
|
|
346
|
+
| `cursor.alternativeModel` / `codex.alternativeModel` / `ccc.alternativeModel` | 单个备选模型;加入 `/model` 人工切换列表,不会自动故障转移 |
|
|
347
|
+
| `ccc.DEEPSEEK_API_KEY` / `ccc.DEEPSEEK_BASE_URL` | CCC Agent 使用的 DeepSeek 兼容 API 凭证和服务地址 |
|
|
348
|
+
| `ccc.model` | CCC Agent 默认模型 |
|
|
330
349
|
|
|
331
350
|
> **权限控制**:普通消息以 `bypassPermissions` 模式运行,跳过 Agent 操作确认。使用 `/plan` 或 `/ask` 前缀时,ChatCCC 自动切换为只读模式:Claude SDK 仅放行 Read + stop-stuck-loop 网络请求,Codex 使用 `--sandbox read-only`,Cursor 使用 `--mode plan/ask`。请只在可信环境中使用。
|
|
332
351
|
|
|
333
352
|
### 5. 开始使用
|
|
334
353
|
|
|
335
|
-
**飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor` 或 `/new
|
|
354
|
+
**飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor`、`/new codex` 或 `/new ccc`,机器人会另外创建会话群。
|
|
336
355
|
|
|
337
356
|
**微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
|
|
338
357
|
|
|
339
358
|
**会话停滞保护:** 当 Agent 连续 3 分钟停在“正在启动 Agent”且没有任何事件,或停在“正在生成回复”且回复字符数没有变化,同时尚未报告权威终态时,ChatCCC 会结束旧 CLI,并优先补发一次“完成了吗?如果没完成继续”;恢复轮再次发生相同停滞时不再递归续跑。`/new claude` 和 `/new cursor` 在等待底层 init 事件时也使用 3 分钟超时并主动清理 SDK/CLI。思考、搜索和工具调用阶段不按回复字符数误判,由进程资源监控负责识别真正僵死。Codex 只有 `turn.completed` 才算权威终态,阶段性的 `agent_message` 不算;任一 Agent 报告权威终态后若输出流仍超过 10 秒未关闭,ChatCCC 会强制清理该 CLI 并按正常完成收尾,不会重复询问 Agent。
|
|
340
359
|
|
|
360
|
+
**CCC Agent 代码搜索:** `search_code` 使用项目自带的跨平台 ripgrep,不要求系统另行安装 `rg`。如果当前平台没有可用的 bundled/system ripgrep,会自动降级为内置 Node 搜索,并继续支持常用正则、glob、结果上限、中止和超时控制。
|
|
361
|
+
|
|
341
362
|
## 可用指令
|
|
342
363
|
|
|
343
364
|
| 指令 | 作用 |
|
|
@@ -346,6 +367,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
346
367
|
| `/new claude` | 创建 Claude Code 会话;飞书中会创建新群 |
|
|
347
368
|
| `/new cursor` | 创建 Cursor 会话;飞书中会创建新群 |
|
|
348
369
|
| `/new codex` | 创建 Codex 会话;飞书中会创建新群 |
|
|
370
|
+
| `/new ccc` | 创建内置 CCC Agent 会话;飞书中会创建新群 |
|
|
349
371
|
| `/newh` | 在当前聊天原地重置会话;群聊保留工作目录,飞书私聊固定使用系统用户目录 |
|
|
350
372
|
| `/model` | 查看或切换当前会话的模型 |
|
|
351
373
|
| `/fast` | 查看当前 Codex 会话的 Fast 模式;使用 `/fast on` 或 `/fast off` 切换 |
|
|
@@ -355,7 +377,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
355
377
|
| `/cd` | 查看或设置后续新建会话的默认工作目录,不改变当前会话;飞书私聊自身始终使用系统用户目录 |
|
|
356
378
|
| `/sessions` | 查看所有会话状态 |
|
|
357
379
|
| `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话;飞书私聊不支持切换 |
|
|
358
|
-
| `/usage` | 查看当前会话对应 Agent 的用量;Codex
|
|
380
|
+
| `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7天窗口,Cursor 显示当前周期用量,使用官方 DeepSeek API 的 CCC Agent 显示账户余额 |
|
|
359
381
|
| `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
|
|
360
382
|
| `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
|
|
361
383
|
| `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
|
|
@@ -366,7 +388,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
366
388
|
|
|
367
389
|
`/update` 会在执行 npm 更新前把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新 `/update` 因事件 ID 不同,仍可立即执行。该保护仅作用于 `/update`,普通消息与 `/restart` 的处理不变。
|
|
368
390
|
|
|
369
|
-
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel
|
|
391
|
+
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex 和 CCC Agent 使用各自的 `model` / `alternativeModel`。
|
|
370
392
|
|
|
371
393
|
> **Codex Fast 模式**:Web UI 中的“Fast 模式”设置新 Codex 会话的全局默认值,默认关闭。进入 Codex 会话后,`/fast` 查询当前状态,`/fast on` 和 `/fast off` 只覆盖当前会话并从下一条消息生效。ChatCCC 会显式向 Codex CLI 传入 `service_tier="fast"` 或 `service_tier="default"`,因此关闭时不会继承用户 `config.toml` 中可能开启的 Fast。
|
|
372
394
|
|
|
@@ -374,4 +396,4 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
374
396
|
|
|
375
397
|
## 技术栈
|
|
376
398
|
|
|
377
|
-
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI / 飞书 WebSocket API / CardKit / 微信 iLink
|
|
399
|
+
TypeScript / Node.js >= 20 / tsx / AI SDK / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI / 飞书 WebSocket API / CardKit / 微信 iLink
|
package/config.sample.json
CHANGED
|
@@ -73,8 +73,11 @@
|
|
|
73
73
|
"fastMode": false
|
|
74
74
|
},
|
|
75
75
|
"ccc": {
|
|
76
|
+
"enabled": false,
|
|
77
|
+
"defaultAgent": false,
|
|
76
78
|
"DEEPSEEK_API_KEY": "",
|
|
77
79
|
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
|
78
|
-
"model": "deepseek-v4-pro"
|
|
80
|
+
"model": "deepseek-v4-pro",
|
|
81
|
+
"alternativeModel": ""
|
|
79
82
|
}
|
|
80
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.220",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"@anthropic-ai/claude-agent-sdk": "0.2.133",
|
|
52
52
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
53
53
|
"@openilink/openilink-sdk-node": "^0.6.0",
|
|
54
|
+
"@vscode/ripgrep": "^1.18.0",
|
|
54
55
|
"ai": "^6.0.184",
|
|
55
56
|
"nodemailer": "^8.0.7",
|
|
56
57
|
"qrcode-terminal": "^0.12.0",
|
|
@@ -18,9 +18,12 @@ afterEach(() => {
|
|
|
18
18
|
describe("builtin ChatSession config", () => {
|
|
19
19
|
it("does not fall back to DEEPSEEK_API_KEY environment variable", () => {
|
|
20
20
|
config.ccc = {
|
|
21
|
+
enabled: false,
|
|
22
|
+
defaultAgent: false,
|
|
21
23
|
DEEPSEEK_API_KEY: "",
|
|
22
24
|
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
23
25
|
model: "deepseek-v4-pro",
|
|
26
|
+
alternativeModel: "",
|
|
24
27
|
};
|
|
25
28
|
process.env.DEEPSEEK_API_KEY = "sk-env-should-not-be-used";
|
|
26
29
|
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
5
|
|
|
8
6
|
import { afterEach, describe, expect, it } from "vitest";
|
|
9
7
|
|
|
@@ -19,7 +17,6 @@ import {
|
|
|
19
17
|
searchCodeForTool,
|
|
20
18
|
} from "../builtin/file-tools.ts";
|
|
21
19
|
|
|
22
|
-
const execFileAsync = promisify(execFile);
|
|
23
20
|
const tempDirs: string[] = [];
|
|
24
21
|
|
|
25
22
|
async function makeTempDir(): Promise<string> {
|
|
@@ -28,15 +25,6 @@ async function makeTempDir(): Promise<string> {
|
|
|
28
25
|
return dir;
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
async function hasRg(): Promise<boolean> {
|
|
32
|
-
try {
|
|
33
|
-
await execFileAsync("rg", ["--version"]);
|
|
34
|
-
return true;
|
|
35
|
-
} catch {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
28
|
function sha256(text: string): string {
|
|
41
29
|
return createHash("sha256").update(text).digest("hex");
|
|
42
30
|
}
|
|
@@ -75,20 +63,52 @@ describe("builtin file tools", () => {
|
|
|
75
63
|
}));
|
|
76
64
|
});
|
|
77
65
|
|
|
78
|
-
it("searches
|
|
79
|
-
if (!await hasRg()) return;
|
|
80
|
-
|
|
66
|
+
it("searches with the project-bundled ripgrep even when rg is absent from PATH", async () => {
|
|
81
67
|
const dir = await makeTempDir();
|
|
82
68
|
await writeFile(join(dir, "a.ts"), "const marker = 1;\n", "utf8");
|
|
69
|
+
const originalPath = process.env.PATH;
|
|
70
|
+
process.env.PATH = "";
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const result = await searchCodeForTool(dir, { query: "marker", glob: "*.ts" });
|
|
74
|
+
|
|
75
|
+
expect(result.matches).toEqual([
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
line: 1,
|
|
78
|
+
column: 7,
|
|
79
|
+
text: "const marker = 1;",
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
} finally {
|
|
83
|
+
process.env.PATH = originalPath;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
83
86
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
it("falls back to Node search when no ripgrep executable can be used", async () => {
|
|
88
|
+
const dir = await makeTempDir();
|
|
89
|
+
await writeFile(join(dir, "a.ts"), "const marker = 1;\n", "utf8");
|
|
90
|
+
await mkdir(join(dir, "nested"));
|
|
91
|
+
await writeFile(join(dir, "nested", "b.md"), "xx marker = 2\n", "utf8");
|
|
92
|
+
await writeFile(join(dir, "ignored.txt"), "marker = 3\n", "utf8");
|
|
93
|
+
const originalPath = process.env.PATH;
|
|
94
|
+
process.env.PATH = "";
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const result = await searchCodeForTool(
|
|
98
|
+
dir,
|
|
99
|
+
{ query: "marker\\s*=\\s*\\d", glob: "**/*.{ts,md}", maxResults: 10 },
|
|
100
|
+
undefined,
|
|
101
|
+
{ ripgrepCommands: [join(dir, "missing-rg")] },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(result.matches).toEqual([
|
|
105
|
+
expect.objectContaining({ path: join(dir, "a.ts"), line: 1, column: 7 }),
|
|
106
|
+
expect.objectContaining({ path: join(dir, "nested", "b.md"), line: 1, column: 4 }),
|
|
107
|
+
]);
|
|
108
|
+
expect(result.truncated).toBe(false);
|
|
109
|
+
} finally {
|
|
110
|
+
process.env.PATH = originalPath;
|
|
111
|
+
}
|
|
92
112
|
});
|
|
93
113
|
|
|
94
114
|
it("runs non-interactive shell commands in the requested cwd", async () => {
|
|
@@ -161,7 +161,7 @@ describe("buildHelpCard", () => {
|
|
|
161
161
|
const parsed = JSON.parse(card);
|
|
162
162
|
const action = parsed.elements[2];
|
|
163
163
|
expect(action.tag).toBe("action");
|
|
164
|
-
expect(action.actions).toHaveLength(
|
|
164
|
+
expect(action.actions).toHaveLength(8);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it("adds ABD prefix help as the final help line", () => {
|
|
@@ -169,17 +169,17 @@ describe("buildHelpCard", () => {
|
|
|
169
169
|
const parsed = JSON.parse(card);
|
|
170
170
|
const lines = parsed.elements[1].text.content.split("\n");
|
|
171
171
|
|
|
172
|
-
expect(lines).toContain("发送 **/usage**
|
|
172
|
+
expect(lines).toContain("发送 **/usage** 查看当前 Agent 的用量或余额");
|
|
173
173
|
expect(lines.at(-1)).toBe(ABD_HELP_LINE);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
-
it("
|
|
176
|
+
it("advertises CCC Agent as a normal session option", () => {
|
|
177
177
|
const card = buildHelpCard("test");
|
|
178
178
|
const parsed = JSON.parse(card);
|
|
179
179
|
const text = JSON.stringify(parsed);
|
|
180
180
|
|
|
181
|
-
expect(text).
|
|
182
|
-
expect(text).
|
|
181
|
+
expect(text).toContain("/new ccc");
|
|
182
|
+
expect(text).toContain("CCC Agent");
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
@@ -419,7 +419,7 @@ describe("buildSessionsCard", () => {
|
|
|
419
419
|
expect(content).not.toContain("Cursor 会话");
|
|
420
420
|
});
|
|
421
421
|
|
|
422
|
-
it("labels
|
|
422
|
+
it("labels CCC sessions without grouping them as Claude Code", () => {
|
|
423
423
|
const card = buildSessionsCard([
|
|
424
424
|
{ sessionId: "session-20260702-121530-a1b2c3", chatName: "", chatId: "oc_ccc", active: false, turnCount: 1, elapsedSeconds: null, model: "deepseek-v4-pro", tool: "ccc" },
|
|
425
425
|
]);
|
|
@@ -72,7 +72,7 @@ const baseAppConfig: AppConfig = {
|
|
|
72
72
|
onDemandMonthlyBudget: 1000,
|
|
73
73
|
},
|
|
74
74
|
codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", alternativeModel: "initial-codex-alt-model", effort: "initial-codex-effort", fastMode: false },
|
|
75
|
-
ccc: { DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model" },
|
|
75
|
+
ccc: { enabled: true, defaultAgent: false, DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model", alternativeModel: "initial-ccc-alt-model" },
|
|
76
76
|
};
|
|
77
77
|
|
|
78
78
|
// 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
|
|
@@ -177,15 +177,18 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
177
177
|
expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
it("/model 候选列表包含 Cursor/Codex 的单个备选模型并保持主模型优先", () => {
|
|
180
|
+
it("/model 候选列表包含 Cursor/Codex/CCC 的单个备选模型并保持主模型优先", () => {
|
|
181
181
|
const cfg = structuredClone(baseAppConfig);
|
|
182
182
|
cfg.cursor.model = "cursor-main";
|
|
183
183
|
cfg.cursor.alternativeModel = "cursor-alt";
|
|
184
184
|
cfg.codex.model = "codex-main";
|
|
185
|
-
cfg.codex.alternativeModel = "codex-main";
|
|
185
|
+
cfg.codex.alternativeModel = "codex-main";
|
|
186
|
+
cfg.ccc.model = "ccc-main";
|
|
187
|
+
cfg.ccc.alternativeModel = "ccc-alt";
|
|
186
188
|
|
|
187
189
|
expect(getAllModelsForTool("cursor", cfg)).toEqual(["cursor-main", "cursor-alt"]);
|
|
188
|
-
expect(getAllModelsForTool("codex", cfg)).toEqual(["codex-main"]);
|
|
190
|
+
expect(getAllModelsForTool("codex", cfg)).toEqual(["codex-main"]);
|
|
191
|
+
expect(getAllModelsForTool("ccc", cfg)).toEqual(["ccc-main", "ccc-alt"]);
|
|
189
192
|
});
|
|
190
193
|
|
|
191
194
|
it("不修改 CHATCCC_PORT(端口在 setup 切换时必须保持不变)", () => {
|
|
@@ -262,12 +265,20 @@ describe("resolveDefaultAgentTool", () => {
|
|
|
262
265
|
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
263
266
|
});
|
|
264
267
|
|
|
265
|
-
it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
|
|
268
|
+
it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
|
|
266
269
|
const cfg = structuredClone(baseAppConfig);
|
|
267
270
|
cfg.claude.enabled = false;
|
|
268
271
|
cfg.claude.defaultAgent = true;
|
|
269
272
|
cfg.cursor.defaultAgent = false;
|
|
270
273
|
|
|
271
|
-
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
+
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("支持把已启用的 CCC Agent 设为默认 Agent", () => {
|
|
278
|
+
const cfg = structuredClone(baseAppConfig);
|
|
279
|
+
for (const tool of ["claude", "cursor", "codex"] as const) cfg[tool].defaultAgent = false;
|
|
280
|
+
cfg.ccc.defaultAgent = true;
|
|
281
|
+
|
|
282
|
+
expect(resolveDefaultAgentTool(cfg)).toBe("ccc");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -33,19 +33,23 @@ describe("config.sample.json", () => {
|
|
|
33
33
|
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
34
34
|
cursor?: { alternativeModel?: unknown };
|
|
35
35
|
codex?: { alternativeModel?: unknown; fastMode?: unknown };
|
|
36
|
+
ccc?: { alternativeModel?: unknown };
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
expect(sample.cursor?.alternativeModel).toBe("");
|
|
39
40
|
expect(sample.codex?.alternativeModel).toBe("");
|
|
41
|
+
expect(sample.ccc?.alternativeModel).toBe("");
|
|
40
42
|
expect(sample.codex?.fastMode).toBe(false);
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
it("sets ccc agent DeepSeek defaults in the sample config", () => {
|
|
44
46
|
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
45
47
|
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
46
|
-
ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
|
|
48
|
+
ccc?: { enabled?: unknown; defaultAgent?: unknown; DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
expect(sample.ccc?.enabled).toBe(false);
|
|
52
|
+
expect(sample.ccc?.defaultAgent).toBe(false);
|
|
49
53
|
expect(sample.ccc?.DEEPSEEK_API_KEY).toBe("");
|
|
50
54
|
expect(sample.ccc?.DEEPSEEK_BASE_URL).toBe("https://api.deepseek.com/v1");
|
|
51
55
|
expect(sample.ccc?.model).toBe("deepseek-v4-pro");
|
|
@@ -89,4 +93,4 @@ describe("config.sample.json", () => {
|
|
|
89
93
|
expect(sample.rawStreamLogs?.[tool]?.keepCompleted).toBe(false);
|
|
90
94
|
}
|
|
91
95
|
});
|
|
92
|
-
});
|
|
96
|
+
});
|
|
@@ -148,6 +148,12 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
148
148
|
config.cursor.defaultAgent = false;
|
|
149
149
|
config.codex.defaultAgent = false;
|
|
150
150
|
config.codex.fastMode = false;
|
|
151
|
+
config.ccc.enabled = true;
|
|
152
|
+
config.ccc.defaultAgent = false;
|
|
153
|
+
config.ccc.DEEPSEEK_API_KEY = "sk-test-ccc";
|
|
154
|
+
config.ccc.DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1";
|
|
155
|
+
config.ccc.model = "deepseek-v4-pro";
|
|
156
|
+
config.ccc.alternativeModel = "deepseek-v4-flash";
|
|
151
157
|
mockStreamStates.clear();
|
|
152
158
|
mockGetCodexUsageSummary.mockReset();
|
|
153
159
|
mockGetCursorUsageSummary.mockReset();
|
|
@@ -204,7 +210,8 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
204
210
|
_clearAdapterCacheForTest();
|
|
205
211
|
_resetSessionRegistryFileForTest();
|
|
206
212
|
_resetSessionToolsFileForTest();
|
|
207
|
-
vi.useRealTimers();
|
|
213
|
+
vi.useRealTimers();
|
|
214
|
+
vi.unstubAllGlobals();
|
|
208
215
|
await rm(tempDir, { recursive: true, force: true });
|
|
209
216
|
});
|
|
210
217
|
|
|
@@ -1013,7 +1020,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
1013
1020
|
expect(card.elements[0].text.content).toContain("ChatGPT browser session has no access token.");
|
|
1014
1021
|
});
|
|
1015
1022
|
|
|
1016
|
-
it("handles /usage as Cursor usage in Cursor chats", async () => {
|
|
1023
|
+
it("handles /usage as Cursor usage in Cursor chats", async () => {
|
|
1017
1024
|
const platform = mockPlatform("feishu");
|
|
1018
1025
|
await recordSessionRegistry({
|
|
1019
1026
|
chatId: "cursor-chat",
|
|
@@ -1113,6 +1120,64 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
1113
1120
|
expect(claudeReadyCall?.[2]).not.toContain("/usage");
|
|
1114
1121
|
});
|
|
1115
1122
|
|
|
1123
|
+
it("handles /usage as DeepSeek balance in CCC Agent chats", async () => {
|
|
1124
|
+
const platform = mockPlatform("feishu");
|
|
1125
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
|
1126
|
+
is_available: true,
|
|
1127
|
+
balance_infos: [{
|
|
1128
|
+
currency: "CNY",
|
|
1129
|
+
total_balance: "12.34",
|
|
1130
|
+
topped_up_balance: "10.00",
|
|
1131
|
+
granted_balance: "2.34",
|
|
1132
|
+
}],
|
|
1133
|
+
}), { status: 200 }));
|
|
1134
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1135
|
+
await recordSessionRegistry({
|
|
1136
|
+
chatId: "ccc-chat",
|
|
1137
|
+
sessionId: "sid-ccc",
|
|
1138
|
+
tool: "ccc",
|
|
1139
|
+
chatName: "ccc-session",
|
|
1140
|
+
running: false,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
await handleCommand(platform, "/usage", "ccc-chat", "ou-user", Date.now(), "group");
|
|
1144
|
+
|
|
1145
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
1146
|
+
"https://api.deepseek.com/user/balance",
|
|
1147
|
+
expect.objectContaining({ headers: { Authorization: "Bearer sk-test-ccc" } }),
|
|
1148
|
+
);
|
|
1149
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
1150
|
+
"ccc-chat",
|
|
1151
|
+
"CCC Usage",
|
|
1152
|
+
expect.stringContaining("总余额: 12.34"),
|
|
1153
|
+
"blue",
|
|
1154
|
+
);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it("does not query balance for a non-official CCC API endpoint", async () => {
|
|
1158
|
+
const platform = mockPlatform("feishu");
|
|
1159
|
+
const fetchMock = vi.fn();
|
|
1160
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1161
|
+
config.ccc.DEEPSEEK_BASE_URL = "https://deepseek-proxy.example.com/v1";
|
|
1162
|
+
await recordSessionRegistry({
|
|
1163
|
+
chatId: "ccc-proxy-chat",
|
|
1164
|
+
sessionId: "sid-ccc-proxy",
|
|
1165
|
+
tool: "ccc",
|
|
1166
|
+
chatName: "ccc-proxy-session",
|
|
1167
|
+
running: false,
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
await handleCommand(platform, "/usage", "ccc-proxy-chat", "ou-user", Date.now(), "group");
|
|
1171
|
+
|
|
1172
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1173
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
1174
|
+
"ccc-proxy-chat",
|
|
1175
|
+
"CCC Usage",
|
|
1176
|
+
expect.stringContaining("仅支持官方 DeepSeek API"),
|
|
1177
|
+
"blue",
|
|
1178
|
+
);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1116
1181
|
it("reloads runtime config for the exact /reload command", async () => {
|
|
1117
1182
|
const platform = mockPlatform("feishu");
|
|
1118
1183
|
|
|
@@ -1129,7 +1194,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
1129
1194
|
);
|
|
1130
1195
|
});
|
|
1131
1196
|
|
|
1132
|
-
it("allows the
|
|
1197
|
+
it("allows and advertises the /new ccc entry", async () => {
|
|
1133
1198
|
const platform = mockPlatform("feishu");
|
|
1134
1199
|
_setAdapterForToolForTest("ccc", mockAdapter("session-20260702-121530-a1b2c3"));
|
|
1135
1200
|
|
|
@@ -1150,7 +1215,21 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
1150
1215
|
const helpPlatform = mockPlatform("feishu");
|
|
1151
1216
|
await handleCommand(helpPlatform, "hello", "feishu-group-help", "ou-user", Date.now(), "group");
|
|
1152
1217
|
const helpCard = vi.mocked(helpPlatform.sendRawCard).mock.calls.at(-1)?.[1] as string;
|
|
1153
|
-
expect(helpCard).
|
|
1154
|
-
expect(helpCard).
|
|
1218
|
+
expect(helpCard).toContain("/new ccc");
|
|
1219
|
+
expect(helpCard).toContain("CCC Agent");
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
it("shows the CCC model list when CCC Agent is the unbound default", async () => {
|
|
1223
|
+
const platform = mockPlatform("feishu");
|
|
1224
|
+
config.claude.defaultAgent = false;
|
|
1225
|
+
config.cursor.defaultAgent = false;
|
|
1226
|
+
config.codex.defaultAgent = false;
|
|
1227
|
+
config.ccc.defaultAgent = true;
|
|
1228
|
+
|
|
1229
|
+
await handleCommand(platform, "/model", "feishu-p2p-ccc-model", "ou-user", Date.now(), "p2p");
|
|
1230
|
+
|
|
1231
|
+
const card = vi.mocked(platform.sendRawCard).mock.calls.at(-1)?.[1] as string;
|
|
1232
|
+
expect(card).toContain("deepseek-v4-pro");
|
|
1233
|
+
expect(card).toContain("deepseek-v4-flash");
|
|
1155
1234
|
});
|
|
1156
1235
|
});
|
|
@@ -80,4 +80,14 @@ describe("SimulatedPlatform", () => {
|
|
|
80
80
|
// 近期消息不触发
|
|
81
81
|
expect(SimulatedPlatform.formatDelayNotice(Date.now(), "test")).toBeNull();
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
it("formatDelayNotice 边界:恰好 15 分钟触发,未来时间戳不触发", () => {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
// 恰好等于阈值(15 分钟)应触发提醒
|
|
87
|
+
expect(SimulatedPlatform.formatDelayNotice(now - 15 * 60 * 1000, "边界", now)).not.toBeNull();
|
|
88
|
+
// 14 分 59 秒不触发
|
|
89
|
+
expect(SimulatedPlatform.formatDelayNotice(now - (15 * 60 * 1000 - 1000), "边界", now)).toBeNull();
|
|
90
|
+
// 未来时间戳(时钟偏移)不触发
|
|
91
|
+
expect(SimulatedPlatform.formatDelayNotice(now + 60 * 1000, "边界", now)).toBeNull();
|
|
92
|
+
});
|
|
83
93
|
});
|
|
@@ -86,6 +86,28 @@ describe("unflattenConfig", () => {
|
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
it("maps CCC Agent settings into ccc config", () => {
|
|
90
|
+
expect(
|
|
91
|
+
unflattenConfig({
|
|
92
|
+
CHATCCC_CCC_ENABLED: true,
|
|
93
|
+
CHATCCC_CCC_DEFAULT_AGENT: true,
|
|
94
|
+
CHATCCC_CCC_API_KEY: "sk-test-ccc",
|
|
95
|
+
CHATCCC_CCC_BASE_URL: "https://api.deepseek.com/v1",
|
|
96
|
+
CHATCCC_CCC_MODEL: "deepseek-v4-flash",
|
|
97
|
+
CHATCCC_CCC_ALTERNATIVE_MODEL: "deepseek-v4-pro",
|
|
98
|
+
}),
|
|
99
|
+
).toEqual({
|
|
100
|
+
ccc: {
|
|
101
|
+
enabled: true,
|
|
102
|
+
defaultAgent: true,
|
|
103
|
+
DEEPSEEK_API_KEY: "sk-test-ccc",
|
|
104
|
+
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
105
|
+
model: "deepseek-v4-flash",
|
|
106
|
+
alternativeModel: "deepseek-v4-pro",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
89
111
|
it("maps Chrome CDP guard fields into chromeDevtools config", () => {
|
|
90
112
|
expect(
|
|
91
113
|
unflattenConfig({
|
|
@@ -153,6 +175,14 @@ describe("getRestartRequiredReasons", () => {
|
|
|
153
175
|
effort: "",
|
|
154
176
|
fastMode: false,
|
|
155
177
|
},
|
|
178
|
+
ccc: {
|
|
179
|
+
enabled: true,
|
|
180
|
+
defaultAgent: false,
|
|
181
|
+
DEEPSEEK_API_KEY: "sk-test-ccc",
|
|
182
|
+
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
183
|
+
model: "deepseek-v4-pro",
|
|
184
|
+
alternativeModel: "",
|
|
185
|
+
},
|
|
156
186
|
};
|
|
157
187
|
|
|
158
188
|
it("does not require restart for agent and Chrome runtime settings", () => {
|
|
@@ -215,6 +245,16 @@ describe("dashboard edit modal", () => {
|
|
|
215
245
|
expect(PAGE_HTML).toContain("备选模型");
|
|
216
246
|
});
|
|
217
247
|
|
|
248
|
+
it("shows CCC Agent in the wizard and dashboard with model switching fields", () => {
|
|
249
|
+
expect(PAGE_HTML).toContain('id="agent-card-ccc"');
|
|
250
|
+
expect(PAGE_HTML).toContain('id="agent-enable-ccc"');
|
|
251
|
+
expect(PAGE_HTML).toContain('id="agent-default-ccc"');
|
|
252
|
+
expect(PAGE_HTML).toContain('id="field-CHATCCC_CCC_MODEL"');
|
|
253
|
+
expect(PAGE_HTML).toContain('id="field-CHATCCC_CCC_ALTERNATIVE_MODEL"');
|
|
254
|
+
expect(PAGE_HTML).toContain('id="dash-ccc"');
|
|
255
|
+
expect(PAGE_HTML).toContain("editSection('ccc')");
|
|
256
|
+
});
|
|
257
|
+
|
|
218
258
|
it("shows config effect scope hints", () => {
|
|
219
259
|
expect(PAGE_HTML).toContain("生效范围:保存后下一条消息或下个新会话生效");
|
|
220
260
|
expect(PAGE_HTML).toContain("生效范围:飞书开关、App ID、App Secret 或平台类型变更需要重启 ChatCCC");
|