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
@@ -1,165 +1,165 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { spawn } from "node:child_process";
3
-
4
- import {
5
- ensureChromeCdpRunning,
6
- ensureChatcccPageOpen,
7
- isChromeCdpHealthy,
8
- probeChromeCdp,
9
- resolveChromeExecutable,
10
- resolveChromeUserDataDir,
11
- } from "../chrome-devtools-guard.ts";
12
-
13
- describe("Chrome CDP guard", () => {
14
- it("treats /json/version with Browser as healthy", async () => {
15
- const fetchImpl = vi.fn(async () =>
16
- new Response(JSON.stringify({ Browser: "Chrome/144.0.0.0" }), { status: 200 }),
17
- ) as unknown as typeof fetch;
18
-
19
- await expect(isChromeCdpHealthy(15166, { fetchImpl })).resolves.toBe(true);
20
- });
21
-
22
- it("does not spawn Chrome when the CDP endpoint is already healthy", async () => {
23
- const fetchImpl = vi.fn(async () =>
24
- new Response(JSON.stringify({ webSocketDebuggerUrl: "ws://127.0.0.1:15166/devtools/browser/1" }), { status: 200 }),
25
- ) as unknown as typeof fetch;
26
- const spawnImpl = vi.fn() as unknown as typeof spawn;
27
-
28
- await expect(
29
- ensureChromeCdpRunning(
30
- { enabled: true, port: 15166, chromePath: "C:/Chrome/chrome.exe" },
31
- { fetchImpl, spawnImpl },
32
- ),
33
- ).resolves.toEqual({ ok: true, started: false, port: 15166 });
34
- expect(spawnImpl).not.toHaveBeenCalled();
35
- });
36
-
37
- it("spawns Chrome with a dedicated profile when the endpoint is unhealthy", async () => {
38
- const fetchImpl = vi.fn()
39
- .mockRejectedValueOnce(new Error("ECONNREFUSED"))
40
- .mockResolvedValue(new Response(JSON.stringify({ Browser: "Chrome/144.0.0.0" }), { status: 200 })) as unknown as typeof fetch;
41
- const unref = vi.fn();
42
- const spawnImpl = vi.fn(() => ({ unref })) as unknown as typeof spawn;
43
- const mkdirSyncImpl = vi.fn();
44
-
45
- await expect(
46
- ensureChromeCdpRunning(
47
- { enabled: true, port: 15166, chromePath: "C:/Chrome/chrome.exe" },
48
- {
49
- fetchImpl,
50
- spawnImpl,
51
- mkdirSyncImpl: mkdirSyncImpl as never,
52
- existsSyncImpl: vi.fn(() => true),
53
- env: { LOCALAPPDATA: "C:/Users/me/AppData/Local" },
54
- },
55
- ),
56
- ).resolves.toEqual({ ok: true, started: true, port: 15166 });
57
-
58
- expect(mkdirSyncImpl).toHaveBeenCalledWith("C:\\Users\\me\\AppData\\Local\\chatccc\\chrome-cdp-15166", { recursive: true });
59
- expect(spawnImpl).toHaveBeenCalledWith(
60
- "C:/Chrome/chrome.exe",
61
- expect.arrayContaining([
62
- "--remote-debugging-address=127.0.0.1",
63
- "--remote-debugging-port=15166",
64
- "--user-data-dir=C:\\Users\\me\\AppData\\Local\\chatccc\\chrome-cdp-15166",
65
- "--new-window",
66
- "about:blank",
67
- ]),
68
- expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }),
69
- );
70
- expect(unref).toHaveBeenCalled();
71
- });
72
-
73
- it("does not spawn Chrome when the port is occupied by a non-CDP service", async () => {
74
- const fetchImpl = vi.fn(async () => new Response("not found", { status: 404 })) as unknown as typeof fetch;
75
- const spawnImpl = vi.fn() as unknown as typeof spawn;
76
-
77
- await expect(probeChromeCdp(15166, { fetchImpl })).resolves.toBe("occupied");
78
- await expect(
79
- ensureChromeCdpRunning(
80
- { enabled: true, port: 15166, chromePath: "C:/Chrome/chrome.exe" },
81
- { fetchImpl, spawnImpl },
82
- ),
83
- ).resolves.toMatchObject({
84
- ok: false,
85
- started: false,
86
- port: 15166,
87
- error: expect.stringContaining("not a healthy Chrome CDP endpoint"),
88
- });
89
- expect(spawnImpl).not.toHaveBeenCalled();
90
- });
91
-
92
- it("reports a clear error when Chrome cannot be found", async () => {
93
- const fetchImpl = vi.fn(async () => { throw new Error("ECONNREFUSED"); }) as unknown as typeof fetch;
94
-
95
- await expect(
96
- ensureChromeCdpRunning(
97
- { enabled: true, port: 15166, chromePath: "" },
98
- {
99
- fetchImpl,
100
- existsSyncImpl: vi.fn(() => false),
101
- platform: "win32",
102
- env: {},
103
- },
104
- ),
105
- ).resolves.toMatchObject({
106
- ok: false,
107
- started: false,
108
- port: 15166,
109
- error: expect.stringContaining("Cannot find chrome executable"),
110
- });
111
- });
112
-
113
- it("resolves explicit and default Chrome paths", () => {
114
- expect(resolveChromeExecutable("C:/Chrome/chrome.exe", { existsSyncImpl: vi.fn(() => true) })).toBe("C:/Chrome/chrome.exe");
115
- expect(resolveChromeExecutable("", {
116
- existsSyncImpl: vi.fn((p: unknown) => String(p).endsWith("Google\\Chrome\\Application\\chrome.exe")),
117
- platform: "win32",
118
- env: { ProgramFiles: "C:/Program Files" },
119
- })).toBe("C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe");
120
- });
121
-
122
- it("uses LocalAppData for the Chrome CDP user data directory", () => {
123
- expect(resolveChromeUserDataDir(15166, { LOCALAPPDATA: "C:/Users/me/AppData/Local" }))
124
- .toBe("C:\\Users\\me\\AppData\\Local\\chatccc\\chrome-cdp-15166");
125
- });
126
-
127
- it("opens the configured ChatCCC page when no localhost or loopback page is present", async () => {
128
- const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
129
- const url = String(input);
130
- if (url === "http://127.0.0.1:15166/json/list") {
131
- return new Response(JSON.stringify([
132
- { id: "chatgpt", type: "page", url: "https://chatgpt.com/", webSocketDebuggerUrl: "ws://page" },
133
- ]), { status: 200 });
134
- }
135
- if (url === "http://127.0.0.1:15166/json/new?http%3A%2F%2Flocalhost%3A18081%2F") {
136
- expect(init?.method).toBe("PUT");
137
- return new Response(JSON.stringify({ id: "chatccc" }), { status: 200 });
138
- }
139
- throw new Error(`unexpected fetch ${url}`);
140
- }) as unknown as typeof fetch;
141
-
142
- await expect(ensureChatcccPageOpen(15166, 18081, { fetchImpl })).resolves.toEqual({
143
- ok: true,
144
- opened: true,
145
- });
146
- });
147
-
148
- it("does not open another ChatCCC page when localhost or 127.0.0.1 is already present", async () => {
149
- const fetchImpl = vi.fn(async (input: string | URL | Request) => {
150
- const url = String(input);
151
- if (url === "http://127.0.0.1:15166/json/list") {
152
- return new Response(JSON.stringify([
153
- { id: "chatccc", type: "page", url: "http://127.0.0.1:18080/", webSocketDebuggerUrl: "ws://page" },
154
- ]), { status: 200 });
155
- }
156
- throw new Error(`unexpected fetch ${url}`);
157
- }) as unknown as typeof fetch;
158
-
159
- await expect(ensureChatcccPageOpen(15166, 18080, { fetchImpl })).resolves.toEqual({
160
- ok: true,
161
- opened: false,
162
- });
163
- expect(fetchImpl).toHaveBeenCalledTimes(1);
164
- });
165
- });
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { spawn } from "node:child_process";
3
+
4
+ import {
5
+ ensureChromeCdpRunning,
6
+ ensureChatcccPageOpen,
7
+ isChromeCdpHealthy,
8
+ probeChromeCdp,
9
+ resolveChromeExecutable,
10
+ resolveChromeUserDataDir,
11
+ } from "../chrome-devtools-guard.ts";
12
+
13
+ describe("Chrome CDP guard", () => {
14
+ it("treats /json/version with Browser as healthy", async () => {
15
+ const fetchImpl = vi.fn(async () =>
16
+ new Response(JSON.stringify({ Browser: "Chrome/144.0.0.0" }), { status: 200 }),
17
+ ) as unknown as typeof fetch;
18
+
19
+ await expect(isChromeCdpHealthy(15166, { fetchImpl })).resolves.toBe(true);
20
+ });
21
+
22
+ it("does not spawn Chrome when the CDP endpoint is already healthy", async () => {
23
+ const fetchImpl = vi.fn(async () =>
24
+ new Response(JSON.stringify({ webSocketDebuggerUrl: "ws://127.0.0.1:15166/devtools/browser/1" }), { status: 200 }),
25
+ ) as unknown as typeof fetch;
26
+ const spawnImpl = vi.fn() as unknown as typeof spawn;
27
+
28
+ await expect(
29
+ ensureChromeCdpRunning(
30
+ { enabled: true, port: 15166, chromePath: "C:/Chrome/chrome.exe" },
31
+ { fetchImpl, spawnImpl },
32
+ ),
33
+ ).resolves.toEqual({ ok: true, started: false, port: 15166 });
34
+ expect(spawnImpl).not.toHaveBeenCalled();
35
+ });
36
+
37
+ it("spawns Chrome with a dedicated profile when the endpoint is unhealthy", async () => {
38
+ const fetchImpl = vi.fn()
39
+ .mockRejectedValueOnce(new Error("ECONNREFUSED"))
40
+ .mockResolvedValue(new Response(JSON.stringify({ Browser: "Chrome/144.0.0.0" }), { status: 200 })) as unknown as typeof fetch;
41
+ const unref = vi.fn();
42
+ const spawnImpl = vi.fn(() => ({ unref })) as unknown as typeof spawn;
43
+ const mkdirSyncImpl = vi.fn();
44
+
45
+ await expect(
46
+ ensureChromeCdpRunning(
47
+ { enabled: true, port: 15166, chromePath: "C:/Chrome/chrome.exe" },
48
+ {
49
+ fetchImpl,
50
+ spawnImpl,
51
+ mkdirSyncImpl: mkdirSyncImpl as never,
52
+ existsSyncImpl: vi.fn(() => true),
53
+ env: { LOCALAPPDATA: "C:/Users/me/AppData/Local" },
54
+ },
55
+ ),
56
+ ).resolves.toEqual({ ok: true, started: true, port: 15166 });
57
+
58
+ expect(mkdirSyncImpl).toHaveBeenCalledWith("C:\\Users\\me\\AppData\\Local\\chatccc\\chrome-cdp-15166", { recursive: true });
59
+ expect(spawnImpl).toHaveBeenCalledWith(
60
+ "C:/Chrome/chrome.exe",
61
+ expect.arrayContaining([
62
+ "--remote-debugging-address=127.0.0.1",
63
+ "--remote-debugging-port=15166",
64
+ "--user-data-dir=C:\\Users\\me\\AppData\\Local\\chatccc\\chrome-cdp-15166",
65
+ "--new-window",
66
+ "about:blank",
67
+ ]),
68
+ expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }),
69
+ );
70
+ expect(unref).toHaveBeenCalled();
71
+ });
72
+
73
+ it("does not spawn Chrome when the port is occupied by a non-CDP service", async () => {
74
+ const fetchImpl = vi.fn(async () => new Response("not found", { status: 404 })) as unknown as typeof fetch;
75
+ const spawnImpl = vi.fn() as unknown as typeof spawn;
76
+
77
+ await expect(probeChromeCdp(15166, { fetchImpl })).resolves.toBe("occupied");
78
+ await expect(
79
+ ensureChromeCdpRunning(
80
+ { enabled: true, port: 15166, chromePath: "C:/Chrome/chrome.exe" },
81
+ { fetchImpl, spawnImpl },
82
+ ),
83
+ ).resolves.toMatchObject({
84
+ ok: false,
85
+ started: false,
86
+ port: 15166,
87
+ error: expect.stringContaining("not a healthy Chrome CDP endpoint"),
88
+ });
89
+ expect(spawnImpl).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("reports a clear error when Chrome cannot be found", async () => {
93
+ const fetchImpl = vi.fn(async () => { throw new Error("ECONNREFUSED"); }) as unknown as typeof fetch;
94
+
95
+ await expect(
96
+ ensureChromeCdpRunning(
97
+ { enabled: true, port: 15166, chromePath: "" },
98
+ {
99
+ fetchImpl,
100
+ existsSyncImpl: vi.fn(() => false),
101
+ platform: "win32",
102
+ env: {},
103
+ },
104
+ ),
105
+ ).resolves.toMatchObject({
106
+ ok: false,
107
+ started: false,
108
+ port: 15166,
109
+ error: expect.stringContaining("Cannot find chrome executable"),
110
+ });
111
+ });
112
+
113
+ it("resolves explicit and default Chrome paths", () => {
114
+ expect(resolveChromeExecutable("C:/Chrome/chrome.exe", { existsSyncImpl: vi.fn(() => true) })).toBe("C:/Chrome/chrome.exe");
115
+ expect(resolveChromeExecutable("", {
116
+ existsSyncImpl: vi.fn((p: unknown) => String(p).endsWith("Google\\Chrome\\Application\\chrome.exe")),
117
+ platform: "win32",
118
+ env: { ProgramFiles: "C:/Program Files" },
119
+ })).toBe("C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe");
120
+ });
121
+
122
+ it("uses LocalAppData for the Chrome CDP user data directory", () => {
123
+ expect(resolveChromeUserDataDir(15166, { LOCALAPPDATA: "C:/Users/me/AppData/Local" }))
124
+ .toBe("C:\\Users\\me\\AppData\\Local\\chatccc\\chrome-cdp-15166");
125
+ });
126
+
127
+ it("opens the configured ChatCCC page when no localhost or loopback page is present", async () => {
128
+ const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
129
+ const url = String(input);
130
+ if (url === "http://127.0.0.1:15166/json/list") {
131
+ return new Response(JSON.stringify([
132
+ { id: "chatgpt", type: "page", url: "https://chatgpt.com/", webSocketDebuggerUrl: "ws://page" },
133
+ ]), { status: 200 });
134
+ }
135
+ if (url === "http://127.0.0.1:15166/json/new?http%3A%2F%2Flocalhost%3A18081%2F") {
136
+ expect(init?.method).toBe("PUT");
137
+ return new Response(JSON.stringify({ id: "chatccc" }), { status: 200 });
138
+ }
139
+ throw new Error(`unexpected fetch ${url}`);
140
+ }) as unknown as typeof fetch;
141
+
142
+ await expect(ensureChatcccPageOpen(15166, 18081, { fetchImpl })).resolves.toEqual({
143
+ ok: true,
144
+ opened: true,
145
+ });
146
+ });
147
+
148
+ it("does not open another ChatCCC page when localhost or 127.0.0.1 is already present", async () => {
149
+ const fetchImpl = vi.fn(async (input: string | URL | Request) => {
150
+ const url = String(input);
151
+ if (url === "http://127.0.0.1:15166/json/list") {
152
+ return new Response(JSON.stringify([
153
+ { id: "chatccc", type: "page", url: "http://127.0.0.1:18080/", webSocketDebuggerUrl: "ws://page" },
154
+ ]), { status: 200 });
155
+ }
156
+ throw new Error(`unexpected fetch ${url}`);
157
+ }) as unknown as typeof fetch;
158
+
159
+ await expect(ensureChatcccPageOpen(15166, 18080, { fetchImpl })).resolves.toEqual({
160
+ ok: true,
161
+ opened: false,
162
+ });
163
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
164
+ });
165
+ });
@@ -0,0 +1,87 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const resumeSessionMock = vi.hoisted(() => vi.fn());
4
+ const createRawStreamLogMock = vi.hoisted(() => vi.fn());
5
+ const rawLogWriteLineMock = vi.hoisted(() => vi.fn());
6
+ const rawLogCloseMock = vi.hoisted(() => vi.fn());
7
+
8
+ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
9
+ getSessionInfo: vi.fn(),
10
+ unstable_v2_createSession: vi.fn(),
11
+ unstable_v2_resumeSession: resumeSessionMock,
12
+ }));
13
+
14
+ vi.mock("../adapters/raw-stream-log.ts", () => ({
15
+ createRawStreamLog: createRawStreamLogMock,
16
+ }));
17
+
18
+ import { config } from "../config.ts";
19
+ import { createClaudeAdapter } from "../adapters/claude-adapter.ts";
20
+
21
+ const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
22
+
23
+ async function collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
24
+ const events: unknown[] = [];
25
+ for await (const event of iterable) events.push(event);
26
+ return events;
27
+ }
28
+
29
+ afterEach(() => {
30
+ resumeSessionMock.mockReset();
31
+ createRawStreamLogMock.mockReset();
32
+ rawLogWriteLineMock.mockReset();
33
+ rawLogCloseMock.mockReset();
34
+ config.rawStreamLogs = structuredClone(originalRawStreamLogs);
35
+ });
36
+
37
+ describe("Claude raw stream logs", () => {
38
+ it("writes raw Claude SDK stream messages when enabled", async () => {
39
+ const rawMessages = [
40
+ { type: "system", subtype: "init", session_id: "sid-raw", cwd: "F:/project", model: "claude-test" },
41
+ { type: "assistant", message: { content: [{ type: "text", text: "hello" }] } },
42
+ ];
43
+ const session = {
44
+ send: vi.fn(async () => {}),
45
+ stream: async function* () {
46
+ for (const msg of rawMessages) yield msg;
47
+ },
48
+ close: vi.fn(),
49
+ };
50
+ resumeSessionMock.mockReturnValueOnce(session);
51
+ createRawStreamLogMock.mockResolvedValueOnce({
52
+ filePath: "claude.jsonl.gz",
53
+ writeLine: rawLogWriteLineMock,
54
+ close: rawLogCloseMock,
55
+ });
56
+ config.rawStreamLogs.claude = {
57
+ enabled: true,
58
+ maxBytesPerTurn: 4096,
59
+ retentionDays: 5,
60
+ keepCompleted: false,
61
+ };
62
+
63
+ const adapter = createClaudeAdapter({
64
+ model: "claude-test",
65
+ effort: "high",
66
+ isEmpty: (value) => value.trim() === "",
67
+ });
68
+ const events = await collect(adapter.prompt("sid-raw", "hi", "F:/project"));
69
+
70
+ expect(createRawStreamLogMock).toHaveBeenCalledWith(expect.objectContaining({
71
+ enabled: true,
72
+ tool: "claude",
73
+ sessionId: "sid-raw",
74
+ label: "prompt",
75
+ maxBytesPerTurn: 4096,
76
+ retentionDays: 5,
77
+ }));
78
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(1, JSON.stringify(rawMessages[0]));
79
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, JSON.stringify(rawMessages[1]));
80
+ expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: false });
81
+ expect(session.close).toHaveBeenCalledOnce();
82
+ expect(events).toContainEqual({
83
+ type: "assistant",
84
+ blocks: [{ type: "text", text: "hello" }],
85
+ });
86
+ });
87
+ });
@@ -0,0 +1,163 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { PassThrough } from "node:stream";
3
+
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const spawnMock = vi.hoisted(() => vi.fn());
7
+ const createRawStreamLogMock = vi.hoisted(() => vi.fn());
8
+ const rawLogWriteLineMock = vi.hoisted(() => vi.fn());
9
+ const rawLogCloseMock = vi.hoisted(() => vi.fn());
10
+ const killProcessTreeMock = vi.hoisted(() => vi.fn(async () => {}));
11
+
12
+ vi.mock("node:child_process", () => ({
13
+ spawn: spawnMock,
14
+ }));
15
+
16
+ vi.mock("../adapters/raw-stream-log.ts", () => ({
17
+ createRawStreamLog: createRawStreamLogMock,
18
+ }));
19
+
20
+ vi.mock("../adapters/proc-tree-kill.ts", () => ({
21
+ killProcessTree: killProcessTreeMock,
22
+ }));
23
+
24
+ import { config } from "../config.ts";
25
+ import { createCodexAdapter } from "../adapters/codex-adapter.ts";
26
+ import { BadJsonIdleTimeoutError } from "../adapters/jsonl-stream.ts";
27
+ import type { CodexSessionMetaStore } from "../adapters/codex-session-meta-store.ts";
28
+
29
+ const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
30
+
31
+ function createProc(lines: string[]): EventEmitter & {
32
+ stdout: PassThrough;
33
+ stderr: PassThrough;
34
+ stdin: PassThrough;
35
+ pid: number;
36
+ } {
37
+ const proc = new EventEmitter() as EventEmitter & {
38
+ stdout: PassThrough;
39
+ stderr: PassThrough;
40
+ stdin: PassThrough;
41
+ pid: number;
42
+ };
43
+ proc.stdout = new PassThrough();
44
+ proc.stderr = new PassThrough();
45
+ proc.stdin = new PassThrough();
46
+ proc.pid = 4242;
47
+ queueMicrotask(() => {
48
+ for (const line of lines) proc.stdout.write(`${line}\n`);
49
+ proc.stdout.end();
50
+ proc.stderr.end();
51
+ proc.emit("close", 0);
52
+ });
53
+ return proc;
54
+ }
55
+
56
+ function createHangingProc(lines: string[]): EventEmitter & {
57
+ stdout: PassThrough;
58
+ stderr: PassThrough;
59
+ stdin: PassThrough;
60
+ pid: number;
61
+ } {
62
+ const proc = new EventEmitter() as EventEmitter & {
63
+ stdout: PassThrough;
64
+ stderr: PassThrough;
65
+ stdin: PassThrough;
66
+ pid: number;
67
+ };
68
+ proc.stdout = new PassThrough();
69
+ proc.stderr = new PassThrough();
70
+ proc.stdin = new PassThrough();
71
+ proc.pid = 4242;
72
+ queueMicrotask(() => {
73
+ for (const line of lines) proc.stdout.write(`${line}\n`);
74
+ });
75
+ return proc;
76
+ }
77
+
78
+ async function collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
79
+ const events: unknown[] = [];
80
+ for await (const event of iterable) events.push(event);
81
+ return events;
82
+ }
83
+
84
+ function metaStore(): CodexSessionMetaStore {
85
+ return {
86
+ async get(sessionId) {
87
+ return sessionId === "sid-raw" ? { cwd: "F:/project" } : undefined;
88
+ },
89
+ async set() {},
90
+ async setThreadId() {},
91
+ };
92
+ }
93
+
94
+ afterEach(() => {
95
+ vi.useRealTimers();
96
+ spawnMock.mockReset();
97
+ createRawStreamLogMock.mockReset();
98
+ rawLogWriteLineMock.mockReset();
99
+ rawLogCloseMock.mockReset();
100
+ killProcessTreeMock.mockClear();
101
+ config.rawStreamLogs = structuredClone(originalRawStreamLogs);
102
+ });
103
+
104
+ describe("Codex raw stream logs", () => {
105
+ it("writes raw Codex JSONL stdout lines when enabled", async () => {
106
+ const lines = [
107
+ JSON.stringify({ type: "thread.started", thread_id: "thread-1" }),
108
+ JSON.stringify({ type: "item.completed", item: { id: "item-1", type: "agent_message", text: "hello" } }),
109
+ JSON.stringify({ type: "turn.completed" }),
110
+ ];
111
+ spawnMock.mockReturnValueOnce(createProc(lines));
112
+ createRawStreamLogMock.mockResolvedValueOnce({
113
+ filePath: "codex.jsonl.gz",
114
+ writeLine: rawLogWriteLineMock,
115
+ close: rawLogCloseMock,
116
+ });
117
+ config.rawStreamLogs.codex = {
118
+ enabled: true,
119
+ maxBytesPerTurn: 2048,
120
+ retentionDays: 2,
121
+ keepCompleted: false,
122
+ };
123
+
124
+ const adapter = createCodexAdapter({ metaStore: metaStore() });
125
+ const events = await collect(adapter.prompt("sid-raw", "hi", "F:/project"));
126
+
127
+ expect(createRawStreamLogMock).toHaveBeenCalledWith(expect.objectContaining({
128
+ enabled: true,
129
+ tool: "codex",
130
+ sessionId: "sid-raw",
131
+ label: "prompt",
132
+ maxBytesPerTurn: 2048,
133
+ retentionDays: 2,
134
+ }));
135
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(1, lines[0]);
136
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, lines[1]);
137
+ expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(3, lines[2]);
138
+ expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: false });
139
+ expect(events).toContainEqual({
140
+ type: "assistant",
141
+ blocks: [{ type: "text", text: "hello" }],
142
+ });
143
+ });
144
+
145
+ it("fails the turn and kills the process tree when bad JSON is followed by idle stdout", async () => {
146
+ vi.useFakeTimers();
147
+ spawnMock.mockReturnValueOnce(createHangingProc([
148
+ "{\"type\":\"item.started\",\"item\":{\"type\":\"command_execution\"",
149
+ ]));
150
+ createRawStreamLogMock.mockResolvedValueOnce(null);
151
+
152
+ const adapter = createCodexAdapter({ metaStore: metaStore() });
153
+ const pending = collect(adapter.prompt("sid-raw", "hi", "F:/project"))
154
+ .catch((error: unknown) => error);
155
+
156
+ await Promise.resolve();
157
+ await vi.advanceTimersByTimeAsync(120_000);
158
+
159
+ const error = await pending;
160
+ expect(error).toBeInstanceOf(BadJsonIdleTimeoutError);
161
+ expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
162
+ });
163
+ });
@@ -45,12 +45,12 @@ const baseAppConfig: AppConfig = {
45
45
  port: 18080,
46
46
  gitTimeoutSeconds: 180,
47
47
  allowInterrupt: false,
48
- rawStreamLogs: {
49
- claude: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
50
- cursor: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
51
- codex: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
52
- ccc: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
53
- },
48
+ rawStreamLogs: {
49
+ claude: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
50
+ cursor: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
51
+ codex: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
52
+ ccc: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
53
+ },
54
54
  claude: {
55
55
  enabled: true,
56
56
  defaultAgent: true,
@@ -69,10 +69,10 @@ const baseAppConfig: AppConfig = {
69
69
  alternativeModel: "initial-cursor-alt-model",
70
70
  avatarBatteryMode: "apiPercent",
71
71
  onDemandMonthlyBudget: 1000,
72
- },
73
- codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", alternativeModel: "initial-codex-alt-model", effort: "initial-codex-effort" },
74
- ccc: { DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model" },
75
- };
72
+ },
73
+ codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", alternativeModel: "initial-codex-alt-model", effort: "initial-codex-effort" },
74
+ ccc: { DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model" },
75
+ };
76
76
 
77
77
  // 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
78
78
  // 不直接用启动时的 config 引用做 snapshot——它可能已经被前一个 it 改写。
@@ -28,29 +28,29 @@ describe("config.sample.json", () => {
28
28
  expect(sample.claude?.subagentModel).toBe("");
29
29
  });
30
30
 
31
- it("leaves Cursor and Codex alternative models empty by default", () => {
31
+ it("leaves Cursor and Codex alternative models empty by default", () => {
32
32
  const configSamplePath = join(process.cwd(), "config.sample.json");
33
33
  const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
34
34
  cursor?: { alternativeModel?: unknown };
35
35
  codex?: { alternativeModel?: unknown };
36
36
  };
37
37
 
38
- expect(sample.cursor?.alternativeModel).toBe("");
39
- expect(sample.codex?.alternativeModel).toBe("");
40
- });
41
-
42
- it("sets ccc agent DeepSeek defaults in the sample config", () => {
43
- const configSamplePath = join(process.cwd(), "config.sample.json");
44
- const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
45
- ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
46
- };
47
-
48
- expect(sample.ccc?.DEEPSEEK_API_KEY).toBe("");
49
- expect(sample.ccc?.DEEPSEEK_BASE_URL).toBe("https://api.deepseek.com/v1");
50
- expect(sample.ccc?.model).toBe("deepseek-v4-pro");
51
- });
52
-
53
- it("keeps Chrome CDP guard disabled by default with port 15166", () => {
38
+ expect(sample.cursor?.alternativeModel).toBe("");
39
+ expect(sample.codex?.alternativeModel).toBe("");
40
+ });
41
+
42
+ it("sets ccc agent DeepSeek defaults in the sample config", () => {
43
+ const configSamplePath = join(process.cwd(), "config.sample.json");
44
+ const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
45
+ ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
46
+ };
47
+
48
+ expect(sample.ccc?.DEEPSEEK_API_KEY).toBe("");
49
+ expect(sample.ccc?.DEEPSEEK_BASE_URL).toBe("https://api.deepseek.com/v1");
50
+ expect(sample.ccc?.model).toBe("deepseek-v4-pro");
51
+ });
52
+
53
+ it("keeps Chrome CDP guard disabled by default with port 15166", () => {
54
54
  const configSamplePath = join(process.cwd(), "config.sample.json");
55
55
  const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
56
56
  chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
@@ -72,7 +72,7 @@ describe("config.sample.json", () => {
72
72
  }>;
73
73
  };
74
74
 
75
- for (const tool of ["claude", "cursor", "codex", "ccc"]) {
75
+ for (const tool of ["claude", "cursor", "codex", "ccc"]) {
76
76
  expect(sample.rawStreamLogs?.[tool]?.enabled).toBe(false);
77
77
  expect(sample.rawStreamLogs?.[tool]?.maxBytesPerTurn).toBe(52_428_800);
78
78
  expect(sample.rawStreamLogs?.[tool]?.retentionDays).toBe(7);