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 +1 -1
- package/src/__tests__/agent-activity.test.ts +76 -0
- package/src/__tests__/codex-adapter.test.ts +7 -7
- package/src/__tests__/cursor-adapter.test.ts +5 -5
- package/src/__tests__/session.test.ts +97 -17
- package/src/adapters/codex-adapter.ts +1 -0
- package/src/adapters/cursor-adapter.ts +1 -0
- package/src/agent-activity.ts +170 -0
- package/src/session-chat-binding.ts +6 -4
- package/src/session.ts +89 -60
- package/src/stream-state.ts +13 -7
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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();
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1197
|
-
|
|
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.
|
|
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 =
|
|
1616
|
-
if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
|
|
1617
|
-
const displayContent = deltaBase + "\n" + "。"
|
|
1618
|
-
if (
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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 (
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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) {
|
package/src/stream-state.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
140
|
+
updatedAt: now,
|
|
135
141
|
cwd,
|
|
136
142
|
tool,
|
|
137
143
|
};
|