@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.
Files changed (47) hide show
  1. package/AGENTS.md +19 -0
  2. package/dist/index.js +1 -1
  3. package/dist/manifest.json +1 -1
  4. package/dist/smrt-knowledge.json +5 -5
  5. package/dist/svelte/components/agent/ToolCallDisplay.svelte +103 -7
  6. package/dist/svelte/components/agent/ToolCallDisplay.svelte.d.ts +18 -0
  7. package/dist/svelte/components/agent/ToolCallDisplay.svelte.d.ts.map +1 -1
  8. package/dist/svelte/components/agent/__tests__/ToolCallDisplay.test.js +100 -9
  9. package/dist/svelte/components/assistant/AssistantComposer.svelte +354 -0
  10. package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts +20 -0
  11. package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts.map +1 -0
  12. package/dist/svelte/components/assistant/AssistantDock.svelte +534 -0
  13. package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts +30 -0
  14. package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts.map +1 -0
  15. package/dist/svelte/components/assistant/AssistantThreadList.svelte +136 -0
  16. package/dist/svelte/components/assistant/AssistantThreadList.svelte.d.ts +15 -0
  17. package/dist/svelte/components/assistant/AssistantThreadList.svelte.d.ts.map +1 -0
  18. package/dist/svelte/components/assistant/__tests__/AssistantComposer.test.js +75 -0
  19. package/dist/svelte/components/assistant/__tests__/AssistantDock.test.js +395 -0
  20. package/dist/svelte/components/assistant/__tests__/AssistantThreadList.test.js +45 -0
  21. package/dist/svelte/components/assistant/__tests__/action-status.test.js +25 -0
  22. package/dist/svelte/components/assistant/__tests__/assistant-transport.test.js +253 -0
  23. package/dist/svelte/components/assistant/__tests__/attachment-href.test.js +31 -0
  24. package/dist/svelte/components/assistant/__tests__/create-assistant-dock-controller.test.js +2492 -0
  25. package/dist/svelte/components/assistant/action-status.d.ts +14 -0
  26. package/dist/svelte/components/assistant/action-status.d.ts.map +1 -0
  27. package/dist/svelte/components/assistant/action-status.js +7 -0
  28. package/dist/svelte/components/assistant/assistant-transport.d.ts +239 -0
  29. package/dist/svelte/components/assistant/assistant-transport.d.ts.map +1 -0
  30. package/dist/svelte/components/assistant/assistant-transport.js +306 -0
  31. package/dist/svelte/components/assistant/attachment-href.d.ts +23 -0
  32. package/dist/svelte/components/assistant/attachment-href.d.ts.map +1 -0
  33. package/dist/svelte/components/assistant/attachment-href.js +36 -0
  34. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts +172 -0
  35. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts.map +1 -0
  36. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.js +0 -0
  37. package/dist/svelte/components/shared/ModelPicker.svelte +84 -0
  38. package/dist/svelte/components/shared/ModelPicker.svelte.d.ts +26 -0
  39. package/dist/svelte/components/shared/ModelPicker.svelte.d.ts.map +1 -0
  40. package/dist/svelte/components/shared/__tests__/ModelPicker.test.js +36 -0
  41. package/dist/svelte/i18n.d.ts +21 -0
  42. package/dist/svelte/i18n.d.ts.map +1 -1
  43. package/dist/svelte/i18n.js +25 -0
  44. package/dist/svelte/index.d.ts +7 -0
  45. package/dist/svelte/index.d.ts.map +1 -1
  46. package/dist/svelte/index.js +12 -0
  47. 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
+ });