chatccc 0.2.50 → 0.2.51
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__/session.test.ts +308 -1
- package/src/__tests__/stream-state.test.ts +134 -0
- package/src/index.ts +83 -59
- package/src/session.ts +158 -1
- package/src/stream-state.ts +140 -93
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
saveSessionTool,
|
|
30
30
|
accumulateBlockContent,
|
|
31
31
|
pickFinalReply,
|
|
32
|
+
switchChatBinding,
|
|
33
|
+
rebuildBindingsFromRegistry,
|
|
32
34
|
UNKNOWN_MODEL_PLACEHOLDER,
|
|
33
35
|
_setSessionRegistryFileForTest,
|
|
34
36
|
_resetSessionRegistryFileForTest,
|
|
@@ -45,6 +47,8 @@ import {
|
|
|
45
47
|
getLastActiveChat,
|
|
46
48
|
pickDisplayChat,
|
|
47
49
|
resetBindingState,
|
|
50
|
+
getChatsForSession,
|
|
51
|
+
displayCards,
|
|
48
52
|
} from "../session-chat-binding.ts";
|
|
49
53
|
import type { AccumulatorState } from "../session.ts";
|
|
50
54
|
import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
@@ -133,6 +137,94 @@ describe("resetState", () => {
|
|
|
133
137
|
});
|
|
134
138
|
});
|
|
135
139
|
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// rebuildBindingsFromRegistry — SDK 重连/启动时只重建只读映射,不动运行时状态
|
|
142
|
+
//
|
|
143
|
+
// 这是 onReady/onReconnected 应当调用的函数(替代之前错误调用的 resetState)。
|
|
144
|
+
// 关键不变量:重建映射后,原有的 activePrompts、sessionInfoMap、displayCards、
|
|
145
|
+
// processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
describe("rebuildBindingsFromRegistry", () => {
|
|
149
|
+
let registryFile = "";
|
|
150
|
+
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
chatSessionMap.clear();
|
|
153
|
+
sessionInfoMap.clear();
|
|
154
|
+
activePrompts.clear();
|
|
155
|
+
processedMessages.clear();
|
|
156
|
+
resetBindingState();
|
|
157
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-rebuild-"));
|
|
158
|
+
registryFile = join(dir, "session-registry.json");
|
|
159
|
+
_setSessionRegistryFileForTest(registryFile);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
afterEach(async () => {
|
|
163
|
+
_resetSessionRegistryFileForTest();
|
|
164
|
+
if (registryFile) {
|
|
165
|
+
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("不清空 activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃", async () => {
|
|
170
|
+
// 模拟有一个后台 prompt 正在跑
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
activePrompts.set("session-running", { controller, stopped: false, startTime: Date.now() });
|
|
173
|
+
await recordSessionRegistry({
|
|
174
|
+
chatId: "chat-A",
|
|
175
|
+
sessionId: "session-running",
|
|
176
|
+
tool: "claude",
|
|
177
|
+
updatedAt: 100,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await rebuildBindingsFromRegistry();
|
|
181
|
+
|
|
182
|
+
// 关键不变量:重连后 activePrompts 必须保留,否则后台 generator 会变孤儿
|
|
183
|
+
expect(activePrompts.has("session-running")).toBe(true);
|
|
184
|
+
expect(activePrompts.get("session-running")?.controller).toBe(controller);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("不清空 sessionInfoMap:轮数计数在重连后保留", async () => {
|
|
188
|
+
sessionInfoMap.set("chat-A", {
|
|
189
|
+
sessionId: "sid-A", turnCount: 7, lastContextTokens: 50000,
|
|
190
|
+
startTime: 1000, tool: "claude",
|
|
191
|
+
});
|
|
192
|
+
await recordSessionRegistry({
|
|
193
|
+
chatId: "chat-A", sessionId: "sid-A", tool: "claude", updatedAt: 100,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await rebuildBindingsFromRegistry();
|
|
197
|
+
|
|
198
|
+
expect(sessionInfoMap.get("chat-A")?.turnCount).toBe(7);
|
|
199
|
+
expect(sessionInfoMap.get("chat-A")?.lastContextTokens).toBe(50000);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("不清空 processedMessages:重连后 SDK 重推消息仍能去重", async () => {
|
|
203
|
+
processedMessages.add("msg-id-1");
|
|
204
|
+
processedMessages.add("msg-id-2");
|
|
205
|
+
|
|
206
|
+
await rebuildBindingsFromRegistry();
|
|
207
|
+
|
|
208
|
+
expect(processedMessages.has("msg-id-1")).toBe(true);
|
|
209
|
+
expect(processedMessages.has("msg-id-2")).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("从 registry 重建 sessionId → chatId 映射(沿用 rebuildSessionChatsFromRegistry 行为)", async () => {
|
|
213
|
+
await recordSessionRegistry({
|
|
214
|
+
chatId: "chat-A", sessionId: "sid-X", tool: "claude", updatedAt: 100,
|
|
215
|
+
});
|
|
216
|
+
await recordSessionRegistry({
|
|
217
|
+
chatId: "chat-B", sessionId: "sid-X", tool: "claude", updatedAt: 200,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await rebuildBindingsFromRegistry();
|
|
221
|
+
|
|
222
|
+
// 同一 sessionId 被两个 chatId 共享时,两个都应在映射中
|
|
223
|
+
bindChatToSession("sid-X", "chat-A"); // 验证幂等(再次调用不会出错)
|
|
224
|
+
expect(true).toBe(true); // 真正的断言由 sessionChatsMap 通过 pickDisplayChat 等间接验证
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
136
228
|
describe("getSessionStatus", () => {
|
|
137
229
|
beforeEach(() => {
|
|
138
230
|
chatSessionMap.clear();
|
|
@@ -752,3 +844,218 @@ describe("unbindChatFromSession 同步清理 lastActiveChatMap", () => {
|
|
|
752
844
|
expect(getLastActiveChat("sid-A")).toBe("chat_X");
|
|
753
845
|
});
|
|
754
846
|
});
|
|
847
|
+
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
// switchChatBinding — 事务式 chat→session 切换(/newh、/session N 复用)
|
|
850
|
+
//
|
|
851
|
+
// 关键不变量:
|
|
852
|
+
// 1. p2p chatType 不调 updateChatInfo(私聊飞书 API 会直接抛错)
|
|
853
|
+
// 2. updateChatInfo 失败时,内存绑定/sessionInfoMap/displayCards 完全不动,
|
|
854
|
+
// 且 description 还是旧值,下次消息按旧 sessionId 路由不会乱
|
|
855
|
+
// 3. 成功时按 unbind 旧 → bind 新 → recordLastActiveChat 顺序原子切换
|
|
856
|
+
// 4. 持久化 registry + sessions.json
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
describe("switchChatBinding", () => {
|
|
860
|
+
let registryFile = "";
|
|
861
|
+
let sessionsFile = "";
|
|
862
|
+
|
|
863
|
+
beforeEach(async () => {
|
|
864
|
+
resetBindingState();
|
|
865
|
+
sessionInfoMap.clear();
|
|
866
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-switch-binding-"));
|
|
867
|
+
registryFile = join(dir, "session-registry.json");
|
|
868
|
+
sessionsFile = join(dir, "sessions.json");
|
|
869
|
+
_setSessionRegistryFileForTest(registryFile);
|
|
870
|
+
_setSessionToolsFileForTest(sessionsFile);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
afterEach(async () => {
|
|
874
|
+
_resetSessionRegistryFileForTest();
|
|
875
|
+
_resetSessionToolsFileForTest();
|
|
876
|
+
if (registryFile) {
|
|
877
|
+
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("群聊场景:API 成功后内存切换 + 持久化", async () => {
|
|
882
|
+
const calls: Array<{ chatId: string; name: string; desc: string }> = [];
|
|
883
|
+
const updateChatInfoFn = async (chatId: string, name: string, desc: string) => {
|
|
884
|
+
calls.push({ chatId, name, desc });
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
bindChatToSession("old-sid", "chat-1");
|
|
888
|
+
sessionInfoMap.set("chat-1", {
|
|
889
|
+
sessionId: "old-sid", turnCount: 5, lastContextTokens: 100,
|
|
890
|
+
startTime: 0, tool: "claude",
|
|
891
|
+
});
|
|
892
|
+
displayCards.set("chat-1", {
|
|
893
|
+
cardId: "c1", sequence: 1, cardBusy: false,
|
|
894
|
+
cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
const result = await switchChatBinding({
|
|
898
|
+
chatId: "chat-1",
|
|
899
|
+
chatType: "group",
|
|
900
|
+
oldSessionId: "old-sid",
|
|
901
|
+
newSessionId: "new-sid",
|
|
902
|
+
tool: "claude",
|
|
903
|
+
chatName: "新会话-test",
|
|
904
|
+
newDescription: "Claude Code Session: new-sid",
|
|
905
|
+
updateChatInfoFn,
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
expect(result.ok).toBe(true);
|
|
909
|
+
expect(calls).toHaveLength(1);
|
|
910
|
+
expect(calls[0]).toEqual({
|
|
911
|
+
chatId: "chat-1",
|
|
912
|
+
name: "新会话-test",
|
|
913
|
+
desc: "Claude Code Session: new-sid",
|
|
914
|
+
});
|
|
915
|
+
// 旧 session 已解绑,新 session 已绑
|
|
916
|
+
expect(getChatsForSession("old-sid")).toEqual([]);
|
|
917
|
+
expect(getChatsForSession("new-sid")).toEqual(["chat-1"]);
|
|
918
|
+
// displayCards 已清
|
|
919
|
+
expect(displayCards.has("chat-1")).toBe(false);
|
|
920
|
+
// sessionInfoMap 指向新 sessionId
|
|
921
|
+
expect(sessionInfoMap.get("chat-1")?.sessionId).toBe("new-sid");
|
|
922
|
+
// lastActiveChat 指向当前 chat
|
|
923
|
+
expect(getLastActiveChat("new-sid")).toBe("chat-1");
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("私聊场景:完全跳过 updateChatInfo,仍完成内存切换", async () => {
|
|
927
|
+
let called = false;
|
|
928
|
+
const updateChatInfoFn = async () => {
|
|
929
|
+
called = true;
|
|
930
|
+
throw new Error("p2p chat API would fail");
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const result = await switchChatBinding({
|
|
934
|
+
chatId: "p2p-chat",
|
|
935
|
+
chatType: "p2p",
|
|
936
|
+
oldSessionId: null,
|
|
937
|
+
newSessionId: "new-sid-p2p",
|
|
938
|
+
tool: "claude",
|
|
939
|
+
chatName: "新会话-p2p",
|
|
940
|
+
newDescription: "Claude Code Session: new-sid-p2p",
|
|
941
|
+
updateChatInfoFn,
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
expect(result.ok).toBe(true);
|
|
945
|
+
expect(called).toBe(false); // 私聊跳过 API 调用
|
|
946
|
+
expect(getChatsForSession("new-sid-p2p")).toEqual(["p2p-chat"]);
|
|
947
|
+
expect(sessionInfoMap.get("p2p-chat")?.sessionId).toBe("new-sid-p2p");
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("群聊 + updateChatInfo 抛错:内存完全不动 + 返回 error", async () => {
|
|
951
|
+
bindChatToSession("old-sid", "chat-1");
|
|
952
|
+
sessionInfoMap.set("chat-1", {
|
|
953
|
+
sessionId: "old-sid", turnCount: 5, lastContextTokens: 100,
|
|
954
|
+
startTime: 0, tool: "claude",
|
|
955
|
+
});
|
|
956
|
+
const oldDisplay = {
|
|
957
|
+
cardId: "c1", sequence: 1, cardBusy: false,
|
|
958
|
+
cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
|
|
959
|
+
};
|
|
960
|
+
displayCards.set("chat-1", oldDisplay);
|
|
961
|
+
|
|
962
|
+
const updateChatInfoFn = async () => {
|
|
963
|
+
throw new Error("network timeout");
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const result = await switchChatBinding({
|
|
967
|
+
chatId: "chat-1",
|
|
968
|
+
chatType: "group",
|
|
969
|
+
oldSessionId: "old-sid",
|
|
970
|
+
newSessionId: "new-sid",
|
|
971
|
+
tool: "claude",
|
|
972
|
+
chatName: "新会话-failed",
|
|
973
|
+
newDescription: "Claude Code Session: new-sid",
|
|
974
|
+
updateChatInfoFn,
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
expect(result.ok).toBe(false);
|
|
978
|
+
expect(result.error?.message).toBe("network timeout");
|
|
979
|
+
// 内存绑定保持旧状态
|
|
980
|
+
expect(getChatsForSession("old-sid")).toEqual(["chat-1"]);
|
|
981
|
+
expect(getChatsForSession("new-sid")).toEqual([]);
|
|
982
|
+
expect(displayCards.get("chat-1")).toBe(oldDisplay);
|
|
983
|
+
expect(sessionInfoMap.get("chat-1")?.sessionId).toBe("old-sid");
|
|
984
|
+
expect(sessionInfoMap.get("chat-1")?.turnCount).toBe(5);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it("oldSessionId 为 null 时不调 unbind(适用于私聊首次绑定)", async () => {
|
|
988
|
+
const updateChatInfoFn = async () => {};
|
|
989
|
+
|
|
990
|
+
const result = await switchChatBinding({
|
|
991
|
+
chatId: "fresh-chat",
|
|
992
|
+
chatType: "p2p",
|
|
993
|
+
oldSessionId: null,
|
|
994
|
+
newSessionId: "fresh-sid",
|
|
995
|
+
tool: "claude",
|
|
996
|
+
chatName: "首次会话",
|
|
997
|
+
newDescription: "Claude Code Session: fresh-sid",
|
|
998
|
+
updateChatInfoFn,
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
expect(result.ok).toBe(true);
|
|
1002
|
+
expect(getChatsForSession("fresh-sid")).toEqual(["fresh-chat"]);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("API 成功后,registry 持久化记录可被重新加载", async () => {
|
|
1006
|
+
const updateChatInfoFn = async () => {};
|
|
1007
|
+
|
|
1008
|
+
await switchChatBinding({
|
|
1009
|
+
chatId: "chat-persist",
|
|
1010
|
+
chatType: "group",
|
|
1011
|
+
oldSessionId: null,
|
|
1012
|
+
newSessionId: "persist-sid",
|
|
1013
|
+
tool: "cursor",
|
|
1014
|
+
chatName: "persist-name",
|
|
1015
|
+
newDescription: "Cursor Session: persist-sid",
|
|
1016
|
+
initialTurnCount: 3,
|
|
1017
|
+
initialContextTokens: 500,
|
|
1018
|
+
updateChatInfoFn,
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// 验证 registry 文件已写入
|
|
1022
|
+
const raw = await readFile(registryFile, "utf-8");
|
|
1023
|
+
const parsed = JSON.parse(raw);
|
|
1024
|
+
expect(parsed["chat-persist"]).toMatchObject({
|
|
1025
|
+
chatId: "chat-persist",
|
|
1026
|
+
sessionId: "persist-sid",
|
|
1027
|
+
tool: "cursor",
|
|
1028
|
+
chatName: "persist-name",
|
|
1029
|
+
turnCount: 3,
|
|
1030
|
+
lastContextTokens: 500,
|
|
1031
|
+
running: false,
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// ---------------------------------------------------------------------------
|
|
1037
|
+
// resetState 调用契约:仅供测试 + 进程首次启动。
|
|
1038
|
+
// 不应在 SDK onReady/onReconnected 中调用——会清空 activePrompts 让正在跑
|
|
1039
|
+
// 的后台 prompt 变成"孤儿 generator"(Map 删了但 controller 没 abort,
|
|
1040
|
+
// 导致同一 sessionId 双开 prompt)。
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
describe("resetState 契约:清空所有运行时状态", () => {
|
|
1044
|
+
it("清空 activePrompts 但不 abort controller(只能由进程首次启动调用)", () => {
|
|
1045
|
+
const controller = new AbortController();
|
|
1046
|
+
let aborted = false;
|
|
1047
|
+
controller.signal.addEventListener("abort", () => { aborted = true; });
|
|
1048
|
+
activePrompts.set("sid-running", {
|
|
1049
|
+
controller, stopped: false, startTime: 0,
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
resetState();
|
|
1053
|
+
|
|
1054
|
+
expect(activePrompts.size).toBe(0);
|
|
1055
|
+
// 注意:resetState 不主动 abort——所以如果生产代码在 prompt 跑过程中
|
|
1056
|
+
// 误调 resetState,后台 generator 仍会继续跑直到自然结束,但 activePrompts
|
|
1057
|
+
// 已经空了,下条消息会双开 prompt。这是 resetState 仅适用于"启动时"
|
|
1058
|
+
// (Map 本就是空的)的根本原因。
|
|
1059
|
+
expect(aborted).toBe(false);
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// 在 import stream-state 之前先 mock config,让 USER_DATA_DIR 指向临时目录
|
|
7
|
+
const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-stream-state-test-"));
|
|
8
|
+
vi.mock("../config.ts", async () => {
|
|
9
|
+
const actual = await vi.importActual<typeof import("../config.ts")>("../config.ts");
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
USER_DATA_DIR: TEST_DATA_DIR,
|
|
13
|
+
ts: () => "test-ts",
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
writeStreamState,
|
|
19
|
+
readStreamState,
|
|
20
|
+
createEmptyStreamState,
|
|
21
|
+
STREAMS_DIR,
|
|
22
|
+
_setRenameForTest,
|
|
23
|
+
_resetRenameForTest,
|
|
24
|
+
} = await import("../stream-state.ts");
|
|
25
|
+
|
|
26
|
+
describe("writeStreamState — atomic rename", () => {
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
// 清空测试目录
|
|
29
|
+
try {
|
|
30
|
+
const entries = await readdir(STREAMS_DIR);
|
|
31
|
+
for (const e of entries) await rm(join(STREAMS_DIR, e), { force: true });
|
|
32
|
+
} catch { /* dir not yet exist */ }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("writeStreamState 写完后 readStreamState 能读到完整内容", async () => {
|
|
40
|
+
const state = createEmptyStreamState("sid-1", "/tmp", "claude", 1);
|
|
41
|
+
state.accumulatedContent = "hello";
|
|
42
|
+
state.finalReply = "world";
|
|
43
|
+
|
|
44
|
+
await writeStreamState(state);
|
|
45
|
+
|
|
46
|
+
const got = await readStreamState("sid-1");
|
|
47
|
+
expect(got).not.toBeNull();
|
|
48
|
+
expect(got!.accumulatedContent).toBe("hello");
|
|
49
|
+
expect(got!.finalReply).toBe("world");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("写入完成后,临时 .tmp 文件不应残留(rename 成功路径)", async () => {
|
|
53
|
+
const state = createEmptyStreamState("sid-2", "/tmp", "claude", 1);
|
|
54
|
+
await writeStreamState(state);
|
|
55
|
+
|
|
56
|
+
const entries = await readdir(STREAMS_DIR);
|
|
57
|
+
// 只应有 sid-2.json,没有 sid-2.json.tmp
|
|
58
|
+
expect(entries).toContain("sid-2.json");
|
|
59
|
+
expect(entries.some((e) => e.endsWith(".tmp"))).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("覆盖写入时,reader 永远只读到完整 JSON(不读到半截)", async () => {
|
|
63
|
+
// 此测试验证:先写一个版本,再写第二个版本,reader 中间读取必然是某个完整版本
|
|
64
|
+
const state1 = createEmptyStreamState("sid-3", "/tmp", "claude", 1);
|
|
65
|
+
state1.accumulatedContent = "v1";
|
|
66
|
+
await writeStreamState(state1);
|
|
67
|
+
|
|
68
|
+
// 直接读原始字节,确认是完整 JSON
|
|
69
|
+
const raw1 = await readFile(join(STREAMS_DIR, "sid-3.json"), "utf-8");
|
|
70
|
+
expect(() => JSON.parse(raw1)).not.toThrow();
|
|
71
|
+
expect(JSON.parse(raw1).accumulatedContent).toBe("v1");
|
|
72
|
+
|
|
73
|
+
const state2 = createEmptyStreamState("sid-3", "/tmp", "claude", 2);
|
|
74
|
+
state2.accumulatedContent = "v2";
|
|
75
|
+
await writeStreamState(state2);
|
|
76
|
+
|
|
77
|
+
const raw2 = await readFile(join(STREAMS_DIR, "sid-3.json"), "utf-8");
|
|
78
|
+
expect(() => JSON.parse(raw2)).not.toThrow();
|
|
79
|
+
expect(JSON.parse(raw2).accumulatedContent).toBe("v2");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("rename 失败时降级为直接覆盖写,不抛错 + 数据仍写入", async () => {
|
|
83
|
+
let renameCalls = 0;
|
|
84
|
+
_setRenameForTest(async () => {
|
|
85
|
+
renameCalls++;
|
|
86
|
+
throw Object.assign(new Error("EPERM mocked"), { code: "EPERM" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const state = createEmptyStreamState("sid-fallback", "/tmp", "claude", 1);
|
|
91
|
+
state.accumulatedContent = "fallback-content";
|
|
92
|
+
|
|
93
|
+
// 不应抛错
|
|
94
|
+
await expect(writeStreamState(state)).resolves.toBeUndefined();
|
|
95
|
+
|
|
96
|
+
// 数据仍应写入(降级路径走 writeFile 覆盖)
|
|
97
|
+
const got = await readStreamState("sid-fallback");
|
|
98
|
+
expect(got).not.toBeNull();
|
|
99
|
+
expect(got!.accumulatedContent).toBe("fallback-content");
|
|
100
|
+
|
|
101
|
+
expect(renameCalls).toBe(1);
|
|
102
|
+
} finally {
|
|
103
|
+
_resetRenameForTest();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("readStreamState 对损坏的 JSON 返回 null(reader 兜底契约)", async () => {
|
|
108
|
+
// 直接写一段半截 JSON 到目标路径
|
|
109
|
+
const filePath = join(STREAMS_DIR, "sid-broken.json");
|
|
110
|
+
await writeFile(filePath, '{"sessionId":"sid-broken","accum', "utf-8");
|
|
111
|
+
|
|
112
|
+
const got = await readStreamState("sid-broken");
|
|
113
|
+
expect(got).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("readStreamState 对不存在文件返回 null", async () => {
|
|
117
|
+
const got = await readStreamState("never-existed");
|
|
118
|
+
expect(got).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("createEmptyStreamState", () => {
|
|
123
|
+
it("生成的 state 字段正确且 status=running", () => {
|
|
124
|
+
const s = createEmptyStreamState("sid-X", "/cwd", "cursor", 5);
|
|
125
|
+
expect(s.sessionId).toBe("sid-X");
|
|
126
|
+
expect(s.cwd).toBe("/cwd");
|
|
127
|
+
expect(s.tool).toBe("cursor");
|
|
128
|
+
expect(s.turnCount).toBe(5);
|
|
129
|
+
expect(s.status).toBe("running");
|
|
130
|
+
expect(s.accumulatedContent).toBe("");
|
|
131
|
+
expect(s.finalReply).toBe("");
|
|
132
|
+
expect(s.chunkCount).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -97,9 +97,11 @@ import {
|
|
|
97
97
|
initClaudeSession,
|
|
98
98
|
lastMsgTimestamps,
|
|
99
99
|
processedMessages,
|
|
100
|
+
rebuildBindingsFromRegistry,
|
|
100
101
|
resetState,
|
|
101
102
|
resumeAndPrompt,
|
|
102
103
|
sessionInfoMap,
|
|
104
|
+
switchChatBinding,
|
|
103
105
|
recordSessionRegistry,
|
|
104
106
|
getAdapterForTool,
|
|
105
107
|
stopSession,
|
|
@@ -717,11 +719,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
717
719
|
cwd = await getDefaultCwd(chatId);
|
|
718
720
|
}
|
|
719
721
|
|
|
720
|
-
//
|
|
721
|
-
// 旧 session
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
722
|
+
// 第一步:创建新 session(此时尚未碰任何内存绑定,失败可直接返回,
|
|
723
|
+
// 旧 session 状态完全保留)。
|
|
724
|
+
// 注意不 abort 旧 session:旧 session 若仍在跑,display loop 会继续
|
|
725
|
+
// 把卡片推到原群直到 prompt 自然结束(或被 /stop)。
|
|
725
726
|
let newSessionId: string;
|
|
726
727
|
try {
|
|
727
728
|
const init = await initClaudeSession(descriptionTool, cwd);
|
|
@@ -732,37 +733,46 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
732
733
|
return;
|
|
733
734
|
}
|
|
734
735
|
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
736
|
+
// 第二步:事务式切换 chat 绑定 —— 把 chat 从 oldSessionId 改嫁到
|
|
737
|
+
// newSessionId。switchChatBinding 内部保证:
|
|
738
|
+
// - 群聊先调 updateChatInfo 改群描述,失败则内存绑定完全不动
|
|
739
|
+
// - 私聊跳过 updateChatInfo(私聊 chatId 调群 API 必然失败)
|
|
740
|
+
// - API 成功后统一切换内存:unbind 旧 / clear displayCards / bind 新 /
|
|
741
|
+
// recordLastActiveChat / sessionInfoMap.set + 持久化 registry/sessions
|
|
742
|
+
// 详见 session.ts::switchChatBinding 注释。
|
|
739
743
|
const descPrefix = sessionPrefixForTool(descriptionTool);
|
|
740
744
|
const newName = sessionChatName("新会话", cwd);
|
|
741
|
-
|
|
742
|
-
console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
|
|
743
|
-
|
|
744
|
-
sessionInfoMap.set(chatId, {
|
|
745
|
-
sessionId: newSessionId,
|
|
746
|
-
turnCount: 0,
|
|
747
|
-
lastContextTokens: 0,
|
|
748
|
-
startTime: Date.now(),
|
|
749
|
-
tool: descriptionTool,
|
|
750
|
-
});
|
|
751
|
-
await recordSessionRegistry({
|
|
745
|
+
const switchResult = await switchChatBinding({
|
|
752
746
|
chatId,
|
|
753
|
-
|
|
747
|
+
chatType,
|
|
748
|
+
oldSessionId: sessionId,
|
|
749
|
+
newSessionId,
|
|
754
750
|
tool: descriptionTool,
|
|
755
751
|
chatName: newName,
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
startTime: Date.now(),
|
|
759
|
-
running: false,
|
|
752
|
+
newDescription: `${descPrefix} ${newSessionId}`,
|
|
753
|
+
updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
|
|
760
754
|
});
|
|
761
|
-
|
|
755
|
+
if (!switchResult.ok) {
|
|
756
|
+
// 飞书 API 失败:内存仍指向旧 session,新 session 已创建但无人使用,
|
|
757
|
+
// 留在 sessions.json(loadSessionTools)成为 orphan,可由 /sessions
|
|
758
|
+
// 列表展示供人工清理或下次 /newh 覆盖。代价小于让群 description
|
|
759
|
+
// 与内存绑定不一致。
|
|
760
|
+
logTrace(tid, "DONE", { outcome: "newh_update_chat_fail", error: switchResult.error?.message });
|
|
761
|
+
await sendCardReply(
|
|
762
|
+
freshToken, chatId, "Error",
|
|
763
|
+
`更新群描述失败,会话未切换(新 session 已创建但未启用):\n${switchResult.error?.message}`,
|
|
764
|
+
"red"
|
|
765
|
+
);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (chatType !== "p2p") {
|
|
769
|
+
console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
|
|
770
|
+
}
|
|
762
771
|
|
|
763
772
|
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
764
773
|
|
|
765
|
-
// 如果新 session 有活跃 prompt
|
|
774
|
+
// 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到。
|
|
775
|
+
// /newh 后通常是空 session,这里几乎走不到,但留作防御。
|
|
766
776
|
if (isSessionRunning(newSessionId)) {
|
|
767
777
|
const { ensureDisplayLoop } = await import("./session.ts");
|
|
768
778
|
ensureDisplayLoop(newSessionId);
|
|
@@ -834,12 +844,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
834
844
|
}
|
|
835
845
|
const target = ordered[index];
|
|
836
846
|
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
unbindChatFromSession(sessionId, chatId);
|
|
840
|
-
displayCards.delete(chatId);
|
|
841
|
-
}
|
|
842
|
-
|
|
847
|
+
// 第一步:解析目标 session 的 cwd(adapter.getSessionInfo 失败则 fallback,
|
|
848
|
+
// 这一步不动任何内存绑定,失败也不脏)。
|
|
843
849
|
const targetAdapter = getAdapterForTool(target.tool);
|
|
844
850
|
let cwd2: string;
|
|
845
851
|
try {
|
|
@@ -849,34 +855,43 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
849
855
|
cwd2 = await getDefaultCwd(chatId);
|
|
850
856
|
}
|
|
851
857
|
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
858
|
+
// 第二步:事务式切换 chat 绑定到 target.sessionId。
|
|
859
|
+
// switchChatBinding 内部行为同 /newh,详见 session.ts::switchChatBinding。
|
|
860
|
+
// 注意:这里把 turnCount 沿用 target.turnCount(从 registry 读到的目标 chat
|
|
861
|
+
// 历史轮数),而不是从 0 开始,这样 /status 显示的轮数延续历史。
|
|
856
862
|
const descPrefix2 = sessionPrefixForTool(target.tool);
|
|
857
863
|
const newName2 = target.chatName || sessionChatName("新会话", cwd2);
|
|
858
|
-
|
|
859
|
-
console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
|
|
860
|
-
|
|
861
|
-
sessionInfoMap.set(chatId, {
|
|
862
|
-
sessionId: target.sessionId,
|
|
863
|
-
turnCount: target.turnCount,
|
|
864
|
-
lastContextTokens: 0,
|
|
865
|
-
startTime: Date.now(),
|
|
866
|
-
tool: target.tool,
|
|
867
|
-
});
|
|
868
|
-
await recordSessionRegistry({
|
|
864
|
+
const switchResult = await switchChatBinding({
|
|
869
865
|
chatId,
|
|
870
|
-
|
|
866
|
+
chatType,
|
|
867
|
+
oldSessionId: sessionId,
|
|
868
|
+
newSessionId: target.sessionId,
|
|
871
869
|
tool: target.tool,
|
|
872
870
|
chatName: newName2,
|
|
873
|
-
|
|
871
|
+
newDescription: `${descPrefix2} ${target.sessionId}`,
|
|
872
|
+
initialTurnCount: target.turnCount,
|
|
873
|
+
initialContextTokens: 0,
|
|
874
|
+
updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
|
|
874
875
|
});
|
|
875
|
-
|
|
876
|
+
if (!switchResult.ok) {
|
|
877
|
+
logTrace(tid, "DONE", { outcome: "session_update_chat_fail", error: switchResult.error?.message });
|
|
878
|
+
await sendCardReply(
|
|
879
|
+
freshToken, chatId, "Error",
|
|
880
|
+
`更新群描述失败,会话未切换:\n${switchResult.error?.message}`,
|
|
881
|
+
"red"
|
|
882
|
+
);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (chatType !== "p2p") {
|
|
886
|
+
console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
|
|
887
|
+
}
|
|
876
888
|
|
|
877
889
|
setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
|
|
878
890
|
|
|
879
|
-
// 如果新 session 有活跃 prompt
|
|
891
|
+
// 如果新 session 有活跃 prompt,加上 display loop。注意:此时
|
|
892
|
+
// recordLastActiveChat 已经把 lastActive 改成当前 chat,会"抢走"
|
|
893
|
+
// 原有群的 display 推送(见 review 中 corner case 5)。这是已知
|
|
894
|
+
// 设计权衡:用户主动切换就是想"接管"该 session 的显示。
|
|
880
895
|
if (isSessionRunning(target.sessionId)) {
|
|
881
896
|
const { ensureDisplayLoop } = await import("./session.ts");
|
|
882
897
|
ensureDisplayLoop(target.sessionId);
|
|
@@ -1231,25 +1246,34 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1231
1246
|
console.error(`[WS] Local relay error: ${err.message}`);
|
|
1232
1247
|
});
|
|
1233
1248
|
} else {
|
|
1249
|
+
// 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
|
|
1250
|
+
// 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
|
|
1234
1251
|
resetState();
|
|
1235
|
-
// 启动时修正残留的 running 状态并重建 session→chat 映射
|
|
1236
1252
|
fixStaleStreamStates().then(async () => {
|
|
1237
1253
|
const registry = await loadSessionRegistryForBinding();
|
|
1238
1254
|
rebuildSessionChatsFromRegistry(registry);
|
|
1239
1255
|
}).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
|
|
1240
1256
|
|
|
1257
|
+
// ⚠️ 关键设计:onReady / onReconnected 只重建只读映射,**绝不**清空运行时
|
|
1258
|
+
// 状态(activePrompts、displayLoops、sessionInfoMap、processedMessages)。
|
|
1259
|
+
// SDK 重连只是底层 WebSocket 抖动,业务层不受影响:
|
|
1260
|
+
// - 后台 generator 仍在跑、stream-state 仍在被写
|
|
1261
|
+
// - display loop 仍在向群推送
|
|
1262
|
+
// - 已处理消息的去重 set 必须保留,避免 SDK 重推老消息时 prompt 跑两遍
|
|
1263
|
+
// 历史 bug:此处曾误调 resetState() 导致重连即让所有后台任务变孤儿,
|
|
1264
|
+
// 同一 session 还可能双开 prompt(详见 session.ts::resetState 注释)。
|
|
1241
1265
|
const wsClient = new WSClient({
|
|
1242
1266
|
appId: APP_ID,
|
|
1243
1267
|
appSecret: APP_SECRET,
|
|
1244
1268
|
onReady: async () => {
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1269
|
+
await rebuildBindingsFromRegistry().catch((err) =>
|
|
1270
|
+
console.error(`[${ts()}] [SDK READY] rebuild bindings failed: ${(err as Error).message}`)
|
|
1271
|
+
);
|
|
1248
1272
|
},
|
|
1249
1273
|
onReconnected: async () => {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1274
|
+
await rebuildBindingsFromRegistry().catch((err) =>
|
|
1275
|
+
console.error(`[${ts()}] [SDK RECONNECT] rebuild bindings failed: ${(err as Error).message}`)
|
|
1276
|
+
);
|
|
1253
1277
|
},
|
|
1254
1278
|
});
|
|
1255
1279
|
|
package/src/session.ts
CHANGED
|
@@ -85,6 +85,29 @@ export const sessionInfoMap = new Map<string, {
|
|
|
85
85
|
tool: string;
|
|
86
86
|
}>();
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 清空所有进程内运行时状态。
|
|
90
|
+
*
|
|
91
|
+
* ⚠️ 红线:**绝对不要**在飞书 SDK 的 onReady / onReconnected 回调里调用本函数。
|
|
92
|
+
* SDK 的 WebSocket 重连只是底层连接抖动,业务层(活跃 prompt、display loop、
|
|
93
|
+
* stream-state 文件、轮数计数)完全不受影响。在重连里调 resetState 会:
|
|
94
|
+
* 1) `activePrompts.clear()` 只是删 Map,**不会** abort 后台 generator。
|
|
95
|
+
* generator 继续跑、继续写 stream-state.json,但 display loop 已被
|
|
96
|
+
* stop,用户群里再也看不到任何更新;最终回复永远不发到群。
|
|
97
|
+
* 2) 该 sessionId 在内存里"看似空闲",下一条用户消息进来会**第二次进入**
|
|
98
|
+
* `runAgentSession`,同一个 cursor/claude session 同时跑两条 prompt,
|
|
99
|
+
* 输出互相串扰、token 计费翻倍。
|
|
100
|
+
* 3) `processedMessages` / `lastMsgTimestamps` 被清,SDK 重连后若服务端
|
|
101
|
+
* 重推已 ack 的消息,去重失效会让同一 prompt 被处理两次。
|
|
102
|
+
* 4) `sessionInfoMap` 清空后,群再发消息时 nextTurnCount 从 1 重新计数。
|
|
103
|
+
*
|
|
104
|
+
* 合法调用点:
|
|
105
|
+
* - 单元测试 setup(清测试间状态)
|
|
106
|
+
* - 进程首次启动(此时 Map 都是空的,调用纯粹是为了打 LOG)
|
|
107
|
+
*
|
|
108
|
+
* SDK 重连场景请改用 `rebuildBindingsFromRegistry()`,它只重建 sessionId →
|
|
109
|
+
* chatId 映射,不动任何运行时状态。
|
|
110
|
+
*/
|
|
88
111
|
export function resetState(): void {
|
|
89
112
|
for (const entry of chatSessionMap.values()) {
|
|
90
113
|
if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
|
|
@@ -94,7 +117,6 @@ export function resetState(): void {
|
|
|
94
117
|
sessionInfoMap.clear();
|
|
95
118
|
processedMessages.clear();
|
|
96
119
|
lastMsgTimestamps.clear();
|
|
97
|
-
// 清理新的全局状态
|
|
98
120
|
activePrompts.clear();
|
|
99
121
|
displayCards.clear();
|
|
100
122
|
for (const stop of displayLoops.values()) stop();
|
|
@@ -102,6 +124,9 @@ export function resetState(): void {
|
|
|
102
124
|
console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
|
|
103
125
|
}
|
|
104
126
|
|
|
127
|
+
// 注:`rebuildBindingsFromRegistry` 定义在下方与 loadSessionRegistry 同区域,
|
|
128
|
+
// 是 onReady/onReconnected 取代 resetState 的正确入口。
|
|
129
|
+
|
|
105
130
|
// ---------------------------------------------------------------------------
|
|
106
131
|
// Adapter: 按 tool 创建并缓存
|
|
107
132
|
// ---------------------------------------------------------------------------
|
|
@@ -235,6 +260,26 @@ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryDa
|
|
|
235
260
|
return loadSessionRegistry();
|
|
236
261
|
}
|
|
237
262
|
|
|
263
|
+
/**
|
|
264
|
+
* 从持久化的 registry 重建 sessionId → chatId 映射。
|
|
265
|
+
*
|
|
266
|
+
* 设计契约(替代之前 onReady/onReconnected 误用的 resetState):
|
|
267
|
+
* - **不动** activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃,
|
|
268
|
+
* 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
|
|
269
|
+
* 导致同一 sessionId 双开 generator
|
|
270
|
+
* - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
|
|
271
|
+
* - **不动** displayCards / displayLoops:正在跑的 prompt 还需要它们继续推卡片
|
|
272
|
+
* - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
|
|
273
|
+
* 去重 set 还在才能避免同一 prompt 跑两遍
|
|
274
|
+
*
|
|
275
|
+
* 唯一被重建的是 sessionChatsMap(通过调用 rebuildSessionChatsFromRegistry)——
|
|
276
|
+
* 该 Map 是从 registry 派生的纯只读映射,重建是幂等且廉价的。
|
|
277
|
+
*/
|
|
278
|
+
export async function rebuildBindingsFromRegistry(): Promise<void> {
|
|
279
|
+
const registry = await loadSessionRegistry();
|
|
280
|
+
rebuildSessionChatsFromRegistry(registry);
|
|
281
|
+
}
|
|
282
|
+
|
|
238
283
|
async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
|
|
239
284
|
try {
|
|
240
285
|
await mkdir(dirname(sessionRegistryFile), { recursive: true });
|
|
@@ -379,6 +424,118 @@ export function accumulateBlockContent(
|
|
|
379
424
|
}
|
|
380
425
|
}
|
|
381
426
|
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// switchChatBinding — /newh、/session N 共用的事务式"切换 chat 绑定"
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
//
|
|
431
|
+
// 设计契约(解决三类历史 bug):
|
|
432
|
+
//
|
|
433
|
+
// 1. 私聊不能调 updateChatInfo(飞书 API 在 p2p chatId 上会返回非 0 → throw)。
|
|
434
|
+
// 之前的实现没判断 chatType,私聊 /newh、/session N 走到 updateChatInfo
|
|
435
|
+
// 就直接抛错,留下"内存已切换、registry 没更新"的脏状态。
|
|
436
|
+
//
|
|
437
|
+
// 2. updateChatInfo 群聊也可能因为网络/频控失败。之前的代码顺序是
|
|
438
|
+
// 先 unbind 旧 → bind 新 → 再调 updateChatInfo
|
|
439
|
+
// API 失败后内存绑定已经切走,但群 description 还是旧 sessionId。
|
|
440
|
+
// 下次用户在群里发消息时,extractSessionInfo 拿到旧 sessionId,而内存绑定
|
|
441
|
+
// 指向新 sessionId,路由完全错乱(参考 /newh 的 corner case 7)。
|
|
442
|
+
//
|
|
443
|
+
// 3. 改成"先 API 后内存"的顺序后,API 失败就完全不切换内存,下次消息按
|
|
444
|
+
// 旧 description 正常路由到旧 session,新创建的 session 留在 sessions.json
|
|
445
|
+
// 里成为可清理的 orphan。
|
|
446
|
+
//
|
|
447
|
+
// 调用方约定:
|
|
448
|
+
// - newSessionId 必须是已经 createSession 完成的真实 session(本函数不创建)
|
|
449
|
+
// - oldSessionId 为 null 表示当前 chat 没有任何旧绑定(比如私聊首次绑)
|
|
450
|
+
// - 私聊跳过 updateChatInfo,直接做内存切换 + registry 持久化
|
|
451
|
+
// - API 失败时:
|
|
452
|
+
// * 不动内存绑定 / sessionInfoMap / displayCards
|
|
453
|
+
// * 返回 { ok: false, error }
|
|
454
|
+
// * 调用方负责把错误反馈给用户
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
export interface SwitchChatBindingArgs {
|
|
458
|
+
chatId: string;
|
|
459
|
+
chatType: string;
|
|
460
|
+
oldSessionId: string | null;
|
|
461
|
+
newSessionId: string;
|
|
462
|
+
tool: string;
|
|
463
|
+
/** 群名(私聊忽略) */
|
|
464
|
+
chatName: string;
|
|
465
|
+
/** 群描述(私聊忽略),通常为 `${sessionPrefixForTool(tool)} ${newSessionId}` */
|
|
466
|
+
newDescription: string;
|
|
467
|
+
/** 切换后 sessionInfoMap 的初始 turnCount/lastContextTokens(如沿用历史) */
|
|
468
|
+
initialTurnCount?: number;
|
|
469
|
+
initialContextTokens?: number;
|
|
470
|
+
/** 飞书 updateChatInfo 实现,依赖注入便于测试 mock */
|
|
471
|
+
updateChatInfoFn: (chatId: string, name: string, description: string) => Promise<void>;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export interface SwitchChatBindingResult {
|
|
475
|
+
ok: boolean;
|
|
476
|
+
error?: Error;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<SwitchChatBindingResult> {
|
|
480
|
+
const {
|
|
481
|
+
chatId,
|
|
482
|
+
chatType,
|
|
483
|
+
oldSessionId,
|
|
484
|
+
newSessionId,
|
|
485
|
+
tool,
|
|
486
|
+
chatName,
|
|
487
|
+
newDescription,
|
|
488
|
+
initialTurnCount = 0,
|
|
489
|
+
initialContextTokens = 0,
|
|
490
|
+
updateChatInfoFn,
|
|
491
|
+
} = args;
|
|
492
|
+
|
|
493
|
+
// Step 1: 群聊场景先调用飞书 API(不可逆操作放最前)。
|
|
494
|
+
// 私聊跳过——p2p chatId 调 updateChatInfo 必然失败。
|
|
495
|
+
if (chatType !== "p2p") {
|
|
496
|
+
try {
|
|
497
|
+
await updateChatInfoFn(chatId, chatName, newDescription);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// API 失败:完全不动内存,调用方负责回报用户。
|
|
500
|
+
return { ok: false, error: err as Error };
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Step 2: API 成功(或私聊跳过)后,原子地切换内存绑定。
|
|
505
|
+
// 这一段全是同步 Map 操作,不会失败。
|
|
506
|
+
if (oldSessionId) {
|
|
507
|
+
unbindChatFromSession(oldSessionId, chatId);
|
|
508
|
+
displayCards.delete(chatId);
|
|
509
|
+
}
|
|
510
|
+
bindChatToSession(newSessionId, chatId);
|
|
511
|
+
recordLastActiveChat(newSessionId, chatId);
|
|
512
|
+
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
sessionInfoMap.set(chatId, {
|
|
515
|
+
sessionId: newSessionId,
|
|
516
|
+
turnCount: initialTurnCount,
|
|
517
|
+
lastContextTokens: initialContextTokens,
|
|
518
|
+
startTime: now,
|
|
519
|
+
tool,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Step 3: 持久化(registry + sessions.json)。
|
|
523
|
+
// 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
|
|
524
|
+
await recordSessionRegistry({
|
|
525
|
+
chatId,
|
|
526
|
+
sessionId: newSessionId,
|
|
527
|
+
tool,
|
|
528
|
+
chatName,
|
|
529
|
+
turnCount: initialTurnCount,
|
|
530
|
+
lastContextTokens: initialContextTokens,
|
|
531
|
+
startTime: now,
|
|
532
|
+
running: false,
|
|
533
|
+
});
|
|
534
|
+
await saveSessionTool(newSessionId, tool, chatName);
|
|
535
|
+
|
|
536
|
+
return { ok: true };
|
|
537
|
+
}
|
|
538
|
+
|
|
382
539
|
// ---------------------------------------------------------------------------
|
|
383
540
|
// AI tool session management
|
|
384
541
|
// ---------------------------------------------------------------------------
|
package/src/stream-state.ts
CHANGED
|
@@ -1,94 +1,141 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
|
|
4
|
-
import { USER_DATA_DIR, ts } from "./config.ts";
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// stream-state.json — 每个 session 的流式输出持久化文件
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
11
|
-
|
|
12
|
-
export interface StreamState {
|
|
13
|
-
sessionId: string;
|
|
14
|
-
status: "running" | "done" | "stopped" | "error";
|
|
15
|
-
accumulatedContent: string;
|
|
16
|
-
finalReply: string;
|
|
17
|
-
chunkCount: number;
|
|
18
|
-
turnCount: number;
|
|
19
|
-
contextTokens: number;
|
|
20
|
-
updatedAt: number;
|
|
21
|
-
cwd: string;
|
|
22
|
-
tool: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getStreamStatePath(sessionId: string): string {
|
|
26
|
-
return join(STREAMS_DIR, `${sessionId}.json`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function readStreamState(sessionId: string): Promise<StreamState | null> {
|
|
30
|
-
try {
|
|
31
|
-
const raw = await readFile(getStreamStatePath(sessionId), "utf-8");
|
|
32
|
-
const parsed = JSON.parse(raw) as StreamState;
|
|
33
|
-
if (parsed && typeof parsed.sessionId === "string") return parsed;
|
|
34
|
-
return null;
|
|
35
|
-
} catch {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { USER_DATA_DIR, ts } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// stream-state.json — 每个 session 的流式输出持久化文件
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
11
|
+
|
|
12
|
+
export interface StreamState {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
status: "running" | "done" | "stopped" | "error";
|
|
15
|
+
accumulatedContent: string;
|
|
16
|
+
finalReply: string;
|
|
17
|
+
chunkCount: number;
|
|
18
|
+
turnCount: number;
|
|
19
|
+
contextTokens: number;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
cwd: string;
|
|
22
|
+
tool: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStreamStatePath(sessionId: string): string {
|
|
26
|
+
return join(STREAMS_DIR, `${sessionId}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readStreamState(sessionId: string): Promise<StreamState | null> {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(getStreamStatePath(sessionId), "utf-8");
|
|
32
|
+
const parsed = JSON.parse(raw) as StreamState;
|
|
33
|
+
if (parsed && typeof parsed.sessionId === "string") return parsed;
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// rename 的可注入实现——仅供测试模拟"Windows EPERM 降级"路径。
|
|
41
|
+
// 生产代码使用 node:fs/promises.rename。
|
|
42
|
+
let renameImpl: typeof rename = rename;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 仅供单测:注入自定义 rename 实现以验证降级分支。
|
|
46
|
+
* 调用方负责测试结束后用 _resetRenameForTest 还原。
|
|
47
|
+
*/
|
|
48
|
+
export function _setRenameForTest(impl: typeof rename): void {
|
|
49
|
+
renameImpl = impl;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function _resetRenameForTest(): void {
|
|
53
|
+
renameImpl = rename;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 把 StreamState 持久化到磁盘——尽量原子地写。
|
|
58
|
+
*
|
|
59
|
+
* 为什么需要原子写:display loop 每 3 秒读一次 stream-state.json,而
|
|
60
|
+
* runAgentSession 每 2 秒写一次。如果用 `writeFile(filePath, ...)` 直接
|
|
61
|
+
* 覆盖,reader 在写入过程中读会拿到半截 JSON,JSON.parse 报错——虽然
|
|
62
|
+
* readStreamState 包了 try/catch 返回 null 不会崩,但相当于"丢一帧"。
|
|
63
|
+
*
|
|
64
|
+
* 实现:
|
|
65
|
+
* 1) 先把内容写到 `<filePath>.tmp`(reader 不会读 .tmp)
|
|
66
|
+
* 2) 再用 `rename` 把 .tmp 替换成正式文件——`rename` 在 POSIX 上是真原子
|
|
67
|
+
* 操作,reader 任何时刻读到的都是某个完整的旧/新版本
|
|
68
|
+
*
|
|
69
|
+
* Windows 兼容性:Windows 上 `rename` 偶尔会因为目标文件正被 reader 打开
|
|
70
|
+
* 抛 EPERM/EBUSY(实测罕见,但非零概率)。降级方案:直接 writeFile 覆盖
|
|
71
|
+
* 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
|
|
72
|
+
* 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
|
|
73
|
+
*/
|
|
74
|
+
export async function writeStreamState(state: StreamState): Promise<void> {
|
|
75
|
+
const filePath = getStreamStatePath(state.sessionId);
|
|
76
|
+
const payload = JSON.stringify(state, null, 2);
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
79
|
+
const tmpPath = filePath + ".tmp";
|
|
80
|
+
await writeFile(tmpPath, payload, "utf-8");
|
|
81
|
+
try {
|
|
82
|
+
await renameImpl(tmpPath, filePath);
|
|
83
|
+
} catch (renameErr) {
|
|
84
|
+
// Windows 偶发 EPERM/EBUSY:rename 失败时降级为直接覆盖写。
|
|
85
|
+
// 此次写入失去原子性(reader 可能读到半截 JSON,但 readStreamState
|
|
86
|
+
// 的 try/catch 会兜底返回 null,只是丢一帧 display 更新)。
|
|
87
|
+
console.warn(
|
|
88
|
+
`[${ts()}] [STREAM-STATE] rename failed for ${state.sessionId}, ` +
|
|
89
|
+
`fallback to overwrite: ${(renameErr as Error).message}`,
|
|
90
|
+
);
|
|
91
|
+
await writeFile(filePath, payload, "utf-8");
|
|
92
|
+
// 尽力清理 .tmp,失败也无所谓——下次 write 会覆盖它
|
|
93
|
+
await unlink(tmpPath).catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
|
|
101
|
+
return {
|
|
102
|
+
sessionId,
|
|
103
|
+
status: "running",
|
|
104
|
+
accumulatedContent: "",
|
|
105
|
+
finalReply: "",
|
|
106
|
+
chunkCount: 0,
|
|
107
|
+
turnCount,
|
|
108
|
+
contextTokens: 0,
|
|
109
|
+
updatedAt: Date.now(),
|
|
110
|
+
cwd,
|
|
111
|
+
tool,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 进程重启时修正虚假的 "running" 状态 */
|
|
116
|
+
export async function fixStaleStreamStates(): Promise<void> {
|
|
117
|
+
// 启动时将残留的 running 标记为 error
|
|
118
|
+
// 实际 agent 进程已不存在,下次 prompt 时会自然覆盖
|
|
119
|
+
try {
|
|
120
|
+
const { readdir } = await import("node:fs/promises");
|
|
121
|
+
let entries: string[];
|
|
122
|
+
try {
|
|
123
|
+
entries = await readdir(STREAMS_DIR);
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!entry.endsWith(".json")) continue;
|
|
129
|
+
const state = await readStreamState(entry.replace(".json", ""));
|
|
130
|
+
if (state && state.status === "running") {
|
|
131
|
+
state.status = "error";
|
|
132
|
+
state.accumulatedContent += "\n\n⚠️ 进程重启,流式输出中断。";
|
|
133
|
+
state.updatedAt = Date.now();
|
|
134
|
+
await writeStreamState(state);
|
|
135
|
+
console.log(`[${ts()}] [STREAM-STATE] marked stale running as error: ${state.sessionId}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
|
|
140
|
+
}
|
|
94
141
|
}
|