@happyvertical/smrt-chat 0.51.5 → 0.51.6
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/AGENTS.md +19 -0
- package/dist/index.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/smrt-knowledge.json +5 -5
- package/dist/svelte/components/agent/ToolCallDisplay.svelte +103 -7
- package/dist/svelte/components/agent/ToolCallDisplay.svelte.d.ts +18 -0
- package/dist/svelte/components/agent/ToolCallDisplay.svelte.d.ts.map +1 -1
- package/dist/svelte/components/agent/__tests__/ToolCallDisplay.test.js +100 -9
- package/dist/svelte/components/assistant/AssistantComposer.svelte +354 -0
- package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts +20 -0
- package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/AssistantDock.svelte +534 -0
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts +30 -0
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/AssistantThreadList.svelte +136 -0
- package/dist/svelte/components/assistant/AssistantThreadList.svelte.d.ts +15 -0
- package/dist/svelte/components/assistant/AssistantThreadList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/__tests__/AssistantComposer.test.js +75 -0
- package/dist/svelte/components/assistant/__tests__/AssistantDock.test.js +395 -0
- package/dist/svelte/components/assistant/__tests__/AssistantThreadList.test.js +45 -0
- package/dist/svelte/components/assistant/__tests__/action-status.test.js +25 -0
- package/dist/svelte/components/assistant/__tests__/assistant-transport.test.js +253 -0
- package/dist/svelte/components/assistant/__tests__/attachment-href.test.js +31 -0
- package/dist/svelte/components/assistant/__tests__/create-assistant-dock-controller.test.js +2492 -0
- package/dist/svelte/components/assistant/action-status.d.ts +14 -0
- package/dist/svelte/components/assistant/action-status.d.ts.map +1 -0
- package/dist/svelte/components/assistant/action-status.js +7 -0
- package/dist/svelte/components/assistant/assistant-transport.d.ts +239 -0
- package/dist/svelte/components/assistant/assistant-transport.d.ts.map +1 -0
- package/dist/svelte/components/assistant/assistant-transport.js +306 -0
- package/dist/svelte/components/assistant/attachment-href.d.ts +23 -0
- package/dist/svelte/components/assistant/attachment-href.d.ts.map +1 -0
- package/dist/svelte/components/assistant/attachment-href.js +36 -0
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts +172 -0
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.js +0 -0
- package/dist/svelte/components/shared/ModelPicker.svelte +84 -0
- package/dist/svelte/components/shared/ModelPicker.svelte.d.ts +26 -0
- package/dist/svelte/components/shared/ModelPicker.svelte.d.ts.map +1 -0
- package/dist/svelte/components/shared/__tests__/ModelPicker.test.js +36 -0
- package/dist/svelte/i18n.d.ts +21 -0
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +25 -0
- package/dist/svelte/index.d.ts +7 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +12 -0
- package/package.json +11 -11
|
@@ -0,0 +1,2492 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data-surface';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createInMemoryAssistantTransport, } from '../assistant-transport.js';
|
|
5
|
+
import { createAssistantDockController } from '../create-assistant-dock-controller.svelte.js';
|
|
6
|
+
// A hand-rolled transport (not the stock in-memory one) for the finding-A
|
|
7
|
+
// poll-resolution tests: gives full control over exactly what loadMessages()
|
|
8
|
+
// returns on each poll, independent of when/whether sendMessage() resolves.
|
|
9
|
+
function scriptedTransport(seed = {}) {
|
|
10
|
+
const store = new Map(Object.entries(seed).map(([threadId, msgs]) => [threadId, [...msgs]]));
|
|
11
|
+
const threads = new Map();
|
|
12
|
+
for (const threadId of store.keys()) {
|
|
13
|
+
threads.set(threadId, {
|
|
14
|
+
id: threadId,
|
|
15
|
+
title: threadId,
|
|
16
|
+
isResolved: false,
|
|
17
|
+
messageCount: store.get(threadId)?.length ?? 0,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
let counter = 0;
|
|
21
|
+
const transport = {
|
|
22
|
+
async listThreads() {
|
|
23
|
+
return Array.from(threads.values());
|
|
24
|
+
},
|
|
25
|
+
async createThread(title) {
|
|
26
|
+
const id = `thread-${++counter}`;
|
|
27
|
+
const thread = {
|
|
28
|
+
id,
|
|
29
|
+
title,
|
|
30
|
+
isResolved: false,
|
|
31
|
+
messageCount: 0,
|
|
32
|
+
};
|
|
33
|
+
threads.set(id, thread);
|
|
34
|
+
store.set(id, []);
|
|
35
|
+
return thread;
|
|
36
|
+
},
|
|
37
|
+
async loadMessages(threadId) {
|
|
38
|
+
return [...(store.get(threadId) ?? [])];
|
|
39
|
+
},
|
|
40
|
+
async sendMessage(input) {
|
|
41
|
+
const list = store.get(input.threadId) ?? [];
|
|
42
|
+
const userMessage = {
|
|
43
|
+
id: `msg-${++counter}`,
|
|
44
|
+
threadId: input.threadId,
|
|
45
|
+
content: input.content,
|
|
46
|
+
role: 'user',
|
|
47
|
+
createdAt: new Date(),
|
|
48
|
+
clientRequestId: input.clientRequestId,
|
|
49
|
+
};
|
|
50
|
+
list.push(userMessage);
|
|
51
|
+
store.set(input.threadId, list);
|
|
52
|
+
// Always reports inProgress: the test drives resolution purely
|
|
53
|
+
// through what a later loadMessages() poll returns, by pushing an
|
|
54
|
+
// assistant reply onto `store` directly.
|
|
55
|
+
return { inProgress: true, userMessage };
|
|
56
|
+
},
|
|
57
|
+
async uploadAttachment(file) {
|
|
58
|
+
return { id: `att-${++counter}`, name: file.name };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return { transport, store };
|
|
62
|
+
}
|
|
63
|
+
function pushAssistantReply(store, threadId, content) {
|
|
64
|
+
const list = store.get(threadId) ?? [];
|
|
65
|
+
list.push({
|
|
66
|
+
id: `reply-${list.length}`,
|
|
67
|
+
threadId,
|
|
68
|
+
content,
|
|
69
|
+
role: 'assistant',
|
|
70
|
+
createdAt: new Date(),
|
|
71
|
+
});
|
|
72
|
+
store.set(threadId, list);
|
|
73
|
+
}
|
|
74
|
+
// A real registry (not the static fakeRegistry below) so 'unregistered'
|
|
75
|
+
// events actually fire, for the F2 invalidation test.
|
|
76
|
+
function realRegistryWithSurface(surfaceId, subject = { type: 'tenant', id: 'tenant-a' }) {
|
|
77
|
+
const identity = {
|
|
78
|
+
surfaceId,
|
|
79
|
+
kind: 'table',
|
|
80
|
+
subject,
|
|
81
|
+
};
|
|
82
|
+
const descriptor = {
|
|
83
|
+
version: 1,
|
|
84
|
+
identity,
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
label: surfaceId,
|
|
87
|
+
rowKey: 'id',
|
|
88
|
+
columns: [
|
|
89
|
+
{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' },
|
|
90
|
+
],
|
|
91
|
+
query: {
|
|
92
|
+
modes: ['rows'],
|
|
93
|
+
projectableColumnIds: ['id'],
|
|
94
|
+
searchableColumnIds: [],
|
|
95
|
+
filterableColumnIds: [],
|
|
96
|
+
sortableColumnIds: [],
|
|
97
|
+
},
|
|
98
|
+
actions: [],
|
|
99
|
+
controls: [],
|
|
100
|
+
limits: { maxQueryRows: 10, maxQueryBytes: 10_000, maxSelectionSize: 10 },
|
|
101
|
+
};
|
|
102
|
+
const registry = createDataSurfaceRegistry();
|
|
103
|
+
const unregister = registry.register({
|
|
104
|
+
descriptor,
|
|
105
|
+
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
106
|
+
});
|
|
107
|
+
return { registry, identity, unregister };
|
|
108
|
+
}
|
|
109
|
+
function fakeRegistry(descriptors = []) {
|
|
110
|
+
const listeners = new Set();
|
|
111
|
+
return {
|
|
112
|
+
register: vi.fn(() => () => { }),
|
|
113
|
+
unregister: vi.fn(),
|
|
114
|
+
list: () => descriptors.map((d) => ({
|
|
115
|
+
version: 1,
|
|
116
|
+
identity: {
|
|
117
|
+
surfaceId: d.surfaceId,
|
|
118
|
+
kind: d.kind,
|
|
119
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
120
|
+
},
|
|
121
|
+
kind: d.kind,
|
|
122
|
+
title: d.surfaceId,
|
|
123
|
+
columns: [],
|
|
124
|
+
actions: [],
|
|
125
|
+
limits: {
|
|
126
|
+
maxQueryRows: 100,
|
|
127
|
+
maxQueryBytes: 100_000,
|
|
128
|
+
maxSelectionSize: 100,
|
|
129
|
+
},
|
|
130
|
+
})),
|
|
131
|
+
inspect: () => undefined,
|
|
132
|
+
execute: vi.fn(),
|
|
133
|
+
validateQuery: vi.fn(),
|
|
134
|
+
validateAction: vi.fn(),
|
|
135
|
+
subscribe: (listener) => {
|
|
136
|
+
listeners.add(listener);
|
|
137
|
+
return () => listeners.delete(listener);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
describe('createAssistantDockController', () => {
|
|
142
|
+
it('fails closed with an empty registry: no mounted surfaces', async () => {
|
|
143
|
+
const controller = createAssistantDockController({
|
|
144
|
+
transport: createInMemoryAssistantTransport(),
|
|
145
|
+
registry: fakeRegistry([]),
|
|
146
|
+
});
|
|
147
|
+
expect(controller.surfaces).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
it('exposes mounted surfaces from the registry', () => {
|
|
150
|
+
const controller = createAssistantDockController({
|
|
151
|
+
transport: createInMemoryAssistantTransport(),
|
|
152
|
+
registry: fakeRegistry([{ surfaceId: 'orders', kind: 'table' }]),
|
|
153
|
+
});
|
|
154
|
+
expect(controller.surfaces).toHaveLength(1);
|
|
155
|
+
expect(controller.surfaces[0].surfaceId).toBe('orders');
|
|
156
|
+
});
|
|
157
|
+
it('rejects an action preview client-side when the target surface is not mounted', async () => {
|
|
158
|
+
const controller = createAssistantDockController({
|
|
159
|
+
transport: createInMemoryAssistantTransport(),
|
|
160
|
+
registry: fakeRegistry([]),
|
|
161
|
+
});
|
|
162
|
+
await controller.previewAction({
|
|
163
|
+
version: 1,
|
|
164
|
+
requestId: 'req-1',
|
|
165
|
+
identity: {
|
|
166
|
+
surfaceId: 'orders',
|
|
167
|
+
kind: 'table',
|
|
168
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
169
|
+
},
|
|
170
|
+
actionId: 'archive',
|
|
171
|
+
phase: 'preview',
|
|
172
|
+
selection: { scope: 'current-page' },
|
|
173
|
+
});
|
|
174
|
+
const state = controller.actions.get('req-1');
|
|
175
|
+
expect(state?.status).toBe('failed');
|
|
176
|
+
expect(state?.error).toMatch(/not mounted/);
|
|
177
|
+
});
|
|
178
|
+
it('reuses the same clientRequestId across two send() calls for the same draft while it is still unresolved (F5)', async () => {
|
|
179
|
+
const seenIds = [];
|
|
180
|
+
const transport = createInMemoryAssistantTransport({
|
|
181
|
+
// Every send for this thread reports inProgress — the draft must stay
|
|
182
|
+
// "unresolved" from doSend's point of view across both calls.
|
|
183
|
+
simulateInProgressOnce: false,
|
|
184
|
+
});
|
|
185
|
+
// simulateInProgressOnce only covers the FIRST send per thread in the
|
|
186
|
+
// stock in-memory transport; force every call to report inProgress so
|
|
187
|
+
// this test exercises the truly-unresolved window F5 is about.
|
|
188
|
+
const originalSend = transport.sendMessage.bind(transport);
|
|
189
|
+
transport.sendMessage = async (input) => {
|
|
190
|
+
seenIds.push(input.clientRequestId);
|
|
191
|
+
return { inProgress: true, userMessage: undefined };
|
|
192
|
+
};
|
|
193
|
+
void originalSend;
|
|
194
|
+
const controller = createAssistantDockController({
|
|
195
|
+
transport,
|
|
196
|
+
registry: fakeRegistry([]),
|
|
197
|
+
});
|
|
198
|
+
const thread = await controller.createThread('t1');
|
|
199
|
+
await controller.openThread(thread.id);
|
|
200
|
+
await controller.send('hello');
|
|
201
|
+
expect(controller.pendingSends[0]?.status).toBe('processing');
|
|
202
|
+
// A second send() for the identical (threadId, content) draft while the
|
|
203
|
+
// first is still unresolved (inProgress) must observe the SAME
|
|
204
|
+
// clientRequestId at the transport — this is what
|
|
205
|
+
// docs/assistant-dock.md claims mirrors PortalChatTool.svelte:304-322.
|
|
206
|
+
// Before the F5 fix, doSend cleared draftIds even on the inProgress
|
|
207
|
+
// branch, so this second call minted a brand-new id.
|
|
208
|
+
await controller.send('hello');
|
|
209
|
+
expect(seenIds).toHaveLength(2);
|
|
210
|
+
expect(seenIds[0]).toBe(seenIds[1]);
|
|
211
|
+
});
|
|
212
|
+
// Copilot PR #2919 jAwwB: the draft key was (threadId, content) only,
|
|
213
|
+
// even though attachments are part of the send input. After an
|
|
214
|
+
// in-progress send('same text', [A]) cleared the composer, a second
|
|
215
|
+
// send('same text', [B]) before the first resolved reused the SAME
|
|
216
|
+
// clientRequestId — a deduplicating transport returned the first
|
|
217
|
+
// request's cached result and silently dropped attachment B.
|
|
218
|
+
it('a second send() with the same text but a DIFFERENT attachment set mints a new clientRequestId', async () => {
|
|
219
|
+
const seenAttachmentSets = [];
|
|
220
|
+
const transport = createInMemoryAssistantTransport({
|
|
221
|
+
simulateInProgressOnce: false,
|
|
222
|
+
});
|
|
223
|
+
transport.sendMessage = async (input) => {
|
|
224
|
+
seenAttachmentSets.push((input.attachments ?? []).map((a) => a.id));
|
|
225
|
+
return { inProgress: true, userMessage: undefined };
|
|
226
|
+
};
|
|
227
|
+
const controller = createAssistantDockController({
|
|
228
|
+
transport,
|
|
229
|
+
registry: fakeRegistry([]),
|
|
230
|
+
});
|
|
231
|
+
const thread = await controller.createThread('t1');
|
|
232
|
+
await controller.openThread(thread.id);
|
|
233
|
+
await controller.send('same text', [{ id: 'att-A', name: 'a.png' }]);
|
|
234
|
+
const clientRequestIdA = controller.pendingSends[0]?.clientRequestId;
|
|
235
|
+
expect(clientRequestIdA).toBeDefined();
|
|
236
|
+
await controller.send('same text', [{ id: 'att-B', name: 'b.png' }]);
|
|
237
|
+
const clientRequestIdB = controller.pendingSends.find((p) => p.content === 'same text' && p.attachments?.[0]?.id === 'att-B')?.clientRequestId;
|
|
238
|
+
expect(clientRequestIdB).toBeDefined();
|
|
239
|
+
expect(clientRequestIdB).not.toBe(clientRequestIdA);
|
|
240
|
+
expect(seenAttachmentSets).toEqual([['att-A'], ['att-B']]);
|
|
241
|
+
// A THIRD send of the exact same (text, attachments) as the second one
|
|
242
|
+
// — while it's still unresolved — must reuse clientRequestIdB, not mint
|
|
243
|
+
// a third id (retries of the same draft still bind to the same key).
|
|
244
|
+
await controller.send('same text', [{ id: 'att-B', name: 'b.png' }]);
|
|
245
|
+
expect(seenAttachmentSets).toEqual([['att-A'], ['att-B'], ['att-B']]);
|
|
246
|
+
const thirdCallClientRequestId = controller.pendingSends.find((p) => p.content === 'same text' && p.attachments?.[0]?.id === 'att-B')?.clientRequestId;
|
|
247
|
+
expect(thirdCallClientRequestId).toBe(clientRequestIdB);
|
|
248
|
+
});
|
|
249
|
+
it('marks a pending send stale after the timeout and offers retry via the same clientRequestId', async () => {
|
|
250
|
+
vi.useFakeTimers();
|
|
251
|
+
try {
|
|
252
|
+
let clock = 0;
|
|
253
|
+
const transport = createInMemoryAssistantTransport({
|
|
254
|
+
simulateInProgressOnce: true,
|
|
255
|
+
now: () => clock,
|
|
256
|
+
});
|
|
257
|
+
const controller = createAssistantDockController({
|
|
258
|
+
transport,
|
|
259
|
+
registry: fakeRegistry([]),
|
|
260
|
+
now: () => clock,
|
|
261
|
+
staleAfterMs: 1_000,
|
|
262
|
+
activePollIntervalMs: 500,
|
|
263
|
+
idlePollIntervalMs: 500,
|
|
264
|
+
});
|
|
265
|
+
const thread = await controller.createThread('t1');
|
|
266
|
+
await controller.openThread(thread.id);
|
|
267
|
+
await controller.send('hi');
|
|
268
|
+
expect(controller.pendingSends[0]?.status).toBe('processing');
|
|
269
|
+
const clientRequestId = controller.pendingSends[0].clientRequestId;
|
|
270
|
+
clock += 2_000;
|
|
271
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
272
|
+
expect(controller.pendingSends[0]?.status).toBe('stale');
|
|
273
|
+
await controller.retry(clientRequestId);
|
|
274
|
+
// The retry reuses the identical clientRequestId — the in-memory
|
|
275
|
+
// transport's dedup cache (assistant-transport.ts `seenClientRequestIds`)
|
|
276
|
+
// returns the SAME cached "still processing" result rather than posting
|
|
277
|
+
// a second user message, mirroring PortalChatTool.svelte:304-322.
|
|
278
|
+
expect(controller.pendingSends[0]?.clientRequestId).toBe(clientRequestId);
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
vi.useRealTimers();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
// Copilot PR #2919 jAwu8: `simulateInProgressOnce` alone left a turn
|
|
285
|
+
// `inProgress: true` FOREVER — it was never appended or scheduled to
|
|
286
|
+
// resolve, so the shipped in-memory transport could not exercise
|
|
287
|
+
// successful stale-send recovery. `resolveInProgressAfterLoads` makes
|
|
288
|
+
// that resolution configurable and exercisable via polling.
|
|
289
|
+
it('resolves a simulated in-progress turn on a later loadMessages(), letting stale-send recovery succeed', async () => {
|
|
290
|
+
vi.useFakeTimers();
|
|
291
|
+
try {
|
|
292
|
+
const clock = 0;
|
|
293
|
+
const transport = createInMemoryAssistantTransport({
|
|
294
|
+
simulateInProgressOnce: true,
|
|
295
|
+
resolveInProgressAfterLoads: 1,
|
|
296
|
+
now: () => clock,
|
|
297
|
+
});
|
|
298
|
+
const controller = createAssistantDockController({
|
|
299
|
+
transport,
|
|
300
|
+
registry: fakeRegistry([]),
|
|
301
|
+
now: () => clock,
|
|
302
|
+
staleAfterMs: 1_000,
|
|
303
|
+
activePollIntervalMs: 500,
|
|
304
|
+
idlePollIntervalMs: 500,
|
|
305
|
+
});
|
|
306
|
+
const thread = await controller.createThread('t1');
|
|
307
|
+
await controller.openThread(thread.id);
|
|
308
|
+
await controller.send('hi');
|
|
309
|
+
expect(controller.pendingSends[0]?.status).toBe('processing');
|
|
310
|
+
// The next poll tick's loadMessages() call resolves the turn — the
|
|
311
|
+
// assistant reply appears and the pending send clears, WITHOUT ever
|
|
312
|
+
// hitting the staleness timeout.
|
|
313
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
314
|
+
expect(controller.pendingSends).toHaveLength(0);
|
|
315
|
+
expect(controller.messages.some((m) => m.role === 'assistant' && m.content === 'echo: hi')).toBe(true);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
vi.useRealTimers();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
it('startPolling/stopPolling toggle the interval without throwing', () => {
|
|
322
|
+
const controller = createAssistantDockController({
|
|
323
|
+
transport: createInMemoryAssistantTransport(),
|
|
324
|
+
registry: fakeRegistry([]),
|
|
325
|
+
});
|
|
326
|
+
controller.startPolling();
|
|
327
|
+
controller.startPolling(); // idempotent
|
|
328
|
+
controller.stopPolling();
|
|
329
|
+
controller.stopPolling(); // idempotent
|
|
330
|
+
controller.dispose();
|
|
331
|
+
});
|
|
332
|
+
it('has no models when the transport does not provide listModels', async () => {
|
|
333
|
+
const controller = createAssistantDockController({
|
|
334
|
+
transport: createInMemoryAssistantTransport(),
|
|
335
|
+
registry: fakeRegistry([]),
|
|
336
|
+
});
|
|
337
|
+
await controller.loadModels();
|
|
338
|
+
expect(controller.models).toEqual([]);
|
|
339
|
+
expect(controller.selectedModel).toBeUndefined();
|
|
340
|
+
});
|
|
341
|
+
it('the selected model reaches the transport on send', async () => {
|
|
342
|
+
const seenModels = [];
|
|
343
|
+
const transport = createInMemoryAssistantTransport({
|
|
344
|
+
models: [
|
|
345
|
+
{ id: 'model-a', label: 'Model A' },
|
|
346
|
+
{ id: 'model-b', label: 'Model B' },
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
const originalSend = transport.sendMessage.bind(transport);
|
|
350
|
+
transport.sendMessage = async (input) => {
|
|
351
|
+
seenModels.push(input.model);
|
|
352
|
+
return originalSend(input);
|
|
353
|
+
};
|
|
354
|
+
const controller = createAssistantDockController({
|
|
355
|
+
transport,
|
|
356
|
+
registry: fakeRegistry([]),
|
|
357
|
+
});
|
|
358
|
+
await controller.loadModels();
|
|
359
|
+
expect(controller.models).toHaveLength(2);
|
|
360
|
+
// loadModels defaults selectedModel to the first entry.
|
|
361
|
+
expect(controller.selectedModel).toBe('model-a');
|
|
362
|
+
const thread = await controller.createThread('t1');
|
|
363
|
+
await controller.openThread(thread.id);
|
|
364
|
+
await controller.send('hello');
|
|
365
|
+
expect(seenModels).toEqual(['model-a']);
|
|
366
|
+
controller.setSelectedModel('model-b');
|
|
367
|
+
await controller.send('hello again');
|
|
368
|
+
expect(seenModels).toEqual(['model-a', 'model-b']);
|
|
369
|
+
});
|
|
370
|
+
it('reuses the idempotencyKey minted at preview across a retried apply', async () => {
|
|
371
|
+
const seenKeys = [];
|
|
372
|
+
let attempt = 0;
|
|
373
|
+
const controller = createAssistantDockController({
|
|
374
|
+
transport: createInMemoryAssistantTransport(),
|
|
375
|
+
registry: fakeRegistry([{ surfaceId: 'orders', kind: 'table' }]),
|
|
376
|
+
actionClient: {
|
|
377
|
+
preview: async (request) => ({
|
|
378
|
+
version: 1,
|
|
379
|
+
requestId: request.requestId,
|
|
380
|
+
identity: request.identity,
|
|
381
|
+
actionId: request.actionId,
|
|
382
|
+
phase: 'preview',
|
|
383
|
+
ok: true,
|
|
384
|
+
confirmationToken: 'token-1',
|
|
385
|
+
}),
|
|
386
|
+
apply: async (request, idempotencyKey) => {
|
|
387
|
+
seenKeys.push(idempotencyKey);
|
|
388
|
+
attempt += 1;
|
|
389
|
+
// First attempt fails client-side (e.g. simulated timeout); the
|
|
390
|
+
// second (retried) apply must reuse the SAME idempotencyKey.
|
|
391
|
+
return {
|
|
392
|
+
version: 1,
|
|
393
|
+
requestId: request.requestId,
|
|
394
|
+
identity: request.identity,
|
|
395
|
+
actionId: request.actionId,
|
|
396
|
+
phase: 'apply',
|
|
397
|
+
ok: attempt > 1,
|
|
398
|
+
reason: attempt > 1 ? undefined : 'timeout',
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
const requestId = 'req-idem-1';
|
|
404
|
+
await controller.previewAction({
|
|
405
|
+
version: 1,
|
|
406
|
+
requestId,
|
|
407
|
+
identity: {
|
|
408
|
+
surfaceId: 'orders',
|
|
409
|
+
kind: 'table',
|
|
410
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
411
|
+
},
|
|
412
|
+
actionId: 'archive',
|
|
413
|
+
phase: 'preview',
|
|
414
|
+
selection: { scope: 'current-page' },
|
|
415
|
+
});
|
|
416
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
417
|
+
await controller.applyAction(requestId);
|
|
418
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
419
|
+
await controller.applyAction(requestId);
|
|
420
|
+
expect(controller.actions.get(requestId)?.status).toBe('applied');
|
|
421
|
+
expect(seenKeys).toHaveLength(2);
|
|
422
|
+
expect(seenKeys[0]).toBe(seenKeys[1]);
|
|
423
|
+
});
|
|
424
|
+
// F2 (#2904 review): applyAction must re-check mount status, not only
|
|
425
|
+
// previewAction.
|
|
426
|
+
it('fails an apply closed if the surface was unmounted after preview', async () => {
|
|
427
|
+
const { registry, identity, unregister } = realRegistryWithSurface('orders');
|
|
428
|
+
const applySpy = vi.fn();
|
|
429
|
+
const controller = createAssistantDockController({
|
|
430
|
+
transport: createInMemoryAssistantTransport(),
|
|
431
|
+
registry,
|
|
432
|
+
actionClient: {
|
|
433
|
+
preview: async (request) => ({
|
|
434
|
+
version: 1,
|
|
435
|
+
requestId: request.requestId,
|
|
436
|
+
identity: request.identity,
|
|
437
|
+
actionId: request.actionId,
|
|
438
|
+
phase: 'preview',
|
|
439
|
+
ok: true,
|
|
440
|
+
}),
|
|
441
|
+
apply: async (request) => {
|
|
442
|
+
applySpy();
|
|
443
|
+
return {
|
|
444
|
+
version: 1,
|
|
445
|
+
requestId: request.requestId,
|
|
446
|
+
identity: request.identity,
|
|
447
|
+
actionId: request.actionId,
|
|
448
|
+
phase: 'apply',
|
|
449
|
+
ok: true,
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
const requestId = 'req-unmounted-apply';
|
|
455
|
+
await controller.previewAction({
|
|
456
|
+
version: 1,
|
|
457
|
+
requestId,
|
|
458
|
+
identity,
|
|
459
|
+
actionId: 'archive',
|
|
460
|
+
phase: 'preview',
|
|
461
|
+
selection: { scope: 'current-page' },
|
|
462
|
+
});
|
|
463
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
464
|
+
unregister();
|
|
465
|
+
// Copilot PR #2919 jAwwg: unregister() invalidates the previewed entry
|
|
466
|
+
// to 'failed' (via invalidatePreviewedActionsFor) with its request still
|
|
467
|
+
// at the PREVIEW phase — applyAction's status/`retryable` guard refuses
|
|
468
|
+
// it at the top (before ever reaching the apply-time mount re-check
|
|
469
|
+
// below), since a preview-phase failure is never a valid apply retry
|
|
470
|
+
// target. Cycle-4 final finding 2: the refusal is now non-mutating, so
|
|
471
|
+
// the entry's own status/error are left exactly as the invalidation set
|
|
472
|
+
// them; the refusal reason is recorded on controller.error instead.
|
|
473
|
+
await controller.applyAction(requestId);
|
|
474
|
+
expect(applySpy).not.toHaveBeenCalled();
|
|
475
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
476
|
+
expect(controller.actions.get(requestId)?.error).toMatch(/unmounted/);
|
|
477
|
+
expect(controller.error).toMatch(/applyAction refused/);
|
|
478
|
+
});
|
|
479
|
+
// Copilot PR #2919 jAwwg: applyAction() previously only blocked a second
|
|
480
|
+
// CONCURRENT apply ('applying') — 'previewing', 'applied', and a
|
|
481
|
+
// preview-phase 'failed' were all still callable, letting a failed
|
|
482
|
+
// preview reach AssistantActionClient.apply without a successful
|
|
483
|
+
// preview/confirmation, and letting an already-applied action be
|
|
484
|
+
// replayed outside the intended retry path. Cycle-4 final finding 2:
|
|
485
|
+
// jAwwg's OWN fix mutated the refused entry (`status: 'failed'`), which
|
|
486
|
+
// downgraded an 'applied' entry and — on a SECOND applyAction call —
|
|
487
|
+
// satisfied its own retry condition, permitting the exact replay it was
|
|
488
|
+
// meant to prevent. The refusal is now non-mutating; retry eligibility
|
|
489
|
+
// comes from an explicit `retryable` marker set only by a genuine apply
|
|
490
|
+
// attempt.
|
|
491
|
+
describe('applyAction() status/phase guard (cycle-3 second final jAwwg, cycle-4 final finding 2)', () => {
|
|
492
|
+
function makeActionClient(applySpy = vi.fn()) {
|
|
493
|
+
return {
|
|
494
|
+
preview: async (request) => ({
|
|
495
|
+
version: 1,
|
|
496
|
+
requestId: request.requestId,
|
|
497
|
+
identity: request.identity,
|
|
498
|
+
actionId: request.actionId,
|
|
499
|
+
phase: 'preview',
|
|
500
|
+
ok: true,
|
|
501
|
+
}),
|
|
502
|
+
apply: async (request) => {
|
|
503
|
+
applySpy();
|
|
504
|
+
return {
|
|
505
|
+
version: 1,
|
|
506
|
+
requestId: request.requestId,
|
|
507
|
+
identity: request.identity,
|
|
508
|
+
actionId: request.actionId,
|
|
509
|
+
phase: 'apply',
|
|
510
|
+
ok: true,
|
|
511
|
+
};
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
it('refuses a still-"previewing" action (never resolved) with a clear error', async () => {
|
|
516
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
517
|
+
const applySpy = vi.fn();
|
|
518
|
+
const controller = createAssistantDockController({
|
|
519
|
+
transport: createInMemoryAssistantTransport(),
|
|
520
|
+
registry,
|
|
521
|
+
actionClient: makeActionClient(applySpy),
|
|
522
|
+
});
|
|
523
|
+
const requestId = 'req-previewing';
|
|
524
|
+
controller.actions.set(requestId, {
|
|
525
|
+
request: {
|
|
526
|
+
version: 1,
|
|
527
|
+
requestId,
|
|
528
|
+
identity,
|
|
529
|
+
actionId: 'archive',
|
|
530
|
+
phase: 'preview',
|
|
531
|
+
selection: { scope: 'current-page' },
|
|
532
|
+
},
|
|
533
|
+
status: 'previewing',
|
|
534
|
+
idempotencyKey: 'idem-previewing',
|
|
535
|
+
});
|
|
536
|
+
await controller.applyAction(requestId);
|
|
537
|
+
expect(applySpy).not.toHaveBeenCalled();
|
|
538
|
+
// Cycle-4 final finding 2: non-mutating refusal — the entry stays
|
|
539
|
+
// exactly 'previewing', not downgraded to 'failed'.
|
|
540
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewing');
|
|
541
|
+
expect(controller.error).toMatch(/applyAction refused/);
|
|
542
|
+
controller.dispose();
|
|
543
|
+
});
|
|
544
|
+
it('refuses a preview-phase "failed" action (never successfully previewed) with a clear error', async () => {
|
|
545
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
546
|
+
const applySpy = vi.fn();
|
|
547
|
+
const controller = createAssistantDockController({
|
|
548
|
+
transport: createInMemoryAssistantTransport(),
|
|
549
|
+
registry,
|
|
550
|
+
actionClient: makeActionClient(applySpy),
|
|
551
|
+
});
|
|
552
|
+
const requestId = 'req-preview-failed';
|
|
553
|
+
controller.actions.set(requestId, {
|
|
554
|
+
request: {
|
|
555
|
+
version: 1,
|
|
556
|
+
requestId,
|
|
557
|
+
identity,
|
|
558
|
+
actionId: 'archive',
|
|
559
|
+
phase: 'preview',
|
|
560
|
+
selection: { scope: 'current-page' },
|
|
561
|
+
},
|
|
562
|
+
status: 'failed',
|
|
563
|
+
error: 'preview rejected by the server',
|
|
564
|
+
idempotencyKey: 'idem-preview-failed',
|
|
565
|
+
});
|
|
566
|
+
await controller.applyAction(requestId);
|
|
567
|
+
expect(applySpy).not.toHaveBeenCalled();
|
|
568
|
+
// Cycle-4 final finding 2: non-mutating refusal — the entry's OWN
|
|
569
|
+
// error stays the original preview-rejection message; the refusal is
|
|
570
|
+
// recorded on controller.error instead.
|
|
571
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
572
|
+
expect(controller.actions.get(requestId)?.error).toBe('preview rejected by the server');
|
|
573
|
+
expect(controller.error).toMatch(/applyAction refused/);
|
|
574
|
+
controller.dispose();
|
|
575
|
+
});
|
|
576
|
+
it('refuses an already-"applied" action on a second AND third call — no replay, entry stays applied', async () => {
|
|
577
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
578
|
+
const applySpy = vi.fn();
|
|
579
|
+
const controller = createAssistantDockController({
|
|
580
|
+
transport: createInMemoryAssistantTransport(),
|
|
581
|
+
registry,
|
|
582
|
+
actionClient: makeActionClient(applySpy),
|
|
583
|
+
});
|
|
584
|
+
const requestId = 'req-already-applied';
|
|
585
|
+
const applyResult = {
|
|
586
|
+
version: 1,
|
|
587
|
+
requestId,
|
|
588
|
+
identity,
|
|
589
|
+
actionId: 'archive',
|
|
590
|
+
phase: 'apply',
|
|
591
|
+
ok: true,
|
|
592
|
+
};
|
|
593
|
+
controller.actions.set(requestId, {
|
|
594
|
+
request: {
|
|
595
|
+
version: 1,
|
|
596
|
+
requestId,
|
|
597
|
+
identity,
|
|
598
|
+
actionId: 'archive',
|
|
599
|
+
phase: 'apply',
|
|
600
|
+
selection: { scope: 'current-page' },
|
|
601
|
+
},
|
|
602
|
+
status: 'applied',
|
|
603
|
+
applyResult,
|
|
604
|
+
idempotencyKey: 'idem-applied',
|
|
605
|
+
});
|
|
606
|
+
// Cycle-4 final finding 2: jAwwg's original fix downgraded this entry
|
|
607
|
+
// to 'failed' on refusal, which itself satisfied the (then
|
|
608
|
+
// phase-based) retry condition — a SECOND applyAction call would
|
|
609
|
+
// proceed and replay the action against the server. Both the second
|
|
610
|
+
// AND a third call here must refuse without ever calling apply(), and
|
|
611
|
+
// the entry must stay 'applied' with its original applyResult intact
|
|
612
|
+
// throughout.
|
|
613
|
+
await controller.applyAction(requestId);
|
|
614
|
+
expect(applySpy).not.toHaveBeenCalled();
|
|
615
|
+
expect(controller.actions.get(requestId)?.status).toBe('applied');
|
|
616
|
+
expect(controller.actions.get(requestId)?.applyResult).toEqual(applyResult);
|
|
617
|
+
expect(controller.error).toMatch(/applyAction refused/);
|
|
618
|
+
controller.setError(null);
|
|
619
|
+
await controller.applyAction(requestId);
|
|
620
|
+
expect(applySpy).not.toHaveBeenCalled();
|
|
621
|
+
expect(controller.actions.get(requestId)?.status).toBe('applied');
|
|
622
|
+
expect(controller.actions.get(requestId)?.applyResult).toEqual(applyResult);
|
|
623
|
+
expect(controller.error).toMatch(/applyAction refused/);
|
|
624
|
+
controller.dispose();
|
|
625
|
+
});
|
|
626
|
+
it('permits a normal previewed -> apply transition', async () => {
|
|
627
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
628
|
+
const applySpy = vi.fn();
|
|
629
|
+
const controller = createAssistantDockController({
|
|
630
|
+
transport: createInMemoryAssistantTransport(),
|
|
631
|
+
registry,
|
|
632
|
+
actionClient: makeActionClient(applySpy),
|
|
633
|
+
});
|
|
634
|
+
const requestId = 'req-normal-flow';
|
|
635
|
+
await controller.previewAction({
|
|
636
|
+
version: 1,
|
|
637
|
+
requestId,
|
|
638
|
+
identity,
|
|
639
|
+
actionId: 'archive',
|
|
640
|
+
phase: 'preview',
|
|
641
|
+
selection: { scope: 'current-page' },
|
|
642
|
+
});
|
|
643
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
644
|
+
await controller.applyAction(requestId);
|
|
645
|
+
expect(applySpy).toHaveBeenCalledOnce();
|
|
646
|
+
expect(controller.actions.get(requestId)?.status).toBe('applied');
|
|
647
|
+
controller.dispose();
|
|
648
|
+
});
|
|
649
|
+
it('permits a retry of an APPLY-phase failure (a transient apply error after a successful preview)', async () => {
|
|
650
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
651
|
+
let shouldFail = true;
|
|
652
|
+
const applySpy = vi.fn();
|
|
653
|
+
const controller = createAssistantDockController({
|
|
654
|
+
transport: createInMemoryAssistantTransport(),
|
|
655
|
+
registry,
|
|
656
|
+
actionClient: {
|
|
657
|
+
preview: async (request) => ({
|
|
658
|
+
version: 1,
|
|
659
|
+
requestId: request.requestId,
|
|
660
|
+
identity: request.identity,
|
|
661
|
+
actionId: request.actionId,
|
|
662
|
+
phase: 'preview',
|
|
663
|
+
ok: true,
|
|
664
|
+
}),
|
|
665
|
+
apply: async (request) => {
|
|
666
|
+
applySpy();
|
|
667
|
+
if (shouldFail)
|
|
668
|
+
throw new Error('transient 500');
|
|
669
|
+
return {
|
|
670
|
+
version: 1,
|
|
671
|
+
requestId: request.requestId,
|
|
672
|
+
identity: request.identity,
|
|
673
|
+
actionId: request.actionId,
|
|
674
|
+
phase: 'apply',
|
|
675
|
+
ok: true,
|
|
676
|
+
};
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
const requestId = 'req-apply-retry';
|
|
681
|
+
await controller.previewAction({
|
|
682
|
+
version: 1,
|
|
683
|
+
requestId,
|
|
684
|
+
identity,
|
|
685
|
+
actionId: 'archive',
|
|
686
|
+
phase: 'preview',
|
|
687
|
+
selection: { scope: 'current-page' },
|
|
688
|
+
});
|
|
689
|
+
await controller.applyAction(requestId);
|
|
690
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
691
|
+
// Cycle-4 final finding 2: this is the marker the retry guard actually
|
|
692
|
+
// consults now — a genuine apply attempt failed.
|
|
693
|
+
expect(controller.actions.get(requestId)?.retryable).toBe(true);
|
|
694
|
+
shouldFail = false;
|
|
695
|
+
await controller.applyAction(requestId);
|
|
696
|
+
expect(applySpy).toHaveBeenCalledTimes(2);
|
|
697
|
+
expect(controller.actions.get(requestId)?.status).toBe('applied');
|
|
698
|
+
// Cleared on a successful apply.
|
|
699
|
+
expect(controller.actions.get(requestId)?.retryable).toBe(false);
|
|
700
|
+
controller.dispose();
|
|
701
|
+
});
|
|
702
|
+
// Cycle-4 final finding 2: a preview-phase failure must never set
|
|
703
|
+
// `retryable` — only a genuine apply attempt does.
|
|
704
|
+
it('a preview-phase failure is not retryable', async () => {
|
|
705
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
706
|
+
const applySpy = vi.fn();
|
|
707
|
+
const controller = createAssistantDockController({
|
|
708
|
+
transport: createInMemoryAssistantTransport(),
|
|
709
|
+
registry,
|
|
710
|
+
actionClient: {
|
|
711
|
+
preview: async () => {
|
|
712
|
+
throw new Error('preview rejected');
|
|
713
|
+
},
|
|
714
|
+
apply: async (request) => {
|
|
715
|
+
applySpy();
|
|
716
|
+
return {
|
|
717
|
+
version: 1,
|
|
718
|
+
requestId: request.requestId,
|
|
719
|
+
identity: request.identity,
|
|
720
|
+
actionId: request.actionId,
|
|
721
|
+
phase: 'apply',
|
|
722
|
+
ok: true,
|
|
723
|
+
};
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
const requestId = 'req-preview-fails';
|
|
728
|
+
await controller.previewAction({
|
|
729
|
+
version: 1,
|
|
730
|
+
requestId,
|
|
731
|
+
identity,
|
|
732
|
+
actionId: 'archive',
|
|
733
|
+
phase: 'preview',
|
|
734
|
+
selection: { scope: 'current-page' },
|
|
735
|
+
});
|
|
736
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
737
|
+
expect(controller.actions.get(requestId)?.retryable).toBeFalsy();
|
|
738
|
+
await controller.applyAction(requestId);
|
|
739
|
+
expect(applySpy).not.toHaveBeenCalled();
|
|
740
|
+
expect(controller.error).toMatch(/applyAction refused/);
|
|
741
|
+
controller.dispose();
|
|
742
|
+
});
|
|
743
|
+
// Cycle-4 final finding 2: retryable only covers ONE retry attempt per
|
|
744
|
+
// failure — a second consecutive apply failure must still be retryable
|
|
745
|
+
// again (freshly re-set), but a failure must never carry over as
|
|
746
|
+
// retryable past a successful apply.
|
|
747
|
+
it('a failed apply is retryable, and a subsequent success clears it', async () => {
|
|
748
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
749
|
+
let attempt = 0;
|
|
750
|
+
const applySpy = vi.fn();
|
|
751
|
+
const controller = createAssistantDockController({
|
|
752
|
+
transport: createInMemoryAssistantTransport(),
|
|
753
|
+
registry,
|
|
754
|
+
actionClient: {
|
|
755
|
+
preview: async (request) => ({
|
|
756
|
+
version: 1,
|
|
757
|
+
requestId: request.requestId,
|
|
758
|
+
identity: request.identity,
|
|
759
|
+
actionId: request.actionId,
|
|
760
|
+
phase: 'preview',
|
|
761
|
+
ok: true,
|
|
762
|
+
}),
|
|
763
|
+
apply: async (request) => {
|
|
764
|
+
applySpy();
|
|
765
|
+
attempt += 1;
|
|
766
|
+
return {
|
|
767
|
+
version: 1,
|
|
768
|
+
requestId: request.requestId,
|
|
769
|
+
identity: request.identity,
|
|
770
|
+
actionId: request.actionId,
|
|
771
|
+
phase: 'apply',
|
|
772
|
+
ok: attempt >= 3,
|
|
773
|
+
reason: attempt < 3 ? 'transient' : undefined,
|
|
774
|
+
};
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
const requestId = 'req-multi-retry';
|
|
779
|
+
await controller.previewAction({
|
|
780
|
+
version: 1,
|
|
781
|
+
requestId,
|
|
782
|
+
identity,
|
|
783
|
+
actionId: 'archive',
|
|
784
|
+
phase: 'preview',
|
|
785
|
+
selection: { scope: 'current-page' },
|
|
786
|
+
});
|
|
787
|
+
await controller.applyAction(requestId); // attempt 1: fails
|
|
788
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
789
|
+
expect(controller.actions.get(requestId)?.retryable).toBe(true);
|
|
790
|
+
await controller.applyAction(requestId); // attempt 2: fails again — still retryable
|
|
791
|
+
expect(applySpy).toHaveBeenCalledTimes(2);
|
|
792
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
793
|
+
expect(controller.actions.get(requestId)?.retryable).toBe(true);
|
|
794
|
+
await controller.applyAction(requestId); // attempt 3: succeeds
|
|
795
|
+
expect(applySpy).toHaveBeenCalledTimes(3);
|
|
796
|
+
expect(controller.actions.get(requestId)?.status).toBe('applied');
|
|
797
|
+
expect(controller.actions.get(requestId)?.retryable).toBe(false);
|
|
798
|
+
// A fourth call must now refuse (status is 'applied', not retryable).
|
|
799
|
+
await controller.applyAction(requestId);
|
|
800
|
+
expect(applySpy).toHaveBeenCalledTimes(3);
|
|
801
|
+
expect(controller.actions.get(requestId)?.status).toBe('applied');
|
|
802
|
+
controller.dispose();
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
it('invalidates an outstanding previewed action when its surface unregisters', async () => {
|
|
806
|
+
const { registry, identity, unregister } = realRegistryWithSurface('orders');
|
|
807
|
+
const controller = createAssistantDockController({
|
|
808
|
+
transport: createInMemoryAssistantTransport(),
|
|
809
|
+
registry,
|
|
810
|
+
actionClient: {
|
|
811
|
+
preview: async (request) => ({
|
|
812
|
+
version: 1,
|
|
813
|
+
requestId: request.requestId,
|
|
814
|
+
identity: request.identity,
|
|
815
|
+
actionId: request.actionId,
|
|
816
|
+
phase: 'preview',
|
|
817
|
+
ok: true,
|
|
818
|
+
}),
|
|
819
|
+
apply: async (request) => ({
|
|
820
|
+
version: 1,
|
|
821
|
+
requestId: request.requestId,
|
|
822
|
+
identity: request.identity,
|
|
823
|
+
actionId: request.actionId,
|
|
824
|
+
phase: 'apply',
|
|
825
|
+
ok: true,
|
|
826
|
+
}),
|
|
827
|
+
},
|
|
828
|
+
});
|
|
829
|
+
const requestId = 'req-invalidate-on-unregister';
|
|
830
|
+
await controller.previewAction({
|
|
831
|
+
version: 1,
|
|
832
|
+
requestId,
|
|
833
|
+
identity,
|
|
834
|
+
actionId: 'archive',
|
|
835
|
+
phase: 'preview',
|
|
836
|
+
selection: { scope: 'current-page' },
|
|
837
|
+
});
|
|
838
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
839
|
+
unregister();
|
|
840
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
841
|
+
expect(controller.actions.get(requestId)?.error).toMatch(/unmounted/);
|
|
842
|
+
});
|
|
843
|
+
// F6 (#2904 review): surfaceKey() must key by subject too, not just
|
|
844
|
+
// kind + surfaceId — otherwise a request for the same surfaceId/kind but a
|
|
845
|
+
// DIFFERENT subject (another tenant, site, or project instance) passes the
|
|
846
|
+
// mount gate.
|
|
847
|
+
it('rejects preview and apply for the same kind/surfaceId mounted under a different subject', async () => {
|
|
848
|
+
const { registry, identity: mountedIdentity } = realRegistryWithSurface('orders', { type: 'tenant', id: 'tenant-a' });
|
|
849
|
+
const otherSubjectIdentity = {
|
|
850
|
+
...mountedIdentity,
|
|
851
|
+
subject: { type: 'tenant', id: 'tenant-b' },
|
|
852
|
+
};
|
|
853
|
+
const applySpy = vi.fn();
|
|
854
|
+
const controller = createAssistantDockController({
|
|
855
|
+
transport: createInMemoryAssistantTransport(),
|
|
856
|
+
registry,
|
|
857
|
+
actionClient: {
|
|
858
|
+
preview: async (request) => ({
|
|
859
|
+
version: 1,
|
|
860
|
+
requestId: request.requestId,
|
|
861
|
+
identity: request.identity,
|
|
862
|
+
actionId: request.actionId,
|
|
863
|
+
phase: 'preview',
|
|
864
|
+
ok: true,
|
|
865
|
+
}),
|
|
866
|
+
apply: async (request) => {
|
|
867
|
+
applySpy();
|
|
868
|
+
return {
|
|
869
|
+
version: 1,
|
|
870
|
+
requestId: request.requestId,
|
|
871
|
+
identity: request.identity,
|
|
872
|
+
actionId: request.actionId,
|
|
873
|
+
phase: 'apply',
|
|
874
|
+
ok: true,
|
|
875
|
+
};
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
const requestId = 'req-other-subject';
|
|
880
|
+
await controller.previewAction({
|
|
881
|
+
version: 1,
|
|
882
|
+
requestId,
|
|
883
|
+
identity: otherSubjectIdentity,
|
|
884
|
+
actionId: 'archive',
|
|
885
|
+
phase: 'preview',
|
|
886
|
+
selection: { scope: 'current-page' },
|
|
887
|
+
});
|
|
888
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
889
|
+
expect(controller.actions.get(requestId)?.error).toMatch(/not mounted/);
|
|
890
|
+
// Force the action into a previewed state directly (bypassing the
|
|
891
|
+
// preview-time gate) to isolate applyAction's OWN re-check from
|
|
892
|
+
// previewAction's — F2's apply-time gate must independently reject the
|
|
893
|
+
// subject-swapped identity too.
|
|
894
|
+
controller.actions.set(requestId, {
|
|
895
|
+
request: {
|
|
896
|
+
version: 1,
|
|
897
|
+
requestId,
|
|
898
|
+
identity: otherSubjectIdentity,
|
|
899
|
+
actionId: 'archive',
|
|
900
|
+
phase: 'preview',
|
|
901
|
+
selection: { scope: 'current-page' },
|
|
902
|
+
},
|
|
903
|
+
status: 'previewed',
|
|
904
|
+
idempotencyKey: 'idem-other-subject',
|
|
905
|
+
});
|
|
906
|
+
await controller.applyAction(requestId);
|
|
907
|
+
expect(applySpy).not.toHaveBeenCalled();
|
|
908
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
909
|
+
expect(controller.actions.get(requestId)?.error).toMatch(/not mounted/);
|
|
910
|
+
});
|
|
911
|
+
it("does not invalidate a sibling subject's outstanding preview when a different subject unregisters", async () => {
|
|
912
|
+
const tenantA = realRegistryWithSurface('orders', {
|
|
913
|
+
type: 'tenant',
|
|
914
|
+
id: 'tenant-a',
|
|
915
|
+
});
|
|
916
|
+
// Mount a SECOND surface for tenant-b, same kind/surfaceId, on the SAME
|
|
917
|
+
// registry instance the controller watches.
|
|
918
|
+
const tenantBIdentity = {
|
|
919
|
+
surfaceId: 'orders',
|
|
920
|
+
kind: 'table',
|
|
921
|
+
subject: { type: 'tenant', id: 'tenant-b' },
|
|
922
|
+
};
|
|
923
|
+
const unregisterTenantB = tenantA.registry.register({
|
|
924
|
+
descriptor: {
|
|
925
|
+
version: 1,
|
|
926
|
+
identity: tenantBIdentity,
|
|
927
|
+
schemaVersion: 1,
|
|
928
|
+
label: 'orders',
|
|
929
|
+
rowKey: 'id',
|
|
930
|
+
columns: [
|
|
931
|
+
{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' },
|
|
932
|
+
],
|
|
933
|
+
query: {
|
|
934
|
+
modes: ['rows'],
|
|
935
|
+
projectableColumnIds: ['id'],
|
|
936
|
+
searchableColumnIds: [],
|
|
937
|
+
filterableColumnIds: [],
|
|
938
|
+
sortableColumnIds: [],
|
|
939
|
+
},
|
|
940
|
+
actions: [],
|
|
941
|
+
controls: [],
|
|
942
|
+
limits: {
|
|
943
|
+
maxQueryRows: 10,
|
|
944
|
+
maxQueryBytes: 10_000,
|
|
945
|
+
maxSelectionSize: 10,
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
949
|
+
});
|
|
950
|
+
const controller = createAssistantDockController({
|
|
951
|
+
transport: createInMemoryAssistantTransport(),
|
|
952
|
+
registry: tenantA.registry,
|
|
953
|
+
actionClient: {
|
|
954
|
+
preview: async (request) => ({
|
|
955
|
+
version: 1,
|
|
956
|
+
requestId: request.requestId,
|
|
957
|
+
identity: request.identity,
|
|
958
|
+
actionId: request.actionId,
|
|
959
|
+
phase: 'preview',
|
|
960
|
+
ok: true,
|
|
961
|
+
}),
|
|
962
|
+
apply: async (request) => ({
|
|
963
|
+
version: 1,
|
|
964
|
+
requestId: request.requestId,
|
|
965
|
+
identity: request.identity,
|
|
966
|
+
actionId: request.actionId,
|
|
967
|
+
phase: 'apply',
|
|
968
|
+
ok: true,
|
|
969
|
+
}),
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
const requestId = 'req-tenant-a-survives';
|
|
973
|
+
await controller.previewAction({
|
|
974
|
+
version: 1,
|
|
975
|
+
requestId,
|
|
976
|
+
identity: tenantA.identity,
|
|
977
|
+
actionId: 'archive',
|
|
978
|
+
phase: 'preview',
|
|
979
|
+
selection: { scope: 'current-page' },
|
|
980
|
+
});
|
|
981
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
982
|
+
// Unregister tenant-b's surface — same kind/surfaceId as tenant-a's,
|
|
983
|
+
// different subject. tenant-a's outstanding preview must be untouched.
|
|
984
|
+
unregisterTenantB();
|
|
985
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
986
|
+
});
|
|
987
|
+
// F3 (#2904 review): dispose() during an in-flight loadMessages must not
|
|
988
|
+
// re-arm the poll interval.
|
|
989
|
+
it('startPolling() after dispose() is a no-op (F3 disposed guard)', async () => {
|
|
990
|
+
const transport = createInMemoryAssistantTransport();
|
|
991
|
+
const loadMessagesSpy = vi.spyOn(transport, 'loadMessages');
|
|
992
|
+
const controller = createAssistantDockController({
|
|
993
|
+
transport,
|
|
994
|
+
registry: realRegistryWithSurface('orders').registry,
|
|
995
|
+
activePollIntervalMs: 5,
|
|
996
|
+
idlePollIntervalMs: 5,
|
|
997
|
+
});
|
|
998
|
+
const thread = await controller.createThread('t1');
|
|
999
|
+
await controller.openThread(thread.id);
|
|
1000
|
+
loadMessagesSpy.mockClear();
|
|
1001
|
+
controller.dispose();
|
|
1002
|
+
// Before the F3 fix, resetPollInterval unconditionally armed a timer
|
|
1003
|
+
// whenever called, including via a stray startPolling() after dispose.
|
|
1004
|
+
controller.startPolling();
|
|
1005
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
1006
|
+
expect(loadMessagesSpy).not.toHaveBeenCalled();
|
|
1007
|
+
});
|
|
1008
|
+
it('does not re-arm the poll interval when dispose() races an in-flight pollTick', async () => {
|
|
1009
|
+
const transport = createInMemoryAssistantTransport();
|
|
1010
|
+
const controller = createAssistantDockController({
|
|
1011
|
+
transport,
|
|
1012
|
+
registry: realRegistryWithSurface('orders').registry,
|
|
1013
|
+
// Long enough that no second natural tick fires during the test —
|
|
1014
|
+
// the only pollTick in play is the one the real interval fires once.
|
|
1015
|
+
activePollIntervalMs: 30,
|
|
1016
|
+
idlePollIntervalMs: 30,
|
|
1017
|
+
});
|
|
1018
|
+
const thread = await controller.createThread('t1');
|
|
1019
|
+
await controller.openThread(thread.id); // consumes its own loadMessages call, unrelated to the gate below
|
|
1020
|
+
// Gate ONLY loadMessages calls made from here on — i.e. the poll's own
|
|
1021
|
+
// call, not openThread's.
|
|
1022
|
+
let resolveGatedLoadMessages;
|
|
1023
|
+
const gate = new Promise((resolve) => {
|
|
1024
|
+
resolveGatedLoadMessages = resolve;
|
|
1025
|
+
});
|
|
1026
|
+
let callCount = 0;
|
|
1027
|
+
const originalLoadMessages = transport.loadMessages.bind(transport);
|
|
1028
|
+
transport.loadMessages = async (threadId) => {
|
|
1029
|
+
callCount += 1;
|
|
1030
|
+
if (callCount === 1)
|
|
1031
|
+
await gate;
|
|
1032
|
+
return originalLoadMessages(threadId);
|
|
1033
|
+
};
|
|
1034
|
+
controller.startPolling();
|
|
1035
|
+
// Wait for the interval to fire once and land inside the gated await.
|
|
1036
|
+
await new Promise((resolve) => setTimeout(resolve, 45));
|
|
1037
|
+
expect(callCount).toBe(1);
|
|
1038
|
+
// dispose() runs WHILE that pollTick is still awaiting loadMessages.
|
|
1039
|
+
controller.dispose();
|
|
1040
|
+
resolveGatedLoadMessages?.();
|
|
1041
|
+
// Give pollTick's continuation, and any (incorrect) re-armed interval,
|
|
1042
|
+
// several multiples of the poll interval to fire again.
|
|
1043
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1044
|
+
// The bug: pollTick's post-await code unconditionally called
|
|
1045
|
+
// resetPollInterval(...), re-arming a timer after dispose(). The fix
|
|
1046
|
+
// must leave the call count at exactly 1 — the single tick that was
|
|
1047
|
+
// already in flight when dispose() ran, and nothing after.
|
|
1048
|
+
expect(callCount).toBe(1);
|
|
1049
|
+
}, 10_000);
|
|
1050
|
+
// Cycle-3 first final finding 2: mirrors the dispose-race test above —
|
|
1051
|
+
// stopPolling() had no in-flight-await guard of its own, so a pollTick
|
|
1052
|
+
// already awaiting loadMessages when stopPolling() ran would still call
|
|
1053
|
+
// resetPollInterval(...) afterward and re-arm a brand new timer, undoing
|
|
1054
|
+
// the stop.
|
|
1055
|
+
it('does not re-arm the poll interval when stopPolling() races an in-flight pollTick', async () => {
|
|
1056
|
+
const transport = createInMemoryAssistantTransport();
|
|
1057
|
+
const controller = createAssistantDockController({
|
|
1058
|
+
transport,
|
|
1059
|
+
registry: realRegistryWithSurface('orders').registry,
|
|
1060
|
+
// Long enough that no second natural tick fires during the test —
|
|
1061
|
+
// the only pollTick in play is the one the real interval fires once.
|
|
1062
|
+
activePollIntervalMs: 30,
|
|
1063
|
+
idlePollIntervalMs: 30,
|
|
1064
|
+
});
|
|
1065
|
+
const thread = await controller.createThread('t1');
|
|
1066
|
+
await controller.openThread(thread.id); // consumes its own loadMessages call, unrelated to the gate below
|
|
1067
|
+
// Gate ONLY loadMessages calls made from here on — i.e. the poll's own
|
|
1068
|
+
// call, not openThread's.
|
|
1069
|
+
let resolveGatedLoadMessages;
|
|
1070
|
+
const gate = new Promise((resolve) => {
|
|
1071
|
+
resolveGatedLoadMessages = resolve;
|
|
1072
|
+
});
|
|
1073
|
+
let callCount = 0;
|
|
1074
|
+
const originalLoadMessages = transport.loadMessages.bind(transport);
|
|
1075
|
+
transport.loadMessages = async (threadId) => {
|
|
1076
|
+
callCount += 1;
|
|
1077
|
+
if (callCount === 1)
|
|
1078
|
+
await gate;
|
|
1079
|
+
return originalLoadMessages(threadId);
|
|
1080
|
+
};
|
|
1081
|
+
controller.startPolling();
|
|
1082
|
+
// Wait for the interval to fire once and land inside the gated await.
|
|
1083
|
+
await new Promise((resolve) => setTimeout(resolve, 45));
|
|
1084
|
+
expect(callCount).toBe(1);
|
|
1085
|
+
// stopPolling() runs WHILE that pollTick is still awaiting loadMessages.
|
|
1086
|
+
controller.stopPolling();
|
|
1087
|
+
resolveGatedLoadMessages?.();
|
|
1088
|
+
// Give pollTick's continuation, and any (incorrect) re-armed interval,
|
|
1089
|
+
// several multiples of the poll interval to fire again.
|
|
1090
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1091
|
+
// The bug: pollTick's post-await code called resetPollInterval(...)
|
|
1092
|
+
// unconditionally, re-arming a timer after stopPolling(). The fix must
|
|
1093
|
+
// leave the call count at exactly 1 — the single tick already in flight
|
|
1094
|
+
// when stopPolling() ran, and nothing after.
|
|
1095
|
+
expect(callCount).toBe(1);
|
|
1096
|
+
controller.dispose();
|
|
1097
|
+
}, 10_000);
|
|
1098
|
+
// Finding A (#2904 review, third final pass): pollTick's pending-send
|
|
1099
|
+
// resolution must not match an earlier, already-answered occurrence of the
|
|
1100
|
+
// same content, and must not cross threads.
|
|
1101
|
+
it('does not resolve a new in-flight repeat of an already-answered message until ITS OWN reply follows', async () => {
|
|
1102
|
+
const { transport, store } = scriptedTransport({
|
|
1103
|
+
t1: [
|
|
1104
|
+
{
|
|
1105
|
+
id: 'old-user',
|
|
1106
|
+
threadId: 't1',
|
|
1107
|
+
content: 'yes',
|
|
1108
|
+
role: 'user',
|
|
1109
|
+
createdAt: new Date(0),
|
|
1110
|
+
},
|
|
1111
|
+
{
|
|
1112
|
+
id: 'old-reply',
|
|
1113
|
+
threadId: 't1',
|
|
1114
|
+
content: 'Understood.',
|
|
1115
|
+
role: 'assistant',
|
|
1116
|
+
createdAt: new Date(1),
|
|
1117
|
+
},
|
|
1118
|
+
],
|
|
1119
|
+
});
|
|
1120
|
+
const controller = createAssistantDockController({
|
|
1121
|
+
transport,
|
|
1122
|
+
registry: fakeRegistry([]),
|
|
1123
|
+
activePollIntervalMs: 15,
|
|
1124
|
+
idlePollIntervalMs: 15,
|
|
1125
|
+
});
|
|
1126
|
+
await controller.openThread('t1');
|
|
1127
|
+
expect(controller.messages).toHaveLength(2);
|
|
1128
|
+
await controller.send('yes'); // repeats the earlier, already-answered text
|
|
1129
|
+
expect(controller.pendingSends).toHaveLength(1);
|
|
1130
|
+
expect(controller.pendingSends[0]?.status).toBe('processing');
|
|
1131
|
+
controller.startPolling();
|
|
1132
|
+
// Several poll ticks elapse with NO new assistant reply in the store —
|
|
1133
|
+
// before the fix, the OLD "yes" → "Understood." pair (an earlier index
|
|
1134
|
+
// match) would have resolved this immediately.
|
|
1135
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1136
|
+
expect(controller.pendingSends).toHaveLength(1);
|
|
1137
|
+
expect(controller.pendingSends[0]?.status).toBe('processing');
|
|
1138
|
+
// Now the reply to THIS send arrives.
|
|
1139
|
+
pushAssistantReply(store, 't1', 'Confirmed, again.');
|
|
1140
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1141
|
+
expect(controller.pendingSends).toHaveLength(0);
|
|
1142
|
+
controller.dispose();
|
|
1143
|
+
});
|
|
1144
|
+
it("does not resolve a pending send for thread A using thread B's messages", async () => {
|
|
1145
|
+
const { transport, store } = scriptedTransport({ a: [], b: [] });
|
|
1146
|
+
const controller = createAssistantDockController({
|
|
1147
|
+
transport,
|
|
1148
|
+
registry: fakeRegistry([]),
|
|
1149
|
+
activePollIntervalMs: 15,
|
|
1150
|
+
idlePollIntervalMs: 15,
|
|
1151
|
+
});
|
|
1152
|
+
await controller.openThread('a');
|
|
1153
|
+
await controller.send('hello');
|
|
1154
|
+
expect(controller.pendingSends).toHaveLength(1);
|
|
1155
|
+
const pendingForA = controller.pendingSends[0];
|
|
1156
|
+
expect(pendingForA?.threadId).toBe('a');
|
|
1157
|
+
// Switch to thread B, which happens to contain the SAME text followed by
|
|
1158
|
+
// a reply — this must never be read as resolving thread A's pending send.
|
|
1159
|
+
store.set('b', [
|
|
1160
|
+
{
|
|
1161
|
+
id: 'b-user',
|
|
1162
|
+
threadId: 'b',
|
|
1163
|
+
content: 'hello',
|
|
1164
|
+
role: 'user',
|
|
1165
|
+
createdAt: new Date(),
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
id: 'b-reply',
|
|
1169
|
+
threadId: 'b',
|
|
1170
|
+
content: 'hi there',
|
|
1171
|
+
role: 'assistant',
|
|
1172
|
+
createdAt: new Date(),
|
|
1173
|
+
},
|
|
1174
|
+
]);
|
|
1175
|
+
await controller.openThread('b');
|
|
1176
|
+
controller.startPolling();
|
|
1177
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1178
|
+
expect(controller.pendingSends).toHaveLength(1);
|
|
1179
|
+
expect(controller.pendingSends[0]?.clientRequestId).toBe(pendingForA?.clientRequestId);
|
|
1180
|
+
expect(controller.pendingSends[0]?.status).toBe('processing');
|
|
1181
|
+
controller.dispose();
|
|
1182
|
+
});
|
|
1183
|
+
// Finding B (#2904 review, third final pass): the registry subscription
|
|
1184
|
+
// must follow a getter-based `options.registry` when the host reassigns
|
|
1185
|
+
// it, not stay bound to whatever instance was current at construction.
|
|
1186
|
+
// This is the direct controller-level test of syncRegistry(); the
|
|
1187
|
+
// corresponding component-level test in AssistantDock.test.ts drives the
|
|
1188
|
+
// same swap through a real `<AssistantDock>` prop reassignment and
|
|
1189
|
+
// asserts the DOM-visible surfaces-empty notice reacts to it.
|
|
1190
|
+
it('syncRegistry() re-subscribes, resyncs surfaces, and invalidates outstanding previews on a registry swap', async () => {
|
|
1191
|
+
const r1 = realRegistryWithSurface('orders');
|
|
1192
|
+
let currentRegistry = r1.registry;
|
|
1193
|
+
const controller = createAssistantDockController({
|
|
1194
|
+
transport: createInMemoryAssistantTransport(),
|
|
1195
|
+
get registry() {
|
|
1196
|
+
return currentRegistry;
|
|
1197
|
+
},
|
|
1198
|
+
actionClient: {
|
|
1199
|
+
preview: async (request) => ({
|
|
1200
|
+
version: 1,
|
|
1201
|
+
requestId: request.requestId,
|
|
1202
|
+
identity: request.identity,
|
|
1203
|
+
actionId: request.actionId,
|
|
1204
|
+
phase: 'preview',
|
|
1205
|
+
ok: true,
|
|
1206
|
+
}),
|
|
1207
|
+
apply: async (request) => ({
|
|
1208
|
+
version: 1,
|
|
1209
|
+
requestId: request.requestId,
|
|
1210
|
+
identity: request.identity,
|
|
1211
|
+
actionId: request.actionId,
|
|
1212
|
+
phase: 'apply',
|
|
1213
|
+
ok: true,
|
|
1214
|
+
}),
|
|
1215
|
+
},
|
|
1216
|
+
});
|
|
1217
|
+
expect(controller.surfaces).toHaveLength(1);
|
|
1218
|
+
const outstandingRequestId = 'req-outstanding-on-r1';
|
|
1219
|
+
await controller.previewAction({
|
|
1220
|
+
version: 1,
|
|
1221
|
+
requestId: outstandingRequestId,
|
|
1222
|
+
identity: r1.identity,
|
|
1223
|
+
actionId: 'archive',
|
|
1224
|
+
phase: 'preview',
|
|
1225
|
+
selection: { scope: 'current-page' },
|
|
1226
|
+
});
|
|
1227
|
+
expect(controller.actions.get(outstandingRequestId)?.status).toBe('previewed');
|
|
1228
|
+
// Swap to an empty registry (R2) — mirrors a host switching
|
|
1229
|
+
// tenant/workspace context.
|
|
1230
|
+
const r2 = createDataSurfaceRegistry();
|
|
1231
|
+
currentRegistry = r2;
|
|
1232
|
+
controller.syncRegistry();
|
|
1233
|
+
expect(controller.surfaces).toHaveLength(0);
|
|
1234
|
+
// Copilot PR #2919 jAwsd: a registry swap now fully clears the actions
|
|
1235
|
+
// map (resetConversationStateForContextSwap()) rather than marking each
|
|
1236
|
+
// entry 'failed' — the preview taken under R1 must not survive the swap
|
|
1237
|
+
// to R2 in ANY form.
|
|
1238
|
+
expect(controller.actions.has(outstandingRequestId)).toBe(false);
|
|
1239
|
+
// A preview against R1's surface must now be rejected — R1 is no longer
|
|
1240
|
+
// the registry this controller is watching.
|
|
1241
|
+
const rejectedRequestId = 'req-r1-after-swap';
|
|
1242
|
+
await controller.previewAction({
|
|
1243
|
+
version: 1,
|
|
1244
|
+
requestId: rejectedRequestId,
|
|
1245
|
+
identity: r1.identity,
|
|
1246
|
+
actionId: 'archive',
|
|
1247
|
+
phase: 'preview',
|
|
1248
|
+
selection: { scope: 'current-page' },
|
|
1249
|
+
});
|
|
1250
|
+
expect(controller.actions.get(rejectedRequestId)?.status).toBe('failed');
|
|
1251
|
+
expect(controller.actions.get(rejectedRequestId)?.error).toMatch(/not mounted/);
|
|
1252
|
+
// Registering a surface on R2 must be discovered (the subscription
|
|
1253
|
+
// really did move to R2, not just resync once).
|
|
1254
|
+
const r2Identity = {
|
|
1255
|
+
surfaceId: 'products',
|
|
1256
|
+
kind: 'table',
|
|
1257
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
1258
|
+
};
|
|
1259
|
+
r2.register({
|
|
1260
|
+
descriptor: {
|
|
1261
|
+
version: 1,
|
|
1262
|
+
identity: r2Identity,
|
|
1263
|
+
schemaVersion: 1,
|
|
1264
|
+
label: 'products',
|
|
1265
|
+
rowKey: 'id',
|
|
1266
|
+
columns: [
|
|
1267
|
+
{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' },
|
|
1268
|
+
],
|
|
1269
|
+
query: {
|
|
1270
|
+
modes: ['rows'],
|
|
1271
|
+
projectableColumnIds: ['id'],
|
|
1272
|
+
searchableColumnIds: [],
|
|
1273
|
+
filterableColumnIds: [],
|
|
1274
|
+
sortableColumnIds: [],
|
|
1275
|
+
},
|
|
1276
|
+
actions: [],
|
|
1277
|
+
controls: [],
|
|
1278
|
+
limits: {
|
|
1279
|
+
maxQueryRows: 10,
|
|
1280
|
+
maxQueryBytes: 10_000,
|
|
1281
|
+
maxSelectionSize: 10,
|
|
1282
|
+
},
|
|
1283
|
+
},
|
|
1284
|
+
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
1285
|
+
});
|
|
1286
|
+
expect(controller.surfaces).toHaveLength(1);
|
|
1287
|
+
expect(controller.surfaces[0]?.surfaceId).toBe('products');
|
|
1288
|
+
controller.dispose();
|
|
1289
|
+
});
|
|
1290
|
+
// Cycle-4 final finding 1: `openThreadRequestId` guards openThread()
|
|
1291
|
+
// alone — resetConversationStateForContextSwap()'s own invariant ("an old
|
|
1292
|
+
// in-flight load could still write it back") had NO guard at all for
|
|
1293
|
+
// loadThreads()/loadModels()/createThread(). `contextEpoch` closes that:
|
|
1294
|
+
// captured before each of those functions' await(s) and compared after.
|
|
1295
|
+
describe('contextEpoch guards loadThreads/loadModels/createThread against a swap (cycle-4 final finding 1)', () => {
|
|
1296
|
+
it('a slow listThreads() from transport A resolves after a swap to B — threads stay Bs', async () => {
|
|
1297
|
+
let resolveA;
|
|
1298
|
+
const gateA = new Promise((resolve) => {
|
|
1299
|
+
resolveA = resolve;
|
|
1300
|
+
});
|
|
1301
|
+
const transportA = {
|
|
1302
|
+
async listThreads() {
|
|
1303
|
+
return gateA;
|
|
1304
|
+
},
|
|
1305
|
+
async createThread(title) {
|
|
1306
|
+
return { id: 'a-thread', title, isResolved: false, messageCount: 0 };
|
|
1307
|
+
},
|
|
1308
|
+
async loadMessages() {
|
|
1309
|
+
return [];
|
|
1310
|
+
},
|
|
1311
|
+
async sendMessage() {
|
|
1312
|
+
throw new Error('unused');
|
|
1313
|
+
},
|
|
1314
|
+
async uploadAttachment() {
|
|
1315
|
+
throw new Error('unused');
|
|
1316
|
+
},
|
|
1317
|
+
};
|
|
1318
|
+
const transportB = createInMemoryAssistantTransport();
|
|
1319
|
+
const threadB = await transportB.createThread('Context B thread');
|
|
1320
|
+
let currentTransport = transportA;
|
|
1321
|
+
let currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1322
|
+
const controller = createAssistantDockController({
|
|
1323
|
+
get transport() {
|
|
1324
|
+
return currentTransport;
|
|
1325
|
+
},
|
|
1326
|
+
get registry() {
|
|
1327
|
+
return currentRegistry;
|
|
1328
|
+
},
|
|
1329
|
+
});
|
|
1330
|
+
// Kick off a loadThreads() against A — it stays gated (in flight).
|
|
1331
|
+
const staleLoad = controller.loadThreads();
|
|
1332
|
+
// Swap to B WHILE A's listThreads() is still in flight.
|
|
1333
|
+
currentTransport = transportB;
|
|
1334
|
+
currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1335
|
+
controller.syncRegistry();
|
|
1336
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1337
|
+
expect(controller.threads.map((t) => t.id)).toEqual([threadB.id]);
|
|
1338
|
+
// A's stale listThreads() now resolves — it must NOT overwrite B's
|
|
1339
|
+
// already-loaded thread list.
|
|
1340
|
+
resolveA?.([
|
|
1341
|
+
{ id: 'a-thread', title: 'From A', isResolved: false, messageCount: 0 },
|
|
1342
|
+
]);
|
|
1343
|
+
await staleLoad;
|
|
1344
|
+
expect(controller.threads.map((t) => t.id)).toEqual([threadB.id]);
|
|
1345
|
+
controller.dispose();
|
|
1346
|
+
});
|
|
1347
|
+
it('a slow listModels() from transport A resolves after a swap to B — models stay Bs', async () => {
|
|
1348
|
+
let resolveA;
|
|
1349
|
+
const gateA = new Promise((resolve) => {
|
|
1350
|
+
resolveA = resolve;
|
|
1351
|
+
});
|
|
1352
|
+
const transportA = {
|
|
1353
|
+
async listThreads() {
|
|
1354
|
+
return [];
|
|
1355
|
+
},
|
|
1356
|
+
async createThread(title) {
|
|
1357
|
+
return { id: 'a-thread', title, isResolved: false, messageCount: 0 };
|
|
1358
|
+
},
|
|
1359
|
+
async loadMessages() {
|
|
1360
|
+
return [];
|
|
1361
|
+
},
|
|
1362
|
+
async sendMessage() {
|
|
1363
|
+
throw new Error('unused');
|
|
1364
|
+
},
|
|
1365
|
+
async uploadAttachment() {
|
|
1366
|
+
throw new Error('unused');
|
|
1367
|
+
},
|
|
1368
|
+
async listModels() {
|
|
1369
|
+
return gateA;
|
|
1370
|
+
},
|
|
1371
|
+
};
|
|
1372
|
+
const transportB = createInMemoryAssistantTransport({
|
|
1373
|
+
models: [{ id: 'model-b', label: 'Model B' }],
|
|
1374
|
+
});
|
|
1375
|
+
let currentTransport = transportA;
|
|
1376
|
+
let currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1377
|
+
const controller = createAssistantDockController({
|
|
1378
|
+
get transport() {
|
|
1379
|
+
return currentTransport;
|
|
1380
|
+
},
|
|
1381
|
+
get registry() {
|
|
1382
|
+
return currentRegistry;
|
|
1383
|
+
},
|
|
1384
|
+
});
|
|
1385
|
+
const staleLoad = controller.loadModels();
|
|
1386
|
+
currentTransport = transportB;
|
|
1387
|
+
currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1388
|
+
controller.syncRegistry();
|
|
1389
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1390
|
+
expect(controller.models.map((m) => m.id)).toEqual(['model-b']);
|
|
1391
|
+
resolveA?.([{ id: 'model-a', label: 'Model A' }]);
|
|
1392
|
+
await staleLoad;
|
|
1393
|
+
expect(controller.models.map((m) => m.id)).toEqual(['model-b']);
|
|
1394
|
+
controller.dispose();
|
|
1395
|
+
});
|
|
1396
|
+
it('a slow createThread() against transport A resolving after a swap to B does not land in threads', async () => {
|
|
1397
|
+
let resolveA;
|
|
1398
|
+
const gateA = new Promise((resolve) => {
|
|
1399
|
+
resolveA = resolve;
|
|
1400
|
+
});
|
|
1401
|
+
const transportA = {
|
|
1402
|
+
async listThreads() {
|
|
1403
|
+
return [];
|
|
1404
|
+
},
|
|
1405
|
+
async createThread() {
|
|
1406
|
+
return gateA;
|
|
1407
|
+
},
|
|
1408
|
+
async loadMessages() {
|
|
1409
|
+
return [];
|
|
1410
|
+
},
|
|
1411
|
+
async sendMessage() {
|
|
1412
|
+
throw new Error('unused');
|
|
1413
|
+
},
|
|
1414
|
+
async uploadAttachment() {
|
|
1415
|
+
throw new Error('unused');
|
|
1416
|
+
},
|
|
1417
|
+
};
|
|
1418
|
+
const transportB = createInMemoryAssistantTransport();
|
|
1419
|
+
const threadB = await transportB.createThread('Context B thread');
|
|
1420
|
+
let currentTransport = transportA;
|
|
1421
|
+
let currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1422
|
+
const controller = createAssistantDockController({
|
|
1423
|
+
get transport() {
|
|
1424
|
+
return currentTransport;
|
|
1425
|
+
},
|
|
1426
|
+
get registry() {
|
|
1427
|
+
return currentRegistry;
|
|
1428
|
+
},
|
|
1429
|
+
});
|
|
1430
|
+
const staleCreate = controller.createThread('From A');
|
|
1431
|
+
currentTransport = transportB;
|
|
1432
|
+
currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1433
|
+
controller.syncRegistry();
|
|
1434
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1435
|
+
expect(controller.threads.map((t) => t.id)).toEqual([threadB.id]);
|
|
1436
|
+
// A's stale createThread() now resolves — the returned value stays
|
|
1437
|
+
// load-bearing for the (test's own) caller, but it must NOT land in
|
|
1438
|
+
// `threads`, which belongs to context B now.
|
|
1439
|
+
resolveA?.({
|
|
1440
|
+
id: 'a-thread-created',
|
|
1441
|
+
title: 'From A',
|
|
1442
|
+
isResolved: false,
|
|
1443
|
+
messageCount: 0,
|
|
1444
|
+
});
|
|
1445
|
+
const created = await staleCreate;
|
|
1446
|
+
expect(created.id).toBe('a-thread-created');
|
|
1447
|
+
expect(controller.threads.map((t) => t.id)).toEqual([threadB.id]);
|
|
1448
|
+
controller.dispose();
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
// Cycle-4 second final finding 1: resetConversationStateForContextSwap()
|
|
1452
|
+
// cleared threads/activeThreadId/messages/pendingSends/actions/error/
|
|
1453
|
+
// draftIds and bumped the epoch counters, but left `selectedModel` and
|
|
1454
|
+
// `pollErrorActive` (both per-context) untouched — a model id chosen
|
|
1455
|
+
// under the OLD transport's catalog kept flowing into every send() under
|
|
1456
|
+
// the NEW one, and the first loadMessages() failure in the NEW context
|
|
1457
|
+
// was silently swallowed by a "record once" gate that never re-armed.
|
|
1458
|
+
describe('resetConversationStateForContextSwap() resets selectedModel and pollErrorActive (cycle-4 second final finding 1)', () => {
|
|
1459
|
+
it("re-defaults selectedModel from B's catalog after a swap, even when A's listModels() resolved BEFORE the swap", async () => {
|
|
1460
|
+
const transportA = createInMemoryAssistantTransport({
|
|
1461
|
+
models: [{ id: 'model-a', label: 'Model A' }],
|
|
1462
|
+
});
|
|
1463
|
+
const transportB = createInMemoryAssistantTransport({
|
|
1464
|
+
models: [{ id: 'model-b', label: 'Model B' }],
|
|
1465
|
+
});
|
|
1466
|
+
let currentTransport = transportA;
|
|
1467
|
+
let currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1468
|
+
const controller = createAssistantDockController({
|
|
1469
|
+
get transport() {
|
|
1470
|
+
return currentTransport;
|
|
1471
|
+
},
|
|
1472
|
+
get registry() {
|
|
1473
|
+
return currentRegistry;
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
// A's listModels() resolves BEFORE the swap — selectedModel defaults
|
|
1477
|
+
// to A's catalog, exactly the case the fix must still cover (the
|
|
1478
|
+
// pre-fix bug only reproduced once `selectedModel` was truthy).
|
|
1479
|
+
await controller.loadModels();
|
|
1480
|
+
expect(controller.selectedModel).toBe('model-a');
|
|
1481
|
+
currentTransport = transportB;
|
|
1482
|
+
currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1483
|
+
controller.syncRegistry();
|
|
1484
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1485
|
+
expect(controller.models.map((m) => m.id)).toEqual(['model-b']);
|
|
1486
|
+
expect(controller.selectedModel).toBe('model-b');
|
|
1487
|
+
controller.dispose();
|
|
1488
|
+
});
|
|
1489
|
+
it('records the first poll error in the NEW context after a swap, even when a poll error preceded the swap', async () => {
|
|
1490
|
+
// Context A: openThread's own loadMessages() call succeeds (call 1);
|
|
1491
|
+
// every poll tick after that (call 2+) fails — this is what arms
|
|
1492
|
+
// pollTick's `pollErrorActive` "record once" gate via a REAL pollTick,
|
|
1493
|
+
// not openThread's own separate catch.
|
|
1494
|
+
let callsA = 0;
|
|
1495
|
+
const transportA = {
|
|
1496
|
+
async listThreads() {
|
|
1497
|
+
return [];
|
|
1498
|
+
},
|
|
1499
|
+
async createThread(title) {
|
|
1500
|
+
return { id: 'a-thread', title, isResolved: false, messageCount: 0 };
|
|
1501
|
+
},
|
|
1502
|
+
async loadMessages() {
|
|
1503
|
+
callsA += 1;
|
|
1504
|
+
if (callsA === 1)
|
|
1505
|
+
return [];
|
|
1506
|
+
throw new Error('A poll failed');
|
|
1507
|
+
},
|
|
1508
|
+
async sendMessage() {
|
|
1509
|
+
throw new Error('unused');
|
|
1510
|
+
},
|
|
1511
|
+
async uploadAttachment() {
|
|
1512
|
+
throw new Error('unused');
|
|
1513
|
+
},
|
|
1514
|
+
};
|
|
1515
|
+
// Context B: same shape — first call (the post-swap openThread)
|
|
1516
|
+
// succeeds, every call after that fails with a DIFFERENT message, so
|
|
1517
|
+
// the assertion can only pass if pollErrorActive was actually reset
|
|
1518
|
+
// (pre-fix, it stayed armed from A and B's failure was swallowed —
|
|
1519
|
+
// `controller.error` would incorrectly stay `null`).
|
|
1520
|
+
let callsB = 0;
|
|
1521
|
+
const transportB = {
|
|
1522
|
+
async listThreads() {
|
|
1523
|
+
return [];
|
|
1524
|
+
},
|
|
1525
|
+
async createThread(title) {
|
|
1526
|
+
return { id: 'b-thread', title, isResolved: false, messageCount: 0 };
|
|
1527
|
+
},
|
|
1528
|
+
async loadMessages() {
|
|
1529
|
+
callsB += 1;
|
|
1530
|
+
if (callsB === 1)
|
|
1531
|
+
return [];
|
|
1532
|
+
throw new Error('B poll failed');
|
|
1533
|
+
},
|
|
1534
|
+
async sendMessage() {
|
|
1535
|
+
throw new Error('unused');
|
|
1536
|
+
},
|
|
1537
|
+
async uploadAttachment() {
|
|
1538
|
+
throw new Error('unused');
|
|
1539
|
+
},
|
|
1540
|
+
};
|
|
1541
|
+
let currentTransport = transportA;
|
|
1542
|
+
let currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1543
|
+
const controller = createAssistantDockController({
|
|
1544
|
+
get transport() {
|
|
1545
|
+
return currentTransport;
|
|
1546
|
+
},
|
|
1547
|
+
get registry() {
|
|
1548
|
+
return currentRegistry;
|
|
1549
|
+
},
|
|
1550
|
+
activePollIntervalMs: 20,
|
|
1551
|
+
idlePollIntervalMs: 20,
|
|
1552
|
+
});
|
|
1553
|
+
await controller.openThread('t1-a');
|
|
1554
|
+
expect(controller.error).toBeNull();
|
|
1555
|
+
controller.startPolling();
|
|
1556
|
+
// Wait for a poll tick to fail against A.
|
|
1557
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1558
|
+
expect(controller.error).toBe('A poll failed');
|
|
1559
|
+
// Swap WHILE pollErrorActive is still armed from A's failure.
|
|
1560
|
+
currentTransport = transportB;
|
|
1561
|
+
currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1562
|
+
controller.syncRegistry();
|
|
1563
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1564
|
+
// The reset's own loadThreads()/loadModels() succeed and clear
|
|
1565
|
+
// `error` — confirms the reset put us in a clean slate.
|
|
1566
|
+
expect(controller.error).toBeNull();
|
|
1567
|
+
await controller.openThread('b-thread');
|
|
1568
|
+
// Wait for a poll tick to fail against B.
|
|
1569
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1570
|
+
expect(controller.error).toBe('B poll failed');
|
|
1571
|
+
controller.dispose();
|
|
1572
|
+
}, 10_000);
|
|
1573
|
+
});
|
|
1574
|
+
// Copilot PR #2919 jAwsd: a registry (and, via syncTransport(), a
|
|
1575
|
+
// transport) swap now clears threads/activeThreadId/messages/pendingSends
|
|
1576
|
+
// and reloads from the NEW transport, and discards an old in-flight
|
|
1577
|
+
// openThread() load from the previous context (via the same
|
|
1578
|
+
// `openThreadRequestId` guard openThread() itself uses for the
|
|
1579
|
+
// overlapping-call race).
|
|
1580
|
+
it('clears conversation state and reloads from the new transport on a registry swap, discarding an old in-flight load', async () => {
|
|
1581
|
+
const { transport: transportA, store: storeA } = scriptedTransport({
|
|
1582
|
+
't1-a': [
|
|
1583
|
+
{
|
|
1584
|
+
id: 'a1',
|
|
1585
|
+
threadId: 't1-a',
|
|
1586
|
+
content: 'hello from context A',
|
|
1587
|
+
role: 'user',
|
|
1588
|
+
createdAt: new Date(),
|
|
1589
|
+
},
|
|
1590
|
+
],
|
|
1591
|
+
});
|
|
1592
|
+
const transportB = createInMemoryAssistantTransport();
|
|
1593
|
+
const threadB = await transportB.createThread('Context B thread');
|
|
1594
|
+
let currentTransport = transportA;
|
|
1595
|
+
let currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1596
|
+
const controller = createAssistantDockController({
|
|
1597
|
+
get transport() {
|
|
1598
|
+
return currentTransport;
|
|
1599
|
+
},
|
|
1600
|
+
get registry() {
|
|
1601
|
+
return currentRegistry;
|
|
1602
|
+
},
|
|
1603
|
+
});
|
|
1604
|
+
await controller.openThread('t1-a');
|
|
1605
|
+
expect(controller.activeThreadId).toBe('t1-a');
|
|
1606
|
+
expect(controller.messages).toHaveLength(1);
|
|
1607
|
+
// Gate transportA's loadMessages so a SECOND openThread('t1-a') call is
|
|
1608
|
+
// still in flight when the context swap happens.
|
|
1609
|
+
let resolveGatedLoad;
|
|
1610
|
+
const gate = new Promise((resolve) => {
|
|
1611
|
+
resolveGatedLoad = resolve;
|
|
1612
|
+
});
|
|
1613
|
+
const originalLoadMessages = transportA.loadMessages.bind(transportA);
|
|
1614
|
+
transportA.loadMessages = async (threadId) => {
|
|
1615
|
+
await gate;
|
|
1616
|
+
return originalLoadMessages(threadId);
|
|
1617
|
+
};
|
|
1618
|
+
const staleOpenThread = controller.openThread('t1-a');
|
|
1619
|
+
// The context swap (new registry AND new transport — a host switching
|
|
1620
|
+
// tenant/workspace) happens WHILE that load is still gated.
|
|
1621
|
+
currentTransport = transportB;
|
|
1622
|
+
currentRegistry = realRegistryWithSurface('orders').registry;
|
|
1623
|
+
controller.syncRegistry();
|
|
1624
|
+
// Cleared immediately, and reloaded from transportB (which has NO
|
|
1625
|
+
// threads named 't1-a' — only `threadB`).
|
|
1626
|
+
expect(controller.activeThreadId).toBeNull();
|
|
1627
|
+
expect(controller.messages).toHaveLength(0);
|
|
1628
|
+
expect(controller.pendingSends).toHaveLength(0);
|
|
1629
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1630
|
+
expect(controller.threads.map((t) => t.id)).toEqual([threadB.id]);
|
|
1631
|
+
// The stale load from context A now resolves — it must NOT write back
|
|
1632
|
+
// messages/activeThreadId over the new context.
|
|
1633
|
+
resolveGatedLoad?.();
|
|
1634
|
+
await staleOpenThread;
|
|
1635
|
+
expect(controller.activeThreadId).toBeNull();
|
|
1636
|
+
expect(controller.messages).toHaveLength(0);
|
|
1637
|
+
void storeA;
|
|
1638
|
+
controller.dispose();
|
|
1639
|
+
});
|
|
1640
|
+
it('syncTransport() clears conversation state and reloads on a transport swap alone', async () => {
|
|
1641
|
+
const transportA = createInMemoryAssistantTransport();
|
|
1642
|
+
const threadA = await transportA.createThread('t-a');
|
|
1643
|
+
const transportB = createInMemoryAssistantTransport();
|
|
1644
|
+
const threadB = await transportB.createThread('t-b');
|
|
1645
|
+
let currentTransport = transportA;
|
|
1646
|
+
const controller = createAssistantDockController({
|
|
1647
|
+
get transport() {
|
|
1648
|
+
return currentTransport;
|
|
1649
|
+
},
|
|
1650
|
+
registry: fakeRegistry([]),
|
|
1651
|
+
});
|
|
1652
|
+
await controller.loadThreads();
|
|
1653
|
+
await controller.openThread(threadA.id);
|
|
1654
|
+
expect(controller.activeThreadId).toBe(threadA.id);
|
|
1655
|
+
currentTransport = transportB;
|
|
1656
|
+
controller.syncTransport();
|
|
1657
|
+
expect(controller.activeThreadId).toBeNull();
|
|
1658
|
+
expect(controller.messages).toHaveLength(0);
|
|
1659
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1660
|
+
expect(controller.threads.map((t) => t.id)).toEqual([threadB.id]);
|
|
1661
|
+
controller.dispose();
|
|
1662
|
+
});
|
|
1663
|
+
it('syncTransport() is a no-op when the transport has not changed', async () => {
|
|
1664
|
+
const transport = createInMemoryAssistantTransport();
|
|
1665
|
+
const listThreadsSpy = vi.spyOn(transport, 'listThreads');
|
|
1666
|
+
const controller = createAssistantDockController({
|
|
1667
|
+
transport,
|
|
1668
|
+
registry: fakeRegistry([]),
|
|
1669
|
+
});
|
|
1670
|
+
await controller.loadThreads();
|
|
1671
|
+
listThreadsSpy.mockClear();
|
|
1672
|
+
controller.syncTransport();
|
|
1673
|
+
controller.syncTransport();
|
|
1674
|
+
expect(listThreadsSpy).not.toHaveBeenCalled();
|
|
1675
|
+
controller.dispose();
|
|
1676
|
+
});
|
|
1677
|
+
it('syncRegistry() is a no-op when the registry has not changed', () => {
|
|
1678
|
+
const { registry } = realRegistryWithSurface('orders');
|
|
1679
|
+
const controller = createAssistantDockController({
|
|
1680
|
+
transport: createInMemoryAssistantTransport(),
|
|
1681
|
+
registry,
|
|
1682
|
+
});
|
|
1683
|
+
expect(controller.surfaces).toHaveLength(1);
|
|
1684
|
+
controller.syncRegistry();
|
|
1685
|
+
controller.syncRegistry();
|
|
1686
|
+
expect(controller.surfaces).toHaveLength(1);
|
|
1687
|
+
controller.dispose();
|
|
1688
|
+
});
|
|
1689
|
+
// Copilot PR #2919 jAwr0: `surfaces` is a NARROWING filter over the live
|
|
1690
|
+
// registry, not a full replacement — an override entry that isn't
|
|
1691
|
+
// genuinely registered must never pass the mount gate (that would let an
|
|
1692
|
+
// override alone make an unmounted/unregistered surface "appear" mounted,
|
|
1693
|
+
// breaking the documented fail-closed route scoping). Complements
|
|
1694
|
+
// AssistantDock.test.ts's DOM-level discovery assertions for the same
|
|
1695
|
+
// override, wired through the component's exact
|
|
1696
|
+
// `get surfaces() { return surfaces; }` pattern.
|
|
1697
|
+
it('the `surfaces` override narrows against the live registry — an unregistered override entry is NOT mounted', async () => {
|
|
1698
|
+
const { registry, identity: ordersIdentity } = realRegistryWithSurface('orders'); // registered, but NOT in the override
|
|
1699
|
+
const productsIdentity = {
|
|
1700
|
+
surfaceId: 'products',
|
|
1701
|
+
kind: 'table',
|
|
1702
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
1703
|
+
};
|
|
1704
|
+
// Also register `products`, so the override below can demonstrate BOTH
|
|
1705
|
+
// halves: a registered+overridden identity passes, an overridden-only
|
|
1706
|
+
// (never registered) identity does not.
|
|
1707
|
+
registry.register({
|
|
1708
|
+
descriptor: {
|
|
1709
|
+
version: 1,
|
|
1710
|
+
identity: productsIdentity,
|
|
1711
|
+
schemaVersion: 1,
|
|
1712
|
+
label: 'products',
|
|
1713
|
+
rowKey: 'id',
|
|
1714
|
+
columns: [
|
|
1715
|
+
{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' },
|
|
1716
|
+
],
|
|
1717
|
+
query: {
|
|
1718
|
+
modes: ['rows'],
|
|
1719
|
+
projectableColumnIds: ['id'],
|
|
1720
|
+
searchableColumnIds: [],
|
|
1721
|
+
filterableColumnIds: [],
|
|
1722
|
+
sortableColumnIds: [],
|
|
1723
|
+
},
|
|
1724
|
+
actions: [],
|
|
1725
|
+
controls: [],
|
|
1726
|
+
limits: {
|
|
1727
|
+
maxQueryRows: 10,
|
|
1728
|
+
maxQueryBytes: 10_000,
|
|
1729
|
+
maxSelectionSize: 10,
|
|
1730
|
+
},
|
|
1731
|
+
},
|
|
1732
|
+
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
1733
|
+
});
|
|
1734
|
+
const unregisteredOverrideIdentity = {
|
|
1735
|
+
surfaceId: 'invoices',
|
|
1736
|
+
kind: 'table',
|
|
1737
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
1738
|
+
}; // in the override, but never registered anywhere
|
|
1739
|
+
const applySpy = vi.fn();
|
|
1740
|
+
const controller = createAssistantDockController({
|
|
1741
|
+
transport: createInMemoryAssistantTransport(),
|
|
1742
|
+
registry,
|
|
1743
|
+
surfaces: [productsIdentity, unregisteredOverrideIdentity],
|
|
1744
|
+
actionClient: {
|
|
1745
|
+
preview: async (request) => ({
|
|
1746
|
+
version: 1,
|
|
1747
|
+
requestId: request.requestId,
|
|
1748
|
+
identity: request.identity,
|
|
1749
|
+
actionId: request.actionId,
|
|
1750
|
+
phase: 'preview',
|
|
1751
|
+
ok: true,
|
|
1752
|
+
}),
|
|
1753
|
+
apply: async (request) => {
|
|
1754
|
+
applySpy();
|
|
1755
|
+
return {
|
|
1756
|
+
version: 1,
|
|
1757
|
+
requestId: request.requestId,
|
|
1758
|
+
identity: request.identity,
|
|
1759
|
+
actionId: request.actionId,
|
|
1760
|
+
phase: 'apply',
|
|
1761
|
+
ok: true,
|
|
1762
|
+
};
|
|
1763
|
+
},
|
|
1764
|
+
},
|
|
1765
|
+
});
|
|
1766
|
+
// Only the intersection of the override and the live registry is
|
|
1767
|
+
// mounted — `unregisteredOverrideIdentity` is filtered out.
|
|
1768
|
+
expect(controller.surfaces).toEqual([productsIdentity]);
|
|
1769
|
+
// Registered on the live registry, but NOT in the override: rejected.
|
|
1770
|
+
await controller.previewAction({
|
|
1771
|
+
version: 1,
|
|
1772
|
+
requestId: 'req-registered-not-in-override',
|
|
1773
|
+
identity: ordersIdentity,
|
|
1774
|
+
actionId: 'archive',
|
|
1775
|
+
phase: 'preview',
|
|
1776
|
+
selection: { scope: 'current-page' },
|
|
1777
|
+
});
|
|
1778
|
+
expect(controller.actions.get('req-registered-not-in-override')?.status).toBe('failed');
|
|
1779
|
+
expect(controller.actions.get('req-registered-not-in-override')?.error).toMatch(/not mounted/);
|
|
1780
|
+
// In the override, but never registered anywhere: ALSO rejected — this
|
|
1781
|
+
// is the exact case the fix closes (previously accepted).
|
|
1782
|
+
await controller.previewAction({
|
|
1783
|
+
version: 1,
|
|
1784
|
+
requestId: 'req-override-only-unregistered',
|
|
1785
|
+
identity: unregisteredOverrideIdentity,
|
|
1786
|
+
actionId: 'archive',
|
|
1787
|
+
phase: 'preview',
|
|
1788
|
+
selection: { scope: 'current-page' },
|
|
1789
|
+
});
|
|
1790
|
+
expect(controller.actions.get('req-override-only-unregistered')?.status).toBe('failed');
|
|
1791
|
+
expect(controller.actions.get('req-override-only-unregistered')?.error).toMatch(/not mounted/);
|
|
1792
|
+
// Both registered AND in the override: accepted.
|
|
1793
|
+
await controller.previewAction({
|
|
1794
|
+
version: 1,
|
|
1795
|
+
requestId: 'req-registered-and-in-override',
|
|
1796
|
+
identity: productsIdentity,
|
|
1797
|
+
actionId: 'archive',
|
|
1798
|
+
phase: 'preview',
|
|
1799
|
+
selection: { scope: 'current-page' },
|
|
1800
|
+
});
|
|
1801
|
+
expect(controller.actions.get('req-registered-and-in-override')?.status).toBe('previewed');
|
|
1802
|
+
await controller.applyAction('req-registered-and-in-override');
|
|
1803
|
+
expect(applySpy).toHaveBeenCalledOnce();
|
|
1804
|
+
expect(controller.actions.get('req-registered-and-in-override')?.status).toBe('applied');
|
|
1805
|
+
controller.dispose();
|
|
1806
|
+
});
|
|
1807
|
+
// Finding 2 (#2904 review, fresh cycle): a rejecting/throwing actionClient
|
|
1808
|
+
// must always reach a terminal 'failed' status, never leave the entry
|
|
1809
|
+
// stuck at 'previewing'/'applying' or escape as an unhandled rejection.
|
|
1810
|
+
it('previewAction reaches a terminal failed status when actionClient.preview rejects', async () => {
|
|
1811
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
1812
|
+
const controller = createAssistantDockController({
|
|
1813
|
+
transport: createInMemoryAssistantTransport(),
|
|
1814
|
+
registry,
|
|
1815
|
+
actionClient: {
|
|
1816
|
+
preview: async () => {
|
|
1817
|
+
throw new Error('network error');
|
|
1818
|
+
},
|
|
1819
|
+
apply: async () => {
|
|
1820
|
+
throw new Error('unreachable');
|
|
1821
|
+
},
|
|
1822
|
+
},
|
|
1823
|
+
});
|
|
1824
|
+
// await never rejects at the call site — the rejection is caught inside.
|
|
1825
|
+
await expect(controller.previewAction({
|
|
1826
|
+
version: 1,
|
|
1827
|
+
requestId: 'req-preview-rejects',
|
|
1828
|
+
identity,
|
|
1829
|
+
actionId: 'archive',
|
|
1830
|
+
phase: 'preview',
|
|
1831
|
+
selection: { scope: 'current-page' },
|
|
1832
|
+
})).resolves.toBeUndefined();
|
|
1833
|
+
const state = controller.actions.get('req-preview-rejects');
|
|
1834
|
+
expect(state?.status).toBe('failed');
|
|
1835
|
+
expect(state?.error).toBe('network error');
|
|
1836
|
+
controller.dispose();
|
|
1837
|
+
});
|
|
1838
|
+
it('applyAction reaches a terminal failed status when actionClient.apply rejects', async () => {
|
|
1839
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
1840
|
+
const controller = createAssistantDockController({
|
|
1841
|
+
transport: createInMemoryAssistantTransport(),
|
|
1842
|
+
registry,
|
|
1843
|
+
actionClient: {
|
|
1844
|
+
preview: async (request) => ({
|
|
1845
|
+
version: 1,
|
|
1846
|
+
requestId: request.requestId,
|
|
1847
|
+
identity: request.identity,
|
|
1848
|
+
actionId: request.actionId,
|
|
1849
|
+
phase: 'preview',
|
|
1850
|
+
ok: true,
|
|
1851
|
+
}),
|
|
1852
|
+
apply: async () => {
|
|
1853
|
+
throw new Error('server 500');
|
|
1854
|
+
},
|
|
1855
|
+
},
|
|
1856
|
+
});
|
|
1857
|
+
const requestId = 'req-apply-rejects';
|
|
1858
|
+
await controller.previewAction({
|
|
1859
|
+
version: 1,
|
|
1860
|
+
requestId,
|
|
1861
|
+
identity,
|
|
1862
|
+
actionId: 'archive',
|
|
1863
|
+
phase: 'preview',
|
|
1864
|
+
selection: { scope: 'current-page' },
|
|
1865
|
+
});
|
|
1866
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
1867
|
+
await expect(controller.applyAction(requestId)).resolves.toBeUndefined();
|
|
1868
|
+
const state = controller.actions.get(requestId);
|
|
1869
|
+
expect(state?.status).toBe('failed');
|
|
1870
|
+
expect(state?.error).toBe('server 500');
|
|
1871
|
+
controller.dispose();
|
|
1872
|
+
});
|
|
1873
|
+
// Finding 3 (#2904 review, fresh cycle): a Reject during an in-flight
|
|
1874
|
+
// apply must not be silently overridden by that apply landing afterward.
|
|
1875
|
+
it('a slow apply does not resurrect a rejected action as applied', async () => {
|
|
1876
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
1877
|
+
let resolveApply;
|
|
1878
|
+
const applyGate = new Promise((resolve) => {
|
|
1879
|
+
resolveApply = resolve;
|
|
1880
|
+
});
|
|
1881
|
+
const controller = createAssistantDockController({
|
|
1882
|
+
transport: createInMemoryAssistantTransport(),
|
|
1883
|
+
registry,
|
|
1884
|
+
actionClient: {
|
|
1885
|
+
preview: async (request) => ({
|
|
1886
|
+
version: 1,
|
|
1887
|
+
requestId: request.requestId,
|
|
1888
|
+
identity: request.identity,
|
|
1889
|
+
actionId: request.actionId,
|
|
1890
|
+
phase: 'preview',
|
|
1891
|
+
ok: true,
|
|
1892
|
+
}),
|
|
1893
|
+
apply: async (request) => {
|
|
1894
|
+
const ok = await applyGate;
|
|
1895
|
+
return {
|
|
1896
|
+
version: 1,
|
|
1897
|
+
requestId: request.requestId,
|
|
1898
|
+
identity: request.identity,
|
|
1899
|
+
actionId: request.actionId,
|
|
1900
|
+
phase: 'apply',
|
|
1901
|
+
ok,
|
|
1902
|
+
};
|
|
1903
|
+
},
|
|
1904
|
+
},
|
|
1905
|
+
});
|
|
1906
|
+
const thread = await controller.createThread('t1');
|
|
1907
|
+
await controller.openThread(thread.id);
|
|
1908
|
+
const requestId = 'req-reject-during-apply';
|
|
1909
|
+
await controller.previewAction({
|
|
1910
|
+
version: 1,
|
|
1911
|
+
requestId,
|
|
1912
|
+
identity,
|
|
1913
|
+
actionId: 'archive',
|
|
1914
|
+
phase: 'preview',
|
|
1915
|
+
selection: { scope: 'current-page' },
|
|
1916
|
+
});
|
|
1917
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
1918
|
+
const applyPromise = controller.applyAction(requestId);
|
|
1919
|
+
// A second concurrent call is refused outright — it must not disturb
|
|
1920
|
+
// the first one's in-flight apply.
|
|
1921
|
+
await controller.applyAction(requestId);
|
|
1922
|
+
expect(controller.actions.get(requestId)?.status).toBe('applying');
|
|
1923
|
+
// The user rejects WHILE the apply above is still in flight.
|
|
1924
|
+
controller.rejectAction(requestId);
|
|
1925
|
+
expect(controller.actions.has(requestId)).toBe(false);
|
|
1926
|
+
// The server mutation "lands" (resolves ok:true) after the rejection.
|
|
1927
|
+
resolveApply?.(true);
|
|
1928
|
+
await applyPromise;
|
|
1929
|
+
// The entry must NOT be resurrected as 'applied' — it stays gone.
|
|
1930
|
+
expect(controller.actions.has(requestId)).toBe(false);
|
|
1931
|
+
// The server mutation genuinely happened despite the rejection — the
|
|
1932
|
+
// controller surfaces that as a message rather than hiding it.
|
|
1933
|
+
expect(controller.messages.some((m) => m.content.includes('already applied by the server'))).toBe(true);
|
|
1934
|
+
controller.dispose();
|
|
1935
|
+
});
|
|
1936
|
+
// Cycle-4 second final finding 2: the `else if (!current && result.ok)`
|
|
1937
|
+
// "applied after reject" branch was the one post-await write in
|
|
1938
|
+
// applyAction() still missing the `contextEpoch` guard its siblings (the
|
|
1939
|
+
// success and catch branches) already carry. A context swap ALSO produces
|
|
1940
|
+
// `!current` (resetConversationStateForContextSwap()'s actions.clear()),
|
|
1941
|
+
// so without the guard, an apply resolving `ok: true` after a swap would
|
|
1942
|
+
// append a system message naming the OLD context's actionId into the NEW
|
|
1943
|
+
// context's `messages`, stamped with the NEW `activeThreadId`.
|
|
1944
|
+
it('does not append an "already applied" system message into the NEW context after a swap during an in-flight apply', async () => {
|
|
1945
|
+
const { registry: registryA, identity } = realRegistryWithSurface('orders');
|
|
1946
|
+
let resolveApply;
|
|
1947
|
+
const applyGate = new Promise((resolve) => {
|
|
1948
|
+
resolveApply = resolve;
|
|
1949
|
+
});
|
|
1950
|
+
const transportA = createInMemoryAssistantTransport();
|
|
1951
|
+
const transportB = createInMemoryAssistantTransport();
|
|
1952
|
+
let currentTransport = transportA;
|
|
1953
|
+
let currentRegistry = registryA;
|
|
1954
|
+
const controller = createAssistantDockController({
|
|
1955
|
+
get transport() {
|
|
1956
|
+
return currentTransport;
|
|
1957
|
+
},
|
|
1958
|
+
get registry() {
|
|
1959
|
+
return currentRegistry;
|
|
1960
|
+
},
|
|
1961
|
+
actionClient: {
|
|
1962
|
+
preview: async (request) => ({
|
|
1963
|
+
version: 1,
|
|
1964
|
+
requestId: request.requestId,
|
|
1965
|
+
identity: request.identity,
|
|
1966
|
+
actionId: request.actionId,
|
|
1967
|
+
phase: 'preview',
|
|
1968
|
+
ok: true,
|
|
1969
|
+
}),
|
|
1970
|
+
apply: async (request) => {
|
|
1971
|
+
const ok = await applyGate;
|
|
1972
|
+
return {
|
|
1973
|
+
version: 1,
|
|
1974
|
+
requestId: request.requestId,
|
|
1975
|
+
identity: request.identity,
|
|
1976
|
+
actionId: request.actionId,
|
|
1977
|
+
phase: 'apply',
|
|
1978
|
+
ok,
|
|
1979
|
+
};
|
|
1980
|
+
},
|
|
1981
|
+
},
|
|
1982
|
+
});
|
|
1983
|
+
const threadA = await controller.createThread('t1');
|
|
1984
|
+
await controller.openThread(threadA.id);
|
|
1985
|
+
const requestId = 'req-swap-during-apply';
|
|
1986
|
+
await controller.previewAction({
|
|
1987
|
+
version: 1,
|
|
1988
|
+
requestId,
|
|
1989
|
+
identity,
|
|
1990
|
+
actionId: 'archive',
|
|
1991
|
+
phase: 'preview',
|
|
1992
|
+
selection: { scope: 'current-page' },
|
|
1993
|
+
});
|
|
1994
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewed');
|
|
1995
|
+
const applyPromise = controller.applyAction(requestId);
|
|
1996
|
+
expect(controller.actions.get(requestId)?.status).toBe('applying');
|
|
1997
|
+
// Context swap WHILE the apply above is still in flight — this clears
|
|
1998
|
+
// `actions` (producing the same `!current` condition a reject would)
|
|
1999
|
+
// and opens a thread in the NEW context.
|
|
2000
|
+
currentTransport = transportB;
|
|
2001
|
+
currentRegistry = realRegistryWithSurface('orders').registry;
|
|
2002
|
+
controller.syncRegistry();
|
|
2003
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2004
|
+
const threadB = await controller.createThread('t2');
|
|
2005
|
+
await controller.openThread(threadB.id);
|
|
2006
|
+
expect(controller.actions.has(requestId)).toBe(false);
|
|
2007
|
+
// The stale apply from context A now resolves ok:true — it must NOT
|
|
2008
|
+
// write a system message into the NEW context's messages.
|
|
2009
|
+
resolveApply?.(true);
|
|
2010
|
+
await applyPromise;
|
|
2011
|
+
expect(controller.messages.some((m) => m.content.includes('already applied by the server'))).toBe(false);
|
|
2012
|
+
controller.dispose();
|
|
2013
|
+
});
|
|
2014
|
+
// Finding 4 (#2904 review, fresh cycle): a failed listThreads must be
|
|
2015
|
+
// visible on controller.error, not swallowed as an empty thread list.
|
|
2016
|
+
it('loadThreads() records a transport rejection on controller.error', async () => {
|
|
2017
|
+
const transport = createInMemoryAssistantTransport();
|
|
2018
|
+
transport.listThreads = async () => {
|
|
2019
|
+
throw new Error('offline');
|
|
2020
|
+
};
|
|
2021
|
+
const controller = createAssistantDockController({
|
|
2022
|
+
transport,
|
|
2023
|
+
registry: fakeRegistry([]),
|
|
2024
|
+
});
|
|
2025
|
+
expect(controller.error).toBeNull();
|
|
2026
|
+
await controller.loadThreads();
|
|
2027
|
+
expect(controller.error).toBe('offline');
|
|
2028
|
+
expect(controller.threads).toEqual([]);
|
|
2029
|
+
controller.dispose();
|
|
2030
|
+
});
|
|
2031
|
+
it('a successful loadThreads() clears a previously-recorded error', async () => {
|
|
2032
|
+
const transport = createInMemoryAssistantTransport();
|
|
2033
|
+
const controller = createAssistantDockController({
|
|
2034
|
+
transport,
|
|
2035
|
+
registry: fakeRegistry([]),
|
|
2036
|
+
});
|
|
2037
|
+
controller.setError('stale error from something else');
|
|
2038
|
+
await controller.loadThreads();
|
|
2039
|
+
expect(controller.error).toBeNull();
|
|
2040
|
+
controller.dispose();
|
|
2041
|
+
});
|
|
2042
|
+
// Cycle-2 second final finding 1: `surfaces` is captured once at
|
|
2043
|
+
// construction and reassignment was never observed — reachable exactly the
|
|
2044
|
+
// way AssistantDock.svelte passes it, via a live getter.
|
|
2045
|
+
describe('syncSurfaces() (cycle-2 second final finding 1)', () => {
|
|
2046
|
+
it('re-reads a reassigned `surfaces` getter and gates previewAction accordingly, in both directions', async () => {
|
|
2047
|
+
const { registry, identity: ordersIdentity } = realRegistryWithSurface('orders');
|
|
2048
|
+
const productsIdentity = {
|
|
2049
|
+
surfaceId: 'products',
|
|
2050
|
+
kind: 'table',
|
|
2051
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
2052
|
+
};
|
|
2053
|
+
// Copilot PR #2919 jAwr0: `surfaces` narrows against the live
|
|
2054
|
+
// registry, so `products` must be genuinely registered too, or it
|
|
2055
|
+
// would never pass the mount gate regardless of the override.
|
|
2056
|
+
registry.register({
|
|
2057
|
+
descriptor: {
|
|
2058
|
+
version: 1,
|
|
2059
|
+
identity: productsIdentity,
|
|
2060
|
+
schemaVersion: 1,
|
|
2061
|
+
label: 'products',
|
|
2062
|
+
rowKey: 'id',
|
|
2063
|
+
columns: [
|
|
2064
|
+
{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' },
|
|
2065
|
+
],
|
|
2066
|
+
query: {
|
|
2067
|
+
modes: ['rows'],
|
|
2068
|
+
projectableColumnIds: ['id'],
|
|
2069
|
+
searchableColumnIds: [],
|
|
2070
|
+
filterableColumnIds: [],
|
|
2071
|
+
sortableColumnIds: [],
|
|
2072
|
+
},
|
|
2073
|
+
actions: [],
|
|
2074
|
+
controls: [],
|
|
2075
|
+
limits: {
|
|
2076
|
+
maxQueryRows: 10,
|
|
2077
|
+
maxQueryBytes: 10_000,
|
|
2078
|
+
maxSelectionSize: 10,
|
|
2079
|
+
},
|
|
2080
|
+
},
|
|
2081
|
+
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
2082
|
+
});
|
|
2083
|
+
let currentSurfaces = [productsIdentity];
|
|
2084
|
+
const controller = createAssistantDockController({
|
|
2085
|
+
transport: createInMemoryAssistantTransport(),
|
|
2086
|
+
registry,
|
|
2087
|
+
get surfaces() {
|
|
2088
|
+
return currentSurfaces;
|
|
2089
|
+
},
|
|
2090
|
+
actionClient: {
|
|
2091
|
+
preview: async (request) => ({
|
|
2092
|
+
version: 1,
|
|
2093
|
+
requestId: request.requestId,
|
|
2094
|
+
identity: request.identity,
|
|
2095
|
+
actionId: request.actionId,
|
|
2096
|
+
phase: 'preview',
|
|
2097
|
+
ok: true,
|
|
2098
|
+
}),
|
|
2099
|
+
apply: async (request) => ({
|
|
2100
|
+
version: 1,
|
|
2101
|
+
requestId: request.requestId,
|
|
2102
|
+
identity: request.identity,
|
|
2103
|
+
actionId: request.actionId,
|
|
2104
|
+
phase: 'apply',
|
|
2105
|
+
ok: true,
|
|
2106
|
+
}),
|
|
2107
|
+
},
|
|
2108
|
+
});
|
|
2109
|
+
// Initial override: only `products` is mounted, `orders` is gated out
|
|
2110
|
+
// even though it's genuinely registered.
|
|
2111
|
+
expect(controller.surfaces).toEqual([productsIdentity]);
|
|
2112
|
+
await controller.previewAction({
|
|
2113
|
+
version: 1,
|
|
2114
|
+
requestId: 'req-orders-1',
|
|
2115
|
+
identity: ordersIdentity,
|
|
2116
|
+
actionId: 'archive',
|
|
2117
|
+
phase: 'preview',
|
|
2118
|
+
selection: { scope: 'current-page' },
|
|
2119
|
+
});
|
|
2120
|
+
expect(controller.actions.get('req-orders-1')?.status).toBe('failed');
|
|
2121
|
+
// Narrow the override to an EMPTY list — the previously-mounted
|
|
2122
|
+
// `products` preview must be invalidated (it fell out of scope), and a
|
|
2123
|
+
// fresh preview against it must now be gated too.
|
|
2124
|
+
const productsPreview = productsIdentity;
|
|
2125
|
+
await controller.previewAction({
|
|
2126
|
+
version: 1,
|
|
2127
|
+
requestId: 'req-products-1',
|
|
2128
|
+
identity: productsPreview,
|
|
2129
|
+
actionId: 'archive',
|
|
2130
|
+
phase: 'preview',
|
|
2131
|
+
selection: { scope: 'current-page' },
|
|
2132
|
+
});
|
|
2133
|
+
expect(controller.actions.get('req-products-1')?.status).toBe('previewed');
|
|
2134
|
+
currentSurfaces = [];
|
|
2135
|
+
controller.syncSurfaces();
|
|
2136
|
+
expect(controller.surfaces).toEqual([]);
|
|
2137
|
+
expect(controller.actions.get('req-products-1')?.status).toBe('failed');
|
|
2138
|
+
// Widen the override back to include `orders` — discovery AND the
|
|
2139
|
+
// gate must follow the reassignment.
|
|
2140
|
+
currentSurfaces = [ordersIdentity];
|
|
2141
|
+
controller.syncSurfaces();
|
|
2142
|
+
expect(controller.surfaces).toEqual([ordersIdentity]);
|
|
2143
|
+
await controller.previewAction({
|
|
2144
|
+
version: 1,
|
|
2145
|
+
requestId: 'req-orders-2',
|
|
2146
|
+
identity: ordersIdentity,
|
|
2147
|
+
actionId: 'archive',
|
|
2148
|
+
phase: 'preview',
|
|
2149
|
+
selection: { scope: 'current-page' },
|
|
2150
|
+
});
|
|
2151
|
+
expect(controller.actions.get('req-orders-2')?.status).toBe('previewed');
|
|
2152
|
+
controller.dispose();
|
|
2153
|
+
});
|
|
2154
|
+
it('falls back to live registry discovery when `surfaces` is reassigned from defined to undefined', () => {
|
|
2155
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
2156
|
+
let currentSurfaces = [];
|
|
2157
|
+
const controller = createAssistantDockController({
|
|
2158
|
+
transport: createInMemoryAssistantTransport(),
|
|
2159
|
+
registry,
|
|
2160
|
+
get surfaces() {
|
|
2161
|
+
return currentSurfaces;
|
|
2162
|
+
},
|
|
2163
|
+
});
|
|
2164
|
+
// Override is an explicit empty list: registry's real `orders` entry
|
|
2165
|
+
// is suppressed.
|
|
2166
|
+
expect(controller.surfaces).toEqual([]);
|
|
2167
|
+
// Reassign to undefined: discovery must fall back to the live
|
|
2168
|
+
// registry contents.
|
|
2169
|
+
currentSurfaces = undefined;
|
|
2170
|
+
controller.syncSurfaces();
|
|
2171
|
+
expect(controller.surfaces).toEqual([identity]);
|
|
2172
|
+
controller.dispose();
|
|
2173
|
+
});
|
|
2174
|
+
it('the mounted-once "listThreads called exactly once" guarantee (F1) is unaffected by syncSurfaces() calls', async () => {
|
|
2175
|
+
const transport = createInMemoryAssistantTransport();
|
|
2176
|
+
const listThreadsSpy = vi.spyOn(transport, 'listThreads');
|
|
2177
|
+
const { registry } = realRegistryWithSurface('orders');
|
|
2178
|
+
const controller = createAssistantDockController({
|
|
2179
|
+
transport,
|
|
2180
|
+
registry,
|
|
2181
|
+
surfaces: [],
|
|
2182
|
+
});
|
|
2183
|
+
await controller.loadThreads();
|
|
2184
|
+
controller.syncSurfaces();
|
|
2185
|
+
controller.syncSurfaces();
|
|
2186
|
+
expect(listThreadsSpy).toHaveBeenCalledTimes(1);
|
|
2187
|
+
controller.dispose();
|
|
2188
|
+
});
|
|
2189
|
+
});
|
|
2190
|
+
// Cycle-2 second final finding 2: createThread/openThread/retry/pollTick
|
|
2191
|
+
// must never leave an unhandled rejection and must route failures to
|
|
2192
|
+
// controller.error.
|
|
2193
|
+
describe('unhandled-rejection guarding (cycle-2 second final finding 2)', () => {
|
|
2194
|
+
it('a rejecting createThread() records the error and does not change activeThreadId', async () => {
|
|
2195
|
+
const transport = createInMemoryAssistantTransport();
|
|
2196
|
+
transport.createThread = async () => {
|
|
2197
|
+
throw new Error('no writeEndpoint configured');
|
|
2198
|
+
};
|
|
2199
|
+
const controller = createAssistantDockController({
|
|
2200
|
+
transport,
|
|
2201
|
+
registry: fakeRegistry([]),
|
|
2202
|
+
});
|
|
2203
|
+
expect(controller.activeThreadId).toBeNull();
|
|
2204
|
+
await expect(controller.createThread('New conversation')).rejects.toThrow('no writeEndpoint configured');
|
|
2205
|
+
expect(controller.error).toBe('no writeEndpoint configured');
|
|
2206
|
+
expect(controller.activeThreadId).toBeNull();
|
|
2207
|
+
controller.dispose();
|
|
2208
|
+
});
|
|
2209
|
+
it('a rejecting openThread() leaves the previous thread active and its messages intact', async () => {
|
|
2210
|
+
const { transport, store } = scriptedTransport({
|
|
2211
|
+
'thread-a': [
|
|
2212
|
+
{
|
|
2213
|
+
id: 'm1',
|
|
2214
|
+
threadId: 'thread-a',
|
|
2215
|
+
content: 'hello from a',
|
|
2216
|
+
role: 'user',
|
|
2217
|
+
createdAt: new Date(),
|
|
2218
|
+
},
|
|
2219
|
+
],
|
|
2220
|
+
'thread-b': [],
|
|
2221
|
+
});
|
|
2222
|
+
const controller = createAssistantDockController({
|
|
2223
|
+
transport,
|
|
2224
|
+
registry: fakeRegistry([]),
|
|
2225
|
+
});
|
|
2226
|
+
await controller.openThread('thread-a');
|
|
2227
|
+
expect(controller.activeThreadId).toBe('thread-a');
|
|
2228
|
+
expect(controller.messages).toHaveLength(1);
|
|
2229
|
+
const originalLoadMessages = transport.loadMessages.bind(transport);
|
|
2230
|
+
transport.loadMessages = async (threadId) => {
|
|
2231
|
+
if (threadId === 'thread-b')
|
|
2232
|
+
throw new Error('load failed');
|
|
2233
|
+
return originalLoadMessages(threadId);
|
|
2234
|
+
};
|
|
2235
|
+
// await never rejects at the call site — caught internally.
|
|
2236
|
+
await expect(controller.openThread('thread-b')).resolves.toBeUndefined();
|
|
2237
|
+
expect(controller.activeThreadId).toBe('thread-a');
|
|
2238
|
+
expect(controller.messages).toHaveLength(1);
|
|
2239
|
+
expect(controller.error).toBe('load failed');
|
|
2240
|
+
void store;
|
|
2241
|
+
controller.dispose();
|
|
2242
|
+
});
|
|
2243
|
+
it('loadMessages failing mid-poll sets the error once and recovers on the next successful poll', async () => {
|
|
2244
|
+
vi.useFakeTimers();
|
|
2245
|
+
try {
|
|
2246
|
+
const { transport, store } = scriptedTransport({ a: [] });
|
|
2247
|
+
let shouldFail = false;
|
|
2248
|
+
let failureCount = 0;
|
|
2249
|
+
const originalLoadMessages = transport.loadMessages.bind(transport);
|
|
2250
|
+
transport.loadMessages = async (threadId) => {
|
|
2251
|
+
if (shouldFail) {
|
|
2252
|
+
failureCount += 1;
|
|
2253
|
+
throw new Error('poll transport down');
|
|
2254
|
+
}
|
|
2255
|
+
return originalLoadMessages(threadId);
|
|
2256
|
+
};
|
|
2257
|
+
const controller = createAssistantDockController({
|
|
2258
|
+
transport,
|
|
2259
|
+
registry: fakeRegistry([]),
|
|
2260
|
+
activePollIntervalMs: 10,
|
|
2261
|
+
idlePollIntervalMs: 10,
|
|
2262
|
+
});
|
|
2263
|
+
await controller.openThread('a');
|
|
2264
|
+
controller.startPolling();
|
|
2265
|
+
shouldFail = true;
|
|
2266
|
+
await vi.advanceTimersByTimeAsync(35);
|
|
2267
|
+
expect(controller.error).toBe('poll transport down');
|
|
2268
|
+
// Recorded once, not once per tick, even though several ticks fired.
|
|
2269
|
+
expect(failureCount).toBeGreaterThan(1);
|
|
2270
|
+
const failureCountAtCheck = failureCount;
|
|
2271
|
+
expect(controller.error).toBe('poll transport down');
|
|
2272
|
+
void failureCountAtCheck;
|
|
2273
|
+
shouldFail = false;
|
|
2274
|
+
pushAssistantReply(store, 'a', 'recovered');
|
|
2275
|
+
await vi.advanceTimersByTimeAsync(15);
|
|
2276
|
+
expect(controller.error).toBeNull();
|
|
2277
|
+
controller.dispose();
|
|
2278
|
+
}
|
|
2279
|
+
finally {
|
|
2280
|
+
vi.useRealTimers();
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
it('a rejecting retry() surfaces the error and leaves the pending send in its failed status', async () => {
|
|
2284
|
+
const { transport } = scriptedTransport({ a: [] });
|
|
2285
|
+
let failNextSend = false;
|
|
2286
|
+
const originalSendMessage = transport.sendMessage.bind(transport);
|
|
2287
|
+
transport.sendMessage = async (input) => {
|
|
2288
|
+
if (failNextSend)
|
|
2289
|
+
throw new Error('retry send failed');
|
|
2290
|
+
return originalSendMessage(input);
|
|
2291
|
+
};
|
|
2292
|
+
const controller = createAssistantDockController({
|
|
2293
|
+
transport,
|
|
2294
|
+
registry: fakeRegistry([]),
|
|
2295
|
+
});
|
|
2296
|
+
await controller.openThread('a');
|
|
2297
|
+
failNextSend = true;
|
|
2298
|
+
await expect(controller.send('hi there')).rejects.toThrow('retry send failed');
|
|
2299
|
+
const pending = controller.pendingSends.find((p) => p.content === 'hi there');
|
|
2300
|
+
expect(pending?.status).toBe('failed');
|
|
2301
|
+
// await never rejects at the call site — caught internally.
|
|
2302
|
+
await expect(controller.retry(pending?.clientRequestId ?? '')).resolves.toBeUndefined();
|
|
2303
|
+
expect(controller.error).toBe('retry send failed');
|
|
2304
|
+
expect(controller.pendingSends.find((p) => p.content === 'hi there')?.status).toBe('failed');
|
|
2305
|
+
controller.dispose();
|
|
2306
|
+
});
|
|
2307
|
+
});
|
|
2308
|
+
// Cycle-3 first final finding 1: previewAction's post-await write used the
|
|
2309
|
+
// pre-await snapshot unconditionally, so an invalidation (unregister,
|
|
2310
|
+
// syncSurfaces narrowing, registry swap) or an explicit rejectAction()
|
|
2311
|
+
// landing WHILE the preview call is in flight got clobbered the instant
|
|
2312
|
+
// the preview resolved — resurrecting a fail-closed/rejected entry as a
|
|
2313
|
+
// confirmable 'previewed' card. Mirrors the guard applyAction already had
|
|
2314
|
+
// (cycle-2 finding 3's "slow apply does not resurrect a rejected action"
|
|
2315
|
+
// test above).
|
|
2316
|
+
describe('previewAction post-await re-check (cycle-3 first final finding 1)', () => {
|
|
2317
|
+
function gatedPreviewClient() {
|
|
2318
|
+
let resolvePreview;
|
|
2319
|
+
const previewGate = new Promise((resolve) => {
|
|
2320
|
+
resolvePreview = resolve;
|
|
2321
|
+
});
|
|
2322
|
+
return {
|
|
2323
|
+
client: {
|
|
2324
|
+
preview: async (request) => {
|
|
2325
|
+
const ok = await previewGate;
|
|
2326
|
+
return {
|
|
2327
|
+
version: 1,
|
|
2328
|
+
requestId: request.requestId,
|
|
2329
|
+
identity: request.identity,
|
|
2330
|
+
actionId: request.actionId,
|
|
2331
|
+
phase: 'preview',
|
|
2332
|
+
ok,
|
|
2333
|
+
};
|
|
2334
|
+
},
|
|
2335
|
+
apply: async () => {
|
|
2336
|
+
throw new Error('unreachable');
|
|
2337
|
+
},
|
|
2338
|
+
},
|
|
2339
|
+
resolvePreview: () => resolvePreview?.(true),
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
it('an unregister during a pending preview leaves it failed after the preview resolves', async () => {
|
|
2343
|
+
const { registry, identity, unregister } = realRegistryWithSurface('orders');
|
|
2344
|
+
const { client, resolvePreview } = gatedPreviewClient();
|
|
2345
|
+
const controller = createAssistantDockController({
|
|
2346
|
+
transport: createInMemoryAssistantTransport(),
|
|
2347
|
+
registry,
|
|
2348
|
+
actionClient: client,
|
|
2349
|
+
});
|
|
2350
|
+
const requestId = 'req-preview-unregister-race';
|
|
2351
|
+
const previewPromise = controller.previewAction({
|
|
2352
|
+
version: 1,
|
|
2353
|
+
requestId,
|
|
2354
|
+
identity,
|
|
2355
|
+
actionId: 'archive',
|
|
2356
|
+
phase: 'preview',
|
|
2357
|
+
selection: { scope: 'current-page' },
|
|
2358
|
+
});
|
|
2359
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewing');
|
|
2360
|
+
// The surface unmounts WHILE the preview call is in flight.
|
|
2361
|
+
unregister();
|
|
2362
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
2363
|
+
// The preview call now resolves 'ok' — the stale success write must
|
|
2364
|
+
// NOT resurrect the entry as 'previewed'.
|
|
2365
|
+
resolvePreview();
|
|
2366
|
+
await previewPromise;
|
|
2367
|
+
expect(controller.actions.get(requestId)?.status).toBe('failed');
|
|
2368
|
+
expect(controller.actions.get(requestId)?.error).toMatch(/unmounted/);
|
|
2369
|
+
controller.dispose();
|
|
2370
|
+
});
|
|
2371
|
+
it('rejectAction during a pending preview leaves the entry deleted', async () => {
|
|
2372
|
+
const { registry, identity } = realRegistryWithSurface('orders');
|
|
2373
|
+
const { client, resolvePreview } = gatedPreviewClient();
|
|
2374
|
+
const controller = createAssistantDockController({
|
|
2375
|
+
transport: createInMemoryAssistantTransport(),
|
|
2376
|
+
registry,
|
|
2377
|
+
actionClient: client,
|
|
2378
|
+
});
|
|
2379
|
+
const requestId = 'req-preview-reject-race';
|
|
2380
|
+
const previewPromise = controller.previewAction({
|
|
2381
|
+
version: 1,
|
|
2382
|
+
requestId,
|
|
2383
|
+
identity,
|
|
2384
|
+
actionId: 'archive',
|
|
2385
|
+
phase: 'preview',
|
|
2386
|
+
selection: { scope: 'current-page' },
|
|
2387
|
+
});
|
|
2388
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewing');
|
|
2389
|
+
// The user rejects WHILE the preview call is in flight.
|
|
2390
|
+
controller.rejectAction(requestId);
|
|
2391
|
+
expect(controller.actions.has(requestId)).toBe(false);
|
|
2392
|
+
resolvePreview();
|
|
2393
|
+
await previewPromise;
|
|
2394
|
+
// The stale success write must NOT resurrect the rejected entry.
|
|
2395
|
+
expect(controller.actions.has(requestId)).toBe(false);
|
|
2396
|
+
controller.dispose();
|
|
2397
|
+
});
|
|
2398
|
+
it('a registry swap during a pending preview leaves the entry failed', async () => {
|
|
2399
|
+
const r1 = realRegistryWithSurface('orders');
|
|
2400
|
+
const r2 = realRegistryWithSurface('orders');
|
|
2401
|
+
const { client, resolvePreview } = gatedPreviewClient();
|
|
2402
|
+
let currentRegistry = r1.registry;
|
|
2403
|
+
const controller = createAssistantDockController({
|
|
2404
|
+
transport: createInMemoryAssistantTransport(),
|
|
2405
|
+
get registry() {
|
|
2406
|
+
return currentRegistry;
|
|
2407
|
+
},
|
|
2408
|
+
actionClient: client,
|
|
2409
|
+
});
|
|
2410
|
+
const requestId = 'req-preview-registry-swap-race';
|
|
2411
|
+
const previewPromise = controller.previewAction({
|
|
2412
|
+
version: 1,
|
|
2413
|
+
requestId,
|
|
2414
|
+
identity: r1.identity,
|
|
2415
|
+
actionId: 'archive',
|
|
2416
|
+
phase: 'preview',
|
|
2417
|
+
selection: { scope: 'current-page' },
|
|
2418
|
+
});
|
|
2419
|
+
expect(controller.actions.get(requestId)?.status).toBe('previewing');
|
|
2420
|
+
// The host swaps the registry instance (e.g. a route/tenant change)
|
|
2421
|
+
// WHILE the preview call is in flight. Copilot PR #2919 jAwsd: a
|
|
2422
|
+
// registry swap now fully clears the actions map rather than marking
|
|
2423
|
+
// each entry 'failed'.
|
|
2424
|
+
currentRegistry = r2.registry;
|
|
2425
|
+
controller.syncRegistry();
|
|
2426
|
+
expect(controller.actions.has(requestId)).toBe(false);
|
|
2427
|
+
resolvePreview();
|
|
2428
|
+
await previewPromise;
|
|
2429
|
+
// The stale success write must NOT resurrect the entry as 'previewed'
|
|
2430
|
+
// under the OLD registry's trust boundary — it must stay gone.
|
|
2431
|
+
expect(controller.actions.has(requestId)).toBe(false);
|
|
2432
|
+
controller.dispose();
|
|
2433
|
+
});
|
|
2434
|
+
});
|
|
2435
|
+
// Cycle-3 first final sweep: openThread() wrote activeThreadId/messages
|
|
2436
|
+
// unconditionally after its await, so two overlapping calls (e.g. a fast
|
|
2437
|
+
// double-click on two different threads) raced on whichever
|
|
2438
|
+
// loadMessages() happened to resolve LAST, regardless of which openThread
|
|
2439
|
+
// call was started last — an older, slower request could stomp the
|
|
2440
|
+
// newer one's messages after the user had already moved on.
|
|
2441
|
+
it('openThread() only writes from the MOST RECENTLY STARTED call when two calls overlap', async () => {
|
|
2442
|
+
const { transport, store } = scriptedTransport({
|
|
2443
|
+
'thread-a': [
|
|
2444
|
+
{
|
|
2445
|
+
id: 'a1',
|
|
2446
|
+
threadId: 'thread-a',
|
|
2447
|
+
content: 'hello from a',
|
|
2448
|
+
role: 'user',
|
|
2449
|
+
createdAt: new Date(),
|
|
2450
|
+
},
|
|
2451
|
+
],
|
|
2452
|
+
'thread-b': [
|
|
2453
|
+
{
|
|
2454
|
+
id: 'b1',
|
|
2455
|
+
threadId: 'thread-b',
|
|
2456
|
+
content: 'hello from b',
|
|
2457
|
+
role: 'user',
|
|
2458
|
+
createdAt: new Date(),
|
|
2459
|
+
},
|
|
2460
|
+
],
|
|
2461
|
+
});
|
|
2462
|
+
let resolveA;
|
|
2463
|
+
const gateA = new Promise((resolve) => {
|
|
2464
|
+
resolveA = resolve;
|
|
2465
|
+
});
|
|
2466
|
+
const originalLoadMessages = transport.loadMessages.bind(transport);
|
|
2467
|
+
transport.loadMessages = async (threadId) => {
|
|
2468
|
+
if (threadId === 'thread-a')
|
|
2469
|
+
await gateA;
|
|
2470
|
+
return originalLoadMessages(threadId);
|
|
2471
|
+
};
|
|
2472
|
+
const controller = createAssistantDockController({
|
|
2473
|
+
transport,
|
|
2474
|
+
registry: fakeRegistry([]),
|
|
2475
|
+
});
|
|
2476
|
+
// Start opening thread-a (its loadMessages call is gated) and, before it
|
|
2477
|
+
// resolves, start opening thread-b (unGated — resolves first).
|
|
2478
|
+
const openA = controller.openThread('thread-a');
|
|
2479
|
+
const openB = controller.openThread('thread-b');
|
|
2480
|
+
await openB;
|
|
2481
|
+
expect(controller.activeThreadId).toBe('thread-b');
|
|
2482
|
+
expect(controller.messages.map((m) => m.id)).toEqual(['b1']);
|
|
2483
|
+
// thread-a's older, slower request now resolves — it must NOT stomp
|
|
2484
|
+
// thread-b's state, since a newer openThread() call has since started.
|
|
2485
|
+
resolveA?.();
|
|
2486
|
+
await openA;
|
|
2487
|
+
expect(controller.activeThreadId).toBe('thread-b');
|
|
2488
|
+
expect(controller.messages.map((m) => m.id)).toEqual(['b1']);
|
|
2489
|
+
void store;
|
|
2490
|
+
controller.dispose();
|
|
2491
|
+
});
|
|
2492
|
+
});
|