chatccc 0.2.197 → 0.2.199

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 (52) 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 -99
  6. package/src/__tests__/builtin-chat-session.test.ts +277 -277
  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 -224
  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 -114
  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 -87
  19. package/src/__tests__/codex-raw-stream-log.test.ts +163 -120
  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 +114 -1
  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 +200 -200
  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 -121
  31. package/src/adapters/claude-adapter.ts +603 -603
  32. package/src/adapters/codex-adapter.ts +380 -392
  33. package/src/adapters/cursor-adapter.ts +56 -30
  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 -34
  37. package/src/builtin/cli.ts +473 -473
  38. package/src/builtin/context.ts +323 -323
  39. package/src/builtin/file-tools.ts +1072 -1072
  40. package/src/builtin/index.ts +404 -404
  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/orchestrator.ts +179 -179
  50. package/src/runtime-reload.ts +34 -34
  51. package/src/session.ts +141 -141
  52. package/src/web-ui.ts +205 -205
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
@@ -10,6 +10,7 @@ import {
10
10
  createCursorAdapter,
11
11
  formatCursorAgentEmptyOutputMessage,
12
12
  } from "../adapters/cursor-adapter.ts";
13
+ import { BadJsonIdleTimeoutError } from "../adapters/jsonl-stream.ts";
13
14
  import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
14
15
  import type {
15
16
  CursorSessionMeta,
@@ -82,6 +83,33 @@ function createMockCursorProcess(args: {
82
83
  return child;
83
84
  }
84
85
 
86
+ function createHangingMockCursorProcess(args: {
87
+ stdout?: string;
88
+ stderr?: string;
89
+ }): ChildProcess {
90
+ const child = new EventEmitter() as ChildProcess;
91
+ const stdout = new PassThrough();
92
+ const stderr = new PassThrough();
93
+ const stdin = new PassThrough();
94
+ Object.assign(child, {
95
+ stdout,
96
+ stderr,
97
+ stdin,
98
+ pid: undefined,
99
+ });
100
+
101
+ queueMicrotask(() => {
102
+ if (args.stdout) stdout.write(args.stdout);
103
+ if (args.stderr) stderr.write(args.stderr);
104
+ });
105
+
106
+ return child;
107
+ }
108
+
109
+ afterEach(() => {
110
+ vi.useRealTimers();
111
+ });
112
+
85
113
  // ---------------------------------------------------------------------------
86
114
  // normalizeCursorMessage — 核心映射逻辑测试(纯函数)
87
115
  // ---------------------------------------------------------------------------
@@ -716,6 +744,33 @@ describe("Cursor adapter process failures", () => {
716
744
  expect(message).not.toContain("登录态已失效");
717
745
  });
718
746
 
747
+ it("formats empty stdout action-required stderr with the actionable Cursor error", () => {
748
+ const message = formatCursorAgentEmptyOutputMessage({
749
+ exitCode: 1,
750
+ stdoutLength: 0,
751
+ stderr: "ActionRequiredError: You have an unpaid invoice Your team has an unpaid invoice. Please contact your team administrator to pay your invoice and continue using Cursor.\n",
752
+ });
753
+
754
+ expect(message).toContain("底层命令异常退出");
755
+ expect(message).toContain("[Cursor stderr] exit=1");
756
+ expect(message).toContain("ActionRequiredError");
757
+ expect(message).toContain("unpaid invoice");
758
+ expect(message).toContain("team administrator");
759
+ });
760
+
761
+ it("redacts obvious secrets from visible Cursor stderr", () => {
762
+ const message = formatCursorAgentEmptyOutputMessage({
763
+ exitCode: 1,
764
+ stdoutLength: 0,
765
+ stderr: "Error: failed with token=secret-token-value and Bearer abcdefghijklmnop\n",
766
+ });
767
+
768
+ expect(message).toContain("token=<redacted>");
769
+ expect(message).toContain("Bearer <redacted>");
770
+ expect(message).not.toContain("secret-token-value");
771
+ expect(message).not.toContain("abcdefghijklmnop");
772
+ });
773
+
719
774
  it("does not format a message when stdout is non-empty", () => {
720
775
  expect(
721
776
  formatCursorAgentEmptyOutputMessage({
@@ -757,4 +812,62 @@ describe("Cursor adapter process failures", () => {
757
812
  /Cursor Agent exited without stream-json output/,
758
813
  );
759
814
  });
815
+
816
+ it("prompt surfaces action-required stderr before failing the stream", async () => {
817
+ const store = createInMemoryMetaStore({ sid: { cwd: "F:/repo" } });
818
+ const spawnImpl = (() =>
819
+ createMockCursorProcess({
820
+ exitCode: 1,
821
+ stderr: "ActionRequiredError: You have an unpaid invoice Your team has an unpaid invoice. Please contact your team administrator to pay your invoice and continue using Cursor.\n",
822
+ })) as CursorSpawnForTest;
823
+ const adapter = createCursorAdapter({ metaStore: store, spawn: spawnImpl });
824
+ const iterator = adapter.prompt(
825
+ "sid",
826
+ "[User message]\nhello\n[/User message]",
827
+ "F:/repo",
828
+ )[Symbol.asyncIterator]();
829
+
830
+ const first = await iterator.next();
831
+ expect(first.done).toBe(false);
832
+ expect(first.value.blocks).toHaveLength(1);
833
+ expect(first.value.blocks[0]).toMatchObject({ type: "text_final" });
834
+ expect(first.value.blocks[0]).toHaveProperty(
835
+ "text",
836
+ expect.stringContaining("ActionRequiredError"),
837
+ );
838
+ expect(first.value.blocks[0]).toHaveProperty(
839
+ "text",
840
+ expect.stringContaining("unpaid invoice"),
841
+ );
842
+
843
+ await expect(iterator.next()).rejects.toThrow(
844
+ /Cursor Agent exited without stream-json output/,
845
+ );
846
+ });
847
+
848
+ it("prompt fails when Cursor emits bad JSON and then leaves stdout idle", async () => {
849
+ const store = createInMemoryMetaStore({ sid: { cwd: "F:/repo" } });
850
+ const spawnImpl = (() =>
851
+ createHangingMockCursorProcess({
852
+ stdout: "{\"type\":\"tool_call\",\"subtype\":\"started\"\n",
853
+ })) as CursorSpawnForTest;
854
+ const adapter = createCursorAdapter({
855
+ metaStore: store,
856
+ spawn: spawnImpl,
857
+ badJsonIdleTimeoutMs: 10,
858
+ });
859
+
860
+ const pending = (async () => {
861
+ for await (const _event of adapter.prompt(
862
+ "sid",
863
+ "[User message]\nhello\n[/User message]",
864
+ "F:/repo",
865
+ )) {
866
+ // No normalized events are expected before the watchdog fires.
867
+ }
868
+ })().catch((error: unknown) => error);
869
+
870
+ const error = await pending;
871
+ expect(error).toBeInstanceOf(BadJsonIdleTimeoutError);
872
+ });
760
873
  });
@@ -1,4 +1,4 @@
1
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -68,7 +68,7 @@ function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void
68
68
  }));
69
69
  }
70
70
 
71
- function mockAvatarUploadOnlyFetch(uploadedNames: string[]): ReturnType<typeof vi.fn> {
71
+ function mockAvatarUploadOnlyFetch(uploadedNames: string[]): ReturnType<typeof vi.fn> {
72
72
  const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
73
73
  const urlText = String(url);
74
74
  if (urlText === "https://open.feishu.test/im/v1/images") {
@@ -83,44 +83,44 @@ function mockAvatarUploadOnlyFetch(uploadedNames: string[]): ReturnType<typeof v
83
83
  throw new Error(`unexpected fetch: ${urlText}`);
84
84
  });
85
85
  vi.stubGlobal("fetch", fetchMock);
86
- return fetchMock;
87
- }
88
-
89
- describe("Plain avatar fallback", () => {
90
- afterEach(() => {
91
- vi.unstubAllGlobals();
92
- vi.doUnmock("node:os");
93
- vi.doUnmock("../config.ts");
94
- vi.doUnmock("../cursor-usage.ts");
95
- vi.restoreAllMocks();
96
- getCursorUsageSummaryMock.mockReset();
97
- mockConfig.cursor.avatarBatteryMode = "apiPercent";
98
- mockConfig.cursor.onDemandMonthlyBudget = 1000;
99
- });
100
-
101
- it("uses a status-only avatar for unknown tools instead of falling back to Claude", async () => {
102
- const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
103
- const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
104
- const uploadedNames: string[] = [];
105
- mockAvatarUploadOnlyFetch(uploadedNames);
106
-
107
- try {
108
- const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
109
- await setChatAvatar("tenant-token", "chat_1", "ccc", "new");
110
-
111
- expect(uploadedNames).toEqual(["avatar_plain_new.jpg"]);
112
- const cacheRaw = await readFile(join(userDataDir, "state", "avatar-image-keys.json"), "utf-8");
113
- const cache = JSON.parse(cacheRaw) as Record<string, string>;
114
- expect(cache["plain:new"]).toBe("img_test");
115
- expect(cache["claude:new"]).toBeUndefined();
116
- } finally {
117
- await rm(homeDir, { recursive: true, force: true });
118
- await rm(userDataDir, { recursive: true, force: true });
119
- }
120
- });
121
- });
122
-
123
- describe("Codex avatar usage battery", () => {
86
+ return fetchMock;
87
+ }
88
+
89
+ describe("Plain avatar fallback", () => {
90
+ afterEach(() => {
91
+ vi.unstubAllGlobals();
92
+ vi.doUnmock("node:os");
93
+ vi.doUnmock("../config.ts");
94
+ vi.doUnmock("../cursor-usage.ts");
95
+ vi.restoreAllMocks();
96
+ getCursorUsageSummaryMock.mockReset();
97
+ mockConfig.cursor.avatarBatteryMode = "apiPercent";
98
+ mockConfig.cursor.onDemandMonthlyBudget = 1000;
99
+ });
100
+
101
+ it("uses a status-only avatar for unknown tools instead of falling back to Claude", async () => {
102
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
103
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
104
+ const uploadedNames: string[] = [];
105
+ mockAvatarUploadOnlyFetch(uploadedNames);
106
+
107
+ try {
108
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
109
+ await setChatAvatar("tenant-token", "chat_1", "ccc", "new");
110
+
111
+ expect(uploadedNames).toEqual(["avatar_plain_new.jpg"]);
112
+ const cacheRaw = await readFile(join(userDataDir, "state", "avatar-image-keys.json"), "utf-8");
113
+ const cache = JSON.parse(cacheRaw) as Record<string, string>;
114
+ expect(cache["plain:new"]).toBe("img_test");
115
+ expect(cache["claude:new"]).toBeUndefined();
116
+ } finally {
117
+ await rm(homeDir, { recursive: true, force: true });
118
+ await rm(userDataDir, { recursive: true, force: true });
119
+ }
120
+ });
121
+ });
122
+
123
+ describe("Codex avatar usage battery", () => {
124
124
  afterEach(() => {
125
125
  vi.unstubAllGlobals();
126
126
  vi.doUnmock("node:os");
@@ -0,0 +1,79 @@
1
+ import { PassThrough } from "node:stream";
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ BadJsonIdleTimeoutError,
7
+ readJsonLinesWithBadJsonIdleWatchdog,
8
+ } from "../adapters/jsonl-stream.ts";
9
+
10
+ function createReader(input: PassThrough, idleTimeoutMs = 100): AsyncIterator<unknown> {
11
+ return readJsonLinesWithBadJsonIdleWatchdog({
12
+ input,
13
+ tool: "test-agent",
14
+ tag: "sid-test",
15
+ idleTimeoutMs,
16
+ parse: (line) => JSON.parse(line) as unknown,
17
+ })[Symbol.asyncIterator]();
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ describe("readJsonLinesWithBadJsonIdleWatchdog", () => {
25
+ it("throws when a JSON-like bad line remains the last stdout past the idle timeout", async () => {
26
+ vi.useFakeTimers();
27
+ const input = new PassThrough();
28
+ const iterator = createReader(input);
29
+
30
+ const pending = iterator.next().catch((error: unknown) => error);
31
+ input.write("{\"type\":\"tool_call\",\"subtype\":\"started\"\n");
32
+
33
+ await vi.advanceTimersByTimeAsync(100);
34
+
35
+ const error = await pending;
36
+ expect(error).toBeInstanceOf(BadJsonIdleTimeoutError);
37
+ expect(error).toMatchObject({
38
+ code: "BAD_JSON_IDLE_TIMEOUT",
39
+ tool: "test-agent",
40
+ tag: "sid-test",
41
+ });
42
+ });
43
+
44
+ it("does not throw when a valid JSON line arrives after the bad line before timeout", async () => {
45
+ vi.useFakeTimers();
46
+ const input = new PassThrough();
47
+ const iterator = createReader(input);
48
+
49
+ const pending = iterator.next();
50
+ input.write("{\"type\":\"tool_call\"\n");
51
+ await vi.advanceTimersByTimeAsync(50);
52
+ input.write("{\"type\":\"ok\"}\n");
53
+
54
+ await expect(pending).resolves.toEqual({
55
+ done: false,
56
+ value: { type: "ok" },
57
+ });
58
+
59
+ const done = iterator.next();
60
+ input.end();
61
+ await expect(done).resolves.toEqual({ done: true, value: undefined });
62
+ });
63
+
64
+ it("ignores non-JSON banner lines for the bad JSON idle watchdog", async () => {
65
+ vi.useFakeTimers();
66
+ const input = new PassThrough();
67
+ const iterator = createReader(input);
68
+
69
+ const pending = iterator.next();
70
+ input.write("Reading prompt from stdin...\n");
71
+ await vi.advanceTimersByTimeAsync(200);
72
+ input.write("{\"type\":\"ok\"}\n");
73
+
74
+ await expect(pending).resolves.toEqual({
75
+ done: false,
76
+ value: { type: "ok" },
77
+ });
78
+ });
79
+ });