chatccc 0.2.231 → 0.2.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.agents/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  2. package/.claude/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  3. package/.cursor/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  4. package/README.md +90 -90
  5. package/agent-prompts/claude_specific.md +45 -45
  6. package/agent-prompts/codex_specific.md +2 -2
  7. package/agent-prompts/cursor_specific.md +13 -13
  8. package/bin/cccagent.mjs +17 -17
  9. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  10. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  11. package/im-skills/feishu-skill/skill.md +3 -3
  12. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  13. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  14. package/im-skills/wechat-file-skill/skill.md +10 -10
  15. package/im-skills/wechat-image-skill/skill.md +10 -10
  16. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  17. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  18. package/im-skills/wechat-video-skill/skill.md +10 -10
  19. package/package.json +1 -1
  20. package/scripts/postinstall-sharp-check.mjs +58 -58
  21. package/src/__tests__/agent-activity.test.ts +86 -86
  22. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  23. package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
  24. package/src/__tests__/builtin-chat-session.test.ts +449 -393
  25. package/src/__tests__/builtin-cli-json.test.ts +39 -39
  26. package/src/__tests__/builtin-config.test.ts +26 -26
  27. package/src/__tests__/builtin-context.test.ts +319 -202
  28. package/src/__tests__/builtin-file-tools.test.ts +240 -240
  29. package/src/__tests__/builtin-permissions.test.ts +211 -211
  30. package/src/__tests__/builtin-session-search.test.ts +228 -0
  31. package/src/__tests__/builtin-session-select.test.ts +116 -116
  32. package/src/__tests__/builtin-sigint.test.ts +56 -56
  33. package/src/__tests__/builtin-skills.test.ts +284 -284
  34. package/src/__tests__/builtin-web-tools.test.ts +220 -220
  35. package/src/__tests__/card-action-routing.test.ts +18 -18
  36. package/src/__tests__/cardkit.test.ts +60 -60
  37. package/src/__tests__/ccc-adapter.test.ts +170 -170
  38. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  39. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  40. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  41. package/src/__tests__/claude-adapter.test.ts +614 -614
  42. package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
  43. package/src/__tests__/codex-adapter.test.ts +58 -58
  44. package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
  45. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  46. package/src/__tests__/cursor-adapter.test.ts +268 -268
  47. package/src/__tests__/feishu-api.test.ts +60 -60
  48. package/src/__tests__/feishu-avatar.test.ts +164 -164
  49. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  50. package/src/__tests__/feishu-platform.test.ts +22 -22
  51. package/src/__tests__/format-message.test.ts +47 -47
  52. package/src/__tests__/jsonl-stream.test.ts +79 -79
  53. package/src/__tests__/package-files.test.ts +24 -24
  54. package/src/__tests__/privacy.test.ts +198 -198
  55. package/src/__tests__/progress-reducer.test.ts +121 -121
  56. package/src/__tests__/raw-stream-log.test.ts +106 -106
  57. package/src/__tests__/response-stall.test.ts +49 -49
  58. package/src/__tests__/restart.test.ts +132 -132
  59. package/src/__tests__/session.test.ts +199 -199
  60. package/src/__tests__/shared-prefix.test.ts +36 -36
  61. package/src/__tests__/sim-platform.test.ts +16 -16
  62. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  63. package/src/__tests__/stop-session.test.ts +34 -34
  64. package/src/__tests__/stream-state.test.ts +42 -42
  65. package/src/__tests__/terminal-error.test.ts +54 -54
  66. package/src/__tests__/terminal-renderer.test.ts +247 -247
  67. package/src/__tests__/update-command-guard.test.ts +144 -144
  68. package/src/__tests__/web-ui.test.ts +326 -326
  69. package/src/adapters/adapter-interface.ts +33 -33
  70. package/src/adapters/ccc-adapter.ts +141 -141
  71. package/src/adapters/claude-adapter.ts +620 -620
  72. package/src/adapters/claude-session-meta-store.ts +120 -120
  73. package/src/adapters/codex-adapter.ts +426 -426
  74. package/src/adapters/cursor-adapter.ts +681 -681
  75. package/src/adapters/jsonl-stream.ts +157 -157
  76. package/src/adapters/raw-stream-log.ts +124 -124
  77. package/src/adapters/resource-monitor.ts +140 -140
  78. package/src/agent-activity.ts +175 -175
  79. package/src/agent-delegate-task-rpc.ts +153 -153
  80. package/src/agent-delegate-task.ts +91 -91
  81. package/src/agent-reload-config-rpc.ts +34 -34
  82. package/src/agent-stop-stuck.ts +129 -129
  83. package/src/builtin/cli.ts +6 -0
  84. package/src/builtin/config.ts +84 -84
  85. package/src/builtin/context.ts +465 -391
  86. package/src/builtin/file-log.ts +38 -38
  87. package/src/builtin/file-tools.ts +34 -0
  88. package/src/builtin/index.ts +24 -23
  89. package/src/builtin/proc-tree-kill.ts +61 -61
  90. package/src/builtin/progress/cards-helpers.ts +76 -76
  91. package/src/builtin/progress/reducer.ts +113 -113
  92. package/src/builtin/progress/terminal-renderer.ts +294 -294
  93. package/src/builtin/progress/view.ts +77 -77
  94. package/src/builtin/raw-stream-log.ts +124 -124
  95. package/src/builtin/session-search.ts +363 -0
  96. package/src/builtin/session-select.ts +48 -48
  97. package/src/builtin/sigint.ts +50 -50
  98. package/src/builtin/skills.ts +205 -205
  99. package/src/builtin/web-tools.ts +313 -313
  100. package/src/card-action-routing.ts +14 -14
  101. package/src/chatgpt-subscription-rpc.ts +27 -27
  102. package/src/chatgpt-subscription.ts +299 -299
  103. package/src/chrome-devtools-guard.ts +318 -318
  104. package/src/codex-reset-actions.ts +184 -184
  105. package/src/feishu-api.ts +193 -193
  106. package/src/feishu-message-ingress.ts +195 -195
  107. package/src/feishu-platform.ts +20 -20
  108. package/src/format-message.ts +293 -293
  109. package/src/litellm-proxy.ts +374 -374
  110. package/src/orchestrator.ts +2494 -2494
  111. package/src/platform-adapter.ts +6 -6
  112. package/src/privacy.ts +118 -118
  113. package/src/progress/reducer.ts +113 -113
  114. package/src/progress/terminal-renderer.ts +294 -294
  115. package/src/progress/view.ts +77 -77
  116. package/src/response-stall.ts +28 -28
  117. package/src/runtime-reload.ts +34 -34
  118. package/src/session-chat-binding.ts +82 -82
  119. package/src/session-name.ts +8 -8
  120. package/src/session.ts +201 -201
  121. package/src/shared-prefix.ts +29 -29
  122. package/src/sim-platform.ts +20 -20
  123. package/src/startup-lifecycle.ts +250 -250
  124. package/src/stream-state.ts +21 -21
  125. package/src/terminal-error.ts +129 -129
  126. package/src/turn-cards.ts +117 -117
  127. package/src/update-command-guard.ts +165 -165
@@ -1,393 +1,449 @@
1
- import { mkdir, mkdtemp, 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 "../builtin/config.ts";
8
- import { estimateBuiltinContextTokens } from "../builtin/context.ts";
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("../builtin/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("injects cwd project instruction files before runtime workspace details", async () => {
60
- const { ChatSession } = await import("../builtin/index.ts");
61
- const dir = await mkdtemp(join(tmpdir(), "deepccc-session-instructions-"));
62
- await writeFile(join(dir, "AGENTS.md"), "agents root guidance", "utf-8");
63
- await writeFile(join(dir, "AGENTS.local.md"), "agents local guidance", "utf-8");
64
- await writeFile(join(dir, "CLAUDE.md"), "claude root guidance", "utf-8");
65
- await writeFile(join(dir, "CLAUDE.local.md"), "claude local guidance", "utf-8");
66
- streamTextMock.mockReturnValueOnce({ textStream: textStream() });
67
-
68
- const session = new ChatSession(
69
- { apiKey: "sk-test" },
70
- {
71
- cwd: dir,
72
- sessionId: "project-instructions",
73
- },
74
- );
75
- await collect(session.chat("hi"));
76
-
77
- const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
78
- expect(system).toContain("## Project Instructions");
79
- expect(system).toContain("### AGENTS.md");
80
- expect(system).toContain("agents root guidance");
81
- expect(system).toContain("### AGENTS.local.md");
82
- expect(system).toContain("agents local guidance");
83
- expect(system).toContain("### CLAUDE.md");
84
- expect(system).toContain("claude root guidance");
85
- expect(system).toContain("### CLAUDE.local.md");
86
- expect(system).toContain("claude local guidance");
87
-
88
- expect(system.indexOf("agents root guidance")).toBeLessThan(system.indexOf("agents local guidance"));
89
- expect(system.indexOf("agents local guidance")).toBeLessThan(system.indexOf("claude root guidance"));
90
- expect(system.indexOf("claude root guidance")).toBeLessThan(system.indexOf("claude local guidance"));
91
- expect(system.indexOf("claude local guidance")).toBeLessThan(system.indexOf(dir));
92
- });
93
-
94
- it("places the volatile skills index at the very end of system prompt", async () => {
95
- const { ChatSession } = await import("../builtin/index.ts");
96
- const dir = await mkdtemp(join(tmpdir(), "deepccc-session-skill-order-"));
97
- const skillsDir = join(dir, "skills");
98
- await mkdir(join(skillsDir, "demo-skill"), { recursive: true });
99
- await writeFile(
100
- join(skillsDir, "demo-skill", "SKILL.md"),
101
- "---\nname: demo-skill\ndescription: demo description\n---\n\nbody\n",
102
- "utf-8",
103
- );
104
- streamTextMock.mockReturnValueOnce({ textStream: textStream("ok") });
105
-
106
- const session = new ChatSession(
107
- { apiKey: "sk-test" },
108
- {
109
- cwd: dir,
110
- sessionId: "skill-order",
111
- systemPrompt: "CUSTOM PROMPT MARKER",
112
- skillsDirs: [skillsDir],
113
- },
114
- );
115
- await collect(session.chat("hi"));
116
-
117
- const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
118
- expect(system).toContain("demo-skill");
119
- expect(system).toContain("CUSTOM PROMPT MARKER");
120
- expect(system).toContain("Current working directory");
121
- // 稳定性排序:固定规则 → 项目指令 → runtime → custom → 技能索引(最后)
122
- expect(system.indexOf("CUSTOM PROMPT MARKER")).toBeGreaterThan(
123
- system.indexOf("Current working directory"),
124
- );
125
- expect(system.indexOf("demo-skill")).toBeGreaterThan(system.indexOf("CUSTOM PROMPT MARKER"));
126
- });
127
-
128
- it("does not read project instruction files from parent directories", async () => {
129
- const { ChatSession } = await import("../builtin/index.ts");
130
- const parent = await mkdtemp(join(tmpdir(), "deepccc-session-parent-instructions-"));
131
- const child = join(parent, "child");
132
- await mkdir(child);
133
- await writeFile(join(parent, "AGENTS.md"), "parent-only guidance", "utf-8");
134
- streamTextMock.mockReturnValueOnce({ textStream: textStream() });
135
-
136
- const session = new ChatSession(
137
- { apiKey: "sk-test" },
138
- {
139
- cwd: child,
140
- sessionId: "no-parent-instructions",
141
- },
142
- );
143
- await collect(session.chat("hi"));
144
-
145
- const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
146
- expect(system).not.toContain("parent-only guidance");
147
- });
148
-
149
- it("uses loop-finished stopping by default", async () => {
150
- const { ChatSession } = await import("../builtin/index.ts");
151
- const dir = await mkdtemp(join(tmpdir(), "deepccc-session-unlimited-"));
152
- streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
153
-
154
- const session = new ChatSession(
155
- { apiKey: "sk-test" },
156
- {
157
- cwd: dir,
158
- sessionId: "unlimited-steps",
159
- },
160
- );
161
- await collect(session.chat("run a multi-stage workflow"));
162
-
163
- expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
164
- stopWhen: { loopFinished: true },
165
- }));
166
- });
167
-
168
- it("uses a configured tool step limit when provided", async () => {
169
- const { ChatSession } = await import("../builtin/index.ts");
170
- const dir = await mkdtemp(join(tmpdir(), "deepccc-session-step-budget-"));
171
- streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
172
-
173
- const session = new ChatSession(
174
- { apiKey: "sk-test" },
175
- {
176
- cwd: dir,
177
- sessionId: "step-budget",
178
- maxSteps: 7,
179
- },
180
- );
181
- await collect(session.chat("run a bounded workflow"));
182
-
183
- expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
184
- stopWhen: { count: 7 },
185
- }));
186
- });
187
-
188
- it("loads persisted context, compacts older messages, and persists the new assistant reply", async () => {
189
- const { ChatSession } = await import("../builtin/index.ts");
190
- const dir = await mkdtemp(join(tmpdir(), "deepccc-session-context-"));
191
-
192
- const seed = new ChatSession(
193
- { apiKey: "sk-test" },
194
- {
195
- persist: true,
196
- contextDir: dir,
197
- sessionId: "integration",
198
- compactAtTokens: 10_000,
199
- },
200
- );
201
- streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
202
- await collect(seed.chat("old question"));
203
-
204
- generateTextMock.mockResolvedValueOnce({ text: "## Current Task\n- old question summarized" });
205
- streamTextMock.mockReturnValueOnce({ textStream: textStream("new answer") });
206
-
207
- const restored = new ChatSession(
208
- { apiKey: "sk-test" },
209
- {
210
- persist: true,
211
- contextDir: dir,
212
- sessionId: "integration",
213
- compactAtTokens: 1,
214
- keepRecentMessages: 1,
215
- },
216
- );
217
- const events = await collect(restored.chat("new question"));
218
-
219
- expect(generateTextMock).toHaveBeenCalledOnce();
220
- expect(generateTextMock).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0 }));
221
- expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
222
- messages: expect.arrayContaining([
223
- expect.objectContaining({ content: expect.stringContaining("old question summarized") }),
224
- expect.objectContaining({ role: "user", content: "new question" }),
225
- ]),
226
- }));
227
- expect(events.slice(0, 3)).toEqual([
228
- { type: "status", phase: "compacting" },
229
- { type: "compact", compactedMessages: 2 },
230
- { type: "status", phase: "generating" },
231
- ]);
232
- expect(restored.history.map((m) => m.content).join("\n")).toContain("new answer");
233
- });
234
-
235
- it("times out context compaction independently before reply generation", async () => {
236
- vi.useFakeTimers();
237
- const { ChatSession } = await import("../builtin/index.ts");
238
- const dir = await mkdtemp(join(tmpdir(), "deepccc-session-compaction-timeout-"));
239
- const seed = new ChatSession(
240
- { apiKey: "sk-test" },
241
- { persist: true, contextDir: dir, sessionId: "timeout", compactAtTokens: 10_000 },
242
- );
243
- streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
244
- await collect(seed.chat("old question"));
245
-
246
- generateTextMock.mockImplementationOnce(({ abortSignal }: { abortSignal: AbortSignal }) =>
247
- new Promise((_resolve, reject) => {
248
- abortSignal.addEventListener("abort", () => reject(abortSignal.reason), { once: true });
249
- }));
250
- const restored = new ChatSession(
251
- { apiKey: "sk-test" },
252
- {
253
- persist: true,
254
- contextDir: dir,
255
- sessionId: "timeout",
256
- compactAtTokens: 1,
257
- keepRecentMessages: 1,
258
- compactionTimeoutMs: 100,
259
- },
260
- );
261
-
262
- const result = collect(restored.chat("new question"));
263
- const timeoutAssertion = expect(result).rejects.toThrow("Context compaction timed out");
264
- await vi.waitFor(() => expect(generateTextMock).toHaveBeenCalledOnce());
265
- await vi.advanceTimersByTimeAsync(101);
266
- await timeoutAssertion;
267
- expect(streamTextMock).toHaveBeenCalledTimes(1);
268
- });
269
-
270
- it("streams tool calls and tool results from fullStream", async () => {
271
- const { ChatSession } = await import("../builtin/index.ts");
272
- const dir = await mkdtemp(join(tmpdir(), "deepccc-session-tools-"));
273
- const session = new ChatSession(
274
- { apiKey: "sk-test" },
275
- {
276
- persist: true,
277
- contextDir: dir,
278
- sessionId: "tools",
279
- },
280
- );
281
-
282
- streamTextMock.mockReturnValueOnce({
283
- fullStream: fullStream(
284
- { type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "package.json" } },
285
- { type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "{}" } },
286
- { type: "text-delta", text: "done" },
287
- ),
288
- });
289
-
290
- const events = await collect(session.chat("read package"));
291
-
292
- expect(events).toContainEqual({
293
- type: "tool_use",
294
- id: "call-1",
295
- name: "read_file",
296
- input: { path: "package.json" },
297
- });
298
- expect(events).toContainEqual({
299
- type: "tool_result",
300
- tool_use_id: "call-1",
301
- name: "read_file",
302
- content: { content: "{}" },
303
- is_error: false,
304
- });
305
- expect(events).toContainEqual({ type: "text", text: "done", accumulated: "done" });
306
- });
307
-
308
- it("caps the total persisted tool transcript for a turn", async () => {
309
- const { ChatSession } = await import("../builtin/index.ts");
310
- const dir = await mkdtemp(join(tmpdir(), "deepccc-session-tool-cap-"));
311
- const session = new ChatSession(
312
- { apiKey: "sk-test" },
313
- { persist: true, contextDir: dir, sessionId: "tool-cap" },
314
- );
315
- const parts = Array.from({ length: 12 }, (_, index) => ({
316
- type: "tool-result",
317
- toolCallId: `call-${index}`,
318
- toolName: "read_file",
319
- output: { content: "x".repeat(8_000) },
320
- }));
321
- streamTextMock.mockReturnValueOnce({
322
- fullStream: fullStream(...parts, { type: "text-delta", text: "done" }),
323
- });
324
-
325
- await collect(session.chat("read many files"));
326
-
327
- const persisted = session.history.at(-1)?.content ?? "";
328
- expect(persisted.length).toBeLessThan(40_000);
329
- expect(persisted).toContain("tool transcript truncated");
330
- });
331
-
332
- it("writes raw DeepCCC fullStream parts when raw stream logs are enabled", async () => {
333
- const { ChatSession } = await import("../builtin/index.ts");
334
- config.rawStreamLogs = {
335
- enabled: true,
336
- maxBytesPerTurn: 4096,
337
- retentionDays: 3,
338
- keepCompleted: true,
339
- };
340
- createRawStreamLogMock.mockResolvedValueOnce({
341
- filePath: "raw.jsonl.gz",
342
- writeLine: rawLogWriteLineMock,
343
- close: rawLogCloseMock,
344
- });
345
- const session = new ChatSession(
346
- { apiKey: "sk-test" },
347
- {
348
- sessionId: "raw-log-session",
349
- },
350
- );
351
- const textPart = { type: "text-delta", text: "hello" };
352
- const toolPart = {
353
- type: "tool-call",
354
- toolCallId: "call-1",
355
- toolName: "read_file",
356
- input: { path: "package.json" },
357
- };
358
-
359
- streamTextMock.mockReturnValueOnce({
360
- fullStream: fullStream(textPart, toolPart),
361
- });
362
-
363
- await collect(session.chat("hi"));
364
-
365
- expect(createRawStreamLogMock).toHaveBeenCalledWith(expect.objectContaining({
366
- enabled: true,
367
- tool: "deepccc",
368
- sessionId: "raw-log-session",
369
- label: "prompt",
370
- maxBytesPerTurn: 4096,
371
- retentionDays: 3,
372
- }));
373
- expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(1, JSON.stringify(textPart));
374
- expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, JSON.stringify(toolPart));
375
- expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: true });
376
- });
377
- });
378
-
379
- describe("estimateBuiltinContextTokens", () => {
380
- it("weights CJK characters closer to one token per character", () => {
381
- const messages = [{ role: "user" as const, content: "你好".repeat(100) }];
382
- const estimate = estimateBuiltinContextTokens("", messages);
383
- // 200 个汉字 200 tokens(旧算法 chars/3 只估到 ~70,明显低估)
384
- expect(estimate).toBeGreaterThanOrEqual(180);
385
- });
386
-
387
- it("keeps latin text near chars/3.5", () => {
388
- const messages = [{ role: "user" as const, content: "a".repeat(1000) }];
389
- const estimate = estimateBuiltinContextTokens("", messages);
390
- expect(estimate).toBeGreaterThan(250);
391
- expect(estimate).toBeLessThan(340);
392
- });
393
- });
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 "../builtin/config.ts";
8
+ import { estimateBuiltinContextTokens } from "../builtin/context.ts";
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("../builtin/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("injects cwd project instruction files before runtime workspace details", async () => {
60
+ const { ChatSession } = await import("../builtin/index.ts");
61
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-instructions-"));
62
+ await writeFile(join(dir, "AGENTS.md"), "agents root guidance", "utf-8");
63
+ await writeFile(join(dir, "AGENTS.local.md"), "agents local guidance", "utf-8");
64
+ await writeFile(join(dir, "CLAUDE.md"), "claude root guidance", "utf-8");
65
+ await writeFile(join(dir, "CLAUDE.local.md"), "claude local guidance", "utf-8");
66
+ streamTextMock.mockReturnValueOnce({ textStream: textStream() });
67
+
68
+ const session = new ChatSession(
69
+ { apiKey: "sk-test" },
70
+ {
71
+ cwd: dir,
72
+ sessionId: "project-instructions",
73
+ },
74
+ );
75
+ await collect(session.chat("hi"));
76
+
77
+ const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
78
+ expect(system).toContain("## Project Instructions");
79
+ expect(system).toContain("### AGENTS.md");
80
+ expect(system).toContain("agents root guidance");
81
+ expect(system).toContain("### AGENTS.local.md");
82
+ expect(system).toContain("agents local guidance");
83
+ expect(system).toContain("### CLAUDE.md");
84
+ expect(system).toContain("claude root guidance");
85
+ expect(system).toContain("### CLAUDE.local.md");
86
+ expect(system).toContain("claude local guidance");
87
+
88
+ expect(system.indexOf("agents root guidance")).toBeLessThan(system.indexOf("agents local guidance"));
89
+ expect(system.indexOf("agents local guidance")).toBeLessThan(system.indexOf("claude root guidance"));
90
+ expect(system.indexOf("claude root guidance")).toBeLessThan(system.indexOf("claude local guidance"));
91
+ expect(system.indexOf("claude local guidance")).toBeLessThan(system.indexOf(dir));
92
+ });
93
+
94
+ it("places the volatile skills index at the very end of system prompt", async () => {
95
+ const { ChatSession } = await import("../builtin/index.ts");
96
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-skill-order-"));
97
+ const skillsDir = join(dir, "skills");
98
+ await mkdir(join(skillsDir, "demo-skill"), { recursive: true });
99
+ await writeFile(
100
+ join(skillsDir, "demo-skill", "SKILL.md"),
101
+ "---\nname: demo-skill\ndescription: demo description\n---\n\nbody\n",
102
+ "utf-8",
103
+ );
104
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("ok") });
105
+
106
+ const session = new ChatSession(
107
+ { apiKey: "sk-test" },
108
+ {
109
+ cwd: dir,
110
+ sessionId: "skill-order",
111
+ systemPrompt: "CUSTOM PROMPT MARKER",
112
+ skillsDirs: [skillsDir],
113
+ },
114
+ );
115
+ await collect(session.chat("hi"));
116
+
117
+ const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
118
+ expect(system).toContain("demo-skill");
119
+ expect(system).toContain("CUSTOM PROMPT MARKER");
120
+ expect(system).toContain("Current working directory");
121
+ // 稳定性排序:固定规则 → 项目指令 → runtime → custom → 技能索引(最后)
122
+ expect(system.indexOf("CUSTOM PROMPT MARKER")).toBeGreaterThan(
123
+ system.indexOf("Current working directory"),
124
+ );
125
+ expect(system.indexOf("demo-skill")).toBeGreaterThan(system.indexOf("CUSTOM PROMPT MARKER"));
126
+ });
127
+
128
+ it("does not read project instruction files from parent directories", async () => {
129
+ const { ChatSession } = await import("../builtin/index.ts");
130
+ const parent = await mkdtemp(join(tmpdir(), "deepccc-session-parent-instructions-"));
131
+ const child = join(parent, "child");
132
+ await mkdir(child);
133
+ await writeFile(join(parent, "AGENTS.md"), "parent-only guidance", "utf-8");
134
+ streamTextMock.mockReturnValueOnce({ textStream: textStream() });
135
+
136
+ const session = new ChatSession(
137
+ { apiKey: "sk-test" },
138
+ {
139
+ cwd: child,
140
+ sessionId: "no-parent-instructions",
141
+ },
142
+ );
143
+ await collect(session.chat("hi"));
144
+
145
+ const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
146
+ expect(system).not.toContain("parent-only guidance");
147
+ });
148
+
149
+ it("uses loop-finished stopping by default", async () => {
150
+ const { ChatSession } = await import("../builtin/index.ts");
151
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-unlimited-"));
152
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
153
+
154
+ const session = new ChatSession(
155
+ { apiKey: "sk-test" },
156
+ {
157
+ cwd: dir,
158
+ sessionId: "unlimited-steps",
159
+ },
160
+ );
161
+ await collect(session.chat("run a multi-stage workflow"));
162
+
163
+ expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
164
+ stopWhen: { loopFinished: true },
165
+ }));
166
+ });
167
+
168
+ it("uses a configured tool step limit when provided", async () => {
169
+ const { ChatSession } = await import("../builtin/index.ts");
170
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-step-budget-"));
171
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
172
+
173
+ const session = new ChatSession(
174
+ { apiKey: "sk-test" },
175
+ {
176
+ cwd: dir,
177
+ sessionId: "step-budget",
178
+ maxSteps: 7,
179
+ },
180
+ );
181
+ await collect(session.chat("run a bounded workflow"));
182
+
183
+ expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
184
+ stopWhen: { count: 7 },
185
+ }));
186
+ });
187
+
188
+ it("loads persisted context, compacts older messages, and persists the new assistant reply", async () => {
189
+ const { ChatSession } = await import("../builtin/index.ts");
190
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-context-"));
191
+
192
+ const seed = new ChatSession(
193
+ { apiKey: "sk-test" },
194
+ {
195
+ persist: true,
196
+ contextDir: dir,
197
+ sessionId: "integration",
198
+ compactAtTokens: 10_000,
199
+ },
200
+ );
201
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
202
+ await collect(seed.chat("old question"));
203
+
204
+ generateTextMock.mockResolvedValueOnce({ text: "## Current Task\n- old question summarized" });
205
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("new answer") });
206
+
207
+ const restored = new ChatSession(
208
+ { apiKey: "sk-test" },
209
+ {
210
+ persist: true,
211
+ contextDir: dir,
212
+ sessionId: "integration",
213
+ compactAtTokens: 1,
214
+ keepRecentMessages: 1,
215
+ },
216
+ );
217
+ const events = await collect(restored.chat("new question"));
218
+
219
+ expect(generateTextMock).toHaveBeenCalledOnce();
220
+ expect(generateTextMock).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0 }));
221
+ expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
222
+ messages: expect.arrayContaining([
223
+ expect.objectContaining({ content: expect.stringContaining("old question summarized") }),
224
+ expect.objectContaining({ role: "user", content: "new question" }),
225
+ ]),
226
+ }));
227
+ expect(events.slice(0, 3)).toEqual([
228
+ { type: "status", phase: "compacting" },
229
+ { type: "compact", compactedMessages: 2 },
230
+ { type: "status", phase: "generating" },
231
+ ]);
232
+ expect(restored.history.map((m) => m.content).join("\n")).toContain("new answer");
233
+ });
234
+
235
+ it("times out context compaction independently before reply generation", async () => {
236
+ vi.useFakeTimers();
237
+ const { ChatSession } = await import("../builtin/index.ts");
238
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-compaction-timeout-"));
239
+ const seed = new ChatSession(
240
+ { apiKey: "sk-test" },
241
+ { persist: true, contextDir: dir, sessionId: "timeout", compactAtTokens: 10_000 },
242
+ );
243
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
244
+ await collect(seed.chat("old question"));
245
+
246
+ generateTextMock.mockImplementationOnce(({ abortSignal }: { abortSignal: AbortSignal }) =>
247
+ new Promise((_resolve, reject) => {
248
+ abortSignal.addEventListener("abort", () => reject(abortSignal.reason), { once: true });
249
+ }));
250
+ const restored = new ChatSession(
251
+ { apiKey: "sk-test" },
252
+ {
253
+ persist: true,
254
+ contextDir: dir,
255
+ sessionId: "timeout",
256
+ compactAtTokens: 1,
257
+ keepRecentMessages: 1,
258
+ compactionTimeoutMs: 100,
259
+ },
260
+ );
261
+
262
+ const result = collect(restored.chat("new question"));
263
+ const timeoutAssertion = expect(result).rejects.toThrow("Context compaction timed out");
264
+ await vi.waitFor(() => expect(generateTextMock).toHaveBeenCalledOnce());
265
+ await vi.advanceTimersByTimeAsync(101);
266
+ await timeoutAssertion;
267
+ expect(streamTextMock).toHaveBeenCalledTimes(1);
268
+ });
269
+
270
+ it("streams tool calls and tool results from fullStream", async () => {
271
+ const { ChatSession } = await import("../builtin/index.ts");
272
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-tools-"));
273
+ const session = new ChatSession(
274
+ { apiKey: "sk-test" },
275
+ {
276
+ persist: true,
277
+ contextDir: dir,
278
+ sessionId: "tools",
279
+ },
280
+ );
281
+
282
+ streamTextMock.mockReturnValueOnce({
283
+ fullStream: fullStream(
284
+ { type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "package.json" } },
285
+ { type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "{}" } },
286
+ { type: "text-delta", text: "done" },
287
+ ),
288
+ });
289
+
290
+ const events = await collect(session.chat("read package"));
291
+
292
+ expect(events).toContainEqual({
293
+ type: "tool_use",
294
+ id: "call-1",
295
+ name: "read_file",
296
+ input: { path: "package.json" },
297
+ });
298
+ expect(events).toContainEqual({
299
+ type: "tool_result",
300
+ tool_use_id: "call-1",
301
+ name: "read_file",
302
+ content: { content: "{}" },
303
+ is_error: false,
304
+ });
305
+ expect(events).toContainEqual({ type: "text", text: "done", accumulated: "done" });
306
+ });
307
+
308
+ it("caps the total persisted tool transcript for a turn", async () => {
309
+ const { ChatSession } = await import("../builtin/index.ts");
310
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-tool-cap-"));
311
+ const session = new ChatSession(
312
+ { apiKey: "sk-test" },
313
+ { persist: true, contextDir: dir, sessionId: "tool-cap" },
314
+ );
315
+ const parts = Array.from({ length: 12 }, (_, index) => ({
316
+ type: "tool-result",
317
+ toolCallId: `call-${index}`,
318
+ toolName: "read_file",
319
+ output: { content: "x".repeat(8_000) },
320
+ }));
321
+ streamTextMock.mockReturnValueOnce({
322
+ fullStream: fullStream(...parts, { type: "text-delta", text: "done" }),
323
+ });
324
+
325
+ await collect(session.chat("read many files"));
326
+
327
+ const persisted = session.history.at(-1)?.content ?? "";
328
+ expect(persisted.length).toBeLessThan(40_000);
329
+ expect(persisted).toContain("tool transcript truncated");
330
+ });
331
+
332
+ it("persists structured tool calls alongside the text transcript", async () => {
333
+ const { ChatSession } = await import("../builtin/index.ts");
334
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-structured-tools-"));
335
+ const session = new ChatSession(
336
+ { apiKey: "sk-test" },
337
+ { persist: true, contextDir: dir, sessionId: "structured-tools" },
338
+ );
339
+
340
+ streamTextMock.mockReturnValueOnce({
341
+ fullStream: fullStream(
342
+ { type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: { path: "package.json" } },
343
+ { type: "tool-result", toolCallId: "call-1", toolName: "read_file", output: { content: "{}" } },
344
+ { type: "text-delta", text: "done" },
345
+ ),
346
+ });
347
+
348
+ await collect(session.chat("read package"));
349
+
350
+ const raw = await readFile(join(dir, "structured-tools", "context.json"), "utf8");
351
+ const state = JSON.parse(raw) as { messages: Array<{ content: string; toolCalls?: unknown }> };
352
+ expect(state.messages).toHaveLength(2);
353
+ expect(state.messages[1].toolCalls).toEqual([
354
+ { name: "read_file", input: "{\"path\":\"package.json\"}", output: "{\"content\":\"{}\"}" },
355
+ ]);
356
+ expect(state.messages[1].content).toContain("[Tool transcript]");
357
+ });
358
+
359
+ it("records tool errors and preserves tool call order in structured tool calls", async () => {
360
+ const { ChatSession } = await import("../builtin/index.ts");
361
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-structured-tools-order-"));
362
+ const session = new ChatSession(
363
+ { apiKey: "sk-test" },
364
+ { persist: true, contextDir: dir, sessionId: "structured-tools-order" },
365
+ );
366
+
367
+ // AI SDK 流中工具调用先全部到达(tool-call),结果后到达(tool-result/tool-error)
368
+ streamTextMock.mockReturnValueOnce({
369
+ fullStream: fullStream(
370
+ { type: "tool-call", toolCallId: "c1", toolName: "run_command", input: { command: "npm test" } },
371
+ { type: "tool-call", toolCallId: "c2", toolName: "read_file", input: { path: "a.ts" } },
372
+ { type: "tool-result", toolCallId: "c1", toolName: "run_command", output: { exitCode: 0 } },
373
+ { type: "tool-error", toolCallId: "c2", toolName: "read_file", error: new Error("boom") },
374
+ { type: "text-delta", text: "failed" },
375
+ ),
376
+ });
377
+
378
+ await collect(session.chat("run tests"));
379
+
380
+ const raw = await readFile(join(dir, "structured-tools-order", "context.json"), "utf8");
381
+ const state = JSON.parse(raw) as { messages: Array<{ toolCalls?: unknown[] }> };
382
+ expect(state.messages[1].toolCalls).toEqual([
383
+ { name: "run_command", input: "{\"command\":\"npm test\"}", output: "{\"exitCode\":0}" },
384
+ { name: "read_file", input: "{\"path\":\"a.ts\"}", output: "boom", is_error: true },
385
+ ]);
386
+ });
387
+
388
+ it("writes raw DeepCCC fullStream parts when raw stream logs are enabled", async () => {
389
+ const { ChatSession } = await import("../builtin/index.ts");
390
+ config.rawStreamLogs = {
391
+ enabled: true,
392
+ maxBytesPerTurn: 4096,
393
+ retentionDays: 3,
394
+ keepCompleted: true,
395
+ };
396
+ createRawStreamLogMock.mockResolvedValueOnce({
397
+ filePath: "raw.jsonl.gz",
398
+ writeLine: rawLogWriteLineMock,
399
+ close: rawLogCloseMock,
400
+ });
401
+ const session = new ChatSession(
402
+ { apiKey: "sk-test" },
403
+ {
404
+ sessionId: "raw-log-session",
405
+ },
406
+ );
407
+ const textPart = { type: "text-delta", text: "hello" };
408
+ const toolPart = {
409
+ type: "tool-call",
410
+ toolCallId: "call-1",
411
+ toolName: "read_file",
412
+ input: { path: "package.json" },
413
+ };
414
+
415
+ streamTextMock.mockReturnValueOnce({
416
+ fullStream: fullStream(textPart, toolPart),
417
+ });
418
+
419
+ await collect(session.chat("hi"));
420
+
421
+ expect(createRawStreamLogMock).toHaveBeenCalledWith(expect.objectContaining({
422
+ enabled: true,
423
+ tool: "deepccc",
424
+ sessionId: "raw-log-session",
425
+ label: "prompt",
426
+ maxBytesPerTurn: 4096,
427
+ retentionDays: 3,
428
+ }));
429
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(1, JSON.stringify(textPart));
430
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, JSON.stringify(toolPart));
431
+ expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: true });
432
+ });
433
+ });
434
+
435
+ describe("estimateBuiltinContextTokens", () => {
436
+ it("weights CJK characters closer to one token per character", () => {
437
+ const messages = [{ role: "user" as const, content: "你好".repeat(100) }];
438
+ const estimate = estimateBuiltinContextTokens("", messages);
439
+ // 200 个汉字 ≈ 200 tokens(旧算法 chars/3 只估到 ~70,明显低估)
440
+ expect(estimate).toBeGreaterThanOrEqual(180);
441
+ });
442
+
443
+ it("keeps latin text near chars/3.5", () => {
444
+ const messages = [{ role: "user" as const, content: "a".repeat(1000) }];
445
+ const estimate = estimateBuiltinContextTokens("", messages);
446
+ expect(estimate).toBeGreaterThan(250);
447
+ expect(estimate).toBeLessThan(340);
448
+ });
449
+ });