chatccc 0.2.194 → 0.2.195

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.
@@ -28,13 +28,19 @@
28
28
  "retentionDays": 7,
29
29
  "keepCompleted": false
30
30
  },
31
- "codex": {
32
- "enabled": false,
33
- "maxBytesPerTurn": 52428800,
34
- "retentionDays": 7,
35
- "keepCompleted": false
36
- }
37
- },
31
+ "codex": {
32
+ "enabled": false,
33
+ "maxBytesPerTurn": 52428800,
34
+ "retentionDays": 7,
35
+ "keepCompleted": false
36
+ },
37
+ "ccc": {
38
+ "enabled": false,
39
+ "maxBytesPerTurn": 52428800,
40
+ "retentionDays": 7,
41
+ "keepCompleted": false
42
+ }
43
+ },
38
44
  "claude": {
39
45
  "enabled": false,
40
46
  "defaultAgent": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.194",
3
+ "version": "0.2.195",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -4,8 +4,14 @@ import { join } from "node:path";
4
4
 
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
 
7
+ import { config } from "../config.ts";
8
+
7
9
  const streamTextMock = vi.fn();
8
10
  const generateTextMock = vi.fn();
11
+ const createRawStreamLogMock = vi.fn();
12
+ const rawLogWriteLineMock = vi.fn();
13
+ const rawLogCloseMock = vi.fn();
14
+ const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
9
15
 
10
16
  vi.mock("@ai-sdk/openai-compatible", () => ({
11
17
  createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
@@ -14,6 +20,13 @@ vi.mock("@ai-sdk/openai-compatible", () => ({
14
20
  vi.mock("ai", () => ({
15
21
  streamText: streamTextMock,
16
22
  generateText: generateTextMock,
23
+ stepCountIs: vi.fn((count: number) => ({ count })),
24
+ jsonSchema: vi.fn((schema: unknown) => schema),
25
+ tool: vi.fn((definition: unknown) => definition),
26
+ }));
27
+
28
+ vi.mock("../adapters/raw-stream-log.ts", () => ({
29
+ createRawStreamLog: createRawStreamLogMock,
17
30
  }));
18
31
 
19
32
  async function collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
@@ -26,9 +39,17 @@ async function* textStream(...chunks: string[]): AsyncIterable<string> {
26
39
  for (const chunk of chunks) yield chunk;
27
40
  }
28
41
 
42
+ async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
43
+ for (const part of parts) yield part;
44
+ }
45
+
29
46
  afterEach(() => {
30
47
  streamTextMock.mockReset();
31
48
  generateTextMock.mockReset();
49
+ createRawStreamLogMock.mockReset();
50
+ rawLogWriteLineMock.mockReset();
51
+ rawLogCloseMock.mockReset();
52
+ config.rawStreamLogs = structuredClone(originalRawStreamLogs);
32
53
  });
33
54
 
34
55
  describe("ChatSession context management", () => {
@@ -73,4 +94,88 @@ describe("ChatSession context management", () => {
73
94
  expect(events).toContainEqual({ type: "compact", compactedMessages: 2 });
74
95
  expect(restored.history.map((m) => m.content).join("\n")).toContain("new answer");
75
96
  });
97
+
98
+ it("streams tool calls and tool results from fullStream", async () => {
99
+ const { ChatSession } = await import("../builtin/index.ts");
100
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-session-tools-"));
101
+ const session = new ChatSession(
102
+ { apiKey: "sk-test" },
103
+ {
104
+ persist: true,
105
+ contextDir: dir,
106
+ sessionId: "tools",
107
+ },
108
+ );
109
+
110
+ streamTextMock.mockReturnValueOnce({
111
+ fullStream: fullStream(
112
+ { type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "package.json" } },
113
+ { type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "{}" } },
114
+ { type: "text-delta", text: "done" },
115
+ ),
116
+ });
117
+
118
+ const events = await collect(session.chat("read package"));
119
+
120
+ expect(events).toContainEqual({
121
+ type: "tool_use",
122
+ id: "call-1",
123
+ name: "read_file",
124
+ input: { path: "package.json" },
125
+ });
126
+ expect(events).toContainEqual({
127
+ type: "tool_result",
128
+ tool_use_id: "call-1",
129
+ name: "read_file",
130
+ content: { content: "{}" },
131
+ is_error: false,
132
+ });
133
+ expect(events).toContainEqual({ type: "text", text: "done", accumulated: "done" });
134
+ });
135
+
136
+ it("writes raw CCC fullStream parts when raw stream logs are enabled", async () => {
137
+ const { ChatSession } = await import("../builtin/index.ts");
138
+ config.rawStreamLogs.ccc = {
139
+ enabled: true,
140
+ maxBytesPerTurn: 4096,
141
+ retentionDays: 3,
142
+ keepCompleted: true,
143
+ };
144
+ createRawStreamLogMock.mockResolvedValueOnce({
145
+ filePath: "raw.jsonl.gz",
146
+ writeLine: rawLogWriteLineMock,
147
+ close: rawLogCloseMock,
148
+ });
149
+ const session = new ChatSession(
150
+ { apiKey: "sk-test" },
151
+ {
152
+ sessionId: "raw-log-session",
153
+ },
154
+ );
155
+ const textPart = { type: "text-delta", text: "hello" };
156
+ const toolPart = {
157
+ type: "tool-call",
158
+ toolCallId: "call-1",
159
+ toolName: "read_file",
160
+ input: { path: "package.json" },
161
+ };
162
+
163
+ streamTextMock.mockReturnValueOnce({
164
+ fullStream: fullStream(textPart, toolPart),
165
+ });
166
+
167
+ await collect(session.chat("hi"));
168
+
169
+ expect(createRawStreamLogMock).toHaveBeenCalledWith(expect.objectContaining({
170
+ enabled: true,
171
+ tool: "ccc",
172
+ sessionId: "raw-log-session",
173
+ label: "prompt",
174
+ maxBytesPerTurn: 4096,
175
+ retentionDays: 3,
176
+ }));
177
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(1, JSON.stringify(textPart));
178
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, JSON.stringify(toolPart));
179
+ expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: true });
180
+ });
76
181
  });
@@ -0,0 +1,196 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+
10
+ import {
11
+ applyPatchForTool,
12
+ createFileForTool,
13
+ deleteFileForTool,
14
+ editFileForTool,
15
+ listDirForTool,
16
+ moveFileForTool,
17
+ readFileForTool,
18
+ searchCodeForTool,
19
+ } from "../builtin/file-tools.ts";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+ const tempDirs: string[] = [];
23
+
24
+ async function makeTempDir(): Promise<string> {
25
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-builtin-tools-"));
26
+ tempDirs.push(dir);
27
+ return dir;
28
+ }
29
+
30
+ async function hasRg(): Promise<boolean> {
31
+ try {
32
+ await execFileAsync("rg", ["--version"]);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function sha256(text: string): string {
40
+ return createHash("sha256").update(text).digest("hex");
41
+ }
42
+
43
+ afterEach(async () => {
44
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
45
+ });
46
+
47
+ describe("builtin file tools", () => {
48
+ it("reads a text file with line ranges", async () => {
49
+ const dir = await makeTempDir();
50
+ await writeFile(join(dir, ".secret.txt"), "one\ntwo\nthree\n", "utf8");
51
+
52
+ const result = await readFileForTool(dir, { path: ".secret.txt", startLine: 2, endLine: 3 });
53
+
54
+ expect(result).toEqual(expect.objectContaining({
55
+ sha256: sha256("one\ntwo\nthree\n"),
56
+ isBinary: false,
57
+ content: "two\nthree",
58
+ startLine: 2,
59
+ endLine: 3,
60
+ totalLines: 4,
61
+ }));
62
+ expect(result.path).toContain(".secret.txt");
63
+ });
64
+
65
+ it("lists directory entries including hidden files", async () => {
66
+ const dir = await makeTempDir();
67
+ await writeFile(join(dir, ".env"), "TOKEN=x", "utf8");
68
+
69
+ const result = await listDirForTool(dir);
70
+
71
+ expect(result.entries).toContainEqual(expect.objectContaining({
72
+ name: ".env",
73
+ type: "file",
74
+ }));
75
+ });
76
+
77
+ it("searches code with rg without using a shell", async () => {
78
+ if (!await hasRg()) return;
79
+
80
+ const dir = await makeTempDir();
81
+ await writeFile(join(dir, "a.ts"), "const marker = 1;\n", "utf8");
82
+
83
+ const result = await searchCodeForTool(dir, { query: "marker", glob: "*.ts" });
84
+
85
+ expect(result.matches).toEqual([
86
+ expect.objectContaining({
87
+ line: 1,
88
+ text: "const marker = 1;",
89
+ }),
90
+ ]);
91
+ });
92
+
93
+ it("edits a file with exact replacements and a SHA-256 precondition", async () => {
94
+ const dir = await makeTempDir();
95
+ const file = join(dir, "edit.txt");
96
+ await writeFile(file, "alpha\nbeta\ngamma\n", "utf8");
97
+
98
+ const result = await editFileForTool(dir, {
99
+ path: "edit.txt",
100
+ expectedSha256: sha256("alpha\nbeta\ngamma\n"),
101
+ edits: [{ oldText: "beta", newText: "BETA" }],
102
+ });
103
+
104
+ expect(result).toEqual(expect.objectContaining({
105
+ changed: true,
106
+ editsApplied: 1,
107
+ beforeSha256: sha256("alpha\nbeta\ngamma\n"),
108
+ afterSha256: sha256("alpha\nBETA\ngamma\n"),
109
+ }));
110
+ await expect(readFile(file, "utf8")).resolves.toBe("alpha\nBETA\ngamma\n");
111
+ });
112
+
113
+ it("rejects edits when the SHA-256 precondition does not match", async () => {
114
+ const dir = await makeTempDir();
115
+ await writeFile(join(dir, "edit.txt"), "current\n", "utf8");
116
+
117
+ await expect(editFileForTool(dir, {
118
+ path: "edit.txt",
119
+ expectedSha256: sha256("stale\n"),
120
+ edits: [{ oldText: "current", newText: "next" }],
121
+ })).rejects.toThrow("SHA-256 mismatch");
122
+ });
123
+
124
+ it("creates and deletes files", async () => {
125
+ const dir = await makeTempDir();
126
+
127
+ const created = await createFileForTool(dir, {
128
+ path: "created.txt",
129
+ content: "created\n",
130
+ });
131
+ expect(created).toEqual(expect.objectContaining({
132
+ changed: true,
133
+ afterSha256: sha256("created\n"),
134
+ }));
135
+ await expect(readFile(join(dir, "created.txt"), "utf8")).resolves.toBe("created\n");
136
+
137
+ const deleted = await deleteFileForTool(dir, {
138
+ path: "created.txt",
139
+ expectedSha256: sha256("created\n"),
140
+ });
141
+ expect(deleted).toEqual(expect.objectContaining({
142
+ deleted: true,
143
+ beforeSha256: sha256("created\n"),
144
+ }));
145
+ await expect(stat(join(dir, "created.txt"))).rejects.toThrow();
146
+ });
147
+
148
+ it("moves files and creates the destination directory", async () => {
149
+ const dir = await makeTempDir();
150
+ await writeFile(join(dir, "old.txt"), "move me\n", "utf8");
151
+
152
+ const result = await moveFileForTool(dir, {
153
+ sourcePath: "old.txt",
154
+ destinationPath: "nested/new.txt",
155
+ expectedSourceSha256: sha256("move me\n"),
156
+ });
157
+
158
+ expect(result).toEqual(expect.objectContaining({
159
+ moved: true,
160
+ sourceSha256: sha256("move me\n"),
161
+ }));
162
+ await expect(stat(join(dir, "old.txt"))).rejects.toThrow();
163
+ await expect(readFile(join(dir, "nested", "new.txt"), "utf8")).resolves.toBe("move me\n");
164
+ });
165
+
166
+ it("applies a unified diff patch", async () => {
167
+ const dir = await makeTempDir();
168
+ await writeFile(join(dir, "patch.txt"), "one\ntwo\nthree\n", "utf8");
169
+
170
+ const result = await applyPatchForTool(dir, {
171
+ patch: [
172
+ "--- a/patch.txt",
173
+ "+++ b/patch.txt",
174
+ "@@ -1,4 +1,4 @@",
175
+ " one",
176
+ "-two",
177
+ "+TWO",
178
+ " three",
179
+ " ",
180
+ "",
181
+ ].join("\n"),
182
+ expectedSha256ByPath: {
183
+ "patch.txt": sha256("one\ntwo\nthree\n"),
184
+ },
185
+ });
186
+
187
+ expect(result.changedFiles).toEqual([
188
+ expect.objectContaining({
189
+ action: "edit",
190
+ beforeSha256: sha256("one\ntwo\nthree\n"),
191
+ afterSha256: sha256("one\nTWO\nthree\n"),
192
+ }),
193
+ ]);
194
+ await expect(readFile(join(dir, "patch.txt"), "utf8")).resolves.toBe("one\nTWO\nthree\n");
195
+ });
196
+ });
@@ -14,12 +14,19 @@ vi.mock("@ai-sdk/openai-compatible", () => ({
14
14
  vi.mock("ai", () => ({
15
15
  streamText: streamTextMock,
16
16
  generateText: generateTextMock,
17
+ stepCountIs: vi.fn((count: number) => ({ count })),
18
+ jsonSchema: vi.fn((schema: unknown) => schema),
19
+ tool: vi.fn((definition: unknown) => definition),
17
20
  }));
18
21
 
19
22
  async function* textStream(...chunks: string[]): AsyncIterable<string> {
20
23
  for (const chunk of chunks) yield chunk;
21
24
  }
22
25
 
26
+ async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
27
+ for (const part of parts) yield part;
28
+ }
29
+
23
30
  afterEach(() => {
24
31
  streamTextMock.mockReset();
25
32
  generateTextMock.mockReset();
@@ -70,4 +77,37 @@ describe("createCccAdapter", () => {
70
77
  model: { modelId: "deepseek-v4-flash" },
71
78
  }));
72
79
  });
80
+
81
+ it("maps ChatSession tool events to unified tool blocks", async () => {
82
+ const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
83
+ const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-tools-"));
84
+ const adapter = createCccAdapter({
85
+ apiKey: "sk-test",
86
+ contextDir,
87
+ model: "deepseek-v4-flash",
88
+ });
89
+ const { sessionId } = await adapter.createSession("F:\\repo");
90
+ streamTextMock.mockReturnValueOnce({
91
+ fullStream: fullStream(
92
+ { type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "README.md" } },
93
+ { type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "hello" } },
94
+ ),
95
+ });
96
+
97
+ const messages = [];
98
+ for await (const message of adapter.prompt(sessionId, "read", "F:\\repo")) {
99
+ messages.push(message);
100
+ }
101
+
102
+ expect(messages).toEqual([
103
+ {
104
+ type: "assistant",
105
+ blocks: [{ type: "tool_use", id: "call-1", name: "read_file", input: { path: "README.md" } }],
106
+ },
107
+ {
108
+ type: "assistant",
109
+ blocks: [{ type: "tool_result", tool_use_id: "call-1", content: { content: "hello" }, is_error: false }],
110
+ },
111
+ ]);
112
+ });
73
113
  });
@@ -45,11 +45,12 @@ const baseAppConfig: AppConfig = {
45
45
  port: 18080,
46
46
  gitTimeoutSeconds: 180,
47
47
  allowInterrupt: false,
48
- rawStreamLogs: {
49
- claude: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
50
- cursor: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
51
- codex: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
52
- },
48
+ rawStreamLogs: {
49
+ claude: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
50
+ cursor: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
51
+ codex: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
52
+ ccc: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
53
+ },
53
54
  claude: {
54
55
  enabled: true,
55
56
  defaultAgent: true,
@@ -72,7 +72,7 @@ describe("config.sample.json", () => {
72
72
  }>;
73
73
  };
74
74
 
75
- for (const tool of ["claude", "cursor", "codex"]) {
75
+ for (const tool of ["claude", "cursor", "codex", "ccc"]) {
76
76
  expect(sample.rawStreamLogs?.[tool]?.enabled).toBe(false);
77
77
  expect(sample.rawStreamLogs?.[tool]?.maxBytesPerTurn).toBe(52_428_800);
78
78
  expect(sample.rawStreamLogs?.[tool]?.retentionDays).toBe(7);
@@ -74,6 +74,26 @@ export function createCccAdapter(options: CccAdapterOptions = {}): ToolAdapter {
74
74
  type: "assistant",
75
75
  blocks: [{ type: "text", text: event.text }],
76
76
  };
77
+ } else if (event.type === "tool_use") {
78
+ yield {
79
+ type: "assistant",
80
+ blocks: [{
81
+ type: "tool_use",
82
+ id: event.id,
83
+ name: event.name,
84
+ input: event.input,
85
+ }],
86
+ };
87
+ } else if (event.type === "tool_result") {
88
+ yield {
89
+ type: "assistant",
90
+ blocks: [{
91
+ type: "tool_result",
92
+ tool_use_id: event.tool_use_id,
93
+ content: event.content,
94
+ is_error: event.is_error,
95
+ }],
96
+ };
77
97
  } else if (event.type === "error") {
78
98
  throw new Error(event.message);
79
99
  }
@@ -182,6 +182,21 @@ function streamJsonEvent(event: ChatEvent): void {
182
182
  type: "compact",
183
183
  compacted_messages: event.compactedMessages,
184
184
  });
185
+ } else if (event.type === "tool_use") {
186
+ writeJsonLine({
187
+ type: "tool_call",
188
+ id: event.id,
189
+ name: event.name,
190
+ input: event.input,
191
+ });
192
+ } else if (event.type === "tool_result") {
193
+ writeJsonLine({
194
+ type: "tool_result",
195
+ tool_call_id: event.tool_use_id,
196
+ name: event.name,
197
+ content: event.content,
198
+ is_error: event.is_error,
199
+ });
185
200
  } else if (event.type === "done") {
186
201
  writeJsonLine({
187
202
  type: "done",
@@ -358,6 +373,11 @@ async function runRepl(args: ParsedArgs): Promise<void> {
358
373
  console.log(`${C.dim}[done]${C.reset}`);
359
374
  } else if (event.type === "compact") {
360
375
  console.log(`${C.dim}[context compacted: ${event.compactedMessages} old messages]${C.reset}`);
376
+ } else if (event.type === "tool_use") {
377
+ console.log(`\n${C.dim}[tool] ${event.name} ${stringifyConsoleArg(event.input)}${C.reset}`);
378
+ } else if (event.type === "tool_result") {
379
+ const status = event.is_error ? "error" : "ok";
380
+ console.log(`${C.dim}[tool result] ${event.name ?? event.tool_use_id} ${status}${C.reset}`);
361
381
  } else if (event.type === "error") {
362
382
  console.log(`\n${C.yellow}[error] ${event.message}${C.reset}`);
363
383
  }