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
@@ -1,134 +1,134 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import { mkdtemp, rm, readFile, readdir, writeFile } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
-
6
- // 在 import stream-state 之前先 mock config,让 USER_DATA_DIR 指向临时目录
7
- const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-stream-state-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
- const {
18
- writeStreamState,
19
- readStreamState,
20
- createEmptyStreamState,
21
- STREAMS_DIR,
22
- _setRenameForTest,
23
- _resetRenameForTest,
24
- } = await import("../stream-state.ts");
25
-
26
- describe("writeStreamState — atomic rename", () => {
27
- beforeEach(async () => {
28
- // 清空测试目录
29
- try {
30
- const entries = await readdir(STREAMS_DIR);
31
- for (const e of entries) await rm(join(STREAMS_DIR, e), { force: true });
32
- } catch { /* dir not yet exist */ }
33
- });
34
-
35
- afterEach(async () => {
36
- vi.restoreAllMocks();
37
- });
38
-
39
- it("writeStreamState 写完后 readStreamState 能读到完整内容", async () => {
40
- const state = createEmptyStreamState("sid-1", "/tmp", "claude", 1);
41
- state.accumulatedContent = "hello";
42
- state.finalReply = "world";
43
-
44
- await writeStreamState(state);
45
-
46
- const got = await readStreamState("sid-1");
47
- expect(got).not.toBeNull();
48
- expect(got!.accumulatedContent).toBe("hello");
49
- expect(got!.finalReply).toBe("world");
50
- });
51
-
52
- it("写入完成后,临时 .tmp 文件不应残留(rename 成功路径)", async () => {
53
- const state = createEmptyStreamState("sid-2", "/tmp", "claude", 1);
54
- await writeStreamState(state);
55
-
56
- const entries = await readdir(STREAMS_DIR);
57
- // 只应有 sid-2.json,没有 sid-2.json.tmp
58
- expect(entries).toContain("sid-2.json");
59
- expect(entries.some((e) => e.endsWith(".tmp"))).toBe(false);
60
- });
61
-
62
- it("覆盖写入时,reader 永远只读到完整 JSON(不读到半截)", async () => {
63
- // 此测试验证:先写一个版本,再写第二个版本,reader 中间读取必然是某个完整版本
64
- const state1 = createEmptyStreamState("sid-3", "/tmp", "claude", 1);
65
- state1.accumulatedContent = "v1";
66
- await writeStreamState(state1);
67
-
68
- // 直接读原始字节,确认是完整 JSON
69
- const raw1 = await readFile(join(STREAMS_DIR, "sid-3.json"), "utf-8");
70
- expect(() => JSON.parse(raw1)).not.toThrow();
71
- expect(JSON.parse(raw1).accumulatedContent).toBe("v1");
72
-
73
- const state2 = createEmptyStreamState("sid-3", "/tmp", "claude", 2);
74
- state2.accumulatedContent = "v2";
75
- await writeStreamState(state2);
76
-
77
- const raw2 = await readFile(join(STREAMS_DIR, "sid-3.json"), "utf-8");
78
- expect(() => JSON.parse(raw2)).not.toThrow();
79
- expect(JSON.parse(raw2).accumulatedContent).toBe("v2");
80
- });
81
-
82
- it("rename 失败时降级为直接覆盖写,不抛错 + 数据仍写入", async () => {
83
- let renameCalls = 0;
84
- _setRenameForTest(async () => {
85
- renameCalls++;
86
- throw Object.assign(new Error("EPERM mocked"), { code: "EPERM" });
87
- });
88
-
89
- try {
90
- const state = createEmptyStreamState("sid-fallback", "/tmp", "claude", 1);
91
- state.accumulatedContent = "fallback-content";
92
-
93
- // 不应抛错
94
- await expect(writeStreamState(state)).resolves.toBeUndefined();
95
-
96
- // 数据仍应写入(降级路径走 writeFile 覆盖)
97
- const got = await readStreamState("sid-fallback");
98
- expect(got).not.toBeNull();
99
- expect(got!.accumulatedContent).toBe("fallback-content");
100
-
101
- expect(renameCalls).toBe(1);
102
- } finally {
103
- _resetRenameForTest();
104
- }
105
- });
106
-
107
- it("readStreamState 对损坏的 JSON 返回 null(reader 兜底契约)", async () => {
108
- // 直接写一段半截 JSON 到目标路径
109
- const filePath = join(STREAMS_DIR, "sid-broken.json");
110
- await writeFile(filePath, '{"sessionId":"sid-broken","accum', "utf-8");
111
-
112
- const got = await readStreamState("sid-broken");
113
- expect(got).toBeNull();
114
- });
115
-
116
- it("readStreamState 对不存在文件返回 null", async () => {
117
- const got = await readStreamState("never-existed");
118
- expect(got).toBeNull();
119
- });
120
- });
121
-
122
- describe("createEmptyStreamState", () => {
123
- it("生成的 state 字段正确且 status=running", () => {
124
- const s = createEmptyStreamState("sid-X", "/cwd", "cursor", 5);
125
- expect(s.sessionId).toBe("sid-X");
126
- expect(s.cwd).toBe("/cwd");
127
- expect(s.tool).toBe("cursor");
128
- expect(s.turnCount).toBe(5);
129
- expect(s.status).toBe("running");
130
- expect(s.accumulatedContent).toBe("");
131
- expect(s.finalReply).toBe("");
132
- expect(s.chunkCount).toBe(0);
133
- });
134
- });
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtemp, rm, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // 在 import stream-state 之前先 mock config,让 USER_DATA_DIR 指向临时目录
7
+ const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-stream-state-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
+ const {
18
+ writeStreamState,
19
+ readStreamState,
20
+ createEmptyStreamState,
21
+ STREAMS_DIR,
22
+ _setRenameForTest,
23
+ _resetRenameForTest,
24
+ } = await import("../stream-state.ts");
25
+
26
+ describe("writeStreamState — atomic rename", () => {
27
+ beforeEach(async () => {
28
+ // 清空测试目录
29
+ try {
30
+ const entries = await readdir(STREAMS_DIR);
31
+ for (const e of entries) await rm(join(STREAMS_DIR, e), { force: true });
32
+ } catch { /* dir not yet exist */ }
33
+ });
34
+
35
+ afterEach(async () => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ it("writeStreamState 写完后 readStreamState 能读到完整内容", async () => {
40
+ const state = createEmptyStreamState("sid-1", "/tmp", "claude", 1);
41
+ state.accumulatedContent = "hello";
42
+ state.finalReply = "world";
43
+
44
+ await writeStreamState(state);
45
+
46
+ const got = await readStreamState("sid-1");
47
+ expect(got).not.toBeNull();
48
+ expect(got!.accumulatedContent).toBe("hello");
49
+ expect(got!.finalReply).toBe("world");
50
+ });
51
+
52
+ it("写入完成后,临时 .tmp 文件不应残留(rename 成功路径)", async () => {
53
+ const state = createEmptyStreamState("sid-2", "/tmp", "claude", 1);
54
+ await writeStreamState(state);
55
+
56
+ const entries = await readdir(STREAMS_DIR);
57
+ // 只应有 sid-2.json,没有 sid-2.json.tmp
58
+ expect(entries).toContain("sid-2.json");
59
+ expect(entries.some((e) => e.endsWith(".tmp"))).toBe(false);
60
+ });
61
+
62
+ it("覆盖写入时,reader 永远只读到完整 JSON(不读到半截)", async () => {
63
+ // 此测试验证:先写一个版本,再写第二个版本,reader 中间读取必然是某个完整版本
64
+ const state1 = createEmptyStreamState("sid-3", "/tmp", "claude", 1);
65
+ state1.accumulatedContent = "v1";
66
+ await writeStreamState(state1);
67
+
68
+ // 直接读原始字节,确认是完整 JSON
69
+ const raw1 = await readFile(join(STREAMS_DIR, "sid-3.json"), "utf-8");
70
+ expect(() => JSON.parse(raw1)).not.toThrow();
71
+ expect(JSON.parse(raw1).accumulatedContent).toBe("v1");
72
+
73
+ const state2 = createEmptyStreamState("sid-3", "/tmp", "claude", 2);
74
+ state2.accumulatedContent = "v2";
75
+ await writeStreamState(state2);
76
+
77
+ const raw2 = await readFile(join(STREAMS_DIR, "sid-3.json"), "utf-8");
78
+ expect(() => JSON.parse(raw2)).not.toThrow();
79
+ expect(JSON.parse(raw2).accumulatedContent).toBe("v2");
80
+ });
81
+
82
+ it("rename 失败时降级为直接覆盖写,不抛错 + 数据仍写入", async () => {
83
+ let renameCalls = 0;
84
+ _setRenameForTest(async () => {
85
+ renameCalls++;
86
+ throw Object.assign(new Error("EPERM mocked"), { code: "EPERM" });
87
+ });
88
+
89
+ try {
90
+ const state = createEmptyStreamState("sid-fallback", "/tmp", "claude", 1);
91
+ state.accumulatedContent = "fallback-content";
92
+
93
+ // 不应抛错
94
+ await expect(writeStreamState(state)).resolves.toBeUndefined();
95
+
96
+ // 数据仍应写入(降级路径走 writeFile 覆盖)
97
+ const got = await readStreamState("sid-fallback");
98
+ expect(got).not.toBeNull();
99
+ expect(got!.accumulatedContent).toBe("fallback-content");
100
+
101
+ expect(renameCalls).toBe(1);
102
+ } finally {
103
+ _resetRenameForTest();
104
+ }
105
+ });
106
+
107
+ it("readStreamState 对损坏的 JSON 返回 null(reader 兜底契约)", async () => {
108
+ // 直接写一段半截 JSON 到目标路径
109
+ const filePath = join(STREAMS_DIR, "sid-broken.json");
110
+ await writeFile(filePath, '{"sessionId":"sid-broken","accum', "utf-8");
111
+
112
+ const got = await readStreamState("sid-broken");
113
+ expect(got).toBeNull();
114
+ });
115
+
116
+ it("readStreamState 对不存在文件返回 null", async () => {
117
+ const got = await readStreamState("never-existed");
118
+ expect(got).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe("createEmptyStreamState", () => {
123
+ it("生成的 state 字段正确且 status=running", () => {
124
+ const s = createEmptyStreamState("sid-X", "/cwd", "cursor", 5);
125
+ expect(s.sessionId).toBe("sid-X");
126
+ expect(s.cwd).toBe("/cwd");
127
+ expect(s.tool).toBe("cursor");
128
+ expect(s.turnCount).toBe(5);
129
+ expect(s.status).toBe("running");
130
+ expect(s.accumulatedContent).toBe("");
131
+ expect(s.finalReply).toBe("");
132
+ expect(s.chunkCount).toBe(0);
133
+ });
134
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { buildHelpCard } from "../cards.ts";
4
+ import { createWechatAdapter } from "../wechat-platform.ts";
5
+
6
+ describe("createWechatAdapter", () => {
7
+ it("degrades raw cards to plain text messages", async () => {
8
+ const wire = {
9
+ push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
10
+ sendText: vi.fn(
11
+ async (_chatId: string, _text: string, _contextToken?: string) =>
12
+ "msg-id",
13
+ ),
14
+ };
15
+ const log = vi.fn();
16
+ const platform = createWechatAdapter({
17
+ getWire: () => wire,
18
+ log,
19
+ });
20
+
21
+ const ok = await platform.sendRawCard("wx-chat-help", buildHelpCard("Hello"));
22
+
23
+ expect(ok).toBe(true);
24
+ expect(wire.push).toHaveBeenCalledTimes(1);
25
+ expect(wire.sendText).not.toHaveBeenCalled();
26
+ const [, text] = wire.push.mock.calls[0];
27
+ expect(text).toContain("# ChatCCC");
28
+ expect(text).toContain("Hello");
29
+ expect(text).toContain("/new");
30
+ expect(log).toHaveBeenCalledWith("[WECHAT] sendRawCard degraded to text");
31
+ });
32
+
33
+ it("reports unsent non-final messages after the claw limit", async () => {
34
+ const wire = {
35
+ push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
36
+ sendText: vi.fn(
37
+ async (_chatId: string, _text: string, _contextToken?: string) =>
38
+ "msg-id",
39
+ ),
40
+ };
41
+ const log = vi.fn();
42
+ const platform = createWechatAdapter({
43
+ getWire: () => wire,
44
+ log,
45
+ });
46
+
47
+ for (let i = 0; i < 10; i++) {
48
+ await expect(platform.sendText("wx-chat-limit", `chunk ${i}`)).resolves.toBe(true);
49
+ }
50
+
51
+ await expect(platform.sendText("wx-chat-limit", "chunk 10")).resolves.toBe(false);
52
+ expect(wire.push).toHaveBeenCalledTimes(10);
53
+ expect(log).toHaveBeenCalledWith(
54
+ "[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=11",
55
+ );
56
+ });
57
+ });
@@ -89,11 +89,32 @@ function buildSdkEnv(
89
89
  if (!apiKeyTrim && !baseUrlTrim) return undefined;
90
90
 
91
91
  const env: Record<string, string | undefined> = { ...process.env };
92
+ // ChatCCC's Claude API config is authoritative when present. Remove Claude
93
+ // Code/user settings env that can silently override gateway/auth/model choice.
94
+ delete env.ANTHROPIC_AUTH_TOKEN;
95
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
96
+ delete env.ANTHROPIC_MODEL;
97
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
98
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
99
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
100
+ delete env.CLAUDE_CODE_SUBAGENT_MODEL;
101
+ delete env.CLAUDE_CODE_EFFORT_LEVEL;
102
+
92
103
  if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
93
104
  if (baseUrlTrim) env.ANTHROPIC_BASE_URL = baseUrlTrim;
105
+ else delete env.ANTHROPIC_BASE_URL;
94
106
  return env;
95
107
  }
96
108
 
109
+ function resolveSettingSources(
110
+ _apiKey: string | undefined,
111
+ _baseUrl: string | undefined,
112
+ ): Array<"project" | "local"> {
113
+ // CLAUDE.md / CLAUDE.local.md 是 Agent 指令文件,与 API 来源无关,
114
+ // 无论使用官方 Anthropic 还是第三方网关都应加载。
115
+ return ["project", "local"];
116
+ }
117
+
97
118
  // ---------------------------------------------------------------------------
98
119
  // buildSessionOptions — 还原 claudeSdkSessionOptions 的精确行为
99
120
  // ---------------------------------------------------------------------------
@@ -111,7 +132,7 @@ function buildSessionOptions(
111
132
  permissionMode: "bypassPermissions",
112
133
  allowDangerouslySkipPermissions: true,
113
134
  autoCompactEnabled: true,
114
- settingSources: ["project", "local"],
135
+ settingSources: resolveSettingSources(apiKey, baseUrl),
115
136
  };
116
137
  if (!isEmpty(model)) o.model = model;
117
138
  if (!isEmpty(effort)) o.effort = effort;
@@ -304,4 +325,4 @@ export function createClaudeAdapter(
304
325
  options: ClaudeAdapterOptions,
305
326
  ): ToolAdapter {
306
327
  return new ClaudeAdapter(options);
307
- }
328
+ }