chatccc 0.2.50 → 0.2.52
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/README.md +51 -4
- package/config.sample.json +34 -30
- package/package.json +1 -1
- package/src/__tests__/card-plain-text.test.ts +42 -0
- package/src/__tests__/claude-adapter.test.ts +54 -1
- package/src/__tests__/config-reload.test.ts +64 -47
- package/src/__tests__/config-sample.test.ts +19 -0
- package/src/__tests__/session.test.ts +346 -1
- package/src/__tests__/stream-state.test.ts +134 -0
- package/src/__tests__/wechat-platform.test.ts +32 -0
- package/src/adapters/claude-adapter.ts +23 -2
- package/src/card-plain-text.ts +108 -0
- package/src/cards.ts +4 -4
- package/src/config.ts +775 -742
- package/src/feishu-api.ts +6 -0
- package/src/index.ts +172 -781
- package/src/orchestrator.ts +1032 -0
- package/src/platform-adapter.ts +61 -0
- package/src/session-chat-binding.ts +2 -0
- package/src/session.ts +431 -114
- package/src/sim-agent.ts +42 -31
- package/src/stream-state.ts +140 -93
- package/src/web-ui.ts +1891 -1718
- package/src/wechat-platform.ts +517 -0
|
@@ -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,
|
|
@@ -36,6 +38,9 @@ import {
|
|
|
36
38
|
_resetSessionToolsFileForTest,
|
|
37
39
|
_setAdapterForToolForTest,
|
|
38
40
|
_clearAdapterCacheForTest,
|
|
41
|
+
setSessionPlatform,
|
|
42
|
+
recordChatPlatform,
|
|
43
|
+
_getPlatformForChatForTest,
|
|
39
44
|
} from "../session.ts";
|
|
40
45
|
import {
|
|
41
46
|
activePrompts,
|
|
@@ -45,9 +50,12 @@ import {
|
|
|
45
50
|
getLastActiveChat,
|
|
46
51
|
pickDisplayChat,
|
|
47
52
|
resetBindingState,
|
|
53
|
+
getChatsForSession,
|
|
54
|
+
displayCards,
|
|
48
55
|
} from "../session-chat-binding.ts";
|
|
49
56
|
import type { AccumulatorState } from "../session.ts";
|
|
50
57
|
import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
58
|
+
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
51
59
|
|
|
52
60
|
// Helper to create a mock active session entry
|
|
53
61
|
function mockActiveSession(chatId: string, overrides: Partial<{
|
|
@@ -112,6 +120,23 @@ function mockAdapter(getInfo: (sid: string) => SessionInfo | undefined): ToolAda
|
|
|
112
120
|
};
|
|
113
121
|
}
|
|
114
122
|
|
|
123
|
+
function mockPlatform(name: string): PlatformAdapter {
|
|
124
|
+
return {
|
|
125
|
+
sendText: vi.fn(async () => true),
|
|
126
|
+
sendCard: vi.fn(async () => true),
|
|
127
|
+
sendRawCard: vi.fn(async () => true),
|
|
128
|
+
createGroup: vi.fn(async () => `${name}-group`),
|
|
129
|
+
updateChatInfo: vi.fn(async () => {}),
|
|
130
|
+
getChatInfo: vi.fn(async () => ({ name, description: "" })),
|
|
131
|
+
disbandChat: vi.fn(async () => {}),
|
|
132
|
+
setChatAvatar: vi.fn(async () => {}),
|
|
133
|
+
extractSessionInfo: vi.fn(() => null),
|
|
134
|
+
cardCreate: vi.fn(async () => `${name}-card`),
|
|
135
|
+
cardSend: vi.fn(async () => `${name}-message`),
|
|
136
|
+
cardUpdate: vi.fn(async () => {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
describe("resetState", () => {
|
|
116
141
|
it("clears all maps and sets", () => {
|
|
117
142
|
chatSessionMap.set("chat1", {
|
|
@@ -133,6 +158,111 @@ describe("resetState", () => {
|
|
|
133
158
|
});
|
|
134
159
|
});
|
|
135
160
|
|
|
161
|
+
describe("chat platform routing", () => {
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
resetState();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("uses the platform recorded for the chat before falling back to the default platform", () => {
|
|
167
|
+
const feishu = mockPlatform("feishu");
|
|
168
|
+
const wechat = mockPlatform("wechat");
|
|
169
|
+
|
|
170
|
+
setSessionPlatform(feishu);
|
|
171
|
+
recordChatPlatform("wx-chat", wechat);
|
|
172
|
+
|
|
173
|
+
expect(_getPlatformForChatForTest("wx-chat")).toBe(wechat);
|
|
174
|
+
expect(_getPlatformForChatForTest("feishu-chat")).toBe(feishu);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// rebuildBindingsFromRegistry — SDK 重连/启动时只重建只读映射,不动运行时状态
|
|
180
|
+
//
|
|
181
|
+
// 这是 onReady/onReconnected 应当调用的函数(替代之前错误调用的 resetState)。
|
|
182
|
+
// 关键不变量:重建映射后,原有的 activePrompts、sessionInfoMap、displayCards、
|
|
183
|
+
// processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
describe("rebuildBindingsFromRegistry", () => {
|
|
187
|
+
let registryFile = "";
|
|
188
|
+
|
|
189
|
+
beforeEach(async () => {
|
|
190
|
+
chatSessionMap.clear();
|
|
191
|
+
sessionInfoMap.clear();
|
|
192
|
+
activePrompts.clear();
|
|
193
|
+
processedMessages.clear();
|
|
194
|
+
resetBindingState();
|
|
195
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-rebuild-"));
|
|
196
|
+
registryFile = join(dir, "session-registry.json");
|
|
197
|
+
_setSessionRegistryFileForTest(registryFile);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
afterEach(async () => {
|
|
201
|
+
_resetSessionRegistryFileForTest();
|
|
202
|
+
if (registryFile) {
|
|
203
|
+
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("不清空 activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃", async () => {
|
|
208
|
+
// 模拟有一个后台 prompt 正在跑
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
activePrompts.set("session-running", { controller, stopped: false, startTime: Date.now() });
|
|
211
|
+
await recordSessionRegistry({
|
|
212
|
+
chatId: "chat-A",
|
|
213
|
+
sessionId: "session-running",
|
|
214
|
+
tool: "claude",
|
|
215
|
+
updatedAt: 100,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await rebuildBindingsFromRegistry();
|
|
219
|
+
|
|
220
|
+
// 关键不变量:重连后 activePrompts 必须保留,否则后台 generator 会变孤儿
|
|
221
|
+
expect(activePrompts.has("session-running")).toBe(true);
|
|
222
|
+
expect(activePrompts.get("session-running")?.controller).toBe(controller);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("不清空 sessionInfoMap:轮数计数在重连后保留", async () => {
|
|
226
|
+
sessionInfoMap.set("chat-A", {
|
|
227
|
+
sessionId: "sid-A", turnCount: 7, lastContextTokens: 50000,
|
|
228
|
+
startTime: 1000, tool: "claude",
|
|
229
|
+
});
|
|
230
|
+
await recordSessionRegistry({
|
|
231
|
+
chatId: "chat-A", sessionId: "sid-A", tool: "claude", updatedAt: 100,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await rebuildBindingsFromRegistry();
|
|
235
|
+
|
|
236
|
+
expect(sessionInfoMap.get("chat-A")?.turnCount).toBe(7);
|
|
237
|
+
expect(sessionInfoMap.get("chat-A")?.lastContextTokens).toBe(50000);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("不清空 processedMessages:重连后 SDK 重推消息仍能去重", async () => {
|
|
241
|
+
processedMessages.add("msg-id-1");
|
|
242
|
+
processedMessages.add("msg-id-2");
|
|
243
|
+
|
|
244
|
+
await rebuildBindingsFromRegistry();
|
|
245
|
+
|
|
246
|
+
expect(processedMessages.has("msg-id-1")).toBe(true);
|
|
247
|
+
expect(processedMessages.has("msg-id-2")).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("从 registry 重建 sessionId → chatId 映射(沿用 rebuildSessionChatsFromRegistry 行为)", async () => {
|
|
251
|
+
await recordSessionRegistry({
|
|
252
|
+
chatId: "chat-A", sessionId: "sid-X", tool: "claude", updatedAt: 100,
|
|
253
|
+
});
|
|
254
|
+
await recordSessionRegistry({
|
|
255
|
+
chatId: "chat-B", sessionId: "sid-X", tool: "claude", updatedAt: 200,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await rebuildBindingsFromRegistry();
|
|
259
|
+
|
|
260
|
+
// 同一 sessionId 被两个 chatId 共享时,两个都应在映射中
|
|
261
|
+
bindChatToSession("sid-X", "chat-A"); // 验证幂等(再次调用不会出错)
|
|
262
|
+
expect(true).toBe(true); // 真正的断言由 sessionChatsMap 通过 pickDisplayChat 等间接验证
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
136
266
|
describe("getSessionStatus", () => {
|
|
137
267
|
beforeEach(() => {
|
|
138
268
|
chatSessionMap.clear();
|
|
@@ -752,3 +882,218 @@ describe("unbindChatFromSession 同步清理 lastActiveChatMap", () => {
|
|
|
752
882
|
expect(getLastActiveChat("sid-A")).toBe("chat_X");
|
|
753
883
|
});
|
|
754
884
|
});
|
|
885
|
+
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
// switchChatBinding — 事务式 chat→session 切换(/newh、/session N 复用)
|
|
888
|
+
//
|
|
889
|
+
// 关键不变量:
|
|
890
|
+
// 1. p2p chatType 不调 updateChatInfo(私聊飞书 API 会直接抛错)
|
|
891
|
+
// 2. updateChatInfo 失败时,内存绑定/sessionInfoMap/displayCards 完全不动,
|
|
892
|
+
// 且 description 还是旧值,下次消息按旧 sessionId 路由不会乱
|
|
893
|
+
// 3. 成功时按 unbind 旧 → bind 新 → recordLastActiveChat 顺序原子切换
|
|
894
|
+
// 4. 持久化 registry + sessions.json
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
describe("switchChatBinding", () => {
|
|
898
|
+
let registryFile = "";
|
|
899
|
+
let sessionsFile = "";
|
|
900
|
+
|
|
901
|
+
beforeEach(async () => {
|
|
902
|
+
resetBindingState();
|
|
903
|
+
sessionInfoMap.clear();
|
|
904
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-switch-binding-"));
|
|
905
|
+
registryFile = join(dir, "session-registry.json");
|
|
906
|
+
sessionsFile = join(dir, "sessions.json");
|
|
907
|
+
_setSessionRegistryFileForTest(registryFile);
|
|
908
|
+
_setSessionToolsFileForTest(sessionsFile);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
afterEach(async () => {
|
|
912
|
+
_resetSessionRegistryFileForTest();
|
|
913
|
+
_resetSessionToolsFileForTest();
|
|
914
|
+
if (registryFile) {
|
|
915
|
+
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("群聊场景:API 成功后内存切换 + 持久化", async () => {
|
|
920
|
+
const calls: Array<{ chatId: string; name: string; desc: string }> = [];
|
|
921
|
+
const updateChatInfoFn = async (chatId: string, name: string, desc: string) => {
|
|
922
|
+
calls.push({ chatId, name, desc });
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
bindChatToSession("old-sid", "chat-1");
|
|
926
|
+
sessionInfoMap.set("chat-1", {
|
|
927
|
+
sessionId: "old-sid", turnCount: 5, lastContextTokens: 100,
|
|
928
|
+
startTime: 0, tool: "claude",
|
|
929
|
+
});
|
|
930
|
+
displayCards.set("chat-1", {
|
|
931
|
+
cardId: "c1", sequence: 1, cardBusy: false,
|
|
932
|
+
cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
const result = await switchChatBinding({
|
|
936
|
+
chatId: "chat-1",
|
|
937
|
+
chatType: "group",
|
|
938
|
+
oldSessionId: "old-sid",
|
|
939
|
+
newSessionId: "new-sid",
|
|
940
|
+
tool: "claude",
|
|
941
|
+
chatName: "新会话-test",
|
|
942
|
+
newDescription: "Claude Code Session: new-sid",
|
|
943
|
+
updateChatInfoFn,
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
expect(result.ok).toBe(true);
|
|
947
|
+
expect(calls).toHaveLength(1);
|
|
948
|
+
expect(calls[0]).toEqual({
|
|
949
|
+
chatId: "chat-1",
|
|
950
|
+
name: "新会话-test",
|
|
951
|
+
desc: "Claude Code Session: new-sid",
|
|
952
|
+
});
|
|
953
|
+
// 旧 session 已解绑,新 session 已绑
|
|
954
|
+
expect(getChatsForSession("old-sid")).toEqual([]);
|
|
955
|
+
expect(getChatsForSession("new-sid")).toEqual(["chat-1"]);
|
|
956
|
+
// displayCards 已清
|
|
957
|
+
expect(displayCards.has("chat-1")).toBe(false);
|
|
958
|
+
// sessionInfoMap 指向新 sessionId
|
|
959
|
+
expect(sessionInfoMap.get("chat-1")?.sessionId).toBe("new-sid");
|
|
960
|
+
// lastActiveChat 指向当前 chat
|
|
961
|
+
expect(getLastActiveChat("new-sid")).toBe("chat-1");
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it("私聊场景:完全跳过 updateChatInfo,仍完成内存切换", async () => {
|
|
965
|
+
let called = false;
|
|
966
|
+
const updateChatInfoFn = async () => {
|
|
967
|
+
called = true;
|
|
968
|
+
throw new Error("p2p chat API would fail");
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
const result = await switchChatBinding({
|
|
972
|
+
chatId: "p2p-chat",
|
|
973
|
+
chatType: "p2p",
|
|
974
|
+
oldSessionId: null,
|
|
975
|
+
newSessionId: "new-sid-p2p",
|
|
976
|
+
tool: "claude",
|
|
977
|
+
chatName: "新会话-p2p",
|
|
978
|
+
newDescription: "Claude Code Session: new-sid-p2p",
|
|
979
|
+
updateChatInfoFn,
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
expect(result.ok).toBe(true);
|
|
983
|
+
expect(called).toBe(false); // 私聊跳过 API 调用
|
|
984
|
+
expect(getChatsForSession("new-sid-p2p")).toEqual(["p2p-chat"]);
|
|
985
|
+
expect(sessionInfoMap.get("p2p-chat")?.sessionId).toBe("new-sid-p2p");
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it("群聊 + updateChatInfo 抛错:内存完全不动 + 返回 error", async () => {
|
|
989
|
+
bindChatToSession("old-sid", "chat-1");
|
|
990
|
+
sessionInfoMap.set("chat-1", {
|
|
991
|
+
sessionId: "old-sid", turnCount: 5, lastContextTokens: 100,
|
|
992
|
+
startTime: 0, tool: "claude",
|
|
993
|
+
});
|
|
994
|
+
const oldDisplay = {
|
|
995
|
+
cardId: "c1", sequence: 1, cardBusy: false,
|
|
996
|
+
cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
|
|
997
|
+
};
|
|
998
|
+
displayCards.set("chat-1", oldDisplay);
|
|
999
|
+
|
|
1000
|
+
const updateChatInfoFn = async () => {
|
|
1001
|
+
throw new Error("network timeout");
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
const result = await switchChatBinding({
|
|
1005
|
+
chatId: "chat-1",
|
|
1006
|
+
chatType: "group",
|
|
1007
|
+
oldSessionId: "old-sid",
|
|
1008
|
+
newSessionId: "new-sid",
|
|
1009
|
+
tool: "claude",
|
|
1010
|
+
chatName: "新会话-failed",
|
|
1011
|
+
newDescription: "Claude Code Session: new-sid",
|
|
1012
|
+
updateChatInfoFn,
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
expect(result.ok).toBe(false);
|
|
1016
|
+
expect(result.error?.message).toBe("network timeout");
|
|
1017
|
+
// 内存绑定保持旧状态
|
|
1018
|
+
expect(getChatsForSession("old-sid")).toEqual(["chat-1"]);
|
|
1019
|
+
expect(getChatsForSession("new-sid")).toEqual([]);
|
|
1020
|
+
expect(displayCards.get("chat-1")).toBe(oldDisplay);
|
|
1021
|
+
expect(sessionInfoMap.get("chat-1")?.sessionId).toBe("old-sid");
|
|
1022
|
+
expect(sessionInfoMap.get("chat-1")?.turnCount).toBe(5);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it("oldSessionId 为 null 时不调 unbind(适用于私聊首次绑定)", async () => {
|
|
1026
|
+
const updateChatInfoFn = async () => {};
|
|
1027
|
+
|
|
1028
|
+
const result = await switchChatBinding({
|
|
1029
|
+
chatId: "fresh-chat",
|
|
1030
|
+
chatType: "p2p",
|
|
1031
|
+
oldSessionId: null,
|
|
1032
|
+
newSessionId: "fresh-sid",
|
|
1033
|
+
tool: "claude",
|
|
1034
|
+
chatName: "首次会话",
|
|
1035
|
+
newDescription: "Claude Code Session: fresh-sid",
|
|
1036
|
+
updateChatInfoFn,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
expect(result.ok).toBe(true);
|
|
1040
|
+
expect(getChatsForSession("fresh-sid")).toEqual(["fresh-chat"]);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it("API 成功后,registry 持久化记录可被重新加载", async () => {
|
|
1044
|
+
const updateChatInfoFn = async () => {};
|
|
1045
|
+
|
|
1046
|
+
await switchChatBinding({
|
|
1047
|
+
chatId: "chat-persist",
|
|
1048
|
+
chatType: "group",
|
|
1049
|
+
oldSessionId: null,
|
|
1050
|
+
newSessionId: "persist-sid",
|
|
1051
|
+
tool: "cursor",
|
|
1052
|
+
chatName: "persist-name",
|
|
1053
|
+
newDescription: "Cursor Session: persist-sid",
|
|
1054
|
+
initialTurnCount: 3,
|
|
1055
|
+
initialContextTokens: 500,
|
|
1056
|
+
updateChatInfoFn,
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// 验证 registry 文件已写入
|
|
1060
|
+
const raw = await readFile(registryFile, "utf-8");
|
|
1061
|
+
const parsed = JSON.parse(raw);
|
|
1062
|
+
expect(parsed["chat-persist"]).toMatchObject({
|
|
1063
|
+
chatId: "chat-persist",
|
|
1064
|
+
sessionId: "persist-sid",
|
|
1065
|
+
tool: "cursor",
|
|
1066
|
+
chatName: "persist-name",
|
|
1067
|
+
turnCount: 3,
|
|
1068
|
+
lastContextTokens: 500,
|
|
1069
|
+
running: false,
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// ---------------------------------------------------------------------------
|
|
1075
|
+
// resetState 调用契约:仅供测试 + 进程首次启动。
|
|
1076
|
+
// 不应在 SDK onReady/onReconnected 中调用——会清空 activePrompts 让正在跑
|
|
1077
|
+
// 的后台 prompt 变成"孤儿 generator"(Map 删了但 controller 没 abort,
|
|
1078
|
+
// 导致同一 sessionId 双开 prompt)。
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
describe("resetState 契约:清空所有运行时状态", () => {
|
|
1082
|
+
it("清空 activePrompts 但不 abort controller(只能由进程首次启动调用)", () => {
|
|
1083
|
+
const controller = new AbortController();
|
|
1084
|
+
let aborted = false;
|
|
1085
|
+
controller.signal.addEventListener("abort", () => { aborted = true; });
|
|
1086
|
+
activePrompts.set("sid-running", {
|
|
1087
|
+
controller, stopped: false, startTime: 0,
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
resetState();
|
|
1091
|
+
|
|
1092
|
+
expect(activePrompts.size).toBe(0);
|
|
1093
|
+
// 注意:resetState 不主动 abort——所以如果生产代码在 prompt 跑过程中
|
|
1094
|
+
// 误调 resetState,后台 generator 仍会继续跑直到自然结束,但 activePrompts
|
|
1095
|
+
// 已经空了,下条消息会双开 prompt。这是 resetState 仅适用于"启动时"
|
|
1096
|
+
// (Map 本就是空的)的根本原因。
|
|
1097
|
+
expect(aborted).toBe(false);
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildHelpCard } from "../cards.ts";
|
|
4
|
+
import { createWechatAdapter } from "../wechat-platform.ts";
|
|
5
|
+
|
|
6
|
+
describe("createWechatAdapter", () => {
|
|
7
|
+
it("degrades raw cards to plain text messages", async () => {
|
|
8
|
+
const wire = {
|
|
9
|
+
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
10
|
+
sendText: vi.fn(
|
|
11
|
+
async (_chatId: string, _text: string, _contextToken?: string) =>
|
|
12
|
+
"msg-id",
|
|
13
|
+
),
|
|
14
|
+
};
|
|
15
|
+
const log = vi.fn();
|
|
16
|
+
const platform = createWechatAdapter({
|
|
17
|
+
getWire: () => wire,
|
|
18
|
+
log,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const ok = await platform.sendRawCard("wx-chat-help", buildHelpCard("Hello"));
|
|
22
|
+
|
|
23
|
+
expect(ok).toBe(true);
|
|
24
|
+
expect(wire.push).toHaveBeenCalledTimes(1);
|
|
25
|
+
expect(wire.sendText).not.toHaveBeenCalled();
|
|
26
|
+
const [, text] = wire.push.mock.calls[0];
|
|
27
|
+
expect(text).toContain("# ChatCCC");
|
|
28
|
+
expect(text).toContain("Hello");
|
|
29
|
+
expect(text).toContain("/new");
|
|
30
|
+
expect(log).toHaveBeenCalledWith("[WECHAT] sendRawCard degraded to text");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -89,11 +89,32 @@ function buildSdkEnv(
|
|
|
89
89
|
if (!apiKeyTrim && !baseUrlTrim) return undefined;
|
|
90
90
|
|
|
91
91
|
const env: Record<string, string | undefined> = { ...process.env };
|
|
92
|
+
// ChatCCC's Claude API config is authoritative when present. Remove Claude
|
|
93
|
+
// Code/user settings env that can silently override gateway/auth/model choice.
|
|
94
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
95
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
96
|
+
delete env.ANTHROPIC_MODEL;
|
|
97
|
+
delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
98
|
+
delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
99
|
+
delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
100
|
+
delete env.CLAUDE_CODE_SUBAGENT_MODEL;
|
|
101
|
+
delete env.CLAUDE_CODE_EFFORT_LEVEL;
|
|
102
|
+
|
|
92
103
|
if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
|
|
93
104
|
if (baseUrlTrim) env.ANTHROPIC_BASE_URL = baseUrlTrim;
|
|
105
|
+
else delete env.ANTHROPIC_BASE_URL;
|
|
94
106
|
return env;
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
function resolveSettingSources(
|
|
110
|
+
_apiKey: string | undefined,
|
|
111
|
+
_baseUrl: string | undefined,
|
|
112
|
+
): Array<"project" | "local"> {
|
|
113
|
+
// CLAUDE.md / CLAUDE.local.md 是 Agent 指令文件,与 API 来源无关,
|
|
114
|
+
// 无论使用官方 Anthropic 还是第三方网关都应加载。
|
|
115
|
+
return ["project", "local"];
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
// ---------------------------------------------------------------------------
|
|
98
119
|
// buildSessionOptions — 还原 claudeSdkSessionOptions 的精确行为
|
|
99
120
|
// ---------------------------------------------------------------------------
|
|
@@ -111,7 +132,7 @@ function buildSessionOptions(
|
|
|
111
132
|
permissionMode: "bypassPermissions",
|
|
112
133
|
allowDangerouslySkipPermissions: true,
|
|
113
134
|
autoCompactEnabled: true,
|
|
114
|
-
settingSources:
|
|
135
|
+
settingSources: resolveSettingSources(apiKey, baseUrl),
|
|
115
136
|
};
|
|
116
137
|
if (!isEmpty(model)) o.model = model;
|
|
117
138
|
if (!isEmpty(effort)) o.effort = effort;
|
|
@@ -304,4 +325,4 @@ export function createClaudeAdapter(
|
|
|
304
325
|
options: ClaudeAdapterOptions,
|
|
305
326
|
): ToolAdapter {
|
|
306
327
|
return new ClaudeAdapter(options);
|
|
307
|
-
}
|
|
328
|
+
}
|