chatccc 0.2.219 → 0.2.221

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 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 Codex 继续写代码、查问题、跑命令;不用一直守在电脑前。
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
- ChatCCC 只负责把聊天消息转给本地 AI 工具,不捆绑这些 CLI。你只需要安装自己要用的 Agent。
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
- "avatarBatteryMode": "apiPercent",
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,13 +342,16 @@ 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 codex`,机器人会另外创建会话群。
354
+ **飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor`、`/new codex` 或 `/new ccc`,机器人会另外创建会话群。
336
355
 
337
356
  **微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
338
357
 
@@ -348,6 +367,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
348
367
  | `/new claude` | 创建 Claude Code 会话;飞书中会创建新群 |
349
368
  | `/new cursor` | 创建 Cursor 会话;飞书中会创建新群 |
350
369
  | `/new codex` | 创建 Codex 会话;飞书中会创建新群 |
370
+ | `/new ccc` | 创建内置 CCC Agent 会话;飞书中会创建新群 |
351
371
  | `/newh` | 在当前聊天原地重置会话;群聊保留工作目录,飞书私聊固定使用系统用户目录 |
352
372
  | `/model` | 查看或切换当前会话的模型 |
353
373
  | `/fast` | 查看当前 Codex 会话的 Fast 模式;使用 `/fast on` 或 `/fast off` 切换 |
@@ -357,7 +377,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
357
377
  | `/cd` | 查看或设置后续新建会话的默认工作目录,不改变当前会话;飞书私聊自身始终使用系统用户目录 |
358
378
  | `/sessions` | 查看所有会话状态 |
359
379
  | `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话;飞书私聊不支持切换 |
360
- | `/usage` | 查看当前会话对应 Agent 的用量;Codex 按接口实际返回显示 5h/7天窗口(缺失窗口不显示),若 Chrome CDP 可用且 ChatGPT 已登录,会额外显示订阅到期时间;Cursor 显示当前周期用量 |
380
+ | `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7天窗口,Cursor 显示当前周期用量,使用官方 DeepSeek API CCC Agent 显示账户余额 |
361
381
  | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
362
382
  | `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
363
383
  | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
@@ -368,7 +388,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
368
388
 
369
389
  `/update` 会在执行 npm 更新前把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新 `/update` 因事件 ID 不同,仍可立即执行。该保护仅作用于 `/update`,普通消息与 `/restart` 的处理不变。
370
390
 
371
- > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
391
+ > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex CCC Agent 使用各自的 `model` / `alternativeModel`。
372
392
 
373
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。
374
394
 
@@ -376,4 +396,4 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
376
396
 
377
397
  ## 技术栈
378
398
 
379
- 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
@@ -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.219",
3
+ "version": "0.2.221",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
 
@@ -158,6 +158,37 @@ describe("builtin file tools", () => {
158
158
  await expect(readFile(file, "utf8")).resolves.toBe("alpha\nBETA\ngamma\n");
159
159
  });
160
160
 
161
+ it("edits a CRLF file with LF oldText by normalizing line endings", async () => {
162
+ const dir = await makeTempDir();
163
+ const file = join(dir, "crlf.txt");
164
+ const crlfContent = "alpha\r\nbeta\r\ngamma\r\n";
165
+ await writeFile(file, crlfContent, "utf8");
166
+
167
+ const result = await editFileForTool(dir, {
168
+ path: "crlf.txt",
169
+ expectedSha256: sha256(crlfContent),
170
+ edits: [{ oldText: "alpha\nbeta", newText: "ALPHA\nBETA" }],
171
+ });
172
+
173
+ expect(result).toEqual(expect.objectContaining({
174
+ changed: true,
175
+ editsApplied: 1,
176
+ beforeSha256: sha256(crlfContent),
177
+ afterSha256: sha256("ALPHA\r\nBETA\r\ngamma\r\n"),
178
+ }));
179
+ await expect(readFile(file, "utf8")).resolves.toBe("ALPHA\r\nBETA\r\ngamma\r\n");
180
+ });
181
+
182
+ it("rejects a multi-line oldText that still does not match after EOL normalization", async () => {
183
+ const dir = await makeTempDir();
184
+ await writeFile(join(dir, "crlf.txt"), "alpha\r\nbeta\r\n", "utf8");
185
+
186
+ await expect(editFileForTool(dir, {
187
+ path: "crlf.txt",
188
+ edits: [{ oldText: "alpha\ngamma", newText: "x" }],
189
+ })).rejects.toThrow("oldText was not found");
190
+ });
191
+
161
192
  it("rejects edits when the SHA-256 precondition does not match", async () => {
162
193
  const dir = await makeTempDir();
163
194
  await writeFile(join(dir, "edit.txt"), "current\n", "utf8");
@@ -13,6 +13,7 @@ import {
13
13
  buildButtons,
14
14
  truncateContent,
15
15
  getToolEmoji,
16
+ normalizeToolName,
16
17
  } from "../cards.ts";
17
18
  import { ABD_HELP_LINE } from "../shared-prefix.ts";
18
19
 
@@ -83,6 +84,25 @@ describe("getToolEmoji", () => {
83
84
  expect(getToolEmoji("AskUserQuestion")).toBe("\u{2753}");// ❓
84
85
  });
85
86
 
87
+ it("returns correct emoji for CCC builtin tool names (snake_case)", () => {
88
+ expect(getToolEmoji("read_file")).toBe("\u{1F4D6}");
89
+ expect(getToolEmoji("list_dir")).toBe("\u{1F4C2}");
90
+ expect(getToolEmoji("search_code")).toBe("\u{1F50E}");
91
+ expect(getToolEmoji("run_command")).toBe("\u{1F5A5}\u{FE0F}");
92
+ expect(getToolEmoji("edit_file")).toBe("\u{270F}\u{FE0F}");
93
+ expect(getToolEmoji("create_file")).toBe("\u{270D}\u{FE0F}");
94
+ expect(getToolEmoji("delete_file")).toBe("\u{1F5D1}\u{FE0F}");
95
+ expect(getToolEmoji("move_file")).toBe("\u{1F4E6}");
96
+ expect(getToolEmoji("apply_patch")).toBe("\u{1F4CB}");
97
+ });
98
+
99
+ it("normalizeToolName converts snake_case to PascalCase", () => {
100
+ expect(normalizeToolName("read_file")).toBe("ReadFile");
101
+ expect(normalizeToolName("run_command")).toBe("RunCommand");
102
+ expect(normalizeToolName("Read")).toBe("Read");
103
+ expect(normalizeToolName("")).toBe("");
104
+ });
105
+
86
106
  it("returns wrench for unknown tool names", () => {
87
107
  expect(getToolEmoji("UnknownTool")).toBe("\u{1F527}");
88
108
  expect(getToolEmoji("cat")).toBe("\u{1F527}");
@@ -161,7 +181,7 @@ describe("buildHelpCard", () => {
161
181
  const parsed = JSON.parse(card);
162
182
  const action = parsed.elements[2];
163
183
  expect(action.tag).toBe("action");
164
- expect(action.actions).toHaveLength(7);
184
+ expect(action.actions).toHaveLength(8);
165
185
  });
166
186
 
167
187
  it("adds ABD prefix help as the final help line", () => {
@@ -169,17 +189,17 @@ describe("buildHelpCard", () => {
169
189
  const parsed = JSON.parse(card);
170
190
  const lines = parsed.elements[1].text.content.split("\n");
171
191
 
172
- expect(lines).toContain("发送 **/usage** 查看 Codex 实际存在的 5h/7天用量窗口,以及查询/使用主动重置卡");
192
+ expect(lines).toContain("发送 **/usage** 查看当前 Agent 的用量或余额");
173
193
  expect(lines.at(-1)).toBe(ABD_HELP_LINE);
174
194
  });
175
195
 
176
- it("does not advertise the hidden ccc agent", () => {
196
+ it("advertises CCC Agent as a normal session option", () => {
177
197
  const card = buildHelpCard("test");
178
198
  const parsed = JSON.parse(card);
179
199
  const text = JSON.stringify(parsed);
180
200
 
181
- expect(text).not.toContain("/new ccc");
182
- expect(text).not.toContain("CCC Agent");
201
+ expect(text).toContain("/new ccc");
202
+ expect(text).toContain("CCC Agent");
183
203
  });
184
204
  });
185
205
 
@@ -419,7 +439,7 @@ describe("buildSessionsCard", () => {
419
439
  expect(content).not.toContain("Cursor 会话");
420
440
  });
421
441
 
422
- it("labels hidden ccc sessions without grouping them as Claude Code", () => {
442
+ it("labels CCC sessions without grouping them as Claude Code", () => {
423
443
  const card = buildSessionsCard([
424
444
  { sessionId: "session-20260702-121530-a1b2c3", chatName: "", chatId: "oc_ccc", active: false, turnCount: 1, elapsedSeconds: null, model: "deepseek-v4-pro", tool: "ccc" },
425
445
  ]);
@@ -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 hidden /new ccc entry without advertising it in normal help", async () => {
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).not.toContain("/new ccc");
1154
- expect(helpCard).not.toContain("CCC Agent");
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");
@@ -979,13 +979,19 @@ export async function editFileForTool(cwd: string, input: EditFileInput): Promis
979
979
  const before = await readEditableTextFile(filePath);
980
980
  assertExpectedSha256(filePath, before.sha, input.expectedSha256);
981
981
 
982
- let text = before.text;
982
+ // Normalize line endings before matching so that LF-based oldText/newText
983
+ // (which is what models typically emit) works against CRLF files checked
984
+ // out on Windows. The file's dominant EOL style is restored on write.
985
+ const eol = detectEol(before.text);
986
+ let text = eol === "\r\n" ? before.text.replace(/\r\n/g, "\n") : before.text;
983
987
  let editsApplied = 0;
984
988
  for (const [index, edit] of input.edits.entries()) {
985
989
  if (!edit.oldText) {
986
990
  throw new Error(`edit ${index + 1} oldText must not be empty`);
987
991
  }
988
- const count = countOccurrences(text, edit.oldText);
992
+ const oldText = edit.oldText.replace(/\r\n/g, "\n");
993
+ const newText = edit.newText.replace(/\r\n/g, "\n");
994
+ const count = countOccurrences(text, oldText);
989
995
  if (count === 0) {
990
996
  throw new Error(`edit ${index + 1} oldText was not found in ${filePath}`);
991
997
  }
@@ -993,10 +999,14 @@ export async function editFileForTool(cwd: string, input: EditFileInput): Promis
993
999
  throw new Error(`edit ${index + 1} oldText matched ${count} times in ${filePath}; set replaceAll=true or provide more context`);
994
1000
  }
995
1001
  text = edit.replaceAll
996
- ? replaceAllLiteral(text, edit.oldText, edit.newText)
997
- : text.replace(edit.oldText, edit.newText);
1002
+ ? replaceAllLiteral(text, oldText, newText)
1003
+ : text.replace(oldText, newText);
998
1004
  editsApplied += edit.replaceAll ? count : 1;
999
1005
  }
1006
+ if (eol === "\r\n") {
1007
+ // After normalization above the buffer contains only \n, so this is safe.
1008
+ text = text.replace(/\n/g, "\r\n");
1009
+ }
1000
1010
 
1001
1011
  assertTextSize(filePath, text, MAX_EDIT_BYTES);
1002
1012
  const afterSha = sha256(text);