@happyvertical/smrt-chat 0.51.10 → 0.51.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -3
- package/dist/chunks/{ChatService-B2piYas4.js → ChatService-D48KBDZy.js} +110 -7
- package/dist/chunks/ChatService-D48KBDZy.js.map +1 -0
- package/dist/collections/AgentSessionCollection.d.ts +2 -0
- package/dist/collections/AgentSessionCollection.d.ts.map +1 -1
- package/dist/index.js +10 -7
- package/dist/index.js.map +1 -1
- package/dist/internal/agent-runtime.js +1 -1
- package/dist/manifest.json +34 -5
- package/dist/models/AgentSession.d.ts +15 -0
- package/dist/models/AgentSession.d.ts.map +1 -1
- package/dist/persona-conversation.d.ts +6 -0
- package/dist/persona-conversation.d.ts.map +1 -1
- package/dist/services/ChatService.d.ts +16 -1
- package/dist/services/ChatService.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +23 -9
- package/dist/svelte/components/assistant/AssistantComposer.svelte +2 -0
- package/dist/svelte/components/assistant/AssistantDock.svelte +339 -144
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts +18 -3
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts.map +1 -1
- package/dist/svelte/components/assistant/__tests__/AssistantDock.test.js +121 -1
- package/dist/svelte/components/assistant/__tests__/assistant-dock-action-outcome.test.js +320 -0
- package/dist/svelte/components/assistant/__tests__/assistant-dock-host-actions.test.js +185 -0
- package/dist/svelte/components/assistant/__tests__/create-assistant-dock-controller.test.js +10 -11
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts +30 -0
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts.map +1 -1
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.js +0 -0
- package/dist/svelte/i18n.d.ts +3 -0
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +3 -0
- package/dist/svelte/index.d.ts +1 -1
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +1 -1
- package/dist/tool-loop.d.ts +7 -0
- package/dist/tool-loop.d.ts.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/voice.d.ts.map +1 -1
- package/package.json +14 -12
- package/dist/chunks/ChatService-B2piYas4.js.map +0 -1
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data-surface';
|
|
15
15
|
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
16
|
+
import { createRawSnippet } from 'svelte';
|
|
16
17
|
import { describe, expect, it, vi } from 'vitest';
|
|
17
18
|
import AssistantDock from '../AssistantDock.svelte';
|
|
18
|
-
import { createInMemoryAssistantTransport } from '../assistant-transport.js';
|
|
19
|
+
import { createInMemoryAssistantTransport, } from '../assistant-transport.js';
|
|
19
20
|
const identity = {
|
|
20
21
|
surfaceId: 'orders',
|
|
21
22
|
kind: 'table',
|
|
@@ -392,4 +393,123 @@ describe('AssistantDock (mounted component)', () => {
|
|
|
392
393
|
const attachmentText = await screen.findByText('evil.txt');
|
|
393
394
|
expect(attachmentText.closest('a')).toBeNull();
|
|
394
395
|
});
|
|
396
|
+
// #3000: in a narrow container the thread list collapses behind a
|
|
397
|
+
// "Conversations" disclosure (the `@container` rule decides visibility;
|
|
398
|
+
// jsdom has no layout, so this pins the state + ARIA contract the CSS
|
|
399
|
+
// keys off). Widths are measured in a real browser, see the PR.
|
|
400
|
+
it('exposes a labelled Conversations disclosure that controls the thread list and closes when a conversation starts (#3000)', async () => {
|
|
401
|
+
const registry = createDataSurfaceRegistry();
|
|
402
|
+
const transport = createInMemoryAssistantTransport();
|
|
403
|
+
const { container } = render(AssistantDock, {
|
|
404
|
+
props: { transport, registry },
|
|
405
|
+
});
|
|
406
|
+
const toggle = screen.getByRole('button', { name: 'Conversations' });
|
|
407
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
408
|
+
const controlsId = toggle.getAttribute('aria-controls');
|
|
409
|
+
expect(controlsId).toBeTruthy();
|
|
410
|
+
const region = container.querySelector(`#${CSS.escape(controlsId ?? '')}`);
|
|
411
|
+
expect(region).not.toBeNull();
|
|
412
|
+
expect(region?.querySelector('nav[aria-label="Assistant conversations"]')).not.toBeNull();
|
|
413
|
+
const layout = container.querySelector('.assistant-dock-layout');
|
|
414
|
+
expect(layout).not.toHaveAttribute('data-threads-open');
|
|
415
|
+
await userEvent.click(toggle);
|
|
416
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
417
|
+
expect(layout).toHaveAttribute('data-threads-open');
|
|
418
|
+
toggle.focus();
|
|
419
|
+
await userEvent.keyboard('{Enter}');
|
|
420
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
421
|
+
await userEvent.keyboard(' ');
|
|
422
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
423
|
+
// Starting a conversation from the open list hands the width back.
|
|
424
|
+
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
425
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
426
|
+
expect(layout).not.toHaveAttribute('data-threads-open');
|
|
427
|
+
// The activated button is now inside a hidden region; focus returns to
|
|
428
|
+
// the toggle instead of falling back to <body>.
|
|
429
|
+
await vi.waitFor(() => expect(document.activeElement).toBe(toggle));
|
|
430
|
+
await expectNoA11yViolations(container);
|
|
431
|
+
});
|
|
432
|
+
it('closes the narrow list and returns focus to the toggle when an existing thread is selected (#3000)', async () => {
|
|
433
|
+
const registry = createDataSurfaceRegistry();
|
|
434
|
+
const transport = createInMemoryAssistantTransport();
|
|
435
|
+
await transport.createThread('Existing chat');
|
|
436
|
+
render(AssistantDock, { props: { transport, registry } });
|
|
437
|
+
const toggle = screen.getByRole('button', { name: 'Conversations' });
|
|
438
|
+
await userEvent.click(toggle);
|
|
439
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
440
|
+
await userEvent.click(await screen.findByRole('button', { name: /Existing chat/ }));
|
|
441
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
442
|
+
await vi.waitFor(() => expect(document.activeElement).toBe(toggle));
|
|
443
|
+
});
|
|
444
|
+
// #2988: a host renders a message's own toolCallData through the
|
|
445
|
+
// `toolCall` snippet; without one the dock never renders the payload.
|
|
446
|
+
describe('toolCall snippet (#2988)', () => {
|
|
447
|
+
const hostile = '<img src=x onerror="window.__pwned=1">';
|
|
448
|
+
function transportWithToolCall() {
|
|
449
|
+
return createInMemoryAssistantTransport({
|
|
450
|
+
respond: (threadId, userMessage) => ({
|
|
451
|
+
id: `assistant-${userMessage.id}`,
|
|
452
|
+
threadId,
|
|
453
|
+
content: userMessage.content === 'plain' ? 'no tool' : 'with tool',
|
|
454
|
+
role: 'assistant',
|
|
455
|
+
createdAt: new Date(),
|
|
456
|
+
toolCallData: userMessage.content === 'plain'
|
|
457
|
+
? undefined
|
|
458
|
+
: { kind: 'imageEditCandidate', label: hostile },
|
|
459
|
+
}),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
async function sendThroughDock(text) {
|
|
463
|
+
const textarea = await screen.findByLabelText('Message');
|
|
464
|
+
await userEvent.type(textarea, text);
|
|
465
|
+
await userEvent.click(screen.getByRole('button', { name: 'Send' }));
|
|
466
|
+
}
|
|
467
|
+
it('renders the host snippet inside the bubble only for messages with toolCallData', async () => {
|
|
468
|
+
const seen = [];
|
|
469
|
+
const toolCall = createRawSnippet((message) => ({
|
|
470
|
+
render: () => '<span class="host-tool-call"></span>',
|
|
471
|
+
setup(node) {
|
|
472
|
+
const m = message();
|
|
473
|
+
seen.push(m);
|
|
474
|
+
const data = m.toolCallData;
|
|
475
|
+
node.textContent = `${data.kind}: ${data.label}`;
|
|
476
|
+
},
|
|
477
|
+
}));
|
|
478
|
+
const { container } = render(AssistantDock, {
|
|
479
|
+
props: {
|
|
480
|
+
transport: transportWithToolCall(),
|
|
481
|
+
registry: createDataSurfaceRegistry(),
|
|
482
|
+
toolCall,
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
486
|
+
await sendThroughDock('plain');
|
|
487
|
+
expect(await screen.findByText('no tool')).toBeInTheDocument();
|
|
488
|
+
await sendThroughDock('tool');
|
|
489
|
+
expect(await screen.findByText('with tool')).toBeInTheDocument();
|
|
490
|
+
const regions = container.querySelectorAll('.assistant-dock-tool-call');
|
|
491
|
+
expect(regions).toHaveLength(1);
|
|
492
|
+
expect(regions[0].textContent).toBe(`imageEditCandidate: ${hostile}`);
|
|
493
|
+
// Rendered as text, never parsed as markup.
|
|
494
|
+
expect(container.querySelector('img')).toBeNull();
|
|
495
|
+
expect(regions[0]
|
|
496
|
+
.closest('li')
|
|
497
|
+
?.querySelector('.assistant-dock-message-content')?.textContent).toBe('with tool');
|
|
498
|
+
expect(seen.every((m) => m.toolCallData != null)).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
it('renders no tool-call region and never the raw payload without a snippet', async () => {
|
|
501
|
+
const { container } = render(AssistantDock, {
|
|
502
|
+
props: {
|
|
503
|
+
transport: transportWithToolCall(),
|
|
504
|
+
registry: createDataSurfaceRegistry(),
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
508
|
+
await sendThroughDock('tool');
|
|
509
|
+
expect(await screen.findByText('with tool')).toBeInTheDocument();
|
|
510
|
+
expect(container.querySelector('.assistant-dock-tool-call')).toBeNull();
|
|
511
|
+
expect(container.innerHTML).not.toContain('imageEditCandidate');
|
|
512
|
+
expect(container.querySelector('img')).toBeNull();
|
|
513
|
+
});
|
|
514
|
+
});
|
|
395
515
|
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* #2990: an apply that reached no server decision ("unknown outcome") is kept
|
|
4
|
+
* apart from a refusal, keeps its idempotency key, and can only be retried
|
|
5
|
+
* with that same key — never discarded or re-proposed under a fresh one.
|
|
6
|
+
*
|
|
7
|
+
* `idempotentServer` models the server adapter's contract: the first apply
|
|
8
|
+
* for a key commits and records its result; any later apply with the same
|
|
9
|
+
* key replays that result without committing again, and a different key is
|
|
10
|
+
* a new mutation. `commits` is the double-apply oracle.
|
|
11
|
+
*/
|
|
12
|
+
import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data-surface';
|
|
13
|
+
import { render, screen, userEvent } from '@happyvertical/smrt-vitest/svelte';
|
|
14
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import AssistantDock from '../AssistantDock.svelte';
|
|
16
|
+
import { createInMemoryAssistantTransport } from '../assistant-transport.js';
|
|
17
|
+
import { ASSISTANT_ACTION_UNKNOWN_OUTCOME_REASONS, createAssistantDockController, } from '../create-assistant-dock-controller.svelte.js';
|
|
18
|
+
const identity = {
|
|
19
|
+
surfaceId: 'orders',
|
|
20
|
+
kind: 'table',
|
|
21
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
22
|
+
};
|
|
23
|
+
function registryWithSurface() {
|
|
24
|
+
const registry = createDataSurfaceRegistry();
|
|
25
|
+
const descriptor = {
|
|
26
|
+
version: 1,
|
|
27
|
+
identity,
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
label: 'Orders',
|
|
30
|
+
rowKey: 'id',
|
|
31
|
+
columns: [
|
|
32
|
+
{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' },
|
|
33
|
+
],
|
|
34
|
+
query: {
|
|
35
|
+
modes: ['rows'],
|
|
36
|
+
projectableColumnIds: ['id'],
|
|
37
|
+
searchableColumnIds: [],
|
|
38
|
+
filterableColumnIds: [],
|
|
39
|
+
sortableColumnIds: [],
|
|
40
|
+
},
|
|
41
|
+
actions: [],
|
|
42
|
+
controls: [],
|
|
43
|
+
limits: { maxQueryRows: 10, maxQueryBytes: 10_000, maxSelectionSize: 10 },
|
|
44
|
+
};
|
|
45
|
+
registry.register({
|
|
46
|
+
descriptor,
|
|
47
|
+
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
48
|
+
});
|
|
49
|
+
return registry;
|
|
50
|
+
}
|
|
51
|
+
const REQUEST_ID = 'req-ship';
|
|
52
|
+
function proposal() {
|
|
53
|
+
return {
|
|
54
|
+
version: 1,
|
|
55
|
+
requestId: REQUEST_ID,
|
|
56
|
+
identity,
|
|
57
|
+
actionId: 'ship',
|
|
58
|
+
phase: 'preview',
|
|
59
|
+
selection: { scope: 'explicit-ids', rowIds: ['order-1'] },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function result(request, phase, ok, reason) {
|
|
63
|
+
return {
|
|
64
|
+
version: 1,
|
|
65
|
+
requestId: request.requestId,
|
|
66
|
+
identity: request.identity,
|
|
67
|
+
actionId: request.actionId,
|
|
68
|
+
phase,
|
|
69
|
+
ok,
|
|
70
|
+
...(reason ? { reason } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function idempotentServer() {
|
|
74
|
+
const byKey = new Map();
|
|
75
|
+
const server = {
|
|
76
|
+
commits: 0,
|
|
77
|
+
applyCalls: 0,
|
|
78
|
+
keys: [],
|
|
79
|
+
mode: 'commit',
|
|
80
|
+
gate: undefined,
|
|
81
|
+
preview: vi.fn(async (request) => result(request, 'preview', true)),
|
|
82
|
+
client: undefined,
|
|
83
|
+
};
|
|
84
|
+
server.client = {
|
|
85
|
+
preview: server.preview,
|
|
86
|
+
apply: async (request, key) => {
|
|
87
|
+
server.applyCalls += 1;
|
|
88
|
+
server.keys.push(key);
|
|
89
|
+
if (server.gate)
|
|
90
|
+
await server.gate;
|
|
91
|
+
const replay = byKey.get(key);
|
|
92
|
+
if (replay)
|
|
93
|
+
return replay;
|
|
94
|
+
if (server.mode === 'in-progress') {
|
|
95
|
+
return result(request, 'apply', false, 'idempotency_in_progress');
|
|
96
|
+
}
|
|
97
|
+
server.commits += 1;
|
|
98
|
+
const applied = result(request, 'apply', true);
|
|
99
|
+
byKey.set(key, applied);
|
|
100
|
+
if (server.mode === 'commit-then-lose-response') {
|
|
101
|
+
throw new Error('network: response lost');
|
|
102
|
+
}
|
|
103
|
+
return applied;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
return server;
|
|
107
|
+
}
|
|
108
|
+
function setup(server = idempotentServer()) {
|
|
109
|
+
const onActionApplied = vi.fn();
|
|
110
|
+
const controller = createAssistantDockController({
|
|
111
|
+
transport: createInMemoryAssistantTransport(),
|
|
112
|
+
registry: registryWithSurface(),
|
|
113
|
+
actionClient: server.client,
|
|
114
|
+
onActionApplied,
|
|
115
|
+
});
|
|
116
|
+
return { controller, server, onActionApplied };
|
|
117
|
+
}
|
|
118
|
+
async function previewed(controller) {
|
|
119
|
+
await controller.previewAction(proposal());
|
|
120
|
+
expect(controller.actions.get(REQUEST_ID)?.status).toBe('previewed');
|
|
121
|
+
return controller.actions.get(REQUEST_ID)?.idempotencyKey;
|
|
122
|
+
}
|
|
123
|
+
describe('unknown apply outcome vs refusal (#2990)', () => {
|
|
124
|
+
it('marks a rejected apply (transport failure) as an unknown outcome and keeps its key', async () => {
|
|
125
|
+
const { controller, server } = setup();
|
|
126
|
+
const key = await previewed(controller);
|
|
127
|
+
server.mode = 'commit-then-lose-response';
|
|
128
|
+
await controller.applyAction(REQUEST_ID);
|
|
129
|
+
const state = controller.actions.get(REQUEST_ID);
|
|
130
|
+
expect(state?.status).toBe('failed');
|
|
131
|
+
expect(state?.outcomeUnknown).toBe(true);
|
|
132
|
+
expect(state?.retryable).toBe(true);
|
|
133
|
+
expect(state?.idempotencyKey).toBe(key);
|
|
134
|
+
controller.dispose();
|
|
135
|
+
});
|
|
136
|
+
it.each(ASSISTANT_ACTION_UNKNOWN_OUTCOME_REASONS)('treats ok:false with reason %s as an unknown outcome', async (reason) => {
|
|
137
|
+
const controller = createAssistantDockController({
|
|
138
|
+
transport: createInMemoryAssistantTransport(),
|
|
139
|
+
registry: registryWithSurface(),
|
|
140
|
+
actionClient: {
|
|
141
|
+
preview: async (request) => result(request, 'preview', true),
|
|
142
|
+
apply: async (request) => result(request, 'apply', false, reason),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
await previewed(controller);
|
|
146
|
+
await controller.applyAction(REQUEST_ID);
|
|
147
|
+
expect(controller.actions.get(REQUEST_ID)?.outcomeUnknown).toBe(true);
|
|
148
|
+
controller.dispose();
|
|
149
|
+
});
|
|
150
|
+
it.each([
|
|
151
|
+
'denied',
|
|
152
|
+
'not_found',
|
|
153
|
+
'idempotency_conflict',
|
|
154
|
+
'stale_revision',
|
|
155
|
+
'confirmation_required',
|
|
156
|
+
'confirmation_mismatch',
|
|
157
|
+
])('treats ok:false with reason %s as a refusal', async (reason) => {
|
|
158
|
+
const controller = createAssistantDockController({
|
|
159
|
+
transport: createInMemoryAssistantTransport(),
|
|
160
|
+
registry: registryWithSurface(),
|
|
161
|
+
actionClient: {
|
|
162
|
+
preview: async (request) => result(request, 'preview', true),
|
|
163
|
+
apply: async (request) => result(request, 'apply', false, reason),
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
await previewed(controller);
|
|
167
|
+
await controller.applyAction(REQUEST_ID);
|
|
168
|
+
const state = controller.actions.get(REQUEST_ID);
|
|
169
|
+
expect(state?.status).toBe('failed');
|
|
170
|
+
expect(state?.outcomeUnknown).toBe(false);
|
|
171
|
+
// A refusal is terminal, so discarding it is allowed.
|
|
172
|
+
controller.rejectAction(REQUEST_ID);
|
|
173
|
+
expect(controller.actions.has(REQUEST_ID)).toBe(false);
|
|
174
|
+
controller.dispose();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('an unknown outcome cannot be discarded or re-keyed (#2990)', () => {
|
|
178
|
+
it('refuses rejectAction and keeps the entry and key', async () => {
|
|
179
|
+
const { controller, server } = setup();
|
|
180
|
+
const key = await previewed(controller);
|
|
181
|
+
server.mode = 'commit-then-lose-response';
|
|
182
|
+
await controller.applyAction(REQUEST_ID);
|
|
183
|
+
controller.rejectAction(REQUEST_ID);
|
|
184
|
+
expect(controller.actions.get(REQUEST_ID)?.idempotencyKey).toBe(key);
|
|
185
|
+
expect(controller.actions.get(REQUEST_ID)?.outcomeUnknown).toBe(true);
|
|
186
|
+
expect(controller.error).toMatch(/rejectAction refused/);
|
|
187
|
+
controller.dispose();
|
|
188
|
+
});
|
|
189
|
+
it('refuses a new previewAction for the same request id', async () => {
|
|
190
|
+
const { controller, server } = setup();
|
|
191
|
+
const key = await previewed(controller);
|
|
192
|
+
server.mode = 'commit-then-lose-response';
|
|
193
|
+
await controller.applyAction(REQUEST_ID);
|
|
194
|
+
server.preview.mockClear();
|
|
195
|
+
await controller.previewAction(proposal());
|
|
196
|
+
expect(server.preview).not.toHaveBeenCalled();
|
|
197
|
+
expect(controller.actions.get(REQUEST_ID)?.idempotencyKey).toBe(key);
|
|
198
|
+
expect(controller.actions.get(REQUEST_ID)?.outcomeUnknown).toBe(true);
|
|
199
|
+
expect(controller.error).toMatch(/previewAction refused/);
|
|
200
|
+
controller.dispose();
|
|
201
|
+
});
|
|
202
|
+
it('refuses a new previewAction while an apply is in flight', async () => {
|
|
203
|
+
const { controller, server } = setup();
|
|
204
|
+
const key = await previewed(controller);
|
|
205
|
+
let release;
|
|
206
|
+
server.gate = new Promise((resolve) => {
|
|
207
|
+
release = resolve;
|
|
208
|
+
});
|
|
209
|
+
const applying = controller.applyAction(REQUEST_ID);
|
|
210
|
+
await controller.previewAction(proposal());
|
|
211
|
+
expect(controller.actions.get(REQUEST_ID)?.status).toBe('applying');
|
|
212
|
+
expect(controller.actions.get(REQUEST_ID)?.idempotencyKey).toBe(key);
|
|
213
|
+
release();
|
|
214
|
+
await applying;
|
|
215
|
+
expect(controller.actions.get(REQUEST_ID)?.status).toBe('applied');
|
|
216
|
+
expect(server.commits).toBe(1);
|
|
217
|
+
controller.dispose();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('same-key retry after an unknown outcome (#2990)', () => {
|
|
221
|
+
it('replays with the same key, applies exactly once, and clears the flag', async () => {
|
|
222
|
+
const { controller, server, onActionApplied } = setup();
|
|
223
|
+
const key = await previewed(controller);
|
|
224
|
+
server.mode = 'commit-then-lose-response';
|
|
225
|
+
await controller.applyAction(REQUEST_ID);
|
|
226
|
+
expect(server.commits).toBe(1);
|
|
227
|
+
// The operator tries every discard/re-propose path, then retries.
|
|
228
|
+
controller.rejectAction(REQUEST_ID);
|
|
229
|
+
await controller.previewAction(proposal());
|
|
230
|
+
server.mode = 'commit';
|
|
231
|
+
await controller.applyAction(REQUEST_ID);
|
|
232
|
+
const state = controller.actions.get(REQUEST_ID);
|
|
233
|
+
expect(state?.status).toBe('applied');
|
|
234
|
+
expect(state?.outcomeUnknown).toBe(false);
|
|
235
|
+
expect(server.keys).toEqual([key, key]);
|
|
236
|
+
expect(server.commits).toBe(1);
|
|
237
|
+
expect(onActionApplied).toHaveBeenCalledTimes(1);
|
|
238
|
+
controller.dispose();
|
|
239
|
+
});
|
|
240
|
+
it('keeps the key through idempotency_in_progress until the first attempt settles', async () => {
|
|
241
|
+
const { controller, server } = setup();
|
|
242
|
+
const key = await previewed(controller);
|
|
243
|
+
server.mode = 'in-progress';
|
|
244
|
+
await controller.applyAction(REQUEST_ID);
|
|
245
|
+
await controller.applyAction(REQUEST_ID);
|
|
246
|
+
expect(controller.actions.get(REQUEST_ID)?.outcomeUnknown).toBe(true);
|
|
247
|
+
server.mode = 'commit';
|
|
248
|
+
await controller.applyAction(REQUEST_ID);
|
|
249
|
+
expect(controller.actions.get(REQUEST_ID)?.status).toBe('applied');
|
|
250
|
+
expect(new Set(server.keys)).toEqual(new Set([key]));
|
|
251
|
+
expect(server.commits).toBe(1);
|
|
252
|
+
controller.dispose();
|
|
253
|
+
});
|
|
254
|
+
it('coalesces concurrent duplicate retries into one in-flight apply', async () => {
|
|
255
|
+
const { controller, server } = setup();
|
|
256
|
+
const key = await previewed(controller);
|
|
257
|
+
server.mode = 'commit-then-lose-response';
|
|
258
|
+
await controller.applyAction(REQUEST_ID);
|
|
259
|
+
server.mode = 'commit';
|
|
260
|
+
let release;
|
|
261
|
+
server.gate = new Promise((resolve) => {
|
|
262
|
+
release = resolve;
|
|
263
|
+
});
|
|
264
|
+
const callsBefore = server.applyCalls;
|
|
265
|
+
const retries = [
|
|
266
|
+
controller.applyAction(REQUEST_ID),
|
|
267
|
+
controller.applyAction(REQUEST_ID),
|
|
268
|
+
controller.applyAction(REQUEST_ID),
|
|
269
|
+
];
|
|
270
|
+
controller.rejectAction(REQUEST_ID);
|
|
271
|
+
release();
|
|
272
|
+
await Promise.all(retries);
|
|
273
|
+
expect(server.applyCalls - callsBefore).toBe(1);
|
|
274
|
+
expect(server.keys.every((k) => k === key)).toBe(true);
|
|
275
|
+
expect(server.commits).toBe(1);
|
|
276
|
+
expect(controller.actions.get(REQUEST_ID)?.status).toBe('applied');
|
|
277
|
+
controller.dispose();
|
|
278
|
+
});
|
|
279
|
+
it('refuses a duplicate retry once the action is applied', async () => {
|
|
280
|
+
const { controller, server } = setup();
|
|
281
|
+
await previewed(controller);
|
|
282
|
+
server.mode = 'commit-then-lose-response';
|
|
283
|
+
await controller.applyAction(REQUEST_ID);
|
|
284
|
+
server.mode = 'commit';
|
|
285
|
+
await controller.applyAction(REQUEST_ID);
|
|
286
|
+
const calls = server.applyCalls;
|
|
287
|
+
await controller.applyAction(REQUEST_ID);
|
|
288
|
+
expect(server.applyCalls).toBe(calls);
|
|
289
|
+
expect(server.commits).toBe(1);
|
|
290
|
+
controller.dispose();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
describe('AssistantDock unknown-outcome affordance (#2990)', () => {
|
|
294
|
+
it('withdraws Reject and offers a same-key Check again', async () => {
|
|
295
|
+
const server = idempotentServer();
|
|
296
|
+
let dock;
|
|
297
|
+
render(AssistantDock, {
|
|
298
|
+
props: {
|
|
299
|
+
transport: createInMemoryAssistantTransport(),
|
|
300
|
+
registry: registryWithSurface(),
|
|
301
|
+
actionClient: server.client,
|
|
302
|
+
oncontroller: (c) => {
|
|
303
|
+
dock = c;
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
await dock?.previewAction(proposal());
|
|
308
|
+
server.mode = 'commit-then-lose-response';
|
|
309
|
+
await userEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
|
310
|
+
const check = await screen.findByRole('button', { name: 'Check again' });
|
|
311
|
+
expect(screen.getByRole('status')).toHaveTextContent(/couldn't confirm whether this change was applied/);
|
|
312
|
+
expect(screen.queryByRole('button', { name: 'Reject' })).toBeNull();
|
|
313
|
+
expect(screen.queryByRole('button', { name: 'Confirm' })).toBeNull();
|
|
314
|
+
server.mode = 'commit';
|
|
315
|
+
await userEvent.click(check);
|
|
316
|
+
await vi.waitFor(() => expect(screen.queryByRole('button', { name: 'Check again' })).toBeNull());
|
|
317
|
+
expect(dock?.actions.get(REQUEST_ID)?.status).toBe('applied');
|
|
318
|
+
expect(server.commits).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* #2989: a host can propose an action into the dock and observe an applied
|
|
4
|
+
* outcome — controller `onActionApplied`, and AssistantDock's
|
|
5
|
+
* `oncontroller` / `onactionapplied` props.
|
|
6
|
+
*/
|
|
7
|
+
import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data-surface';
|
|
8
|
+
import { render, screen, userEvent } from '@happyvertical/smrt-vitest/svelte';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import AssistantDock from '../AssistantDock.svelte';
|
|
11
|
+
import { createInMemoryAssistantTransport } from '../assistant-transport.js';
|
|
12
|
+
import { createAssistantDockController, } from '../create-assistant-dock-controller.svelte.js';
|
|
13
|
+
const identity = {
|
|
14
|
+
surfaceId: 'assets',
|
|
15
|
+
kind: 'table',
|
|
16
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
17
|
+
};
|
|
18
|
+
function registryWithSurface() {
|
|
19
|
+
const registry = createDataSurfaceRegistry();
|
|
20
|
+
const descriptor = {
|
|
21
|
+
version: 1,
|
|
22
|
+
identity,
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
label: 'Assets',
|
|
25
|
+
rowKey: 'id',
|
|
26
|
+
columns: [
|
|
27
|
+
{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' },
|
|
28
|
+
],
|
|
29
|
+
query: {
|
|
30
|
+
modes: ['rows'],
|
|
31
|
+
projectableColumnIds: ['id'],
|
|
32
|
+
searchableColumnIds: [],
|
|
33
|
+
filterableColumnIds: [],
|
|
34
|
+
sortableColumnIds: [],
|
|
35
|
+
},
|
|
36
|
+
actions: [],
|
|
37
|
+
controls: [],
|
|
38
|
+
limits: { maxQueryRows: 10, maxQueryBytes: 10_000, maxSelectionSize: 10 },
|
|
39
|
+
};
|
|
40
|
+
registry.register({
|
|
41
|
+
descriptor,
|
|
42
|
+
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
43
|
+
});
|
|
44
|
+
return registry;
|
|
45
|
+
}
|
|
46
|
+
function proposal(requestId = 'req-approve') {
|
|
47
|
+
return {
|
|
48
|
+
version: 1,
|
|
49
|
+
requestId,
|
|
50
|
+
identity,
|
|
51
|
+
actionId: 'approve',
|
|
52
|
+
phase: 'preview',
|
|
53
|
+
selection: { scope: 'explicit-ids', rowIds: ['row-sent'] },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function result(request, phase, ok, extra = {}) {
|
|
57
|
+
return {
|
|
58
|
+
version: 1,
|
|
59
|
+
requestId: request.requestId,
|
|
60
|
+
identity: request.identity,
|
|
61
|
+
actionId: request.actionId,
|
|
62
|
+
phase,
|
|
63
|
+
ok,
|
|
64
|
+
...extra,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function client(apply) {
|
|
68
|
+
return {
|
|
69
|
+
preview: async (request) => result(request, 'preview', true, { confirmationToken: 'tok' }),
|
|
70
|
+
apply,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
describe('controller onActionApplied (#2989)', () => {
|
|
74
|
+
it('fires once with the server result for an accepted apply', async () => {
|
|
75
|
+
const onActionApplied = vi.fn();
|
|
76
|
+
const controller = createAssistantDockController({
|
|
77
|
+
transport: createInMemoryAssistantTransport(),
|
|
78
|
+
registry: registryWithSurface(),
|
|
79
|
+
actionClient: client(async (request) => result(request, 'apply', true, {
|
|
80
|
+
details: { approvedAssetId: 'asset-from-server' },
|
|
81
|
+
})),
|
|
82
|
+
onActionApplied,
|
|
83
|
+
});
|
|
84
|
+
await controller.previewAction(proposal());
|
|
85
|
+
await controller.applyAction('req-approve');
|
|
86
|
+
expect(controller.actions.get('req-approve')?.status).toBe('applied');
|
|
87
|
+
expect(onActionApplied).toHaveBeenCalledTimes(1);
|
|
88
|
+
const [request, applied] = onActionApplied.mock.calls[0];
|
|
89
|
+
expect(request.phase).toBe('apply');
|
|
90
|
+
expect(request.requestId).toBe('req-approve');
|
|
91
|
+
expect(applied.details).toEqual({ approvedAssetId: 'asset-from-server' });
|
|
92
|
+
controller.dispose();
|
|
93
|
+
});
|
|
94
|
+
it('does not fire for a refusal or a thrown apply', async () => {
|
|
95
|
+
const onActionApplied = vi.fn();
|
|
96
|
+
let mode = 'refuse';
|
|
97
|
+
const controller = createAssistantDockController({
|
|
98
|
+
transport: createInMemoryAssistantTransport(),
|
|
99
|
+
registry: registryWithSurface(),
|
|
100
|
+
actionClient: client(async (request) => {
|
|
101
|
+
if (mode === 'throw')
|
|
102
|
+
throw new Error('network down');
|
|
103
|
+
return result(request, 'apply', false, { reason: 'denied' });
|
|
104
|
+
}),
|
|
105
|
+
onActionApplied,
|
|
106
|
+
});
|
|
107
|
+
await controller.previewAction(proposal('req-a'));
|
|
108
|
+
await controller.applyAction('req-a');
|
|
109
|
+
mode = 'throw';
|
|
110
|
+
await controller.previewAction(proposal('req-b'));
|
|
111
|
+
await controller.applyAction('req-b');
|
|
112
|
+
expect(onActionApplied).not.toHaveBeenCalled();
|
|
113
|
+
controller.dispose();
|
|
114
|
+
});
|
|
115
|
+
it('keeps the applied state when the host callback throws', async () => {
|
|
116
|
+
const onActionApplied = vi.fn(() => {
|
|
117
|
+
throw new Error('host bug');
|
|
118
|
+
});
|
|
119
|
+
const controller = createAssistantDockController({
|
|
120
|
+
transport: createInMemoryAssistantTransport(),
|
|
121
|
+
registry: registryWithSurface(),
|
|
122
|
+
actionClient: client(async (request) => result(request, 'apply', true)),
|
|
123
|
+
onActionApplied,
|
|
124
|
+
});
|
|
125
|
+
await controller.previewAction(proposal());
|
|
126
|
+
await expect(controller.applyAction('req-approve')).resolves.toBeUndefined();
|
|
127
|
+
expect(onActionApplied).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(controller.actions.get('req-approve')?.status).toBe('applied');
|
|
129
|
+
controller.dispose();
|
|
130
|
+
});
|
|
131
|
+
it('does not fire for an apply that lands after a context swap', async () => {
|
|
132
|
+
const onActionApplied = vi.fn();
|
|
133
|
+
let release;
|
|
134
|
+
let registry = registryWithSurface();
|
|
135
|
+
const controller = createAssistantDockController({
|
|
136
|
+
transport: createInMemoryAssistantTransport(),
|
|
137
|
+
get registry() {
|
|
138
|
+
return registry;
|
|
139
|
+
},
|
|
140
|
+
actionClient: client(async (request) => {
|
|
141
|
+
await new Promise((resolve) => {
|
|
142
|
+
release = resolve;
|
|
143
|
+
});
|
|
144
|
+
return result(request, 'apply', true);
|
|
145
|
+
}),
|
|
146
|
+
onActionApplied,
|
|
147
|
+
});
|
|
148
|
+
await controller.previewAction(proposal());
|
|
149
|
+
const applying = controller.applyAction('req-approve');
|
|
150
|
+
registry = registryWithSurface();
|
|
151
|
+
controller.syncRegistry();
|
|
152
|
+
release();
|
|
153
|
+
await applying;
|
|
154
|
+
expect(onActionApplied).not.toHaveBeenCalled();
|
|
155
|
+
controller.dispose();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('AssistantDock oncontroller / onactionapplied (#2989)', () => {
|
|
159
|
+
it('lets a host propose an action and observe the applied outcome', async () => {
|
|
160
|
+
let hostController;
|
|
161
|
+
const onactionapplied = vi.fn();
|
|
162
|
+
const oncontroller = vi.fn((c) => {
|
|
163
|
+
hostController = c;
|
|
164
|
+
});
|
|
165
|
+
render(AssistantDock, {
|
|
166
|
+
props: {
|
|
167
|
+
transport: createInMemoryAssistantTransport(),
|
|
168
|
+
registry: registryWithSurface(),
|
|
169
|
+
actionClient: client(async (request) => result(request, 'apply', true, {
|
|
170
|
+
details: { approvedAssetId: 'asset-from-server' },
|
|
171
|
+
})),
|
|
172
|
+
oncontroller,
|
|
173
|
+
onactionapplied,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
expect(oncontroller).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(hostController).toBeDefined();
|
|
178
|
+
await hostController?.previewAction(proposal());
|
|
179
|
+
await userEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
|
180
|
+
await vi.waitFor(() => expect(onactionapplied).toHaveBeenCalledTimes(1));
|
|
181
|
+
expect(onactionapplied.mock.calls[0][1].details).toEqual({
|
|
182
|
+
approvedAssetId: 'asset-from-server',
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -1870,9 +1870,11 @@ describe('createAssistantDockController', () => {
|
|
|
1870
1870
|
expect(state?.error).toBe('server 500');
|
|
1871
1871
|
controller.dispose();
|
|
1872
1872
|
});
|
|
1873
|
-
// Finding 3 (#2904 review, fresh cycle): a Reject during an
|
|
1874
|
-
// apply
|
|
1875
|
-
|
|
1873
|
+
// Finding 3 (#2904 review, fresh cycle) and #2990: a Reject during an
|
|
1874
|
+
// in-flight apply is refused — the entry holds the only idempotency key
|
|
1875
|
+
// that keeps a retry from applying twice — and the apply that lands
|
|
1876
|
+
// afterward is recorded as applied, not dropped.
|
|
1877
|
+
it('refuses a reject while an apply is in flight and records the landed apply', async () => {
|
|
1876
1878
|
const { registry, identity } = realRegistryWithSurface('orders');
|
|
1877
1879
|
let resolveApply;
|
|
1878
1880
|
const applyGate = new Promise((resolve) => {
|
|
@@ -1920,17 +1922,14 @@ describe('createAssistantDockController', () => {
|
|
|
1920
1922
|
// the first one's in-flight apply.
|
|
1921
1923
|
await controller.applyAction(requestId);
|
|
1922
1924
|
expect(controller.actions.get(requestId)?.status).toBe('applying');
|
|
1923
|
-
// The user rejects WHILE the apply above is still in flight.
|
|
1925
|
+
// The user rejects WHILE the apply above is still in flight: refused.
|
|
1924
1926
|
controller.rejectAction(requestId);
|
|
1925
|
-
expect(controller.actions.
|
|
1926
|
-
|
|
1927
|
+
expect(controller.actions.get(requestId)?.status).toBe('applying');
|
|
1928
|
+
expect(controller.error).toMatch(/rejectAction refused/);
|
|
1929
|
+
// The server mutation lands; the entry records it.
|
|
1927
1930
|
resolveApply?.(true);
|
|
1928
1931
|
await applyPromise;
|
|
1929
|
-
|
|
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);
|
|
1932
|
+
expect(controller.actions.get(requestId)?.status).toBe('applied');
|
|
1934
1933
|
controller.dispose();
|
|
1935
1934
|
});
|
|
1936
1935
|
// Cycle-4 second final finding 2: the `else if (!current && result.ok)`
|