chatccc 0.2.231 → 0.2.233

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 (130) 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__/crash-logging.test.ts +95 -1
  47. package/src/__tests__/cursor-adapter.test.ts +268 -268
  48. package/src/__tests__/feishu-api.test.ts +60 -60
  49. package/src/__tests__/feishu-avatar.test.ts +164 -164
  50. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  51. package/src/__tests__/feishu-platform.test.ts +22 -22
  52. package/src/__tests__/format-message.test.ts +47 -47
  53. package/src/__tests__/jsonl-stream.test.ts +79 -79
  54. package/src/__tests__/package-files.test.ts +24 -24
  55. package/src/__tests__/privacy.test.ts +198 -198
  56. package/src/__tests__/progress-reducer.test.ts +121 -121
  57. package/src/__tests__/raw-stream-log.test.ts +106 -106
  58. package/src/__tests__/response-stall.test.ts +49 -49
  59. package/src/__tests__/restart.test.ts +55 -10
  60. package/src/__tests__/session.test.ts +199 -199
  61. package/src/__tests__/shared-prefix.test.ts +36 -36
  62. package/src/__tests__/sim-platform.test.ts +16 -16
  63. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  64. package/src/__tests__/stop-session.test.ts +34 -34
  65. package/src/__tests__/stream-state.test.ts +42 -42
  66. package/src/__tests__/terminal-error.test.ts +54 -54
  67. package/src/__tests__/terminal-renderer.test.ts +247 -247
  68. package/src/__tests__/update-command-guard.test.ts +144 -144
  69. package/src/__tests__/web-ui.test.ts +326 -326
  70. package/src/adapters/adapter-interface.ts +33 -33
  71. package/src/adapters/ccc-adapter.ts +141 -141
  72. package/src/adapters/claude-adapter.ts +620 -620
  73. package/src/adapters/claude-session-meta-store.ts +120 -120
  74. package/src/adapters/codex-adapter.ts +426 -426
  75. package/src/adapters/cursor-adapter.ts +681 -681
  76. package/src/adapters/jsonl-stream.ts +157 -157
  77. package/src/adapters/raw-stream-log.ts +124 -124
  78. package/src/adapters/resource-monitor.ts +140 -140
  79. package/src/agent-activity.ts +175 -175
  80. package/src/agent-delegate-task-rpc.ts +153 -153
  81. package/src/agent-delegate-task.ts +91 -91
  82. package/src/agent-reload-config-rpc.ts +34 -34
  83. package/src/agent-stop-stuck.ts +129 -129
  84. package/src/builtin/cli.ts +6 -0
  85. package/src/builtin/config.ts +84 -84
  86. package/src/builtin/context.ts +465 -391
  87. package/src/builtin/file-log.ts +38 -38
  88. package/src/builtin/file-tools.ts +34 -0
  89. package/src/builtin/index.ts +24 -23
  90. package/src/builtin/proc-tree-kill.ts +61 -61
  91. package/src/builtin/progress/cards-helpers.ts +76 -76
  92. package/src/builtin/progress/reducer.ts +113 -113
  93. package/src/builtin/progress/terminal-renderer.ts +294 -294
  94. package/src/builtin/progress/view.ts +77 -77
  95. package/src/builtin/raw-stream-log.ts +124 -124
  96. package/src/builtin/session-search.ts +363 -0
  97. package/src/builtin/session-select.ts +48 -48
  98. package/src/builtin/sigint.ts +50 -50
  99. package/src/builtin/skills.ts +205 -205
  100. package/src/builtin/web-tools.ts +313 -313
  101. package/src/card-action-routing.ts +14 -14
  102. package/src/chatgpt-subscription-rpc.ts +27 -27
  103. package/src/chatgpt-subscription.ts +299 -299
  104. package/src/chrome-devtools-guard.ts +318 -318
  105. package/src/codex-reset-actions.ts +184 -184
  106. package/src/feishu-api.ts +193 -193
  107. package/src/feishu-message-ingress.ts +195 -195
  108. package/src/feishu-platform.ts +20 -20
  109. package/src/format-message.ts +293 -293
  110. package/src/index.ts +6 -1
  111. package/src/litellm-proxy.ts +374 -374
  112. package/src/orchestrator.ts +36 -13
  113. package/src/platform-adapter.ts +6 -6
  114. package/src/privacy.ts +118 -118
  115. package/src/progress/reducer.ts +113 -113
  116. package/src/progress/terminal-renderer.ts +294 -294
  117. package/src/progress/view.ts +77 -77
  118. package/src/response-stall.ts +28 -28
  119. package/src/runtime-reload.ts +34 -34
  120. package/src/session-chat-binding.ts +82 -82
  121. package/src/session-name.ts +8 -8
  122. package/src/session.ts +201 -201
  123. package/src/shared-prefix.ts +29 -29
  124. package/src/shared.ts +44 -0
  125. package/src/sim-platform.ts +20 -20
  126. package/src/startup-lifecycle.ts +250 -250
  127. package/src/stream-state.ts +21 -21
  128. package/src/terminal-error.ts +129 -129
  129. package/src/turn-cards.ts +117 -117
  130. 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
+ });