chatccc 0.2.226 → 0.2.227

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 (64) hide show
  1. package/.agents/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  2. package/.claude/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  3. package/.cursor/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  4. package/README.md +90 -90
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-activity.test.ts +76 -76
  7. package/src/__tests__/builtin-chat-session.test.ts +350 -350
  8. package/src/__tests__/builtin-config.test.ts +26 -26
  9. package/src/__tests__/builtin-context.test.ts +163 -163
  10. package/src/__tests__/builtin-file-tools.test.ts +275 -275
  11. package/src/__tests__/builtin-permissions.test.ts +211 -211
  12. package/src/__tests__/builtin-session-select.test.ts +116 -116
  13. package/src/__tests__/builtin-skills.test.ts +185 -74
  14. package/src/__tests__/card-action-routing.test.ts +18 -18
  15. package/src/__tests__/ccc-adapter.test.ts +136 -136
  16. package/src/__tests__/claude-adapter.test.ts +614 -614
  17. package/src/__tests__/codex-adapter.test.ts +58 -58
  18. package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
  19. package/src/__tests__/cursor-adapter.test.ts +268 -268
  20. package/src/__tests__/feishu-avatar.test.ts +164 -164
  21. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  22. package/src/__tests__/package-files.test.ts +24 -24
  23. package/src/__tests__/progress-reducer.test.ts +110 -110
  24. package/src/__tests__/response-stall.test.ts +49 -49
  25. package/src/__tests__/sim-platform.test.ts +16 -16
  26. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  27. package/src/__tests__/stop-session.test.ts +34 -34
  28. package/src/__tests__/terminal-renderer.test.ts +247 -247
  29. package/src/__tests__/update-command-guard.test.ts +144 -144
  30. package/src/__tests__/web-ui.test.ts +326 -326
  31. package/src/adapters/adapter-interface.ts +18 -18
  32. package/src/adapters/ccc-adapter.ts +131 -131
  33. package/src/adapters/claude-adapter.ts +620 -620
  34. package/src/adapters/codex-adapter.ts +426 -426
  35. package/src/adapters/cursor-adapter.ts +681 -681
  36. package/src/agent-activity.ts +170 -170
  37. package/src/agent-delegate-task.ts +91 -91
  38. package/src/builtin/cli.ts +61 -2
  39. package/src/builtin/config.ts +84 -84
  40. package/src/builtin/context.ts +323 -323
  41. package/src/builtin/file-log.ts +38 -38
  42. package/src/builtin/index.ts +44 -24
  43. package/src/builtin/proc-tree-kill.ts +61 -61
  44. package/src/builtin/progress/cards-helpers.ts +76 -76
  45. package/src/builtin/progress/reducer.ts +108 -108
  46. package/src/builtin/progress/terminal-renderer.ts +294 -294
  47. package/src/builtin/progress/view.ts +77 -77
  48. package/src/builtin/raw-stream-log.ts +124 -124
  49. package/src/builtin/session-select.ts +48 -48
  50. package/src/builtin/skills.ts +126 -44
  51. package/src/card-action-routing.ts +14 -14
  52. package/src/feishu-api.ts +193 -193
  53. package/src/feishu-message-ingress.ts +195 -195
  54. package/src/index.ts +306 -306
  55. package/src/orchestrator.ts +2388 -2388
  56. package/src/platform-adapter.ts +6 -6
  57. package/src/progress/reducer.ts +108 -108
  58. package/src/progress/terminal-renderer.ts +294 -294
  59. package/src/progress/view.ts +77 -77
  60. package/src/response-stall.ts +28 -28
  61. package/src/session-chat-binding.ts +82 -82
  62. package/src/startup-lifecycle.ts +250 -250
  63. package/src/stream-state.ts +18 -18
  64. package/src/update-command-guard.ts +165 -165
@@ -1,40 +1,60 @@
1
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
 
5
- import { afterEach, describe, expect, it } from "vitest";
6
-
7
6
  import {
8
- buildSkillsIndexPrompt,
9
7
  parseSkillFrontmatter,
10
8
  scanSkillsDirs,
9
+ buildDefaultSkillDirs,
10
+ buildSkillsIndexPrompt,
11
+ buildSkillTemplate,
12
+ type SkillDirSpec,
13
+ type SkillSource,
14
+ type SkillScope,
11
15
  } from "../builtin/skills.ts";
12
16
 
13
- const tempDirs: string[] = [];
17
+ let tempRoot: string;
14
18
 
15
- async function makeTempDir(): Promise<string> {
16
- const dir = await mkdtemp(join(tmpdir(), "chatccc-skills-"));
17
- tempDirs.push(dir);
18
- return dir;
19
- }
19
+ beforeEach(() => {
20
+ tempRoot = mkdtempSync(join(tmpdir(), "deepccc-skills-"));
21
+ });
20
22
 
21
- afterEach(async () => {
22
- await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
23
+ afterEach(() => {
24
+ try {
25
+ rmSync(tempRoot, { recursive: true, force: true });
26
+ } catch {}
23
27
  });
24
28
 
29
+ function makeSkill(specDir: string, name: string, description: string): string {
30
+ const dir = join(specDir, name);
31
+ mkdirSync(dir, { recursive: true });
32
+ const skillPath = join(dir, "SKILL.md");
33
+ writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\n\nbody\n`, "utf8");
34
+ return skillPath;
35
+ }
36
+
37
+ function spec(
38
+ dir: string,
39
+ source: SkillSource,
40
+ scope: SkillScope = "global",
41
+ ): SkillDirSpec {
42
+ return { dir, source, scope };
43
+ }
44
+
25
45
  describe("parseSkillFrontmatter", () => {
26
46
  it("parses name and description from frontmatter", () => {
27
47
  const content = [
28
48
  "---",
29
49
  "name: feishu-doc-download-md",
30
- "description: 下载飞书文档为 Markdown",
50
+ "description: 涓嬭浇椋炰功鏂囨。涓?Markdown",
31
51
  "---",
32
52
  "",
33
- "# 正文",
53
+ "# 姝f枃",
34
54
  ].join("\n");
35
55
  expect(parseSkillFrontmatter(content)).toEqual({
36
56
  name: "feishu-doc-download-md",
37
- description: "下载飞书文档为 Markdown",
57
+ description: "涓嬭浇椋炰功鏂囨。涓?Markdown",
38
58
  });
39
59
  });
40
60
 
@@ -50,88 +70,164 @@ describe("parseSkillFrontmatter", () => {
50
70
  });
51
71
 
52
72
  it("handles CRLF line endings", () => {
53
- const content = "---\r\nname: crlf-skill\r\ndescription: CRLF 描述\r\n---\r\n\r\nbody";
73
+ const content = "---\r\nname: crlf-skill\r\ndescription: CRLF 鎻忚堪\r\n---\r\n\r\nbody";
54
74
  expect(parseSkillFrontmatter(content)).toEqual({
55
75
  name: "crlf-skill",
56
- description: "CRLF 描述",
76
+ description: "CRLF 鎻忚堪",
57
77
  });
58
78
  });
59
79
  });
60
80
 
61
- describe("scanSkillsDirs", () => {
62
- it("scans multiple dirs, skips hidden/system dirs, dedupes by name (later dir wins)", async () => {
63
- const dir = await makeTempDir();
64
- const userA = join(dir, "codex-skills");
65
- const userB = join(dir, "agents-skills");
66
- const project = join(dir, "project-codex");
67
- await mkdir(join(userA, "feishu-doc"), { recursive: true });
68
- await mkdir(join(userA, ".system"), { recursive: true });
69
- await mkdir(join(userA, "no-skill-dir"));
70
- await mkdir(join(userB, "feishu-doc"), { recursive: true });
71
- await mkdir(join(userB, "another"), { recursive: true });
72
- await mkdir(join(project, "feishu-doc"), { recursive: true });
73
-
74
- await writeFile(
75
- join(userA, "feishu-doc", "SKILL.md"),
76
- "---\nname: feishu-doc\ndescription: 用户级 A\n---\n",
77
- "utf8",
78
- );
79
- await writeFile(
80
- join(userA, ".system", "SKILL.md"),
81
- "---\nname: system-skill\ndescription: 内置\n---\n",
82
- "utf8",
83
- );
84
- await writeFile(
85
- join(userB, "feishu-doc", "SKILL.md"),
86
- "---\nname: feishu-doc\ndescription: 用户级 B\n---\n",
87
- "utf8",
88
- );
89
- await writeFile(
90
- join(userB, "another", "SKILL.md"),
91
- "---\nname: another\ndescription: 另一个\n---\n",
92
- "utf8",
93
- );
94
- await writeFile(
95
- join(project, "feishu-doc", "SKILL.md"),
96
- "---\nname: feishu-doc\ndescription: 项目级覆盖\n---\n",
97
- "utf8",
98
- );
99
-
100
- const skills = scanSkillsDirs([userA, userB, project]);
101
-
102
- // 去重:同名保留一个,后面的目录(项目级)覆盖前面的(用户级)
103
- expect(skills).toHaveLength(2);
81
+ describe("scanSkillsDirs priority matrix", () => {
82
+ it("codex wins over cursor, cursor wins over claude for same-name skills", async () => {
83
+ const claudeDir = join(tempRoot, "claude");
84
+ const cursorDir = join(tempRoot, "cursor");
85
+ const codexDir = join(tempRoot, "codex");
86
+ makeSkill(claudeDir, "dupe", "claude version");
87
+ makeSkill(cursorDir, "dupe", "cursor version");
88
+ makeSkill(codexDir, "dupe", "codex version");
89
+ makeSkill(codexDir, "only-codex", "codex only");
90
+
91
+ const skills = await scanSkillsDirs([
92
+ spec(claudeDir, "claude"),
93
+ spec(cursorDir, "cursor"),
94
+ spec(codexDir, "codex"),
95
+ ]);
96
+
97
+ const byName = new Map(skills.map((s) => [s.name, s]));
98
+ expect(byName.get("dupe")?.description).toBe("codex version");
99
+ expect(byName.get("dupe")?.source).toBe("codex");
100
+ expect(byName.get("dupe")?.skillPath).toContain(join("codex", "dupe"));
101
+ expect(byName.get("only-codex")?.source).toBe("codex");
102
+ });
103
+
104
+ it("project scope wins over global scope within the same source", async () => {
105
+ const globalCodex = join(tempRoot, "codex-global");
106
+ const projectCodex = join(tempRoot, "codex-project");
107
+ makeSkill(globalCodex, "dup", "global version");
108
+ makeSkill(projectCodex, "dup", "project version");
109
+
110
+ const skills = await scanSkillsDirs([
111
+ spec(globalCodex, "codex", "global"),
112
+ spec(projectCodex, "codex", "project"),
113
+ ]);
114
+
104
115
  const byName = new Map(skills.map((s) => [s.name, s]));
105
- expect(byName.get("feishu-doc")?.description).toBe("项目级覆盖");
106
- expect(byName.get("feishu-doc")?.skillPath).toBe(join(project, "feishu-doc", "SKILL.md"));
107
- expect(byName.get("another")?.description).toBe("另一个");
108
- expect(byName.has("system-skill")).toBe(false); // 隐藏目录被排除
116
+ expect(byName.get("dup")?.description).toBe("project version");
117
+ expect(byName.get("dup")?.scope).toBe("project");
118
+ });
119
+
120
+ it("deepccc source has the highest priority over codex", async () => {
121
+ const codexDir = join(tempRoot, "codex");
122
+ const deepcccDir = join(tempRoot, "deepccc");
123
+ makeSkill(codexDir, "dup", "from codex");
124
+ makeSkill(deepcccDir, "dup", "from deepccc");
125
+
126
+ const skills = await scanSkillsDirs([
127
+ spec(codexDir, "codex"),
128
+ spec(deepcccDir, "deepccc"),
129
+ ]);
130
+
131
+ expect(skills.find((s) => s.name === "dup")?.description).toBe("from deepccc");
132
+ expect(skills.find((s) => s.name === "dup")?.source).toBe("deepccc");
133
+ });
134
+
135
+ it("skips hidden dirs, dirs without SKILL.md, and missing dirs", async () => {
136
+ const dir = join(tempRoot, "src");
137
+ mkdirSync(join(dir, ".system"), { recursive: true });
138
+ writeFileSync(join(dir, ".system", "SKILL.md"), "---\nname: system-skill\ndescription: x\n---\n", "utf8");
139
+ mkdirSync(join(dir, "no-skill-dir"));
140
+
141
+ makeSkill(dir, "ok", "fine");
142
+ const skills = await scanSkillsDirs([
143
+ spec(join(tempRoot, "missing-dir"), "codex"),
144
+ spec(dir, "codex"),
145
+ ]);
146
+
147
+ expect(skills).toHaveLength(1);
148
+ expect(skills[0].name).toBe("ok");
149
+ });
150
+ });
151
+
152
+ describe("scanSkillsDirs hot reload (mtime cache)", () => {
153
+ it("re-reads a skill after its SKILL.md content changes", async () => {
154
+ const dir = join(tempRoot, "src");
155
+ const skillPath = makeSkill(dir, "live", "v1");
156
+
157
+ const first = await scanSkillsDirs([spec(dir, "deepccc")]);
158
+ expect(first.find((s) => s.name === "live")?.description).toBe("v1");
159
+
160
+ writeFileSync(skillPath, "---\nname: live\ndescription: v2\n---\n\nbody\n", "utf8");
161
+ utimesSync(skillPath, new Date(Date.now() + 3000), new Date(Date.now() + 3000)); // 寮哄埗 mtime 鍓嶈繘
162
+
163
+ const second = await scanSkillsDirs([spec(dir, "deepccc")]);
164
+ expect(second.find((s) => s.name === "live")?.description).toBe("v2");
165
+ });
166
+
167
+ it("new skill dirs are picked up on the next scan", async () => {
168
+ const dir = join(tempRoot, "src");
169
+ makeSkill(dir, "a", "A");
170
+
171
+ const first = await scanSkillsDirs([spec(dir, "deepccc")]);
172
+ expect(first).toHaveLength(1);
173
+
174
+ makeSkill(dir, "b", "B"); // 鏂版妧鑳斤紝鏃犻渶鏀?mtime锛堢洰褰曟灇涓炬瘡娆¢兘鍋氾級
175
+ const second = await scanSkillsDirs([spec(dir, "deepccc")]);
176
+ expect(second.map((s) => s.name).sort()).toEqual(["a", "b"]);
109
177
  });
110
178
 
111
- it("skips missing directories and dirs without SKILL.md", async () => {
112
- const dir = await makeTempDir();
113
- await mkdir(join(dir, "empty-dir"));
179
+ it("unchanged skills return identical results across scans", async () => {
180
+ const dir = join(tempRoot, "src");
181
+ makeSkill(dir, "a", "A");
182
+
183
+ const first = await scanSkillsDirs([spec(dir, "deepccc")]);
184
+ const second = await scanSkillsDirs([spec(dir, "deepccc")]);
185
+ expect(second).toEqual(first);
186
+ });
187
+ });
114
188
 
115
- const skills = scanSkillsDirs([join(dir, "missing-dir"), join(dir, "empty-dir")]);
189
+ describe("buildDefaultSkillDirs", () => {
190
+ it("orders dirs low->high priority: claude < cursor < codex < deepccc, project after global", () => {
191
+ const dirs = buildDefaultSkillDirs("C:/proj");
192
+ expect(dirs.map((d) => `${d.source}:${d.scope}`)).toEqual([
193
+ "claude:global",
194
+ "claude:project",
195
+ "cursor:global",
196
+ "cursor:project",
197
+ "codex:global",
198
+ "codex:global",
199
+ "codex:project",
200
+ "deepccc:global",
201
+ "deepccc:project",
202
+ ]);
203
+ });
116
204
 
117
- expect(skills).toEqual([]);
205
+ it("points codex global dirs at ~/.codex/skills and ~/.agents/skills", () => {
206
+ const dirs = buildDefaultSkillDirs("C:/proj").filter((d) => d.source === "codex" && d.scope === "global");
207
+ expect(dirs.map((d) => d.dir)).toEqual([
208
+ join(require("node:os").homedir(), ".codex", "skills"),
209
+ join(require("node:os").homedir(), ".agents", "skills"),
210
+ ]);
118
211
  });
119
212
  });
120
213
 
121
214
  describe("buildSkillsIndexPrompt", () => {
122
- it("renders a skill index with names, paths and descriptions", () => {
215
+ it("renders index with source markers and the skill creation convention", () => {
123
216
  const prompt = buildSkillsIndexPrompt([
124
217
  {
125
218
  name: "feishu-doc",
126
- description: "下载飞书文档",
127
- skillPath: "C:\\users\\x\\skills\\feishu-doc\\SKILL.md",
219
+ description: "涓嬭浇椋炰功鏂囨。",
220
+ skillPath: "C:/x/feishu-doc/SKILL.md",
221
+ source: "codex",
222
+ scope: "global",
128
223
  },
129
224
  ]);
130
225
 
131
226
  expect(prompt).toContain("## Available Skills");
132
227
  expect(prompt).toContain("**feishu-doc**");
133
- expect(prompt).toContain("下载飞书文档");
134
- expect(prompt).toContain("C:\\users\\x\\skills\\feishu-doc\\SKILL.md");
228
+ expect(prompt).toContain("[codex:global]");
229
+ expect(prompt).toContain("## Creating Skills");
230
+ expect(prompt).toContain(".deepccc/skills");
135
231
  expect(prompt).toContain("read_file");
136
232
  });
137
233
 
@@ -139,3 +235,18 @@ describe("buildSkillsIndexPrompt", () => {
139
235
  expect(buildSkillsIndexPrompt([])).toBe("");
140
236
  });
141
237
  });
238
+
239
+ describe("buildSkillTemplate", () => {
240
+ it("renders a Codex-style SKILL.md with frontmatter", () => {
241
+ const tpl = buildSkillTemplate("my-skill", "does something");
242
+ expect(tpl.startsWith("---")).toBe(true);
243
+ expect(tpl).toContain("name: my-skill");
244
+ expect(tpl).toContain("description: does something");
245
+ expect(tpl).toContain("# my-skill");
246
+ });
247
+
248
+ it("defaults description to empty when omitted", () => {
249
+ const tpl = buildSkillTemplate("bare-skill", "");
250
+ expect(tpl).toContain("description: ");
251
+ });
252
+ });
@@ -1,18 +1,18 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { resolveFeishuCardActionChatType } from "../card-action-routing.ts";
4
-
5
- describe("resolveFeishuCardActionChatType", () => {
6
- it("keeps card commands in a persisted private chat on the p2p route", () => {
7
- expect(resolveFeishuCardActionChatType("private-chat", {
8
- "private-chat": { chatType: "p2p" },
9
- })).toBe("p2p");
10
- });
11
-
12
- it("defaults unknown and group chats to the group route", () => {
13
- expect(resolveFeishuCardActionChatType("group-chat", {
14
- "group-chat": { chatType: "group" },
15
- })).toBe("group");
16
- expect(resolveFeishuCardActionChatType("unknown-chat", {})).toBe("group");
17
- });
18
- });
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveFeishuCardActionChatType } from "../card-action-routing.ts";
4
+
5
+ describe("resolveFeishuCardActionChatType", () => {
6
+ it("keeps card commands in a persisted private chat on the p2p route", () => {
7
+ expect(resolveFeishuCardActionChatType("private-chat", {
8
+ "private-chat": { chatType: "p2p" },
9
+ })).toBe("p2p");
10
+ });
11
+
12
+ it("defaults unknown and group chats to the group route", () => {
13
+ expect(resolveFeishuCardActionChatType("group-chat", {
14
+ "group-chat": { chatType: "group" },
15
+ })).toBe("group");
16
+ expect(resolveFeishuCardActionChatType("unknown-chat", {})).toBe("group");
17
+ });
18
+ });
@@ -1,136 +1,136 @@
1
- import { mkdtemp } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- import { afterEach, describe, expect, it, vi } from "vitest";
6
-
7
- const streamTextMock = vi.fn();
8
- const generateTextMock = vi.fn();
9
-
10
- vi.mock("@ai-sdk/openai-compatible", () => ({
11
- createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
12
- }));
13
-
14
- vi.mock("ai", () => ({
15
- streamText: streamTextMock,
16
- generateText: generateTextMock,
17
- isLoopFinished: vi.fn(() => ({ loopFinished: true })),
18
- stepCountIs: vi.fn((count: number) => ({ count })),
19
- jsonSchema: vi.fn((schema: unknown) => schema),
20
- tool: vi.fn((definition: unknown) => definition),
21
- }));
22
-
23
- async function* textStream(...chunks: string[]): AsyncIterable<string> {
24
- for (const chunk of chunks) yield chunk;
25
- }
26
-
27
- async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
28
- for (const part of parts) yield part;
29
- }
30
-
31
- afterEach(() => {
32
- streamTextMock.mockReset();
33
- generateTextMock.mockReset();
34
- });
35
-
36
- describe("createCccAdapter", () => {
37
- it("creates a persisted ccc session and exposes model/cwd metadata", async () => {
38
- const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
39
- const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-meta-"));
40
- const adapter = createCccAdapter({
41
- apiKey: "sk-test",
42
- contextDir,
43
- model: "deepseek-v4-pro",
44
- });
45
-
46
- const created = await adapter.createSession("F:\\repo");
47
- const info = await adapter.getSessionInfo(created.sessionId);
48
-
49
- expect(created.sessionId).toMatch(/^session-\d{8}-\d{6}-[a-f0-9]{6}$/);
50
- expect(info).toEqual(expect.objectContaining({
51
- sessionId: created.sessionId,
52
- cwd: "F:\\repo",
53
- model: "deepseek-v4-pro",
54
- }));
55
- });
56
-
57
- it("maps ChatSession text chunks to unified assistant text blocks", async () => {
58
- const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
59
- const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-stream-"));
60
- const adapter = createCccAdapter({
61
- apiKey: "sk-test",
62
- contextDir,
63
- model: "deepseek-v4-flash",
64
- });
65
- const { sessionId } = await adapter.createSession("F:\\repo");
66
- streamTextMock.mockReturnValueOnce({ textStream: textStream("hello", " world") });
67
-
68
- const messages = [];
69
- for await (const message of adapter.prompt(sessionId, "hi", "F:\\repo")) {
70
- messages.push(message);
71
- }
72
-
73
- expect(messages).toEqual([
74
- { type: "assistant", blocks: [{ type: "text", text: "hello" }] },
75
- { type: "assistant", blocks: [{ type: "text", text: " world" }] },
76
- { type: "assistant", blocks: [], isFinalResponse: true },
77
- ]);
78
- expect(streamTextMock).toHaveBeenCalledWith(expect.objectContaining({
79
- model: { modelId: "deepseek-v4-flash" },
80
- }));
81
- });
82
-
83
- it("maps ChatSession tool events to unified tool blocks", async () => {
84
- const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
85
- const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-tools-"));
86
- const adapter = createCccAdapter({
87
- apiKey: "sk-test",
88
- contextDir,
89
- model: "deepseek-v4-flash",
90
- });
91
- const { sessionId } = await adapter.createSession("F:\\repo");
92
- streamTextMock.mockReturnValueOnce({
93
- fullStream: fullStream(
94
- { type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "README.md" } },
95
- { type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "hello" } },
96
- ),
97
- });
98
-
99
- const messages = [];
100
- for await (const message of adapter.prompt(sessionId, "read", "F:\\repo")) {
101
- messages.push(message);
102
- }
103
-
104
- expect(messages).toEqual([
105
- {
106
- type: "assistant",
107
- blocks: [{ type: "tool_use", id: "call-1", name: "read_file", input: { path: "README.md" } }],
108
- },
109
- {
110
- type: "assistant",
111
- blocks: [{ type: "tool_result", tool_use_id: "call-1", content: { content: "hello" }, is_error: false }],
112
- },
113
- { type: "assistant", blocks: [], isFinalResponse: true },
114
- ]);
115
- });
116
-
117
- it("passes effort into ChatSession so streamText receives reasoningEffort", async () => {
118
- const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
119
- const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-effort-"));
120
- const adapter = createCccAdapter({
121
- apiKey: "sk-test",
122
- contextDir,
123
- effort: "xhigh",
124
- });
125
- const { sessionId } = await adapter.createSession("F:\\repo");
126
- streamTextMock.mockReturnValueOnce({ textStream: textStream("ok") });
127
-
128
- for await (const _message of adapter.prompt(sessionId, "hi", "F:\\repo")) {
129
- // drain
130
- }
131
-
132
- expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
133
- providerOptions: { deepseek: { reasoningEffort: "xhigh" } },
134
- }));
135
- });
136
- });
1
+ import { mkdtemp } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const streamTextMock = vi.fn();
8
+ const generateTextMock = vi.fn();
9
+
10
+ vi.mock("@ai-sdk/openai-compatible", () => ({
11
+ createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
12
+ }));
13
+
14
+ vi.mock("ai", () => ({
15
+ streamText: streamTextMock,
16
+ generateText: generateTextMock,
17
+ isLoopFinished: vi.fn(() => ({ loopFinished: true })),
18
+ stepCountIs: vi.fn((count: number) => ({ count })),
19
+ jsonSchema: vi.fn((schema: unknown) => schema),
20
+ tool: vi.fn((definition: unknown) => definition),
21
+ }));
22
+
23
+ async function* textStream(...chunks: string[]): AsyncIterable<string> {
24
+ for (const chunk of chunks) yield chunk;
25
+ }
26
+
27
+ async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
28
+ for (const part of parts) yield part;
29
+ }
30
+
31
+ afterEach(() => {
32
+ streamTextMock.mockReset();
33
+ generateTextMock.mockReset();
34
+ });
35
+
36
+ describe("createCccAdapter", () => {
37
+ it("creates a persisted ccc session and exposes model/cwd metadata", async () => {
38
+ const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
39
+ const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-meta-"));
40
+ const adapter = createCccAdapter({
41
+ apiKey: "sk-test",
42
+ contextDir,
43
+ model: "deepseek-v4-pro",
44
+ });
45
+
46
+ const created = await adapter.createSession("F:\\repo");
47
+ const info = await adapter.getSessionInfo(created.sessionId);
48
+
49
+ expect(created.sessionId).toMatch(/^session-\d{8}-\d{6}-[a-f0-9]{6}$/);
50
+ expect(info).toEqual(expect.objectContaining({
51
+ sessionId: created.sessionId,
52
+ cwd: "F:\\repo",
53
+ model: "deepseek-v4-pro",
54
+ }));
55
+ });
56
+
57
+ it("maps ChatSession text chunks to unified assistant text blocks", async () => {
58
+ const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
59
+ const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-stream-"));
60
+ const adapter = createCccAdapter({
61
+ apiKey: "sk-test",
62
+ contextDir,
63
+ model: "deepseek-v4-flash",
64
+ });
65
+ const { sessionId } = await adapter.createSession("F:\\repo");
66
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("hello", " world") });
67
+
68
+ const messages = [];
69
+ for await (const message of adapter.prompt(sessionId, "hi", "F:\\repo")) {
70
+ messages.push(message);
71
+ }
72
+
73
+ expect(messages).toEqual([
74
+ { type: "assistant", blocks: [{ type: "text", text: "hello" }] },
75
+ { type: "assistant", blocks: [{ type: "text", text: " world" }] },
76
+ { type: "assistant", blocks: [], isFinalResponse: true },
77
+ ]);
78
+ expect(streamTextMock).toHaveBeenCalledWith(expect.objectContaining({
79
+ model: { modelId: "deepseek-v4-flash" },
80
+ }));
81
+ });
82
+
83
+ it("maps ChatSession tool events to unified tool blocks", async () => {
84
+ const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
85
+ const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-tools-"));
86
+ const adapter = createCccAdapter({
87
+ apiKey: "sk-test",
88
+ contextDir,
89
+ model: "deepseek-v4-flash",
90
+ });
91
+ const { sessionId } = await adapter.createSession("F:\\repo");
92
+ streamTextMock.mockReturnValueOnce({
93
+ fullStream: fullStream(
94
+ { type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "README.md" } },
95
+ { type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "hello" } },
96
+ ),
97
+ });
98
+
99
+ const messages = [];
100
+ for await (const message of adapter.prompt(sessionId, "read", "F:\\repo")) {
101
+ messages.push(message);
102
+ }
103
+
104
+ expect(messages).toEqual([
105
+ {
106
+ type: "assistant",
107
+ blocks: [{ type: "tool_use", id: "call-1", name: "read_file", input: { path: "README.md" } }],
108
+ },
109
+ {
110
+ type: "assistant",
111
+ blocks: [{ type: "tool_result", tool_use_id: "call-1", content: { content: "hello" }, is_error: false }],
112
+ },
113
+ { type: "assistant", blocks: [], isFinalResponse: true },
114
+ ]);
115
+ });
116
+
117
+ it("passes effort into ChatSession so streamText receives reasoningEffort", async () => {
118
+ const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
119
+ const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-effort-"));
120
+ const adapter = createCccAdapter({
121
+ apiKey: "sk-test",
122
+ contextDir,
123
+ effort: "xhigh",
124
+ });
125
+ const { sessionId } = await adapter.createSession("F:\\repo");
126
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("ok") });
127
+
128
+ for await (const _message of adapter.prompt(sessionId, "hi", "F:\\repo")) {
129
+ // drain
130
+ }
131
+
132
+ expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
133
+ providerOptions: { deepseek: { reasoningEffort: "xhigh" } },
134
+ }));
135
+ });
136
+ });