chatccc 0.2.51 → 0.2.53

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 (48) hide show
  1. package/README.md +103 -217
  2. package/bin/chatccc.mjs +23 -23
  3. package/config.sample.json +34 -30
  4. package/demo/ilink_echo_probe.ts +222 -222
  5. package/im-skills/feishu-skill/download-video.mjs +162 -162
  6. package/im-skills/feishu-skill/receive-send-file.md +66 -66
  7. package/im-skills/feishu-skill/receive-send-image.md +27 -27
  8. package/im-skills/feishu-skill/send-file.mjs +50 -50
  9. package/im-skills/feishu-skill/send-image.mjs +50 -50
  10. package/im-skills/feishu-skill/skill.md +10 -10
  11. package/package.json +59 -59
  12. package/src/__tests__/agent-image-rpc.test.ts +33 -33
  13. package/src/__tests__/agent-rpc-body.test.ts +41 -41
  14. package/src/__tests__/card-plain-text.test.ts +42 -0
  15. package/src/__tests__/claude-adapter.test.ts +54 -1
  16. package/src/__tests__/codex-adapter.test.ts +304 -304
  17. package/src/__tests__/config-reload.test.ts +58 -41
  18. package/src/__tests__/config-sample.test.ts +19 -0
  19. package/src/__tests__/crash-logging.test.ts +62 -62
  20. package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
  21. package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
  22. package/src/__tests__/im-skills.test.ts +46 -46
  23. package/src/__tests__/session.test.ts +95 -2
  24. package/src/__tests__/sim-agent.test.ts +173 -173
  25. package/src/__tests__/sim-platform.test.ts +76 -76
  26. package/src/__tests__/sim-store.test.ts +213 -213
  27. package/src/__tests__/stream-state.test.ts +134 -134
  28. package/src/__tests__/wechat-platform.test.ts +57 -0
  29. package/src/adapters/claude-adapter.ts +23 -2
  30. package/src/adapters/codex-adapter.ts +294 -294
  31. package/src/adapters/codex-session-meta-store.ts +130 -130
  32. package/src/agent-file-rpc.ts +156 -156
  33. package/src/agent-image-rpc.ts +152 -152
  34. package/src/agent-rpc-body.ts +48 -48
  35. package/src/card-plain-text.ts +108 -0
  36. package/src/cards.ts +4 -4
  37. package/src/config.ts +775 -742
  38. package/src/feishu-api.ts +6 -0
  39. package/src/im-skills.ts +109 -109
  40. package/src/index.ts +155 -788
  41. package/src/orchestrator.ts +1032 -0
  42. package/src/platform-adapter.ts +61 -0
  43. package/src/session-chat-binding.ts +7 -0
  44. package/src/session.ts +278 -113
  45. package/src/sim-agent.ts +167 -156
  46. package/src/trace.ts +50 -50
  47. package/src/web-ui.ts +1891 -1718
  48. package/src/wechat-platform.ts +517 -0
@@ -14,13 +14,15 @@ import {
14
14
  CLAUDE_MODEL,
15
15
  CURSOR_AGENT_ARGS,
16
16
  CURSOR_AGENT_COMMAND,
17
+ FEISHU_ENABLED,
17
18
  GIT_TIMEOUT_MS,
18
19
  GIT_TIMEOUT_SECONDS,
19
- applyLoadedConfig,
20
- config,
21
- resolveDefaultAgentTool,
22
- type AppConfig,
23
- } from "../config.ts";
20
+ ILINK_ENABLED,
21
+ applyLoadedConfig,
22
+ config,
23
+ resolveDefaultAgentTool,
24
+ type AppConfig,
25
+ } from "../config.ts";
24
26
 
25
27
  // ---------------------------------------------------------------------------
26
28
  // applyLoadedConfig — setup → service「在线切换」时刷新进程内 config 的核心机制
@@ -35,18 +37,20 @@ import {
35
37
 
36
38
  const baseAppConfig: AppConfig = {
37
39
  feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
40
+ platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
38
41
  port: 18080,
39
42
  gitTimeoutSeconds: 180,
40
- claude: {
41
- enabled: true,
42
- defaultAgent: true,
43
- model: "initial-model",
43
+ allowInterrupt: false,
44
+ claude: {
45
+ enabled: true,
46
+ defaultAgent: true,
47
+ model: "initial-model",
44
48
  effort: "initial-effort",
45
49
  apiKey: "sk-initial",
46
50
  baseUrl: "https://initial.gw/anthropic",
47
51
  },
48
- cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
49
- codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
52
+ cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
53
+ codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
50
54
  };
51
55
 
52
56
  // 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
@@ -77,10 +81,10 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
77
81
  it("更新 Claude 配置(model / effort / apiKey / baseUrl)", () => {
78
82
  applyLoadedConfig({
79
83
  ...structuredClone(baseAppConfig),
80
- claude: {
81
- enabled: true,
82
- defaultAgent: true,
83
- model: "deepseek-v4-pro",
84
+ claude: {
85
+ enabled: true,
86
+ defaultAgent: true,
87
+ model: "deepseek-v4-pro",
84
88
  effort: "high",
85
89
  apiKey: "sk-newkey",
86
90
  baseUrl: "https://gw2.example/anthropic",
@@ -104,10 +108,23 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
104
108
  expect(GIT_TIMEOUT_MS).toBe(240 * 1000);
105
109
  });
106
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
+
107
124
  it("CURSOR_AGENT_ARGS 跟随 cursor.model 重新解析", () => {
108
125
  applyLoadedConfig({
109
126
  ...structuredClone(baseAppConfig),
110
- cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
127
+ cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
111
128
  });
112
129
 
113
130
  // CURSOR_AGENT_ARGS 是 ['-p', '--force', ..., '--model', 'claude-3.7-sonnet']
@@ -118,7 +135,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
118
135
  it("cursor.model 留空时 CURSOR_AGENT_ARGS 不含 --model", () => {
119
136
  applyLoadedConfig({
120
137
  ...structuredClone(baseAppConfig),
121
- cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
138
+ cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
122
139
  });
123
140
 
124
141
  expect(CURSOR_AGENT_ARGS).not.toContain("--model");
@@ -127,7 +144,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
127
144
  it("CURSOR_AGENT_COMMAND 优先取 config.cursor.path", () => {
128
145
  applyLoadedConfig({
129
146
  ...structuredClone(baseAppConfig),
130
- cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
147
+ cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
131
148
  });
132
149
 
133
150
  expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
@@ -145,14 +162,14 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
145
162
  });
146
163
  });
147
164
 
148
- describe("applyLoadedConfig — config 对象引用契约", () => {
165
+ describe("applyLoadedConfig — config 对象引用契约", () => {
149
166
  it("config 引用保持不变(就地更新),但字段被刷新", () => {
150
167
  const refBefore = config;
151
168
 
152
169
  applyLoadedConfig({
153
170
  ...structuredClone(baseAppConfig),
154
171
  feishu: { appId: "REF_TEST_APP", appSecret: "REF_TEST_SECRET" },
155
- codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", effort: "low" },
172
+ codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", effort: "low" },
156
173
  });
157
174
 
158
175
  // 必须是同一个引用:codex-adapter 等下游模块"直接 import config",
@@ -170,24 +187,24 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
170
187
  expect(APP_ID).toBe("");
171
188
  expect(APP_SECRET).toBe("");
172
189
  expect(config.feishu.appId).toBe("");
173
- });
174
- });
175
-
176
- describe("resolveDefaultAgentTool", () => {
177
- it("优先使用显式 defaultAgent 且已启用的 Agent", () => {
178
- const cfg = structuredClone(baseAppConfig);
179
- cfg.claude.defaultAgent = false;
180
- cfg.cursor.defaultAgent = true;
181
-
182
- expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
183
- });
184
-
185
- it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
186
- const cfg = structuredClone(baseAppConfig);
187
- cfg.claude.enabled = false;
188
- cfg.claude.defaultAgent = true;
189
- cfg.cursor.defaultAgent = false;
190
-
191
- expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
192
- });
193
- });
190
+ });
191
+ });
192
+
193
+ describe("resolveDefaultAgentTool", () => {
194
+ it("优先使用显式 defaultAgent 且已启用的 Agent", () => {
195
+ const cfg = structuredClone(baseAppConfig);
196
+ cfg.claude.defaultAgent = false;
197
+ cfg.cursor.defaultAgent = true;
198
+
199
+ expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
200
+ });
201
+
202
+ it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
203
+ const cfg = structuredClone(baseAppConfig);
204
+ cfg.claude.enabled = false;
205
+ cfg.claude.defaultAgent = true;
206
+ cfg.cursor.defaultAgent = false;
207
+
208
+ expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
209
+ });
210
+ });
@@ -0,0 +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,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
+ });
@@ -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) => ({
@@ -38,6 +38,10 @@ import {
38
38
  _resetSessionToolsFileForTest,
39
39
  _setAdapterForToolForTest,
40
40
  _clearAdapterCacheForTest,
41
+ setSessionPlatform,
42
+ recordChatPlatform,
43
+ _getPlatformForChatForTest,
44
+ ensureDisplayLoop,
41
45
  } from "../session.ts";
42
46
  import {
43
47
  activePrompts,
@@ -52,6 +56,7 @@ import {
52
56
  } from "../session-chat-binding.ts";
53
57
  import type { AccumulatorState } from "../session.ts";
54
58
  import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
59
+ import type { PlatformAdapter } from "../platform-adapter.ts";
55
60
 
56
61
  // Helper to create a mock active session entry
57
62
  function mockActiveSession(chatId: string, overrides: Partial<{
@@ -116,6 +121,23 @@ function mockAdapter(getInfo: (sid: string) => SessionInfo | undefined): ToolAda
116
121
  };
117
122
  }
118
123
 
124
+ function mockPlatform(name: string): PlatformAdapter {
125
+ return {
126
+ sendText: vi.fn(async () => true),
127
+ sendCard: vi.fn(async () => true),
128
+ sendRawCard: vi.fn(async () => true),
129
+ createGroup: vi.fn(async () => `${name}-group`),
130
+ updateChatInfo: vi.fn(async () => {}),
131
+ getChatInfo: vi.fn(async () => ({ name, description: "" })),
132
+ disbandChat: vi.fn(async () => {}),
133
+ setChatAvatar: vi.fn(async () => {}),
134
+ extractSessionInfo: vi.fn(() => null),
135
+ cardCreate: vi.fn(async () => `${name}-card`),
136
+ cardSend: vi.fn(async () => `${name}-message`),
137
+ cardUpdate: vi.fn(async () => {}),
138
+ };
139
+ }
140
+
119
141
  describe("resetState", () => {
120
142
  it("clears all maps and sets", () => {
121
143
  chatSessionMap.set("chat1", {
@@ -137,6 +159,23 @@ describe("resetState", () => {
137
159
  });
138
160
  });
139
161
 
162
+ describe("chat platform routing", () => {
163
+ beforeEach(() => {
164
+ resetState();
165
+ });
166
+
167
+ it("uses the platform recorded for the chat before falling back to the default platform", () => {
168
+ const feishu = mockPlatform("feishu");
169
+ const wechat = mockPlatform("wechat");
170
+
171
+ setSessionPlatform(feishu);
172
+ recordChatPlatform("wx-chat", wechat);
173
+
174
+ expect(_getPlatformForChatForTest("wx-chat")).toBe(wechat);
175
+ expect(_getPlatformForChatForTest("feishu-chat")).toBe(feishu);
176
+ });
177
+ });
178
+
140
179
  // ---------------------------------------------------------------------------
141
180
  // rebuildBindingsFromRegistry — SDK 重连/启动时只重建只读映射,不动运行时状态
142
181
  //
@@ -145,6 +184,60 @@ describe("resetState", () => {
145
184
  // processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
146
185
  // ---------------------------------------------------------------------------
147
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
+
148
241
  describe("rebuildBindingsFromRegistry", () => {
149
242
  let registryFile = "";
150
243