chatccc 0.2.242 → 0.2.243
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/README.md +5 -5
- package/bin/cccagent.mjs +17 -17
- package/deepccc-agent/bin/deepccc.mjs +26 -26
- package/deepccc-agent/package.json +62 -62
- package/deepccc-agent/src/__tests__/chat-session.test.ts +578 -522
- package/deepccc-agent/src/__tests__/cli-json.test.ts +49 -49
- package/deepccc-agent/src/__tests__/config.test.ts +26 -26
- package/deepccc-agent/src/__tests__/context.test.ts +319 -319
- package/deepccc-agent/src/__tests__/file-tools.test.ts +240 -240
- package/deepccc-agent/src/__tests__/permissions.test.ts +195 -195
- package/deepccc-agent/src/__tests__/progress-reducer.test.ts +121 -121
- package/deepccc-agent/src/__tests__/session-search.test.ts +262 -262
- package/deepccc-agent/src/__tests__/session-select.test.ts +116 -116
- package/deepccc-agent/src/__tests__/sigint.test.ts +56 -56
- package/deepccc-agent/src/__tests__/skills.test.ts +284 -284
- package/deepccc-agent/src/__tests__/terminal-renderer.test.ts +247 -247
- package/deepccc-agent/src/__tests__/web-tools.test.ts +220 -220
- package/deepccc-agent/src/config.ts +84 -84
- package/deepccc-agent/src/context.ts +465 -465
- package/deepccc-agent/src/file-log.ts +38 -38
- package/deepccc-agent/src/index.ts +22 -0
- package/deepccc-agent/src/proc-tree-kill.ts +61 -61
- package/deepccc-agent/src/progress/cards-helpers.ts +76 -76
- package/deepccc-agent/src/progress/reducer.ts +113 -113
- package/deepccc-agent/src/progress/terminal-renderer.ts +294 -294
- package/deepccc-agent/src/progress/view.ts +77 -77
- package/deepccc-agent/src/raw-stream-log.ts +124 -124
- package/deepccc-agent/src/session-search.ts +370 -370
- package/deepccc-agent/src/session-select.ts +48 -48
- package/deepccc-agent/src/sigint.ts +50 -50
- package/deepccc-agent/src/skills.ts +205 -205
- package/deepccc-agent/src/web-tools.ts +313 -313
- package/deepccc-agent/tsconfig.build.json +13 -13
- package/deepccc-agent/tsconfig.json +13 -13
- package/deepccc-agent/vitest.config.ts +7 -7
- package/package.json +1 -1
- package/src/__tests__/builtin-chat-session.test.ts +522 -522
- package/src/__tests__/builtin-config.test.ts +26 -26
- package/src/__tests__/builtin-context.test.ts +319 -319
- package/src/__tests__/builtin-file-tools.test.ts +240 -240
- package/src/__tests__/builtin-permissions.test.ts +211 -211
- package/src/__tests__/builtin-session-search.test.ts +262 -262
- package/src/__tests__/builtin-session-select.test.ts +116 -116
- package/src/__tests__/builtin-sigint.test.ts +56 -56
- package/src/__tests__/builtin-skills.test.ts +284 -284
- package/src/__tests__/builtin-web-tools.test.ts +220 -220
- package/src/__tests__/config.test.ts +17 -17
- package/src/__tests__/progress-reducer.test.ts +121 -121
- package/src/__tests__/session-ccc-config.test.ts +45 -45
- package/src/__tests__/session.test.ts +298 -298
- package/src/adapters/ccc-adapter.ts +145 -145
- package/src/config-utils.ts +13 -13
- package/src/config.ts +13 -13
- package/src/progress/reducer.ts +113 -113
- package/src/session-chat-binding.ts +83 -83
- package/src/session.ts +311 -311
|
@@ -1,522 +1,578 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, readFile, writeFile } 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
|
-
import { config } from "../config.js";
|
|
8
|
-
import { estimateBuiltinContextTokens } from "../context.js";
|
|
9
|
-
|
|
10
|
-
const streamTextMock = vi.fn();
|
|
11
|
-
const generateTextMock = vi.fn();
|
|
12
|
-
const createRawStreamLogMock = vi.fn();
|
|
13
|
-
const rawLogWriteLineMock = vi.fn();
|
|
14
|
-
const rawLogCloseMock = vi.fn();
|
|
15
|
-
const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
|
|
16
|
-
|
|
17
|
-
vi.mock("@ai-sdk/openai-compatible", () => ({
|
|
18
|
-
createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
vi.mock("ai", () => ({
|
|
22
|
-
streamText: streamTextMock,
|
|
23
|
-
generateText: generateTextMock,
|
|
24
|
-
isLoopFinished: vi.fn(() => ({ loopFinished: true })),
|
|
25
|
-
stepCountIs: vi.fn((count: number) => ({ count })),
|
|
26
|
-
jsonSchema: vi.fn((schema: unknown) => schema),
|
|
27
|
-
tool: vi.fn((definition: unknown) => definition),
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
vi.mock("../raw-stream-log.js", () => ({
|
|
31
|
-
createRawStreamLog: createRawStreamLogMock,
|
|
32
|
-
}));
|
|
33
|
-
|
|
34
|
-
async function collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
|
|
35
|
-
const events: unknown[] = [];
|
|
36
|
-
for await (const event of iterable) events.push(event);
|
|
37
|
-
return events;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function* textStream(...chunks: string[]): AsyncIterable<string> {
|
|
41
|
-
for (const chunk of chunks) yield chunk;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
|
|
45
|
-
for (const part of parts) yield part;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
afterEach(() => {
|
|
49
|
-
streamTextMock.mockReset();
|
|
50
|
-
generateTextMock.mockReset();
|
|
51
|
-
createRawStreamLogMock.mockReset();
|
|
52
|
-
rawLogWriteLineMock.mockReset();
|
|
53
|
-
rawLogCloseMock.mockReset();
|
|
54
|
-
config.rawStreamLogs = structuredClone(originalRawStreamLogs);
|
|
55
|
-
vi.useRealTimers();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe("ChatSession context management", () => {
|
|
59
|
-
it("keeps the generalized evidence gate in the stable system prompt prefix", async () => {
|
|
60
|
-
const { ChatSession } = await import("../index.js");
|
|
61
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-evidence-gate-"));
|
|
62
|
-
await writeFile(join(dir, "AGENTS.md"), "PROJECT GUIDANCE MARKER", "utf-8");
|
|
63
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
64
|
-
|
|
65
|
-
const session = new ChatSession(
|
|
66
|
-
{ apiKey: "sk-test" },
|
|
67
|
-
{
|
|
68
|
-
cwd: dir,
|
|
69
|
-
sessionId: "evidence-gate",
|
|
70
|
-
systemPrompt: "CUSTOM PROMPT MARKER",
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
await collect(session.chat("diagnose a consequential problem"));
|
|
74
|
-
|
|
75
|
-
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
76
|
-
expect(system).toContain("## Evidence-Gated Conclusions");
|
|
77
|
-
expect(system).toContain("source of truth");
|
|
78
|
-
expect(system).toContain("direct observations from inferences");
|
|
79
|
-
expect(system).toContain("plausible alternative explanations");
|
|
80
|
-
expect(system).toContain("runtime behavior for runtime claims");
|
|
81
|
-
expect(system).toContain("state uncertainty");
|
|
82
|
-
expect(system).toContain("Do not repeat checks once decisive evidence exists");
|
|
83
|
-
expect(system).not.toContain("CodesForUnity");
|
|
84
|
-
expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
|
|
85
|
-
system.indexOf("PROJECT GUIDANCE MARKER"),
|
|
86
|
-
);
|
|
87
|
-
expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
|
|
88
|
-
system.indexOf("Current working directory"),
|
|
89
|
-
);
|
|
90
|
-
expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
|
|
91
|
-
system.indexOf("CUSTOM PROMPT MARKER"),
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("keeps execution discipline sections in the stable system prompt prefix", async () => {
|
|
96
|
-
const { ChatSession } = await import("../index.js");
|
|
97
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-discipline-"));
|
|
98
|
-
await writeFile(join(dir, "AGENTS.md"), "PROJECT GUIDANCE MARKER", "utf-8");
|
|
99
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
100
|
-
|
|
101
|
-
const session = new ChatSession(
|
|
102
|
-
{ apiKey: "sk-test" },
|
|
103
|
-
{
|
|
104
|
-
cwd: dir,
|
|
105
|
-
sessionId: "execution-discipline",
|
|
106
|
-
systemPrompt: "CUSTOM PROMPT MARKER",
|
|
107
|
-
},
|
|
108
|
-
);
|
|
109
|
-
await collect(session.chat("build a feature"));
|
|
110
|
-
|
|
111
|
-
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
112
|
-
// 先盘点再动手:动手前低开销盘点 + 输出含验证策略的计划
|
|
113
|
-
expect(system).toContain("## Survey Before Acting");
|
|
114
|
-
expect(system).toContain("map the landscape");
|
|
115
|
-
expect(system).toContain("how you will verify the result");
|
|
116
|
-
// 授权自主:用户委托决策后只问真正阻塞项,不抛实现级选择题
|
|
117
|
-
expect(system).toContain("## Delegated Authority");
|
|
118
|
-
expect(system).toContain("irreversible actions");
|
|
119
|
-
expect(system).toContain("Do not bounce implementation-level multiple-choice");
|
|
120
|
-
// 交付自检:声明做了什么/如何验证/未验证项
|
|
121
|
-
expect(system).toContain("## Pre-Delivery Self-Check");
|
|
122
|
-
expect(system).toContain("remains unverified or risky");
|
|
123
|
-
// 稳定前缀全部位于项目指令与 runtime 上下文之前
|
|
124
|
-
for (const section of ["## Survey Before Acting", "## Delegated Authority", "## Pre-Delivery Self-Check"]) {
|
|
125
|
-
expect(system.indexOf(section)).toBeGreaterThan(0);
|
|
126
|
-
expect(system.indexOf(section)).toBeLessThan(system.indexOf("PROJECT GUIDANCE MARKER"));
|
|
127
|
-
expect(system.indexOf(section)).toBeLessThan(system.indexOf("Current working directory"));
|
|
128
|
-
expect(system.indexOf(section)).toBeLessThan(system.indexOf("CUSTOM PROMPT MARKER"));
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("injects cwd project instruction files before runtime workspace details", async () => {
|
|
133
|
-
const { ChatSession } = await import("../index.js");
|
|
134
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-instructions-"));
|
|
135
|
-
await writeFile(join(dir, "AGENTS.md"), "agents root guidance", "utf-8");
|
|
136
|
-
await writeFile(join(dir, "AGENTS.local.md"), "agents local guidance", "utf-8");
|
|
137
|
-
await writeFile(join(dir, "CLAUDE.md"), "claude root guidance", "utf-8");
|
|
138
|
-
await writeFile(join(dir, "CLAUDE.local.md"), "claude local guidance", "utf-8");
|
|
139
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream() });
|
|
140
|
-
|
|
141
|
-
const session = new ChatSession(
|
|
142
|
-
{ apiKey: "sk-test" },
|
|
143
|
-
{
|
|
144
|
-
cwd: dir,
|
|
145
|
-
sessionId: "project-instructions",
|
|
146
|
-
},
|
|
147
|
-
);
|
|
148
|
-
await collect(session.chat("hi"));
|
|
149
|
-
|
|
150
|
-
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
151
|
-
expect(system).toContain("## Project Instructions");
|
|
152
|
-
expect(system).toContain("### AGENTS.md");
|
|
153
|
-
expect(system).toContain("agents root guidance");
|
|
154
|
-
expect(system).toContain("### AGENTS.local.md");
|
|
155
|
-
expect(system).toContain("agents local guidance");
|
|
156
|
-
expect(system).toContain("### CLAUDE.md");
|
|
157
|
-
expect(system).toContain("claude root guidance");
|
|
158
|
-
expect(system).toContain("### CLAUDE.local.md");
|
|
159
|
-
expect(system).toContain("claude local guidance");
|
|
160
|
-
|
|
161
|
-
expect(system.indexOf("agents root guidance")).toBeLessThan(system.indexOf("agents local guidance"));
|
|
162
|
-
expect(system.indexOf("agents local guidance")).toBeLessThan(system.indexOf("claude root guidance"));
|
|
163
|
-
expect(system.indexOf("claude root guidance")).toBeLessThan(system.indexOf("claude local guidance"));
|
|
164
|
-
expect(system.indexOf("claude local guidance")).toBeLessThan(system.indexOf(dir));
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("places the volatile skills index at the very end of system prompt", async () => {
|
|
168
|
-
const { ChatSession } = await import("../index.js");
|
|
169
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-skill-order-"));
|
|
170
|
-
const skillsDir = join(dir, "skills");
|
|
171
|
-
await mkdir(join(skillsDir, "demo-skill"), { recursive: true });
|
|
172
|
-
await writeFile(
|
|
173
|
-
join(skillsDir, "demo-skill", "SKILL.md"),
|
|
174
|
-
"---\nname: demo-skill\ndescription: demo description\n---\n\nbody\n",
|
|
175
|
-
"utf-8",
|
|
176
|
-
);
|
|
177
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream("ok") });
|
|
178
|
-
|
|
179
|
-
const session = new ChatSession(
|
|
180
|
-
{ apiKey: "sk-test" },
|
|
181
|
-
{
|
|
182
|
-
cwd: dir,
|
|
183
|
-
sessionId: "skill-order",
|
|
184
|
-
systemPrompt: "CUSTOM PROMPT MARKER",
|
|
185
|
-
skillsDirs: [skillsDir],
|
|
186
|
-
},
|
|
187
|
-
);
|
|
188
|
-
await collect(session.chat("hi"));
|
|
189
|
-
|
|
190
|
-
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
191
|
-
expect(system).toContain("demo-skill");
|
|
192
|
-
expect(system).toContain("CUSTOM PROMPT MARKER");
|
|
193
|
-
expect(system).toContain("Current working directory");
|
|
194
|
-
// 稳定性排序:固定规则 → 项目指令 → runtime → custom → 技能索引(最后)
|
|
195
|
-
expect(system.indexOf("CUSTOM PROMPT MARKER")).toBeGreaterThan(
|
|
196
|
-
system.indexOf("Current working directory"),
|
|
197
|
-
);
|
|
198
|
-
expect(system.indexOf("demo-skill")).toBeGreaterThan(system.indexOf("CUSTOM PROMPT MARKER"));
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("does not read project instruction files from parent directories", async () => {
|
|
202
|
-
const { ChatSession } = await import("../index.js");
|
|
203
|
-
const parent = await mkdtemp(join(tmpdir(), "deepccc-session-parent-instructions-"));
|
|
204
|
-
const child = join(parent, "child");
|
|
205
|
-
await mkdir(child);
|
|
206
|
-
await writeFile(join(parent, "AGENTS.md"), "parent-only guidance", "utf-8");
|
|
207
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream() });
|
|
208
|
-
|
|
209
|
-
const session = new ChatSession(
|
|
210
|
-
{ apiKey: "sk-test" },
|
|
211
|
-
{
|
|
212
|
-
cwd: child,
|
|
213
|
-
sessionId: "no-parent-instructions",
|
|
214
|
-
},
|
|
215
|
-
);
|
|
216
|
-
await collect(session.chat("hi"));
|
|
217
|
-
|
|
218
|
-
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
219
|
-
expect(system).not.toContain("parent-only guidance");
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("uses loop-finished stopping by default", async () => {
|
|
223
|
-
const { ChatSession } = await import("../index.js");
|
|
224
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-unlimited-"));
|
|
225
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
226
|
-
|
|
227
|
-
const session = new ChatSession(
|
|
228
|
-
{ apiKey: "sk-test" },
|
|
229
|
-
{
|
|
230
|
-
cwd: dir,
|
|
231
|
-
sessionId: "unlimited-steps",
|
|
232
|
-
},
|
|
233
|
-
);
|
|
234
|
-
await collect(session.chat("run a multi-stage workflow"));
|
|
235
|
-
|
|
236
|
-
expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
237
|
-
stopWhen: { loopFinished: true },
|
|
238
|
-
}));
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("uses a configured tool step limit when provided", async () => {
|
|
242
|
-
const { ChatSession } = await import("../index.js");
|
|
243
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-step-budget-"));
|
|
244
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
245
|
-
|
|
246
|
-
const session = new ChatSession(
|
|
247
|
-
{ apiKey: "sk-test" },
|
|
248
|
-
{
|
|
249
|
-
cwd: dir,
|
|
250
|
-
sessionId: "step-budget",
|
|
251
|
-
maxSteps: 7,
|
|
252
|
-
},
|
|
253
|
-
);
|
|
254
|
-
await collect(session.chat("run a bounded workflow"));
|
|
255
|
-
|
|
256
|
-
expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
257
|
-
stopWhen: { count: 7 },
|
|
258
|
-
}));
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it("loads persisted context, compacts older messages, and persists the new assistant reply", async () => {
|
|
262
|
-
const { ChatSession } = await import("../index.js");
|
|
263
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-context-"));
|
|
264
|
-
|
|
265
|
-
const seed = new ChatSession(
|
|
266
|
-
{ apiKey: "sk-test" },
|
|
267
|
-
{
|
|
268
|
-
persist: true,
|
|
269
|
-
contextDir: dir,
|
|
270
|
-
sessionId: "integration",
|
|
271
|
-
compactAtTokens: 10_000,
|
|
272
|
-
},
|
|
273
|
-
);
|
|
274
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
|
|
275
|
-
await collect(seed.chat("old question"));
|
|
276
|
-
|
|
277
|
-
generateTextMock.mockResolvedValueOnce({ text: "## Current Task\n- old question summarized" });
|
|
278
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream("new answer") });
|
|
279
|
-
|
|
280
|
-
const restored = new ChatSession(
|
|
281
|
-
{ apiKey: "sk-test" },
|
|
282
|
-
{
|
|
283
|
-
persist: true,
|
|
284
|
-
contextDir: dir,
|
|
285
|
-
sessionId: "integration",
|
|
286
|
-
compactAtTokens: 1,
|
|
287
|
-
keepRecentMessages: 1,
|
|
288
|
-
},
|
|
289
|
-
);
|
|
290
|
-
const events = await collect(restored.chat("new question"));
|
|
291
|
-
|
|
292
|
-
expect(generateTextMock).toHaveBeenCalledOnce();
|
|
293
|
-
expect(generateTextMock).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0 }));
|
|
294
|
-
expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
295
|
-
messages: expect.arrayContaining([
|
|
296
|
-
expect.objectContaining({ content: expect.stringContaining("old question summarized") }),
|
|
297
|
-
expect.objectContaining({ role: "user", content: "new question" }),
|
|
298
|
-
]),
|
|
299
|
-
}));
|
|
300
|
-
expect(events.slice(0, 3)).toEqual([
|
|
301
|
-
{ type: "status", phase: "compacting" },
|
|
302
|
-
{ type: "compact", compactedMessages: 2 },
|
|
303
|
-
{ type: "status", phase: "generating" },
|
|
304
|
-
]);
|
|
305
|
-
expect(restored.history.map((m) => m.content).join("\n")).toContain("new answer");
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it("times out context compaction independently before reply generation", async () => {
|
|
309
|
-
vi.useFakeTimers();
|
|
310
|
-
const { ChatSession } = await import("../index.js");
|
|
311
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-compaction-timeout-"));
|
|
312
|
-
const seed = new ChatSession(
|
|
313
|
-
{ apiKey: "sk-test" },
|
|
314
|
-
{ persist: true, contextDir: dir, sessionId: "timeout", compactAtTokens: 10_000 },
|
|
315
|
-
);
|
|
316
|
-
streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
|
|
317
|
-
await collect(seed.chat("old question"));
|
|
318
|
-
|
|
319
|
-
generateTextMock.mockImplementationOnce(({ abortSignal }: { abortSignal: AbortSignal }) =>
|
|
320
|
-
new Promise((_resolve, reject) => {
|
|
321
|
-
abortSignal.addEventListener("abort", () => reject(abortSignal.reason), { once: true });
|
|
322
|
-
}));
|
|
323
|
-
const restored = new ChatSession(
|
|
324
|
-
{ apiKey: "sk-test" },
|
|
325
|
-
{
|
|
326
|
-
persist: true,
|
|
327
|
-
contextDir: dir,
|
|
328
|
-
sessionId: "timeout",
|
|
329
|
-
compactAtTokens: 1,
|
|
330
|
-
keepRecentMessages: 1,
|
|
331
|
-
compactionTimeoutMs: 100,
|
|
332
|
-
},
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
const result = collect(restored.chat("new question"));
|
|
336
|
-
const timeoutAssertion = expect(result).rejects.toThrow("Context compaction timed out");
|
|
337
|
-
await vi.waitFor(() => expect(generateTextMock).toHaveBeenCalledOnce());
|
|
338
|
-
await vi.advanceTimersByTimeAsync(101);
|
|
339
|
-
await timeoutAssertion;
|
|
340
|
-
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it("streams tool calls and tool results from fullStream", async () => {
|
|
344
|
-
const { ChatSession } = await import("../index.js");
|
|
345
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-tools-"));
|
|
346
|
-
const session = new ChatSession(
|
|
347
|
-
{ apiKey: "sk-test" },
|
|
348
|
-
{
|
|
349
|
-
persist: true,
|
|
350
|
-
contextDir: dir,
|
|
351
|
-
sessionId: "tools",
|
|
352
|
-
},
|
|
353
|
-
);
|
|
354
|
-
|
|
355
|
-
streamTextMock.mockReturnValueOnce({
|
|
356
|
-
fullStream: fullStream(
|
|
357
|
-
{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "package.json" } },
|
|
358
|
-
{ type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "{}" } },
|
|
359
|
-
{ type: "text-delta", text: "done" },
|
|
360
|
-
),
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
const events = await collect(session.chat("read package"));
|
|
364
|
-
|
|
365
|
-
expect(events).toContainEqual({
|
|
366
|
-
type: "tool_use",
|
|
367
|
-
id: "call-1",
|
|
368
|
-
name: "read_file",
|
|
369
|
-
input: { path: "package.json" },
|
|
370
|
-
});
|
|
371
|
-
expect(events).toContainEqual({
|
|
372
|
-
type: "tool_result",
|
|
373
|
-
tool_use_id: "call-1",
|
|
374
|
-
name: "read_file",
|
|
375
|
-
content: { content: "{}" },
|
|
376
|
-
is_error: false,
|
|
377
|
-
});
|
|
378
|
-
expect(events).toContainEqual({ type: "text", text: "done", accumulated: "done" });
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it("caps the total persisted tool transcript for a turn", async () => {
|
|
382
|
-
const { ChatSession } = await import("../index.js");
|
|
383
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-tool-cap-"));
|
|
384
|
-
const session = new ChatSession(
|
|
385
|
-
{ apiKey: "sk-test" },
|
|
386
|
-
{ persist: true, contextDir: dir, sessionId: "tool-cap" },
|
|
387
|
-
);
|
|
388
|
-
const parts = Array.from({ length: 12 }, (_, index) => ({
|
|
389
|
-
type: "tool-result",
|
|
390
|
-
toolCallId: `call-${index}`,
|
|
391
|
-
toolName: "read_file",
|
|
392
|
-
output: { content: "x".repeat(8_000) },
|
|
393
|
-
}));
|
|
394
|
-
streamTextMock.mockReturnValueOnce({
|
|
395
|
-
fullStream: fullStream(...parts, { type: "text-delta", text: "done" }),
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
await collect(session.chat("read many files"));
|
|
399
|
-
|
|
400
|
-
const persisted = session.history.at(-1)?.content ?? "";
|
|
401
|
-
expect(persisted.length).toBeLessThan(40_000);
|
|
402
|
-
expect(persisted).toContain("tool transcript truncated");
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
it("persists structured tool calls alongside the text transcript", async () => {
|
|
406
|
-
const { ChatSession } = await import("../index.js");
|
|
407
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-structured-tools-"));
|
|
408
|
-
const session = new ChatSession(
|
|
409
|
-
{ apiKey: "sk-test" },
|
|
410
|
-
{ persist: true, contextDir: dir, sessionId: "structured-tools" },
|
|
411
|
-
);
|
|
412
|
-
|
|
413
|
-
streamTextMock.mockReturnValueOnce({
|
|
414
|
-
fullStream: fullStream(
|
|
415
|
-
{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "package.json" } },
|
|
416
|
-
{ type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "{}" } },
|
|
417
|
-
{ type: "text-delta", text: "done" },
|
|
418
|
-
),
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
await collect(session.chat("read package"));
|
|
422
|
-
|
|
423
|
-
const raw = await readFile(join(dir, "structured-tools", "context.json"), "utf8");
|
|
424
|
-
const state = JSON.parse(raw) as { messages: Array<{ content: string; toolCalls?: unknown }> };
|
|
425
|
-
expect(state.messages).toHaveLength(2);
|
|
426
|
-
expect(state.messages[1].toolCalls).toEqual([
|
|
427
|
-
{ name: "read_file", input: "{\"path\":\"package.json\"}", output: "{\"content\":\"{}\"}" },
|
|
428
|
-
]);
|
|
429
|
-
expect(state.messages[1].content).toContain("[Tool transcript]");
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
it("records tool errors and preserves tool call order in structured tool calls", async () => {
|
|
433
|
-
const { ChatSession } = await import("../index.js");
|
|
434
|
-
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-structured-tools-order-"));
|
|
435
|
-
const session = new ChatSession(
|
|
436
|
-
{ apiKey: "sk-test" },
|
|
437
|
-
{ persist: true, contextDir: dir, sessionId: "structured-tools-order" },
|
|
438
|
-
);
|
|
439
|
-
|
|
440
|
-
// AI SDK 流中工具调用先全部到达(tool-call),结果后到达(tool-result/tool-error)
|
|
441
|
-
streamTextMock.mockReturnValueOnce({
|
|
442
|
-
fullStream: fullStream(
|
|
443
|
-
{ type: "tool-call", toolCallId: "c1", toolName: "run_command", input: { command: "npm test" } },
|
|
444
|
-
{ type: "tool-call", toolCallId: "c2", toolName: "read_file", input: { path: "a.ts" } },
|
|
445
|
-
{ type: "tool-result", toolCallId: "c1", toolName: "run_command", output: { exitCode: 0 } },
|
|
446
|
-
{ type: "tool-error", toolCallId: "c2", toolName: "read_file", error: new Error("boom") },
|
|
447
|
-
{ type: "text-delta", text: "failed" },
|
|
448
|
-
),
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
await collect(session.chat("run tests"));
|
|
452
|
-
|
|
453
|
-
const raw = await readFile(join(dir, "structured-tools-order", "context.json"), "utf8");
|
|
454
|
-
const state = JSON.parse(raw) as { messages: Array<{ toolCalls?: unknown[] }> };
|
|
455
|
-
expect(state.messages[1].toolCalls).toEqual([
|
|
456
|
-
{ name: "run_command", input: "{\"command\":\"npm test\"}", output: "{\"exitCode\":0}" },
|
|
457
|
-
{ name: "read_file", input: "{\"path\":\"a.ts\"}", output: "boom", is_error: true },
|
|
458
|
-
]);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it("writes raw DeepCCC fullStream parts when raw stream logs are enabled", async () => {
|
|
462
|
-
const { ChatSession } = await import("../index.js");
|
|
463
|
-
config.rawStreamLogs = {
|
|
464
|
-
enabled: true,
|
|
465
|
-
maxBytesPerTurn: 4096,
|
|
466
|
-
retentionDays: 3,
|
|
467
|
-
keepCompleted: true,
|
|
468
|
-
};
|
|
469
|
-
createRawStreamLogMock.mockResolvedValueOnce({
|
|
470
|
-
filePath: "raw.jsonl.gz",
|
|
471
|
-
writeLine: rawLogWriteLineMock,
|
|
472
|
-
close: rawLogCloseMock,
|
|
473
|
-
});
|
|
474
|
-
const session = new ChatSession(
|
|
475
|
-
{ apiKey: "sk-test" },
|
|
476
|
-
{
|
|
477
|
-
sessionId: "raw-log-session",
|
|
478
|
-
},
|
|
479
|
-
);
|
|
480
|
-
const textPart = { type: "text-delta", text: "hello" };
|
|
481
|
-
const toolPart = {
|
|
482
|
-
type: "tool-call",
|
|
483
|
-
toolCallId: "call-1",
|
|
484
|
-
toolName: "read_file",
|
|
485
|
-
input: { path: "package.json" },
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
streamTextMock.mockReturnValueOnce({
|
|
489
|
-
fullStream: fullStream(textPart, toolPart),
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
await collect(session.chat("hi"));
|
|
493
|
-
|
|
494
|
-
expect(createRawStreamLogMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
495
|
-
enabled: true,
|
|
496
|
-
tool: "deepccc",
|
|
497
|
-
sessionId: "raw-log-session",
|
|
498
|
-
label: "prompt",
|
|
499
|
-
maxBytesPerTurn: 4096,
|
|
500
|
-
retentionDays: 3,
|
|
501
|
-
}));
|
|
502
|
-
expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(1, JSON.stringify(textPart));
|
|
503
|
-
expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, JSON.stringify(toolPart));
|
|
504
|
-
expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: true });
|
|
505
|
-
});
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
describe("
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
});
|
|
1
|
+
import { mkdir, mkdtemp, readFile, writeFile } 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
|
+
import { config } from "../config.js";
|
|
8
|
+
import { estimateBuiltinContextTokens } from "../context.js";
|
|
9
|
+
|
|
10
|
+
const streamTextMock = vi.fn();
|
|
11
|
+
const generateTextMock = vi.fn();
|
|
12
|
+
const createRawStreamLogMock = vi.fn();
|
|
13
|
+
const rawLogWriteLineMock = vi.fn();
|
|
14
|
+
const rawLogCloseMock = vi.fn();
|
|
15
|
+
const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
|
|
16
|
+
|
|
17
|
+
vi.mock("@ai-sdk/openai-compatible", () => ({
|
|
18
|
+
createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("ai", () => ({
|
|
22
|
+
streamText: streamTextMock,
|
|
23
|
+
generateText: generateTextMock,
|
|
24
|
+
isLoopFinished: vi.fn(() => ({ loopFinished: true })),
|
|
25
|
+
stepCountIs: vi.fn((count: number) => ({ count })),
|
|
26
|
+
jsonSchema: vi.fn((schema: unknown) => schema),
|
|
27
|
+
tool: vi.fn((definition: unknown) => definition),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("../raw-stream-log.js", () => ({
|
|
31
|
+
createRawStreamLog: createRawStreamLogMock,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
async function collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
|
|
35
|
+
const events: unknown[] = [];
|
|
36
|
+
for await (const event of iterable) events.push(event);
|
|
37
|
+
return events;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function* textStream(...chunks: string[]): AsyncIterable<string> {
|
|
41
|
+
for (const chunk of chunks) yield chunk;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
|
|
45
|
+
for (const part of parts) yield part;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
streamTextMock.mockReset();
|
|
50
|
+
generateTextMock.mockReset();
|
|
51
|
+
createRawStreamLogMock.mockReset();
|
|
52
|
+
rawLogWriteLineMock.mockReset();
|
|
53
|
+
rawLogCloseMock.mockReset();
|
|
54
|
+
config.rawStreamLogs = structuredClone(originalRawStreamLogs);
|
|
55
|
+
vi.useRealTimers();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("ChatSession context management", () => {
|
|
59
|
+
it("keeps the generalized evidence gate in the stable system prompt prefix", async () => {
|
|
60
|
+
const { ChatSession } = await import("../index.js");
|
|
61
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-evidence-gate-"));
|
|
62
|
+
await writeFile(join(dir, "AGENTS.md"), "PROJECT GUIDANCE MARKER", "utf-8");
|
|
63
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
64
|
+
|
|
65
|
+
const session = new ChatSession(
|
|
66
|
+
{ apiKey: "sk-test" },
|
|
67
|
+
{
|
|
68
|
+
cwd: dir,
|
|
69
|
+
sessionId: "evidence-gate",
|
|
70
|
+
systemPrompt: "CUSTOM PROMPT MARKER",
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
await collect(session.chat("diagnose a consequential problem"));
|
|
74
|
+
|
|
75
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
76
|
+
expect(system).toContain("## Evidence-Gated Conclusions");
|
|
77
|
+
expect(system).toContain("source of truth");
|
|
78
|
+
expect(system).toContain("direct observations from inferences");
|
|
79
|
+
expect(system).toContain("plausible alternative explanations");
|
|
80
|
+
expect(system).toContain("runtime behavior for runtime claims");
|
|
81
|
+
expect(system).toContain("state uncertainty");
|
|
82
|
+
expect(system).toContain("Do not repeat checks once decisive evidence exists");
|
|
83
|
+
expect(system).not.toContain("CodesForUnity");
|
|
84
|
+
expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
|
|
85
|
+
system.indexOf("PROJECT GUIDANCE MARKER"),
|
|
86
|
+
);
|
|
87
|
+
expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
|
|
88
|
+
system.indexOf("Current working directory"),
|
|
89
|
+
);
|
|
90
|
+
expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
|
|
91
|
+
system.indexOf("CUSTOM PROMPT MARKER"),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps execution discipline sections in the stable system prompt prefix", async () => {
|
|
96
|
+
const { ChatSession } = await import("../index.js");
|
|
97
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-discipline-"));
|
|
98
|
+
await writeFile(join(dir, "AGENTS.md"), "PROJECT GUIDANCE MARKER", "utf-8");
|
|
99
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
100
|
+
|
|
101
|
+
const session = new ChatSession(
|
|
102
|
+
{ apiKey: "sk-test" },
|
|
103
|
+
{
|
|
104
|
+
cwd: dir,
|
|
105
|
+
sessionId: "execution-discipline",
|
|
106
|
+
systemPrompt: "CUSTOM PROMPT MARKER",
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
await collect(session.chat("build a feature"));
|
|
110
|
+
|
|
111
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
112
|
+
// 先盘点再动手:动手前低开销盘点 + 输出含验证策略的计划
|
|
113
|
+
expect(system).toContain("## Survey Before Acting");
|
|
114
|
+
expect(system).toContain("map the landscape");
|
|
115
|
+
expect(system).toContain("how you will verify the result");
|
|
116
|
+
// 授权自主:用户委托决策后只问真正阻塞项,不抛实现级选择题
|
|
117
|
+
expect(system).toContain("## Delegated Authority");
|
|
118
|
+
expect(system).toContain("irreversible actions");
|
|
119
|
+
expect(system).toContain("Do not bounce implementation-level multiple-choice");
|
|
120
|
+
// 交付自检:声明做了什么/如何验证/未验证项
|
|
121
|
+
expect(system).toContain("## Pre-Delivery Self-Check");
|
|
122
|
+
expect(system).toContain("remains unverified or risky");
|
|
123
|
+
// 稳定前缀全部位于项目指令与 runtime 上下文之前
|
|
124
|
+
for (const section of ["## Survey Before Acting", "## Delegated Authority", "## Pre-Delivery Self-Check"]) {
|
|
125
|
+
expect(system.indexOf(section)).toBeGreaterThan(0);
|
|
126
|
+
expect(system.indexOf(section)).toBeLessThan(system.indexOf("PROJECT GUIDANCE MARKER"));
|
|
127
|
+
expect(system.indexOf(section)).toBeLessThan(system.indexOf("Current working directory"));
|
|
128
|
+
expect(system.indexOf(section)).toBeLessThan(system.indexOf("CUSTOM PROMPT MARKER"));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("injects cwd project instruction files before runtime workspace details", async () => {
|
|
133
|
+
const { ChatSession } = await import("../index.js");
|
|
134
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-instructions-"));
|
|
135
|
+
await writeFile(join(dir, "AGENTS.md"), "agents root guidance", "utf-8");
|
|
136
|
+
await writeFile(join(dir, "AGENTS.local.md"), "agents local guidance", "utf-8");
|
|
137
|
+
await writeFile(join(dir, "CLAUDE.md"), "claude root guidance", "utf-8");
|
|
138
|
+
await writeFile(join(dir, "CLAUDE.local.md"), "claude local guidance", "utf-8");
|
|
139
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream() });
|
|
140
|
+
|
|
141
|
+
const session = new ChatSession(
|
|
142
|
+
{ apiKey: "sk-test" },
|
|
143
|
+
{
|
|
144
|
+
cwd: dir,
|
|
145
|
+
sessionId: "project-instructions",
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
await collect(session.chat("hi"));
|
|
149
|
+
|
|
150
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
151
|
+
expect(system).toContain("## Project Instructions");
|
|
152
|
+
expect(system).toContain("### AGENTS.md");
|
|
153
|
+
expect(system).toContain("agents root guidance");
|
|
154
|
+
expect(system).toContain("### AGENTS.local.md");
|
|
155
|
+
expect(system).toContain("agents local guidance");
|
|
156
|
+
expect(system).toContain("### CLAUDE.md");
|
|
157
|
+
expect(system).toContain("claude root guidance");
|
|
158
|
+
expect(system).toContain("### CLAUDE.local.md");
|
|
159
|
+
expect(system).toContain("claude local guidance");
|
|
160
|
+
|
|
161
|
+
expect(system.indexOf("agents root guidance")).toBeLessThan(system.indexOf("agents local guidance"));
|
|
162
|
+
expect(system.indexOf("agents local guidance")).toBeLessThan(system.indexOf("claude root guidance"));
|
|
163
|
+
expect(system.indexOf("claude root guidance")).toBeLessThan(system.indexOf("claude local guidance"));
|
|
164
|
+
expect(system.indexOf("claude local guidance")).toBeLessThan(system.indexOf(dir));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("places the volatile skills index at the very end of system prompt", async () => {
|
|
168
|
+
const { ChatSession } = await import("../index.js");
|
|
169
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-skill-order-"));
|
|
170
|
+
const skillsDir = join(dir, "skills");
|
|
171
|
+
await mkdir(join(skillsDir, "demo-skill"), { recursive: true });
|
|
172
|
+
await writeFile(
|
|
173
|
+
join(skillsDir, "demo-skill", "SKILL.md"),
|
|
174
|
+
"---\nname: demo-skill\ndescription: demo description\n---\n\nbody\n",
|
|
175
|
+
"utf-8",
|
|
176
|
+
);
|
|
177
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("ok") });
|
|
178
|
+
|
|
179
|
+
const session = new ChatSession(
|
|
180
|
+
{ apiKey: "sk-test" },
|
|
181
|
+
{
|
|
182
|
+
cwd: dir,
|
|
183
|
+
sessionId: "skill-order",
|
|
184
|
+
systemPrompt: "CUSTOM PROMPT MARKER",
|
|
185
|
+
skillsDirs: [skillsDir],
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
await collect(session.chat("hi"));
|
|
189
|
+
|
|
190
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
191
|
+
expect(system).toContain("demo-skill");
|
|
192
|
+
expect(system).toContain("CUSTOM PROMPT MARKER");
|
|
193
|
+
expect(system).toContain("Current working directory");
|
|
194
|
+
// 稳定性排序:固定规则 → 项目指令 → runtime → custom → 技能索引(最后)
|
|
195
|
+
expect(system.indexOf("CUSTOM PROMPT MARKER")).toBeGreaterThan(
|
|
196
|
+
system.indexOf("Current working directory"),
|
|
197
|
+
);
|
|
198
|
+
expect(system.indexOf("demo-skill")).toBeGreaterThan(system.indexOf("CUSTOM PROMPT MARKER"));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does not read project instruction files from parent directories", async () => {
|
|
202
|
+
const { ChatSession } = await import("../index.js");
|
|
203
|
+
const parent = await mkdtemp(join(tmpdir(), "deepccc-session-parent-instructions-"));
|
|
204
|
+
const child = join(parent, "child");
|
|
205
|
+
await mkdir(child);
|
|
206
|
+
await writeFile(join(parent, "AGENTS.md"), "parent-only guidance", "utf-8");
|
|
207
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream() });
|
|
208
|
+
|
|
209
|
+
const session = new ChatSession(
|
|
210
|
+
{ apiKey: "sk-test" },
|
|
211
|
+
{
|
|
212
|
+
cwd: child,
|
|
213
|
+
sessionId: "no-parent-instructions",
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
await collect(session.chat("hi"));
|
|
217
|
+
|
|
218
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
219
|
+
expect(system).not.toContain("parent-only guidance");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("uses loop-finished stopping by default", async () => {
|
|
223
|
+
const { ChatSession } = await import("../index.js");
|
|
224
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-unlimited-"));
|
|
225
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
226
|
+
|
|
227
|
+
const session = new ChatSession(
|
|
228
|
+
{ apiKey: "sk-test" },
|
|
229
|
+
{
|
|
230
|
+
cwd: dir,
|
|
231
|
+
sessionId: "unlimited-steps",
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
await collect(session.chat("run a multi-stage workflow"));
|
|
235
|
+
|
|
236
|
+
expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
237
|
+
stopWhen: { loopFinished: true },
|
|
238
|
+
}));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("uses a configured tool step limit when provided", async () => {
|
|
242
|
+
const { ChatSession } = await import("../index.js");
|
|
243
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-step-budget-"));
|
|
244
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
245
|
+
|
|
246
|
+
const session = new ChatSession(
|
|
247
|
+
{ apiKey: "sk-test" },
|
|
248
|
+
{
|
|
249
|
+
cwd: dir,
|
|
250
|
+
sessionId: "step-budget",
|
|
251
|
+
maxSteps: 7,
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
await collect(session.chat("run a bounded workflow"));
|
|
255
|
+
|
|
256
|
+
expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
257
|
+
stopWhen: { count: 7 },
|
|
258
|
+
}));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("loads persisted context, compacts older messages, and persists the new assistant reply", async () => {
|
|
262
|
+
const { ChatSession } = await import("../index.js");
|
|
263
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-context-"));
|
|
264
|
+
|
|
265
|
+
const seed = new ChatSession(
|
|
266
|
+
{ apiKey: "sk-test" },
|
|
267
|
+
{
|
|
268
|
+
persist: true,
|
|
269
|
+
contextDir: dir,
|
|
270
|
+
sessionId: "integration",
|
|
271
|
+
compactAtTokens: 10_000,
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
|
|
275
|
+
await collect(seed.chat("old question"));
|
|
276
|
+
|
|
277
|
+
generateTextMock.mockResolvedValueOnce({ text: "## Current Task\n- old question summarized" });
|
|
278
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("new answer") });
|
|
279
|
+
|
|
280
|
+
const restored = new ChatSession(
|
|
281
|
+
{ apiKey: "sk-test" },
|
|
282
|
+
{
|
|
283
|
+
persist: true,
|
|
284
|
+
contextDir: dir,
|
|
285
|
+
sessionId: "integration",
|
|
286
|
+
compactAtTokens: 1,
|
|
287
|
+
keepRecentMessages: 1,
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
const events = await collect(restored.chat("new question"));
|
|
291
|
+
|
|
292
|
+
expect(generateTextMock).toHaveBeenCalledOnce();
|
|
293
|
+
expect(generateTextMock).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0 }));
|
|
294
|
+
expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
295
|
+
messages: expect.arrayContaining([
|
|
296
|
+
expect.objectContaining({ content: expect.stringContaining("old question summarized") }),
|
|
297
|
+
expect.objectContaining({ role: "user", content: "new question" }),
|
|
298
|
+
]),
|
|
299
|
+
}));
|
|
300
|
+
expect(events.slice(0, 3)).toEqual([
|
|
301
|
+
{ type: "status", phase: "compacting" },
|
|
302
|
+
{ type: "compact", compactedMessages: 2 },
|
|
303
|
+
{ type: "status", phase: "generating" },
|
|
304
|
+
]);
|
|
305
|
+
expect(restored.history.map((m) => m.content).join("\n")).toContain("new answer");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("times out context compaction independently before reply generation", async () => {
|
|
309
|
+
vi.useFakeTimers();
|
|
310
|
+
const { ChatSession } = await import("../index.js");
|
|
311
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-compaction-timeout-"));
|
|
312
|
+
const seed = new ChatSession(
|
|
313
|
+
{ apiKey: "sk-test" },
|
|
314
|
+
{ persist: true, contextDir: dir, sessionId: "timeout", compactAtTokens: 10_000 },
|
|
315
|
+
);
|
|
316
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
|
|
317
|
+
await collect(seed.chat("old question"));
|
|
318
|
+
|
|
319
|
+
generateTextMock.mockImplementationOnce(({ abortSignal }: { abortSignal: AbortSignal }) =>
|
|
320
|
+
new Promise((_resolve, reject) => {
|
|
321
|
+
abortSignal.addEventListener("abort", () => reject(abortSignal.reason), { once: true });
|
|
322
|
+
}));
|
|
323
|
+
const restored = new ChatSession(
|
|
324
|
+
{ apiKey: "sk-test" },
|
|
325
|
+
{
|
|
326
|
+
persist: true,
|
|
327
|
+
contextDir: dir,
|
|
328
|
+
sessionId: "timeout",
|
|
329
|
+
compactAtTokens: 1,
|
|
330
|
+
keepRecentMessages: 1,
|
|
331
|
+
compactionTimeoutMs: 100,
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const result = collect(restored.chat("new question"));
|
|
336
|
+
const timeoutAssertion = expect(result).rejects.toThrow("Context compaction timed out");
|
|
337
|
+
await vi.waitFor(() => expect(generateTextMock).toHaveBeenCalledOnce());
|
|
338
|
+
await vi.advanceTimersByTimeAsync(101);
|
|
339
|
+
await timeoutAssertion;
|
|
340
|
+
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("streams tool calls and tool results from fullStream", async () => {
|
|
344
|
+
const { ChatSession } = await import("../index.js");
|
|
345
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-tools-"));
|
|
346
|
+
const session = new ChatSession(
|
|
347
|
+
{ apiKey: "sk-test" },
|
|
348
|
+
{
|
|
349
|
+
persist: true,
|
|
350
|
+
contextDir: dir,
|
|
351
|
+
sessionId: "tools",
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
streamTextMock.mockReturnValueOnce({
|
|
356
|
+
fullStream: fullStream(
|
|
357
|
+
{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "package.json" } },
|
|
358
|
+
{ type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "{}" } },
|
|
359
|
+
{ type: "text-delta", text: "done" },
|
|
360
|
+
),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const events = await collect(session.chat("read package"));
|
|
364
|
+
|
|
365
|
+
expect(events).toContainEqual({
|
|
366
|
+
type: "tool_use",
|
|
367
|
+
id: "call-1",
|
|
368
|
+
name: "read_file",
|
|
369
|
+
input: { path: "package.json" },
|
|
370
|
+
});
|
|
371
|
+
expect(events).toContainEqual({
|
|
372
|
+
type: "tool_result",
|
|
373
|
+
tool_use_id: "call-1",
|
|
374
|
+
name: "read_file",
|
|
375
|
+
content: { content: "{}" },
|
|
376
|
+
is_error: false,
|
|
377
|
+
});
|
|
378
|
+
expect(events).toContainEqual({ type: "text", text: "done", accumulated: "done" });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("caps the total persisted tool transcript for a turn", async () => {
|
|
382
|
+
const { ChatSession } = await import("../index.js");
|
|
383
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-tool-cap-"));
|
|
384
|
+
const session = new ChatSession(
|
|
385
|
+
{ apiKey: "sk-test" },
|
|
386
|
+
{ persist: true, contextDir: dir, sessionId: "tool-cap" },
|
|
387
|
+
);
|
|
388
|
+
const parts = Array.from({ length: 12 }, (_, index) => ({
|
|
389
|
+
type: "tool-result",
|
|
390
|
+
toolCallId: `call-${index}`,
|
|
391
|
+
toolName: "read_file",
|
|
392
|
+
output: { content: "x".repeat(8_000) },
|
|
393
|
+
}));
|
|
394
|
+
streamTextMock.mockReturnValueOnce({
|
|
395
|
+
fullStream: fullStream(...parts, { type: "text-delta", text: "done" }),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await collect(session.chat("read many files"));
|
|
399
|
+
|
|
400
|
+
const persisted = session.history.at(-1)?.content ?? "";
|
|
401
|
+
expect(persisted.length).toBeLessThan(40_000);
|
|
402
|
+
expect(persisted).toContain("tool transcript truncated");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("persists structured tool calls alongside the text transcript", async () => {
|
|
406
|
+
const { ChatSession } = await import("../index.js");
|
|
407
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-structured-tools-"));
|
|
408
|
+
const session = new ChatSession(
|
|
409
|
+
{ apiKey: "sk-test" },
|
|
410
|
+
{ persist: true, contextDir: dir, sessionId: "structured-tools" },
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
streamTextMock.mockReturnValueOnce({
|
|
414
|
+
fullStream: fullStream(
|
|
415
|
+
{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "package.json" } },
|
|
416
|
+
{ type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "{}" } },
|
|
417
|
+
{ type: "text-delta", text: "done" },
|
|
418
|
+
),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await collect(session.chat("read package"));
|
|
422
|
+
|
|
423
|
+
const raw = await readFile(join(dir, "structured-tools", "context.json"), "utf8");
|
|
424
|
+
const state = JSON.parse(raw) as { messages: Array<{ content: string; toolCalls?: unknown }> };
|
|
425
|
+
expect(state.messages).toHaveLength(2);
|
|
426
|
+
expect(state.messages[1].toolCalls).toEqual([
|
|
427
|
+
{ name: "read_file", input: "{\"path\":\"package.json\"}", output: "{\"content\":\"{}\"}" },
|
|
428
|
+
]);
|
|
429
|
+
expect(state.messages[1].content).toContain("[Tool transcript]");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("records tool errors and preserves tool call order in structured tool calls", async () => {
|
|
433
|
+
const { ChatSession } = await import("../index.js");
|
|
434
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-structured-tools-order-"));
|
|
435
|
+
const session = new ChatSession(
|
|
436
|
+
{ apiKey: "sk-test" },
|
|
437
|
+
{ persist: true, contextDir: dir, sessionId: "structured-tools-order" },
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// AI SDK 流中工具调用先全部到达(tool-call),结果后到达(tool-result/tool-error)
|
|
441
|
+
streamTextMock.mockReturnValueOnce({
|
|
442
|
+
fullStream: fullStream(
|
|
443
|
+
{ type: "tool-call", toolCallId: "c1", toolName: "run_command", input: { command: "npm test" } },
|
|
444
|
+
{ type: "tool-call", toolCallId: "c2", toolName: "read_file", input: { path: "a.ts" } },
|
|
445
|
+
{ type: "tool-result", toolCallId: "c1", toolName: "run_command", output: { exitCode: 0 } },
|
|
446
|
+
{ type: "tool-error", toolCallId: "c2", toolName: "read_file", error: new Error("boom") },
|
|
447
|
+
{ type: "text-delta", text: "failed" },
|
|
448
|
+
),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await collect(session.chat("run tests"));
|
|
452
|
+
|
|
453
|
+
const raw = await readFile(join(dir, "structured-tools-order", "context.json"), "utf8");
|
|
454
|
+
const state = JSON.parse(raw) as { messages: Array<{ toolCalls?: unknown[] }> };
|
|
455
|
+
expect(state.messages[1].toolCalls).toEqual([
|
|
456
|
+
{ name: "run_command", input: "{\"command\":\"npm test\"}", output: "{\"exitCode\":0}" },
|
|
457
|
+
{ name: "read_file", input: "{\"path\":\"a.ts\"}", output: "boom", is_error: true },
|
|
458
|
+
]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("writes raw DeepCCC fullStream parts when raw stream logs are enabled", async () => {
|
|
462
|
+
const { ChatSession } = await import("../index.js");
|
|
463
|
+
config.rawStreamLogs = {
|
|
464
|
+
enabled: true,
|
|
465
|
+
maxBytesPerTurn: 4096,
|
|
466
|
+
retentionDays: 3,
|
|
467
|
+
keepCompleted: true,
|
|
468
|
+
};
|
|
469
|
+
createRawStreamLogMock.mockResolvedValueOnce({
|
|
470
|
+
filePath: "raw.jsonl.gz",
|
|
471
|
+
writeLine: rawLogWriteLineMock,
|
|
472
|
+
close: rawLogCloseMock,
|
|
473
|
+
});
|
|
474
|
+
const session = new ChatSession(
|
|
475
|
+
{ apiKey: "sk-test" },
|
|
476
|
+
{
|
|
477
|
+
sessionId: "raw-log-session",
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
const textPart = { type: "text-delta", text: "hello" };
|
|
481
|
+
const toolPart = {
|
|
482
|
+
type: "tool-call",
|
|
483
|
+
toolCallId: "call-1",
|
|
484
|
+
toolName: "read_file",
|
|
485
|
+
input: { path: "package.json" },
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
streamTextMock.mockReturnValueOnce({
|
|
489
|
+
fullStream: fullStream(textPart, toolPart),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await collect(session.chat("hi"));
|
|
493
|
+
|
|
494
|
+
expect(createRawStreamLogMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
495
|
+
enabled: true,
|
|
496
|
+
tool: "deepccc",
|
|
497
|
+
sessionId: "raw-log-session",
|
|
498
|
+
label: "prompt",
|
|
499
|
+
maxBytesPerTurn: 4096,
|
|
500
|
+
retentionDays: 3,
|
|
501
|
+
}));
|
|
502
|
+
expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(1, JSON.stringify(textPart));
|
|
503
|
+
expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, JSON.stringify(toolPart));
|
|
504
|
+
expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: true });
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("platform-specific system prompt injection", () => {
|
|
509
|
+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
510
|
+
|
|
511
|
+
const setPlatform = (value: string) => {
|
|
512
|
+
Object.defineProperty(process, "platform", { value, configurable: true });
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
afterEach(() => {
|
|
516
|
+
if (originalPlatform) {
|
|
517
|
+
Object.defineProperty(process, "platform", originalPlatform);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("injects Windows command-line guidance only on win32", async () => {
|
|
522
|
+
const { ChatSession } = await import("../index.js");
|
|
523
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-win32-prompt-"));
|
|
524
|
+
setPlatform("win32");
|
|
525
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("ok") });
|
|
526
|
+
|
|
527
|
+
const session = new ChatSession(
|
|
528
|
+
{ apiKey: "sk-test" },
|
|
529
|
+
{ cwd: dir, sessionId: "win32-prompt" },
|
|
530
|
+
);
|
|
531
|
+
await collect(session.chat("hi"));
|
|
532
|
+
|
|
533
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
534
|
+
expect(system).toContain("## Windows Command-Line Notes");
|
|
535
|
+
expect(system).toContain("cmd.exe");
|
|
536
|
+
expect(system).toMatch(/double quotes/i);
|
|
537
|
+
expect(system).toMatch(/single quotes/i);
|
|
538
|
+
// 平台指引属于固定规则区,位于 runtime workspace 上下文之前
|
|
539
|
+
expect(system.indexOf("## Windows Command-Line Notes")).toBeGreaterThan(
|
|
540
|
+
system.indexOf("## Fixed Rules"),
|
|
541
|
+
);
|
|
542
|
+
expect(system.indexOf("## Windows Command-Line Notes")).toBeLessThan(
|
|
543
|
+
system.indexOf("Current working directory"),
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("omits Windows guidance on non-Windows platforms", async () => {
|
|
548
|
+
const { ChatSession } = await import("../index.js");
|
|
549
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-posix-prompt-"));
|
|
550
|
+
setPlatform("linux");
|
|
551
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("ok") });
|
|
552
|
+
|
|
553
|
+
const session = new ChatSession(
|
|
554
|
+
{ apiKey: "sk-test" },
|
|
555
|
+
{ cwd: dir, sessionId: "posix-prompt" },
|
|
556
|
+
);
|
|
557
|
+
await collect(session.chat("hi"));
|
|
558
|
+
|
|
559
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
560
|
+
expect(system).not.toContain("## Windows Command-Line Notes");
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe("estimateBuiltinContextTokens", () => {
|
|
565
|
+
it("weights CJK characters closer to one token per character", () => {
|
|
566
|
+
const messages = [{ role: "user" as const, content: "你好".repeat(100) }];
|
|
567
|
+
const estimate = estimateBuiltinContextTokens("", messages);
|
|
568
|
+
// 200 个汉字 ≈ 200 tokens(旧算法 chars/3 只估到 ~70,明显低估)
|
|
569
|
+
expect(estimate).toBeGreaterThanOrEqual(180);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("keeps latin text near chars/3.5", () => {
|
|
573
|
+
const messages = [{ role: "user" as const, content: "a".repeat(1000) }];
|
|
574
|
+
const estimate = estimateBuiltinContextTokens("", messages);
|
|
575
|
+
expect(estimate).toBeGreaterThan(250);
|
|
576
|
+
expect(estimate).toBeLessThan(340);
|
|
577
|
+
});
|
|
578
|
+
});
|