chatccc 0.2.229 → 0.2.230

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.229",
3
+ "version": "0.2.230",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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");
@@ -1635,6 +1694,110 @@ describe("unified display loop terminal card update", () => {
1635
1694
  expect(displayCards.has("chat-auto-ended")).toBe(false);
1636
1695
  });
1637
1696
 
1697
+ it("shows the stream root cause in the error card without a duplicate text notice", async () => {
1698
+ const platform = mockPlatform("feishu");
1699
+ setSessionPlatform(platform);
1700
+
1701
+ bindChatToSession("sid-network-error", "chat-network-error");
1702
+ recordLastActiveChat("sid-network-error", "chat-network-error");
1703
+ sessionInfoMap.set("chat-network-error", {
1704
+ sessionId: "sid-network-error",
1705
+ turnCount: 1,
1706
+ lastContextTokens: 0,
1707
+ startTime: 0,
1708
+ tool: "ccc",
1709
+ });
1710
+ displayCards.set("chat-network-error", {
1711
+ cardId: "card-network-error",
1712
+ sequence: 1,
1713
+ cardBusy: false,
1714
+ cardCreatedAt: Date.now(),
1715
+ lastSentContent: "",
1716
+ streamErrorNotified: false,
1717
+ sessionId: "sid-network-error",
1718
+ turnCount: 1,
1719
+ dotCount: 0,
1720
+ });
1721
+ mockStreamStates.set("sid-network-error", {
1722
+ accumulatedContent: "",
1723
+ finalReply: "",
1724
+ status: "error",
1725
+ turnCount: 1,
1726
+ terminalError: {
1727
+ kind: "network_timeout",
1728
+ title: "网络连接超时",
1729
+ message: "连接模型服务失败,已重试 3 次,单次连接等待 10 秒。请检查网络、VPN或模型服务状态后重试。",
1730
+ occurredAt: Date.now(),
1731
+ },
1732
+ });
1733
+
1734
+ startUnifiedDisplayLoop();
1735
+ await vi.advanceTimersByTimeAsync(3_000);
1736
+
1737
+ const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
1738
+ const card = JSON.parse(payload as string) as {
1739
+ header: { title: { content: string }; template?: string };
1740
+ body: { elements: Array<{ content?: string }> };
1741
+ };
1742
+ expect(card.header.title.content).toBe("异常结束 · 网络连接超时");
1743
+ expect(card.header.template).toBe("red");
1744
+ expect(card.body.elements[0]?.content).toContain("已重试 3 次");
1745
+ expect(platform.sendText).not.toHaveBeenCalled();
1746
+ expect(mockStreamStates.get("sid-network-error")?.finalReplySentTurn).toBe(1);
1747
+ expect(displayCards.has("chat-network-error")).toBe(false);
1748
+ });
1749
+
1750
+ it("falls back to a root-cause text when the error card cannot be updated", async () => {
1751
+ vi.spyOn(console, "error").mockImplementation(() => {});
1752
+ const platform = mockPlatform("feishu");
1753
+ platform.cardUpdate = vi.fn(async () => {
1754
+ throw new Error("CardKit unavailable");
1755
+ });
1756
+ setSessionPlatform(platform);
1757
+
1758
+ bindChatToSession("sid-error-fallback", "chat-error-fallback");
1759
+ recordLastActiveChat("sid-error-fallback", "chat-error-fallback");
1760
+ sessionInfoMap.set("chat-error-fallback", {
1761
+ sessionId: "sid-error-fallback",
1762
+ turnCount: 1,
1763
+ lastContextTokens: 0,
1764
+ startTime: 0,
1765
+ tool: "ccc",
1766
+ });
1767
+ displayCards.set("chat-error-fallback", {
1768
+ cardId: "card-error-fallback",
1769
+ sequence: 1,
1770
+ cardBusy: false,
1771
+ cardCreatedAt: Date.now(),
1772
+ lastSentContent: "",
1773
+ streamErrorNotified: false,
1774
+ sessionId: "sid-error-fallback",
1775
+ turnCount: 1,
1776
+ dotCount: 0,
1777
+ });
1778
+ mockStreamStates.set("sid-error-fallback", {
1779
+ accumulatedContent: "",
1780
+ finalReply: "",
1781
+ status: "error",
1782
+ turnCount: 1,
1783
+ terminalError: {
1784
+ kind: "network_timeout",
1785
+ title: "网络连接超时",
1786
+ message: "连接模型服务失败,已重试 3 次。",
1787
+ occurredAt: Date.now(),
1788
+ },
1789
+ });
1790
+
1791
+ startUnifiedDisplayLoop();
1792
+ await vi.advanceTimersByTimeAsync(3_000);
1793
+
1794
+ expect(platform.sendText).toHaveBeenCalledWith(
1795
+ "chat-error-fallback",
1796
+ expect.stringContaining("异常结束:网络连接超时"),
1797
+ );
1798
+ expect(displayCards.has("chat-error-fallback")).toBe(false);
1799
+ });
1800
+
1638
1801
  it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
1639
1802
  const platform = mockPlatform("feishu");
1640
1803
  platform.cardUpdate = vi.fn(async () => {
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { classifyTerminalError, formatTerminalErrorNotice } from "../terminal-error.ts";
4
+
5
+ describe("terminal error classification", () => {
6
+ it("turns a retried connection timeout into an actionable root-cause summary", () => {
7
+ const error = classifyTerminalError(new Error(
8
+ "Failed after 3 attempts. Last error: Cannot connect to API: Connect Timeout Error " +
9
+ "(attempted addresses: 172.19.67.251:443, 172.19.68.1:443, timeout: 10000ms)",
10
+ ), 123);
11
+
12
+ expect(error).toEqual({
13
+ kind: "network_timeout",
14
+ title: "网络连接超时",
15
+ message: "连接模型服务失败,已重试 3 次,单次连接等待 10 秒。请检查网络、VPN或模型服务状态后重试。",
16
+ occurredAt: 123,
17
+ });
18
+ expect(JSON.stringify(error)).not.toContain("172.19.67.251");
19
+ });
20
+
21
+ it("redacts credentials from an otherwise unknown error", () => {
22
+ const error = classifyTerminalError(
23
+ new Error("custom provider rejected api_key=secret-value Bearer eyJ.private sk-live-secret"),
24
+ 456,
25
+ );
26
+
27
+ expect(error.kind).toBe("unknown");
28
+ expect(error.message).toContain("custom provider rejected");
29
+ expect(error.message).not.toContain("secret-value");
30
+ expect(error.message).not.toContain("eyJ.private");
31
+ expect(error.message).not.toContain("sk-live-secret");
32
+ });
33
+
34
+ it("marks an existing reply as potentially incomplete", () => {
35
+ const error = classifyTerminalError(new Error("HTTP 429 rate limit"), 789);
36
+ const notice = formatTerminalErrorNotice(error, "partial answer");
37
+
38
+ expect(notice).toContain("请求受到限流");
39
+ expect(notice).toContain("以下回复可能不完整");
40
+ expect(notice).toContain("partial answer");
41
+ });
42
+
43
+ it.each([
44
+ ["HTTP 401 unauthorized", "authentication", "模型服务鉴权失败"],
45
+ ["HTTP 429 too many requests", "rate_limit", "请求受到限流"],
46
+ ["HTTP 503 service unavailable", "provider", "模型服务暂时不可用"],
47
+ ["getaddrinfo ENOTFOUND api.example.test", "network", "无法连接模型服务"],
48
+ ] as const)("classifies %s as %s", (message, kind, title) => {
49
+ const error = classifyTerminalError(new Error(message), 999);
50
+
51
+ expect(error.kind).toBe(kind);
52
+ expect(error.title).toBe(title);
53
+ });
54
+ });
@@ -2246,7 +2246,7 @@ export async function handleCommand(
2246
2246
 
2247
2247
  try {
2248
2248
  logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
2249
- await resumeAndPrompt(
2249
+ const resumeOutcome = await resumeAndPrompt(
2250
2250
  sessionId,
2251
2251
  promptText,
2252
2252
  platform,
@@ -2255,8 +2255,13 @@ export async function handleCommand(
2255
2255
  descriptionTool,
2256
2256
  tid,
2257
2257
  );
2258
- logTrace(tid, "DONE", { outcome: "resume_done", sessionId });
2259
- console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
2258
+ if (resumeOutcome === "error") {
2259
+ logTrace(tid, "DONE", { outcome: "resume_error", sessionId });
2260
+ console.error(`[${ts()}] [RESUME] Session ${sessionId} ended with an Agent error`);
2261
+ } else {
2262
+ logTrace(tid, "DONE", { outcome: "resume_done", sessionId, sessionOutcome: resumeOutcome });
2263
+ console.log(`[${ts()}] [RESUME] Session ${sessionId} done (${resumeOutcome})`);
2264
+ }
2260
2265
  } catch (err) {
2261
2266
  logTrace(tid, "DONE", {
2262
2267
  outcome: "resume_fail",
package/src/session.ts CHANGED
@@ -43,6 +43,12 @@ import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/
43
43
  import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
44
44
  import type { PlatformAdapter } from "./platform-adapter.ts";
45
45
  import { hasResponseStalled, observeResponseProgress } from "./response-stall.ts";
46
+ import {
47
+ classifyTerminalError,
48
+ formatTerminalErrorNotice,
49
+ formatTerminalErrorReason,
50
+ type TerminalErrorInfo,
51
+ } from "./terminal-error.ts";
46
52
  import {
47
53
  MAX_PROCESSED,
48
54
  clearFeishuMessageLedgerMemory,
@@ -305,13 +311,18 @@ function scheduleFinalResponseCloseGuard(
305
311
  runningPrompt.finalResponseCloseTimer = handle;
306
312
  }
307
313
 
308
- function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
314
+ function formatTerminalHeader(
315
+ status: "running" | "done" | "stopped" | "error" | "auto_ended",
316
+ terminalError?: TerminalErrorInfo,
317
+ ): {
309
318
  title: string;
310
319
  template?: string;
311
320
  } {
312
321
  if (status === "auto_ended") return { title: "已自动结束 · 3分钟无新内容", template: "orange" };
313
322
  if (status === "stopped") return { title: "已停止", template: "red" };
314
- if (status === "error") return { title: "异常结束", template: "red" };
323
+ if (status === "error") {
324
+ return { title: terminalError ? `异常结束 · ${terminalError.title}` : "异常结束", template: "red" };
325
+ }
315
326
  return { title: "完成" };
316
327
  }
317
328
 
@@ -337,11 +348,42 @@ function monitorsOutputProgress(kind: AgentActivityKind): boolean {
337
348
  function formatTerminalReply(
338
349
  status: "running" | "done" | "stopped" | "error" | "auto_ended",
339
350
  finalReply: string,
351
+ terminalError?: TerminalErrorInfo,
340
352
  ): string | null {
341
353
  if (status === "auto_ended") return formatAutoEndedReply(finalReply);
354
+ if (status === "error") {
355
+ const error = terminalError ?? {
356
+ kind: "unknown" as const,
357
+ title: "原因未记录",
358
+ message: "当前状态中没有可用的错误详情,请查看运行日志。",
359
+ occurredAt: Date.now(),
360
+ };
361
+ return formatTerminalErrorNotice(error, finalReply);
362
+ }
342
363
  return finalReply || null;
343
364
  }
344
365
 
366
+ function formatTerminalCardContent(state: {
367
+ status: "running" | "done" | "stopped" | "error" | "auto_ended";
368
+ accumulatedContent: string;
369
+ finalReply: string;
370
+ terminalError?: TerminalErrorInfo;
371
+ }): string {
372
+ const content = state.accumulatedContent + state.finalReply;
373
+ if (state.status !== "error") return content;
374
+
375
+ const error = state.terminalError ?? {
376
+ kind: "unknown" as const,
377
+ title: "原因未记录",
378
+ message: "当前状态中没有可用的错误详情,请查看运行日志。",
379
+ occurredAt: Date.now(),
380
+ };
381
+ const reason = formatTerminalErrorReason(error);
382
+ return content.trim()
383
+ ? `${content}\n\n${reason}\n以上内容可能不完整。`
384
+ : reason;
385
+ }
386
+
345
387
  function isCardKitSequenceConflict(err: unknown): boolean {
346
388
  return err instanceof Error && err.message.includes("300317");
347
389
  }
@@ -1110,7 +1152,7 @@ export async function resumeAndPrompt(
1110
1152
  msgTimestamp: number,
1111
1153
  tool: string,
1112
1154
  traceId?: string,
1113
- ): Promise<void> {
1155
+ ): Promise<SessionRunOutcome> {
1114
1156
  return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
1115
1157
  }
1116
1158
 
@@ -1126,6 +1168,8 @@ interface RunAgentSessionOptions {
1126
1168
  autoRecovery?: boolean;
1127
1169
  }
1128
1170
 
1171
+ export type SessionRunOutcome = "busy" | "done" | "stopped" | "error" | "auto_ended";
1172
+
1129
1173
  export async function runAgentSession(
1130
1174
  sessionId: string,
1131
1175
  userText: string,
@@ -1135,7 +1179,7 @@ export async function runAgentSession(
1135
1179
  tool: string,
1136
1180
  traceId?: string,
1137
1181
  options: RunAgentSessionOptions = {},
1138
- ): Promise<void> {
1182
+ ): Promise<SessionRunOutcome> {
1139
1183
  const tid = traceId ?? "";
1140
1184
 
1141
1185
  // runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
@@ -1162,7 +1206,7 @@ export async function runAgentSession(
1162
1206
  ? "当前正在生成回复中,请等待完成后再发送消息。也可以发送 /stop 结束,已完成的步骤不会丢失。"
1163
1207
  : "该会话正在生成回复中,请等待完成后再发送消息。也可以发送 /stop 结束,已完成的步骤不会丢失。";
1164
1208
  await platform.sendText(_chatId, busyMsg).catch(() => {});
1165
- return;
1209
+ return "busy";
1166
1210
  }
1167
1211
 
1168
1212
  // 立即标记活跃,确保 /sessions、isSessionRunning 等查询在异步准备阶段就能看到运行状态。
@@ -1290,7 +1334,7 @@ export async function runAgentSession(
1290
1334
  // 再开始缓存问题对应的任务"。
1291
1335
  const prevState = await readStreamState(sessionId);
1292
1336
  if (prevState && prevState.status !== "running") {
1293
- const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply);
1337
+ const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply, prevState.terminalError);
1294
1338
  const displayChatId = pickDisplayChat(sessionId);
1295
1339
  if (displayChatId) {
1296
1340
  const pp = platformForChat(displayChatId);
@@ -1309,8 +1353,8 @@ export async function runAgentSession(
1309
1353
  setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1310
1354
  } else {
1311
1355
  const nextSeq = display.sequence + 1;
1312
- const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
1313
- const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
1356
+ const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status, prevState.terminalError);
1357
+ const cardContent = truncateContent(formatTerminalCardContent(prevState)) || " ";
1314
1358
  const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1315
1359
  await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(err => {
1316
1360
  console.error(`[${ts()}] [DISPLAY] prevState final cardUpdate failed: ${(err as Error).message}`);
@@ -1405,6 +1449,8 @@ export async function runAgentSession(
1405
1449
  const FILE_WRITE_INTERVAL_MS = 2000;
1406
1450
  const toolCallMap = new Map<string, { name: string; input: unknown }>();
1407
1451
  let streamErrored = false;
1452
+ let streamTerminalError: TerminalErrorInfo | undefined;
1453
+ let runOutcome: SessionRunOutcome = "error";
1408
1454
 
1409
1455
  const runningPrompt = activePrompts.get(sessionId);
1410
1456
  if (runningPrompt) {
@@ -1580,6 +1626,7 @@ export async function runAgentSession(
1580
1626
  }
1581
1627
  } catch (streamErr) {
1582
1628
  streamErrored = true;
1629
+ streamTerminalError = classifyTerminalError(streamErr);
1583
1630
  console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
1584
1631
  } finally {
1585
1632
  // 标记 prompt 结束
@@ -1629,6 +1676,23 @@ export async function runAgentSession(
1629
1676
  ? "stopped"
1630
1677
  : "done";
1631
1678
  const finalReply = pickFinalReply(state).trim();
1679
+ const terminalError = streamTerminalError
1680
+ ?? (wasAbnormalExit
1681
+ ? {
1682
+ kind: "process" as const,
1683
+ title: "Agent 进程意外退出",
1684
+ message: "Agent CLI 进程已退出。若回复不完整,请重新发送上一条指令。",
1685
+ occurredAt: Date.now(),
1686
+ }
1687
+ : wasResourceStuck
1688
+ ? {
1689
+ kind: "resource" as const,
1690
+ title: "Agent 进程失去响应",
1691
+ message: "Agent 进程长时间没有 CPU 或内存变化,已被强制停止。若回复不完整,请重新发送上一条指令。",
1692
+ occurredAt: Date.now(),
1693
+ }
1694
+ : undefined);
1695
+ runOutcome = finalStatus;
1632
1696
 
1633
1697
  // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1634
1698
  // stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
@@ -1659,6 +1723,7 @@ export async function runAgentSession(
1659
1723
  tool,
1660
1724
  ...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
1661
1725
  ...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
1726
+ ...(terminalError ? { terminalError } : {}),
1662
1727
  });
1663
1728
 
1664
1729
  // display loop 下一轮会读到最终状态并发送消息
@@ -1749,6 +1814,52 @@ export async function runAgentSession(
1749
1814
  if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
1750
1815
  console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1751
1816
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1817
+ } else if (streamErrored || wasResourceStuck) {
1818
+ for (const cid of finalizationChatIds) {
1819
+ const finfo = sessionInfoMap.get(cid);
1820
+ await recordSessionRegistry({
1821
+ chatId: cid,
1822
+ sessionId,
1823
+ tool,
1824
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1825
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1826
+ startTime: finfo?.startTime ?? now,
1827
+ running: false,
1828
+ });
1829
+ }
1830
+ const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1831
+ if (activeErr) {
1832
+ const pp = platformForChat(activeErr) ?? platform;
1833
+ const terminalState = await readStreamState(sessionId);
1834
+ if (
1835
+ terminalError
1836
+ && !displayCards.has(activeErr)
1837
+ && (!terminalState || !isFinalReplySentForTurn(terminalState))
1838
+ ) {
1839
+ await sendFinalReplyTextOnce(
1840
+ pp,
1841
+ activeErr,
1842
+ sessionId,
1843
+ nextTurnCount,
1844
+ formatTerminalErrorNotice(terminalError, finalReplyToWrite),
1845
+ );
1846
+ }
1847
+ setSessionChatAvatar(pp, activeErr, tool, "idle", sessionId).catch(() => {});
1848
+ }
1849
+ const errorOutcome = wasResourceStuck ? "resource_stuck" : "stream_error";
1850
+ console.error(
1851
+ `[${ts()}] Session ${sessionId} ended with ${errorOutcome}` +
1852
+ `${terminalError ? ` (${terminalError.kind}: ${terminalError.title})` : ""} (content chunks: ${state.chunkCount})`,
1853
+ );
1854
+ if (tid) {
1855
+ logTrace(tid, "SESSION_END", {
1856
+ sessionId,
1857
+ outcome: errorOutcome,
1858
+ errorKind: terminalError?.kind,
1859
+ errorTitle: terminalError?.title,
1860
+ chunks: state.chunkCount,
1861
+ });
1862
+ }
1752
1863
  } else {
1753
1864
  for (const cid of finalizationChatIds) {
1754
1865
  const finfo = sessionInfoMap.get(cid);
@@ -1862,6 +1973,7 @@ export async function runAgentSession(
1862
1973
  clearSessionFinalizing(sessionId);
1863
1974
  }
1864
1975
  }
1976
+ return runOutcome;
1865
1977
  }
1866
1978
 
1867
1979
  // ---------------------------------------------------------------------------
@@ -1941,9 +2053,11 @@ export function startUnifiedDisplayLoop(): void {
1941
2053
  const tail = "━━━ 回答结束 ━━━";
1942
2054
  const finalMsg = state.status === "auto_ended"
1943
2055
  ? formatAutoEndedReply(remaining)
1944
- : remaining
1945
- ? remaining + "\n" + tail
1946
- : tail;
2056
+ : state.status === "error"
2057
+ ? formatTerminalReply(state.status, remaining, state.terminalError) ?? tail
2058
+ : remaining
2059
+ ? remaining + "\n" + tail
2060
+ : tail;
1947
2061
  if (!isFinalReplySentForTurn(state)) {
1948
2062
  await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
1949
2063
  }
@@ -1965,8 +2079,8 @@ export function startUnifiedDisplayLoop(): void {
1965
2079
  let terminalCardUpdateAccepted = terminalCardAlreadyUpdated;
1966
2080
  if (!terminalCardAlreadyUpdated) {
1967
2081
  const nextSeq = display.sequence + 1;
1968
- const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status);
1969
- const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
2082
+ const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status, state.terminalError);
2083
+ const cardContent = truncateContent(formatTerminalCardContent(state)) || " ";
1970
2084
  const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1971
2085
  await p.cardUpdate(display.cardId, doneCard, nextSeq).then(() => {
1972
2086
  display.sequence = nextSeq;
@@ -1993,8 +2107,16 @@ export function startUnifiedDisplayLoop(): void {
1993
2107
  }
1994
2108
 
1995
2109
  let terminalTextDelivered = true;
1996
- const terminalReply = formatTerminalReply(state.status, state.finalReply);
1997
- if (terminalReply) {
2110
+ const terminalReply = formatTerminalReply(state.status, state.finalReply, state.terminalError);
2111
+ const errorWasDeliveredByCard =
2112
+ state.status === "error"
2113
+ && !state.finalReply.trim()
2114
+ && terminalCardUpdateAccepted;
2115
+ if (errorWasDeliveredByCard) {
2116
+ if (!isFinalReplySentForTurn(state)) {
2117
+ await markFinalReplySent(sessionId, state.turnCount);
2118
+ }
2119
+ } else if (terminalReply) {
1998
2120
  if (!isFinalReplySentForTurn(state)) {
1999
2121
  terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, terminalReply);
2000
2122
  }
@@ -1,9 +1,10 @@
1
1
  import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
- import { USER_DATA_DIR, ts } from "./config.ts";
5
- import { createAgentActivityTracker } from "./agent-activity.ts";
6
- import type { AgentActivity } from "./agent-activity.ts";
4
+ import { USER_DATA_DIR, ts } from "./config.ts";
5
+ import { createAgentActivityTracker } from "./agent-activity.ts";
6
+ import type { AgentActivity } from "./agent-activity.ts";
7
+ import type { TerminalErrorInfo } from "./terminal-error.ts";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // stream-state.json — 每个 session 的流式输出持久化文件
@@ -13,14 +14,14 @@ export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
13
14
 
14
15
  export interface StreamState {
15
16
  sessionId: string;
16
- status: "running" | "done" | "stopped" | "error" | "auto_ended";
17
+ status: "running" | "done" | "stopped" | "error" | "auto_ended";
17
18
  accumulatedContent: string;
18
19
  /** 本轮会话中 LLM 输出的全部文本内容(所有 text block 的累加)。
19
20
  * 命名含 "final" 但实为"全部累积文本",并非仅"最终一段回复"。
20
21
  * 参见 session.ts 的 AccumulatorState 注释。 */
21
- finalReply: string;
22
- /** Current user-visible work phase for running progress cards. */
23
- activity?: AgentActivity;
22
+ finalReply: string;
23
+ /** Current user-visible work phase for running progress cards. */
24
+ activity?: AgentActivity;
24
25
  /** The turn whose terminal text reply has already been delivered to IM. */
25
26
  finalReplySentTurn?: number;
26
27
  finalReplySentAt?: number;
@@ -32,10 +33,12 @@ export interface StreamState {
32
33
  tool: string;
33
34
  /** Set by stop-stuck-loop to prevent the session from being resumed.
34
35
  * The orchestrator checks this before resuming and creates a new session instead. */
35
- stuckAt?: number;
36
- /** Set when the shared response watchdog ends a turn after three minutes without new characters. */
37
- autoEndedAt?: number;
38
- }
36
+ stuckAt?: number;
37
+ /** Set when the shared response watchdog ends a turn after three minutes without new characters. */
38
+ autoEndedAt?: number;
39
+ /** 脱敏后的用户可见根因;完整原始异常只保留在运行日志中。 */
40
+ terminalError?: TerminalErrorInfo;
41
+ }
39
42
 
40
43
  function getStreamStatePath(sessionId: string): string {
41
44
  return join(STREAMS_DIR, `${sessionId}.json`);
@@ -128,18 +131,18 @@ export async function markFinalReplySent(sessionId: string, turnCount: number, s
128
131
  await writeStreamState(state);
129
132
  }
130
133
 
131
- export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
132
- const now = Date.now();
133
- return {
134
+ export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
135
+ const now = Date.now();
136
+ return {
134
137
  sessionId,
135
138
  status: "running",
136
- accumulatedContent: "",
137
- finalReply: "",
138
- activity: createAgentActivityTracker(now).activity,
139
+ accumulatedContent: "",
140
+ finalReply: "",
141
+ activity: createAgentActivityTracker(now).activity,
139
142
  chunkCount: 0,
140
143
  turnCount,
141
144
  contextTokens: 0,
142
- updatedAt: now,
145
+ updatedAt: now,
143
146
  cwd,
144
147
  tool,
145
148
  };
@@ -0,0 +1,129 @@
1
+ /** 用户可见的终态错误。这里只保存脱敏摘要;完整原始异常继续只写运行日志。 */
2
+ export type TerminalErrorKind =
3
+ | "network_timeout"
4
+ | "network"
5
+ | "authentication"
6
+ | "rate_limit"
7
+ | "provider"
8
+ | "process"
9
+ | "resource"
10
+ | "unknown";
11
+
12
+ export interface TerminalErrorInfo {
13
+ kind: TerminalErrorKind;
14
+ title: string;
15
+ message: string;
16
+ occurredAt: number;
17
+ }
18
+
19
+ const MAX_UNKNOWN_ERROR_LENGTH = 300;
20
+
21
+ function errorMessage(error: unknown): string {
22
+ if (error instanceof Error) return error.message;
23
+ if (typeof error === "string") return error;
24
+ try {
25
+ return JSON.stringify(error);
26
+ } catch {
27
+ return String(error);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * 未分类错误仍应给出可诊断线索,但不能把常见凭据带进群聊或持久化状态。
33
+ * 已分类错误使用固定文案,不复述地址、请求体或 provider 原始响应。
34
+ */
35
+ export function sanitizeTerminalErrorDetail(raw: string): string {
36
+ return raw
37
+ .replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [已脱敏]")
38
+ .replace(/\b(api[_-]?key|access[_-]?token|token|secret)\s*[:=]\s*[^\s,;]+/gi, "$1=[已脱敏]")
39
+ .replace(/\bsk-[A-Za-z0-9._-]{6,}\b/g, "sk-[已脱敏]")
40
+ .replace(/([?&](?:api[_-]?key|access[_-]?token|token|secret)=)[^&\s]+/gi, "$1[已脱敏]")
41
+ .slice(0, MAX_UNKNOWN_ERROR_LENGTH);
42
+ }
43
+
44
+ function parsePositiveInt(message: string, pattern: RegExp): number | undefined {
45
+ const value = Number.parseInt(message.match(pattern)?.[1] ?? "", 10);
46
+ return Number.isFinite(value) && value > 0 ? value : undefined;
47
+ }
48
+
49
+ function formatSeconds(milliseconds: number): string {
50
+ if (milliseconds % 1000 === 0) return `${milliseconds / 1000} 秒`;
51
+ return `${milliseconds} 毫秒`;
52
+ }
53
+
54
+ export function classifyTerminalError(error: unknown, occurredAt = Date.now()): TerminalErrorInfo {
55
+ const raw = errorMessage(error);
56
+ const lower = raw.toLowerCase();
57
+ const attempts = parsePositiveInt(raw, /\bafter\s+(\d+)\s+attempts?\b/i);
58
+ const timeoutMs = parsePositiveInt(raw, /\btimeout\s*:\s*(\d+)\s*ms\b/i);
59
+
60
+ if (/\b429\b|rate[ _-]?limit|too many requests/.test(lower)) {
61
+ return {
62
+ kind: "rate_limit",
63
+ title: "请求受到限流",
64
+ message: "模型服务请求受到限流,请稍后重试。",
65
+ occurredAt,
66
+ };
67
+ }
68
+
69
+ if (/\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid api key|authentication/.test(lower)) {
70
+ return {
71
+ kind: "authentication",
72
+ title: "模型服务鉴权失败",
73
+ message: "模型服务拒绝了当前凭据,请检查 API Key、账号权限或凭据是否过期。",
74
+ occurredAt,
75
+ };
76
+ }
77
+
78
+ if (
79
+ /connect timeout|connection timed out|etimedout|und_err_connect_timeout/.test(lower)
80
+ || (lower.includes("cannot connect") && lower.includes("timeout"))
81
+ ) {
82
+ const retryText = attempts ? `,已重试 ${attempts} 次` : "";
83
+ const timeoutText = timeoutMs ? `,单次连接等待 ${formatSeconds(timeoutMs)}` : "";
84
+ return {
85
+ kind: "network_timeout",
86
+ title: "网络连接超时",
87
+ message: `连接模型服务失败${retryText}${timeoutText}。请检查网络、VPN或模型服务状态后重试。`,
88
+ occurredAt,
89
+ };
90
+ }
91
+
92
+ if (/econnrefused|econnreset|enotfound|eai_again|socket hang up|network error|cannot connect/.test(lower)) {
93
+ return {
94
+ kind: "network",
95
+ title: "无法连接模型服务",
96
+ message: "与模型服务的网络连接失败,请检查网络、VPN、DNS或服务状态后重试。",
97
+ occurredAt,
98
+ };
99
+ }
100
+
101
+ const httpStatus = raw.match(/\b(?:HTTP\s*)?(5\d\d)\b/i)?.[1];
102
+ if (httpStatus || /service unavailable|bad gateway|gateway timeout|provider error/.test(lower)) {
103
+ return {
104
+ kind: "provider",
105
+ title: "模型服务暂时不可用",
106
+ message: `模型服务返回异常${httpStatus ? `(HTTP ${httpStatus})` : ""},请稍后重试。`,
107
+ occurredAt,
108
+ };
109
+ }
110
+
111
+ const safeDetail = sanitizeTerminalErrorDetail(raw).trim() || "未提供错误详情";
112
+ return {
113
+ kind: "unknown",
114
+ title: "Agent 执行错误",
115
+ message: `发生未分类错误:${safeDetail}`,
116
+ occurredAt,
117
+ };
118
+ }
119
+
120
+ export function formatTerminalErrorReason(error: TerminalErrorInfo): string {
121
+ return `⚠️ 异常结束:${error.title}\n${error.message}`;
122
+ }
123
+
124
+ export function formatTerminalErrorNotice(error: TerminalErrorInfo, finalReply = ""): string {
125
+ const reason = formatTerminalErrorReason(error);
126
+ return finalReply.trim()
127
+ ? `${reason}\n\n以下回复可能不完整:\n\n${finalReply.trim()}`
128
+ : reason;
129
+ }