chatccc 0.2.49 → 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__/cards.test.ts +9 -1
- package/src/__tests__/session.test.ts +426 -2
- package/src/__tests__/stream-state.test.ts +134 -0
- package/src/cards.ts +2 -2
- package/src/index.ts +125 -73
- package/src/session-chat-binding.ts +23 -0
- package/src/session.ts +208 -9
- package/src/shared.ts +95 -68
- package/src/stream-state.ts +140 -93
package/package.json
CHANGED
|
@@ -380,6 +380,14 @@ describe("buildSessionsCard", () => {
|
|
|
380
380
|
expect(afterPrivateChat).not.toContain("(群聊)");
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
+
it("shows chat id missing for sessions without a chat binding", () => {
|
|
384
|
+
const card = buildSessionsCard([
|
|
385
|
+
{ sessionId: "orphan", chatName: "", chatId: "", active: true, turnCount: 0, elapsedSeconds: 3, model: "Claude Opus 4.7", tool: "claude" },
|
|
386
|
+
]);
|
|
387
|
+
const content: string = JSON.parse(card).elements[0].text.content;
|
|
388
|
+
expect(content).toContain("chat id缺失");
|
|
389
|
+
});
|
|
390
|
+
|
|
383
391
|
it("includes /session help text in non-empty card", () => {
|
|
384
392
|
const card = buildSessionsCard([
|
|
385
393
|
{ sessionId: "abc123", chatName: "", chatId: "oc_abc123", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
|
|
@@ -446,4 +454,4 @@ describe("buildButtons", () => {
|
|
|
446
454
|
const obj = result as any;
|
|
447
455
|
expect(obj.actions[0].type).toBe("primary");
|
|
448
456
|
});
|
|
449
|
-
});
|
|
457
|
+
});
|
|
@@ -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
|
|
|
@@ -26,15 +26,30 @@ import {
|
|
|
26
26
|
getSessionStatus,
|
|
27
27
|
getAllSessionsStatus,
|
|
28
28
|
recordSessionRegistry,
|
|
29
|
+
saveSessionTool,
|
|
29
30
|
accumulateBlockContent,
|
|
30
31
|
pickFinalReply,
|
|
32
|
+
switchChatBinding,
|
|
33
|
+
rebuildBindingsFromRegistry,
|
|
31
34
|
UNKNOWN_MODEL_PLACEHOLDER,
|
|
32
35
|
_setSessionRegistryFileForTest,
|
|
33
36
|
_resetSessionRegistryFileForTest,
|
|
37
|
+
_setSessionToolsFileForTest,
|
|
38
|
+
_resetSessionToolsFileForTest,
|
|
34
39
|
_setAdapterForToolForTest,
|
|
35
40
|
_clearAdapterCacheForTest,
|
|
36
41
|
} from "../session.ts";
|
|
37
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
activePrompts,
|
|
44
|
+
bindChatToSession,
|
|
45
|
+
unbindChatFromSession,
|
|
46
|
+
recordLastActiveChat,
|
|
47
|
+
getLastActiveChat,
|
|
48
|
+
pickDisplayChat,
|
|
49
|
+
resetBindingState,
|
|
50
|
+
getChatsForSession,
|
|
51
|
+
displayCards,
|
|
52
|
+
} from "../session-chat-binding.ts";
|
|
38
53
|
import type { AccumulatorState } from "../session.ts";
|
|
39
54
|
import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
40
55
|
|
|
@@ -122,6 +137,94 @@ describe("resetState", () => {
|
|
|
122
137
|
});
|
|
123
138
|
});
|
|
124
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
|
+
|
|
125
228
|
describe("getSessionStatus", () => {
|
|
126
229
|
beforeEach(() => {
|
|
127
230
|
chatSessionMap.clear();
|
|
@@ -233,6 +336,7 @@ describe("getSessionStatus", () => {
|
|
|
233
336
|
|
|
234
337
|
describe("getAllSessionsStatus", () => {
|
|
235
338
|
let registryFile = "";
|
|
339
|
+
let sessionsFile = "";
|
|
236
340
|
|
|
237
341
|
beforeEach(async () => {
|
|
238
342
|
chatSessionMap.clear();
|
|
@@ -240,12 +344,15 @@ describe("getAllSessionsStatus", () => {
|
|
|
240
344
|
activePrompts.clear();
|
|
241
345
|
const dir = await mkdtemp(join(tmpdir(), "chatccc-session-registry-"));
|
|
242
346
|
registryFile = join(dir, "session-registry.json");
|
|
347
|
+
sessionsFile = join(dir, "sessions.json");
|
|
243
348
|
_setSessionRegistryFileForTest(registryFile);
|
|
349
|
+
_setSessionToolsFileForTest(sessionsFile);
|
|
244
350
|
});
|
|
245
351
|
|
|
246
352
|
afterEach(async () => {
|
|
247
353
|
_clearAdapterCacheForTest();
|
|
248
354
|
_resetSessionRegistryFileForTest();
|
|
355
|
+
_resetSessionToolsFileForTest();
|
|
249
356
|
if (registryFile) {
|
|
250
357
|
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
251
358
|
}
|
|
@@ -298,6 +405,32 @@ describe("getAllSessionsStatus", () => {
|
|
|
298
405
|
expect(result[1].chatName).toBe("test-chat-2");
|
|
299
406
|
});
|
|
300
407
|
|
|
408
|
+
it("includes recent sessions without a registry chat binding", async () => {
|
|
409
|
+
await saveSessionTool("orphan-session", "claude");
|
|
410
|
+
activePrompts.set("orphan-session", {
|
|
411
|
+
controller: new AbortController(),
|
|
412
|
+
stopped: false,
|
|
413
|
+
startTime: 3000,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const result = await getAllSessionsStatus();
|
|
417
|
+
expect(result).toHaveLength(1);
|
|
418
|
+
expect(result[0].sessionId).toBe("orphan-session");
|
|
419
|
+
expect(result[0].chatId).toBe("");
|
|
420
|
+
expect(result[0].active).toBe(true);
|
|
421
|
+
expect(result[0].turnCount).toBe(0);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("orphan sessions preserve chatName from sessions.json", async () => {
|
|
425
|
+
await saveSessionTool("orphan-with-name", "claude", "帮我写代码-src");
|
|
426
|
+
const result = await getAllSessionsStatus();
|
|
427
|
+
expect(result).toHaveLength(1);
|
|
428
|
+
expect(result[0].sessionId).toBe("orphan-with-name");
|
|
429
|
+
expect(result[0].chatId).toBe("");
|
|
430
|
+
expect(result[0].chatName).toBe("帮我写代码-src");
|
|
431
|
+
expect(result[0].active).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
|
|
301
434
|
it("returns recent disk sessions by updatedAt desc, limited to 20", async () => {
|
|
302
435
|
for (let i = 0; i < 25; i++) {
|
|
303
436
|
await recordSessionRegistry({
|
|
@@ -635,3 +768,294 @@ describe("pickFinalReply", () => {
|
|
|
635
768
|
).toBe("");
|
|
636
769
|
});
|
|
637
770
|
});
|
|
771
|
+
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
// pickDisplayChat — display loop 选择推送目标 chat 的纯函数
|
|
774
|
+
// 关键不变量:仅当某 chatId 既是 session 的"最后活跃 chat"且仍然绑定到该
|
|
775
|
+
// session 时才返回。否则返回 undefined(loop 当作"无活跃群",不推送)。
|
|
776
|
+
// 这是为了修复 /newh 后旧 session 仍向已解绑群推卡片的 bug。
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
describe("pickDisplayChat", () => {
|
|
780
|
+
beforeEach(() => {
|
|
781
|
+
resetBindingState();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("绑定 + 记录活跃后,返回该 chatId", () => {
|
|
785
|
+
bindChatToSession("sid-A", "chat_X");
|
|
786
|
+
recordLastActiveChat("sid-A", "chat_X");
|
|
787
|
+
expect(pickDisplayChat("sid-A")).toBe("chat_X");
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("从未记录过活跃 chat 时返回 undefined", () => {
|
|
791
|
+
bindChatToSession("sid-A", "chat_X");
|
|
792
|
+
expect(pickDisplayChat("sid-A")).toBeUndefined();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("最后活跃 chat 已被解绑(如 /newh 场景)时返回 undefined,避免向已离开本 session 的群推送", () => {
|
|
796
|
+
bindChatToSession("sid-A", "chat_X");
|
|
797
|
+
recordLastActiveChat("sid-A", "chat_X");
|
|
798
|
+
// 模拟 /newh:chat_X 被解绑,转给新 session
|
|
799
|
+
unbindChatFromSession("sid-A", "chat_X");
|
|
800
|
+
expect(pickDisplayChat("sid-A")).toBeUndefined();
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("session 仍绑定其他 chat 但 lastActive 是已解绑 chat 时返回 undefined(不应回退到任意绑定)", () => {
|
|
804
|
+
// 多群共享 session 的极端情况:lastActive 指向 chat_X,但 chat_X 已解绑
|
|
805
|
+
bindChatToSession("sid-A", "chat_X");
|
|
806
|
+
bindChatToSession("sid-A", "chat_Y");
|
|
807
|
+
recordLastActiveChat("sid-A", "chat_X");
|
|
808
|
+
unbindChatFromSession("sid-A", "chat_X");
|
|
809
|
+
expect(pickDisplayChat("sid-A")).toBeUndefined();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("session 绑定多个 chat 且 lastActive 是仍绑定的 chat 时正确返回", () => {
|
|
813
|
+
bindChatToSession("sid-A", "chat_X");
|
|
814
|
+
bindChatToSession("sid-A", "chat_Y");
|
|
815
|
+
recordLastActiveChat("sid-A", "chat_Y");
|
|
816
|
+
expect(pickDisplayChat("sid-A")).toBe("chat_Y");
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
// unbindChatFromSession — 双保险:清理 lastActiveChatMap[sessionId]
|
|
822
|
+
// 若该 sessionId 的 lastActive 正好指向被解绑的 chatId,则一并清掉,
|
|
823
|
+
// 防止后续逻辑(不仅 display loop)读到悬挂的旧记录。
|
|
824
|
+
// ---------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
describe("unbindChatFromSession 同步清理 lastActiveChatMap", () => {
|
|
827
|
+
beforeEach(() => {
|
|
828
|
+
resetBindingState();
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("解绑的 chat 正是 lastActive 时清掉记录", () => {
|
|
832
|
+
bindChatToSession("sid-A", "chat_X");
|
|
833
|
+
recordLastActiveChat("sid-A", "chat_X");
|
|
834
|
+
unbindChatFromSession("sid-A", "chat_X");
|
|
835
|
+
expect(getLastActiveChat("sid-A")).toBeUndefined();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("解绑的 chat 不是 lastActive 时保留 lastActive", () => {
|
|
839
|
+
// chat_X 是 lastActive,解绑 chat_Y 不应影响
|
|
840
|
+
bindChatToSession("sid-A", "chat_X");
|
|
841
|
+
bindChatToSession("sid-A", "chat_Y");
|
|
842
|
+
recordLastActiveChat("sid-A", "chat_X");
|
|
843
|
+
unbindChatFromSession("sid-A", "chat_Y");
|
|
844
|
+
expect(getLastActiveChat("sid-A")).toBe("chat_X");
|
|
845
|
+
});
|
|
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/cards.ts
CHANGED
|
@@ -294,8 +294,8 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
294
294
|
}
|
|
295
295
|
const toolLabel = s.tool === "cursor" ? "Cursor" : s.tool === "codex" ? "Codex" : "Claude Code";
|
|
296
296
|
const namePart = s.chatName ? `**${s.chatName}** ` : "";
|
|
297
|
-
const
|
|
298
|
-
return `**${i + 1}.** ${namePart}${
|
|
297
|
+
const chatTag = !s.chatId ? " (chat id缺失)" : s.chatId.startsWith("oc_") ? " (群聊)" : "";
|
|
298
|
+
return `**${i + 1}.** ${namePart}${chatTag} \`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
|
|
299
299
|
};
|
|
300
300
|
|
|
301
301
|
const lines: string[] = [`共 **${sessions.length}** 个会话:`, ""];
|