chatccc 0.2.52 → 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 (39) 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__/session.test.ts +57 -2
  22. package/src/__tests__/sim-agent.test.ts +173 -173
  23. package/src/__tests__/sim-platform.test.ts +76 -76
  24. package/src/__tests__/sim-store.test.ts +213 -213
  25. package/src/__tests__/stream-state.test.ts +134 -134
  26. package/src/__tests__/wechat-platform.test.ts +57 -32
  27. package/src/adapters/codex-adapter.ts +294 -294
  28. package/src/adapters/codex-session-meta-store.ts +130 -130
  29. package/src/agent-file-rpc.ts +156 -156
  30. package/src/agent-image-rpc.ts +152 -152
  31. package/src/agent-rpc-body.ts +48 -48
  32. package/src/card-plain-text.ts +108 -108
  33. package/src/im-skills.ts +109 -109
  34. package/src/platform-adapter.ts +60 -60
  35. package/src/session-chat-binding.ts +6 -1
  36. package/src/session.ts +40 -35
  37. package/src/sim-agent.ts +167 -167
  38. package/src/trace.ts +50 -50
  39. package/src/wechat-platform.ts +1 -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
+ });
@@ -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