mu-agent-bridge 1.0.35 → 1.0.39

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.
Files changed (59) hide show
  1. package/README.md +2 -0
  2. package/dist/src-ts/channel-sessions.js +90 -24
  3. package/dist/src-ts/control-server.js +48 -7
  4. package/dist/src-ts/plugin-manager.js +47 -0
  5. package/dist/src-ts/plugins/chatgpt/cdp-client.js +72 -0
  6. package/dist/src-ts/plugins/chatgpt/index.js +122 -4
  7. package/dist/src-ts/plugins/doubao/cdp-client.js +243 -18
  8. package/dist/src-ts/plugins/doubao/index.js +81 -5
  9. package/dist/src-ts/plugins/doubao/session-history.js +123 -0
  10. package/dist/src-ts/plugins/dsh/index.js +16 -2
  11. package/dist/src-ts/plugins/dsh/session-history.js +371 -0
  12. package/dist/src-ts/plugins/qwenwork/cdp-client.js +8 -0
  13. package/dist/src-ts/plugins/qwenwork/index.js +12 -1
  14. package/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
  15. package/dist/src-ts/plugins/workbuddy/cdp-client.js +33 -0
  16. package/dist/src-ts/plugins/workbuddy/index.js +21 -1
  17. package/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
  18. package/dist/src-ts/session-archive.js +509 -0
  19. package/dist/src-ts/session-history.js +2 -0
  20. package/dist/src-ts/session-management.js +665 -0
  21. package/dist/src-ts/session-summary.js +171 -0
  22. package/dist/tests-ts/channel-sessions.test.js +233 -2
  23. package/dist/tests-ts/chatgpt-history.test.js +197 -0
  24. package/dist/tests-ts/control-server.test.js +39 -0
  25. package/dist/tests-ts/doubao-history.test.js +172 -0
  26. package/dist/tests-ts/doubao-plugin.test.js +124 -3
  27. package/dist/tests-ts/dsh-history.test.js +165 -0
  28. package/dist/tests-ts/native-session-management.test.js +195 -0
  29. package/dist/tests-ts/plugin-manager.test.js +254 -3
  30. package/dist/tests-ts/qwenwork-history.test.js +339 -0
  31. package/dist/tests-ts/session-archive.test.js +422 -0
  32. package/dist/tests-ts/session-management.test.js +323 -0
  33. package/dist/tests-ts/session-summary.test.js +199 -0
  34. package/dist/tests-ts/workbuddy-history.test.js +256 -0
  35. package/package.json +1 -1
  36. package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/cdp-client.js +72 -0
  37. package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/index.js +122 -4
  38. package/plugins/chatgpt/package.json +1 -1
  39. package/plugins/chatgpt/plugin.json +14 -1
  40. package/plugins/doubao/dist/src-ts/plugins/doubao/cdp-client.js +243 -18
  41. package/plugins/doubao/dist/src-ts/plugins/doubao/index.js +81 -5
  42. package/plugins/doubao/dist/src-ts/plugins/doubao/session-history.js +123 -0
  43. package/plugins/doubao/package.json +1 -1
  44. package/plugins/doubao/plugin.json +1 -1
  45. package/plugins/dsh/config.schema.json +2 -1
  46. package/plugins/dsh/dist/src-ts/plugins/dsh/index.js +16 -2
  47. package/plugins/dsh/dist/src-ts/plugins/dsh/session-history.js +371 -0
  48. package/plugins/dsh/package.json +1 -1
  49. package/plugins/dsh/plugin.json +1 -1
  50. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/cdp-client.js +8 -0
  51. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/index.js +12 -1
  52. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
  53. package/plugins/qwenwork/package.json +1 -1
  54. package/plugins/qwenwork/plugin.json +1 -1
  55. package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/cdp-client.js +33 -0
  56. package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/index.js +21 -1
  57. package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
  58. package/plugins/workbuddy/package.json +1 -1
  59. package/plugins/workbuddy/plugin.json +1 -1
@@ -0,0 +1,422 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const strict_1 = __importDefault(require("node:assert/strict"));
40
+ const promises_1 = require("node:fs/promises");
41
+ const node_os_1 = require("node:os");
42
+ const node_path_1 = require("node:path");
43
+ const node_test_1 = __importDefault(require("node:test"));
44
+ const session_archive_1 = require("../src-ts/session-archive");
45
+ const now = Date.parse("2026-10-21T12:00:00.000Z");
46
+ function turn(overrides = {}) {
47
+ return { sessionId: "会话一", agentPlugin: "workbuddy", channel: "aun", principal: "用户一", source: "aun:本端:聊天:对端:话题", time: new Date(now).toISOString(), role: "user", text: "用户正文", completed: true, messageId: "消息一", ...overrides };
48
+ }
49
+ async function temporary(run) {
50
+ const root = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "mab-session-archive-"));
51
+ try {
52
+ await run(root);
53
+ }
54
+ finally {
55
+ await (0, promises_1.rm)(root, { recursive: true, force: true });
56
+ }
57
+ }
58
+ (0, node_test_1.default)("归档默认关闭且不会创建包含正文的文件", () => temporary(async (root) => {
59
+ const archive = new session_archive_1.SessionArchive(root);
60
+ await archive.recordTurn(turn());
61
+ strict_1.default.deepEqual(await (0, promises_1.readdir)(root), []);
62
+ strict_1.default.equal((await archive.listSessions()).total, 0);
63
+ }));
64
+ (0, node_test_1.default)("只保存完成轮次,附件去除字节且消息与请求重试去重", () => temporary(async (root) => {
65
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
66
+ await archive.recordTurn(turn({ completed: false }));
67
+ const data = Buffer.from("不应保存附件字节");
68
+ const attachment = { filename: "报告.pdf", contentType: "application/pdf", sizeBytes: data.length, data };
69
+ await Promise.all([
70
+ archive.recordTurn(turn({ attachments: [attachment], requestId: "请求一" })),
71
+ archive.recordTurn(turn({ messageId: "重发消息", requestId: "请求一" })),
72
+ archive.recordTurn(turn({ role: "assistant", messageId: "回复一", requestId: "请求一", text: "完成回复" })),
73
+ ]);
74
+ const loaded = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
75
+ const page = await loaded.readSession("会话一");
76
+ strict_1.default.equal(page.turns.length, 2);
77
+ strict_1.default.deepEqual(page.turns[0].attachments, [{ filename: "报告.pdf", contentType: "application/pdf", sizeBytes: data.length }]);
78
+ strict_1.default.equal(page.session?.source, turn().source);
79
+ strict_1.default.equal(page.session?.principal, "用户一");
80
+ strict_1.default.equal(page.complete, true);
81
+ for (const filename of await (0, promises_1.readdir)(root)) {
82
+ strict_1.default.equal((await (0, promises_1.stat)((0, node_path_1.join)(root, filename))).mode & 0o777, 0o600);
83
+ strict_1.default.equal((await (0, promises_1.readFile)((0, node_path_1.join)(root, filename), "utf8")).includes("不应保存附件字节"), false);
84
+ }
85
+ }));
86
+ (0, node_test_1.default)("会话身份不可跨来源复用,非法角色不能落盘", () => temporary(async (root) => {
87
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
88
+ await archive.recordTurn(turn());
89
+ await strict_1.default.rejects(archive.recordTurn(turn({ source: "其他来源" })), /来源/);
90
+ await strict_1.default.rejects(archive.recordTurn(turn({ role: "system" })), /角色/);
91
+ strict_1.default.equal((await archive.readSession("会话一")).turns.length, 1);
92
+ }));
93
+ (0, node_test_1.default)("固定默认上限且 UTF-8 超限正文明确标记截断", () => temporary(async (root) => {
94
+ strict_1.default.equal(session_archive_1.SESSION_ARCHIVE_LIMITS.maxTextBytes, 64 * 1024);
95
+ strict_1.default.equal(session_archive_1.SESSION_ARCHIVE_LIMITS.maxSessionBytes, 8 * 1024 * 1024);
96
+ strict_1.default.equal(session_archive_1.SESSION_ARCHIVE_LIMITS.maxTotalBytes, 256 * 1024 * 1024);
97
+ strict_1.default.equal(session_archive_1.SESSION_ARCHIVE_LIMITS.retentionDays, 30);
98
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
99
+ await archive.recordTurn(turn({ text: "中".repeat(30_000) }));
100
+ const page = await archive.readSession("会话一");
101
+ strict_1.default.ok(Buffer.byteLength(page.turns[0].text) <= 64 * 1024);
102
+ strict_1.default.equal(page.turns[0].text.includes("�"), false);
103
+ strict_1.default.equal(page.complete, false);
104
+ strict_1.default.ok(page.missingReasons.some((reason) => reason.includes("文本")));
105
+ }));
106
+ (0, node_test_1.default)("会话字节与轮数上限拒绝继续保存并暴露缺口", () => temporary(async (root) => {
107
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now, limits: { maxSessionBytes: 1024, maxTurnsPerSession: 2 } });
108
+ for (let i = 0; i < 4; i++)
109
+ await archive.recordTurn(turn({ messageId: `${i}`, text: "长".repeat(100) }));
110
+ const page = await archive.readSession("会话一");
111
+ strict_1.default.ok(page.turns.length > 0 && page.turns.length <= 2);
112
+ strict_1.default.equal(page.complete, false);
113
+ const files = (await (0, promises_1.readdir)(root)).filter((name) => name.endsWith(".jsonl"));
114
+ strict_1.default.ok((await (0, promises_1.stat)((0, node_path_1.join)(root, files[0]))).size <= 1024);
115
+ }));
116
+ (0, node_test_1.default)("总字节与会话数上限会清理旧会话并标记目录不完整", () => temporary(async (root) => {
117
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now, limits: { maxTotalBytes: 1500, maxSessions: 2 } });
118
+ for (let i = 0; i < 5; i++)
119
+ await archive.recordTurn(turn({ sessionId: `${i}`, messageId: `${i}`, time: new Date(now + i).toISOString(), text: "长".repeat(100) }));
120
+ const page = await archive.listSessions();
121
+ strict_1.default.ok(page.total > 0 && page.total <= 2);
122
+ strict_1.default.equal(page.complete, false);
123
+ let bytes = 0;
124
+ for (const filename of await (0, promises_1.readdir)(root))
125
+ bytes += (await (0, promises_1.stat)((0, node_path_1.join)(root, filename))).size;
126
+ strict_1.default.ok(bytes <= 1500);
127
+ }));
128
+ (0, node_test_1.default)("分页上限为五十个会话和一百条消息且来源过滤发生在分页前", () => temporary(async (root) => {
129
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
130
+ for (let i = 0; i < 55; i++)
131
+ await archive.recordTurn(turn({ sessionId: `会话${i}`, messageId: `${i}` }));
132
+ for (let i = 0; i < 101; i++)
133
+ await archive.recordTurn(turn({ sessionId: "分页会话", messageId: `${i}`, source: "独立来源" }));
134
+ const list = await archive.listSessions({ limit: 100 });
135
+ strict_1.default.equal(list.sessions.length, 50);
136
+ strict_1.default.equal(list.hasNext, true);
137
+ strict_1.default.equal((await archive.listSessions({ sources: ["独立来源"] })).total, 1);
138
+ const first = await archive.readSession("分页会话", { limit: 500 });
139
+ strict_1.default.equal(first.turns.length, 100);
140
+ strict_1.default.equal(first.hasNext, true);
141
+ strict_1.default.equal((await archive.readSession("分页会话", { page: 2 })).turns.length, 1);
142
+ }));
143
+ (0, node_test_1.default)("保留期逐条清理进行中的旧历史并保留不完整标记", () => temporary(async (root) => {
144
+ let clock = now - 31 * 86400_000;
145
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => clock });
146
+ await archive.recordTurn(turn({ time: new Date(clock).toISOString() }));
147
+ clock = now;
148
+ await archive.recordTurn(turn({ messageId: "新消息", text: "保留", time: new Date(now).toISOString() }));
149
+ await archive.prune();
150
+ const page = await archive.readSession("会话一");
151
+ strict_1.default.deepEqual(page.turns.map((item) => item.text), ["保留"]);
152
+ strict_1.default.equal(page.complete, false);
153
+ strict_1.default.ok(page.missingReasons.some((reason) => reason.includes("过期")));
154
+ }));
155
+ (0, node_test_1.default)("损坏和缺失正文显式报告不完整,删除和清空可重启验证", () => temporary(async (root) => {
156
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
157
+ await archive.recordTurn(turn());
158
+ const filename = (await (0, promises_1.readdir)(root)).find((name) => name.endsWith(".jsonl"));
159
+ await (0, promises_1.appendFile)((0, node_path_1.join)(root, filename), "{坏行\n");
160
+ const page = await archive.readSession("会话一");
161
+ strict_1.default.equal(page.turns.length, 1);
162
+ strict_1.default.equal(page.complete, false);
163
+ strict_1.default.ok(page.missingReasons.some((reason) => reason.includes("损坏")));
164
+ await (0, promises_1.rm)((0, node_path_1.join)(root, filename));
165
+ strict_1.default.equal((await archive.readSession("会话一")).complete, false);
166
+ await archive.deleteSession("会话一");
167
+ strict_1.default.equal((await new session_archive_1.SessionArchive(root).listSessions()).total, 0);
168
+ await archive.recordTurn(turn({ sessionId: "二" }));
169
+ await archive.clear();
170
+ strict_1.default.equal((await new session_archive_1.SessionArchive(root).listSessions()).total, 0);
171
+ strict_1.default.equal((await (0, promises_1.readdir)(root)).filter((name) => name.endsWith(".jsonl")).length, 0);
172
+ }));
173
+ (0, node_test_1.default)("损坏目录不会伪装成完整空列表或覆盖原文件", () => temporary(async (root) => {
174
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
175
+ await archive.recordTurn(turn());
176
+ const filename = (await (0, promises_1.readdir)(root)).find((name) => name.endsWith(".json"));
177
+ await (0, promises_1.appendFile)((0, node_path_1.join)(root, filename), "坏数据");
178
+ const broken = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
179
+ strict_1.default.equal((await broken.listSessions()).complete, false);
180
+ await strict_1.default.rejects(broken.recordTurn(turn({ sessionId: "新会话" })), /目录/);
181
+ strict_1.default.ok((await (0, promises_1.readFile)((0, node_path_1.join)(root, filename), "utf8")).endsWith("坏数据"));
182
+ }));
183
+ (0, node_test_1.default)("启停设置与写入按调用顺序执行,目录快照不被后续更新改变", () => temporary(async (root) => {
184
+ const archive = new session_archive_1.SessionArchive(root, { now: () => now });
185
+ archive.setEnabled(true);
186
+ const pending = archive.recordTurn(turn());
187
+ archive.setEnabled(false);
188
+ await pending;
189
+ await archive.recordTurn(turn({ messageId: "不保存" }));
190
+ const snapshot = await archive.snapshotSessions();
191
+ strict_1.default.equal(snapshot.sessions[0].turnCount, 1);
192
+ archive.setEnabled(true);
193
+ await archive.recordTurn(turn({ messageId: "保存" }));
194
+ strict_1.default.equal(snapshot.sessions[0].turnCount, 1);
195
+ strict_1.default.equal((await archive.readSession("会话一")).turns.length, 2);
196
+ }));
197
+ (0, node_test_1.default)("会话数拒录不删除现有会话,单会话不完整不影响目录可信度", () => temporary(async (root) => {
198
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now, limits: { maxSessions: 1, maxTextBytes: 4 } });
199
+ await archive.recordTurn(turn());
200
+ strict_1.default.equal((await archive.listSessions()).complete, true);
201
+ strict_1.default.equal((await archive.readSession("会话一")).complete, false);
202
+ await archive.recordTurn(turn({ sessionId: "会话二" }));
203
+ strict_1.default.equal((await archive.listSessions()).sessions[0].sessionId, "会话一");
204
+ strict_1.default.equal((await archive.listSessions()).complete, false);
205
+ }));
206
+ (0, node_test_1.default)("写入失败不会假装成功且保留可查询的缺口", () => temporary(async (root) => {
207
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
208
+ await archive.recordTurn(turn());
209
+ const filename = (await (0, promises_1.readdir)(root)).find((name) => name.endsWith(".jsonl"));
210
+ await (0, promises_1.rm)((0, node_path_1.join)(root, filename));
211
+ const { mkdir } = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
212
+ await mkdir((0, node_path_1.join)(root, filename));
213
+ await strict_1.default.rejects(archive.recordTurn(turn({ messageId: "写入失败" })));
214
+ const page = await archive.readSession("会话一");
215
+ strict_1.default.equal(page.complete, false);
216
+ strict_1.default.ok(page.missingReasons.some((reason) => reason.includes("写入失败")));
217
+ }));
218
+ (0, node_test_1.default)("闲置超过保留期后所有查询入口先清理过期正文", () => temporary(async (root) => {
219
+ for (const query of ["read", "list", "snapshot"]) {
220
+ let clock = now;
221
+ const path = (0, node_path_1.join)(root, query);
222
+ const archive = new session_archive_1.SessionArchive(path, { enabled: true, now: () => clock });
223
+ await archive.recordTurn(turn());
224
+ clock += 31 * 86400_000;
225
+ if (query === "read") {
226
+ const page = await archive.readSession("会话一");
227
+ strict_1.default.equal(page.turns.length, 0);
228
+ strict_1.default.equal(page.complete, false);
229
+ }
230
+ else if (query === "list")
231
+ strict_1.default.equal((await archive.listSessions()).total, 0);
232
+ else
233
+ strict_1.default.equal((await archive.snapshotSessions()).sessions.length, 0);
234
+ strict_1.default.equal((await (0, promises_1.readdir)(path)).some((file) => file.endsWith(".jsonl")), false);
235
+ }
236
+ }));
237
+ (0, node_test_1.default)("保留期刚届满且距离上次清理不到一小时也不能读取", () => temporary(async (root) => {
238
+ let clock = now;
239
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => clock });
240
+ await archive.recordTurn(turn({ time: new Date(now - 30 * 86400_000 + 1000).toISOString() }));
241
+ await archive.prune();
242
+ clock += 1001;
243
+ strict_1.default.equal((await archive.readSession("会话一")).turns.length, 0);
244
+ }));
245
+ (0, node_test_1.default)("合法 JSON 中的损坏目录字段会被标记且拒绝覆盖", () => temporary(async (root) => {
246
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
247
+ await archive.recordTurn(turn());
248
+ const path = (0, node_path_1.join)(root, "metadata.json");
249
+ const original = JSON.parse(await (0, promises_1.readFile)(path, "utf8"));
250
+ for (const mutate of [
251
+ (value) => { value.sessions = []; },
252
+ (value) => { value.sessions["会话一"] = null; },
253
+ (value) => { value.sessions["会话一"].title = { 无效: true }; },
254
+ (value) => { value.sessions["会话一"].firstSeenAt = 2026; },
255
+ (value) => { value.sessions["会话一"].incomplete = "错误"; },
256
+ (value) => { value.sessions["会话一"].turnCount = 1.2; },
257
+ ]) {
258
+ const metadata = structuredClone(original);
259
+ mutate(metadata);
260
+ await (0, promises_1.writeFile)(path, JSON.stringify(metadata));
261
+ const broken = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
262
+ strict_1.default.equal((await broken.listSessions()).complete, false);
263
+ await strict_1.default.rejects(broken.recordTurn(turn()), /目录/);
264
+ }
265
+ }));
266
+ (0, node_test_1.default)("记录中的非法时间和附件元数据不能作为正文返回", () => temporary(async (root) => {
267
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
268
+ await archive.recordTurn(turn());
269
+ const file = (0, node_path_1.join)(root, (await (0, promises_1.readdir)(root)).find((name) => name.endsWith(".jsonl")));
270
+ const original = JSON.parse((await (0, promises_1.readFile)(file, "utf8")).trim());
271
+ for (const patch of [{ time: 2026 }, { attachments: {} }, { attachments: [null] }, { attachments: [{ filename: 123, contentType: "text/plain", sizeBytes: 5 }] }, { attachments: [{ filename: "报告", contentType: "text/plain", sizeBytes: -1 }] }]) {
272
+ await (0, promises_1.writeFile)(file, `${JSON.stringify({ ...original, ...patch })}\n`);
273
+ const page = await archive.readSession("会话一");
274
+ strict_1.default.equal(page.turns.length, 0);
275
+ strict_1.default.equal(page.complete, false);
276
+ }
277
+ }));
278
+ (0, node_test_1.default)("超大归档文件在读入前拒绝且返回不完整", () => temporary(async (root) => {
279
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
280
+ await archive.recordTurn(turn());
281
+ const file = (0, node_path_1.join)(root, (await (0, promises_1.readdir)(root)).find((name) => name.endsWith(".jsonl")));
282
+ await (0, promises_1.truncate)(file, session_archive_1.SESSION_ARCHIVE_LIMITS.maxSessionBytes + 1);
283
+ const page = await archive.readSession("会话一");
284
+ strict_1.default.equal(page.turns.length, 0);
285
+ strict_1.default.equal(page.complete, false);
286
+ strict_1.default.ok(page.missingReasons.some((reason) => reason.includes("大小")));
287
+ await (0, promises_1.truncate)((0, node_path_1.join)(root, "metadata.json"), 8 * 1024 * 1024 + 1);
288
+ strict_1.default.equal((await new session_archive_1.SessionArchive(root, { now: () => now }).snapshotSessions()).complete, false);
289
+ }));
290
+ (0, node_test_1.default)("拒录产生的完整性元数据同样受总磁盘字节上限约束", () => temporary(async (root) => {
291
+ const initial = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
292
+ await initial.recordTurn(turn());
293
+ let bytes = 0;
294
+ for (const filename of await (0, promises_1.readdir)(root))
295
+ bytes += (await (0, promises_1.stat)((0, node_path_1.join)(root, filename))).size;
296
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now, limits: { maxTotalBytes: bytes, maxTurnsPerSession: 1 } });
297
+ await archive.recordTurn(turn({ messageId: "超额" }));
298
+ let after = 0;
299
+ for (const filename of await (0, promises_1.readdir)(root))
300
+ after += (await (0, promises_1.stat)((0, node_path_1.join)(root, filename))).size;
301
+ strict_1.default.ok(after <= bytes, `${after} > ${bytes}`);
302
+ }));
303
+ (0, node_test_1.default)("删除正文失败时保留原目录并明确报告缺口", () => temporary(async (root) => {
304
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
305
+ await archive.recordTurn(turn());
306
+ const file = (0, node_path_1.join)(root, (await (0, promises_1.readdir)(root)).find((name) => name.endsWith(".jsonl")));
307
+ await (0, promises_1.rm)(file);
308
+ const { mkdir } = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
309
+ await mkdir(file);
310
+ await strict_1.default.rejects(archive.deleteSession("会话一"));
311
+ strict_1.default.equal((await archive.listSessions()).total, 1);
312
+ strict_1.default.equal((await archive.readSession("会话一")).complete, false);
313
+ strict_1.default.equal((await new session_archive_1.SessionArchive(root, { now: () => now }).listSessions()).total, 1);
314
+ }));
315
+ (0, node_test_1.default)("整轮批量归档和读取串行执行,读取不会看到半轮", () => temporary(async (root) => {
316
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
317
+ const response = turn({ role: "assistant", messageId: "回复", text: "完整回复" });
318
+ const writing = archive.recordTurns([turn(), response]);
319
+ const reading = archive.readSession("会话一");
320
+ await writing;
321
+ strict_1.default.deepEqual((await reading).turns.map((item) => item.role), ["user", "assistant"]);
322
+ const before = archive.readSession("会话一");
323
+ const next = archive.recordTurns([turn({ messageId: "下一问" }), turn({ ...response, messageId: "下一答" })]);
324
+ strict_1.default.equal((await before).turns.length, 2);
325
+ await next;
326
+ strict_1.default.equal((await archive.readSession("会话一")).turns.length, 4);
327
+ }));
328
+ (0, node_test_1.default)("整轮归档中途写入失败时已写入部分明确不完整", () => temporary(async (root) => {
329
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
330
+ await archive.recordTurn(turn());
331
+ const file = (0, node_path_1.join)(root, (await (0, promises_1.readdir)(root)).find((name) => name.endsWith(".jsonl")));
332
+ await (0, promises_1.rm)(file);
333
+ const { mkdir } = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
334
+ await mkdir(file);
335
+ await strict_1.default.rejects(archive.recordTurns([turn({ messageId: "新消息" }), turn({ messageId: "失败" })]));
336
+ strict_1.default.equal((await archive.readSession("会话一")).complete, false);
337
+ }));
338
+ (0, node_test_1.default)("查询已经启动时后续批量写入删除及清空必须等待整个快照读取", () => temporary(async (root) => {
339
+ for (const query of ["read", "list", "snapshot"]) {
340
+ for (const mutation of ["record", "delete", "clear"]) {
341
+ const archive = new session_archive_1.SessionArchive((0, node_path_1.join)(root, `${query}-${mutation}`), { enabled: true, now: () => now });
342
+ await archive.recordTurn(turn());
343
+ let release;
344
+ let started;
345
+ const blocked = new Promise((resolve) => { release = resolve; });
346
+ const entered = new Promise((resolve) => { started = resolve; });
347
+ const internal = archive;
348
+ const loadMetadata = internal.loadMetadata.bind(archive);
349
+ let loads = 0;
350
+ let mutationEntered = false;
351
+ internal.loadMetadata = async () => {
352
+ const metadata = await loadMetadata();
353
+ if (++loads === 2) {
354
+ started();
355
+ await blocked;
356
+ }
357
+ else if (loads > 2)
358
+ mutationEntered = true;
359
+ return metadata;
360
+ };
361
+ const reading = query === "read" ? archive.readSession("会话一") : query === "list" ? archive.listSessions() : archive.snapshotSessions();
362
+ await entered;
363
+ let mutationFinished = false;
364
+ const change = (mutation === "record" ? archive.recordTurns([turn({ messageId: "下一问" }), turn({ messageId: "下一答", role: "assistant" })]) : mutation === "delete" ? archive.deleteSession("会话一") : archive.clear()).then(() => { mutationFinished = true; });
365
+ await new Promise((resolve) => setImmediate(resolve));
366
+ const enteredBeforeQueryFinished = mutationEntered || mutationFinished;
367
+ release();
368
+ const result = await reading;
369
+ if ("turns" in result)
370
+ strict_1.default.equal(result.turns.length, 1);
371
+ else
372
+ strict_1.default.equal(result.sessions[0].turnCount, 1);
373
+ await change;
374
+ strict_1.default.equal(enteredBeforeQueryFinished, false, `${query}/${mutation} 在查询期间修改了归档`);
375
+ }
376
+ }
377
+ }));
378
+ (0, node_test_1.default)("完整来源键超过单维标识上限时仍可归档并重启读取", () => temporary(async (root) => {
379
+ const source = JSON.stringify(["aun", "本端", "聊天", "对端", "话题".repeat(250)]);
380
+ strict_1.default.ok(source.length > 512 && source.length <= 4096);
381
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
382
+ await archive.recordTurn(turn({ source, principal: source }));
383
+ const restarted = new session_archive_1.SessionArchive(root, { now: () => now });
384
+ const page = await restarted.readSession("会话一");
385
+ strict_1.default.equal(page.complete, true);
386
+ strict_1.default.equal(page.turns[0].text, "用户正文");
387
+ strict_1.default.equal(page.session?.source, source);
388
+ strict_1.default.equal(page.session?.principal, source);
389
+ }));
390
+ (0, node_test_1.default)("来源和主体拒绝超长或含控制字符的键,会话标识仍限制单维长度", () => temporary(async (root) => {
391
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
392
+ for (const field of ["source", "principal"]) {
393
+ for (const value of ["长".repeat(4097), "含\u0000字符", "含\r字符", "含\n字符"])
394
+ await strict_1.default.rejects(archive.recordTurn(turn({ [field]: value })), /无效/);
395
+ }
396
+ await strict_1.default.rejects(archive.recordTurn(turn({ sessionId: "长".repeat(513) })), /会话标识/);
397
+ }));
398
+ (0, node_test_1.default)("长来源累计目录元数据超过读取上限时清理旧会话并保持可重启读取", () => temporary(async (root) => {
399
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
400
+ await archive.recordTurn(turn());
401
+ const internal = archive;
402
+ const source = JSON.stringify(["aun", "本端", "聊天", "对端", "话".repeat(4000)]);
403
+ for (let index = 0; index < 400; index++)
404
+ internal.metadata.sessions[`长来源${index}`] = {
405
+ sessionId: `长来源${index}`, source, principal: source, channel: "aun", agentPlugin: "workbuddy", firstSeenAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(), turnCount: 0,
406
+ };
407
+ strict_1.default.ok(Buffer.byteLength(JSON.stringify(internal.metadata)) > 8 * 1024 * 1024);
408
+ await internal.saveMetadata(internal.metadata);
409
+ strict_1.default.ok((await (0, promises_1.stat)((0, node_path_1.join)(root, "metadata.json"))).size <= 8 * 1024 * 1024);
410
+ const restarted = new session_archive_1.SessionArchive(root, { now: () => now });
411
+ const snapshot = await restarted.snapshotSessions();
412
+ strict_1.default.ok(snapshot.sessions.length > 0 && snapshot.sessions.length < 401);
413
+ strict_1.default.equal(snapshot.complete, false);
414
+ strict_1.default.ok(snapshot.missingReasons.some((reason) => reason.includes("目录元数据")));
415
+ strict_1.default.equal(snapshot.missingReasons.some((reason) => reason.includes("损坏")), false);
416
+ }));
417
+ (0, node_test_1.default)("批量归档在首个落盘前预校验全部消息,避免半轮完整记录", () => temporary(async (root) => {
418
+ const archive = new session_archive_1.SessionArchive(root, { enabled: true, now: () => now });
419
+ const invalid = turn({ role: "assistant", messageId: "无效", attachments: [{ filename: "过长".repeat(300), contentType: "text/plain", sizeBytes: 1 }] });
420
+ await strict_1.default.rejects(archive.recordTurns([turn({ messageId: "批次用户" }), invalid]), /附件/);
421
+ strict_1.default.equal((await archive.listSessions()).total, 0);
422
+ }));