chatccc 0.2.204 → 0.2.205

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.204",
3
+ "version": "0.2.205",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ createAgentActivityTracker,
5
+ formatAgentActivityTitle,
6
+ updateAgentActivity,
7
+ } from "../agent-activity.ts";
8
+
9
+ describe("agent activity", () => {
10
+ it("starts with an explicit startup status", () => {
11
+ const tracker = createAgentActivityTracker(1_000);
12
+
13
+ expect(formatAgentActivityTitle(tracker.activity, 4_000)).toBe("正在启动 Agent · 3秒");
14
+ });
15
+
16
+ it("keeps the thinking timer stable across repeated thinking blocks", () => {
17
+ const tracker = createAgentActivityTracker(1_000);
18
+
19
+ expect(updateAgentActivity(tracker, { type: "thinking", thinking: "first" }, 2_000)).toBe(true);
20
+ expect(updateAgentActivity(tracker, { type: "thinking", thinking: "second" }, 5_000)).toBe(false);
21
+ expect(formatAgentActivityTitle(tracker.activity, 8_000)).toBe("思考中 · 6秒");
22
+ });
23
+
24
+ it("shows the active tool name and elapsed time", () => {
25
+ const tracker = createAgentActivityTracker(1_000);
26
+
27
+ updateAgentActivity(tracker, {
28
+ type: "tool_use",
29
+ id: "tool-1",
30
+ name: "Bash",
31
+ input: { command: "npm test" },
32
+ }, 3_000);
33
+
34
+ expect(formatAgentActivityTitle(tracker.activity, 38_000)).toBe("正在执行 Bash · 35秒");
35
+ });
36
+
37
+ it("tracks parallel tools and keeps the remaining tool after one result", () => {
38
+ const tracker = createAgentActivityTracker(1_000);
39
+
40
+ updateAgentActivity(tracker, { type: "tool_use", id: "read", name: "Read", input: {} }, 2_000);
41
+ updateAgentActivity(tracker, { type: "tool_use", id: "grep", name: "Grep", input: {} }, 3_000);
42
+ expect(formatAgentActivityTitle(tracker.activity, 4_000)).toBe("正在执行 Read 等 2 项 · 2秒");
43
+
44
+ updateAgentActivity(tracker, {
45
+ type: "tool_result",
46
+ tool_use_id: "read",
47
+ content: "done",
48
+ }, 5_000);
49
+ expect(formatAgentActivityTitle(tracker.activity, 7_000)).toBe("正在执行 Grep · 4秒");
50
+
51
+ updateAgentActivity(tracker, {
52
+ type: "tool_result",
53
+ tool_use_id: "grep",
54
+ content: "done",
55
+ }, 8_000);
56
+ expect(formatAgentActivityTitle(tracker.activity, 10_000)).toBe("正在处理工具结果 · 2秒");
57
+ });
58
+
59
+ it("switches to response and context-compaction statuses", () => {
60
+ const tracker = createAgentActivityTracker(1_000);
61
+
62
+ updateAgentActivity(tracker, { type: "text", text: "answer" }, 2_000);
63
+ expect(formatAgentActivityTitle(tracker.activity, 3_000)).toBe("正在生成回复 · 1秒");
64
+
65
+ updateAgentActivity(tracker, {
66
+ type: "compact_boundary",
67
+ trigger: "auto",
68
+ pre_tokens: 100_000,
69
+ }, 4_000);
70
+ expect(formatAgentActivityTitle(tracker.activity, 5_000)).toBe("正在整理上下文 · 1秒");
71
+ });
72
+
73
+ it("formats longer elapsed time without decorative animation", () => {
74
+ expect(formatAgentActivityTitle({ kind: "thinking", startedAt: 1_000 }, 74_000)).toBe("思考中 · 1分13秒");
75
+ });
76
+ });
@@ -76,12 +76,12 @@ describe("normalizeCodexMessage", () => {
76
76
  status: "in_progress",
77
77
  },
78
78
  });
79
- expect(result).not.toBeNull();
80
- expect(result!.type).toBe("assistant");
81
- expect(result!.blocks).toEqual([
82
- { type: "tool_use", name: "Bash", input: { command: "powershell.exe -Command ls" } },
83
- ]);
84
- });
79
+ expect(result).not.toBeNull();
80
+ expect(result!.type).toBe("assistant");
81
+ expect(result!.blocks).toEqual([
82
+ { type: "tool_use", id: "item_0", name: "Bash", input: { command: "powershell.exe -Command ls" } },
83
+ ]);
84
+ });
85
85
 
86
86
  it("normalizes command_execution completion as tool_result (success)", () => {
87
87
  const result = normalizeCodexMessage({
@@ -302,4 +302,4 @@ describe("createCodexAdapter", () => {
302
302
  });
303
303
  expect(await adapter.getSessionInfo("sid-C")).toBeUndefined();
304
304
  });
305
- });
305
+ });
@@ -660,11 +660,11 @@ describe("Cursor stream fixture - 端到端不重复", () => {
660
660
  session_id: "sid",
661
661
  timestamp_ms: 1000,
662
662
  } as Parameters<typeof normalizeCursorMessage>[0]);
663
- expect(result).not.toBeNull();
664
- expect(result!.blocks).toEqual([
665
- { type: "tool_use", name: "Bash", input: { command: "ls" } },
666
- ]);
667
- });
663
+ expect(result).not.toBeNull();
664
+ expect(result!.blocks).toEqual([
665
+ { type: "tool_use", id: "toolu_abc", name: "Bash", input: { command: "ls" } },
666
+ ]);
667
+ });
668
668
 
669
669
  it("normalizes tool_call completed → tool_result block (success)", () => {
670
670
  const result = normalizeCursorMessage({
@@ -4,10 +4,16 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
6
  // mock stream-state 以支持在测试中控制累积长度
7
- const mockStreamStates = new Map<string, {
8
- accumulatedContent: string;
9
- finalReply: string;
10
- status?: "running" | "done" | "stopped" | "error";
7
+ const mockStreamStates = new Map<string, {
8
+ accumulatedContent: string;
9
+ finalReply: string;
10
+ activity?: {
11
+ kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
12
+ startedAt: number;
13
+ toolName?: string;
14
+ toolCount?: number;
15
+ };
16
+ status?: "running" | "done" | "stopped" | "error";
11
17
  turnCount?: number;
12
18
  finalReplySentTurn?: number;
13
19
  finalReplySentAt?: number;
@@ -18,8 +24,9 @@ vi.mock("../stream-state.ts", () => ({
18
24
  if (!state) return null;
19
25
  return {
20
26
  sessionId: sid,
21
- accumulatedContent: state.accumulatedContent,
22
- finalReply: state.finalReply,
27
+ accumulatedContent: state.accumulatedContent,
28
+ finalReply: state.finalReply,
29
+ activity: state.activity,
23
30
  finalReplySentTurn: state.finalReplySentTurn,
24
31
  finalReplySentAt: state.finalReplySentAt,
25
32
  status: state.status ?? "running",
@@ -34,24 +41,31 @@ vi.mock("../stream-state.ts", () => ({
34
41
  writeStreamState: async (state: {
35
42
  sessionId: string;
36
43
  accumulatedContent: string;
37
- finalReply: string;
44
+ finalReply: string;
45
+ activity?: {
46
+ kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
47
+ startedAt: number;
48
+ toolName?: string;
49
+ toolCount?: number;
50
+ };
38
51
  status?: "running" | "done" | "stopped" | "error";
39
52
  turnCount?: number;
40
53
  finalReplySentTurn?: number;
41
54
  finalReplySentAt?: number;
42
55
  }) => {
43
56
  mockStreamStates.set(state.sessionId, {
44
- accumulatedContent: state.accumulatedContent,
45
- finalReply: state.finalReply,
57
+ accumulatedContent: state.accumulatedContent,
58
+ finalReply: state.finalReply,
59
+ activity: state.activity,
46
60
  status: state.status,
47
61
  turnCount: state.turnCount,
48
62
  finalReplySentTurn: state.finalReplySentTurn,
49
63
  finalReplySentAt: state.finalReplySentAt,
50
64
  });
51
65
  },
52
- createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
53
- sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
54
- }),
66
+ createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
67
+ sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", activity: { kind: "starting" as const, startedAt: Date.now() }, chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
68
+ }),
55
69
  isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
56
70
  markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
57
71
  const state = mockStreamStates.get(sid);
@@ -569,7 +583,7 @@ describe("runAgentSession process monitor", () => {
569
583
  });
570
584
  });
571
585
 
572
- describe("unified display loop WeChat delta", () => {
586
+ describe("unified display loop WeChat delta", () => {
573
587
  beforeEach(() => {
574
588
  vi.useFakeTimers();
575
589
  resetState();
@@ -636,10 +650,76 @@ describe("unified display loop WeChat delta", () => {
636
650
  "chat-wechat",
637
651
  "tool output",
638
652
  );
639
- });
640
- });
641
-
642
- describe("unified display loop terminal card update", () => {
653
+ });
654
+ });
655
+
656
+ describe("unified display loop activity status", () => {
657
+ beforeEach(() => {
658
+ vi.useFakeTimers();
659
+ vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
660
+ resetState();
661
+ resetBindingState();
662
+ mockStreamStates.clear();
663
+ });
664
+
665
+ afterEach(() => {
666
+ stopUnifiedDisplayLoop();
667
+ resetBindingState();
668
+ vi.useRealTimers();
669
+ });
670
+
671
+ it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
672
+ const platform = mockPlatform("feishu");
673
+ setSessionPlatform(platform);
674
+
675
+ bindChatToSession("sid-activity", "chat-activity");
676
+ recordLastActiveChat("sid-activity", "chat-activity");
677
+ sessionInfoMap.set("chat-activity", {
678
+ sessionId: "sid-activity",
679
+ turnCount: 1,
680
+ lastContextTokens: 0,
681
+ startTime: Date.now() - 12_000,
682
+ tool: "codex",
683
+ });
684
+ displayCards.set("chat-activity", {
685
+ cardId: "card-activity",
686
+ sequence: 1,
687
+ cardBusy: false,
688
+ cardCreatedAt: Date.now(),
689
+ lastSentContent: "",
690
+ streamErrorNotified: false,
691
+ sessionId: "sid-activity",
692
+ turnCount: 1,
693
+ dotCount: 0,
694
+ });
695
+ mockStreamStates.set("sid-activity", {
696
+ accumulatedContent: "正在检查日志",
697
+ finalReply: "",
698
+ activity: {
699
+ kind: "tool",
700
+ startedAt: Date.now() - 12_000,
701
+ toolName: "Shell",
702
+ toolCount: 1,
703
+ },
704
+ status: "running",
705
+ turnCount: 1,
706
+ });
707
+
708
+ startUnifiedDisplayLoop();
709
+ await vi.advanceTimersByTimeAsync(3_000);
710
+
711
+ const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
712
+ expect(payload).toBeTypeOf("string");
713
+ const card = JSON.parse(payload as string) as {
714
+ header: { title: { content: string } };
715
+ body: { elements: Array<{ tag: string; content?: string }> };
716
+ };
717
+ expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
718
+ expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
719
+ });
720
+ });
721
+
722
+ describe("unified display loop terminal card update", () => {
643
723
  beforeEach(() => {
644
724
  vi.useFakeTimers();
645
725
  resetState();
@@ -153,6 +153,7 @@ export function normalizeCodexMessage(
153
153
  blocks: [
154
154
  {
155
155
  type: "tool_use",
156
+ id: msg.item.id,
156
157
  name: "Bash",
157
158
  input: { command: msg.item.command },
158
159
  },
@@ -297,6 +297,7 @@ export function normalizeCursorMessage(
297
297
  blocks: [
298
298
  {
299
299
  type: "tool_use",
300
+ id: msg.call_id,
300
301
  name: mapToolCallKey(toolKey),
301
302
  input: toolData.args ?? {},
302
303
  },
@@ -0,0 +1,170 @@
1
+ import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
2
+
3
+ export type AgentActivityKind =
4
+ | "starting"
5
+ | "thinking"
6
+ | "tool"
7
+ | "processing"
8
+ | "responding"
9
+ | "searching"
10
+ | "compacting";
11
+
12
+ /** The user-visible activity of a running Agent turn. */
13
+ export interface AgentActivity {
14
+ kind: AgentActivityKind;
15
+ /** Time when the current activity began, used for truthful elapsed time. */
16
+ startedAt: number;
17
+ toolName?: string;
18
+ toolCount?: number;
19
+ }
20
+
21
+ interface ActiveTool {
22
+ id: string;
23
+ name: string;
24
+ startedAt: number;
25
+ }
26
+
27
+ export interface AgentActivityTracker {
28
+ activity: AgentActivity;
29
+ activeTools: Map<string, ActiveTool>;
30
+ nextAnonymousToolId: number;
31
+ }
32
+
33
+ export function createAgentActivityTracker(now = Date.now()): AgentActivityTracker {
34
+ return {
35
+ activity: { kind: "starting", startedAt: now },
36
+ activeTools: new Map(),
37
+ nextAnonymousToolId: 1,
38
+ };
39
+ }
40
+
41
+ function sameVisibleActivity(left: AgentActivity, right: AgentActivity): boolean {
42
+ return left.kind === right.kind
43
+ && left.toolName === right.toolName
44
+ && left.toolCount === right.toolCount;
45
+ }
46
+
47
+ function setActivity(tracker: AgentActivityTracker, next: AgentActivity): boolean {
48
+ if (sameVisibleActivity(tracker.activity, next)) return false;
49
+ tracker.activity = next;
50
+ return true;
51
+ }
52
+
53
+ function refreshToolActivity(tracker: AgentActivityTracker): boolean {
54
+ const tools = [...tracker.activeTools.values()];
55
+ const first = tools[0];
56
+ if (!first) return false;
57
+ return setActivity(tracker, {
58
+ kind: "tool",
59
+ startedAt: first.startedAt,
60
+ toolName: first.name,
61
+ toolCount: tools.length,
62
+ });
63
+ }
64
+
65
+ function removeCompletedTool(tracker: AgentActivityTracker, toolUseId: string): void {
66
+ if (toolUseId && tracker.activeTools.delete(toolUseId)) return;
67
+
68
+ // Older adapters did not always include a tool ID. Prefer an anonymous entry;
69
+ // if there is only one active call, it is still safe to match that result.
70
+ const anonymousId = [...tracker.activeTools.keys()].find((id) => id.startsWith("anonymous:"));
71
+ if (anonymousId) {
72
+ tracker.activeTools.delete(anonymousId);
73
+ } else if (tracker.activeTools.size === 1) {
74
+ const onlyId = tracker.activeTools.keys().next().value as string | undefined;
75
+ if (onlyId) tracker.activeTools.delete(onlyId);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Applies one normalized Agent event and returns whether the persisted activity
81
+ * changed. Tool activity takes precedence while a tool call is still active.
82
+ */
83
+ export function updateAgentActivity(
84
+ tracker: AgentActivityTracker,
85
+ block: UnifiedBlock,
86
+ now = Date.now(),
87
+ ): boolean {
88
+ if (block.type === "tool_use") {
89
+ const id = block.id || `anonymous:${tracker.nextAnonymousToolId++}`;
90
+ const existing = tracker.activeTools.get(id);
91
+ tracker.activeTools.set(id, {
92
+ id,
93
+ name: block.name || "未知工具",
94
+ startedAt: existing?.startedAt ?? now,
95
+ });
96
+ return refreshToolActivity(tracker);
97
+ }
98
+
99
+ if (block.type === "tool_result") {
100
+ removeCompletedTool(tracker, block.tool_use_id);
101
+ if (tracker.activeTools.size > 0) return refreshToolActivity(tracker);
102
+ return setActivity(tracker, { kind: "processing", startedAt: now });
103
+ }
104
+
105
+ if (tracker.activeTools.size > 0) return false;
106
+
107
+ switch (block.type) {
108
+ case "thinking":
109
+ case "redacted_thinking":
110
+ return setActivity(tracker, { kind: "thinking", startedAt: now });
111
+ case "text":
112
+ case "text_final":
113
+ return setActivity(tracker, { kind: "responding", startedAt: now });
114
+ case "search_result":
115
+ return setActivity(tracker, { kind: "searching", startedAt: now });
116
+ case "compact_boundary":
117
+ return setActivity(tracker, { kind: "compacting", startedAt: now });
118
+ }
119
+ }
120
+
121
+ function formatElapsed(startedAt: number, now: number): string {
122
+ const totalSeconds = Math.max(0, Math.floor((now - startedAt) / 1000));
123
+ if (totalSeconds < 60) return `${totalSeconds}秒`;
124
+ const totalMinutes = Math.floor(totalSeconds / 60);
125
+ const seconds = totalSeconds % 60;
126
+ if (totalMinutes < 60) return `${totalMinutes}分${seconds}秒`;
127
+ const hours = Math.floor(totalMinutes / 60);
128
+ const minutes = totalMinutes % 60;
129
+ return `${hours}小时${minutes}分`;
130
+ }
131
+
132
+ function displayToolName(name: string | undefined): string {
133
+ const normalized = (name || "未知工具").replace(/\s+/g, " ").trim();
134
+ return normalized.length > 24 ? `${normalized.slice(0, 23)}…` : normalized;
135
+ }
136
+
137
+ export function formatAgentActivityTitle(
138
+ activity: AgentActivity | undefined,
139
+ now = Date.now(),
140
+ ): string {
141
+ if (!activity) return "正在处理";
142
+
143
+ let label: string;
144
+ switch (activity.kind) {
145
+ case "starting":
146
+ label = "正在启动 Agent";
147
+ break;
148
+ case "thinking":
149
+ label = "思考中";
150
+ break;
151
+ case "tool": {
152
+ const count = Math.max(1, activity.toolCount ?? 1);
153
+ label = `正在执行 ${displayToolName(activity.toolName)}${count > 1 ? ` 等 ${count} 项` : ""}`;
154
+ break;
155
+ }
156
+ case "processing":
157
+ label = "正在处理工具结果";
158
+ break;
159
+ case "responding":
160
+ label = "正在生成回复";
161
+ break;
162
+ case "searching":
163
+ label = "正在处理搜索结果";
164
+ break;
165
+ case "compacting":
166
+ label = "正在整理上下文";
167
+ break;
168
+ }
169
+ return `${label} · ${formatElapsed(activity.startedAt, now)}`;
170
+ }
@@ -139,8 +139,10 @@ export interface DisplayCardState {
139
139
  cardId: string;
140
140
  sequence: number;
141
141
  cardBusy: boolean;
142
- cardCreatedAt: number;
143
- lastSentContent: string;
142
+ cardCreatedAt: number;
143
+ lastSentContent: string;
144
+ /** Last rendered activity header; elapsed time can change without body output. */
145
+ lastSentHeaderTitle?: string;
144
146
  streamErrorNotified: boolean;
145
147
  /** 所属 session */
146
148
  sessionId: string;
@@ -153,8 +155,8 @@ export interface DisplayCardState {
153
155
  lastSentAccLen?: number;
154
156
  /** WeChat delta: 上次发送时的 finalReply */
155
157
  lastSentFinalReply?: string;
156
- /** 点点点动画计数器(统一 display loop 每个卡片独立计数) */
157
- dotCount: number;
158
+ /** Liveness animation counter; the explicit activity header conveys Agent state. */
159
+ dotCount: number;
158
160
  }
159
161
 
160
162
  export const displayCards = new Map<string, DisplayCardState>();
package/src/session.ts CHANGED
@@ -21,7 +21,12 @@ import {
21
21
  toolDisplayName,
22
22
  ts,
23
23
  } from "./config.ts";
24
- import { buildProgressCard, getToolEmoji, isCodeBlockOpen, truncateContent } from "./cards.ts";
24
+ import { buildProgressCard, getToolEmoji, isCodeBlockOpen, truncateContent } from "./cards.ts";
25
+ import {
26
+ createAgentActivityTracker,
27
+ formatAgentActivityTitle,
28
+ updateAgentActivity,
29
+ } from "./agent-activity.ts";
25
30
  import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
26
31
  import { logTrace } from "./trace.ts";
27
32
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
@@ -82,18 +87,19 @@ async function sendFinalReplyTextOnce(
82
87
  return sent;
83
88
  }
84
89
 
85
- async function createVisibleProgressCard(
90
+ async function createVisibleProgressCard(
86
91
  platform: PlatformAdapter,
87
92
  chatId: string,
88
93
  sessionId: string,
89
- turnCount: number,
90
- notifyFailureText?: string,
91
- ): Promise<string | null> {
94
+ turnCount: number,
95
+ notifyFailureText?: string,
96
+ headerTitle = "正在启动 Agent · 0秒",
97
+ ): Promise<string | null> {
92
98
  for (let attempt = 1; attempt <= 2; attempt++) {
93
99
  let cardId: string | null = null;
94
100
  try {
95
- cardId = await platform.cardCreate(
96
- buildProgressCard("", { showStop: true, headerTitle: "生成中..." }),
101
+ cardId = await platform.cardCreate(
102
+ buildProgressCard("等待 Agent 输出...", { showStop: true, headerTitle }),
97
103
  );
98
104
  if (!cardId) throw new Error("empty card id");
99
105
  await platform.cardSend(chatId, cardId);
@@ -1115,29 +1121,33 @@ export async function runAgentSession(
1115
1121
  }
1116
1122
 
1117
1123
  // 初始化 stream-state.json
1118
- const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1119
- await writeStreamState(initialState);
1124
+ const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1125
+ const activityTracker = createAgentActivityTracker(initialState.activity?.startedAt ?? Date.now());
1126
+ await writeStreamState(initialState);
1120
1127
 
1121
1128
  // 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
1122
1129
  // 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
1123
1130
  const displayChatIdForNew = pickDisplayChat(sessionId);
1124
- if (displayChatIdForNew) {
1125
- const ppNew = platformForChat(displayChatIdForNew);
1126
- if (ppNew && ppNew.kind !== "wechat") {
1127
- const cardId = await createVisibleProgressCard(
1131
+ if (displayChatIdForNew) {
1132
+ const ppNew = platformForChat(displayChatIdForNew);
1133
+ if (ppNew && ppNew.kind !== "wechat") {
1134
+ const initialHeaderTitle = formatAgentActivityTitle(activityTracker.activity);
1135
+ const cardId = await createVisibleProgressCard(
1128
1136
  ppNew,
1129
1137
  displayChatIdForNew,
1130
1138
  sessionId,
1131
- nextTurnCount,
1132
- "生成中卡片发送失败,结果将以文本形式发送。",
1139
+ nextTurnCount,
1140
+ "生成中卡片发送失败,结果将以文本形式发送。",
1141
+ initialHeaderTitle,
1133
1142
  );
1134
1143
  if (cardId) {
1135
1144
  displayCards.set(displayChatIdForNew, {
1136
1145
  cardId,
1137
1146
  sequence: 1,
1138
1147
  cardBusy: false,
1139
- cardCreatedAt: Date.now(),
1140
- lastSentContent: "",
1148
+ cardCreatedAt: Date.now(),
1149
+ lastSentContent: "",
1150
+ lastSentHeaderTitle: initialHeaderTitle,
1141
1151
  streamErrorNotified: false,
1142
1152
  sessionId,
1143
1153
  turnCount: nextTurnCount,
@@ -1179,7 +1189,7 @@ export async function runAgentSession(
1179
1189
  let streamErrored = false;
1180
1190
 
1181
1191
  try {
1182
- for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1192
+ for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1183
1193
  onProcessStart: (processInfo) => {
1184
1194
  startPromptProcessMonitor(sessionId, processInfo);
1185
1195
  if (processInfo.pid !== undefined) registerProcess(processInfo.pid, sessionId);
@@ -1193,8 +1203,10 @@ export async function runAgentSession(
1193
1203
  if (prompt) prompt.closeSession = closeSession;
1194
1204
  },
1195
1205
  })) {
1196
- for (const block of unifiedMsg.blocks) {
1197
- accumulateBlockContent(block, state, toolCallMap);
1206
+ let activityChanged = false;
1207
+ for (const block of unifiedMsg.blocks) {
1208
+ if (updateAgentActivity(activityTracker, block)) activityChanged = true;
1209
+ accumulateBlockContent(block, state, toolCallMap);
1198
1210
 
1199
1211
  if (block.type === "compact_boundary" && block.post_tokens) {
1200
1212
  for (const cid of getChatsForSession(sessionId)) {
@@ -1213,13 +1225,14 @@ export async function runAgentSession(
1213
1225
 
1214
1226
  // 定时写入文件
1215
1227
  const now2 = Date.now();
1216
- if (now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1217
- lastFileWrite = now2;
1218
- await writeStreamState({
1228
+ if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1229
+ lastFileWrite = now2;
1230
+ await writeStreamState({
1219
1231
  sessionId,
1220
1232
  status: "running",
1221
- accumulatedContent: state.accumulatedContent,
1222
- finalReply: pickFinalReply(state),
1233
+ accumulatedContent: state.accumulatedContent,
1234
+ finalReply: pickFinalReply(state),
1235
+ activity: activityTracker.activity,
1223
1236
  chunkCount: state.chunkCount,
1224
1237
  turnCount: nextTurnCount,
1225
1238
  contextTokens: existingInfo?.lastContextTokens ?? 0,
@@ -1267,8 +1280,9 @@ export async function runAgentSession(
1267
1280
  await writeStreamState({
1268
1281
  sessionId,
1269
1282
  status: finalStatus,
1270
- accumulatedContent: state.accumulatedContent,
1271
- finalReply: finalReplyToWrite,
1283
+ accumulatedContent: state.accumulatedContent,
1284
+ finalReply: finalReplyToWrite,
1285
+ activity: activityTracker.activity,
1272
1286
  chunkCount: state.chunkCount,
1273
1287
  turnCount: nextTurnCount,
1274
1288
  contextTokens: existingInfo?.lastContextTokens ?? 0,
@@ -1553,14 +1567,16 @@ export function startUnifiedDisplayLoop(): void {
1553
1567
  }
1554
1568
  } else {
1555
1569
  // 非 WeChat: 卡片流程
1556
- if (display.turnCount !== state.turnCount) {
1570
+ if (display.turnCount !== state.turnCount) {
1557
1571
  console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
1558
1572
  finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => {});
1559
1573
  displayCards.delete(chatId);
1560
- continue;
1561
- }
1562
-
1563
- // 卡片轮转
1574
+ continue;
1575
+ }
1576
+
1577
+ const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
1578
+
1579
+ // 卡片轮转
1564
1580
  if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
1565
1581
  display.cardBusy = true;
1566
1582
  try {
@@ -1568,8 +1584,9 @@ export function startUnifiedDisplayLoop(): void {
1568
1584
  p,
1569
1585
  chatId,
1570
1586
  sessionId,
1571
- display.turnCount,
1572
- display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
1587
+ display.turnCount,
1588
+ display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
1589
+ activityHeaderTitle,
1573
1590
  );
1574
1591
  if (!newCardId) {
1575
1592
  display.streamErrorNotified = true;
@@ -1577,7 +1594,7 @@ export function startUnifiedDisplayLoop(): void {
1577
1594
  }
1578
1595
  const oldSeqBase = display.sequence;
1579
1596
  const oldContent = state.accumulatedContent + state.finalReply;
1580
- const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
1597
+ const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "上一阶段记录" });
1581
1598
  await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).then(() => {
1582
1599
  display.sequence = oldSeqBase + 1;
1583
1600
  }).catch(err => {
@@ -1588,9 +1605,10 @@ export function startUnifiedDisplayLoop(): void {
1588
1605
  display.sequence = 1;
1589
1606
  display.cardCreatedAt = Date.now();
1590
1607
  display.rotationAccLen = state.accumulatedContent.length;
1591
- display.rotationFinalReply = state.finalReply;
1592
- display.lastSentContent = "";
1593
- display.streamErrorNotified = false;
1608
+ display.rotationFinalReply = state.finalReply;
1609
+ display.lastSentContent = "";
1610
+ display.lastSentHeaderTitle = activityHeaderTitle;
1611
+ display.streamErrorNotified = false;
1594
1612
  } catch (err) {
1595
1613
  console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
1596
1614
  } finally {
@@ -1608,17 +1626,24 @@ export function startUnifiedDisplayLoop(): void {
1608
1626
  replyDelta = state.finalReply.slice(rotReply.length);
1609
1627
  } else {
1610
1628
  replyDelta = state.finalReply;
1611
- }
1612
- const delta = (accDelta + replyDelta).trim();
1613
-
1614
- display.dotCount = (display.dotCount % 9) + 1;
1615
- let deltaBase = (delta || "");
1616
- if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
1617
- const displayContent = deltaBase + "\n" + "。" .repeat(display.dotCount);
1618
- if (displayContent === display.lastSentContent) continue;
1619
-
1620
- display.lastSentContent = displayContent;
1621
- const deltaCard = buildProgressCard(truncateContent(displayContent) || "处理中...", { showStop: true, headerTitle: "生成中..." });
1629
+ }
1630
+ const delta = (accDelta + replyDelta).trim();
1631
+
1632
+ display.dotCount = (display.dotCount % 9) + 1;
1633
+ let deltaBase = delta;
1634
+ if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
1635
+ const displayContent = deltaBase + "\n" + "。".repeat(display.dotCount);
1636
+ if (
1637
+ displayContent === display.lastSentContent
1638
+ && activityHeaderTitle === display.lastSentHeaderTitle
1639
+ ) continue;
1640
+
1641
+ display.lastSentContent = displayContent;
1642
+ display.lastSentHeaderTitle = activityHeaderTitle;
1643
+ const deltaCard = buildProgressCard(truncateContent(displayContent) || "等待 Agent 输出...", {
1644
+ showStop: true,
1645
+ headerTitle: activityHeaderTitle,
1646
+ });
1622
1647
  display.cardBusy = true;
1623
1648
  const mySeq = display.sequence + 1;
1624
1649
  try {
@@ -1637,20 +1662,24 @@ export function startUnifiedDisplayLoop(): void {
1637
1662
  display.cardBusy = false;
1638
1663
  }
1639
1664
  continue;
1640
- }
1641
-
1642
- display.dotCount = (display.dotCount % 9) + 1;
1643
- let contentBase = state.accumulatedContent + state.finalReply;
1644
- if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
1645
- const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
1646
- if (fullContent === display.lastSentContent) continue;
1647
-
1648
- display.lastSentContent = fullContent;
1649
- const cardContent = truncateContent(fullContent);
1665
+ }
1666
+
1667
+ display.dotCount = (display.dotCount % 9) + 1;
1668
+ let contentBase = state.accumulatedContent + state.finalReply;
1669
+ if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
1670
+ const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
1671
+ if (
1672
+ fullContent === display.lastSentContent
1673
+ && activityHeaderTitle === display.lastSentHeaderTitle
1674
+ ) continue;
1675
+
1676
+ display.lastSentContent = fullContent;
1677
+ display.lastSentHeaderTitle = activityHeaderTitle;
1678
+ const cardContent = truncateContent(fullContent) || "等待 Agent 输出...";
1650
1679
  display.cardBusy = true;
1651
1680
  const mySeq = display.sequence + 1;
1652
1681
  try {
1653
- const card = buildProgressCard(cardContent, { showStop: true, headerTitle: "生成中..." });
1682
+ const card = buildProgressCard(cardContent, { showStop: true, headerTitle: activityHeaderTitle });
1654
1683
  await p.cardUpdate(display.cardId, card, mySeq);
1655
1684
  display.sequence = mySeq;
1656
1685
  } catch (err) {
@@ -1,7 +1,9 @@
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";
4
+ import { USER_DATA_DIR, ts } from "./config.ts";
5
+ import { createAgentActivityTracker } from "./agent-activity.ts";
6
+ import type { AgentActivity } from "./agent-activity.ts";
5
7
 
6
8
  // ---------------------------------------------------------------------------
7
9
  // stream-state.json — 每个 session 的流式输出持久化文件
@@ -16,7 +18,9 @@ export interface StreamState {
16
18
  /** 本轮会话中 LLM 输出的全部文本内容(所有 text block 的累加)。
17
19
  * 命名含 "final" 但实为"全部累积文本",并非仅"最终一段回复"。
18
20
  * 参见 session.ts 的 AccumulatorState 注释。 */
19
- finalReply: string;
21
+ finalReply: string;
22
+ /** Current user-visible work phase for running progress cards. */
23
+ activity?: AgentActivity;
20
24
  /** The turn whose terminal text reply has already been delivered to IM. */
21
25
  finalReplySentTurn?: number;
22
26
  finalReplySentAt?: number;
@@ -122,16 +126,18 @@ export async function markFinalReplySent(sessionId: string, turnCount: number, s
122
126
  await writeStreamState(state);
123
127
  }
124
128
 
125
- export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
126
- return {
129
+ export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
130
+ const now = Date.now();
131
+ return {
127
132
  sessionId,
128
133
  status: "running",
129
- accumulatedContent: "",
130
- finalReply: "",
134
+ accumulatedContent: "",
135
+ finalReply: "",
136
+ activity: createAgentActivityTracker(now).activity,
131
137
  chunkCount: 0,
132
138
  turnCount,
133
139
  contextTokens: 0,
134
- updatedAt: Date.now(),
140
+ updatedAt: now,
135
141
  cwd,
136
142
  tool,
137
143
  };