dsh-remote-control 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +6 -0
- package/dist/bundle/client.cjs +146 -0
- package/dist/bundle/index.js +667 -264
- package/package.json +13 -6
- package/dist/src/core/clock.d.ts +0 -45
- package/dist/src/core/clock.js +0 -83
- package/dist/src/core/clock.js.map +0 -1
- package/dist/src/core/keys.d.ts +0 -98
- package/dist/src/core/keys.js +0 -176
- package/dist/src/core/keys.js.map +0 -1
- package/dist/src/core/pairing-window.d.ts +0 -95
- package/dist/src/core/pairing-window.js +0 -92
- package/dist/src/core/pairing-window.js.map +0 -1
- package/dist/src/core/runtime.d.ts +0 -104
- package/dist/src/core/runtime.js +0 -491
- package/dist/src/core/runtime.js.map +0 -1
- package/dist/src/core/sleep-policy.d.ts +0 -81
- package/dist/src/core/sleep-policy.js +0 -134
- package/dist/src/core/sleep-policy.js.map +0 -1
- package/dist/src/core/window.d.ts +0 -88
- package/dist/src/core/window.js +0 -185
- package/dist/src/core/window.js.map +0 -1
- package/dist/src/index.d.ts +0 -70
- package/dist/src/index.js +0 -882
- package/dist/src/index.js.map +0 -1
- package/dist/src/platform/carrier-services.d.ts +0 -96
- package/dist/src/platform/carrier-services.js +0 -982
- package/dist/src/platform/carrier-services.js.map +0 -1
- package/dist/src/platform/guard.d.ts +0 -60
- package/dist/src/platform/guard.js +0 -124
- package/dist/src/platform/guard.js.map +0 -1
- package/dist/src/platform/mock-kernel.d.ts +0 -95
- package/dist/src/platform/mock-kernel.js +0 -180
- package/dist/src/platform/mock-kernel.js.map +0 -1
- package/dist/src/platform/qr.d.ts +0 -28
- package/dist/src/platform/qr.js +0 -80
- package/dist/src/platform/qr.js.map +0 -1
- package/dist/src/platform/sleep-posix.d.ts +0 -17
- package/dist/src/platform/sleep-posix.js +0 -91
- package/dist/src/platform/sleep-posix.js.map +0 -1
- package/dist/src/ports/index.d.ts +0 -246
- package/dist/src/ports/index.js +0 -2
- package/dist/src/ports/index.js.map +0 -1
- package/dist/src/shell/config.d.ts +0 -56
- package/dist/src/shell/config.js +0 -212
- package/dist/src/shell/config.js.map +0 -1
- package/dist/src/shell/pairing-text.d.ts +0 -38
- package/dist/src/shell/pairing-text.js +0 -22
- package/dist/src/shell/pairing-text.js.map +0 -1
- package/dist/src/shell/status.d.ts +0 -39
- package/dist/src/shell/status.js +0 -119
- package/dist/src/shell/status.js.map +0 -1
- package/dist/src/transport/relay.d.ts +0 -107
- package/dist/src/transport/relay.js +0 -359
- package/dist/src/transport/relay.js.map +0 -1
- package/dist/tests/carrier-events.test.d.ts +0 -1
- package/dist/tests/carrier-events.test.js +0 -88
- package/dist/tests/carrier-events.test.js.map +0 -1
- package/dist/tests/carrier-services.test.d.ts +0 -1
- package/dist/tests/carrier-services.test.js +0 -562
- package/dist/tests/carrier-services.test.js.map +0 -1
- package/dist/tests/carrier-vocabulary.test.d.ts +0 -1
- package/dist/tests/carrier-vocabulary.test.js +0 -149
- package/dist/tests/carrier-vocabulary.test.js.map +0 -1
- package/dist/tests/clock-probe-timers.test.d.ts +0 -1
- package/dist/tests/clock-probe-timers.test.js +0 -73
- package/dist/tests/clock-probe-timers.test.js.map +0 -1
- package/dist/tests/command-result.test.d.ts +0 -1
- package/dist/tests/command-result.test.js +0 -185
- package/dist/tests/command-result.test.js.map +0 -1
- package/dist/tests/config.test.d.ts +0 -1
- package/dist/tests/config.test.js +0 -223
- package/dist/tests/config.test.js.map +0 -1
- package/dist/tests/guard.test.d.ts +0 -1
- package/dist/tests/guard.test.js +0 -175
- package/dist/tests/guard.test.js.map +0 -1
- package/dist/tests/ids-and-keys.test.d.ts +0 -1
- package/dist/tests/ids-and-keys.test.js +0 -211
- package/dist/tests/ids-and-keys.test.js.map +0 -1
- package/dist/tests/pairing-text.test.d.ts +0 -1
- package/dist/tests/pairing-text.test.js +0 -62
- package/dist/tests/pairing-text.test.js.map +0 -1
- package/dist/tests/pairing-window.test.d.ts +0 -1
- package/dist/tests/pairing-window.test.js +0 -165
- package/dist/tests/pairing-window.test.js.map +0 -1
- package/dist/tests/relay-client.test.d.ts +0 -1
- package/dist/tests/relay-client.test.js +0 -557
- package/dist/tests/relay-client.test.js.map +0 -1
- package/dist/tests/runtime.test.d.ts +0 -1
- package/dist/tests/runtime.test.js +0 -963
- package/dist/tests/runtime.test.js.map +0 -1
- package/dist/tests/sleep.test.d.ts +0 -1
- package/dist/tests/sleep.test.js +0 -264
- package/dist/tests/sleep.test.js.map +0 -1
- package/dist/tests/status.test.d.ts +0 -1
- package/dist/tests/status.test.js +0 -238
- package/dist/tests/status.test.js.map +0 -1
- package/dist/tests/window.test.d.ts +0 -1
- package/dist/tests/window.test.js +0 -210
- package/dist/tests/window.test.js.map +0 -1
|
@@ -1,963 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* runtime.test — `core/runtime.ts` 的 HostRuntime。
|
|
3
|
-
*
|
|
4
|
-
* 这一层是端口-适配器分层的兑现点:它**不认识 cordis、不认识 socket**,
|
|
5
|
-
* 所以审批闭环、F8 的推送义务、合帧顺序、回执对答全都能在纯内存里跑出来。
|
|
6
|
-
* 旧实现做不到(它的 36 项测试全靠手写 cordis 替身,取证 docs/DESIGN.md §5.3 与
|
|
7
|
-
* docs/legacy-spec/host-plugin-runtime.md §7.4——那张"没有任何测试"的清单里
|
|
8
|
-
* §2.2/§2.3/§3.8/§4 四条就在这里补齐)。
|
|
9
|
-
*
|
|
10
|
-
* 判据编号对应 docs/DESIGN.md §2.2:F7(updatedAt 只能是 ISO 字符串)、
|
|
11
|
-
* F8(列表推送义务)、F9(done 帧不可丢)、F10(decision 逐字)、F11(两个无会话归属的载荷)。
|
|
12
|
-
*/
|
|
13
|
-
import { test } from 'node:test';
|
|
14
|
-
import assert from 'node:assert/strict';
|
|
15
|
-
import { PAYLOAD_TYPES } from 'dsh-remote-wire';
|
|
16
|
-
import { HostRuntime } from '../src/core/runtime.js';
|
|
17
|
-
import { KeepAwake } from '../src/core/sleep-policy.js';
|
|
18
|
-
import { FakeClock } from '../src/core/clock.js';
|
|
19
|
-
/** 手机看到的会话行:`updatedAt` 刻意给 ISO 字符串(F7 的合法形态)。 */
|
|
20
|
-
const SESSIONS = [
|
|
21
|
-
{
|
|
22
|
-
id: 'ses_live',
|
|
23
|
-
state: 'running',
|
|
24
|
-
running: true,
|
|
25
|
-
title: '在跑的会话',
|
|
26
|
-
workspace: '/w/live',
|
|
27
|
-
updatedAt: '2026-10-02T08:00:00.000Z',
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
id: 'ses_arch',
|
|
31
|
-
state: 'archived',
|
|
32
|
-
running: false,
|
|
33
|
-
title: '归档会话',
|
|
34
|
-
workspace: '/w/arch',
|
|
35
|
-
updatedAt: '2026-10-01T07:30:00.000Z',
|
|
36
|
-
},
|
|
37
|
-
];
|
|
38
|
-
class FakeTransport {
|
|
39
|
-
replies = [];
|
|
40
|
-
broadcasts = [];
|
|
41
|
-
voided = [];
|
|
42
|
-
conversations = [];
|
|
43
|
-
peers = new Set();
|
|
44
|
-
reply(conversationId, payload) {
|
|
45
|
-
this.replies.push({ conversationId, payload });
|
|
46
|
-
return this.peers.has(conversationId);
|
|
47
|
-
}
|
|
48
|
-
broadcast(payload) {
|
|
49
|
-
this.broadcasts.push(payload);
|
|
50
|
-
return this.peers.size > 0 ? this.peers.size : 0;
|
|
51
|
-
}
|
|
52
|
-
hasPeer(conversationId) {
|
|
53
|
-
return this.peers.has(conversationId);
|
|
54
|
-
}
|
|
55
|
-
conversationIds() {
|
|
56
|
-
return [...this.conversations];
|
|
57
|
-
}
|
|
58
|
-
voidConversation(conversationId) {
|
|
59
|
-
this.voided.push(conversationId);
|
|
60
|
-
}
|
|
61
|
-
/** 模拟一次配对成功:有一条通道并且有活的对端。 */
|
|
62
|
-
pair(conversationId) {
|
|
63
|
-
this.conversations.push(conversationId);
|
|
64
|
-
this.peers.add(conversationId);
|
|
65
|
-
}
|
|
66
|
-
ofType(type) {
|
|
67
|
-
return this.broadcasts.filter((payload) => payload.t === type);
|
|
68
|
-
}
|
|
69
|
-
resultReplies() {
|
|
70
|
-
return this.replies
|
|
71
|
-
.map((item) => item.payload)
|
|
72
|
-
.filter((payload) => payload.t === PAYLOAD_TYPES.evResult)
|
|
73
|
-
.map((payload) => payload);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
function makeKernel() {
|
|
77
|
-
const events = [];
|
|
78
|
-
const kernel = {
|
|
79
|
-
carrier: 'fake',
|
|
80
|
-
events,
|
|
81
|
-
sink: undefined,
|
|
82
|
-
calls: { listSessions: [], sendPrompt: [], interrupt: [], ensureRunnable: [], unsubscribes: 0 },
|
|
83
|
-
failList: false,
|
|
84
|
-
throwOnSend: false,
|
|
85
|
-
rejectSendFor: undefined,
|
|
86
|
-
listing: undefined,
|
|
87
|
-
ensureRunnableImpl: undefined,
|
|
88
|
-
async listSessions(limit) {
|
|
89
|
-
kernel.calls.listSessions.push(limit);
|
|
90
|
-
if (kernel.failList)
|
|
91
|
-
throw new Error('内核读列表失败');
|
|
92
|
-
const source = kernel.listing ?? SESSIONS;
|
|
93
|
-
return source.slice(0, limit).map((summary) => ({ summary, live: summary.state === 'running' }));
|
|
94
|
-
},
|
|
95
|
-
async runState() {
|
|
96
|
-
return { running: false, state: 'idle' };
|
|
97
|
-
},
|
|
98
|
-
async sendPrompt(sessionId, text) {
|
|
99
|
-
if (kernel.throwOnSend)
|
|
100
|
-
throw new Error('内核进程里炸了');
|
|
101
|
-
if (kernel.rejectSendFor === sessionId)
|
|
102
|
-
return { ok: false, message: '会话已归档,需先恢复' };
|
|
103
|
-
kernel.calls.sendPrompt.push({ sessionId, text });
|
|
104
|
-
return { ok: true };
|
|
105
|
-
},
|
|
106
|
-
async interrupt(sessionId) {
|
|
107
|
-
kernel.calls.interrupt.push(sessionId);
|
|
108
|
-
return { ok: true };
|
|
109
|
-
},
|
|
110
|
-
subscribe(onEvent) {
|
|
111
|
-
events.push(onEvent);
|
|
112
|
-
return () => {
|
|
113
|
-
kernel.calls.unsubscribes += 1;
|
|
114
|
-
};
|
|
115
|
-
},
|
|
116
|
-
attachInteractionSink(sink) {
|
|
117
|
-
kernel.sink = sink;
|
|
118
|
-
return () => {
|
|
119
|
-
kernel.sink = undefined;
|
|
120
|
-
};
|
|
121
|
-
},
|
|
122
|
-
async ensureRunnable(sessionId) {
|
|
123
|
-
kernel.calls.ensureRunnable.push(sessionId);
|
|
124
|
-
if (kernel.ensureRunnableImpl)
|
|
125
|
-
return kernel.ensureRunnableImpl(sessionId);
|
|
126
|
-
return { ok: true };
|
|
127
|
-
},
|
|
128
|
-
describe() {
|
|
129
|
-
return { carrier: 'fake' };
|
|
130
|
-
},
|
|
131
|
-
feed(event) {
|
|
132
|
-
for (const listener of events)
|
|
133
|
-
listener(event);
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
return kernel;
|
|
137
|
-
}
|
|
138
|
-
class FakeSleepPort {
|
|
139
|
-
starts = 0;
|
|
140
|
-
stops = 0;
|
|
141
|
-
ok = true;
|
|
142
|
-
message;
|
|
143
|
-
backend() {
|
|
144
|
-
return 'fake-caffeinate';
|
|
145
|
-
}
|
|
146
|
-
platform() {
|
|
147
|
-
return 'fakeos';
|
|
148
|
-
}
|
|
149
|
-
start() {
|
|
150
|
-
this.starts += 1;
|
|
151
|
-
return { ok: this.ok, ...(this.message === undefined ? {} : { message: this.message }) };
|
|
152
|
-
}
|
|
153
|
-
stop() {
|
|
154
|
-
this.stops += 1;
|
|
155
|
-
}
|
|
156
|
-
isActive() {
|
|
157
|
-
return this.starts > this.stops;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
/** 记录 runtime 对锁做了什么:挂起审批必须"有 hold 必有 release"(旧实现泄漏的那条)。 */
|
|
161
|
-
class SpyKeepAwake extends KeepAwake {
|
|
162
|
-
holdIds = [];
|
|
163
|
-
releaseIds = [];
|
|
164
|
-
/** 此刻仍挂着的 id:释放是幂等的(stop 与结算点会各调一次),所以只能这样核对。 */
|
|
165
|
-
liveHolds = new Set();
|
|
166
|
-
enabledCalls = [];
|
|
167
|
-
hold(id) {
|
|
168
|
-
this.holdIds.push(id);
|
|
169
|
-
this.liveHolds.add(id);
|
|
170
|
-
super.hold(id);
|
|
171
|
-
}
|
|
172
|
-
releaseHold(id) {
|
|
173
|
-
this.releaseIds.push(id);
|
|
174
|
-
this.liveHolds.delete(id);
|
|
175
|
-
super.releaseHold(id);
|
|
176
|
-
}
|
|
177
|
-
setEnabled(enabled, idleReleaseSec) {
|
|
178
|
-
this.enabledCalls.push({ enabled, idleReleaseSec });
|
|
179
|
-
return super.setEnabled(enabled, idleReleaseSec);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* 组装一台"没有 cordis、没有 socket"的主机:
|
|
184
|
-
* `listingRefreshMs` 默认拉到 1 小时,免得定时刷新混进断言。
|
|
185
|
-
*/
|
|
186
|
-
function fixture(options = {}) {
|
|
187
|
-
const clock = new FakeClock();
|
|
188
|
-
const kernel = makeKernel();
|
|
189
|
-
const transport = new FakeTransport();
|
|
190
|
-
const sleepPort = new FakeSleepPort();
|
|
191
|
-
const sleep = new SpyKeepAwake(sleepPort, clock, { tickMs: 60_000, pendingRefreshMs: 60_000 });
|
|
192
|
-
const logs = [];
|
|
193
|
-
const runtime = new HostRuntime(kernel, transport, sleep, clock, {
|
|
194
|
-
listingRefreshMs: 3_600_000,
|
|
195
|
-
approvalTimeoutMs: 5_000,
|
|
196
|
-
questionTimeoutMs: 5_000,
|
|
197
|
-
log: (message) => logs.push(message),
|
|
198
|
-
...options,
|
|
199
|
-
});
|
|
200
|
-
return { clock, kernel, transport, sleep, sleepPort, runtime, logs };
|
|
201
|
-
}
|
|
202
|
-
/** 把 async 桩里的 Promise 链跑完(handleCommand 与 pushSessions 都是纯微任务)。 */
|
|
203
|
-
async function settle(times = 12) {
|
|
204
|
-
for (let i = 0; i < times; i++)
|
|
205
|
-
await Promise.resolve();
|
|
206
|
-
}
|
|
207
|
-
function cmd(t, extra) {
|
|
208
|
-
return { t, cmdId: `cmd_${t}`, ...extra };
|
|
209
|
-
}
|
|
210
|
-
test('cmd.list_sessions 必须额外推一条 ev.session_changed:ev.result.data.sessions 手机根本不看(F8)', async () => {
|
|
211
|
-
const { runtime, transport, kernel } = fixture();
|
|
212
|
-
runtime.start();
|
|
213
|
-
await settle();
|
|
214
|
-
transport.pair('c_000000000001');
|
|
215
|
-
transport.broadcasts.length = 0;
|
|
216
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdListSessions, {}), 'c_000000000001');
|
|
217
|
-
await settle();
|
|
218
|
-
const changed = transport.ofType(PAYLOAD_TYPES.evSessionChanged);
|
|
219
|
-
assert.ok(changed.length >= 1, '只回了 ev.result 就完了 → 手机 sessions.js 从 ev.session_changed 取数,列表永远空白');
|
|
220
|
-
const payload = changed[0];
|
|
221
|
-
assert.equal(payload.sessions.length, 2, 'session_changed 必须是全量快照(增量手机不会合并)');
|
|
222
|
-
assert.equal(payload.sessions[0]?.id, 'ses_live', 'sessions[].id 必须与载荷里的 sessionId 同名同值(F3/F11)');
|
|
223
|
-
const results = transport.resultReplies();
|
|
224
|
-
assert.ok(results.length >= 1, 'cmd 必须回执,否则手机等到自己超时');
|
|
225
|
-
assert.equal(results[0]?.cmdId, `cmd_${PAYLOAD_TYPES.cmdListSessions}`, 'cmdId 不回原值 → 手机认不出这是哪条命令的回执');
|
|
226
|
-
assert.equal(results[0]?.ok, true);
|
|
227
|
-
assert.deepEqual(results[0]?.data, { sessions: SESSIONS }, '回执里仍要带 data.sessions(e2e 读它,且旧实现如此)');
|
|
228
|
-
assert.ok(kernel.calls.listSessions.length >= 1, '列表必须真的去内核取过,不是拿缓存糊弄');
|
|
229
|
-
});
|
|
230
|
-
test('session_changed 里的 updatedAt 只能是 ISO 字符串:给数字会让手机整页渲染抛错并被静默吞掉(F7)', async () => {
|
|
231
|
-
const { runtime, transport } = fixture();
|
|
232
|
-
runtime.start();
|
|
233
|
-
await settle();
|
|
234
|
-
transport.pair('c_000000000001');
|
|
235
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdListSessions, {}), 'c_000000000001');
|
|
236
|
-
await settle();
|
|
237
|
-
const changed = transport.ofType(PAYLOAD_TYPES.evSessionChanged)[0];
|
|
238
|
-
assert.ok(changed.sessions.length >= 2, '快照至少要有两行才谈得上字段形状');
|
|
239
|
-
for (const session of changed.sessions) {
|
|
240
|
-
if (session.updatedAt === undefined)
|
|
241
|
-
continue; // 缺省是合法的:手机不显示时间角标
|
|
242
|
-
assert.equal(typeof session.updatedAt, 'string', `updatedAt=${String(session.updatedAt)}(类型 ${typeof session.updatedAt})→ 手机 sessions.js:138 调 .slice(11,19) 抛错`);
|
|
243
|
-
assert.match(String(session.updatedAt), /^\d{4}-\d{2}-\d{2}T/, '必须是 ISO-8601 文本,手机按字符串切时间');
|
|
244
|
-
assert.equal(Number.isNaN(Date.parse(String(session.updatedAt))), false, 'Date.parse 认不出来的字符串同样是抛错');
|
|
245
|
-
assert.ok(['idle', 'running', 'detached', 'archived', 'awaiting-permission', 'awaiting-answer'].includes(String(session.state)), `state=${String(session.state)} 不在枚举里 → 手机徽标退化成"空闲",归档会话看着像正常会话`);
|
|
246
|
-
}
|
|
247
|
-
});
|
|
248
|
-
test('ev.session_changed 与 ev.keep_awake_state 的产物里不许存在 sessionId 键(F11)', async () => {
|
|
249
|
-
const { runtime, transport } = fixture();
|
|
250
|
-
runtime.start();
|
|
251
|
-
await settle();
|
|
252
|
-
transport.pair('c_000000000001');
|
|
253
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdKeepAwake, { enabled: true }), 'c_000000000001');
|
|
254
|
-
await settle();
|
|
255
|
-
const pairs = [
|
|
256
|
-
[transport.ofType(PAYLOAD_TYPES.evSessionChanged)[0], 'ev.session_changed'],
|
|
257
|
-
[transport.ofType(PAYLOAD_TYPES.evKeepAwakeState)[0], 'ev.keep_awake_state'],
|
|
258
|
-
];
|
|
259
|
-
for (const [payload, name] of pairs) {
|
|
260
|
-
assert.ok(payload !== undefined, `${name} 根本没发出去 → 手机开关/列表停在默认态`);
|
|
261
|
-
assert.equal('sessionId' in payload, false, `${name} 带了 sessionId → 聊天页按"别的会话"把它整条丢掉`);
|
|
262
|
-
assert.equal(Array.prototype.includes.call(Object.keys(payload), 'sessionId'), false, `${name} 的键集合里出现了 sessionId`);
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
test('有对端时审批推 ev.permission_request:requestId 与 options[].id 逐字回传,reject 这个词不能改(F10)', async () => {
|
|
266
|
-
const { runtime, kernel, transport } = fixture();
|
|
267
|
-
runtime.start();
|
|
268
|
-
await settle();
|
|
269
|
-
transport.pair('c_aaaaaaaaaaaa');
|
|
270
|
-
const kernelSide = kernel.sink?.approval({ sessionId: 'ses_live', action: 'bash rm -rf', reason: '要删东西' });
|
|
271
|
-
assert.notEqual(kernelSide, undefined, '审批请求没走 InteractionSink → 手机上根本没有审批卡');
|
|
272
|
-
await settle();
|
|
273
|
-
const request = transport.replies
|
|
274
|
-
.map((item) => item.payload)
|
|
275
|
-
.find((payload) => payload.t === PAYLOAD_TYPES.evPermissionRequest);
|
|
276
|
-
assert.ok(request !== undefined, '没有对端时也该有回退路径;有对端时必须推 ev.permission_request');
|
|
277
|
-
assert.equal(request?.t, PAYLOAD_TYPES.evPermissionRequest);
|
|
278
|
-
const card = request;
|
|
279
|
-
assert.equal(card.sessionId, 'ses_live', '载荷里的 sessionId 必须是 DSH 会话 id(填错 id → 整页无输出)');
|
|
280
|
-
assert.equal(card.action, 'bash rm -rf');
|
|
281
|
-
assert.equal(card.reason, '要删东西');
|
|
282
|
-
assert.ok(typeof card.requestId === 'string' && card.requestId.length > 0, 'requestId 缺失 → 卡片能显示但审批无效(主机认不出请求)');
|
|
283
|
-
assert.deepEqual(card.options, [
|
|
284
|
-
{ id: 'approve', label: '允许一次' },
|
|
285
|
-
{ id: 'reject', label: '拒绝' },
|
|
286
|
-
], "options[].id 是手机逐字回传的值;把 'reject' 改名 → 点拒绝后界面仍显示运行中");
|
|
287
|
-
// 手机逐字回传我们下发的 id。
|
|
288
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdResolvePermission, { sessionId: 'ses_live', requestId: card.requestId, decision: 'reject' }), 'c_aaaaaaaaaaaa');
|
|
289
|
-
const decision = await kernelSide;
|
|
290
|
-
assert.equal(decision, 'rejected', "decision 'reject' 必须折成平台词汇 'rejected'");
|
|
291
|
-
assert.equal(transport.replies.filter((item) => item.payload.t === PAYLOAD_TYPES.evResult).length >= 1, true, 'resolve_permission 也必须回执');
|
|
292
|
-
});
|
|
293
|
-
test('审批选 approve 时折成 allowed-once:手机回的是 id,不是 label', async () => {
|
|
294
|
-
const { runtime, kernel, transport } = fixture();
|
|
295
|
-
runtime.start();
|
|
296
|
-
await settle();
|
|
297
|
-
transport.pair('c_bbbbbbbbbbbb');
|
|
298
|
-
const kernelSide = kernel.sink?.approval({ sessionId: 'ses_live', action: 'write file' });
|
|
299
|
-
await settle();
|
|
300
|
-
const card = transport.replies
|
|
301
|
-
.map((item) => item.payload)
|
|
302
|
-
.find((payload) => payload.t === PAYLOAD_TYPES.evPermissionRequest);
|
|
303
|
-
assert.ok(card !== undefined, '没推出审批卡');
|
|
304
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdResolvePermission, { sessionId: 'ses_live', requestId: card.requestId, decision: 'approve' }), 'c_bbbbbbbbbbbb');
|
|
305
|
-
assert.equal(await kernelSide, 'allowed-once', "'approve' 必须放行一次;返回别的值的话平台的 outcome 校验会把它当成拒绝");
|
|
306
|
-
});
|
|
307
|
-
test('一次审批的 requestId 是一次性的:重复/迟到的答案被丢弃且不许崩(M4)', async () => {
|
|
308
|
-
const { runtime, kernel, transport } = fixture();
|
|
309
|
-
runtime.start();
|
|
310
|
-
await settle();
|
|
311
|
-
transport.pair('c_cccccccccccc');
|
|
312
|
-
const kernelSide = kernel.sink?.approval({ sessionId: 'ses_live', action: 'bash' });
|
|
313
|
-
await settle();
|
|
314
|
-
const card = transport.replies
|
|
315
|
-
.map((item) => item.payload)
|
|
316
|
-
.find((payload) => payload.t === PAYLOAD_TYPES.evPermissionRequest);
|
|
317
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdResolvePermission, { sessionId: 'ses_live', requestId: card.requestId, decision: 'approve' }), 'c_cccccccccccc');
|
|
318
|
-
assert.equal(await kernelSide, 'allowed-once');
|
|
319
|
-
// 手机重发(回前台补发)与一个根本不存在的 requestId。
|
|
320
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdResolvePermission, { sessionId: 'ses_live', requestId: card.requestId, decision: 'reject' }), 'c_cccccccccccc');
|
|
321
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdResolvePermission, { sessionId: 'ses_live', requestId: 'ap_不存在', decision: 'approve' }), 'c_cccccccccccc');
|
|
322
|
-
await settle();
|
|
323
|
-
const results = transport.resultReplies();
|
|
324
|
-
assert.ok(results.length >= 3, `三条 resolve_permission 只回了 ${results.length} 条 → 手机 UI 挂在"提交中"`);
|
|
325
|
-
assert.equal(results[1]?.ok, false, '重复的 requestId 必须回 ok:false(静默 ok:true 会让手机以为又一次审批成功了)');
|
|
326
|
-
assert.equal(results[2]?.ok, false, '未知 requestId 必须回 ok:false');
|
|
327
|
-
assert.equal(typeof results[2]?.message, 'string', 'ok:false 要带可读 message,手机 toast 前 40 字');
|
|
328
|
-
});
|
|
329
|
-
test('此刻没有已配对的手机时审批返回 decline,把决定权交还桌面 UI,且不推卡片', async () => {
|
|
330
|
-
const { runtime, kernel, transport } = fixture();
|
|
331
|
-
runtime.start();
|
|
332
|
-
await settle();
|
|
333
|
-
assert.equal(transport.conversations.length, 0, '夹具自检:此刻确实没有对端');
|
|
334
|
-
const decision = await kernel.sink?.approval({ sessionId: 'ses_live', action: 'bash' });
|
|
335
|
-
assert.equal(decision, 'decline', '没对端时必须 decline(适配器据此调平台的 next());替用户决定=越权');
|
|
336
|
-
assert.equal(transport.replies.filter((item) => item.payload.t === PAYLOAD_TYPES.evPermissionRequest).length, 0, '没对端还推卡片=白发(也没人收)');
|
|
337
|
-
});
|
|
338
|
-
test('审批超时返回 decline 并配对释放锁:手机迟迟不点时不该替用户决定', async () => {
|
|
339
|
-
const { runtime, kernel, transport, clock, sleep } = fixture({ approvalTimeoutMs: 1_000 });
|
|
340
|
-
runtime.start();
|
|
341
|
-
await settle();
|
|
342
|
-
transport.pair('c_dddddddddddd');
|
|
343
|
-
const kernelSide = kernel.sink?.approval({ sessionId: 'ses_live', action: 'bash' });
|
|
344
|
-
await settle();
|
|
345
|
-
const card = transport.replies
|
|
346
|
-
.map((item) => item.payload)
|
|
347
|
-
.find((payload) => payload.t === PAYLOAD_TYPES.evPermissionRequest);
|
|
348
|
-
assert.ok(sleep.holdIds.includes(card.requestId), '挂起审批必须 hold 住锁,否则用户还没决定笔记本先睡了');
|
|
349
|
-
await clock.advance(1_001);
|
|
350
|
-
assert.equal(await kernelSide, 'decline', '超时该 decline(交还桌面),而不是自行 allowed-once');
|
|
351
|
-
assert.equal(sleep.liveHolds.size, 0, `超时的审批没释放挂锁:还挂着 ${JSON.stringify([...sleep.liveHolds])} → 旧实现正是这样泄漏的`);
|
|
352
|
-
});
|
|
353
|
-
test('手机平台撤销审批(AbortSignal)时结论是 cancelled,并且同样释放挂锁', async () => {
|
|
354
|
-
const { runtime, kernel, transport, sleep } = fixture();
|
|
355
|
-
runtime.start();
|
|
356
|
-
await settle();
|
|
357
|
-
transport.pair('c_eeeeeeeeeeee');
|
|
358
|
-
const controller = new AbortController();
|
|
359
|
-
const kernelSide = kernel.sink?.approval({ sessionId: 'ses_live', action: 'bash', signal: controller.signal });
|
|
360
|
-
await settle();
|
|
361
|
-
controller.abort();
|
|
362
|
-
assert.equal(await kernelSide, 'cancelled', '平台撤销必须转成 cancelled,不能继续挂着');
|
|
363
|
-
assert.equal(sleep.liveHolds.size, 0, '撤销路径也必须释放挂锁(hold 与 release 一一对应)');
|
|
364
|
-
});
|
|
365
|
-
test('stop() 结算所有挂起交互:返回 decline 并释放每一次挂锁', async () => {
|
|
366
|
-
const { runtime, kernel, transport, sleep } = fixture();
|
|
367
|
-
runtime.start();
|
|
368
|
-
await settle();
|
|
369
|
-
transport.pair('c_ffffffffff01');
|
|
370
|
-
const first = kernel.sink?.approval({ sessionId: 'ses_live', action: 'bash 1' });
|
|
371
|
-
const second = kernel.sink?.approval({ sessionId: 'ses_arch', action: 'bash 2' });
|
|
372
|
-
await settle();
|
|
373
|
-
assert.ok(sleep.holdIds.length >= 2, `两次审批应各挂一次锁,实际 ${sleep.holdIds.length} 次`);
|
|
374
|
-
runtime.stop();
|
|
375
|
-
assert.equal(await first, 'decline', '停机时挂着的审批必须立刻结算,不能让平台的 waterfall 永远等下去');
|
|
376
|
-
assert.equal(await second, 'decline');
|
|
377
|
-
assert.equal(sleep.liveHolds.size, 0, `停机后仍有挂锁未释放:还挂着 ${JSON.stringify([...sleep.liveHolds])}`);
|
|
378
|
-
});
|
|
379
|
-
test('cmd.answer 把手机回传的 id 反查成平台要的 label:映射错 = 桌面端解不出选项', async () => {
|
|
380
|
-
const { runtime, kernel, transport } = fixture();
|
|
381
|
-
runtime.start();
|
|
382
|
-
await settle();
|
|
383
|
-
transport.pair('c_ffffffff02');
|
|
384
|
-
const questions = [
|
|
385
|
-
{
|
|
386
|
-
id: 'q_env',
|
|
387
|
-
question: '在哪个环境跑?',
|
|
388
|
-
options: [
|
|
389
|
-
{ id: 'opt_prod', label: '生产' },
|
|
390
|
-
{ id: 'opt_test', label: '测试' },
|
|
391
|
-
],
|
|
392
|
-
},
|
|
393
|
-
{
|
|
394
|
-
id: 'q_mode',
|
|
395
|
-
question: '要不要覆盖?',
|
|
396
|
-
multi: true,
|
|
397
|
-
options: [
|
|
398
|
-
{ id: 'opt_yes', label: '覆盖' },
|
|
399
|
-
{ id: 'opt_no', label: '不覆盖' },
|
|
400
|
-
],
|
|
401
|
-
},
|
|
402
|
-
];
|
|
403
|
-
const kernelSide = kernel.sink?.question({ sessionId: 'ses_live', questions });
|
|
404
|
-
await settle();
|
|
405
|
-
const card = transport.replies
|
|
406
|
-
.map((item) => item.payload)
|
|
407
|
-
.find((payload) => payload.t === PAYLOAD_TYPES.evQuestionRequest);
|
|
408
|
-
assert.ok(card !== undefined, '提问卡没推给手机 → 桌面 UI 等着,手机看不见');
|
|
409
|
-
assert.deepEqual(card.questions, questions, 'questions[].id/question/options[].id 必须逐字下发:手机把它原样回传');
|
|
410
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdAnswer, {
|
|
411
|
-
sessionId: 'ses_live',
|
|
412
|
-
requestId: card.requestId,
|
|
413
|
-
answers: [
|
|
414
|
-
{ questionId: 'q_env', selected: ['opt_prod'] },
|
|
415
|
-
{ questionId: 'q_mode', selected: ['opt_yes', 'opt_no'], freeText: '只今天一次' },
|
|
416
|
-
],
|
|
417
|
-
}), 'c_ffffffff02');
|
|
418
|
-
const answer = await kernelSide;
|
|
419
|
-
assert.ok(answer !== null && answer !== undefined, '答案不能折成 null(提问服务没有 next 可交还,null=明确失败)');
|
|
420
|
-
assert.equal(answer.answers.length, 2, '答案条数必须与问题条数一致(顺序同 questions)');
|
|
421
|
-
assert.equal(answer.answers[0]?.id, 'q_env', 'answers[].id 是 questionId:平台靠它对号入座');
|
|
422
|
-
assert.deepEqual(answer.answers[0]?.selected, ['生产'], 'selected 必须是**选项 label**:给 id 的话平台侧找不到选项');
|
|
423
|
-
assert.deepEqual(answer.answers[1]?.selected, ['覆盖', '不覆盖'], '多选的每个 id 都要各自反查成 label,且保持手机给的顺序');
|
|
424
|
-
assert.equal(answer.answers[1]?.custom, '只今天一次', 'freeText 是整卡共享的,折成 custom 交给平台');
|
|
425
|
-
const results = transport.resultReplies();
|
|
426
|
-
assert.ok(results.length >= 1, 'cmd.answer 必须回执');
|
|
427
|
-
assert.equal(results[results.length - 1]?.data?.answered, card.requestId);
|
|
428
|
-
});
|
|
429
|
-
test('提问没人应答时返回 null(适配器必须转成明确失败,绝不静默挂住 agent)', async () => {
|
|
430
|
-
const { runtime, kernel, clock } = fixture({ questionTimeoutMs: 1_000 });
|
|
431
|
-
runtime.start();
|
|
432
|
-
await settle();
|
|
433
|
-
assert.equal(runtime.handleCommand !== undefined, true, '夹具自检:runtime 已起');
|
|
434
|
-
const kernelSide = kernel.sink?.question({ sessionId: 'ses_live', questions: [{ id: 'q1', question: '跑哪个?' }] });
|
|
435
|
-
assert.notEqual(kernelSide, undefined, '提问必须走 InteractionSink');
|
|
436
|
-
const answer = await kernelSide;
|
|
437
|
-
assert.equal(answer, null, '此刻没有已配对的手机:提问必须是明确失败(null),不能挂住 agent,也不能替用户答');
|
|
438
|
-
await clock.advance(0);
|
|
439
|
-
});
|
|
440
|
-
test('提问超时同样返回 null,并且释放挂锁', async () => {
|
|
441
|
-
const { runtime, kernel, transport, sleep, clock } = fixture({ questionTimeoutMs: 1_000 });
|
|
442
|
-
runtime.start();
|
|
443
|
-
await settle();
|
|
444
|
-
transport.pair('c_ffffffff03');
|
|
445
|
-
const kernelSide = kernel.sink?.question({ sessionId: 'ses_live', questions: [{ id: 'q1', question: '跑哪个?' }] });
|
|
446
|
-
await settle();
|
|
447
|
-
assert.equal(sleep.holdIds.length >= 1, true, '挂起的提问同样要 hold 锁');
|
|
448
|
-
await clock.advance(1_001);
|
|
449
|
-
assert.equal(await kernelSide, null, '超时=没人应答,必须明确失败');
|
|
450
|
-
assert.equal(sleep.liveHolds.size, 0, '提问超时也要释放挂锁');
|
|
451
|
-
});
|
|
452
|
-
test('cmd.keep_awake 关掉时不带 idleReleaseSec:带了就会覆盖用户设定的空闲阈值', async () => {
|
|
453
|
-
const { runtime, transport, sleep } = fixture();
|
|
454
|
-
runtime.start();
|
|
455
|
-
await settle();
|
|
456
|
-
transport.pair('c_ffffffff04');
|
|
457
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdKeepAwake, { enabled: true, idleReleaseSec: 0 }), 'c_ffffffff04');
|
|
458
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdKeepAwake, { enabled: false }), 'c_ffffffff04');
|
|
459
|
-
await settle();
|
|
460
|
-
assert.deepEqual(sleep.enabledCalls, [
|
|
461
|
-
{ enabled: true, idleReleaseSec: 0 },
|
|
462
|
-
{ enabled: false, idleReleaseSec: undefined },
|
|
463
|
-
], '关闭时手机不带这个字段,runtime 也不许凭空造一个(0 会被当成"永不自动释放")');
|
|
464
|
-
const states = transport.ofType(PAYLOAD_TYPES.evKeepAwakeState);
|
|
465
|
-
assert.ok(states.length >= 2, `keep_awake 状态只推了 ${states.length} 条 → 手机开关回显错误`);
|
|
466
|
-
const last = states[states.length - 1];
|
|
467
|
-
assert.equal(last.enabled, false, '关掉后广播的快照必须回显 enabled:false');
|
|
468
|
-
assert.equal(typeof last.platform, 'string', 'platform 是 e2e 断言字段,缺了这些测试会红');
|
|
469
|
-
assert.equal(typeof last.backend, 'string', 'backend 名字要在:它区分"没锁"与"锁不了"');
|
|
470
|
-
const results = transport.resultReplies();
|
|
471
|
-
assert.ok(results.length >= 2, '两条 keep_awake 命令都要回执');
|
|
472
|
-
assert.equal(results[results.length - 1]?.cmdId, `cmd_${PAYLOAD_TYPES.cmdKeepAwake}`, 'cmdId 必须原样回传');
|
|
473
|
-
});
|
|
474
|
-
test('命令异常必须回 ev.result{ok:false} 且 cmdId 原样:回执对答靠它,漏发就静默卡住', async () => {
|
|
475
|
-
const { runtime, kernel, transport } = fixture();
|
|
476
|
-
runtime.start();
|
|
477
|
-
await settle();
|
|
478
|
-
transport.pair('c_ffffffff05');
|
|
479
|
-
kernel.throwOnSend = true;
|
|
480
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSendPrompt, { sessionId: 'ses_live', text: '你好' }), 'c_ffffffff05');
|
|
481
|
-
const results = transport.resultReplies();
|
|
482
|
-
assert.ok(results.length >= 1, '内核抛异常时一条回执都没有 → 手机输入框永久禁用,等到自己超时');
|
|
483
|
-
assert.equal(results[0]?.cmdId, `cmd_${PAYLOAD_TYPES.cmdSendPrompt}`, '异常兜底的 cmdId 也必须回原值(回错了手机匹配不到)');
|
|
484
|
-
assert.equal(results[0]?.ok, false, '异常必须是 ok:false');
|
|
485
|
-
assert.match(String(results[0]?.message), /内核进程里炸了/, 'message 要带上底层原因,手机 toast 只显示前 40 字');
|
|
486
|
-
// 业务失败(不是异常)同样回 ok:false + message,而且指令没有真的进内核。
|
|
487
|
-
kernel.throwOnSend = false;
|
|
488
|
-
kernel.rejectSendFor = 'ses_arch';
|
|
489
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSendPrompt, { sessionId: 'ses_arch', text: 'x' }), 'c_ffffffff05');
|
|
490
|
-
await settle();
|
|
491
|
-
const results2 = transport.resultReplies();
|
|
492
|
-
assert.ok(results2.length >= 2, `业务失败没回执(只有 ${results2.length} 条)→ 手机以为指令发出去了`);
|
|
493
|
-
const biz = results2.at(-1);
|
|
494
|
-
assert.equal(biz?.cmdId, `cmd_${PAYLOAD_TYPES.cmdSendPrompt}`, '回执必须回原 cmdId:手机按 id 查表,查不到就丢弃');
|
|
495
|
-
assert.equal(biz?.ok, false, 'ok:false 有两种来源(业务拒绝/主机异常),手机文案层要按 message 渲染,不能一律当成网络错误');
|
|
496
|
-
assert.match(String(biz?.message), /需先恢复/, '业务拒绝要带具体 message');
|
|
497
|
-
});
|
|
498
|
-
test('回执之后的会话状态变化必须额外推 session_changed:发指令/中断/审批都是(F8)', async () => {
|
|
499
|
-
const { runtime, transport, clock } = fixture();
|
|
500
|
-
runtime.start();
|
|
501
|
-
await settle();
|
|
502
|
-
transport.pair('c_ffffffff06');
|
|
503
|
-
for (const [type, extra] of [
|
|
504
|
-
[PAYLOAD_TYPES.cmdSendPrompt, { sessionId: 'ses_live', text: '跑一下' }],
|
|
505
|
-
[PAYLOAD_TYPES.cmdInterrupt, { sessionId: 'ses_live' }],
|
|
506
|
-
[PAYLOAD_TYPES.cmdListSessions, {}],
|
|
507
|
-
]) {
|
|
508
|
-
await clock.advance(600); // 越过相邻推送的合并窗口(500ms),见 pushSessions
|
|
509
|
-
transport.broadcasts.length = 0;
|
|
510
|
-
await runtime.handleCommand(cmd(type, extra), 'c_ffffffff06');
|
|
511
|
-
await settle();
|
|
512
|
-
const changed = transport.ofType(PAYLOAD_TYPES.evSessionChanged);
|
|
513
|
-
assert.ok(changed.length >= 1, `${type} 之后没有推 ev.session_changed → 手机列表停在旧状态,最长等 15 秒`);
|
|
514
|
-
const reason = changed[0].reason;
|
|
515
|
-
assert.equal(typeof reason, 'string', 'session_changed 要带 reason,排错时区分是谁触发的推送');
|
|
516
|
-
}
|
|
517
|
-
});
|
|
518
|
-
test('相邻 500ms 内的重复列表推送被合并(全量快照,合并中间几次不丢信息)', async () => {
|
|
519
|
-
const { runtime, kernel, clock, transport } = fixture();
|
|
520
|
-
runtime.start();
|
|
521
|
-
await settle();
|
|
522
|
-
transport.pair('c_ffffffff07');
|
|
523
|
-
await clock.advance(1_000);
|
|
524
|
-
transport.broadcasts.length = 0;
|
|
525
|
-
kernel.feed({ kind: 'sessions-changed', reason: 'a' });
|
|
526
|
-
await settle();
|
|
527
|
-
kernel.feed({ kind: 'sessions-changed', reason: 'b' });
|
|
528
|
-
await settle();
|
|
529
|
-
const changed = transport.ofType(PAYLOAD_TYPES.evSessionChanged);
|
|
530
|
-
assert.ok(changed.length >= 1, '第一次推送必须出去');
|
|
531
|
-
assert.equal(changed.length, 1, `同一次 500ms 内推了 ${changed.length} 条全量列表:合并规则没生效,吵闹的内核会把帧率打满`);
|
|
532
|
-
await clock.advance(1_000);
|
|
533
|
-
kernel.feed({ kind: 'sessions-changed', reason: 'c' });
|
|
534
|
-
await settle();
|
|
535
|
-
assert.equal(transport.ofType(PAYLOAD_TYPES.evSessionChanged).length >= 2, true, '过了合并窗口就该再推:否则最后一次变化永远丢了');
|
|
536
|
-
});
|
|
537
|
-
test('被合并掉的那一次必须变成尾随推送补发:合并 ≠ 丢弃(F8 的最后一环)', async () => {
|
|
538
|
-
const { runtime, kernel, clock, transport } = fixture();
|
|
539
|
-
runtime.start();
|
|
540
|
-
await settle();
|
|
541
|
-
transport.pair('c_ffffffff09');
|
|
542
|
-
await clock.advance(1_000);
|
|
543
|
-
transport.broadcasts.length = 0;
|
|
544
|
-
kernel.feed({ kind: 'sessions-changed', reason: 'a' });
|
|
545
|
-
await settle();
|
|
546
|
-
// 第二次的列表内容与第一次不同:只有这样才分得出"补发的是新快照"还是"根本没补发"。
|
|
547
|
-
kernel.listing = [
|
|
548
|
-
{
|
|
549
|
-
id: 'ses_new',
|
|
550
|
-
state: 'idle',
|
|
551
|
-
running: false,
|
|
552
|
-
title: '刚结束的那次',
|
|
553
|
-
workspace: '/w/new',
|
|
554
|
-
updatedAt: '2026-10-02T09:00:00.000Z',
|
|
555
|
-
},
|
|
556
|
-
];
|
|
557
|
-
kernel.feed({ kind: 'sessions-changed', reason: 'b' });
|
|
558
|
-
await settle();
|
|
559
|
-
const during = transport.ofType(PAYLOAD_TYPES.evSessionChanged);
|
|
560
|
-
assert.equal(during.length, 1, `窗口内出了 ${during.length} 条:合并没有生效`);
|
|
561
|
-
assert.ok(JSON.stringify(during[0]).includes('在跑的会话'), '第一条应该是合并前的快照');
|
|
562
|
-
await clock.advance(600); // 越过合并窗口
|
|
563
|
-
const after = transport.ofType(PAYLOAD_TYPES.evSessionChanged);
|
|
564
|
-
assert.equal(after.length, 2, `尾随推送没补发(总共 ${after.length} 条):最后那次变化永远没人告知手机,列表停在旧状态直到 15 秒兜底刷新`);
|
|
565
|
-
assert.ok(JSON.stringify(after[1]).includes('刚结束的那次'), '补发必须是**最新**快照,不是合并时刻那份旧数据');
|
|
566
|
-
// 补发过一次就收手:定时器不许自我续期成"每 500ms 广播一次全量列表"。
|
|
567
|
-
await clock.advance(5_000);
|
|
568
|
-
assert.equal(transport.ofType(PAYLOAD_TYPES.evSessionChanged).length, 2, '尾随推送之后又凭空多推:合并窗口变成了周期性广播');
|
|
569
|
-
});
|
|
570
|
-
test('stop() 之后尾随推送不许再发:拆了 socket 就不能再往中继写东西', async () => {
|
|
571
|
-
const { runtime, kernel, clock, transport } = fixture();
|
|
572
|
-
runtime.start();
|
|
573
|
-
await settle();
|
|
574
|
-
transport.pair('c_ffffffff10');
|
|
575
|
-
await clock.advance(1_000);
|
|
576
|
-
kernel.feed({ kind: 'sessions-changed', reason: 'a' });
|
|
577
|
-
await settle();
|
|
578
|
-
kernel.feed({ kind: 'sessions-changed', reason: 'b' }); // 这条被合并 → 装了尾随定时器
|
|
579
|
-
await settle();
|
|
580
|
-
transport.broadcasts.length = 0;
|
|
581
|
-
runtime.stop();
|
|
582
|
-
await clock.advance(5_000);
|
|
583
|
-
assert.equal(transport.ofType(PAYLOAD_TYPES.evSessionChanged).length, 0, '停机后尾随定时器还在广播:中继那边 socket 已经关了');
|
|
584
|
-
});
|
|
585
|
-
test('一条带正文的 done 必须把正文带上(内核没有 token 级增量:正文与 done 同到)', async () => {
|
|
586
|
-
// 真机取证:`assistant/message` 是"一条消息一帧完整文本 + done",而第一版只走 complete(),
|
|
587
|
-
// complete 对缓冲区里没有这条消息的情形发的是 done-only 帧 → 回复被整个丢掉,
|
|
588
|
-
// 手机上"⑧ 流式输出到达 / ⑨ 有 done"两条断言照样绿,屏幕上一个字都没有。
|
|
589
|
-
const { runtime, kernel, transport, clock } = fixture();
|
|
590
|
-
runtime.start();
|
|
591
|
-
await settle();
|
|
592
|
-
transport.pair('c_vtext01');
|
|
593
|
-
await clock.advance(1_000);
|
|
594
|
-
transport.broadcasts.length = 0;
|
|
595
|
-
kernel.feed({
|
|
596
|
-
kind: 'delta',
|
|
597
|
-
sessionId: 'ses_live',
|
|
598
|
-
messageId: 'msg_v',
|
|
599
|
-
text: '当前有 0 个提醒。',
|
|
600
|
-
role: 'assistant',
|
|
601
|
-
done: true,
|
|
602
|
-
});
|
|
603
|
-
await clock.advance(200);
|
|
604
|
-
await settle();
|
|
605
|
-
const frames = transport.broadcasts.filter((payload) => payload.t === PAYLOAD_TYPES.evMessageDelta);
|
|
606
|
-
assert.equal(frames.length, 1, `正文与 done 被拆成了 ${frames.length} 帧(应为 1 帧合并)或整条被吞`);
|
|
607
|
-
const only = frames[0];
|
|
608
|
-
assert.equal(only.delta, '当前有 0 个提醒。', '回复文本丢了:手机上就是一个字都没有的空消息');
|
|
609
|
-
assert.equal(only.done, true, 'done 必须同帧到达');
|
|
610
|
-
assert.equal(only.messageId, 'msg_v');
|
|
611
|
-
assert.equal(only.role, 'assistant');
|
|
612
|
-
});
|
|
613
|
-
test('非 delta 事件之前先 flush 同会话缓冲:出站类型序列必须严格是 message_delta → tool_event → message_delta(F9)', async () => {
|
|
614
|
-
const { runtime, kernel, transport, clock } = fixture();
|
|
615
|
-
runtime.start();
|
|
616
|
-
await settle();
|
|
617
|
-
transport.pair('c_ffffffff08');
|
|
618
|
-
await clock.advance(1_000);
|
|
619
|
-
transport.broadcasts.length = 0;
|
|
620
|
-
kernel.feed({ kind: 'delta', sessionId: 'ses_live', messageId: 'msg_1', text: '我先说明一下', done: false });
|
|
621
|
-
kernel.feed({ kind: 'tool', sessionId: 'ses_live', callId: 'call_1', phase: 'started', tool: 'bash', title: 'echo' });
|
|
622
|
-
kernel.feed({ kind: 'delta', sessionId: 'ses_live', messageId: 'msg_1', text: '然后是补充', done: false });
|
|
623
|
-
kernel.feed({ kind: 'delta', sessionId: 'ses_live', messageId: 'msg_1', text: '', done: true });
|
|
624
|
-
await settle();
|
|
625
|
-
const types = transport.broadcasts.map((payload) => payload.t);
|
|
626
|
-
assert.deepEqual(types, [PAYLOAD_TYPES.evMessageDelta, PAYLOAD_TYPES.evToolEvent, PAYLOAD_TYPES.evMessageDelta], `出站顺序是 ${JSON.stringify(types)}:工具行插到了它自己的文本前面 → 手机先看到工具调用、后看到调用前的说明`);
|
|
627
|
-
const texts = transport
|
|
628
|
-
.ofType(PAYLOAD_TYPES.evMessageDelta)
|
|
629
|
-
.map((payload) => payload.delta);
|
|
630
|
-
assert.deepEqual(texts, ['我先说明一下', '然后是补充'], '文本必须按到达顺序分段出去,一个字符都不能少');
|
|
631
|
-
const last = transport.ofType(PAYLOAD_TYPES.evMessageDelta).at(-1);
|
|
632
|
-
assert.equal(last.done, true, '收尾的 done 帧不许丢:丢了手机永远转圈');
|
|
633
|
-
assert.equal(last.messageId, 'msg_1', '同一条消息切帧不许换 messageId');
|
|
634
|
-
});
|
|
635
|
-
test('空文本的 done 帧穿过 runtime 也绝不丢(F9 在事件入口这一侧)', async () => {
|
|
636
|
-
const { runtime, kernel, transport, clock } = fixture();
|
|
637
|
-
runtime.start();
|
|
638
|
-
await settle();
|
|
639
|
-
transport.pair('c_ffffffff09');
|
|
640
|
-
await clock.advance(1_000);
|
|
641
|
-
transport.broadcasts.length = 0;
|
|
642
|
-
// 内核 turn/end 的真实形状:先一条没产生任何文本的 done(纯工具轮次),再 ev.run_state。
|
|
643
|
-
kernel.feed({ kind: 'delta', sessionId: 'ses_live', messageId: 'turn', text: '', done: true });
|
|
644
|
-
await settle();
|
|
645
|
-
const deltas = transport.ofType(PAYLOAD_TYPES.evMessageDelta);
|
|
646
|
-
assert.ok(deltas.length >= 1, 'done-only 帧在 runtime 入口就被丢了 → 手机为这一条消息一直转圈');
|
|
647
|
-
const frame = deltas[0];
|
|
648
|
-
assert.equal(frame.done, true);
|
|
649
|
-
assert.equal(frame.delta, '', "delta 必须是空串:手机对 undefined 做 += 会拼出字面量 'undefined'");
|
|
650
|
-
assert.equal(frame.messageId, 'turn', 'messageId 逐字回传(手机用它定位消息行)');
|
|
651
|
-
});
|
|
652
|
-
test('run-state 事件必须先 flush 缓冲再推 ev.run_state,并额外推一次会话列表(F8/F9)', async () => {
|
|
653
|
-
const { runtime, kernel, transport, clock } = fixture();
|
|
654
|
-
runtime.start();
|
|
655
|
-
await settle();
|
|
656
|
-
transport.pair('c_ffffffff0a');
|
|
657
|
-
await clock.advance(1_000);
|
|
658
|
-
transport.broadcasts.length = 0;
|
|
659
|
-
kernel.feed({ kind: 'delta', sessionId: 'ses_live', messageId: 'msg_1', text: '半句', done: false });
|
|
660
|
-
kernel.feed({ kind: 'run-state', sessionId: 'ses_live', state: 'idle', detail: 'completed' });
|
|
661
|
-
await settle();
|
|
662
|
-
const types = transport.broadcasts.map((payload) => payload.t);
|
|
663
|
-
assert.deepEqual(types, [
|
|
664
|
-
PAYLOAD_TYPES.evMessageDelta,
|
|
665
|
-
PAYLOAD_TYPES.evRunState,
|
|
666
|
-
PAYLOAD_TYPES.evSessionChanged,
|
|
667
|
-
PAYLOAD_TYPES.evKeepAwakeState,
|
|
668
|
-
], `实际出站序列 ${JSON.stringify(types)}:run_state 之前必须先把同会话的文本冲出去,之后补一次列表与锁状态`);
|
|
669
|
-
const runState = transport.ofType(PAYLOAD_TYPES.evRunState)[0];
|
|
670
|
-
assert.equal(runState.state, 'idle', "run_state 只有 'running'/'idle' 会让手机收起挂着的卡片");
|
|
671
|
-
assert.equal(runState.detail, 'completed', 'detail 是 UI 不读、测试断言的字段');
|
|
672
|
-
assert.equal(runState.sessionId, 'ses_live', '载荷里的 sessionId 必须是 DSH 会话 id(F3)');
|
|
673
|
-
});
|
|
674
|
-
test('title 与 sessions-changed 事件都要推列表:手机上标题/归档标记的唯一来源(F7/F8)', async () => {
|
|
675
|
-
const { runtime, kernel, transport, clock } = fixture();
|
|
676
|
-
runtime.start();
|
|
677
|
-
await settle();
|
|
678
|
-
transport.pair('c_ffffffff0b');
|
|
679
|
-
await clock.advance(1_000);
|
|
680
|
-
transport.broadcasts.length = 0;
|
|
681
|
-
kernel.feed({ kind: 'title', sessionId: 'ses_live', title: '新标题' });
|
|
682
|
-
await settle();
|
|
683
|
-
assert.ok(transport.ofType(PAYLOAD_TYPES.evSessionChanged).length >= 1, '改标题不推列表 → 手机列表上的标题永远是旧的');
|
|
684
|
-
await clock.advance(1_000);
|
|
685
|
-
transport.broadcasts.length = 0;
|
|
686
|
-
kernel.feed({ kind: 'sessions-changed', reason: 'created' });
|
|
687
|
-
await settle();
|
|
688
|
-
const changed = transport.ofType(PAYLOAD_TYPES.evSessionChanged)[0];
|
|
689
|
-
assert.equal(changed.reason, 'created', '内核给的原因要透出来,排错时才知道是谁推的');
|
|
690
|
-
assert.equal(changed.sessions.length, 2, '会话新建后推的必须是全量快照');
|
|
691
|
-
});
|
|
692
|
-
test('内核读列表失败时不许把手机上的列表清成空白:保留上一次已知的快照(M25)', async () => {
|
|
693
|
-
const { runtime, kernel, transport, clock, logs } = fixture();
|
|
694
|
-
runtime.start();
|
|
695
|
-
await settle();
|
|
696
|
-
transport.pair('c_ffffffff0c');
|
|
697
|
-
await clock.advance(1_000);
|
|
698
|
-
const good = transport.ofType(PAYLOAD_TYPES.evSessionChanged).at(-1);
|
|
699
|
-
assert.equal(good.sessions.length, 2, '夹具自检:先拿到一份非空列表');
|
|
700
|
-
kernel.failList = true;
|
|
701
|
-
await clock.advance(1_000);
|
|
702
|
-
transport.broadcasts.length = 0;
|
|
703
|
-
kernel.feed({ kind: 'sessions-changed', reason: '抖动' });
|
|
704
|
-
await settle();
|
|
705
|
-
const after = transport.ofType(PAYLOAD_TYPES.evSessionChanged).at(-1);
|
|
706
|
-
assert.ok(after !== undefined, '读失败也不能一条都不发:手机至少要看得到锁状态那一侧');
|
|
707
|
-
assert.equal(after.sessions.length, 2, '内核抖一次就广播空列表 → 手机上的会话列表被清空,比"这一轮没更新"糟得多');
|
|
708
|
-
assert.ok(logs.some((line) => line === 'list sessions failed'), '失败要留痕:否则"列表为什么没更新"没有答案');
|
|
709
|
-
});
|
|
710
|
-
test('start() 时内核订阅失败只能降级,绝不向外抛(apply() 的红线:插件不许带崩宿主)', async () => {
|
|
711
|
-
const clock = new FakeClock();
|
|
712
|
-
const transport = new FakeTransport();
|
|
713
|
-
const sleepPort = new FakeSleepPort();
|
|
714
|
-
const sleep = new KeepAwake(sleepPort, clock);
|
|
715
|
-
const broken = {
|
|
716
|
-
carrier: 'broken',
|
|
717
|
-
async listSessions() {
|
|
718
|
-
throw new Error('没有这个服务');
|
|
719
|
-
},
|
|
720
|
-
async runState() {
|
|
721
|
-
return { running: false, state: 'idle' };
|
|
722
|
-
},
|
|
723
|
-
async sendPrompt() {
|
|
724
|
-
return { ok: false, message: '没接上' };
|
|
725
|
-
},
|
|
726
|
-
async interrupt() {
|
|
727
|
-
return { ok: false, message: '没接上' };
|
|
728
|
-
},
|
|
729
|
-
subscribe() {
|
|
730
|
-
throw new Error('on() 炸了');
|
|
731
|
-
},
|
|
732
|
-
describe() {
|
|
733
|
-
return { carrier: 'broken' };
|
|
734
|
-
},
|
|
735
|
-
};
|
|
736
|
-
const logs = [];
|
|
737
|
-
const runtime = new HostRuntime(broken, transport, sleep, clock, {
|
|
738
|
-
listingRefreshMs: 3_600_000,
|
|
739
|
-
log: (m) => logs.push(m),
|
|
740
|
-
});
|
|
741
|
-
assert.doesNotThrow(() => {
|
|
742
|
-
runtime.start();
|
|
743
|
-
}, '内核面缺失时 start() 抛了出去 → 用户的 DSH 进程起不来');
|
|
744
|
-
await settle();
|
|
745
|
-
assert.ok(logs.some((line) => line === 'kernel subscribe failed'), '订阅失败要留痕,不能静默');
|
|
746
|
-
runtime.stop();
|
|
747
|
-
});
|
|
748
|
-
test('stop() 先把缓冲里的尾句挤出去,再退订:顺序反了尾句就永久丢失(M24)', async () => {
|
|
749
|
-
const { runtime, kernel, transport, clock } = fixture();
|
|
750
|
-
runtime.start();
|
|
751
|
-
await settle();
|
|
752
|
-
transport.pair('c_ffffffff0d');
|
|
753
|
-
await clock.advance(1_000);
|
|
754
|
-
transport.broadcasts.length = 0;
|
|
755
|
-
kernel.feed({ kind: 'delta', sessionId: 'ses_live', messageId: 'msg_last', text: '尾句', done: false });
|
|
756
|
-
assert.equal(transport.ofType(PAYLOAD_TYPES.evMessageDelta).length, 0, '夹具自检:尾句还在 120ms 窗口里');
|
|
757
|
-
runtime.stop();
|
|
758
|
-
const tail = transport.ofType(PAYLOAD_TYPES.evMessageDelta);
|
|
759
|
-
assert.ok(tail.length >= 1, '停机没把缓冲冲出去 → 用户最后看到的那段输出直接蒸发');
|
|
760
|
-
assert.equal(tail[0].delta, '尾句');
|
|
761
|
-
assert.ok(tail[0] !== undefined && 'done' in tail[0] === false, '停机不是消息收尾,不许伪造 done');
|
|
762
|
-
});
|
|
763
|
-
test('归档会话发指令前自动恢复:ensureRunnable 必须先于 sendPrompt,且这个行为可以关掉', async () => {
|
|
764
|
-
const auto = fixture();
|
|
765
|
-
auto.runtime.start();
|
|
766
|
-
await settle();
|
|
767
|
-
auto.transport.pair('c_ffffffff0e');
|
|
768
|
-
await auto.runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSendPrompt, { sessionId: 'ses_arch', text: '继续' }), 'c_ffffffff0e');
|
|
769
|
-
assert.deepEqual(auto.kernel.calls.ensureRunnable, ['ses_arch'], '没先恢复归档会话就发指令 → 宿主 gate 直接拒掉,手机只看到一个看不懂的失败');
|
|
770
|
-
assert.deepEqual(auto.kernel.calls.sendPrompt, [{ sessionId: 'ses_arch', text: '继续' }], '恢复之后必须真的把指令送进去');
|
|
771
|
-
const off = fixture({ unarchiveOnPrompt: false });
|
|
772
|
-
off.runtime.start();
|
|
773
|
-
await settle();
|
|
774
|
-
off.transport.pair('c_ffffffff0f');
|
|
775
|
-
await off.runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSendPrompt, { sessionId: 'ses_arch', text: '继续' }), 'c_ffffffff0f');
|
|
776
|
-
assert.deepEqual(off.kernel.calls.ensureRunnable, [], "配置说不恢复就不许动归档状态('配置说不就不做')");
|
|
777
|
-
assert.equal(off.kernel.calls.sendPrompt.length, 1, '关掉自动恢复时指令照发,由宿主自己决定收不收');
|
|
778
|
-
});
|
|
779
|
-
test('恢复失败时把底层原因回给手机,且不再发指令:手机 toast 的唯一内容来源', async () => {
|
|
780
|
-
const { runtime, kernel, transport } = fixture();
|
|
781
|
-
runtime.start();
|
|
782
|
-
await settle();
|
|
783
|
-
transport.pair('c_ffffffff10');
|
|
784
|
-
kernel.ensureRunnableImpl = async () => ({ ok: false, message: '底层:归档盘不可读' });
|
|
785
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSendPrompt, { sessionId: 'ses_arch', text: '继续' }), 'c_ffffffff10');
|
|
786
|
-
const result = transport.resultReplies().at(-1);
|
|
787
|
-
assert.equal(result?.ok, false, '恢复失败必须 ok:false,不能假装发出去了');
|
|
788
|
-
assert.match(String(result?.message), /归档盘不可读/, '底层原因要透出来,否则"为什么没反应"没有答案');
|
|
789
|
-
assert.equal(kernel.calls.sendPrompt.length, 0, '恢复失败还继续发指令 → 宿主 gate 抛的错会更难懂');
|
|
790
|
-
});
|
|
791
|
-
test('挂起审批期间每 pendingRefreshMs 续锁,且 runtime 侧的 hold 与 release 严格配对(旧实现的 60s 心跳永不释放)', async () => {
|
|
792
|
-
const { runtime, kernel, transport, sleep, sleepPort, clock } = fixture({ approvalTimeoutMs: 2 * 3_600_000 });
|
|
793
|
-
sleep.setEnabled(true, 300); // 空闲 300 秒就该放锁
|
|
794
|
-
runtime.start();
|
|
795
|
-
await settle();
|
|
796
|
-
transport.pair('c_ffffffff11');
|
|
797
|
-
sleepPort.stops = 0; // 从这一刻起只观察"放锁"次数
|
|
798
|
-
const kernelSide = kernel.sink?.approval({ sessionId: 'ses_live', action: 'bash' });
|
|
799
|
-
await settle();
|
|
800
|
-
const card = transport.replies
|
|
801
|
-
.map((item) => item.payload)
|
|
802
|
-
.find((payload) => payload.t === PAYLOAD_TYPES.evPermissionRequest);
|
|
803
|
-
assert.ok(sleep.holdIds.includes(card.requestId), '挂起审批必须按 requestId 挂一次锁,否则用户还没决定笔记本先睡了');
|
|
804
|
-
assert.equal(sleep.snapshot().active, true, '挂起期间必须正在持锁');
|
|
805
|
-
// 审批挂着 30 分钟(没超时):远超 300 秒的空闲阈值。旧实现在这里会因"事件里没有 requestId"
|
|
806
|
-
// 而永远找不到释放点,把锁拖到进程结束;本实现靠 pendingRefreshMs 续锁 + releaseHold 放锁。
|
|
807
|
-
await clock.advance(30 * 60_000);
|
|
808
|
-
assert.equal(sleepPort.stops, 0, `审批还挂着就把锁放了(stops=${sleepPort.stops})→ 手机上按钮还在,机器已经睡了`);
|
|
809
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdResolvePermission, { sessionId: 'ses_live', requestId: card.requestId, decision: 'approve' }), 'c_ffffffff11');
|
|
810
|
-
assert.equal(await kernelSide, 'allowed-once');
|
|
811
|
-
assert.equal(sleep.liveHolds.size, 0, `手机答完之后仍有挂锁没释放:还挂着 ${JSON.stringify([...sleep.liveHolds])}`);
|
|
812
|
-
// 答案之后没有活动:空闲阈值必须重新生效(旧实现是永久心跳)。
|
|
813
|
-
// 360s 而不是 301s:节拍的粒度是 tickMs=60s,判据是严格大于,留出一次可观测的节拍。
|
|
814
|
-
await clock.advance(360_000);
|
|
815
|
-
assert.ok(sleepPort.stops >= 1, '审批结束后锁必须能靠空闲自动释放:这就是"这台机器一次远程审批过就再也睡不了"那条事故');
|
|
816
|
-
});
|
|
817
|
-
// ── 历史:手机打开会话时要读到主机上已有的内容 ──────────────────────
|
|
818
|
-
test('cmd.session_history:条目走与实时流同一批出站构造器,游标原样带回去', async () => {
|
|
819
|
-
const { runtime, transport, kernel } = fixture();
|
|
820
|
-
const asked = [];
|
|
821
|
-
kernel.readHistory = (sessionId, options) => {
|
|
822
|
-
asked.push({ sessionId, ...options });
|
|
823
|
-
return Promise.resolve({
|
|
824
|
-
events: [
|
|
825
|
-
{ kind: 'delta', sessionId, messageId: 'm1', text: '看看完成度', role: 'user', done: true },
|
|
826
|
-
{ kind: 'tool', sessionId, callId: 'c1', phase: 'completed', tool: 'Bash', resultPreview: 'total 88' },
|
|
827
|
-
{ kind: 'delta', sessionId, messageId: 'm2', text: '结构清楚。', role: 'assistant', done: true },
|
|
828
|
-
],
|
|
829
|
-
nextBeforeSeq: 9,
|
|
830
|
-
});
|
|
831
|
-
};
|
|
832
|
-
runtime.start();
|
|
833
|
-
await settle();
|
|
834
|
-
transport.pair('c_000000000001');
|
|
835
|
-
transport.broadcasts.length = 0;
|
|
836
|
-
transport.replies.length = 0;
|
|
837
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSessionHistory, { sessionId: 'ses_live', beforeSeq: 42, limit: 3 }), 'c_000000000001');
|
|
838
|
-
await settle();
|
|
839
|
-
// 历史是**回执**不是广播:广播会把整份历史发给每一条配对通道
|
|
840
|
-
const pages = transport.replies
|
|
841
|
-
.map((item) => item.payload)
|
|
842
|
-
.filter((payload) => payload.t === PAYLOAD_TYPES.evSessionHistory);
|
|
843
|
-
assert.equal(pages.length, 1, `历史页必须只回给发起者(实得 ${pages.length} 条)`);
|
|
844
|
-
const page = pages[0];
|
|
845
|
-
assert.equal(page.sessionId, 'ses_live', 'F3:载荷里的 sessionId 是 DSH 会话 id,必须逐字回原值');
|
|
846
|
-
assert.equal(page.cmdId, `cmd_${PAYLOAD_TYPES.cmdSessionHistory}`, 'cmdId 不回原值 → 手机认不出这是哪次请求的页');
|
|
847
|
-
assert.deepEqual(asked, [{ sessionId: 'ses_live', beforeSeq: 42, limit: 3 }], '游标与条数必须逐字传给内核端口');
|
|
848
|
-
assert.deepEqual(page.items.map((item) => item.t), ['ev.message_delta', 'ev.tool_event', 'ev.message_delta'], '条目顺序必须保持(升序),否则手机上整段历史是倒着讲的');
|
|
849
|
-
assert.equal(page.nextBeforeSeq, 9, '有更早的一页就必须把游标带回去,否则「加载更早」永远点不动');
|
|
850
|
-
// 运行态不许混进历史:回放历史不该去改顶栏。类型上本来就写不出来(条目只允许两种叶子载荷),
|
|
851
|
-
// 这里再对**产物**查一遍——schema 是运行期才拦的,而漏掉它只会静默生效。
|
|
852
|
-
assert.equal(JSON.stringify(page.items).includes('run_state'), false);
|
|
853
|
-
});
|
|
854
|
-
test('cmd.session_history:内核没有这个能力时明确拒绝,绝不回一张空页', async () => {
|
|
855
|
-
const { runtime, transport, kernel } = fixture();
|
|
856
|
-
runtime.start();
|
|
857
|
-
await settle();
|
|
858
|
-
transport.pair('c_000000000001');
|
|
859
|
-
transport.broadcasts.length = 0;
|
|
860
|
-
transport.replies.length = 0;
|
|
861
|
-
assert.equal(kernel.readHistory, undefined, '夹具有 readHistory 的话这条断言就失去意义了');
|
|
862
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSessionHistory, { sessionId: 'ses_live' }), 'c_000000000001');
|
|
863
|
-
await settle();
|
|
864
|
-
const pages = transport.replies.map((item) => item.payload).filter((p) => p.t === PAYLOAD_TYPES.evSessionHistory);
|
|
865
|
-
assert.equal(pages.length, 0, '能力缺失却回了一张空页 → 手机上显示"这个会话没内容",一个会让人查错方向的假事实');
|
|
866
|
-
const results = transport.resultReplies();
|
|
867
|
-
assert.equal(results[0]?.ok, false);
|
|
868
|
-
assert.match(String(results[0]?.message ?? ''), /历史/, '拒绝原因必须点名"历史",否则手机上只有一句看不懂的话');
|
|
869
|
-
});
|
|
870
|
-
test('cmd.session_history:读历史时炸了也必须回执失败(手机不能对着转圈干等)', async () => {
|
|
871
|
-
const { runtime, transport, kernel } = fixture();
|
|
872
|
-
kernel.readHistory = () => Promise.reject(new Error('这一代宿主不能读会话历史'));
|
|
873
|
-
runtime.start();
|
|
874
|
-
await settle();
|
|
875
|
-
transport.pair('c_000000000001');
|
|
876
|
-
transport.replies.length = 0;
|
|
877
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSessionHistory, { sessionId: 'ses_live' }), 'c_000000000001');
|
|
878
|
-
await settle();
|
|
879
|
-
const results = transport.resultReplies();
|
|
880
|
-
assert.equal(results.length, 1);
|
|
881
|
-
assert.equal(results[0]?.ok, false);
|
|
882
|
-
assert.equal(results[0]?.cmdId, `cmd_${PAYLOAD_TYPES.cmdSessionHistory}`);
|
|
883
|
-
assert.match(String(results[0]?.message ?? ''), /不能读会话历史/);
|
|
884
|
-
});
|
|
885
|
-
test('cmd.session_history:不带游标 = 要最新一页(不该把 beforeSeq 塞成 undefined 传给内核)', async () => {
|
|
886
|
-
const { runtime, transport, kernel } = fixture();
|
|
887
|
-
let seen;
|
|
888
|
-
kernel.readHistory = (sessionId, options) => {
|
|
889
|
-
seen = { sessionId, ...options };
|
|
890
|
-
return Promise.resolve({ events: [] });
|
|
891
|
-
};
|
|
892
|
-
runtime.start();
|
|
893
|
-
await settle();
|
|
894
|
-
transport.pair('c_000000000001');
|
|
895
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdSessionHistory, { sessionId: 'ses_live' }), 'c_000000000001');
|
|
896
|
-
await settle();
|
|
897
|
-
assert.deepEqual(seen, { sessionId: 'ses_live', limit: 40 }, '省掉 beforeSeq 才是"最新一页";多一个 undefined 键会让内核侧的判空走岔');
|
|
898
|
-
});
|
|
899
|
-
// ── 新建会话:手机上那个「+ 新建」 ──────────────────────────────────
|
|
900
|
-
test('cmd.new_session:回执带上主机分配的 id,并额外推一次列表(F8)', async () => {
|
|
901
|
-
const { runtime, transport, kernel, clock } = fixture();
|
|
902
|
-
let calls = 0;
|
|
903
|
-
kernel.newSession = () => {
|
|
904
|
-
calls += 1;
|
|
905
|
-
return Promise.resolve({ ok: true, sessionId: 'ses_made' });
|
|
906
|
-
};
|
|
907
|
-
runtime.start();
|
|
908
|
-
await settle();
|
|
909
|
-
transport.pair('c_000000000001');
|
|
910
|
-
// 越过合并窗口再动手:否则这一次列表推送会被合并成尾随推送(窗口是 500ms 的防抖),
|
|
911
|
-
// 而这条用例要看的是"新建之后有没有推列表",不是"防抖还灵不灵"(那条另有用例)。
|
|
912
|
-
await clock.advance(1_000);
|
|
913
|
-
transport.broadcasts.length = 0;
|
|
914
|
-
transport.replies.length = 0;
|
|
915
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdNewSession, {}), 'c_000000000001');
|
|
916
|
-
await settle();
|
|
917
|
-
const results = transport.resultReplies();
|
|
918
|
-
assert.equal(results.length, 1, '新建必须回执一次且只一次');
|
|
919
|
-
assert.equal(results[0]?.ok, true);
|
|
920
|
-
assert.equal(results[0]?.cmdId, `cmd_${PAYLOAD_TYPES.cmdNewSession}`, 'cmdId 不回原值 → 手机认不出这是哪次新建的结果');
|
|
921
|
-
assert.equal(results[0]?.data?.sessionId, 'ses_made', '新会话的 id 必须回给手机:它是接下来所有命令的 sessionId');
|
|
922
|
-
assert.equal(calls, 1);
|
|
923
|
-
// F8:状态变了就要额外推一次列表。不推的话,用户新建完回到列表,那条会话最长要等
|
|
924
|
-
// 15 秒的兜底刷新才出现 —— 表现就是"新建了但列表没变"。
|
|
925
|
-
const changed = transport.ofType(PAYLOAD_TYPES.evSessionChanged);
|
|
926
|
-
assert.ok(changed.length >= 1, '新建之后没有额外推 session_changed → 列表停在旧快照');
|
|
927
|
-
});
|
|
928
|
-
test('cmd.new_session:内核没有这个能力时明确拒绝(不许回一个编出来的 id)', async () => {
|
|
929
|
-
const { runtime, transport, kernel } = fixture();
|
|
930
|
-
runtime.start();
|
|
931
|
-
await settle();
|
|
932
|
-
transport.pair('c_000000000001');
|
|
933
|
-
transport.broadcasts.length = 0;
|
|
934
|
-
transport.replies.length = 0;
|
|
935
|
-
assert.equal(kernel.newSession, undefined, '夹具有 newSession 的话这条断言就失去意义了');
|
|
936
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdNewSession, {}), 'c_000000000001');
|
|
937
|
-
await settle();
|
|
938
|
-
const results = transport.resultReplies();
|
|
939
|
-
assert.equal(results.length, 1);
|
|
940
|
-
assert.equal(results[0]?.ok, false);
|
|
941
|
-
assert.equal(results[0]?.data?.sessionId, undefined, '拒绝时绝不能带 id');
|
|
942
|
-
assert.match(String(results[0]?.message ?? ''), /新建会话/, '拒绝原因必须点名"新建会话",否则手机上只有一句看不懂的话');
|
|
943
|
-
});
|
|
944
|
-
test('cmd.new_session:创建炸了也回执失败,且不推列表(手机不能对着转圈干等)', async () => {
|
|
945
|
-
const { runtime, transport, kernel, clock } = fixture();
|
|
946
|
-
kernel.newSession = () => Promise.reject(new Error('workspace 挂不上'));
|
|
947
|
-
runtime.start();
|
|
948
|
-
await settle();
|
|
949
|
-
transport.pair('c_000000000001');
|
|
950
|
-
// 同样越过合并窗口:这样"零条 session_changed"才是"没推",而不是"被防抖吃了"。
|
|
951
|
-
await clock.advance(1_000);
|
|
952
|
-
transport.broadcasts.length = 0;
|
|
953
|
-
transport.replies.length = 0;
|
|
954
|
-
await runtime.handleCommand(cmd(PAYLOAD_TYPES.cmdNewSession, {}), 'c_000000000001');
|
|
955
|
-
await settle();
|
|
956
|
-
await clock.advance(1_000); // 就算有尾随推送也该落地了
|
|
957
|
-
const results = transport.resultReplies();
|
|
958
|
-
assert.equal(results.length, 1);
|
|
959
|
-
assert.equal(results[0]?.ok, false);
|
|
960
|
-
assert.match(String(results[0]?.message ?? ''), /workspace 挂不上/, '内核给的原因要原样透传,别换一句更含糊的');
|
|
961
|
-
assert.equal(transport.ofType(PAYLOAD_TYPES.evSessionChanged).length, 0, '没造出会话却推列表 = 让手机去刷新一个没变的东西');
|
|
962
|
-
});
|
|
963
|
-
//# sourceMappingURL=runtime.test.js.map
|