chatccc 0.2.229 → 0.2.231

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 (120) 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 -76
  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 +65 -1
  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 +202 -163
  28. package/src/__tests__/builtin-permissions.test.ts +211 -211
  29. package/src/__tests__/builtin-session-select.test.ts +116 -116
  30. package/src/__tests__/builtin-sigint.test.ts +56 -56
  31. package/src/__tests__/builtin-web-tools.test.ts +220 -220
  32. package/src/__tests__/card-action-routing.test.ts +18 -18
  33. package/src/__tests__/cardkit.test.ts +60 -60
  34. package/src/__tests__/ccc-adapter.test.ts +170 -136
  35. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  36. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  37. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  38. package/src/__tests__/claude-adapter.test.ts +614 -614
  39. package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
  40. package/src/__tests__/codex-adapter.test.ts +58 -58
  41. package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
  42. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  43. package/src/__tests__/cursor-adapter.test.ts +268 -268
  44. package/src/__tests__/feishu-api.test.ts +60 -60
  45. package/src/__tests__/feishu-avatar.test.ts +164 -164
  46. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  47. package/src/__tests__/feishu-platform.test.ts +22 -22
  48. package/src/__tests__/format-message.test.ts +47 -47
  49. package/src/__tests__/jsonl-stream.test.ts +79 -79
  50. package/src/__tests__/package-files.test.ts +24 -24
  51. package/src/__tests__/privacy.test.ts +198 -198
  52. package/src/__tests__/progress-reducer.test.ts +121 -110
  53. package/src/__tests__/raw-stream-log.test.ts +106 -106
  54. package/src/__tests__/response-stall.test.ts +49 -49
  55. package/src/__tests__/session.test.ts +174 -16
  56. package/src/__tests__/shared-prefix.test.ts +36 -36
  57. package/src/__tests__/sim-platform.test.ts +16 -16
  58. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  59. package/src/__tests__/stop-session.test.ts +34 -34
  60. package/src/__tests__/stream-state.test.ts +42 -42
  61. package/src/__tests__/terminal-error.test.ts +54 -0
  62. package/src/__tests__/terminal-renderer.test.ts +247 -247
  63. package/src/__tests__/update-command-guard.test.ts +144 -144
  64. package/src/__tests__/web-ui.test.ts +326 -326
  65. package/src/adapters/adapter-interface.ts +24 -18
  66. package/src/adapters/ccc-adapter.ts +141 -131
  67. package/src/adapters/claude-adapter.ts +620 -620
  68. package/src/adapters/claude-session-meta-store.ts +120 -120
  69. package/src/adapters/codex-adapter.ts +426 -426
  70. package/src/adapters/cursor-adapter.ts +681 -681
  71. package/src/adapters/jsonl-stream.ts +157 -157
  72. package/src/adapters/raw-stream-log.ts +124 -124
  73. package/src/adapters/resource-monitor.ts +140 -140
  74. package/src/agent-activity.ts +175 -170
  75. package/src/agent-delegate-task-rpc.ts +153 -153
  76. package/src/agent-delegate-task.ts +91 -91
  77. package/src/agent-reload-config-rpc.ts +34 -34
  78. package/src/agent-stop-stuck.ts +129 -129
  79. package/src/builtin/cli.ts +9 -2
  80. package/src/builtin/config.ts +84 -84
  81. package/src/builtin/context.ts +62 -4
  82. package/src/builtin/file-log.ts +38 -38
  83. package/src/builtin/index.ts +83 -19
  84. package/src/builtin/proc-tree-kill.ts +61 -61
  85. package/src/builtin/progress/cards-helpers.ts +76 -76
  86. package/src/builtin/progress/reducer.ts +113 -108
  87. package/src/builtin/progress/terminal-renderer.ts +294 -294
  88. package/src/builtin/progress/view.ts +77 -77
  89. package/src/builtin/raw-stream-log.ts +124 -124
  90. package/src/builtin/session-select.ts +48 -48
  91. package/src/builtin/sigint.ts +50 -50
  92. package/src/builtin/web-tools.ts +313 -313
  93. package/src/card-action-routing.ts +14 -14
  94. package/src/chatgpt-subscription-rpc.ts +27 -27
  95. package/src/chatgpt-subscription.ts +299 -299
  96. package/src/chrome-devtools-guard.ts +318 -318
  97. package/src/codex-reset-actions.ts +184 -184
  98. package/src/feishu-api.ts +193 -193
  99. package/src/feishu-message-ingress.ts +195 -195
  100. package/src/feishu-platform.ts +20 -20
  101. package/src/format-message.ts +293 -293
  102. package/src/litellm-proxy.ts +374 -374
  103. package/src/orchestrator.ts +8 -3
  104. package/src/platform-adapter.ts +6 -6
  105. package/src/privacy.ts +118 -118
  106. package/src/progress/reducer.ts +113 -108
  107. package/src/progress/terminal-renderer.ts +294 -294
  108. package/src/progress/view.ts +77 -77
  109. package/src/response-stall.ts +28 -28
  110. package/src/runtime-reload.ts +34 -34
  111. package/src/session-chat-binding.ts +82 -82
  112. package/src/session-name.ts +8 -8
  113. package/src/session.ts +149 -26
  114. package/src/shared-prefix.ts +29 -29
  115. package/src/sim-platform.ts +20 -20
  116. package/src/startup-lifecycle.ts +250 -250
  117. package/src/stream-state.ts +21 -18
  118. package/src/terminal-error.ts +129 -0
  119. package/src/turn-cards.ts +117 -117
  120. package/src/update-command-guard.ts +165 -165
@@ -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
+ });
@@ -1,49 +1,49 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import {
4
- hasResponseStalled,
5
- observeResponseProgress,
6
- } from "../response-stall.ts";
7
-
8
- describe("response stall detection", () => {
9
- it("starts tracking while responding even when the total character count is zero", () => {
10
- const observation = observeResponseProgress(undefined, true, 0, 1_000);
11
-
12
- expect(observation).toEqual({
13
- totalChars: 0,
14
- unchangedSince: 1_000,
15
- });
16
- });
17
-
18
- it("auto-ends only after the same character count has lasted three minutes", () => {
19
- const first = observeResponseProgress(undefined, true, 12, 1_000);
20
- const unchanged = observeResponseProgress(first, true, 12, 90_000);
21
-
22
- expect(unchanged).toBe(first);
23
- expect(hasResponseStalled(unchanged, 180_999, 180_000)).toBe(false);
24
- expect(hasResponseStalled(unchanged, 181_000, 180_000)).toBe(true);
25
- });
26
-
27
- it("restarts the timer whenever the total character count changes", () => {
28
- const first = observeResponseProgress(undefined, true, 12, 1_000);
29
- const changed = observeResponseProgress(first, true, 13, 150_000);
30
-
31
- expect(changed).toEqual({
32
- totalChars: 13,
33
- unchangedSince: 150_000,
34
- });
35
- expect(hasResponseStalled(changed, 181_000, 180_000)).toBe(false);
36
- });
37
-
38
- it("clears tracking outside responding and starts a fresh window after returning", () => {
39
- const first = observeResponseProgress(undefined, true, 0, 1_000);
40
- const cleared = observeResponseProgress(first, false, 0, 100_000);
41
- const resumed = observeResponseProgress(cleared, true, 0, 200_000);
42
-
43
- expect(cleared).toBeUndefined();
44
- expect(resumed).toEqual({
45
- totalChars: 0,
46
- unchangedSince: 200_000,
47
- });
48
- });
49
- });
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ hasResponseStalled,
5
+ observeResponseProgress,
6
+ } from "../response-stall.ts";
7
+
8
+ describe("response stall detection", () => {
9
+ it("starts tracking while responding even when the total character count is zero", () => {
10
+ const observation = observeResponseProgress(undefined, true, 0, 1_000);
11
+
12
+ expect(observation).toEqual({
13
+ totalChars: 0,
14
+ unchangedSince: 1_000,
15
+ });
16
+ });
17
+
18
+ it("auto-ends only after the same character count has lasted three minutes", () => {
19
+ const first = observeResponseProgress(undefined, true, 12, 1_000);
20
+ const unchanged = observeResponseProgress(first, true, 12, 90_000);
21
+
22
+ expect(unchanged).toBe(first);
23
+ expect(hasResponseStalled(unchanged, 180_999, 180_000)).toBe(false);
24
+ expect(hasResponseStalled(unchanged, 181_000, 180_000)).toBe(true);
25
+ });
26
+
27
+ it("restarts the timer whenever the total character count changes", () => {
28
+ const first = observeResponseProgress(undefined, true, 12, 1_000);
29
+ const changed = observeResponseProgress(first, true, 13, 150_000);
30
+
31
+ expect(changed).toEqual({
32
+ totalChars: 13,
33
+ unchangedSince: 150_000,
34
+ });
35
+ expect(hasResponseStalled(changed, 181_000, 180_000)).toBe(false);
36
+ });
37
+
38
+ it("clears tracking outside responding and starts a fresh window after returning", () => {
39
+ const first = observeResponseProgress(undefined, true, 0, 1_000);
40
+ const cleared = observeResponseProgress(first, false, 0, 100_000);
41
+ const resumed = observeResponseProgress(cleared, true, 0, 200_000);
42
+
43
+ expect(cleared).toBeUndefined();
44
+ expect(resumed).toEqual({
45
+ totalChars: 0,
46
+ unchangedSince: 200_000,
47
+ });
48
+ });
49
+ });
@@ -23,6 +23,12 @@ const mockStreamStates = new Map<string, {
23
23
  turnCount?: number;
24
24
  finalReplySentTurn?: number;
25
25
  finalReplySentAt?: number;
26
+ terminalError?: {
27
+ kind: "network_timeout" | "network" | "authentication" | "rate_limit" | "provider" | "process" | "resource" | "unknown";
28
+ title: string;
29
+ message: string;
30
+ occurredAt: number;
31
+ };
26
32
  }>();
27
33
  vi.mock("../stream-state.ts", () => ({
28
34
  readStreamState: async (sid: string) => {
@@ -35,6 +41,7 @@ vi.mock("../stream-state.ts", () => ({
35
41
  activity: state.activity,
36
42
  finalReplySentTurn: state.finalReplySentTurn,
37
43
  finalReplySentAt: state.finalReplySentAt,
44
+ terminalError: state.terminalError,
38
45
  autoEndedAt: state.autoEndedAt,
39
46
  status: state.status ?? "running",
40
47
  chunkCount: 0,
@@ -60,6 +67,12 @@ vi.mock("../stream-state.ts", () => ({
60
67
  turnCount?: number;
61
68
  finalReplySentTurn?: number;
62
69
  finalReplySentAt?: number;
70
+ terminalError?: {
71
+ kind: "network_timeout" | "network" | "authentication" | "rate_limit" | "provider" | "process" | "resource" | "unknown";
72
+ title: string;
73
+ message: string;
74
+ occurredAt: number;
75
+ };
63
76
  }) => {
64
77
  mockStreamStates.set(state.sessionId, {
65
78
  accumulatedContent: state.accumulatedContent,
@@ -70,6 +83,7 @@ vi.mock("../stream-state.ts", () => ({
70
83
  turnCount: state.turnCount,
71
84
  finalReplySentTurn: state.finalReplySentTurn,
72
85
  finalReplySentAt: state.finalReplySentAt,
86
+ terminalError: state.terminalError,
73
87
  });
74
88
  },
75
89
  createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
@@ -510,6 +524,51 @@ describe("runAgentSession process monitor", () => {
510
524
  );
511
525
  });
512
526
 
527
+ it("persists and sends the root cause when the agent stream fails before producing output", async () => {
528
+ vi.spyOn(console, "error").mockImplementation(() => {});
529
+ const platform = mockPlatform("feishu");
530
+ platform.cardCreate = vi.fn(async () => {
531
+ throw new Error("CardKit unavailable");
532
+ });
533
+ setSessionPlatform(platform);
534
+ bindChatToSession("sid-stream-error", "chat-stream-error");
535
+ recordLastActiveChat("sid-stream-error", "chat-stream-error");
536
+
537
+ const adapter: ToolAdapter = {
538
+ displayName: "CCC Agent",
539
+ sessionDescPrefix: "CCC Session:",
540
+ createSession: async () => ({ sessionId: "sid-stream-error" }),
541
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "/tmp" }),
542
+ closeSession: async () => {},
543
+ prompt: async function* () {
544
+ throw new Error(
545
+ "Failed after 3 attempts. Last error: Cannot connect to API: " +
546
+ "Connect Timeout Error (timeout: 10000ms)",
547
+ );
548
+ },
549
+ };
550
+ _setAdapterForToolForTest("ccc", adapter);
551
+
552
+ const outcome = await runAgentSession(
553
+ "sid-stream-error",
554
+ "prompt",
555
+ platform,
556
+ "chat-stream-error",
557
+ Date.now(),
558
+ "ccc",
559
+ );
560
+
561
+ const state = mockStreamStates.get("sid-stream-error");
562
+ expect(outcome).toBe("error");
563
+ expect(state?.status).toBe("error");
564
+ expect(state?.terminalError?.kind).toBe("network_timeout");
565
+ expect(state?.terminalError?.message).toContain("已重试 3 次");
566
+ expect(platform.sendText).toHaveBeenCalledWith(
567
+ "chat-stream-error",
568
+ expect.stringContaining("网络连接超时"),
569
+ );
570
+ });
571
+
513
572
  it("recreates the progress card when the initial CardKit send fails", async () => {
514
573
  vi.spyOn(console, "error").mockImplementation(() => {});
515
574
  const platform = mockPlatform("feishu");
@@ -848,7 +907,7 @@ describe("runAgentSession response stall watchdog", () => {
848
907
  });
849
908
  });
850
909
 
851
- it("auto-ends an Agent that stays in starting state without emitting any event", async () => {
910
+ it("does not apply the reply-stall timeout while an Agent is compacting context", async () => {
852
911
  vi.setSystemTime(0);
853
912
  _setResponseStallTimeoutForTest(180_000);
854
913
  _setResponseStallCheckIntervalForTest(1_000);
@@ -875,6 +934,7 @@ describe("runAgentSession response stall watchdog", () => {
875
934
  ) {
876
935
  options?.onSessionCreated?.(closeSession);
877
936
  options?.onProcessStart?.({ pid: 4343 });
937
+ yield { type: "assistant", blocks: [{ type: "agent_status", status: "compacting" }] };
878
938
  await new Promise<void>((resolve) => {
879
939
  if (signal?.aborted) {
880
940
  resolve();
@@ -899,11 +959,8 @@ describe("runAgentSession response stall watchdog", () => {
899
959
  await vi.waitFor(() => {
900
960
  expect(activePrompts.get("sid-starting-stall")).toMatchObject({
901
961
  processPid: 4343,
902
- responseProgress: {
903
- totalChars: 0,
904
- unchangedSince: expect.any(Number),
905
- },
906
962
  });
963
+ expect(activePrompts.get("sid-starting-stall")?.responseProgress).toBeUndefined();
907
964
  expect(closeSession).toHaveBeenCalledTimes(0);
908
965
  });
909
966
 
@@ -915,19 +972,16 @@ describe("runAgentSession response stall watchdog", () => {
915
972
  .length;
916
973
 
917
974
  // 旧实现不会结束真正的零事件启动;先清理测试会话,避免失败用例悬挂。
918
- if (stateAfterDeadline?.status !== "auto_ended") {
919
- stopSession("sid-starting-stall");
920
- await vi.advanceTimersByTimeAsync(1_000);
921
- }
975
+ stopSession("sid-starting-stall");
976
+ await vi.advanceTimersByTimeAsync(1_000);
922
977
  await runPromise;
923
978
 
924
979
  expect(stateAfterDeadline).toMatchObject({
925
- status: "auto_ended",
926
- finalReply: "",
927
- autoEndedAt: expect.any(Number),
980
+ status: "running",
981
+ activity: { kind: "compacting" },
928
982
  });
929
- expect(closeCountAfterDeadline).toBe(1);
930
- expect(treeKillCountAfterDeadline).toBeGreaterThanOrEqual(1);
983
+ expect(closeCountAfterDeadline).toBe(0);
984
+ expect(treeKillCountAfterDeadline).toBe(0);
931
985
  });
932
986
 
933
987
  it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
@@ -1371,7 +1425,7 @@ describe("runAgentSession response stall watchdog", () => {
1371
1425
  expect(receivedPrompts[1]).toContain(recoveryPrompt);
1372
1426
  expect(platform.sendText).toHaveBeenCalledWith(
1373
1427
  "chat-recovery-limit",
1374
- "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。",
1428
+ "⚠️ 自动续跑仍连续 3 分钟没有生成新回复,本次不再自动继续。",
1375
1429
  );
1376
1430
  });
1377
1431
 
@@ -1630,11 +1684,115 @@ describe("unified display loop terminal card update", () => {
1630
1684
  expect(card.header.template).toBe("orange");
1631
1685
  expect(platform.sendText).toHaveBeenCalledWith(
1632
1686
  "chat-auto-ended",
1633
- "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。本轮没有可发送的回复内容。",
1687
+ "⚠️ 已自动结束:生成回复阶段连续 3 分钟没有字符变化。本轮没有可发送的回复内容。",
1634
1688
  );
1635
1689
  expect(displayCards.has("chat-auto-ended")).toBe(false);
1636
1690
  });
1637
1691
 
1692
+ it("shows the stream root cause in the error card without a duplicate text notice", async () => {
1693
+ const platform = mockPlatform("feishu");
1694
+ setSessionPlatform(platform);
1695
+
1696
+ bindChatToSession("sid-network-error", "chat-network-error");
1697
+ recordLastActiveChat("sid-network-error", "chat-network-error");
1698
+ sessionInfoMap.set("chat-network-error", {
1699
+ sessionId: "sid-network-error",
1700
+ turnCount: 1,
1701
+ lastContextTokens: 0,
1702
+ startTime: 0,
1703
+ tool: "ccc",
1704
+ });
1705
+ displayCards.set("chat-network-error", {
1706
+ cardId: "card-network-error",
1707
+ sequence: 1,
1708
+ cardBusy: false,
1709
+ cardCreatedAt: Date.now(),
1710
+ lastSentContent: "",
1711
+ streamErrorNotified: false,
1712
+ sessionId: "sid-network-error",
1713
+ turnCount: 1,
1714
+ dotCount: 0,
1715
+ });
1716
+ mockStreamStates.set("sid-network-error", {
1717
+ accumulatedContent: "",
1718
+ finalReply: "",
1719
+ status: "error",
1720
+ turnCount: 1,
1721
+ terminalError: {
1722
+ kind: "network_timeout",
1723
+ title: "网络连接超时",
1724
+ message: "连接模型服务失败,已重试 3 次,单次连接等待 10 秒。请检查网络、VPN或模型服务状态后重试。",
1725
+ occurredAt: Date.now(),
1726
+ },
1727
+ });
1728
+
1729
+ startUnifiedDisplayLoop();
1730
+ await vi.advanceTimersByTimeAsync(3_000);
1731
+
1732
+ const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
1733
+ const card = JSON.parse(payload as string) as {
1734
+ header: { title: { content: string }; template?: string };
1735
+ body: { elements: Array<{ content?: string }> };
1736
+ };
1737
+ expect(card.header.title.content).toBe("异常结束 · 网络连接超时");
1738
+ expect(card.header.template).toBe("red");
1739
+ expect(card.body.elements[0]?.content).toContain("已重试 3 次");
1740
+ expect(platform.sendText).not.toHaveBeenCalled();
1741
+ expect(mockStreamStates.get("sid-network-error")?.finalReplySentTurn).toBe(1);
1742
+ expect(displayCards.has("chat-network-error")).toBe(false);
1743
+ });
1744
+
1745
+ it("falls back to a root-cause text when the error card cannot be updated", async () => {
1746
+ vi.spyOn(console, "error").mockImplementation(() => {});
1747
+ const platform = mockPlatform("feishu");
1748
+ platform.cardUpdate = vi.fn(async () => {
1749
+ throw new Error("CardKit unavailable");
1750
+ });
1751
+ setSessionPlatform(platform);
1752
+
1753
+ bindChatToSession("sid-error-fallback", "chat-error-fallback");
1754
+ recordLastActiveChat("sid-error-fallback", "chat-error-fallback");
1755
+ sessionInfoMap.set("chat-error-fallback", {
1756
+ sessionId: "sid-error-fallback",
1757
+ turnCount: 1,
1758
+ lastContextTokens: 0,
1759
+ startTime: 0,
1760
+ tool: "ccc",
1761
+ });
1762
+ displayCards.set("chat-error-fallback", {
1763
+ cardId: "card-error-fallback",
1764
+ sequence: 1,
1765
+ cardBusy: false,
1766
+ cardCreatedAt: Date.now(),
1767
+ lastSentContent: "",
1768
+ streamErrorNotified: false,
1769
+ sessionId: "sid-error-fallback",
1770
+ turnCount: 1,
1771
+ dotCount: 0,
1772
+ });
1773
+ mockStreamStates.set("sid-error-fallback", {
1774
+ accumulatedContent: "",
1775
+ finalReply: "",
1776
+ status: "error",
1777
+ turnCount: 1,
1778
+ terminalError: {
1779
+ kind: "network_timeout",
1780
+ title: "网络连接超时",
1781
+ message: "连接模型服务失败,已重试 3 次。",
1782
+ occurredAt: Date.now(),
1783
+ },
1784
+ });
1785
+
1786
+ startUnifiedDisplayLoop();
1787
+ await vi.advanceTimersByTimeAsync(3_000);
1788
+
1789
+ expect(platform.sendText).toHaveBeenCalledWith(
1790
+ "chat-error-fallback",
1791
+ expect.stringContaining("异常结束:网络连接超时"),
1792
+ );
1793
+ expect(displayCards.has("chat-error-fallback")).toBe(false);
1794
+ });
1795
+
1638
1796
  it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
1639
1797
  const platform = mockPlatform("feishu");
1640
1798
  platform.cardUpdate = vi.fn(async () => {
@@ -1,36 +1,36 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import {
4
- ABD_APPEND_PROMPT,
5
- applySharedPrefix,
6
- } from "../shared-prefix.ts";
7
-
8
- describe("applySharedPrefix", () => {
9
- it("leaves normal messages unchanged", () => {
10
- expect(applySharedPrefix("帮我分析")).toEqual({
11
- matched: false,
12
- text: "帮我分析",
13
- body: "帮我分析",
14
- });
15
- });
16
-
17
- it("removes /abd without requiring a space", () => {
18
- const result = applySharedPrefix("/abd帮我分析");
19
-
20
- expect(result.matched).toBe(true);
21
- expect(result.body).toBe("帮我分析");
22
- expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
23
- });
24
-
25
- it("removes whitespace after /abd", () => {
26
- const result = applySharedPrefix("/abd 帮我分析");
27
-
28
- expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
29
- });
30
-
31
- it("keeps the appendix when the user body is empty", () => {
32
- const result = applySharedPrefix("/abd ");
33
-
34
- expect(result.text).toBe(`---\n${ABD_APPEND_PROMPT}`);
35
- });
36
- });
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ ABD_APPEND_PROMPT,
5
+ applySharedPrefix,
6
+ } from "../shared-prefix.ts";
7
+
8
+ describe("applySharedPrefix", () => {
9
+ it("leaves normal messages unchanged", () => {
10
+ expect(applySharedPrefix("帮我分析")).toEqual({
11
+ matched: false,
12
+ text: "帮我分析",
13
+ body: "帮我分析",
14
+ });
15
+ });
16
+
17
+ it("removes /abd without requiring a space", () => {
18
+ const result = applySharedPrefix("/abd帮我分析");
19
+
20
+ expect(result.matched).toBe(true);
21
+ expect(result.body).toBe("帮我分析");
22
+ expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
23
+ });
24
+
25
+ it("removes whitespace after /abd", () => {
26
+ const result = applySharedPrefix("/abd 帮我分析");
27
+
28
+ expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
29
+ });
30
+
31
+ it("keeps the appendix when the user body is empty", () => {
32
+ const result = applySharedPrefix("/abd ");
33
+
34
+ expect(result.text).toBe(`---\n${ABD_APPEND_PROMPT}`);
35
+ });
36
+ });