@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2

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 (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -0,0 +1,1998 @@
1
+ /**
2
+ * dsh-events 单测(全本地:127.0.0.1 上自建 HTTP + WebSocketServer 假 DSH)。
3
+ *
4
+ * 为什么必须自建本地假 DSH:
5
+ * `npm run test:bridge` 带 `NODE_OPTIONS=--require scripts/test-net-guard.cjs`,
6
+ * 非 loopback 的 fetch 会直接 throw ENETUNREACH。所以这里在本机 127.0.0.1:0 上
7
+ * 起一个说 mux 协议的服务端(`/api/remote.mux` 升级 + `/api/$events/result` POST)。
8
+ *
9
+ * 覆盖(对应任务清单):
10
+ * 1. $events 订阅 → ready(clientId) → emit 分类;
11
+ * 2. waterfall → 审批 / 提问分类,plan-review 单列;
12
+ * 3. cancel 帧 → 事件作废(§6①「已过期」),此后回执一律被拒(fail-closed);
13
+ * 4. session/follow → turn/end 六种 reason 全部映射;
14
+ * 5. 裸 uuid 归一化为 session-<uuid>;session/not-found 与 session/agent-busy 非致命且不重试打转;
15
+ * 6. 回执 HTTP 信封(审批 / 提问)逐字节正确;
16
+ * 7. eventId 去重:第二次不发出;
17
+ * 8. {ok:false,error} → 类型化失败(code/message/details 原样带出);
18
+ * 9. 重连发 gap 标记(§6②),且换到新的 clientId;
19
+ * 10. 无 socket 泄漏:会话结束后 follow 流确实被关闭(服务端看到 cancel);
20
+ * 11. WS 握手 401 → auth-error 且停止重连,换 Cookie 后 retry() 恢复。
21
+ * 12. 会话控制一元 RPC:线参名(`_request` vs `request`)、rpcId 唯一、prompt 全必填字段、
22
+ * `{ok:false}`/非 JSON/500/超时的类型化失败、`throughSeq` 来源与显式失败、
23
+ * `lastAssistantText` 取最后一条助手文本、本地校验不发请求。
24
+ */
25
+ import assert from "node:assert/strict";
26
+ import { once } from "node:events";
27
+ import http from "node:http";
28
+ import { setTimeout as delay } from "node:timers/promises";
29
+ import test from "node:test";
30
+ import { WebSocketServer } from "ws";
31
+ import {
32
+ APPROVAL_CHOICES,
33
+ DEFAULT_RPC_TIMEOUT_MS,
34
+ EVENTS_ENDPOINT,
35
+ EventAnswerError,
36
+ NODE_KINDS,
37
+ RESULT_PATH,
38
+ SESSION_CANCEL_ENDPOINT,
39
+ SESSION_CONTROL_ENDPOINT,
40
+ SESSION_CREATE_ENDPOINT,
41
+ SESSION_JSON_ARG,
42
+ SESSION_LIST_ENDPOINT,
43
+ SESSION_LIST_WIRE_ARG,
44
+ SESSION_PAGE_ENDPOINT,
45
+ SESSION_PROMPT_ENDPOINT,
46
+ SESSION_RENAME_ENDPOINT,
47
+ TURN_END_REASONS,
48
+ createEventSubscriber,
49
+ extractLastAssistantText,
50
+ sessionIdCandidates,
51
+ toDurableSessionId,
52
+ } from "../dsh-events.mjs";
53
+
54
+ const COOKIE = "dsh-auth-test-cookie";
55
+
56
+ // ============================================================
57
+ // 假 DSH:HTTP(结果 RPC) + WS(mux)
58
+ // ============================================================
59
+
60
+ /** 与 DSH `remoteRequest` 一致的 payload 校验:恰好一个 plain-object 的 `args`。 */
61
+ function isPlainArgsPayload(payload) {
62
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return false;
63
+ const keys = Object.keys(payload);
64
+ if (keys.length !== 1 || keys[0] !== "args") return false;
65
+ const args = payload.args;
66
+ return typeof args === "object" && args !== null && !Array.isArray(args);
67
+ }
68
+
69
+ function readBody(req) {
70
+ return new Promise((resolve) => {
71
+ const chunks = [];
72
+ req.on("data", (chunk) => chunks.push(chunk));
73
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
74
+ });
75
+ }
76
+
77
+ /** 每个一元方法的**线参名**(gateway 的 `payload.args` 键集)。session/list 单列,其余都是 request。 */
78
+ const WIRE_ARG_BY_METHOD = {
79
+ "session/create": "request",
80
+ "session/prompt": "request",
81
+ "session/page": "request",
82
+ "session/cancel": "request",
83
+ "session/rename": "request",
84
+ };
85
+
86
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
87
+
88
+ /**
89
+ * 镜像真 gateway 的 args 校验(typert 描述符):args 恰好一个键、名字对、
90
+ * 内层 request 的必填字段齐全 —— 本地假服务端若放松这条,就漏掉了真机必拒的形状。
91
+ * @returns {string|null} 不合法时的说明;合法返回 null。
92
+ */
93
+ function unaryArgsProblem(method, args) {
94
+ const expected = WIRE_ARG_BY_METHOD[method];
95
+ if (!isPlainObject(args)) return "args must be an object";
96
+ const keys = Object.keys(args);
97
+ if (keys.length !== 1 || keys[0] !== expected) {
98
+ return `missing "${expected}"; unexpected "${keys.filter((k) => k !== expected).join(",")}"`;
99
+ }
100
+ const request = args[expected];
101
+ if (!isPlainObject(request)) return `"${expected}" must be a plain object`;
102
+ const require = (cond, what) => (cond ? null : `missing/invalid "${what}"`);
103
+ switch (method) {
104
+ case "session/create": {
105
+ const allowed = ["workspaceId", "cwd", "sessionId", "agentPreset"];
106
+ const extra = Object.keys(request).filter((k) => !allowed.includes(k));
107
+ return extra.length > 0 ? `unexpected "${extra.join(",")}"` : null;
108
+ }
109
+ case "session/prompt": {
110
+ for (const field of ["requestId", "sessionId", "mode", "content"]) {
111
+ const problem = require(request[field] !== undefined, field);
112
+ if (problem !== null) return problem;
113
+ }
114
+ if (request.mode !== "queue" && request.mode !== "steer") return `invalid "mode"`;
115
+ if (typeof request.requestId !== "string" || request.requestId === "") return `invalid "requestId"`;
116
+ if (typeof request.sessionId !== "string" || request.sessionId === "") return `invalid "sessionId"`;
117
+ if (!Array.isArray(request.content) || request.content.length === 0) return `invalid "content"`;
118
+ for (const part of request.content) {
119
+ if (!isPlainObject(part)) return `invalid "content" block`;
120
+ if (part.type === "text" && typeof part.text === "string") continue;
121
+ if (part.type === "image" && typeof part.data === "string") continue;
122
+ if (part.type === "file" && typeof part.receiptId === "string") continue;
123
+ return `invalid "content" block of type ${String(part.type)}`;
124
+ }
125
+ return null;
126
+ }
127
+ case "session/page": {
128
+ if (!isPlainObject(request.address) || request.address.kind !== "session") return `invalid "address"`;
129
+ if (typeof request.address.sessionId !== "string" || request.address.sessionId === "") {
130
+ return `invalid "address.sessionId"`;
131
+ }
132
+ if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) return `invalid "throughSeq"`;
133
+ if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
134
+ return `invalid "maxMessages"`;
135
+ }
136
+ return null;
137
+ }
138
+ case "session/cancel":
139
+ return typeof request.sessionId === "string" && request.sessionId !== "" ? null : `invalid "sessionId"`;
140
+ case "session/rename": {
141
+ if (typeof request.sessionId !== "string" || request.sessionId === "") return `invalid "sessionId"`;
142
+ return typeof request.title === "string" && request.title !== "" ? null : `invalid "title"`;
143
+ }
144
+ default:
145
+ return `unknown method ${method}`;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 一元方法的默认应答(真值口径),让 happy-path 用例不必逐个配桩。
151
+ * 需要精确控制的用例用 `api.setRpcFor()` 覆盖。
152
+ */
153
+ function defaultRpcFor(method, body) {
154
+ const request = body?.payload?.args?.request ?? {};
155
+ switch (method) {
156
+ case "session/create":
157
+ return {
158
+ ok: true,
159
+ value: {
160
+ sessionId: "session-new-0001",
161
+ ...(typeof request.agentPreset === "string" ? { agentPreset: request.agentPreset } : {}),
162
+ },
163
+ };
164
+ case "session/prompt":
165
+ return { ok: true, value: { accepted: true } };
166
+ case "session/page":
167
+ return { ok: true, value: { records: [], hasMore: false } };
168
+ case "session/cancel":
169
+ return { ok: true, value: { accepted: true } };
170
+ case "session/rename":
171
+ return { ok: true, value: { title: request.title, seq: 42 } };
172
+ default:
173
+ return { ok: true, value: {} };
174
+ }
175
+ }
176
+
177
+ async function startFakeDsh(options = {}) {
178
+ const state = {
179
+ connections: [],
180
+ opens: [],
181
+ cancels: [],
182
+ results: [],
183
+ /** session/list 的返回值(测试可改),以及被调用的次数。 */
184
+ listCalls: 0,
185
+ listItems: options.listItems ?? [],
186
+ /** 服务端认的 Cookie(校验用,独立于交给客户端的那个)。 */
187
+ expectedCookie: options.cookie ?? COOKIE,
188
+ /** 交给客户端的 Cookie(`cookie: () => api.cookie`),可被用例改坏来测 401。 */
189
+ cookie: options.cookie ?? COOKIE,
190
+ /** 除 session/list 与 RESULT_PATH 外的一元调用流水(信封逐字节断言用)。 */
191
+ rpcs: [],
192
+ /** 被挂住的请求数(超时用例:服务端故意不回)。 */
193
+ hung: 0,
194
+ };
195
+ let clientSeq = 0;
196
+ let resultFor = options.resultFor ?? (() => ({ ok: true, value: null }));
197
+ /**
198
+ * 一元方法(session/create、prompt、page、cancel、rename)的应答策略。
199
+ * 返回 `{ok:true,value}` / `{ok:false,error}` → 正常信封;
200
+ * 返回 `{http:{status,body,contentType}}` → 原样 HTTP 应答(非 JSON / 500 用);
201
+ * 返回 `{hang:true}` → 永不回(超时用)。
202
+ */
203
+ let rpcFor = options.rpcFor ?? defaultRpcFor;
204
+ /** session/list 的应答覆盖(默认 undefined = 正常返回 state.listItems)。 */
205
+ let listFor = options.listFor ?? (() => undefined);
206
+ /** 测试可以关掉 snapshot 首帧,模拟「开流即报错」。 */
207
+ const onOpen = options.onOpen;
208
+
209
+ const wss = new WebSocketServer({ noServer: true });
210
+
211
+ /**
212
+ * 把「应答决策」写成 HTTP 响应:
213
+ * `{hang:true}` → 不回(超时用例:靠订阅器自己的超时结算,连接在 api.close() 时销毁);
214
+ * `{http:{status,body,contentType}}` → 原样应答(非 JSON / 500 用例);
215
+ * 其余 → 正常 `{type:'server-response',rpcId,result}` 信封。
216
+ */
217
+ const sendDecision = (res, rpcId, decision) => {
218
+ if (decision.hang === true) {
219
+ state.hung += 1;
220
+ return;
221
+ }
222
+ if (decision.http !== undefined) {
223
+ const { status = 200, body: text = "", contentType = "text/plain" } = decision.http;
224
+ res.writeHead(status, { "content-type": contentType });
225
+ res.end(text);
226
+ return;
227
+ }
228
+ res.writeHead(200, { "content-type": "application/json" });
229
+ res.end(JSON.stringify({ type: "server-response", rpcId, result: decision }));
230
+ };
231
+
232
+ const server = http.createServer(async (req, res) => {
233
+ const path = new URL(req.url, "http://127.0.0.1").pathname;
234
+ if (req.method === "POST" && path === `/api/${SESSION_LIST_ENDPOINT}`) {
235
+ const raw = await readBody(req);
236
+ let body = null;
237
+ try {
238
+ body = JSON.parse(raw);
239
+ } catch {
240
+ body = null;
241
+ }
242
+ state.listCalls += 1;
243
+ state.listBodies = [...(state.listBodies ?? []), body];
244
+ if (Number(options.listDelayMs) > 0) await delay(Number(options.listDelayMs), null, { ref: false });
245
+ const args = body?.payload?.args ?? {};
246
+ const argsOk = Object.keys(args).length === 1 && Object.prototype.hasOwnProperty.call(args, "_request");
247
+ if (!argsOk) {
248
+ sendDecision(res, body?.rpcId, {
249
+ ok: false,
250
+ error: {
251
+ code: "gateway/arguments-invalid",
252
+ message: `typert gateway: session/list: args fields do not match the descriptor: missing "_request"`,
253
+ details: { endpoint: "session/list" },
254
+ },
255
+ });
256
+ return;
257
+ }
258
+ const override = listFor(body, state);
259
+ if (override !== undefined) {
260
+ sendDecision(res, body?.rpcId, override);
261
+ return;
262
+ }
263
+ sendDecision(res, body?.rpcId, { ok: true, value: { items: state.listItems } });
264
+ return;
265
+ }
266
+ if (req.method === "POST" && path === RESULT_PATH) {
267
+ const raw = await readBody(req);
268
+ let body = null;
269
+ try {
270
+ body = JSON.parse(raw);
271
+ } catch {
272
+ body = null;
273
+ }
274
+ state.results.push({ body, raw, headers: req.headers });
275
+ const result = resultFor(body, state.results.length);
276
+ res.writeHead(200, { "content-type": "application/json" });
277
+ res.end(JSON.stringify({ type: "server-response", rpcId: body?.rpcId, result }));
278
+ return;
279
+ }
280
+ // 其余一元方法:session/create | session/prompt | session/page | session/cancel | session/rename
281
+ if (req.method === "POST" && path.startsWith("/api/")) {
282
+ const method = path.slice("/api/".length);
283
+ const raw = await readBody(req);
284
+ let body = null;
285
+ try {
286
+ body = JSON.parse(raw);
287
+ } catch {
288
+ body = null;
289
+ }
290
+ state.rpcs.push({ method, body, raw, path, headers: req.headers });
291
+ const problem = WIRE_ARG_BY_METHOD[method] === undefined ? null : unaryArgsProblem(method, body?.payload?.args);
292
+ if (problem !== null) {
293
+ sendDecision(res, body?.rpcId, {
294
+ ok: false,
295
+ error: {
296
+ code: "gateway/arguments-invalid",
297
+ message: `typert gateway: ${method}: args fields do not match the descriptor: ${problem}`,
298
+ details: { endpoint: method },
299
+ },
300
+ });
301
+ return;
302
+ }
303
+ sendDecision(res, body?.rpcId, rpcFor(method, body, state) ?? { ok: true, value: {} });
304
+ return;
305
+ }
306
+ res.writeHead(404, { "content-type": "text/plain" });
307
+ res.end("not found");
308
+ });
309
+
310
+ server.on("upgrade", (req, socket, head) => {
311
+ if (req.headers.cookie !== state.expectedCookie) {
312
+ socket.end(
313
+ "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\nunauthorized",
314
+ );
315
+ return;
316
+ }
317
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
318
+ });
319
+
320
+ wss.on("connection", (ws) => {
321
+ const conn = { ws, streams: new Map() };
322
+ state.connections.push(conn);
323
+ ws.on("message", (raw) => {
324
+ let msg;
325
+ try {
326
+ msg = JSON.parse(raw.toString());
327
+ } catch {
328
+ return;
329
+ }
330
+ if (msg.type === "open") {
331
+ const stream = { streamId: msg.streamId, endpoint: msg.endpoint, payload: msg.payload, closed: false };
332
+ // 镜像真 gateway 的 payload 校验(dsh-api-gateway/lib/index.js:929):
333
+ // 非 $events 的流必须恰好带一个 plain-object 的 args 字段 —— 本地假服务端若放松这条,
334
+ // 就会漏掉真实线上会拒的形状(这个坑正是靠真机探针发现的)。
335
+ if (!isPlainArgsPayload(msg.payload)) {
336
+ api.fail(conn, msg.streamId, {
337
+ code: "gateway/internal",
338
+ message: "Remote payload must contain exactly one plain-object args field",
339
+ details: {},
340
+ });
341
+ return;
342
+ }
343
+ conn.streams.set(msg.streamId, stream);
344
+ state.opens.push({ conn, endpoint: msg.endpoint, streamId: msg.streamId, payload: msg.payload });
345
+ if (msg.endpoint === EVENTS_ENDPOINT) {
346
+ if (options.failEvents === true) {
347
+ api.fail(conn, msg.streamId, { code: "gateway/service-unavailable", message: "no source", details: {} });
348
+ } else {
349
+ api.send(conn, msg.streamId, {
350
+ type: "ready",
351
+ clientId: `client-${++clientSeq}`,
352
+ host: { home: "/tmp/dsh-home" },
353
+ });
354
+ }
355
+ } else if (msg.endpoint === SESSION_CONTROL_ENDPOINT) {
356
+ api.send(conn, msg.streamId, {
357
+ type: "baseline",
358
+ value: { queues: options.controlQueues ?? {}, jobs: options.controlJobs ?? {}, projections: options.controlProjections ?? {} },
359
+ });
360
+ } else if (msg.endpoint === "session/follow") {
361
+ api.send(conn, msg.streamId, {
362
+ type: "snapshot",
363
+ header: { id: msg.payload?.args?.request?.address?.sessionId, origin: "human" },
364
+ cursor: options.followCursor ?? 0,
365
+ records: [],
366
+ hasMore: false,
367
+ projections: { asOfSeq: options.followCursor ?? 0, values: {} },
368
+ });
369
+ }
370
+ onOpen?.(api, conn, stream);
371
+ return;
372
+ }
373
+ if (msg.type === "cancel") {
374
+ const stream = conn.streams.get(msg.streamId);
375
+ if (stream !== undefined) {
376
+ stream.closed = true;
377
+ state.cancels.push({ conn, streamId: msg.streamId, endpoint: stream.endpoint });
378
+ }
379
+ }
380
+ });
381
+ });
382
+
383
+ const api = {
384
+ state,
385
+ get cookie() {
386
+ return state.cookie;
387
+ },
388
+ set cookie(value) {
389
+ state.cookie = value;
390
+ },
391
+ send(conn, streamId, value) {
392
+ conn.ws.send(JSON.stringify({ type: "item", streamId, value }));
393
+ },
394
+ fail(conn, streamId, error) {
395
+ conn.ws.send(JSON.stringify({ type: "error", streamId, error }));
396
+ const stream = conn.streams.get(streamId);
397
+ if (stream !== undefined) stream.closed = true;
398
+ },
399
+ end(conn, streamId) {
400
+ conn.ws.send(JSON.stringify({ type: "end", streamId }));
401
+ const stream = conn.streams.get(streamId);
402
+ if (stream !== undefined) stream.closed = true;
403
+ },
404
+ setResultFor(fn) {
405
+ resultFor = fn;
406
+ },
407
+ setRpcFor(fn) {
408
+ rpcFor = fn;
409
+ },
410
+ setListFor(fn) {
411
+ listFor = fn;
412
+ },
413
+ /** 某个一元方法收到的调用流水。 */
414
+ rpcsOf(method) {
415
+ return state.rpcs.filter((r) => r.method === method);
416
+ },
417
+ /** 某个一元方法最近一次的信封(`payload.args`)。 */
418
+ lastArgsOf(method) {
419
+ const all = api.rpcsOf(method);
420
+ return all.length === 0 ? undefined : all[all.length - 1].body?.payload?.args;
421
+ },
422
+ currentConn() {
423
+ return state.connections[state.connections.length - 1];
424
+ },
425
+ liveEventsStream(conn = api.currentConn()) {
426
+ return [...(conn?.streams.values() ?? [])].find((s) => s.endpoint === EVENTS_ENDPOINT && !s.closed);
427
+ },
428
+ liveFollowStreams(conn = api.currentConn()) {
429
+ return [...(conn?.streams.values() ?? [])].filter((s) => s.endpoint === "session/follow" && !s.closed);
430
+ },
431
+ /** 等服务端确实收到某条流的 cancel(断言前必须等,否则会抢在收帧之前)。 */
432
+ async waitForCancel(streamId, timeoutMs = 1_000) {
433
+ const deadline = Date.now() + timeoutMs;
434
+ while (Date.now() < deadline) {
435
+ if (state.cancels.some((c) => c.streamId === streamId)) return true;
436
+ await delay(5, null, { ref: false });
437
+ }
438
+ throw new Error(`等待 cancel 超时:${streamId}`);
439
+ },
440
+ /** 等「服务端确实收到了 open」(模块的 follow-opened 事件早于服务端收帧)。 */
441
+ setListItems(items) {
442
+ state.listItems = items;
443
+ },
444
+ liveControlStream(conn = api.currentConn()) {
445
+ return [...(conn?.streams.values() ?? [])].find((s) => s.endpoint === SESSION_CONTROL_ENDPOINT && !s.closed);
446
+ },
447
+ pushControl(value) {
448
+ const conn = api.currentConn();
449
+ api.send(conn, api.liveControlStream(conn).streamId, value);
450
+ },
451
+ async waitForControlStream(timeoutMs = 1_000) {
452
+ const deadline = Date.now() + timeoutMs;
453
+ while (Date.now() < deadline) {
454
+ const stream = api.liveControlStream();
455
+ if (stream !== undefined) return stream;
456
+ await delay(5, null, { ref: false });
457
+ }
458
+ throw new Error("等待 session/control 流超时");
459
+ },
460
+ async waitForListCalls(count = 1, timeoutMs = 1_000) {
461
+ const deadline = Date.now() + timeoutMs;
462
+ while (Date.now() < deadline) {
463
+ if (state.listCalls >= count) return state.listCalls;
464
+ await delay(5, null, { ref: false });
465
+ }
466
+ throw new Error(`等待 session/list 调用超时(期望 ${count} 次,实际 ${state.listCalls})`);
467
+ },
468
+ async waitForFollowStreams(count = 1, timeoutMs = 2_000) {
469
+ const deadline = Date.now() + timeoutMs;
470
+ while (Date.now() < deadline) {
471
+ if (api.liveFollowStreams().length >= count) return api.liveFollowStreams();
472
+ await delay(5, null, { ref: false });
473
+ }
474
+ // 超时文案带上服务端侧的事实:分不清「没开」还是「开了又被关」时最费时间。
475
+ const conns = state.connections
476
+ .map((c, i) => `#${i}:${[...c.streams.values()].map((s) => `${s.endpoint}${s.closed ? "(closed)" : ""}`).join(",") || "-"}`)
477
+ .join(" | ");
478
+ throw new Error(
479
+ `等待 follow 流超时(期望 ${count} 条);conns=${state.connections.length} [${conns}];` +
480
+ `opens=${JSON.stringify(state.opens.map((o) => o.endpoint))};` +
481
+ `cancels=${JSON.stringify(state.cancels.map((c) => c.endpoint))}`,
482
+ );
483
+ },
484
+ push(value) {
485
+ const conn = api.currentConn();
486
+ api.send(conn, api.liveEventsStream(conn).streamId, value);
487
+ },
488
+ pushEmit(event, args) {
489
+ api.push({ type: "emit", event, args });
490
+ },
491
+ pushWaterfall(frame) {
492
+ api.push({ type: "waterfall", ...frame });
493
+ },
494
+ pushes(value) {
495
+ for (const conn of state.connections) {
496
+ const stream = api.liveEventsStream(conn);
497
+ if (stream !== undefined) api.send(conn, stream.streamId, value);
498
+ }
499
+ },
500
+ dropAll() {
501
+ for (const conn of state.connections) conn.ws.terminate();
502
+ },
503
+ async close() {
504
+ for (const conn of state.connections) {
505
+ try {
506
+ conn.ws.terminate();
507
+ } catch {
508
+ /* 已断 */
509
+ }
510
+ }
511
+ await new Promise((resolve) => wss.close(() => resolve()));
512
+ await new Promise((resolve) => {
513
+ server.close(() => resolve());
514
+ server.closeAllConnections?.();
515
+ });
516
+ },
517
+ };
518
+
519
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
520
+ api.base = `http://127.0.0.1:${server.address().port}`;
521
+ return api;
522
+ }
523
+
524
+ /** 起假 DSH + 订阅器;调用方必须 finally dispose()。 */
525
+ async function setup(serverOptions = {}, subscriberOptions = {}) {
526
+ const api = await startFakeDsh(serverOptions);
527
+ const sub = createEventSubscriber({
528
+ upstream: api.base,
529
+ cookie: () => api.cookie,
530
+ followSettleMs: 30,
531
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
532
+ ...subscriberOptions,
533
+ });
534
+ return {
535
+ api,
536
+ sub,
537
+ async dispose() {
538
+ sub.close();
539
+ await api.close();
540
+ },
541
+ };
542
+ }
543
+
544
+ async function dispose2(sub, api) {
545
+ sub.close();
546
+ await api.close();
547
+ }
548
+
549
+ const start = async (sub) => {
550
+ sub.start();
551
+ return sub.whenReady();
552
+ };
553
+
554
+ /**
555
+ * 轮询等一个**条件**成立 —— 不要用固定 sleep 赌「应该到了」。
556
+ *
557
+ * 判据:把 `timeoutMs` 调大只会更宽容,**不会改变结论**(条件真出现过就一定等到);
558
+ * 条件永不出现时,报错文案里带上 label,比「N 毫秒后断言失败」好定位。
559
+ * @param {() => unknown} predicate - 返回真值即成功;该真值会被返回。
560
+ * @param {{label?: string, timeoutMs?: number}} [options]
561
+ */
562
+ async function waitFor(predicate, { label = "条件", timeoutMs = 2_000 } = {}) {
563
+ const deadline = Date.now() + timeoutMs;
564
+ for (;;) {
565
+ const value = predicate();
566
+ if (value) return value;
567
+ if (Date.now() >= deadline) throw new Error(`等待超时:${label}`);
568
+ await delay(5, null, { ref: false });
569
+ }
570
+ }
571
+
572
+ /** 收事件(先挂监听再触发,避免竞态)。 */
573
+ function collector(sub) {
574
+ const nodes = [];
575
+ sub.on("event", (node) => nodes.push(node));
576
+ return {
577
+ nodes,
578
+ async next(predicate = () => true, timeoutMs = 2_000) {
579
+ const deadline = Date.now() + timeoutMs;
580
+ while (Date.now() < deadline) {
581
+ const index = nodes.findIndex(predicate);
582
+ if (index >= 0) return nodes.splice(index, 1)[0];
583
+ await delay(5, null, { ref: false });
584
+ }
585
+ throw new Error(`等待事件超时;已收到 ${JSON.stringify(nodes.map((n) => n.kind))}`);
586
+ },
587
+ };
588
+ }
589
+
590
+ // ============================================================
591
+ // 1. $events:ready → emit 分类
592
+ // ============================================================
593
+
594
+ test("$events:ready 拿到 clientId,emit 被分类为产品节点", async () => {
595
+ const { api, sub, dispose } = await setup();
596
+ try {
597
+ const nodes = collector(sub);
598
+ const ready = await start(sub);
599
+ assert.equal(ready.clientId, "client-1");
600
+ assert.deepEqual(ready.host, { home: "/tmp/dsh-home" });
601
+ assert.equal(sub.clientId, "client-1");
602
+ assert.equal(sub.state, "ready");
603
+
604
+ api.pushEmit("api-session/error", ["session-aaaa", "boom: 工具失败"]);
605
+ const node = await nodes.next((n) => n.kind === NODE_KINDS.SESSION_ERROR);
606
+ assert.equal(node.specNode, 3);
607
+ assert.equal(node.sessionId, "session-aaaa");
608
+ assert.equal(node.agentId, "session-aaaa");
609
+ assert.equal(node.message, "boom: 工具失败");
610
+ assert.ok(Number.isFinite(node.at));
611
+
612
+ // 非产品事件(activity/added)不得产出通知
613
+ api.pushEmit("api-session/activity", ["session-aaaa", 1_700_000_000_000]);
614
+ api.pushEmit("api-session/added", [{ id: "session-aaaa" }]);
615
+ await delay(60, null, { ref: false });
616
+ assert.deepEqual(nodes.nodes, []);
617
+ } finally {
618
+ await dispose();
619
+ }
620
+ });
621
+
622
+ // ============================================================
623
+ // 2. waterfall:审批 / 提问 / plan-review
624
+ // ============================================================
625
+
626
+ test("waterfall:审批与提问分类,plan-review 单列", async () => {
627
+ const { api, sub, dispose } = await setup();
628
+ try {
629
+ const nodes = collector(sub);
630
+ await start(sub);
631
+
632
+ api.pushWaterfall({
633
+ event: "approval/request",
634
+ eventId: "ev-approval",
635
+ agentId: "session-appr",
636
+ request: { toolName: "Bash", callId: "call-1", reason: "需要跑 rm -rf" },
637
+ });
638
+ const approval = await nodes.next((n) => n.kind === NODE_KINDS.APPROVAL_REQUEST);
639
+ assert.equal(approval.specNode, 1);
640
+ assert.equal(approval.answerShape, "approval");
641
+ assert.equal(approval.answerable, true);
642
+ assert.equal(approval.sessionId, "session-appr");
643
+ assert.equal(approval.agentId, "session-appr");
644
+ assert.equal(approval.eventId, "ev-approval");
645
+ assert.equal(approval.toolName, "Bash");
646
+ assert.equal(approval.callId, "call-1");
647
+ assert.equal(approval.reason, "需要跑 rm -rf");
648
+ assert.deepEqual(
649
+ approval.options.map((o) => ({ ...o })),
650
+ APPROVAL_CHOICES.map((o) => ({ ...o })),
651
+ );
652
+
653
+ api.pushWaterfall({
654
+ event: "user-questions/request",
655
+ eventId: "ev-question",
656
+ agentId: "session-q",
657
+ request: {
658
+ questions: [
659
+ { id: "q1", question: "选哪个?", options: [{ label: "A", description: "首选" }, { label: "B" }] },
660
+ ],
661
+ },
662
+ });
663
+ const question = await nodes.next((n) => n.kind === NODE_KINDS.USER_QUESTION);
664
+ assert.equal(question.specNode, 2);
665
+ assert.equal(question.answerShape, "question");
666
+ assert.equal(question.planReview, false);
667
+ assert.equal(question.eventId, "ev-question");
668
+ assert.deepEqual(question.options, [
669
+ { questionId: "q1", id: "q1", label: "A", description: "首选" },
670
+ { questionId: "q1", id: "q1", label: "B", description: undefined },
671
+ ]);
672
+
673
+ api.pushWaterfall({
674
+ event: "user-questions/request",
675
+ eventId: "ev-plan",
676
+ agentId: "session-p",
677
+ request: {
678
+ questions: [
679
+ {
680
+ id: "p1",
681
+ question: "这个计划可以吗?",
682
+ detail: "# 计划\n1. 做事",
683
+ header: "计划待批",
684
+ options: [{ label: "批准" }, { label: "否决" }],
685
+ intent: { kind: "plan-review", approve: "批准" },
686
+ },
687
+ ],
688
+ },
689
+ });
690
+ const plan = await nodes.next((n) => n.kind === NODE_KINDS.PLAN_REVIEW);
691
+ assert.equal(plan.specNode, 2);
692
+ assert.equal(plan.planReview, true);
693
+ assert.equal(plan.eventId, "ev-plan");
694
+ assert.deepEqual(plan.intent, { kind: "plan-review", approve: "批准" });
695
+ assert.equal(plan.detail, "# 计划\n1. 做事");
696
+ } finally {
697
+ await dispose();
698
+ }
699
+ });
700
+
701
+ // ============================================================
702
+ // 3. cancel 帧 = 「这条已过期」;此后回执被拒(fail-closed)
703
+ // ============================================================
704
+
705
+ test("cancel 帧标记事件作废,且过期后回执被拒(绝不把沉默当同意)", async () => {
706
+ const { api, sub, dispose } = await setup();
707
+ try {
708
+ const nodes = collector(sub);
709
+ await start(sub);
710
+
711
+ api.pushWaterfall({
712
+ event: "approval/request",
713
+ eventId: "ev-expire",
714
+ agentId: "session-e",
715
+ request: { toolName: "Bash" },
716
+ });
717
+ await nodes.next((n) => n.kind === NODE_KINDS.APPROVAL_REQUEST);
718
+ assert.deepEqual(sub.pendingEvents(), ["ev-expire"]);
719
+
720
+ const conn = api.currentConn();
721
+ api.send(conn, api.liveEventsStream(conn).streamId, { type: "cancel", eventId: "ev-expire" });
722
+ const expired = await nodes.next((n) => n.kind === NODE_KINDS.EVENT_EXPIRED);
723
+ assert.equal(expired.eventId, "ev-expire");
724
+ assert.equal(expired.node, NODE_KINDS.APPROVAL_REQUEST);
725
+ assert.equal(expired.reason, "cancelled-by-host");
726
+ assert.deepEqual(sub.pendingEvents(), []);
727
+
728
+ await assert.rejects(
729
+ sub.answerApproval("ev-expire", "allowed-once"),
730
+ (error) => error instanceof EventAnswerError && error.code === "event-expired" && error.settled === true,
731
+ );
732
+ assert.equal(api.state.results.length, 0, "过期事件不得发出任何回执");
733
+ } finally {
734
+ await dispose();
735
+ }
736
+ });
737
+
738
+ // ============================================================
739
+ // 4. session/follow:turn/end 六种 reason
740
+ // ============================================================
741
+
742
+ test("session/follow:turn/end 六种 reason 全部精确映射", async () => {
743
+ const { api, sub, dispose } = await setup();
744
+ try {
745
+ const nodes = collector(sub);
746
+ await start(sub);
747
+ assert.deepEqual([...TURN_END_REASONS], [
748
+ "completed",
749
+ "aborted",
750
+ "blocked",
751
+ "error",
752
+ "max-tokens",
753
+ "interrupted",
754
+ ]);
755
+
756
+ api.pushEmit("api-session/status", ["session-turns", true]);
757
+ const opened = await once(sub, "follow-opened");
758
+ assert.equal(opened[0].sessionId, "session-turns");
759
+
760
+ const [follow] = await api.waitForFollowStreams();
761
+ // 线上 payload 必须有外层 args(gateway 硬校验)
762
+ assert.deepEqual(follow.payload, {
763
+ args: { request: { address: { kind: "session", sessionId: "session-turns" } } },
764
+ });
765
+
766
+ TURN_END_REASONS.forEach((reason, index) => {
767
+ api.send(api.currentConn(), follow.streamId, {
768
+ type: "event",
769
+ event: {
770
+ type: "turn/end",
771
+ seq: index + 1,
772
+ time: 1_000 + index,
773
+ data: { turn: index + 1, reason: { kind: reason } },
774
+ },
775
+ });
776
+ });
777
+
778
+ const seen = [];
779
+ for (let index = 0; index < TURN_END_REASONS.length; index += 1) {
780
+ seen.push(await nodes.next((n) => n.kind === NODE_KINDS.TURN_END));
781
+ }
782
+ assert.deepEqual(seen.map((n) => n.reason), [...TURN_END_REASONS]);
783
+ assert.ok(seen.every((n) => n.known === true && n.specNode === 4 && n.sessionId === "session-turns"));
784
+ assert.deepEqual(seen.map((n) => n.at), TURN_END_REASONS.map((_, index) => 1_000 + index));
785
+ // 带上原始 reason,方便上层区分 aborted/error 的具体成因
786
+ assert.equal(seen.find((n) => n.reason === "completed").detail.kind, "completed");
787
+
788
+ // 已经收到过 turn/end 后再报 idle:立即关流(不再等 followSettleMs)
789
+ const closed = once(sub, "follow-closed");
790
+ api.pushEmit("api-session/status", ["session-turns", false]);
791
+ const [closedInfo] = await closed;
792
+ assert.equal(closedInfo.reason, "turn-end");
793
+ assert.equal(closedInfo.streamId, follow.streamId);
794
+ await api.waitForCancel(follow.streamId);
795
+ assert.deepEqual(sub.sessions(), []);
796
+ } finally {
797
+ await dispose();
798
+ }
799
+ });
800
+
801
+ // ============================================================
802
+ // 5. 裸 uuid 归一化 + not-found / agent-busy 非致命且不重试
803
+ // ============================================================
804
+
805
+ test("follow 的会话 id:原样为主、alternative 兜底一次;agent-busy/not-found 非致命且不重试打转", async () => {
806
+ // 纯函数:候选顺序永远是「原样」在前
807
+ assert.deepEqual(sessionIdCandidates("9b4f9e73-8912-49ca-98ba-23da9c34ab47"), [
808
+ "9b4f9e73-8912-49ca-98ba-23da9c34ab47",
809
+ "session-9b4f9e73-8912-49ca-98ba-23da9c34ab47",
810
+ ]);
811
+ assert.deepEqual(sessionIdCandidates("session-9b4f9e73"), ["session-9b4f9e73", "9b4f9e73"]);
812
+ assert.deepEqual(sessionIdCandidates(""), []);
813
+ assert.deepEqual(sessionIdCandidates(undefined), []);
814
+ assert.equal(toDurableSessionId("9b4f"), "session-9b4f");
815
+ assert.equal(toDurableSessionId("session-9b4f"), "session-9b4f");
816
+
817
+ const faults = [];
818
+ // 真机口径:子代理会话的真实 id 是裸 uuid,`session-<uuid>` 不存在(实测)。
819
+ // 这个假服务端只认「原样 id」,对另一种形式回 session/not-found。
820
+ const api = await startFakeDsh({
821
+ onOpen(server, conn, stream) {
822
+ if (stream.endpoint !== "session/follow") return;
823
+ const id = stream.payload.args.request.address.sessionId;
824
+ if (id === globalThis.__acceptedId) return; // 这个形式服务端认
825
+ if (id === "session-subagent-1") {
826
+ server.fail(conn, stream.streamId, {
827
+ code: "session/agent-busy",
828
+ message: "subagent Sessions require their durable parent address",
829
+ details: { reason: "use subagent delivery for this child session" },
830
+ });
831
+ return;
832
+ }
833
+ server.fail(conn, stream.streamId, {
834
+ code: "session/not-found",
835
+ message: `session "${id}" not found`,
836
+ details: { sessionId: id },
837
+ });
838
+ },
839
+ });
840
+ const sub = createEventSubscriber({
841
+ upstream: api.base,
842
+ cookie: () => api.cookie,
843
+ followSettleMs: 30,
844
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
845
+ });
846
+ try {
847
+ sub.on("fault", (fault) => faults.push(fault));
848
+ await start(sub);
849
+
850
+ // 事件里报裸 uuid(= 真机 api-session/status 的口径)→ 主形式必须**原样**,不加前缀
851
+ const reportedId = "9b4f9e73-8912-49ca-98ba-23da9c34ab47";
852
+ globalThis.__acceptedId = `session-${reportedId}`; // 服务端只认带前缀的那种
853
+ api.pushEmit("api-session/status", [reportedId, true]);
854
+ await once(sub, "follow-opened");
855
+ // 先看「第一次发出的 open」(服务端按序记录)——主形式必须是原样
856
+ await api.waitForFollowStreams(1);
857
+ const streams = () => api.state.opens.filter((o) => o.endpoint === "session/follow");
858
+ assert.equal(streams()[0].payload.args.request.address.sessionId, reportedId);
859
+
860
+ // 该形式被服务端 not-found → 兜底换另一种形式,只试一次
861
+ // 等服务端**确实收到**第二次 open(不 sleep 赌它到了)
862
+ await waitFor(() => streams().length >= 2, { label: "兜底换形式后的第二次 follow open" });
863
+ assert.equal(streams().length, 2);
864
+ assert.equal(streams()[1].payload.args.request.address.sessionId, globalThis.__acceptedId);
865
+ assert.equal(faults.filter((f) => f.code === "session/not-found").length, 1);
866
+ assert.equal(faults[0].expected, true);
867
+ assert.equal(sub.state, "ready", "会话不存在不得打断 $events");
868
+ assert.deepEqual(sub.sessions(), [reportedId], "换形式后仍算同一条会话");
869
+
870
+ // 两种形式都试完 → 本代不再重试(否则就是 not-found 打转)
871
+ api.pushEmit("api-session/status", [reportedId, true]);
872
+ await delay(80, null, { ref: false });
873
+ assert.equal(api.state.opens.filter((o) => o.endpoint === "session/follow").length, 2);
874
+
875
+ // 子代理会话:session/agent-busy —— 预期内、非致命,且不换形式重试
876
+ api.pushEmit("api-session/status", ["session-subagent-1", true]);
877
+ const busy = await waitFor(() => faults.find((f) => f.code === "session/agent-busy"), {
878
+ label: "session/agent-busy fault",
879
+ });
880
+ assert.equal(busy.expected, true);
881
+ assert.equal(busy.sessionId, "session-subagent-1");
882
+ await waitFor(
883
+ () => api.state.opens.filter((o) => o.endpoint === "session/follow").length === 3,
884
+ { label: "子代理会话的第三次(且不兜底)open" },
885
+ );
886
+ assert.equal(api.state.opens.filter((o) => o.endpoint === "session/follow").length, 3);
887
+ assert.equal(sub.state, "ready");
888
+ } finally {
889
+ sub.close();
890
+ await api.close();
891
+ delete globalThis.__acceptedId;
892
+ }
893
+ });
894
+
895
+ test("follow 打通:主形式被接受时拿到 snapshot;added(running) 也会开流", async () => {
896
+ const { api, sub, dispose } = await setup();
897
+ try {
898
+ const snapshots = [];
899
+ sub.on("follow-snapshot", (snapshot) => snapshots.push(snapshot));
900
+ await start(sub);
901
+
902
+ api.pushEmit("api-session/status", ["session-ok", true]);
903
+ await once(sub, "follow-opened");
904
+ const [stream] = await api.waitForFollowStreams();
905
+ assert.deepEqual(stream.payload, {
906
+ args: { request: { address: { kind: "session", sessionId: "session-ok" } } },
907
+ });
908
+ // 等服务端发的 snapshot 真的被处理(不是 sleep 40ms 后赌它到了)
909
+ await waitFor(() => snapshots.length >= 1, { label: "follow-snapshot" });
910
+ assert.equal(snapshots.length, 1);
911
+ assert.equal(snapshots[0].sessionId, "session-ok");
912
+ assert.equal(snapshots[0].recordCount, 0);
913
+ assert.equal(snapshots[0].cursor, 0);
914
+ assert.deepEqual(snapshots[0].header, { id: "session-ok", origin: "human" });
915
+
916
+ // status 是边沿事件:added 带 running 的会话也要跟(接进来时可能已错过 status:true)
917
+ api.pushEmit("api-session/added", [
918
+ { sessionId: "session-added", updatedAt: 1, running: true, blank: true, origin: "subagent" },
919
+ ]);
920
+ await api.waitForFollowStreams(2);
921
+ assert.deepEqual(sub.sessions().sort(), ["session-added", "session-ok"]);
922
+
923
+ // running=false 的 added 不跟
924
+ api.pushEmit("api-session/added", [{ sessionId: "session-idle", running: false }]);
925
+ await delay(60, null, { ref: false });
926
+ assert.equal(sub.sessions().includes("session-idle"), false);
927
+ } finally {
928
+ await dispose();
929
+ }
930
+ });
931
+
932
+ // ============================================================
933
+ // 5b. 会话发现($events 是边沿事件,接进来时已在跑的会话只能靠 session/list)
934
+ // ============================================================
935
+
936
+ test("发现:连接时用 session/list 的 running 发现已在跑的会话并 follow(含裸 uuid→持久形式的兜底)", async () => {
937
+ // 服务端只认带前缀的形式,逼出「原样为主 + 另一种兜底」的路径
938
+ const accepted = "session-1111aaaa-2222-4b3b-8c8c-3333dddd4444";
939
+ const faults = [];
940
+ const api = await startFakeDsh({
941
+ listItems: [
942
+ { sessionId: "1111aaaa-2222-4b3b-8c8c-3333dddd4444", running: true, origin: "subagent" },
943
+ { sessionId: "session-idle-one", running: false },
944
+ { sessionId: "session-2222bbbb-4444-4c4c-9d9d-5555eeee6666", running: true },
945
+ ],
946
+ onOpen(server, conn, stream) {
947
+ if (stream.endpoint !== "session/follow") return;
948
+ const id = stream.payload.args.request.address.sessionId;
949
+ if (id === accepted || id === "session-2222bbbb-4444-4c4c-9d9d-5555eeee6666") return;
950
+ server.fail(conn, stream.streamId, {
951
+ code: "session/not-found",
952
+ message: `session "${id}" not found`,
953
+ details: { sessionId: id },
954
+ });
955
+ },
956
+ });
957
+ const sub = createEventSubscriber({
958
+ upstream: api.base,
959
+ cookie: () => api.cookie,
960
+ discoverIntervalMs: 0,
961
+ followSettleMs: 30,
962
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
963
+ });
964
+ try {
965
+ sub.on("fault", (fault) => faults.push(fault));
966
+ const discovered = [];
967
+ sub.on("sessions-discovered", (info) => discovered.push(info));
968
+
969
+ const ready = await start(sub);
970
+ assert.equal(ready.clientId, "client-1");
971
+
972
+ // 一元 HTTP 的线参名必须是 _request(真机实测送 request 会被拒)
973
+ await api.waitForListCalls(1);
974
+ assert.deepEqual(Object.keys(api.state.listBodies[0].payload.args), ["_request"]);
975
+
976
+ await api.waitForFollowStreams(1);
977
+ const firstOpen = api.state.opens.filter((o) => o.endpoint === "session/follow");
978
+ // 主形式 = 原样(裸 uuid);该形式不存在 → 兜底成 session-<uuid>,这次服务端认
979
+ assert.equal(firstOpen[0].payload.args.request.address.sessionId, "1111aaaa-2222-4b3b-8c8c-3333dddd4444");
980
+ // 等「裸 uuid 那条 not-found 后兜底发出 accepted 形式」这一步真的发生
981
+ await waitFor(
982
+ () =>
983
+ api.state.opens.some(
984
+ (o) => o.endpoint === "session/follow" && o.payload.args.request.address.sessionId === accepted,
985
+ ),
986
+ { label: "裸 uuid 兜底成持久形式的那次 follow open" },
987
+ );
988
+ const opens = api.state.opens.filter((o) => o.endpoint === "session/follow").map((o) => o.payload.args.request.address.sessionId);
989
+ // 顺序:两条 running 的**主形式**先同步发出(裸 uuid 那条稍后 not-found 再兜底换形式)
990
+ assert.deepEqual(opens, [
991
+ "1111aaaa-2222-4b3b-8c8c-3333dddd4444",
992
+ "session-2222bbbb-4444-4c4c-9d9d-5555eeee6666",
993
+ accepted,
994
+ ]);
995
+ // 只 follow 在跑的,idle 那条不碰
996
+ assert.equal(opens.includes("session-idle-one"), false);
997
+ // sessions() 的键是**事件里报的 id**(follow 的身份),不是线上兜底后的形式
998
+ assert.deepEqual(sub.sessions().sort(), [
999
+ "1111aaaa-2222-4b3b-8c8c-3333dddd4444",
1000
+ "session-2222bbbb-4444-4c4c-9d9d-5555eeee6666",
1001
+ ].sort());
1002
+ assert.deepEqual(discovered[0].running, [
1003
+ "1111aaaa-2222-4b3b-8c8c-3333dddd4444",
1004
+ "session-2222bbbb-4444-4c4c-9d9d-5555eeee6666",
1005
+ ]);
1006
+ assert.equal(discovered[0].total, 3);
1007
+ assert.equal(faults.filter((f) => f.code === "session/not-found").length, 1);
1008
+ } finally {
1009
+ sub.close();
1010
+ await api.close();
1011
+ }
1012
+ });
1013
+
1014
+ test("发现:reconcile 流能补报 snapshot 里尾部的 turn/end(接进来前刚好结束的那一轮)", async () => {
1015
+ const sessionId = "session-snap-0001";
1016
+ const api = await startFakeDsh({
1017
+ listItems: [{ sessionId, running: true }],
1018
+ onOpen(server, conn, stream) {
1019
+ if (stream.endpoint !== "session/follow") return;
1020
+ // 快照里最后一条就是 turn/end —— 这一轮在我们挂上之前刚好结束
1021
+ server.send(conn, stream.streamId, {
1022
+ type: "snapshot",
1023
+ header: { id: sessionId, version: 3 },
1024
+ cursor: 9,
1025
+ records: [
1026
+ { type: "event", event: { type: "assistant/message", seq: 7, time: 1, data: {} } },
1027
+ { type: "event", event: { type: "turn/end", seq: 8, time: 1_700_000_000_000, data: { turn: 1, reason: { kind: "interrupted" } } } },
1028
+ ],
1029
+ hasMore: false,
1030
+ projections: { asOfSeq: 9, values: {} },
1031
+ });
1032
+ },
1033
+ });
1034
+ const sub = createEventSubscriber({
1035
+ upstream: api.base,
1036
+ cookie: () => api.cookie,
1037
+ discoverIntervalMs: 0,
1038
+ followSettleMs: 30,
1039
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
1040
+ });
1041
+ try {
1042
+ const nodes = collector(sub);
1043
+ await start(sub);
1044
+ const node = await nodes.next((n) => n.kind === NODE_KINDS.TURN_END);
1045
+ assert.equal(node.sessionId, sessionId);
1046
+ assert.equal(node.reason, "interrupted");
1047
+ assert.equal(node.fromSnapshot, true, "补报必须带来源标记,别冒充实时");
1048
+ assert.equal(node.at, 1_700_000_000_000);
1049
+
1050
+ // 同一轮不会被实时帧重复上报
1051
+ api.push({ type: "emit", event: "api-session/status", args: [sessionId, false] });
1052
+ await delay(60, null, { ref: false });
1053
+ assert.equal(nodes.nodes.filter((n) => n.kind === NODE_KINDS.TURN_END).length, 0);
1054
+ } finally {
1055
+ await dispose2(sub, api);
1056
+ }
1057
+ });
1058
+
1059
+ test("发现:实时 status 开的流**不**从 snapshot 补报历史 turn/end(避免假停止)", async () => {
1060
+ const sessionId = "session-live-0001";
1061
+ const api = await startFakeDsh({
1062
+ listItems: [],
1063
+ onOpen(server, conn, stream) {
1064
+ if (stream.endpoint !== "session/follow") return;
1065
+ server.send(conn, stream.streamId, {
1066
+ type: "snapshot",
1067
+ header: { id: sessionId },
1068
+ cursor: 9,
1069
+ records: [
1070
+ { type: "event", event: { type: "turn/end", seq: 8, time: 1, data: { turn: 1, reason: { kind: "completed" } } } },
1071
+ ],
1072
+ hasMore: false,
1073
+ projections: { asOfSeq: 9, values: {} },
1074
+ });
1075
+ },
1076
+ });
1077
+ const sub = createEventSubscriber({
1078
+ upstream: api.base,
1079
+ cookie: () => api.cookie,
1080
+ discoverIntervalMs: 0,
1081
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
1082
+ });
1083
+ try {
1084
+ const nodes = collector(sub);
1085
+ const snapshots = [];
1086
+ sub.on("follow-snapshot", (snapshot) => snapshots.push(snapshot));
1087
+ await start(sub);
1088
+ api.push({ type: "emit", event: "api-session/status", args: [sessionId, true] });
1089
+ await once(sub, "follow-opened");
1090
+ // 等 snapshot **真的被处理**,再断言它没变成通知(否则「node 为空」可能只是还没到)
1091
+ await waitFor(() => snapshots.length >= 1, { label: "live follow 的 snapshot" });
1092
+ assert.deepEqual(nodes.nodes, [], "实时路径不得把历史 turn/end 当成新通知");
1093
+ } finally {
1094
+ await dispose2(sub, api);
1095
+ }
1096
+ });
1097
+
1098
+ test("发现:对账会用 session/list 关掉已经不在跑的 follow(防泄漏),且不与刚到的 status:true 打架", async () => {
1099
+ const api = await startFakeDsh({ listItems: [], listDelayMs: 90 });
1100
+ const sub = createEventSubscriber({
1101
+ upstream: api.base,
1102
+ cookie: () => api.cookie,
1103
+ discoverIntervalMs: 40,
1104
+ followSettleMs: 30,
1105
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
1106
+ });
1107
+ try {
1108
+ await start(sub);
1109
+ await api.waitForListCalls(1);
1110
+
1111
+ // 实时事件跟上一个会话
1112
+ api.push({ type: "emit", event: "api-session/status", args: ["session-gone", true] });
1113
+ const opened = await once(sub, "follow-opened");
1114
+ assert.deepEqual(sub.sessions(), ["session-gone"]);
1115
+
1116
+ // 周期对账:列表说它没在跑 → 关流
1117
+ api.setListItems([{ sessionId: "session-gone", running: false }]);
1118
+ const closed = await once(sub, "follow-closed");
1119
+ assert.equal(closed[0].reason, "not-running");
1120
+ assert.equal(closed[0].streamId, opened[0].streamId);
1121
+ await api.waitForCancel(opened[0].streamId);
1122
+ assert.deepEqual(sub.sessions(), []);
1123
+
1124
+ // 竞态:列表请求在途时(startedAt)来了 status:true → 实时凭据更新,不能被过期列表关掉。
1125
+ //
1126
+ // 这一段必须**构造性**地确定,不能靠"大概来得及":
1127
+ // ① 先留一条 running 的占位会话:`#scheduleDiscovery` 只在**手上有 follow 时**才发列表,
1128
+ // 否则一旦 follow 被关掉、对账就停了(下面 `before+1` 会永远等不到)——这正是
1129
+ // 「期望 N 次、实际 N-1」那种超时的来源。
1130
+ // ② 等**服务端确实收到**一次列表请求(那次请求的 startedAt 已过去),再开 session-race
1131
+ // 的 follow 并**同步**刷 status:true —— 中间没有 await,所以那次在途响应不可能抢先落地。
1132
+ // ③ startedAt < liveAt → 该列表已过期,回来时不得关流。
1133
+ let discoveredCount = 0;
1134
+ sub.on("sessions-discovered", () => {
1135
+ discoveredCount += 1;
1136
+ });
1137
+ api.push({ type: "emit", event: "api-session/status", args: ["session-keepalive", true] });
1138
+ await once(sub, "follow-opened");
1139
+ api.setListItems([
1140
+ { sessionId: "session-keepalive", running: true },
1141
+ { sessionId: "session-race", running: false },
1142
+ ]);
1143
+ const before = api.state.listCalls;
1144
+ await api.waitForListCalls(before + 1); // 请求已在途(响应被 listDelayMs 拖住)
1145
+ const seen = discoveredCount;
1146
+ sub.followSession("session-race");
1147
+ api.push({ type: "emit", event: "api-session/status", args: ["session-race", true] }); // 同步给凭据
1148
+ assert.equal(sub.sessions().includes("session-race"), true);
1149
+ // 等**这次被拖住的列表确实处理完**(以 sessions-discovered 计数为准,不 sleep)
1150
+ await waitFor(() => discoveredCount > seen, { label: "被拖住的 session/list 处理完" });
1151
+ assert.equal(
1152
+ sub.sessions().includes("session-race"),
1153
+ true,
1154
+ "列表在途期间刚到的 status:true 不能被覆盖",
1155
+ );
1156
+ } finally {
1157
+ await dispose2(sub, api);
1158
+ }
1159
+ });
1160
+
1161
+ test("session/control:baseline 给出实时会话集合,projection 帧把活跃会话带进 follow", async () => {
1162
+ const api = await startFakeDsh({
1163
+ listItems: [],
1164
+ controlQueues: { "session-a": [], "session-b": [] },
1165
+ controlJobs: { "session-a": [], "session-b": [] },
1166
+ controlProjections: { "session-a": { asOfSeq: 1, values: {} }, "session-b": { asOfSeq: 2, values: {} } },
1167
+ });
1168
+ const sub = createEventSubscriber({
1169
+ upstream: api.base,
1170
+ cookie: () => api.cookie,
1171
+ discoverIntervalMs: 0,
1172
+ discover: false,
1173
+ followSettleMs: 30,
1174
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
1175
+ });
1176
+ try {
1177
+ const baselines = [];
1178
+ const activity = [];
1179
+ sub.on("control-sessions", (info) => baselines.push(info));
1180
+ sub.on("control-activity", (info) => activity.push(info));
1181
+ await start(sub);
1182
+ const stream = await api.waitForControlStream();
1183
+ assert.equal(api.state.listCalls, 0, "discover:false 时不得调 session/list");
1184
+ assert.deepEqual(stream.payload, { args: {} });
1185
+ await waitFor(() => baselines.length === 1, { label: "session/control baseline" });
1186
+ assert.deepEqual(baselines[0].sessions.sort(), ["session-a", "session-b"]);
1187
+ assert.deepEqual(sub.controlSessions().sort(), ["session-a", "session-b"]);
1188
+ // baseline 只是「谁存在」,不该据此开 follow(idle 会话也在这里)
1189
+ assert.deepEqual(sub.sessions(), []);
1190
+
1191
+ // projection 帧 = 该会话活着 → 开 follow
1192
+ api.pushControl({
1193
+ type: "projection",
1194
+ sessionId: "session-a",
1195
+ key: "tokenUsage",
1196
+ value: { outputTokens: 1 },
1197
+ seq: 5,
1198
+ });
1199
+ const opened = await once(sub, "follow-opened");
1200
+ assert.equal(opened[0].sessionId, "session-a");
1201
+ assert.equal(activity[0].sessionId, "session-a");
1202
+ assert.equal(activity[0].key, "tokenUsage");
1203
+ await api.waitForFollowStreams(1);
1204
+ assert.deepEqual(
1205
+ api.state.opens.filter((o) => o.endpoint === "session/follow").map((o) => o.payload.args.request.address.sessionId),
1206
+ ["session-a"],
1207
+ );
1208
+
1209
+ // 幂等:同一条会话再活跃不会重复开流
1210
+ api.pushControl({ type: "jobs", sessionId: "session-a", jobs: [] });
1211
+ api.pushControl({ type: "projection", sessionId: "session-b", key: "sessionStats", value: {}, seq: 6 });
1212
+ // 等 session-b 的 follow 真开出来(正向信号),再断言「没有多开」
1213
+ await waitFor(() => sub.sessions().includes("session-b"), { label: "session-b 的 follow 开流" });
1214
+ assert.deepEqual(sub.sessions().sort(), ["session-a", "session-b"]);
1215
+ assert.equal(api.state.opens.filter((o) => o.endpoint === "session/follow").length, 2);
1216
+ } finally {
1217
+ await dispose2(sub, api);
1218
+ }
1219
+ });
1220
+
1221
+ test("发现失败不致命:session/list 报错只记 fault,$events 照常", async () => {
1222
+ const api = await startFakeDsh({ listItems: [] });
1223
+ const sub = createEventSubscriber({
1224
+ upstream: api.base,
1225
+ cookie: () => api.cookie,
1226
+ discoverIntervalMs: 0,
1227
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
1228
+ });
1229
+ try {
1230
+ const faults = [];
1231
+ const nodes = collector(sub);
1232
+ sub.on("fault", (fault) => faults.push(fault));
1233
+ // 把 session/list 换成 500:用一个只会失败的假服务端更麻烦,这里直接改 fetchImpl
1234
+ await start(sub);
1235
+ await api.waitForListCalls(1);
1236
+ assert.equal(sub.state, "ready");
1237
+
1238
+ api.pushEmit("api-session/error", ["session-x", "still works"]);
1239
+ const node = await nodes.next((n) => n.kind === NODE_KINDS.SESSION_ERROR);
1240
+ assert.equal(node.message, "still works");
1241
+ assert.equal(faults.filter((f) => f.code === "discovery-failed").length, 0);
1242
+ } finally {
1243
+ await dispose2(sub, api);
1244
+ }
1245
+ });
1246
+
1247
+ // ============================================================
1248
+ // 6/7/8. 回执:信封正确、去重、类型化失败
1249
+ // ============================================================
1250
+
1251
+ test("回执 HTTP 信封:审批与提问逐字段正确", async () => {
1252
+ const { api, sub, dispose } = await setup();
1253
+ try {
1254
+ await start(sub);
1255
+
1256
+ const approval = await sub.answerApproval("ev-a", "allowed-once");
1257
+ assert.equal(approval.ok, true);
1258
+ assert.equal(approval.status, 200);
1259
+ assert.equal(api.state.results.length, 1);
1260
+
1261
+ const posted = api.state.results[0];
1262
+ assert.equal(posted.headers["content-type"], "application/json");
1263
+ assert.equal(posted.headers.cookie, COOKIE);
1264
+ assert.deepEqual(posted.body, {
1265
+ type: "client-request",
1266
+ rpcId: posted.body.rpcId,
1267
+ method: "$events/result",
1268
+ payload: {
1269
+ args: { clientId: "client-1", eventId: "ev-a", outcome: { kind: "result", value: "allowed-once" } },
1270
+ },
1271
+ });
1272
+ assert.equal(typeof posted.body.rpcId, "string");
1273
+ assert.ok(posted.body.rpcId.length >= 8);
1274
+
1275
+ await sub.answerQuestion("ev-b", [{ id: "q1", selected: ["A"] }, { id: "q2", selected: ["B"], custom: "自定义" }]);
1276
+ assert.deepEqual(api.state.results[1].body.payload.args, {
1277
+ clientId: "client-1",
1278
+ eventId: "ev-b",
1279
+ outcome: {
1280
+ kind: "result",
1281
+ value: { answers: [{ id: "q1", selected: ["A"] }, { id: "q2", selected: ["B"], custom: "自定义" }] },
1282
+ },
1283
+ });
1284
+
1285
+ // 参数校验:审批值必须是闭集
1286
+ await assert.rejects(sub.answerApproval("ev-c", "always"), (error) => error instanceof TypeError);
1287
+ await assert.rejects(sub.answerQuestion("ev-c", [{ id: "", selected: [] }]), (error) => error instanceof TypeError);
1288
+ } finally {
1289
+ await dispose();
1290
+ }
1291
+ });
1292
+
1293
+ test("eventId 去重:第二次回执不发请求", async () => {
1294
+ const { api, sub, dispose } = await setup();
1295
+ try {
1296
+ await start(sub);
1297
+ await sub.answerApproval("ev-dup", "rejected");
1298
+ await assert.rejects(
1299
+ sub.answerApproval("ev-dup", "allowed-once"),
1300
+ (error) => error instanceof EventAnswerError && error.code === "already-answered" && error.settled === true,
1301
+ );
1302
+ await assert.rejects(sub.answer("ev-dup", { kind: "result", value: "allowed-once" }), /already-answered|已经回执/);
1303
+ assert.equal(api.state.results.length, 1);
1304
+ assert.equal(api.state.results[0].body.payload.args.outcome.value, "rejected");
1305
+ } finally {
1306
+ await dispose();
1307
+ }
1308
+ });
1309
+
1310
+ test("{ok:false,error} 变成类型化失败,而不是抛出谜题", async () => {
1311
+ const { api, sub, dispose } = await setup();
1312
+ try {
1313
+ api.setResultFor(() => ({
1314
+ ok: false,
1315
+ error: {
1316
+ code: "gateway/bad-request",
1317
+ message: "invalid Remote event result",
1318
+ details: { issues: [{ path: ["outcome"], message: "bad kind" }] },
1319
+ },
1320
+ }));
1321
+ await start(sub);
1322
+ await assert.rejects(
1323
+ sub.answerApproval("ev-bad", "rejected"),
1324
+ (error) =>
1325
+ error instanceof EventAnswerError &&
1326
+ error.code === "gateway/bad-request" &&
1327
+ error.message === "invalid Remote event result" &&
1328
+ error.settled === false &&
1329
+ Array.isArray(error.details.issues) &&
1330
+ error.details.issues[0].path[0] === "outcome",
1331
+ );
1332
+ } finally {
1333
+ await dispose();
1334
+ }
1335
+ });
1336
+
1337
+ // ============================================================
1338
+ // 9. 重连:gap 标记 + 新 clientId
1339
+ // ============================================================
1340
+
1341
+ test("重连:发 gap 标记(不假装连续),并换到新的 clientId", async () => {
1342
+ const { api, sub, dispose } = await setup();
1343
+ try {
1344
+ const nodes = collector(sub);
1345
+ const gaps = [];
1346
+ const readies = [];
1347
+ sub.on("gap", (gap) => gaps.push(gap));
1348
+ sub.on("ready", (ready) => readies.push(ready));
1349
+
1350
+ await start(sub);
1351
+ api.pushEmit("api-session/error", ["session-1", "第一次"]);
1352
+ const before = await nodes.next((n) => n.kind === NODE_KINDS.SESSION_ERROR);
1353
+
1354
+ api.dropAll();
1355
+ const gap = await once(sub, "gap");
1356
+ assert.equal(gap[0].kind, NODE_KINDS.GAP);
1357
+ assert.ok(gap[0].from >= before.at, "gap.from 不得早于最后一条已收帧");
1358
+ assert.ok(gap[0].to >= gap[0].from);
1359
+ assert.match(gap[0].reason, /socket-closed/);
1360
+
1361
+ // 自动重连(backoff 15ms)→ 新世代 ready → 新 clientId
1362
+ await waitFor(() => readies.length === 2 && sub.state === "ready", { label: "重连后的第二个 ready" });
1363
+ assert.equal(sub.state, "ready");
1364
+ assert.equal(sub.clientId, "client-2");
1365
+ assert.deepEqual(readies.map((r) => r.clientId), ["client-1", "client-2"]);
1366
+ assert.equal(gaps.length, 1);
1367
+
1368
+ // 重连后的新的 socket 依旧可用
1369
+ api.pushEmit("api-session/error", ["session-2", "第二次"]);
1370
+ const after = await nodes.next((n) => n.kind === NODE_KINDS.SESSION_ERROR);
1371
+ assert.equal(after.message, "第二次");
1372
+ } finally {
1373
+ await dispose();
1374
+ }
1375
+ });
1376
+
1377
+ // ============================================================
1378
+ // 10. 无 socket 泄漏
1379
+ // ============================================================
1380
+
1381
+ test("订阅集对账:会话结束后 follow 流被关闭(服务端确实收到 cancel)", async () => {
1382
+ const { api, sub, dispose } = await setup();
1383
+ try {
1384
+ // 🔴 这段必须**等明确信号**:连接时的 discoverRunningSessions() 会拿一份
1385
+ // `listItems: []`(本用例没配会话)去对账,凡是「list 的 startedAt 之后才来的
1386
+ // 实时 running 凭据」它都放行。若不等它落地就 push status:true,那次对账可能在
1387
+ // follow 开好之后才回来,于是把这条流按 'not-running' 关掉,而本用例要的是
1388
+ // 「status:false → 等 followSettleMs → 'idle'」。等 sessions-discovered 让它确定性。
1389
+ const discovered = once(sub, "sessions-discovered");
1390
+ await start(sub);
1391
+ await discovered;
1392
+
1393
+ api.pushEmit("api-session/status", ["session-leak", true]);
1394
+ const opened = await once(sub, "follow-opened");
1395
+ assert.deepEqual(sub.sessions(), ["session-leak"]);
1396
+
1397
+ // 会话停下 → followSettleMs(30ms) 后关流
1398
+ api.pushEmit("api-session/status", ["session-leak", false]);
1399
+ const [closed] = await once(sub, "follow-closed");
1400
+ assert.equal(closed.sessionId, "session-leak");
1401
+ assert.equal(closed.streamId, opened[0].streamId);
1402
+ assert.equal(closed.reason, "idle");
1403
+ await api.waitForCancel(opened[0].streamId);
1404
+ assert.deepEqual(sub.sessions(), []);
1405
+ assert.deepEqual(
1406
+ api.state.cancels.map((c) => ({ streamId: c.streamId, endpoint: c.endpoint })),
1407
+ [{ streamId: opened[0].streamId, endpoint: "session/follow" }],
1408
+ );
1409
+ assert.equal(api.liveFollowStreams().length, 0);
1410
+
1411
+ // removed → 立即关流
1412
+ api.pushEmit("api-session/status", ["session-rm", true]);
1413
+ const second = await once(sub, "follow-opened");
1414
+ api.pushEmit("api-session/removed", ["session-rm"]);
1415
+ const [removed] = await once(sub, "follow-closed");
1416
+ assert.equal(removed.reason, "removed");
1417
+ assert.equal(removed.streamId, second[0].streamId);
1418
+ assert.deepEqual(sub.sessions(), []);
1419
+ await api.waitForCancel(second[0].streamId);
1420
+ assert.equal(api.state.cancels.filter((c) => c.streamId === second[0].streamId).length, 1);
1421
+
1422
+ // 上限保护:maxFollowStreams
1423
+ sub.close();
1424
+ } finally {
1425
+ await dispose();
1426
+ }
1427
+ });
1428
+
1429
+ test("follow 流上限:超过 maxFollowStreams 记 fault 且不再开流", async () => {
1430
+ const { api, sub, dispose } = await setup({}, { maxFollowStreams: 1 });
1431
+ try {
1432
+ const faults = [];
1433
+ sub.on("fault", (fault) => faults.push(fault));
1434
+ await start(sub);
1435
+
1436
+ api.pushEmit("api-session/status", ["session-1", true]);
1437
+ await once(sub, "follow-opened");
1438
+ api.pushEmit("api-session/status", ["session-2", true]);
1439
+ // 等上限 fault 真的到(正向信号),再断言「没有再开流」
1440
+ await waitFor(() => faults.some((f) => f.code === "follow-limit"), { label: "follow-limit fault" });
1441
+
1442
+ assert.deepEqual(sub.sessions(), ["session-1"]);
1443
+ assert.equal(api.state.opens.filter((o) => o.endpoint === "session/follow").length, 1);
1444
+ assert.equal(faults.filter((f) => f.code === "follow-limit").length, 1);
1445
+ assert.equal(faults[0].sessionId, "session-2");
1446
+ } finally {
1447
+ await dispose();
1448
+ }
1449
+ });
1450
+
1451
+ // ============================================================
1452
+ // 11. WS 握手 401:surface + 停重连 + retry()
1453
+ // ============================================================
1454
+
1455
+ test("mux 握手 401:emit auth-error、停止重连,换 Cookie 后 retry() 恢复", async () => {
1456
+ const api = await startFakeDsh();
1457
+ const sub = createEventSubscriber({
1458
+ upstream: api.base,
1459
+ cookie: () => api.cookie,
1460
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
1461
+ });
1462
+ try {
1463
+ api.cookie = "stale-cookie";
1464
+ const authErrors = [];
1465
+ sub.on("auth-error", (error) => authErrors.push(error));
1466
+ sub.start();
1467
+
1468
+ const [authError] = await once(sub, "auth-error");
1469
+ assert.equal(authError.status, 401);
1470
+ assert.equal(authError.surface, "ws");
1471
+ assert.match(authError.message, /retry\(\)/);
1472
+ assert.equal(sub.state, "auth-failed");
1473
+
1474
+ // 不得无限重试同一个坏 Cookie
1475
+ await delay(150, null, { ref: false });
1476
+ assert.equal(api.state.connections.length, 0, "401 时服务端根本没有建立起连接");
1477
+ assert.equal(authErrors.length, 1);
1478
+
1479
+ // 换 Cookie 后 retry
1480
+ api.cookie = COOKIE;
1481
+ sub.retry();
1482
+ const ready = await sub.whenReady();
1483
+ assert.equal(ready.clientId, "client-1");
1484
+ assert.equal(sub.state, "ready");
1485
+
1486
+ // 未连接时回执必须是明确失败,而不是静默丢掉
1487
+ const other = createEventSubscriber({ upstream: api.base, cookie: () => api.cookie });
1488
+ await assert.rejects(
1489
+ other.answerApproval("ev-x", "rejected"),
1490
+ (error) => error instanceof EventAnswerError && error.code === "not-connected",
1491
+ );
1492
+ other.close();
1493
+ } finally {
1494
+ sub.close();
1495
+ await api.close();
1496
+ }
1497
+ });
1498
+
1499
+ // ============================================================
1500
+ // 12. 会话控制(一元 RPC):信封 / 类型化失败 / 超时 / throughSeq / 结论
1501
+ // ============================================================
1502
+
1503
+ const SESSION_A = "session-aaaa-0001";
1504
+
1505
+ /** session/list 的一项(真值形状:`projections.asOfSeq` + `projections.values.title`)。 */
1506
+ function listItem(sessionId, overrides = {}) {
1507
+ return {
1508
+ sessionId,
1509
+ updatedAt: 1_700_000_000_000,
1510
+ running: false,
1511
+ blank: false,
1512
+ cwd: "/tmp/work",
1513
+ projections: { asOfSeq: 12, values: { title: "标题 A" } },
1514
+ ...overrides,
1515
+ };
1516
+ }
1517
+
1518
+ /**
1519
+ * 直接 POST 一个任意 args 的一元信封。
1520
+ * 只用来**反向证明**本地假服务端像真 gateway 一样会拒掉错形状
1521
+ * (否则「我们的信封是对的」这条断言就没有意义)。
1522
+ */
1523
+ async function rawRpc(base, method, args) {
1524
+ const response = await fetch(`${base}/api/${method}`, {
1525
+ method: "POST",
1526
+ headers: { "content-type": "application/json", cookie: COOKIE },
1527
+ body: JSON.stringify({ type: "client-request", rpcId: "probe-1", method, payload: { args } }),
1528
+ });
1529
+ return response.json();
1530
+ }
1531
+
1532
+ test("一元 session/list:线参名是 _request(送 request 会被拒),投影摊平成 sessions[]", async () => {
1533
+ const { api, sub, dispose } = await setup({
1534
+ listItems: [
1535
+ listItem(SESSION_A, { running: true }),
1536
+ listItem("bbbb-2222-0002", {
1537
+ updatedAt: 7,
1538
+ blank: true,
1539
+ cwd: undefined,
1540
+ projections: { asOfSeq: 3, values: { title: null } },
1541
+ }),
1542
+ { nonsense: true }, // 形状不认识的一项:跳过而不是抛
1543
+ ],
1544
+ });
1545
+ try {
1546
+ await start(sub);
1547
+ const result = await sub.listSessions();
1548
+ assert.deepEqual(result, {
1549
+ ok: true,
1550
+ sessions: [
1551
+ {
1552
+ sessionId: SESSION_A,
1553
+ title: "标题 A",
1554
+ running: true,
1555
+ blank: false,
1556
+ cwd: "/tmp/work",
1557
+ updatedAt: 1_700_000_000_000,
1558
+ },
1559
+ // title 为 null → "";cwd 线上缺席 → 结果里也没有这个键
1560
+ { sessionId: "bbbb-2222-0002", title: "", running: false, blank: true, updatedAt: 7 },
1561
+ ],
1562
+ });
1563
+
1564
+ const posted = api.state.listBodies.at(-1);
1565
+ assert.deepEqual(posted, {
1566
+ type: "client-request",
1567
+ rpcId: posted.rpcId,
1568
+ method: SESSION_LIST_ENDPOINT,
1569
+ payload: { args: { _request: {} } },
1570
+ });
1571
+ assert.equal(Object.prototype.hasOwnProperty.call(posted.payload.args, "request"), false);
1572
+ assert.equal(typeof posted.rpcId, "string");
1573
+ assert.ok(posted.rpcId.length >= 8);
1574
+
1575
+ // 反向证明:把线参名写成 request,假服务端与真 gateway 一样拒(missing "_request")
1576
+ const probe = await rawRpc(api.base, SESSION_LIST_ENDPOINT, { request: {} });
1577
+ assert.equal(probe.result.ok, false);
1578
+ assert.equal(probe.result.error.code, "gateway/arguments-invalid");
1579
+ assert.match(probe.result.error.message, /missing "_request"/);
1580
+ } finally {
1581
+ await dispose();
1582
+ }
1583
+ });
1584
+
1585
+ test("一元:每个方法的信封与 payload.args 逐字段正确,rpcId 逐次不同", async () => {
1586
+ const { api, sub, dispose } = await setup({ listItems: [listItem(SESSION_A)] }, { discover: false });
1587
+ try {
1588
+ await start(sub);
1589
+
1590
+ assert.equal((await sub.listSessions()).ok, true);
1591
+ assert.deepEqual(await sub.createSession({ cwd: "/tmp/new", agentPreset: "default" }), {
1592
+ ok: true,
1593
+ sessionId: "session-new-0001",
1594
+ agentPreset: "default",
1595
+ });
1596
+ const prompted = await sub.promptSession({ sessionId: SESSION_A, text: "你好" });
1597
+ assert.equal(prompted.ok, true);
1598
+ assert.equal(prompted.accepted, true);
1599
+ assert.deepEqual(await sub.pageSession({ sessionId: SESSION_A }), { ok: true, records: [], hasMore: false });
1600
+ assert.deepEqual(await sub.cancelSession({ sessionId: SESSION_A }), { ok: true });
1601
+ assert.deepEqual(await sub.renameSession({ sessionId: SESSION_A, title: "新标题" }), {
1602
+ ok: true,
1603
+ title: "新标题",
1604
+ seq: 42,
1605
+ });
1606
+
1607
+ // ---- 信封:method / payload.args 线参名逐字段 ----
1608
+ assert.deepEqual(api.lastArgsOf(SESSION_CREATE_ENDPOINT), {
1609
+ request: { cwd: "/tmp/new", agentPreset: "default" },
1610
+ });
1611
+ const promptArgs = api.lastArgsOf(SESSION_PROMPT_ENDPOINT);
1612
+ assert.deepEqual(Object.keys(promptArgs), ["request"], "session/prompt 的线参名必须是 request");
1613
+ const request = promptArgs.request;
1614
+ // 四个全是必填(送 {request:{}} 会被边界校验拒掉,见下面的 probe)
1615
+ assert.deepEqual(Object.keys(request).sort(), ["content", "mode", "requestId", "sessionId"]);
1616
+ assert.equal(request.sessionId, SESSION_A);
1617
+ assert.equal(request.mode, "queue");
1618
+ assert.match(request.requestId, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
1619
+ assert.deepEqual(request.content, [{ type: "text", text: "你好" }]);
1620
+ // throughSeq 来自 session/list 的 projections.asOfSeq(本例 12)
1621
+ assert.deepEqual(api.lastArgsOf(SESSION_PAGE_ENDPOINT), {
1622
+ request: { address: { kind: "session", sessionId: SESSION_A }, throughSeq: 12, maxMessages: 40 },
1623
+ });
1624
+ assert.deepEqual(api.lastArgsOf(SESSION_CANCEL_ENDPOINT), { request: { sessionId: SESSION_A } });
1625
+ assert.deepEqual(api.lastArgsOf(SESSION_RENAME_ENDPOINT), { request: { sessionId: SESSION_A, title: "新标题" } });
1626
+ // 一元方法只带 request,不带 _request(那是 session/list 专属)
1627
+ for (const record of api.state.rpcs) {
1628
+ assert.equal(Object.prototype.hasOwnProperty.call(record.body.payload.args, "_request"), false);
1629
+ assert.equal(record.headers.cookie, COOKIE);
1630
+ assert.equal(record.headers["content-type"], "application/json");
1631
+ }
1632
+
1633
+ // ---- rpcId 逐次不同,且与流 id(s1/s2…)不是一个东西 ----
1634
+ // 本用例关掉 discover(那条链有别的用例覆盖),于是 session/list 只剩两次:
1635
+ // ① 本用例显式调的 listSessions() ② pageSession 内部为拿 throughSeq 调的那次
1636
+ // 只统计**本用例显式发起**的调用(5 个一元方法 + 上面 ①),把 ② 单独断言,
1637
+ // 这样「pageSession 会顺带查一次列表」这个实现事实被显式固化下来。
1638
+ const explicitMethods = [
1639
+ SESSION_CREATE_ENDPOINT,
1640
+ SESSION_PROMPT_ENDPOINT,
1641
+ SESSION_PAGE_ENDPOINT,
1642
+ SESSION_CANCEL_ENDPOINT,
1643
+ SESSION_RENAME_ENDPOINT,
1644
+ ];
1645
+ const explicitRpcIds = [
1646
+ api.state.listBodies[0].rpcId, // listSessions()
1647
+ ...api.state.rpcs.filter((record) => explicitMethods.includes(record.method)).map((record) => record.body.rpcId),
1648
+ ];
1649
+ assert.equal(explicitRpcIds.length, 6, "listSessions + create + prompt + page + cancel + rename");
1650
+ assert.equal(new Set(explicitRpcIds).size, explicitRpcIds.length, "rpcId 必须逐次不同");
1651
+ assert.ok(explicitRpcIds.every((id) => !/^s\d+$/.test(id) && typeof id === "string" && id !== ""));
1652
+
1653
+ // 把「pageSession 会顺带查一次 session/list 拿 throughSeq」这个实现事实**显式固化**:
1654
+ // 没有 throughSeq 就取不到历史,lastAssistantText(完成推送的结论)会整条失效。
1655
+ assert.equal(api.state.listCalls, 2, "pageSession 必须额外发一次 session/list(throughSeq 的来源)");
1656
+ assert.equal(
1657
+ api.state.rpcs.filter((record) => record.method === SESSION_LIST_ENDPOINT).length,
1658
+ 0,
1659
+ "session/list 走它自己的线参(_request),不得混进 request 那批",
1660
+ );
1661
+
1662
+ // ---- requestId:不给就生成(且逐次不同),给了就用给的 ----
1663
+ await sub.promptSession({ sessionId: SESSION_A, text: "第二条" });
1664
+ const first = api.rpcsOf(SESSION_PROMPT_ENDPOINT).at(-2).body.payload.args.request.requestId;
1665
+ const second = api.rpcsOf(SESSION_PROMPT_ENDPOINT).at(-1).body.payload.args.request.requestId;
1666
+ assert.notEqual(first, second);
1667
+ await sub.promptSession({ sessionId: SESSION_A, text: "第三条", requestId: "rid-fixed" });
1668
+ assert.equal(api.lastArgsOf(SESSION_PROMPT_ENDPOINT).request.requestId, "rid-fixed");
1669
+ assert.equal(api.lastArgsOf(SESSION_PROMPT_ENDPOINT).request.mode, "queue");
1670
+ await sub.promptSession({ sessionId: SESSION_A, text: "插队", mode: "steer" });
1671
+ assert.equal(api.lastArgsOf(SESSION_PROMPT_ENDPOINT).request.mode, "steer");
1672
+
1673
+ // 反向证明:`{request:{}}` 缺必填字段 → 假服务端拒(所以上面的全字段断言才有效)
1674
+ const probe = await rawRpc(api.base, SESSION_PROMPT_ENDPOINT, { request: {} });
1675
+ assert.equal(probe.result.ok, false);
1676
+ assert.equal(probe.result.error.code, "gateway/arguments-invalid");
1677
+ assert.match(probe.result.error.message, /requestId/);
1678
+
1679
+ // 默认 agentPreset 透传 + 空 createSession 也是合法请求
1680
+ assert.deepEqual(api.lastArgsOf(SESSION_CREATE_ENDPOINT), { request: { cwd: "/tmp/new", agentPreset: "default" } });
1681
+ assert.equal((await sub.createSession()).ok, true);
1682
+ assert.deepEqual(api.lastArgsOf(SESSION_CREATE_ENDPOINT), { request: {} });
1683
+ } finally {
1684
+ await dispose();
1685
+ }
1686
+ });
1687
+
1688
+ test("一元:{ok:false,error} 在每个方法上都变成类型化失败,而不是抛出谜题", async () => {
1689
+ const { api, sub, dispose } = await setup({ listItems: [listItem(SESSION_A)] });
1690
+ try {
1691
+ await start(sub);
1692
+
1693
+ // ① session/list 自己的失败
1694
+ api.setListFor(() => ({
1695
+ ok: false,
1696
+ error: { code: "gateway/service-unavailable", message: "list 挂了", details: { why: "list" } },
1697
+ }));
1698
+ assert.deepEqual(await sub.listSessions(), {
1699
+ ok: false,
1700
+ code: "gateway/service-unavailable",
1701
+ message: "list 挂了",
1702
+ details: { why: "list" },
1703
+ });
1704
+ api.setListFor(() => undefined); // 恢复
1705
+
1706
+ // ② 其余五个方法:同一个服务端错误 → 同一个类型化失败(细节原样带出)
1707
+ api.setRpcFor(() => ({
1708
+ ok: false,
1709
+ error: { code: "gateway/bad-request", message: "被服务端拒了", details: { issues: [{ path: ["x"] }] } },
1710
+ }));
1711
+ const results = await Promise.all([
1712
+ sub.createSession(),
1713
+ sub.promptSession({ sessionId: SESSION_A, text: "hi" }),
1714
+ sub.pageSession({ sessionId: SESSION_A }),
1715
+ sub.cancelSession({ sessionId: SESSION_A }),
1716
+ sub.renameSession({ sessionId: SESSION_A, title: "t" }),
1717
+ ]);
1718
+ for (const result of results) {
1719
+ assert.equal(result.ok, false);
1720
+ assert.equal(result.code, "gateway/bad-request");
1721
+ assert.equal(result.message, "被服务端拒了");
1722
+ assert.deepEqual(result.details, { issues: [{ path: ["x"] }] });
1723
+ }
1724
+
1725
+ // ③ lastAssistantText 同理,且 text 兜底为 ""
1726
+ const last = await sub.lastAssistantText({ sessionId: SESSION_A });
1727
+ assert.equal(last.ok, false);
1728
+ assert.equal(last.code, "gateway/bad-request");
1729
+ assert.equal(last.text, "");
1730
+ } finally {
1731
+ await dispose();
1732
+ }
1733
+ });
1734
+
1735
+ test("一元:非 JSON / 500 / 超时都不抛异常,各自变成类型化失败", async () => {
1736
+ const { api, sub, dispose } = await setup({ listItems: [listItem(SESSION_A)] }, { rpcTimeoutMs: 150 });
1737
+ try {
1738
+ await start(sub);
1739
+ // 六个方法各来一遍(pageSession 会先拉 session/list,所以 list 也要一起打桩)
1740
+ const calls = () => [
1741
+ [SESSION_LIST_ENDPOINT, () => sub.listSessions()],
1742
+ [SESSION_CREATE_ENDPOINT, () => sub.createSession()],
1743
+ [SESSION_PROMPT_ENDPOINT, () => sub.promptSession({ sessionId: SESSION_A, text: "hi" })],
1744
+ [SESSION_PAGE_ENDPOINT, () => sub.pageSession({ sessionId: SESSION_A })],
1745
+ [SESSION_CANCEL_ENDPOINT, () => sub.cancelSession({ sessionId: SESSION_A })],
1746
+ [SESSION_RENAME_ENDPOINT, () => sub.renameSession({ sessionId: SESSION_A, title: "t" })],
1747
+ ];
1748
+
1749
+ // ① HTTP 500 + 非 JSON 体
1750
+ const boom = () => ({ http: { status: 500, body: "boom", contentType: "text/plain" } });
1751
+ api.setListFor(boom);
1752
+ api.setRpcFor(boom);
1753
+ for (const [method, call] of calls()) {
1754
+ const result = await call();
1755
+ assert.equal(result.ok, false, method);
1756
+ assert.equal(result.code, "http-500", method);
1757
+ assert.match(result.message, /HTTP 500/);
1758
+ }
1759
+
1760
+ // ② HTTP 200 但体不是 JSON(解析失败 ≠ 抛出)
1761
+ const html = () => ({ http: { status: 200, body: "<html>nope</html>", contentType: "text/html" } });
1762
+ api.setListFor(html);
1763
+ api.setRpcFor(html);
1764
+ for (const [method, call] of calls()) {
1765
+ const result = await call();
1766
+ assert.equal(result.ok, false, method);
1767
+ assert.equal(result.code, "http-200", method);
1768
+ }
1769
+
1770
+ // ③ 服务端挂着不回 → 到点自己结算(rpcTimeoutMs=150),绝不把调用方挂死
1771
+ api.setListFor(() => ({ hang: true }));
1772
+ api.setRpcFor(() => ({ hang: true }));
1773
+ for (const [method, call] of calls()) {
1774
+ const startedAt = Date.now();
1775
+ const result = await call();
1776
+ assert.equal(result.ok, false, method);
1777
+ assert.equal(result.code, "timeout", method);
1778
+ assert.ok(Date.now() - startedAt < 1_500, `${method} 的超时必须及时结算`);
1779
+ }
1780
+ assert.ok(api.state.hung >= 6, `服务端应当看到被挂住的请求,实际 ${api.state.hung}`);
1781
+ assert.equal(DEFAULT_RPC_TIMEOUT_MS, 15_000);
1782
+ } finally {
1783
+ await dispose();
1784
+ }
1785
+ });
1786
+
1787
+ test("一元 session/page:throughSeq 取 session/list 的 asOfSeq(与 follow cursor 取大者),拿不到就显式失败", async () => {
1788
+ const api = await startFakeDsh({
1789
+ listItems: [listItem(SESSION_A)],
1790
+ followCursor: 30,
1791
+ });
1792
+ const sub = createEventSubscriber({
1793
+ upstream: api.base,
1794
+ cookie: () => api.cookie,
1795
+ discoverIntervalMs: 0,
1796
+ followSettleMs: 30,
1797
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
1798
+ });
1799
+ try {
1800
+ await start(sub);
1801
+
1802
+ // ① 只靠 session/list 的 projections.asOfSeq(=12)
1803
+ assert.equal((await sub.pageSession({ sessionId: SESSION_A })).ok, true);
1804
+ assert.equal(api.lastArgsOf(SESSION_PAGE_ENDPOINT).request.throughSeq, 12);
1805
+
1806
+ // ② follow 快照的 cursor(=30)更高时要用它 —— 否则这一页会缺最新的几条
1807
+ const snapshot = once(sub, "follow-snapshot");
1808
+ sub.followSession(SESSION_A);
1809
+ const [snap] = await snapshot;
1810
+ assert.equal(snap.cursor, 30);
1811
+ assert.equal((await sub.pageSession({ sessionId: SESSION_A })).ok, true);
1812
+ assert.equal(api.lastArgsOf(SESSION_PAGE_ENDPOINT).request.throughSeq, 30);
1813
+
1814
+ // ③ 两处都拿不到 → 显式失败,绝不猜魔法数字,也不发 page 请求
1815
+ api.setListItems([{ sessionId: "session-cccc", updatedAt: 1, running: false, blank: false }]); // 没有 projections
1816
+ const pageCallsBefore = api.rpcsOf(SESSION_PAGE_ENDPOINT).length;
1817
+ const result = await sub.pageSession({ sessionId: "session-cccc" });
1818
+ assert.equal(result.ok, false);
1819
+ assert.equal(result.code, "through-seq-unavailable");
1820
+ assert.match(result.message, /throughSeq 无法确定/);
1821
+ assert.equal(api.rpcsOf(SESSION_PAGE_ENDPOINT).length, pageCallsBefore, "拿不到 throughSeq 就不得发 page 请求");
1822
+
1823
+ // ④ lastAssistantText 同一条路径上的失败也必须是类型化失败 + text:""
1824
+ const last = await sub.lastAssistantText({ sessionId: "session-cccc" });
1825
+ assert.equal(last.ok, false);
1826
+ assert.equal(last.code, "through-seq-unavailable");
1827
+ assert.equal(last.text, "");
1828
+ } finally {
1829
+ sub.close();
1830
+ await api.close();
1831
+ }
1832
+ });
1833
+
1834
+ test("一元 lastAssistantText:取最后一条助手文本;空/怪形状返回 \"\"", async () => {
1835
+ const records = [
1836
+ { type: "event", event: { type: "user/message", seq: 4, time: 1, data: { role: "user" } } },
1837
+ {
1838
+ type: "event",
1839
+ event: {
1840
+ type: "assistant/message",
1841
+ seq: 5,
1842
+ time: 2,
1843
+ data: {
1844
+ turn: 1,
1845
+ step: 1,
1846
+ stream: [],
1847
+ message: {
1848
+ id: "m1",
1849
+ role: "assistant",
1850
+ source: { kind: "model", provider: "deepseek", model: "x" },
1851
+ // reasoning 不是给用户看的结论,只能取 text 块
1852
+ content: [
1853
+ { type: "reasoning", text: "想想想" },
1854
+ { type: "text", text: "第一版结论" },
1855
+ ],
1856
+ },
1857
+ },
1858
+ },
1859
+ },
1860
+ { type: "event", event: { type: "tool/call", seq: 6, time: 3, data: {} } },
1861
+ {
1862
+ type: "event",
1863
+ event: {
1864
+ type: "assistant/message",
1865
+ seq: 7,
1866
+ time: 4,
1867
+ data: {
1868
+ turn: 1,
1869
+ step: 2,
1870
+ stream: [],
1871
+ message: {
1872
+ id: "m2",
1873
+ role: "assistant",
1874
+ source: { kind: "model", provider: "deepseek", model: "x" },
1875
+ content: [{ type: "text", text: "最终结论" }],
1876
+ },
1877
+ },
1878
+ },
1879
+ },
1880
+ {
1881
+ // 只挂 usage 的空 content 结算消息(surface.js 里真实存在):不得把上一条结论清空
1882
+ type: "event",
1883
+ event: {
1884
+ type: "assistant/message",
1885
+ seq: 9,
1886
+ time: 5,
1887
+ data: {
1888
+ turn: 1,
1889
+ step: 2,
1890
+ stream: [],
1891
+ usage: { inputTokens: 1 },
1892
+ message: {
1893
+ id: "m3",
1894
+ role: "assistant",
1895
+ source: { kind: "model", provider: "deepseek", model: "x" },
1896
+ content: [],
1897
+ },
1898
+ },
1899
+ },
1900
+ },
1901
+ ];
1902
+ const { api, sub, dispose } = await setup({
1903
+ listItems: [listItem(SESSION_A)],
1904
+ rpcFor: (method) => (method === SESSION_PAGE_ENDPOINT ? { ok: true, value: { records, hasMore: false } } : undefined),
1905
+ });
1906
+ try {
1907
+ await start(sub);
1908
+ assert.deepEqual(await sub.lastAssistantText({ sessionId: SESSION_A }), { ok: true, text: "最终结论" });
1909
+ // 页请求确实按 maxMessages 走
1910
+ assert.equal(api.lastArgsOf(SESSION_PAGE_ENDPOINT).request.maxMessages, 40);
1911
+ await sub.lastAssistantText({ sessionId: SESSION_A, maxMessages: 5 });
1912
+ assert.equal(api.lastArgsOf(SESSION_PAGE_ENDPOINT).request.maxMessages, 5);
1913
+
1914
+ // 空 records → ""
1915
+ api.setRpcFor(() => ({ ok: true, value: { records: [], hasMore: false } }));
1916
+ assert.deepEqual(await sub.lastAssistantText({ sessionId: SESSION_A }), { ok: true, text: "" });
1917
+
1918
+ // 各种怪 records → "",绝不抛
1919
+ const odd = (data) => ({ event: { type: "assistant/message", data } });
1920
+ api.setRpcFor(() => ({
1921
+ ok: true,
1922
+ value: {
1923
+ records: [
1924
+ null,
1925
+ 7,
1926
+ {},
1927
+ { event: null },
1928
+ odd(null),
1929
+ odd({ message: { role: "user", content: [{ type: "text", text: "不是助手" }] } }),
1930
+ odd({ message: { role: "assistant", content: [{ type: "tool-call", id: "t", name: "n", arguments: "{}" }] } }),
1931
+ odd({ message: { role: "assistant", content: [{ type: "text", text: " " }] } }),
1932
+ ],
1933
+ hasMore: false,
1934
+ },
1935
+ }));
1936
+ assert.deepEqual(await sub.lastAssistantText({ sessionId: SESSION_A }), { ok: true, text: "" });
1937
+
1938
+ // 纯函数直接过一遍边界(含兜底字段名与多块拼接)
1939
+ assert.equal(extractLastAssistantText(undefined), "");
1940
+ assert.equal(extractLastAssistantText(null), "");
1941
+ assert.equal(extractLastAssistantText("nope"), "");
1942
+ assert.equal(extractLastAssistantText([{}, { event: { type: "assistant/message" } }]), "");
1943
+ assert.equal(
1944
+ extractLastAssistantText([
1945
+ { type: "event", event: { type: "assistant/message", data: { content: [{ type: "text", text: "兜底字段名" }] } } },
1946
+ ]),
1947
+ "兜底字段名",
1948
+ );
1949
+ assert.equal(
1950
+ extractLastAssistantText([
1951
+ {
1952
+ type: "event",
1953
+ event: {
1954
+ type: "assistant/message",
1955
+ data: { message: { role: "assistant", content: [{ type: "text", text: "甲" }, { type: "text", text: "乙" }] } },
1956
+ },
1957
+ },
1958
+ ]),
1959
+ "甲\n乙",
1960
+ );
1961
+ } finally {
1962
+ await dispose();
1963
+ }
1964
+ });
1965
+
1966
+ test("一元:本地校验(空 text / 缺 sessionId / 坏 mode…)直接类型化失败,且一个请求都不发", async () => {
1967
+ const { api, sub, dispose } = await setup({ listItems: [listItem(SESSION_A)] });
1968
+ try {
1969
+ await start(sub);
1970
+ const listCallsBefore = api.state.listCalls; // 连接时的发现会拉一次,先记下来
1971
+ const cases = [
1972
+ [() => sub.promptSession({ sessionId: SESSION_A, text: "" }), /text/],
1973
+ [() => sub.promptSession({ sessionId: SESSION_A, text: " " }), /text/],
1974
+ [() => sub.promptSession({ text: "hi" }), /sessionId/],
1975
+ [() => sub.promptSession({ sessionId: SESSION_A, text: "hi", mode: "later" }), /mode/],
1976
+ [() => sub.promptSession({ sessionId: SESSION_A, text: "hi", requestId: "" }), /requestId/],
1977
+ [() => sub.promptSession({ sessionId: SESSION_A, text: "hi", clientTimeZone: "" }), /clientTimeZone/],
1978
+ [() => sub.cancelSession({}), /sessionId/],
1979
+ [() => sub.renameSession({ sessionId: SESSION_A, title: "" }), /title/],
1980
+ [() => sub.renameSession({}), /sessionId/],
1981
+ [() => sub.pageSession({ sessionId: "" }), /sessionId/],
1982
+ [() => sub.pageSession({ sessionId: SESSION_A, maxMessages: 0 }), /maxMessages/],
1983
+ [() => sub.createSession({ cwd: "" }), /cwd/],
1984
+ [() => sub.createSession({ agentPreset: 7 }), /agentPreset/],
1985
+ [() => sub.lastAssistantText({ sessionId: "" }), /sessionId/],
1986
+ ];
1987
+ for (const [call, needle] of cases) {
1988
+ const result = await call();
1989
+ assert.equal(result.ok, false);
1990
+ assert.equal(result.code, "invalid-arguments");
1991
+ assert.match(result.message, needle);
1992
+ }
1993
+ assert.equal(api.state.rpcs.length, 0, "本地校验失败不得发出任何一元请求");
1994
+ assert.equal(api.state.listCalls, listCallsBefore, "也不得为了 throughSeq 去拉 session/list");
1995
+ } finally {
1996
+ await dispose();
1997
+ }
1998
+ });