chatccc 0.2.194 → 0.2.196
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.
- package/config.sample.json +13 -7
- package/package.json +1 -1
- package/src/__tests__/builtin-chat-session.test.ts +105 -0
- package/src/__tests__/builtin-file-tools.test.ts +196 -0
- package/src/__tests__/ccc-adapter.test.ts +40 -0
- package/src/__tests__/config-reload.test.ts +6 -5
- package/src/__tests__/config-sample.test.ts +1 -1
- package/src/__tests__/cursor-adapter.test.ts +113 -16
- package/src/adapters/ccc-adapter.ts +20 -0
- package/src/adapters/cursor-adapter.ts +219 -94
- package/src/builtin/cli.ts +20 -0
- package/src/builtin/file-tools.ts +915 -0
- package/src/builtin/index.ts +129 -9
- package/src/config.ts +17 -14
- package/src/session.ts +12 -10
package/config.sample.json
CHANGED
|
@@ -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
|
@@ -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);
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type {
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { PassThrough } from "node:stream";
|
|
6
|
+
import type { ChildProcess } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import {
|
|
9
|
+
normalizeCursorMessage,
|
|
10
|
+
createCursorAdapter,
|
|
11
|
+
formatCursorAgentEmptyOutputMessage,
|
|
12
|
+
} from "../adapters/cursor-adapter.ts";
|
|
13
|
+
import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
|
|
7
14
|
import type {
|
|
8
15
|
CursorSessionMeta,
|
|
9
16
|
CursorSessionMetaStore,
|
|
@@ -37,12 +44,43 @@ function createInMemoryMetaStore(
|
|
|
37
44
|
};
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
|
|
49
|
+
type CursorSpawnForTest = NonNullable<
|
|
50
|
+
NonNullable<Parameters<typeof createCursorAdapter>[0]>["spawn"]
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
function readFixture(name: string): unknown[] {
|
|
54
|
+
const raw = readFileSync(join(__dirname, "fixtures", name), "utf-8");
|
|
55
|
+
return raw.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createMockCursorProcess(args: {
|
|
59
|
+
stdout?: string;
|
|
60
|
+
stderr?: string;
|
|
61
|
+
exitCode?: number;
|
|
62
|
+
}): ChildProcess {
|
|
63
|
+
const child = new EventEmitter() as ChildProcess;
|
|
64
|
+
const stdout = new PassThrough();
|
|
65
|
+
const stderr = new PassThrough();
|
|
66
|
+
const stdin = new PassThrough();
|
|
67
|
+
Object.assign(child, {
|
|
68
|
+
stdout,
|
|
69
|
+
stderr,
|
|
70
|
+
stdin,
|
|
71
|
+
pid: undefined,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
setImmediate(() => {
|
|
75
|
+
if (args.stdout) stdout.write(args.stdout);
|
|
76
|
+
if (args.stderr) stderr.write(args.stderr);
|
|
77
|
+
stdout.end();
|
|
78
|
+
stderr.end();
|
|
79
|
+
child.emit("close", args.exitCode ?? 0, null);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return child;
|
|
83
|
+
}
|
|
46
84
|
|
|
47
85
|
// ---------------------------------------------------------------------------
|
|
48
86
|
// normalizeCursorMessage — 核心映射逻辑测试(纯函数)
|
|
@@ -642,7 +680,7 @@ describe("Cursor stream fixture - 端到端不重复", () => {
|
|
|
642
680
|
});
|
|
643
681
|
});
|
|
644
682
|
|
|
645
|
-
it("mapToolCallKey: maps known keys to readable names", () => {
|
|
683
|
+
it("mapToolCallKey: maps known keys to readable names", () => {
|
|
646
684
|
const cases: [string, string][] = [
|
|
647
685
|
["globToolCall", "Glob"],
|
|
648
686
|
["shellToolCall", "Bash"],
|
|
@@ -658,6 +696,65 @@ describe("Cursor stream fixture - 端到端不重复", () => {
|
|
|
658
696
|
tool_call: { [raw]: { args: {} } },
|
|
659
697
|
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
660
698
|
expect(r!.blocks[0]).toMatchObject({ type: "tool_use", name: expected });
|
|
661
|
-
}
|
|
662
|
-
});
|
|
663
|
-
});
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
describe("Cursor adapter process failures", () => {
|
|
704
|
+
it("formats empty stdout auth stderr as a cautious visible message", () => {
|
|
705
|
+
const message = formatCursorAgentEmptyOutputMessage({
|
|
706
|
+
exitCode: 1,
|
|
707
|
+
stdoutLength: 0,
|
|
708
|
+
stderr: "Error: Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.\n",
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
expect(message).toContain("检测到认证相关错误");
|
|
712
|
+
expect(message).toContain("可能需要重新登录 Cursor Agent");
|
|
713
|
+
expect(message).toContain("CURSOR_API_KEY");
|
|
714
|
+
expect(message).toContain("agent status");
|
|
715
|
+
expect(message).toContain("agent login");
|
|
716
|
+
expect(message).not.toContain("登录态已失效");
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("does not format a message when stdout is non-empty", () => {
|
|
720
|
+
expect(
|
|
721
|
+
formatCursorAgentEmptyOutputMessage({
|
|
722
|
+
exitCode: 1,
|
|
723
|
+
stdoutLength: 10,
|
|
724
|
+
stderr: "Error: Authentication required",
|
|
725
|
+
}),
|
|
726
|
+
).toBeNull();
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("prompt surfaces auth-related empty output before failing the stream", async () => {
|
|
730
|
+
const store = createInMemoryMetaStore({ sid: { cwd: "F:/repo" } });
|
|
731
|
+
const spawnImpl = (() =>
|
|
732
|
+
createMockCursorProcess({
|
|
733
|
+
exitCode: 1,
|
|
734
|
+
stderr: "Error: Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.\n",
|
|
735
|
+
})) as CursorSpawnForTest;
|
|
736
|
+
const adapter = createCursorAdapter({ metaStore: store, spawn: spawnImpl });
|
|
737
|
+
const iterator = adapter.prompt(
|
|
738
|
+
"sid",
|
|
739
|
+
"[User message]\nhello\n[/User message]",
|
|
740
|
+
"F:/repo",
|
|
741
|
+
)[Symbol.asyncIterator]();
|
|
742
|
+
|
|
743
|
+
const first = await iterator.next();
|
|
744
|
+
expect(first.done).toBe(false);
|
|
745
|
+
expect(first.value.blocks).toHaveLength(1);
|
|
746
|
+
expect(first.value.blocks[0]).toMatchObject({ type: "text_final" });
|
|
747
|
+
expect(first.value.blocks[0]).toHaveProperty(
|
|
748
|
+
"text",
|
|
749
|
+
expect.stringContaining("可能需要重新登录 Cursor Agent"),
|
|
750
|
+
);
|
|
751
|
+
expect(first.value.blocks[0]).toHaveProperty(
|
|
752
|
+
"text",
|
|
753
|
+
expect.not.stringContaining("登录态已失效"),
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
await expect(iterator.next()).rejects.toThrow(
|
|
757
|
+
/Cursor Agent exited without stream-json output/,
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
@@ -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
|
}
|