@happyvertical/smrt-chat 0.51.10 → 0.51.15
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 +14 -3
- package/agents/dev-workbench.md +71 -0
- 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 +68 -15
- package/dist/svelte/components/assistant/AssistantComposer.svelte +30 -6
- package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts +5 -1
- package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts.map +1 -1
- package/dist/svelte/components/assistant/AssistantDock.svelte +365 -144
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts +30 -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-draft-settled.test.js +448 -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 +83 -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 +27 -21
- 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
|
+
});
|