chatccc 0.2.52 → 0.2.54

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.
Files changed (42) hide show
  1. package/README.md +95 -256
  2. package/bin/chatccc.mjs +23 -23
  3. package/demo/ilink_echo_probe.ts +222 -222
  4. package/im-skills/feishu-skill/download-video.mjs +162 -162
  5. package/im-skills/feishu-skill/receive-send-file.md +66 -66
  6. package/im-skills/feishu-skill/receive-send-image.md +27 -27
  7. package/im-skills/feishu-skill/send-file.mjs +50 -50
  8. package/im-skills/feishu-skill/send-image.mjs +50 -50
  9. package/im-skills/feishu-skill/skill.md +10 -10
  10. package/package.json +59 -59
  11. package/src/__tests__/agent-image-rpc.test.ts +33 -33
  12. package/src/__tests__/agent-rpc-body.test.ts +41 -41
  13. package/src/__tests__/card-plain-text.test.ts +42 -42
  14. package/src/__tests__/codex-adapter.test.ts +304 -304
  15. package/src/__tests__/config-reload.test.ts +26 -26
  16. package/src/__tests__/config-sample.test.ts +19 -19
  17. package/src/__tests__/crash-logging.test.ts +62 -62
  18. package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
  19. package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
  20. package/src/__tests__/im-skills.test.ts +46 -46
  21. package/src/__tests__/privacy.test.ts +143 -0
  22. package/src/__tests__/session.test.ts +57 -2
  23. package/src/__tests__/sim-agent.test.ts +173 -173
  24. package/src/__tests__/sim-platform.test.ts +76 -76
  25. package/src/__tests__/sim-store.test.ts +213 -213
  26. package/src/__tests__/stream-state.test.ts +134 -134
  27. package/src/__tests__/wechat-platform.test.ts +57 -32
  28. package/src/adapters/codex-adapter.ts +294 -294
  29. package/src/adapters/codex-session-meta-store.ts +130 -130
  30. package/src/agent-file-rpc.ts +156 -156
  31. package/src/agent-image-rpc.ts +152 -152
  32. package/src/agent-rpc-body.ts +48 -48
  33. package/src/card-plain-text.ts +108 -108
  34. package/src/feishu-api.ts +7 -3
  35. package/src/im-skills.ts +109 -109
  36. package/src/platform-adapter.ts +60 -60
  37. package/src/privacy.ts +68 -0
  38. package/src/session-chat-binding.ts +6 -1
  39. package/src/session.ts +40 -35
  40. package/src/sim-agent.ts +167 -167
  41. package/src/trace.ts +50 -50
  42. package/src/wechat-platform.ts +4 -1
@@ -12,14 +12,14 @@ import {
12
12
  CLAUDE_BASE_URL,
13
13
  CLAUDE_EFFORT,
14
14
  CLAUDE_MODEL,
15
- CURSOR_AGENT_ARGS,
16
- CURSOR_AGENT_COMMAND,
17
- FEISHU_ENABLED,
18
- GIT_TIMEOUT_MS,
19
- GIT_TIMEOUT_SECONDS,
20
- ILINK_ENABLED,
21
- applyLoadedConfig,
22
- config,
15
+ CURSOR_AGENT_ARGS,
16
+ CURSOR_AGENT_COMMAND,
17
+ FEISHU_ENABLED,
18
+ GIT_TIMEOUT_MS,
19
+ GIT_TIMEOUT_SECONDS,
20
+ ILINK_ENABLED,
21
+ applyLoadedConfig,
22
+ config,
23
23
  resolveDefaultAgentTool,
24
24
  type AppConfig,
25
25
  } from "../config.ts";
@@ -35,9 +35,9 @@ import {
35
35
  // 原地切换复用同一 server,重新读端口只会引入混乱)
36
36
  // ---------------------------------------------------------------------------
37
37
 
38
- const baseAppConfig: AppConfig = {
39
- feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
40
- platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
38
+ const baseAppConfig: AppConfig = {
39
+ feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
40
+ platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
41
41
  port: 18080,
42
42
  gitTimeoutSeconds: 180,
43
43
  allowInterrupt: false,
@@ -97,7 +97,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
97
97
  expect(CLAUDE_BASE_URL).toBe("https://gw2.example/anthropic");
98
98
  });
99
99
 
100
- it("更新 GIT_TIMEOUT_SECONDS 与 GIT_TIMEOUT_MS(毫秒派生值同步刷新)", () => {
100
+ it("更新 GIT_TIMEOUT_SECONDS 与 GIT_TIMEOUT_MS(毫秒派生值同步刷新)", () => {
101
101
  applyLoadedConfig({
102
102
  ...structuredClone(baseAppConfig),
103
103
  gitTimeoutSeconds: 240,
@@ -106,20 +106,20 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
106
106
  expect(GIT_TIMEOUT_SECONDS).toBe(240);
107
107
  // 派生值必须跟着更新,否则 /git 仍然按旧 timeout 运行
108
108
  expect(GIT_TIMEOUT_MS).toBe(240 * 1000);
109
- });
110
-
111
- it("更新平台开关(默认飞书和微信都开启)", () => {
112
- expect(FEISHU_ENABLED).toBe(true);
113
- expect(ILINK_ENABLED).toBe(true);
114
-
115
- applyLoadedConfig({
116
- ...structuredClone(baseAppConfig),
117
- platforms: { feishu: { enabled: false }, ilink: { enabled: true } },
118
- });
119
-
120
- expect(FEISHU_ENABLED).toBe(false);
121
- expect(ILINK_ENABLED).toBe(true);
122
- });
109
+ });
110
+
111
+ it("更新平台开关(默认飞书和微信都开启)", () => {
112
+ expect(FEISHU_ENABLED).toBe(true);
113
+ expect(ILINK_ENABLED).toBe(true);
114
+
115
+ applyLoadedConfig({
116
+ ...structuredClone(baseAppConfig),
117
+ platforms: { feishu: { enabled: false }, ilink: { enabled: true } },
118
+ });
119
+
120
+ expect(FEISHU_ENABLED).toBe(false);
121
+ expect(ILINK_ENABLED).toBe(true);
122
+ });
123
123
 
124
124
  it("CURSOR_AGENT_ARGS 跟随 cursor.model 重新解析", () => {
125
125
  applyLoadedConfig({
@@ -1,19 +1,19 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
- import { describe, expect, it } from "vitest";
5
-
6
- describe("config.sample.json", () => {
7
- it("enables Feishu and WeChat iLink by default", () => {
8
- const configSamplePath = join(process.cwd(), "config.sample.json");
9
- const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
10
- platforms?: {
11
- feishu?: { enabled?: unknown };
12
- ilink?: { enabled?: unknown };
13
- };
14
- };
15
-
16
- expect(sample.platforms?.feishu?.enabled).toBe(true);
17
- expect(sample.platforms?.ilink?.enabled).toBe(true);
18
- });
19
- });
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ describe("config.sample.json", () => {
7
+ it("enables Feishu and WeChat iLink by default", () => {
8
+ const configSamplePath = join(process.cwd(), "config.sample.json");
9
+ const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
10
+ platforms?: {
11
+ feishu?: { enabled?: unknown };
12
+ ilink?: { enabled?: unknown };
13
+ };
14
+ };
15
+
16
+ expect(sample.platforms?.feishu?.enabled).toBe(true);
17
+ expect(sample.platforms?.ilink?.enabled).toBe(true);
18
+ });
19
+ });
@@ -1,9 +1,9 @@
1
- import { mkdtemp, readFile, rm } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { describe, it, expect, vi } from "vitest";
5
-
6
- import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, it, expect, vi } from "vitest";
5
+
6
+ import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
7
7
 
8
8
  describe("buildCrashLoggingHandlers", () => {
9
9
  it("uncaughtException: 写诊断、刷新日志、调用 onFatal", () => {
@@ -144,7 +144,7 @@ describe("buildCrashLoggingHandlers", () => {
144
144
  });
145
145
  });
146
146
 
147
- describe("installCrashLogging", () => {
147
+ describe("installCrashLogging", () => {
148
148
  it("注册所有相关事件监听器", () => {
149
149
  const before = {
150
150
  uncaught: process.listenerCount("uncaughtException"),
@@ -209,58 +209,58 @@ describe("installCrashLogging", () => {
209
209
  } finally {
210
210
  cleanup();
211
211
  }
212
- });
213
- });
214
-
215
- describe("setupFileLogging", () => {
216
- it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
217
- const originalLog = console.log;
218
- const originalError = console.error;
219
- console.log = vi.fn() as never;
220
- console.error = vi.fn() as never;
221
- const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
222
-
223
- try {
224
- const fileLog = setupFileLogging(dir, "index");
225
-
226
- console.log("before flush");
227
- fileLog.flush();
228
-
229
- expect(() => console.error("after flush")).not.toThrow();
230
- fileLog.flush();
231
-
232
- const content = await readFile(fileLog.logPath, "utf8");
233
- expect(content).toContain("[LOG] before flush");
234
- expect(content).toContain("[ERR] after flush");
235
- } finally {
236
- console.log = originalLog;
237
- console.error = originalError;
238
- await rm(dir, { recursive: true, force: true });
239
- }
240
- });
241
-
242
- it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
243
- const originalLog = console.log;
244
- const originalError = console.error;
245
- console.log = vi.fn() as never;
246
- console.error = vi.fn() as never;
247
- const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
248
-
249
- try {
250
- const fileLog = setupFileLogging(dir, "index");
251
- const circular: Record<string, unknown> = { name: "root" };
252
- circular.self = circular;
253
-
254
- expect(() => console.log("circular", circular)).not.toThrow();
255
- fileLog.flush();
256
-
257
- const content = await readFile(fileLog.logPath, "utf8");
258
- expect(content).toContain("[LOG] circular");
259
- expect(content).toContain("self");
260
- } finally {
261
- console.log = originalLog;
262
- console.error = originalError;
263
- await rm(dir, { recursive: true, force: true });
264
- }
265
- });
266
- });
212
+ });
213
+ });
214
+
215
+ describe("setupFileLogging", () => {
216
+ it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
217
+ const originalLog = console.log;
218
+ const originalError = console.error;
219
+ console.log = vi.fn() as never;
220
+ console.error = vi.fn() as never;
221
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
222
+
223
+ try {
224
+ const fileLog = setupFileLogging(dir, "index");
225
+
226
+ console.log("before flush");
227
+ fileLog.flush();
228
+
229
+ expect(() => console.error("after flush")).not.toThrow();
230
+ fileLog.flush();
231
+
232
+ const content = await readFile(fileLog.logPath, "utf8");
233
+ expect(content).toContain("[LOG] before flush");
234
+ expect(content).toContain("[ERR] after flush");
235
+ } finally {
236
+ console.log = originalLog;
237
+ console.error = originalError;
238
+ await rm(dir, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
243
+ const originalLog = console.log;
244
+ const originalError = console.error;
245
+ console.log = vi.fn() as never;
246
+ console.error = vi.fn() as never;
247
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
248
+
249
+ try {
250
+ const fileLog = setupFileLogging(dir, "index");
251
+ const circular: Record<string, unknown> = { name: "root" };
252
+ circular.self = circular;
253
+
254
+ expect(() => console.log("circular", circular)).not.toThrow();
255
+ fileLog.flush();
256
+
257
+ const content = await readFile(fileLog.logPath, "utf8");
258
+ expect(content).toContain("[LOG] circular");
259
+ expect(content).toContain("self");
260
+ } finally {
261
+ console.log = originalLog;
262
+ console.error = originalError;
263
+ await rm(dir, { recursive: true, force: true });
264
+ }
265
+ });
266
+ });
@@ -1,4 +1,4 @@
1
- {"type":"thread.started","thread_id":"019e15c7-t001-7901-b92c-9a4371e07750"}
2
- {"type":"turn.started"}
3
- {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}
4
- {"type":"turn.completed","usage":{"input_tokens":12964,"cached_input_tokens":11136,"output_tokens":5,"reasoning_output_tokens":0}}
1
+ {"type":"thread.started","thread_id":"019e15c7-t001-7901-b92c-9a4371e07750"}
2
+ {"type":"turn.started"}
3
+ {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}
4
+ {"type":"turn.completed","usage":{"input_tokens":12964,"cached_input_tokens":11136,"output_tokens":5,"reasoning_output_tokens":0}}
@@ -1,6 +1,6 @@
1
- {"type":"thread.started","thread_id":"019e15c8-t002-7441-9bfb-eec7b1ef4c4c"}
2
- {"type":"turn.started"}
3
- {"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"powershell.exe -Command echo tool_test","aggregated_output":"","exit_code":null,"status":"in_progress"}}
4
- {"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"powershell.exe -Command echo tool_test","aggregated_output":"tool_test\r\n","exit_code":0,"status":"completed"}}
5
- {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"tool_test"}}
6
- {"type":"turn.completed","usage":{"input_tokens":26021,"cached_input_tokens":22272,"output_tokens":52,"reasoning_output_tokens":0}}
1
+ {"type":"thread.started","thread_id":"019e15c8-t002-7441-9bfb-eec7b1ef4c4c"}
2
+ {"type":"turn.started"}
3
+ {"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"powershell.exe -Command echo tool_test","aggregated_output":"","exit_code":null,"status":"in_progress"}}
4
+ {"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"powershell.exe -Command echo tool_test","aggregated_output":"tool_test\r\n","exit_code":0,"status":"completed"}}
5
+ {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"tool_test"}}
6
+ {"type":"turn.completed","usage":{"input_tokens":26021,"cached_input_tokens":22272,"output_tokens":52,"reasoning_output_tokens":0}}
@@ -1,46 +1,46 @@
1
- import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { tmpdir } from "node:os";
4
- import { afterEach, describe, expect, it } from "vitest";
5
-
6
- import { buildImSkillsPrompt } from "../im-skills.ts";
7
-
8
- let tempRoot: string | null = null;
9
-
10
- describe("IM skills prompt rendering", () => {
11
- afterEach(async () => {
12
- if (tempRoot) await rm(tempRoot, { recursive: true, force: true });
13
- tempRoot = null;
14
- });
15
-
16
- it("loads skill.md files and renders runtime variables", async () => {
17
- tempRoot = await mkdtemp(join(tmpdir(), "chatccc-im-skills-"));
18
- const skillDir = join(tempRoot, "feishu-skill");
19
- await mkdir(skillDir);
20
- await writeFile(
21
- join(skillDir, "skill.md"),
22
- [
23
- "---",
24
- "name: feishu-skill",
25
- "---",
26
- "GET {{session_grants_url}}?sid={{session_id}}",
27
- "cwd={{cwd}}",
28
- ].join("\n"),
29
- "utf-8",
30
- );
31
-
32
- const prompt = await buildImSkillsPrompt({
33
- skillsDir: tempRoot,
34
- variables: {
35
- cwd: "C:/work",
36
- session_grants_url: "http://127.0.0.1:18080/api/agent/session-grants",
37
- session_id: "sid_test",
38
- },
39
- });
40
-
41
- expect(prompt).toContain("[ChatCCC IM skill: feishu-skill]");
42
- expect(prompt).toContain("GET http://127.0.0.1:18080/api/agent/session-grants?sid=sid_test");
43
- expect(prompt).toContain("cwd=C:/work");
44
- expect(prompt).not.toContain("{{");
45
- });
46
- });
1
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { buildImSkillsPrompt } from "../im-skills.ts";
7
+
8
+ let tempRoot: string | null = null;
9
+
10
+ describe("IM skills prompt rendering", () => {
11
+ afterEach(async () => {
12
+ if (tempRoot) await rm(tempRoot, { recursive: true, force: true });
13
+ tempRoot = null;
14
+ });
15
+
16
+ it("loads skill.md files and renders runtime variables", async () => {
17
+ tempRoot = await mkdtemp(join(tmpdir(), "chatccc-im-skills-"));
18
+ const skillDir = join(tempRoot, "feishu-skill");
19
+ await mkdir(skillDir);
20
+ await writeFile(
21
+ join(skillDir, "skill.md"),
22
+ [
23
+ "---",
24
+ "name: feishu-skill",
25
+ "---",
26
+ "GET {{session_grants_url}}?sid={{session_id}}",
27
+ "cwd={{cwd}}",
28
+ ].join("\n"),
29
+ "utf-8",
30
+ );
31
+
32
+ const prompt = await buildImSkillsPrompt({
33
+ skillsDir: tempRoot,
34
+ variables: {
35
+ cwd: "C:/work",
36
+ session_grants_url: "http://127.0.0.1:18080/api/agent/session-grants",
37
+ session_id: "sid_test",
38
+ },
39
+ });
40
+
41
+ expect(prompt).toContain("[ChatCCC IM skill: feishu-skill]");
42
+ expect(prompt).toContain("GET http://127.0.0.1:18080/api/agent/session-grants?sid=sid_test");
43
+ expect(prompt).toContain("cwd=C:/work");
44
+ expect(prompt).not.toContain("{{");
45
+ });
46
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // 在 import privacy 之前 mock config,让 USER_DATA_DIR 指向临时目录
7
+ const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-privacy-test-"));
8
+ vi.mock("../config.ts", async () => {
9
+ const actual = await vi.importActual<typeof import("../config.ts")>("../config.ts");
10
+ return {
11
+ ...actual,
12
+ USER_DATA_DIR: TEST_DATA_DIR,
13
+ ts: () => "test-ts",
14
+ };
15
+ });
16
+
17
+ let applyPrivacy: (text: string) => string;
18
+ let reloadPrivacyRules: () => void;
19
+ let getPrivacyRules: () => Record<string, string>;
20
+
21
+ beforeEach(async () => {
22
+ vi.resetModules();
23
+ // 清理临时目录中的 privacy.json
24
+ try {
25
+ await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
26
+ } catch {}
27
+ const mod = await import("../privacy.ts");
28
+ applyPrivacy = mod.applyPrivacy;
29
+ reloadPrivacyRules = mod.reloadPrivacyRules;
30
+ getPrivacyRules = mod.getPrivacyRules;
31
+ });
32
+
33
+ afterEach(async () => {
34
+ try {
35
+ await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
36
+ } catch {}
37
+ });
38
+
39
+ afterAll(async () => {
40
+ try {
41
+ await rm(TEST_DATA_DIR, { recursive: true, force: true });
42
+ } catch {}
43
+ });
44
+
45
+ describe("applyPrivacy", () => {
46
+ it("无 privacy.json 时返回原文", () => {
47
+ reloadPrivacyRules();
48
+ expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
49
+ });
50
+
51
+ it("privacy.json 存在时按规则替换", async () => {
52
+ await writeFile(
53
+ join(TEST_DATA_DIR, "privacy.json"),
54
+ JSON.stringify({ weizhangjian: "wzj", secret: "***" }),
55
+ "utf-8",
56
+ );
57
+ reloadPrivacyRules();
58
+
59
+ expect(applyPrivacy("hello weizhangjian")).toBe("hello wzj");
60
+ expect(applyPrivacy("my secret is safe")).toBe("my *** is safe");
61
+ expect(applyPrivacy("weizhangjian and secret")).toBe("wzj and ***");
62
+ });
63
+
64
+ it("多规则替换多次出现", async () => {
65
+ await writeFile(
66
+ join(TEST_DATA_DIR, "privacy.json"),
67
+ JSON.stringify({ a: "A", b: "B" }),
68
+ "utf-8",
69
+ );
70
+ reloadPrivacyRules();
71
+
72
+ expect(applyPrivacy("a b a b")).toBe("A B A B");
73
+ });
74
+
75
+ it("空文本直接返回", async () => {
76
+ await writeFile(
77
+ join(TEST_DATA_DIR, "privacy.json"),
78
+ JSON.stringify({ x: "y" }),
79
+ "utf-8",
80
+ );
81
+ reloadPrivacyRules();
82
+
83
+ expect(applyPrivacy("")).toBe("");
84
+ });
85
+
86
+ it("规则中的特殊字符不会被当作正则", async () => {
87
+ await writeFile(
88
+ join(TEST_DATA_DIR, "privacy.json"),
89
+ JSON.stringify({ "a.b": "X", "(test)": "Y", "*star": "Z" }),
90
+ "utf-8",
91
+ );
92
+ reloadPrivacyRules();
93
+
94
+ expect(applyPrivacy("hello a.b world")).toBe("hello X world");
95
+ expect(applyPrivacy("text (test) here")).toBe("text Y here");
96
+ expect(applyPrivacy("a *star shines")).toBe("a Z shines");
97
+ });
98
+
99
+ it("reloadPrivacyRules 强制重新加载", async () => {
100
+ await writeFile(
101
+ join(TEST_DATA_DIR, "privacy.json"),
102
+ JSON.stringify({ old: "OLD" }),
103
+ "utf-8",
104
+ );
105
+ reloadPrivacyRules();
106
+
107
+ expect(applyPrivacy("old")).toBe("OLD");
108
+ expect(getPrivacyRules()).toEqual({ old: "OLD" });
109
+
110
+ // 变更磁盘内容后 reload
111
+ await writeFile(
112
+ join(TEST_DATA_DIR, "privacy.json"),
113
+ JSON.stringify({ new: "NEW" }),
114
+ "utf-8",
115
+ );
116
+ reloadPrivacyRules();
117
+
118
+ expect(applyPrivacy("new")).toBe("NEW");
119
+ expect(getPrivacyRules()).toEqual({ new: "NEW" });
120
+ });
121
+
122
+ it("格式错误的 JSON 不抛异常,返回原文", async () => {
123
+ await writeFile(
124
+ join(TEST_DATA_DIR, "privacy.json"),
125
+ "not json",
126
+ "utf-8",
127
+ );
128
+ reloadPrivacyRules();
129
+
130
+ expect(applyPrivacy("hello")).toBe("hello");
131
+ });
132
+
133
+ it("数组格式的 JSON 不抛异常,返回原文", async () => {
134
+ await writeFile(
135
+ join(TEST_DATA_DIR, "privacy.json"),
136
+ JSON.stringify(["a", "b"]),
137
+ "utf-8",
138
+ );
139
+ reloadPrivacyRules();
140
+
141
+ expect(applyPrivacy("hello")).toBe("hello");
142
+ });
143
+ });
@@ -4,12 +4,12 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
6
  // mock stream-state 以支持在测试中控制累积长度
7
- const mockStreamStates = new Map<string, { accumulatedContent: string; finalReply: string }>();
7
+ const mockStreamStates = new Map<string, { accumulatedContent: string; finalReply: string; status?: "running" | "done" | "stopped" }>();
8
8
  vi.mock("../stream-state.ts", () => ({
9
9
  readStreamState: async (sid: string) => {
10
10
  const state = mockStreamStates.get(sid);
11
11
  if (!state) return null;
12
- return { sessionId: sid, accumulatedContent: state.accumulatedContent, finalReply: state.finalReply, status: "running", chunkCount: 0, turnCount: 0, contextTokens: 0, updatedAt: Date.now(), cwd: "", tool: "claude" };
12
+ return { sessionId: sid, accumulatedContent: state.accumulatedContent, finalReply: state.finalReply, status: state.status ?? "running", chunkCount: 0, turnCount: 0, contextTokens: 0, updatedAt: Date.now(), cwd: "", tool: "claude" };
13
13
  },
14
14
  writeStreamState: async () => {},
15
15
  createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
@@ -41,6 +41,7 @@ import {
41
41
  setSessionPlatform,
42
42
  recordChatPlatform,
43
43
  _getPlatformForChatForTest,
44
+ ensureDisplayLoop,
44
45
  } from "../session.ts";
45
46
  import {
46
47
  activePrompts,
@@ -183,6 +184,60 @@ describe("chat platform routing", () => {
183
184
  // processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
184
185
  // ---------------------------------------------------------------------------
185
186
 
187
+ describe("ensureDisplayLoop WeChat delta", () => {
188
+ beforeEach(() => {
189
+ vi.useFakeTimers();
190
+ resetState();
191
+ resetBindingState();
192
+ mockStreamStates.clear();
193
+ });
194
+
195
+ afterEach(() => {
196
+ resetBindingState();
197
+ vi.useRealTimers();
198
+ });
199
+
200
+ it("sends only new accumulated content when tool output arrives before an already-sent final reply", async () => {
201
+ const platform = mockPlatform("wechat");
202
+ platform.kind = "wechat";
203
+ setSessionPlatform(platform);
204
+
205
+ bindChatToSession("sid-wechat", "chat-wechat");
206
+ recordLastActiveChat("sid-wechat", "chat-wechat");
207
+ sessionInfoMap.set("chat-wechat", {
208
+ sessionId: "sid-wechat",
209
+ turnCount: 1,
210
+ lastContextTokens: 0,
211
+ startTime: 0,
212
+ tool: "claude",
213
+ });
214
+
215
+ mockStreamStates.set("sid-wechat", {
216
+ accumulatedContent: "",
217
+ finalReply: "partial reply",
218
+ });
219
+ ensureDisplayLoop("sid-wechat");
220
+ await vi.advanceTimersByTimeAsync(3000);
221
+
222
+ mockStreamStates.set("sid-wechat", {
223
+ accumulatedContent: "tool output\n",
224
+ finalReply: "partial reply",
225
+ });
226
+ await vi.advanceTimersByTimeAsync(3000);
227
+
228
+ expect(platform.sendText).toHaveBeenNthCalledWith(
229
+ 1,
230
+ "chat-wechat",
231
+ "partial reply",
232
+ );
233
+ expect(platform.sendText).toHaveBeenNthCalledWith(
234
+ 2,
235
+ "chat-wechat",
236
+ "tool output",
237
+ );
238
+ });
239
+ });
240
+
186
241
  describe("rebuildBindingsFromRegistry", () => {
187
242
  let registryFile = "";
188
243