chatccc 0.2.196 → 0.2.198

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 (53) hide show
  1. package/agent-prompts/cursor_specific.md +13 -13
  2. package/bin/cccagent.mjs +17 -17
  3. package/config.sample.json +27 -27
  4. package/package.json +2 -1
  5. package/src/__tests__/agent-reload-config-rpc.test.ts +99 -0
  6. package/src/__tests__/builtin-chat-session.test.ts +277 -181
  7. package/src/__tests__/builtin-cli-json.test.ts +39 -39
  8. package/src/__tests__/builtin-config.test.ts +33 -33
  9. package/src/__tests__/builtin-context.test.ts +163 -163
  10. package/src/__tests__/builtin-file-tools.test.ts +224 -196
  11. package/src/__tests__/builtin-session-select.test.ts +116 -116
  12. package/src/__tests__/builtin-sigint.test.ts +56 -56
  13. package/src/__tests__/cards.test.ts +109 -109
  14. package/src/__tests__/ccc-adapter.test.ts +114 -113
  15. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  16. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  17. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  18. package/src/__tests__/claude-raw-stream-log.test.ts +87 -0
  19. package/src/__tests__/codex-raw-stream-log.test.ts +163 -0
  20. package/src/__tests__/config-reload.test.ts +10 -10
  21. package/src/__tests__/config-sample.test.ts +18 -18
  22. package/src/__tests__/cursor-adapter.test.ts +167 -113
  23. package/src/__tests__/feishu-avatar.test.ts +40 -40
  24. package/src/__tests__/jsonl-stream.test.ts +79 -0
  25. package/src/__tests__/orchestrator.test.ts +181 -154
  26. package/src/__tests__/raw-stream-log.test.ts +106 -106
  27. package/src/__tests__/session.test.ts +40 -40
  28. package/src/__tests__/sim-platform.test.ts +12 -12
  29. package/src/__tests__/web-ui.test.ts +209 -209
  30. package/src/adapters/ccc-adapter.ts +121 -119
  31. package/src/adapters/claude-adapter.ts +603 -566
  32. package/src/adapters/codex-adapter.ts +65 -52
  33. package/src/adapters/cursor-adapter.ts +269 -276
  34. package/src/adapters/jsonl-stream.ts +157 -0
  35. package/src/adapters/raw-stream-log.ts +124 -124
  36. package/src/agent-reload-config-rpc.ts +34 -0
  37. package/src/builtin/cli.ts +473 -461
  38. package/src/builtin/context.ts +323 -323
  39. package/src/builtin/file-tools.ts +1072 -915
  40. package/src/builtin/index.ts +404 -353
  41. package/src/builtin/session-select.ts +48 -48
  42. package/src/builtin/sigint.ts +50 -50
  43. package/src/cards.ts +195 -195
  44. package/src/chatgpt-subscription-rpc.ts +27 -27
  45. package/src/chatgpt-subscription.ts +299 -299
  46. package/src/chrome-devtools-guard.ts +318 -318
  47. package/src/config.ts +125 -125
  48. package/src/feishu-api.ts +49 -49
  49. package/src/index.ts +8 -13
  50. package/src/orchestrator.ts +166 -145
  51. package/src/runtime-reload.ts +34 -0
  52. package/src/session.ts +141 -141
  53. package/src/web-ui.ts +205 -205
@@ -10,6 +10,7 @@ const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped
10
10
  const mockGetCodexUsageSummary = vi.hoisted(() => vi.fn());
11
11
  const mockGetCursorUsageSummary = vi.hoisted(() => vi.fn());
12
12
  const mockGetChatGptSubscriptionStatus = vi.hoisted(() => vi.fn());
13
+ const mockReloadRuntimeConfig = vi.hoisted(() => vi.fn());
13
14
 
14
15
  vi.mock("../im-skills.ts", () => ({
15
16
  buildImSkillsPrompt: async () => "",
@@ -69,6 +70,10 @@ vi.mock("../chatgpt-subscription.ts", () => ({
69
70
  getChatGptSubscriptionStatus: mockGetChatGptSubscriptionStatus,
70
71
  }));
71
72
 
73
+ vi.mock("../runtime-reload.ts", () => ({
74
+ reloadRuntimeConfig: mockReloadRuntimeConfig,
75
+ }));
76
+
72
77
  import { handleCommand } from "../orchestrator.ts";
73
78
  import {
74
79
  _clearAdapterCacheForTest,
@@ -77,14 +82,14 @@ import {
77
82
  _setAdapterForToolForTest,
78
83
  _setSessionRegistryFileForTest,
79
84
  _setSessionToolsFileForTest,
80
- loadSessionRegistryForBinding,
81
- recordSessionRegistry,
82
- resetState,
83
- sessionInfoMap,
84
- } from "../session.ts";
85
+ loadSessionRegistryForBinding,
86
+ recordSessionRegistry,
87
+ resetState,
88
+ sessionInfoMap,
89
+ } from "../session.ts";
85
90
  import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
86
- import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
87
- import { config } from "../config.ts";
91
+ import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
92
+ import { config } from "../config.ts";
88
93
 
89
94
  function mockPlatform(kind: "wechat" | "feishu" = "wechat"): PlatformAdapter {
90
95
  return {
@@ -131,15 +136,16 @@ describe("handleCommand WeChat processing ack", () => {
131
136
  tempDir = await mkdtemp(join(tmpdir(), "chatccc-orchestrator-"));
132
137
  _setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
133
138
  _setSessionToolsFileForTest(join(tempDir, "sessions.json"));
134
- resetState();
135
- resetBindingState();
136
- config.claude.defaultAgent = true;
137
- config.cursor.defaultAgent = false;
138
- config.codex.defaultAgent = false;
139
- mockStreamStates.clear();
139
+ resetState();
140
+ resetBindingState();
141
+ config.claude.defaultAgent = true;
142
+ config.cursor.defaultAgent = false;
143
+ config.codex.defaultAgent = false;
144
+ mockStreamStates.clear();
140
145
  mockGetCodexUsageSummary.mockReset();
141
146
  mockGetCursorUsageSummary.mockReset();
142
147
  mockGetChatGptSubscriptionStatus.mockReset();
148
+ mockReloadRuntimeConfig.mockReset();
143
149
  mockGetCodexUsageSummary.mockResolvedValue({
144
150
  fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
145
151
  weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
@@ -177,6 +183,11 @@ describe("handleCommand WeChat processing ack", () => {
177
183
  reason: "Chrome CDP guard is disabled in ChatCCC config.",
178
184
  chromeCdp: { enabled: false, port: 15166, status: "skipped" },
179
185
  });
186
+ mockReloadRuntimeConfig.mockResolvedValue({
187
+ configPath: "C:\\Users\\me\\.chatccc\\config.json",
188
+ defaultAgent: "codex",
189
+ reloadedAt: "2026-07-02T05:00:00.000Z",
190
+ });
180
191
  _setAdapterForToolForTest("claude", mockAdapter());
181
192
  });
182
193
 
@@ -367,11 +378,11 @@ describe("handleCommand WeChat processing ack", () => {
367
378
  expect(userText).not.toContain("/abd");
368
379
  });
369
380
 
370
- it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
371
- const platform = mockPlatform("feishu");
372
- await recordSessionRegistry({
373
- chatId: "feishu-p2p",
374
- sessionId: "stale-sid",
381
+ it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
382
+ const platform = mockPlatform("feishu");
383
+ await recordSessionRegistry({
384
+ chatId: "feishu-p2p",
385
+ sessionId: "stale-sid",
375
386
  tool: "claude",
376
387
  chatName: "旧私聊绑定",
377
388
  running: false,
@@ -381,108 +392,108 @@ describe("handleCommand WeChat processing ack", () => {
381
392
 
382
393
  expect(platform.createGroup).not.toHaveBeenCalled();
383
394
  expect(platform.sendRawCard).toHaveBeenCalled();
384
- const registry = await loadSessionRegistryForBinding();
385
- expect(registry["feishu-p2p"]).toBeUndefined();
386
- });
387
-
388
- it("shows Claude effort switch card in an active Feishu session", async () => {
389
- const platform = mockPlatform("feishu");
390
- vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "claude-session", description: "Claude Session: sid-claude-effort" });
391
- vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-claude-effort", tool: "claude" });
392
- await recordSessionRegistry({
393
- chatId: "feishu-chat",
394
- sessionId: "sid-claude-effort",
395
- tool: "claude",
396
- chatName: "claude-session",
397
- running: false,
398
- });
399
- sessionInfoMap.set("feishu-chat", {
400
- sessionId: "sid-claude-effort",
401
- tool: "claude",
402
- turnCount: 0,
403
- lastContextTokens: 0,
404
- startTime: Date.now(),
405
- });
406
-
407
- await handleCommand(platform, "/effort", "feishu-chat", "ou-user", Date.now(), "group");
408
-
409
- expect(platform.sendRawCard).toHaveBeenCalled();
410
- const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
411
- const raw = JSON.stringify(card);
412
- expect(raw).toContain("/effort low");
413
- expect(raw).toContain("/effort xhigh");
414
- expect(raw).toContain("/effort max");
415
- });
416
-
417
- it("switches Codex effort for the current session and reflects it in /state", async () => {
418
- const platform = mockPlatform("feishu");
419
- vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "codex-session", description: "Codex Session: sid-codex-effort" });
420
- vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-codex-effort", tool: "codex" });
421
- _setAdapterForToolForTest("codex", mockAdapter("sid-codex-effort"));
422
- await recordSessionRegistry({
423
- chatId: "codex-chat",
424
- sessionId: "sid-codex-effort",
425
- tool: "codex",
426
- chatName: "codex-session",
427
- running: false,
428
- });
429
- sessionInfoMap.set("codex-chat", {
430
- sessionId: "sid-codex-effort",
431
- tool: "codex",
432
- turnCount: 0,
433
- lastContextTokens: 0,
434
- startTime: Date.now(),
435
- });
436
-
437
- await handleCommand(platform, "/effort xhigh", "codex-chat", "ou-user", Date.now(), "group");
438
- expect(platform.sendCard).toHaveBeenCalledWith(
439
- "codex-chat",
440
- "Effort 切换",
441
- expect.stringContaining("xhigh"),
442
- "green",
443
- );
444
-
445
- vi.mocked(platform.sendCard).mockClear();
446
- vi.mocked(platform.sendRawCard).mockClear();
447
- await handleCommand(platform, "/state", "codex-chat", "ou-user", Date.now() + 1, "group");
448
-
449
- expect(platform.sendRawCard).toHaveBeenCalled();
450
- const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
451
- expect(JSON.stringify(card)).toContain("xhigh");
452
- });
453
-
454
- it("rejects /effort in Cursor sessions", async () => {
455
- const platform = mockPlatform("feishu");
456
- vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "cursor-session", description: "Cursor Session: sid-cursor-effort" });
457
- vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-cursor-effort", tool: "cursor" });
458
- _setAdapterForToolForTest("cursor", mockAdapter("sid-cursor-effort"));
459
- await recordSessionRegistry({
460
- chatId: "cursor-chat",
461
- sessionId: "sid-cursor-effort",
462
- tool: "cursor",
463
- chatName: "cursor-session",
464
- running: false,
465
- });
466
- sessionInfoMap.set("cursor-chat", {
467
- sessionId: "sid-cursor-effort",
468
- tool: "cursor",
469
- turnCount: 0,
470
- lastContextTokens: 0,
471
- startTime: Date.now(),
472
- });
473
-
474
- await handleCommand(platform, "/effort high", "cursor-chat", "ou-user", Date.now(), "group");
475
-
476
- expect(platform.sendCard).toHaveBeenCalledWith(
477
- "cursor-chat",
478
- "Effort 切换",
479
- expect.stringContaining("不支持 effort"),
480
- "red",
481
- );
482
- });
483
-
484
- it("handles /usage without creating a new Feishu group", async () => {
485
- const platform = mockPlatform("feishu");
395
+ const registry = await loadSessionRegistryForBinding();
396
+ expect(registry["feishu-p2p"]).toBeUndefined();
397
+ });
398
+
399
+ it("shows Claude effort switch card in an active Feishu session", async () => {
400
+ const platform = mockPlatform("feishu");
401
+ vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "claude-session", description: "Claude Session: sid-claude-effort" });
402
+ vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-claude-effort", tool: "claude" });
403
+ await recordSessionRegistry({
404
+ chatId: "feishu-chat",
405
+ sessionId: "sid-claude-effort",
406
+ tool: "claude",
407
+ chatName: "claude-session",
408
+ running: false,
409
+ });
410
+ sessionInfoMap.set("feishu-chat", {
411
+ sessionId: "sid-claude-effort",
412
+ tool: "claude",
413
+ turnCount: 0,
414
+ lastContextTokens: 0,
415
+ startTime: Date.now(),
416
+ });
417
+
418
+ await handleCommand(platform, "/effort", "feishu-chat", "ou-user", Date.now(), "group");
419
+
420
+ expect(platform.sendRawCard).toHaveBeenCalled();
421
+ const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
422
+ const raw = JSON.stringify(card);
423
+ expect(raw).toContain("/effort low");
424
+ expect(raw).toContain("/effort xhigh");
425
+ expect(raw).toContain("/effort max");
426
+ });
427
+
428
+ it("switches Codex effort for the current session and reflects it in /state", async () => {
429
+ const platform = mockPlatform("feishu");
430
+ vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "codex-session", description: "Codex Session: sid-codex-effort" });
431
+ vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-codex-effort", tool: "codex" });
432
+ _setAdapterForToolForTest("codex", mockAdapter("sid-codex-effort"));
433
+ await recordSessionRegistry({
434
+ chatId: "codex-chat",
435
+ sessionId: "sid-codex-effort",
436
+ tool: "codex",
437
+ chatName: "codex-session",
438
+ running: false,
439
+ });
440
+ sessionInfoMap.set("codex-chat", {
441
+ sessionId: "sid-codex-effort",
442
+ tool: "codex",
443
+ turnCount: 0,
444
+ lastContextTokens: 0,
445
+ startTime: Date.now(),
446
+ });
447
+
448
+ await handleCommand(platform, "/effort xhigh", "codex-chat", "ou-user", Date.now(), "group");
449
+ expect(platform.sendCard).toHaveBeenCalledWith(
450
+ "codex-chat",
451
+ "Effort 切换",
452
+ expect.stringContaining("xhigh"),
453
+ "green",
454
+ );
455
+
456
+ vi.mocked(platform.sendCard).mockClear();
457
+ vi.mocked(platform.sendRawCard).mockClear();
458
+ await handleCommand(platform, "/state", "codex-chat", "ou-user", Date.now() + 1, "group");
459
+
460
+ expect(platform.sendRawCard).toHaveBeenCalled();
461
+ const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
462
+ expect(JSON.stringify(card)).toContain("xhigh");
463
+ });
464
+
465
+ it("rejects /effort in Cursor sessions", async () => {
466
+ const platform = mockPlatform("feishu");
467
+ vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "cursor-session", description: "Cursor Session: sid-cursor-effort" });
468
+ vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-cursor-effort", tool: "cursor" });
469
+ _setAdapterForToolForTest("cursor", mockAdapter("sid-cursor-effort"));
470
+ await recordSessionRegistry({
471
+ chatId: "cursor-chat",
472
+ sessionId: "sid-cursor-effort",
473
+ tool: "cursor",
474
+ chatName: "cursor-session",
475
+ running: false,
476
+ });
477
+ sessionInfoMap.set("cursor-chat", {
478
+ sessionId: "sid-cursor-effort",
479
+ tool: "cursor",
480
+ turnCount: 0,
481
+ lastContextTokens: 0,
482
+ startTime: Date.now(),
483
+ });
484
+
485
+ await handleCommand(platform, "/effort high", "cursor-chat", "ou-user", Date.now(), "group");
486
+
487
+ expect(platform.sendCard).toHaveBeenCalledWith(
488
+ "cursor-chat",
489
+ "Effort 切换",
490
+ expect.stringContaining("不支持 effort"),
491
+ "red",
492
+ );
493
+ });
494
+
495
+ it("handles /usage without creating a new Feishu group", async () => {
496
+ const platform = mockPlatform("feishu");
486
497
  const usage = {
487
498
  fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
488
499
  weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
@@ -626,9 +637,9 @@ describe("handleCommand WeChat processing ack", () => {
626
637
  );
627
638
  });
628
639
 
629
- it("advertises /usage in new Codex and Cursor session ready messages", async () => {
630
- const codexPlatform = mockPlatform("feishu");
631
- _setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
640
+ it("advertises /usage in new Codex and Cursor session ready messages", async () => {
641
+ const codexPlatform = mockPlatform("feishu");
642
+ _setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
632
643
 
633
644
  await handleCommand(codexPlatform, "/new codex", "feishu-p2p", "ou-user", Date.now(), "p2p");
634
645
 
@@ -652,34 +663,50 @@ describe("handleCommand WeChat processing ack", () => {
652
663
  const claudePlatform = mockPlatform("feishu");
653
664
  await handleCommand(claudePlatform, "/new claude", "feishu-p2p-2", "ou-user", Date.now(), "p2p");
654
665
 
655
- const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
656
- ([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
657
- );
658
- expect(claudeReadyCall?.[2]).not.toContain("/usage");
659
- });
660
-
661
- it("allows the hidden /new ccc entry without advertising it in normal help", async () => {
662
- const platform = mockPlatform("feishu");
663
- _setAdapterForToolForTest("ccc", mockAdapter("session-20260702-121530-a1b2c3"));
664
-
665
- await handleCommand(platform, "/new ccc", "feishu-p2p-ccc", "ou-user", Date.now(), "p2p");
666
-
667
- expect(platform.updateChatInfo).toHaveBeenCalledWith(
668
- "feishu-group",
669
- expect.any(String),
670
- "CCC Session: session-20260702-121530-a1b2c3",
671
- );
672
- expect(platform.sendCard).toHaveBeenCalledWith(
673
- "feishu-group",
674
- "CCC Agent Session Ready",
675
- expect.stringContaining("CCC Agent"),
676
- "green",
677
- );
678
-
679
- const helpPlatform = mockPlatform("feishu");
680
- await handleCommand(helpPlatform, "hello", "feishu-group-help", "ou-user", Date.now(), "group");
681
- const helpCard = vi.mocked(helpPlatform.sendRawCard).mock.calls.at(-1)?.[1] as string;
682
- expect(helpCard).not.toContain("/new ccc");
683
- expect(helpCard).not.toContain("CCC Agent");
684
- });
685
- });
666
+ const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
667
+ ([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
668
+ );
669
+ expect(claudeReadyCall?.[2]).not.toContain("/usage");
670
+ });
671
+
672
+ it("reloads runtime config for the exact /reload command", async () => {
673
+ const platform = mockPlatform("feishu");
674
+
675
+ await handleCommand(platform, "/reload", "feishu-chat", "open-1", Date.now(), "group");
676
+
677
+ expect(mockReloadRuntimeConfig).toHaveBeenCalledWith("chat-command");
678
+ expect(platform.sendText).toHaveBeenCalledWith(
679
+ "feishu-chat",
680
+ expect.stringContaining("配置已重新加载"),
681
+ );
682
+ expect(platform.sendText).toHaveBeenCalledWith(
683
+ "feishu-chat",
684
+ expect.stringContaining("默认 Agent: Codex"),
685
+ );
686
+ });
687
+
688
+ it("allows the hidden /new ccc entry without advertising it in normal help", async () => {
689
+ const platform = mockPlatform("feishu");
690
+ _setAdapterForToolForTest("ccc", mockAdapter("session-20260702-121530-a1b2c3"));
691
+
692
+ await handleCommand(platform, "/new ccc", "feishu-p2p-ccc", "ou-user", Date.now(), "p2p");
693
+
694
+ expect(platform.updateChatInfo).toHaveBeenCalledWith(
695
+ "feishu-group",
696
+ expect.any(String),
697
+ "CCC Session: session-20260702-121530-a1b2c3",
698
+ );
699
+ expect(platform.sendCard).toHaveBeenCalledWith(
700
+ "feishu-group",
701
+ "CCC Agent Session Ready",
702
+ expect.stringContaining("CCC Agent"),
703
+ "green",
704
+ );
705
+
706
+ const helpPlatform = mockPlatform("feishu");
707
+ await handleCommand(helpPlatform, "hello", "feishu-group-help", "ou-user", Date.now(), "group");
708
+ const helpCard = vi.mocked(helpPlatform.sendRawCard).mock.calls.at(-1)?.[1] as string;
709
+ expect(helpCard).not.toContain("/new ccc");
710
+ expect(helpCard).not.toContain("CCC Agent");
711
+ });
712
+ });
@@ -1,106 +1,106 @@
1
- import { gunzipSync } from "node:zlib";
2
- import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
-
6
- import { describe, expect, it } from "vitest";
7
-
8
- import {
9
- createRawStreamLog,
10
- sanitizeLogPathSegment,
11
- } from "../adapters/raw-stream-log.ts";
12
-
13
- describe("raw stream log", () => {
14
- it("does nothing when disabled", async () => {
15
- const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
16
- try {
17
- const log = await createRawStreamLog({
18
- enabled: false,
19
- rootDir: root,
20
- tool: "cursor",
21
- sessionId: "sid",
22
- label: "turn",
23
- maxBytesPerTurn: 1024,
24
- retentionDays: 7,
25
- });
26
- expect(log).toBeNull();
27
- } finally {
28
- await rm(root, { recursive: true, force: true });
29
- }
30
- });
31
-
32
- it("writes gzipped JSONL and can keep the file", async () => {
33
- const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
34
- try {
35
- const log = await createRawStreamLog({
36
- enabled: true,
37
- rootDir: root,
38
- tool: "cursor",
39
- sessionId: "sid/unsafe",
40
- label: "turn:1",
41
- maxBytesPerTurn: 1024,
42
- retentionDays: 7,
43
- });
44
- expect(log).not.toBeNull();
45
- log!.writeLine('{"type":"assistant","text":"hello"}');
46
- await log!.close({ keep: true });
47
-
48
- const raw = gunzipSync(await readFile(log!.filePath)).toString("utf-8");
49
- expect(raw).toBe('{"type":"assistant","text":"hello"}\n');
50
- expect(await stat(log!.filePath)).toBeTruthy();
51
- } finally {
52
- await rm(root, { recursive: true, force: true });
53
- }
54
- });
55
-
56
- it("writes a truncation marker once maxBytesPerTurn is exceeded", async () => {
57
- const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
58
- try {
59
- const log = await createRawStreamLog({
60
- enabled: true,
61
- rootDir: root,
62
- tool: "cursor",
63
- sessionId: "sid",
64
- label: "turn",
65
- maxBytesPerTurn: 12,
66
- retentionDays: 7,
67
- });
68
- log!.writeLine('{"a":1}');
69
- log!.writeLine('{"too":"large"}');
70
- log!.writeLine('{"ignored":true}');
71
- await log!.close({ keep: true });
72
-
73
- const raw = gunzipSync(await readFile(log!.filePath)).toString("utf-8");
74
- expect(raw).toContain('{"a":1}');
75
- expect(raw).toContain("chatccc_raw_stream_log_truncated");
76
- expect(raw).not.toContain("ignored");
77
- } finally {
78
- await rm(root, { recursive: true, force: true });
79
- }
80
- });
81
-
82
- it("removes the file when keep is false", async () => {
83
- const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
84
- try {
85
- const log = await createRawStreamLog({
86
- enabled: true,
87
- rootDir: root,
88
- tool: "cursor",
89
- sessionId: "sid",
90
- label: "turn",
91
- maxBytesPerTurn: 1024,
92
- retentionDays: 7,
93
- });
94
- log!.writeLine('{"type":"result"}');
95
- await log!.close({ keep: false });
96
- await expect(stat(log!.filePath)).rejects.toThrow();
97
- } finally {
98
- await rm(root, { recursive: true, force: true });
99
- }
100
- });
101
-
102
- it("sanitizes path segments", () => {
103
- expect(sanitizeLogPathSegment("../sid:1/2")).toBe("sid_1_2");
104
- expect(sanitizeLogPathSegment("")).toBe("unknown");
105
- });
106
- });
1
+ import { gunzipSync } from "node:zlib";
2
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import {
9
+ createRawStreamLog,
10
+ sanitizeLogPathSegment,
11
+ } from "../adapters/raw-stream-log.ts";
12
+
13
+ describe("raw stream log", () => {
14
+ it("does nothing when disabled", async () => {
15
+ const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
16
+ try {
17
+ const log = await createRawStreamLog({
18
+ enabled: false,
19
+ rootDir: root,
20
+ tool: "cursor",
21
+ sessionId: "sid",
22
+ label: "turn",
23
+ maxBytesPerTurn: 1024,
24
+ retentionDays: 7,
25
+ });
26
+ expect(log).toBeNull();
27
+ } finally {
28
+ await rm(root, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ it("writes gzipped JSONL and can keep the file", async () => {
33
+ const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
34
+ try {
35
+ const log = await createRawStreamLog({
36
+ enabled: true,
37
+ rootDir: root,
38
+ tool: "cursor",
39
+ sessionId: "sid/unsafe",
40
+ label: "turn:1",
41
+ maxBytesPerTurn: 1024,
42
+ retentionDays: 7,
43
+ });
44
+ expect(log).not.toBeNull();
45
+ log!.writeLine('{"type":"assistant","text":"hello"}');
46
+ await log!.close({ keep: true });
47
+
48
+ const raw = gunzipSync(await readFile(log!.filePath)).toString("utf-8");
49
+ expect(raw).toBe('{"type":"assistant","text":"hello"}\n');
50
+ expect(await stat(log!.filePath)).toBeTruthy();
51
+ } finally {
52
+ await rm(root, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ it("writes a truncation marker once maxBytesPerTurn is exceeded", async () => {
57
+ const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
58
+ try {
59
+ const log = await createRawStreamLog({
60
+ enabled: true,
61
+ rootDir: root,
62
+ tool: "cursor",
63
+ sessionId: "sid",
64
+ label: "turn",
65
+ maxBytesPerTurn: 12,
66
+ retentionDays: 7,
67
+ });
68
+ log!.writeLine('{"a":1}');
69
+ log!.writeLine('{"too":"large"}');
70
+ log!.writeLine('{"ignored":true}');
71
+ await log!.close({ keep: true });
72
+
73
+ const raw = gunzipSync(await readFile(log!.filePath)).toString("utf-8");
74
+ expect(raw).toContain('{"a":1}');
75
+ expect(raw).toContain("chatccc_raw_stream_log_truncated");
76
+ expect(raw).not.toContain("ignored");
77
+ } finally {
78
+ await rm(root, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ it("removes the file when keep is false", async () => {
83
+ const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
84
+ try {
85
+ const log = await createRawStreamLog({
86
+ enabled: true,
87
+ rootDir: root,
88
+ tool: "cursor",
89
+ sessionId: "sid",
90
+ label: "turn",
91
+ maxBytesPerTurn: 1024,
92
+ retentionDays: 7,
93
+ });
94
+ log!.writeLine('{"type":"result"}');
95
+ await log!.close({ keep: false });
96
+ await expect(stat(log!.filePath)).rejects.toThrow();
97
+ } finally {
98
+ await rm(root, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ it("sanitizes path segments", () => {
103
+ expect(sanitizeLogPathSegment("../sid:1/2")).toBe("sid_1_2");
104
+ expect(sanitizeLogPathSegment("")).toBe("unknown");
105
+ });
106
+ });