@happyvertical/smrt-chat 0.51.39 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/AGENTS.md +19 -2
  2. package/README.md +13 -0
  3. package/dist/assistant-turn-events.d.ts +152 -0
  4. package/dist/assistant-turn-events.d.ts.map +1 -0
  5. package/dist/assistant-turn-events.js +2 -0
  6. package/dist/assistant-turn.d.ts +236 -0
  7. package/dist/assistant-turn.d.ts.map +1 -0
  8. package/dist/chunks/{ChatService-D48KBDZy.js → ChatService-jJi2JJJi.js} +2 -2
  9. package/dist/chunks/ChatService-jJi2JJJi.js.map +1 -0
  10. package/dist/chunks/assistant-turn-events-BbZUf-Ju.js +77 -0
  11. package/dist/chunks/assistant-turn-events-BbZUf-Ju.js.map +1 -0
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +776 -104
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/agent-runtime.js +1 -1
  17. package/dist/manifest.json +6 -2
  18. package/dist/models/AgentSession.d.ts +11 -1
  19. package/dist/models/AgentSession.d.ts.map +1 -1
  20. package/dist/smrt-knowledge.json +7 -13
  21. package/dist/svelte/components/assistant/AssistantChoiceCards.svelte +309 -0
  22. package/dist/svelte/components/assistant/AssistantChoiceCards.svelte.d.ts +19 -0
  23. package/dist/svelte/components/assistant/AssistantChoiceCards.svelte.d.ts.map +1 -0
  24. package/dist/svelte/components/assistant/AssistantComposer.svelte +116 -14
  25. package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts +28 -0
  26. package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts.map +1 -1
  27. package/dist/svelte/components/assistant/AssistantDock.svelte +196 -1
  28. package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts +38 -1
  29. package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts.map +1 -1
  30. package/dist/svelte/components/assistant/AssistantThreadList.svelte +13 -1
  31. package/dist/svelte/components/assistant/__tests__/AssistantDock.test.js +16 -16
  32. package/dist/svelte/components/assistant/__tests__/assistant-composer-dictation.test.js +197 -0
  33. package/dist/svelte/components/assistant/__tests__/assistant-dock-choices-pending.test.js +224 -0
  34. package/dist/svelte/components/assistant/__tests__/assistant-dock-choices-preview.test.js +312 -0
  35. package/dist/svelte/components/assistant/__tests__/assistant-dock-choices.test.js +338 -0
  36. package/dist/svelte/components/assistant/__tests__/assistant-dock-client-tools.test.js +442 -0
  37. package/dist/svelte/components/assistant/__tests__/assistant-dock-run.test.js +303 -0
  38. package/dist/svelte/components/assistant/__tests__/assistant-dock-settle.test.js +146 -0
  39. package/dist/svelte/components/assistant/assistant-choices.svelte.d.ts +251 -0
  40. package/dist/svelte/components/assistant/assistant-choices.svelte.d.ts.map +1 -0
  41. package/dist/svelte/components/assistant/assistant-choices.svelte.js +520 -0
  42. package/dist/svelte/components/assistant/assistant-transport.d.ts +54 -1
  43. package/dist/svelte/components/assistant/assistant-transport.d.ts.map +1 -1
  44. package/dist/svelte/components/assistant/assistant-transport.js +64 -0
  45. package/dist/svelte/components/assistant/client-tools.d.ts +85 -0
  46. package/dist/svelte/components/assistant/client-tools.d.ts.map +1 -0
  47. package/dist/svelte/components/assistant/client-tools.js +61 -0
  48. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts +159 -0
  49. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts.map +1 -1
  50. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.js +0 -0
  51. package/dist/svelte/i18n.d.ts +15 -0
  52. package/dist/svelte/i18n.d.ts.map +1 -1
  53. package/dist/svelte/i18n.js +17 -1
  54. package/dist/svelte/index.d.ts +6 -2
  55. package/dist/svelte/index.d.ts.map +1 -1
  56. package/dist/svelte/index.js +8 -1
  57. package/dist/tool-allow-list.d.ts +8 -0
  58. package/dist/tool-allow-list.d.ts.map +1 -0
  59. package/dist/tool-loop.d.ts +191 -3
  60. package/dist/tool-loop.d.ts.map +1 -1
  61. package/package.json +19 -15
  62. package/dist/chunks/ChatService-D48KBDZy.js.map +0 -1
@@ -54,7 +54,7 @@ describe('AssistantDock (mounted component)', () => {
54
54
  });
55
55
  const listThreadsSpy = vi.spyOn(transport, 'listThreads');
56
56
  render(AssistantDock, { props: { transport, registry } });
57
- expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
57
+ expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
58
58
  expect(listThreadsSpy).toHaveBeenCalledTimes(1);
59
59
  // Create + open a thread through the UI, then send a real message
60
60
  // through the component's OWN mounted controller.
@@ -75,12 +75,12 @@ describe('AssistantDock (mounted component)', () => {
75
75
  // Poll until the notice clears (or fail): the registry event handler
76
76
  // runs synchronously, so this should resolve on the very next microtask.
77
77
  for (let attempt = 0; attempt < 20; attempt += 1) {
78
- if (!screen.queryByText(/No data surfaces are mounted on this route/i)) {
78
+ if (!screen.queryByText(/Nothing on this page can be changed from the chat/i)) {
79
79
  break;
80
80
  }
81
81
  await new Promise((resolve) => setTimeout(resolve, 10));
82
82
  }
83
- expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
83
+ expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
84
84
  // loadThreads must still have fired only once for the mount — not once
85
85
  // per send/poll re-run of a re-triggered effect.
86
86
  expect(listThreadsSpy).toHaveBeenCalledTimes(1);
@@ -107,11 +107,11 @@ describe('AssistantDock (mounted component)', () => {
107
107
  props: { transport, registry: r1 },
108
108
  });
109
109
  // R1 has a mounted surface — the empty notice must not show.
110
- expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
110
+ expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
111
111
  // Reassign the prop to a DIFFERENT, empty registry instance (R2).
112
112
  const r2 = createDataSurfaceRegistry();
113
113
  await rerender({ transport, registry: r2 });
114
- expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
114
+ expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
115
115
  // Registering a surface on R2 must be discovered — proves the
116
116
  // subscription actually moved to R2, not just a one-time resync.
117
117
  const r2Identity = { ...identity, surfaceId: 'products' };
@@ -120,12 +120,12 @@ describe('AssistantDock (mounted component)', () => {
120
120
  getSnapshot: () => ({ revision: 1, state: {} }),
121
121
  });
122
122
  for (let attempt = 0; attempt < 20; attempt += 1) {
123
- if (!screen.queryByText(/No data surfaces are mounted on this route/i)) {
123
+ if (!screen.queryByText(/Nothing on this page can be changed from the chat/i)) {
124
124
  break;
125
125
  }
126
126
  await new Promise((resolve) => setTimeout(resolve, 10));
127
127
  }
128
- expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
128
+ expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
129
129
  // F1 guarantee preserved: the registry-scoped effect must not have
130
130
  // caused the SEPARATE mount effect to re-run — but Copilot PR #2919
131
131
  // jAwsd's fix means the swap ITSELF now legitimately triggers exactly
@@ -153,7 +153,7 @@ describe('AssistantDock (mounted component)', () => {
153
153
  render(AssistantDock, {
154
154
  props: { transport, registry, surfaces: [] },
155
155
  });
156
- expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
156
+ expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
157
157
  });
158
158
  // Copilot PR #2919 jAwr0: `surfaces` is a NARROWING filter over the live
159
159
  // registry — an override identity that isn't genuinely registered must
@@ -167,7 +167,7 @@ describe('AssistantDock (mounted component)', () => {
167
167
  render(AssistantDock, {
168
168
  props: { transport, registry, surfaces: [identity] },
169
169
  });
170
- expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
170
+ expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
171
171
  });
172
172
  it('the `surfaces` override makes discovery non-empty only when the identity IS also registered', async () => {
173
173
  const registry = createDataSurfaceRegistry();
@@ -179,7 +179,7 @@ describe('AssistantDock (mounted component)', () => {
179
179
  render(AssistantDock, {
180
180
  props: { transport, registry, surfaces: [identity] },
181
181
  });
182
- expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
182
+ expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
183
183
  });
184
184
  // Finding 4 (#2904 review, fresh cycle): a failed mount-time listThreads
185
185
  // must render as a dock-level error, not an empty, explanation-free
@@ -222,20 +222,20 @@ describe('AssistantDock (mounted component)', () => {
222
222
  // Override only includes `products`; the registered `orders` surface is
223
223
  // gated out even though it's genuinely registered — the "no surfaces"
224
224
  // notice must NOT show (the override list is non-empty).
225
- expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
225
+ expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
226
226
  // Narrow the override to an EMPTY list.
227
227
  await rerender({ transport, registry, surfaces: [] });
228
- expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
228
+ expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
229
229
  // Widen back to include the registered `orders` surface — discovery
230
230
  // must follow the reassignment and the notice must clear.
231
231
  await rerender({ transport, registry, surfaces: [identity] });
232
232
  for (let attempt = 0; attempt < 20; attempt += 1) {
233
- if (!screen.queryByText(/No data surfaces are mounted on this route/i)) {
233
+ if (!screen.queryByText(/Nothing on this page can be changed from the chat/i)) {
234
234
  break;
235
235
  }
236
236
  await new Promise((resolve) => setTimeout(resolve, 10));
237
237
  }
238
- expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
238
+ expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
239
239
  });
240
240
  // Cycle-2 second final finding 1: the F1 "mount effect runs exactly once"
241
241
  // guarantee must hold even with the new `surfaces`-scoped effect added
@@ -247,7 +247,7 @@ describe('AssistantDock (mounted component)', () => {
247
247
  const { rerender } = render(AssistantDock, {
248
248
  props: { transport, registry, surfaces: [] },
249
249
  });
250
- await screen.findByText(/No data surfaces are mounted on this route/i);
250
+ await screen.findByText(/Nothing on this page can be changed from the chat/i);
251
251
  await rerender({ transport, registry, surfaces: [identity] });
252
252
  await rerender({ transport, registry, surfaces: [] });
253
253
  expect(listThreadsSpy).toHaveBeenCalledTimes(1);
@@ -264,7 +264,7 @@ describe('AssistantDock (mounted component)', () => {
264
264
  throw new Error('no writeEndpoint configured');
265
265
  };
266
266
  render(AssistantDock, { props: { transport, registry } });
267
- await screen.findByText(/No data surfaces are mounted on this route/i);
267
+ await screen.findByText(/Nothing on this page can be changed from the chat/i);
268
268
  await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
269
269
  expect(await screen.findByText(/Something went wrong: no writeEndpoint configured/i)).toBeInTheDocument();
270
270
  });
@@ -0,0 +1,197 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Speaking into the assistant composer (smrt-ui `Dictation` over a mocked
4
+ * speech source; real speech recognition cannot run headless): a named
5
+ * microphone button, press-and-hold on the message box, heard words at the
6
+ * cursor, and Send / Escape stopping it. No source means no microphone.
7
+ */
8
+ import { fireEvent, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import AssistantChoiceCards from '../AssistantChoiceCards.svelte';
11
+ import AssistantComposer from '../AssistantComposer.svelte';
12
+ function mockSpeech() {
13
+ const results = new Set();
14
+ const ends = new Set();
15
+ const source = {
16
+ start: vi.fn(async () => { }),
17
+ stop: vi.fn(async () => {
18
+ for (const cb of ends)
19
+ cb();
20
+ }),
21
+ onResult: (cb) => {
22
+ results.add(cb);
23
+ return () => results.delete(cb);
24
+ },
25
+ onError: () => () => { },
26
+ onEnd: (cb) => {
27
+ ends.add(cb);
28
+ return () => ends.delete(cb);
29
+ },
30
+ };
31
+ return {
32
+ source,
33
+ say(text) {
34
+ for (const cb of results)
35
+ cb({ text, isFinal: true });
36
+ },
37
+ };
38
+ }
39
+ function pointerEvent(type, init = {}) {
40
+ const event = new Event(type, { bubbles: true, cancelable: true });
41
+ Object.assign(event, {
42
+ isPrimary: true,
43
+ pointerId: 3,
44
+ pointerType: 'touch',
45
+ button: 0,
46
+ clientX: 20,
47
+ clientY: 20,
48
+ ...init,
49
+ });
50
+ return event;
51
+ }
52
+ describe('AssistantComposer dictation', () => {
53
+ afterEach(() => vi.useRealTimers());
54
+ it('has no microphone without a speech source', () => {
55
+ render(AssistantComposer, {
56
+ props: { onsend: vi.fn(), onupload: vi.fn() },
57
+ });
58
+ expect(screen.queryByRole('button', { name: 'Speak instead of typing' })).toBeNull();
59
+ });
60
+ it('the microphone listens and puts heard words at the cursor; Send stops it', async () => {
61
+ const speech = mockSpeech();
62
+ const onsend = vi.fn().mockResolvedValue(undefined);
63
+ render(AssistantComposer, {
64
+ props: { onsend, onupload: vi.fn(), dictation: () => speech.source },
65
+ });
66
+ const field = screen.getByLabelText('Message');
67
+ await userEvent.type(field, 'Find a picture of');
68
+ await userEvent.click(screen.getByRole('button', { name: 'Speak instead of typing' }));
69
+ expect(await screen.findByRole('button', { name: 'Stop listening' })).toHaveAttribute('aria-pressed', 'true');
70
+ expect(screen.getByRole('status')).toHaveTextContent('Listening');
71
+ speech.say('the arena');
72
+ expect(field).toHaveValue('Find a picture of the arena');
73
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }));
74
+ expect(onsend).toHaveBeenCalledWith('Find a picture of the arena', []);
75
+ expect(speech.source.stop).toHaveBeenCalled();
76
+ });
77
+ it('pressing and holding the message box starts listening; Escape stops', async () => {
78
+ vi.useFakeTimers({ shouldAdvanceTime: true });
79
+ const speech = mockSpeech();
80
+ render(AssistantComposer, {
81
+ props: {
82
+ onsend: vi.fn(),
83
+ onupload: vi.fn(),
84
+ dictation: () => speech.source,
85
+ },
86
+ });
87
+ const field = screen.getByLabelText('Message');
88
+ field.dispatchEvent(pointerEvent('pointerdown'));
89
+ vi.advanceTimersByTime(520);
90
+ window.dispatchEvent(pointerEvent('pointerup'));
91
+ await vi.waitFor(() => expect(speech.source.start).toHaveBeenCalled());
92
+ expect(await screen.findByRole('button', { name: 'Stop listening' })).toBeInTheDocument();
93
+ await fireEvent.keyDown(field, { key: 'Escape' });
94
+ await vi.waitFor(() => expect(speech.source.stop).toHaveBeenCalled());
95
+ });
96
+ it('a short tap on the message box does not listen', async () => {
97
+ vi.useFakeTimers({ shouldAdvanceTime: true });
98
+ const speech = mockSpeech();
99
+ render(AssistantComposer, {
100
+ props: {
101
+ onsend: vi.fn(),
102
+ onupload: vi.fn(),
103
+ dictation: () => speech.source,
104
+ },
105
+ });
106
+ const field = screen.getByLabelText('Message');
107
+ field.dispatchEvent(pointerEvent('pointerdown'));
108
+ vi.advanceTimersByTime(150);
109
+ window.dispatchEvent(pointerEvent('pointerup'));
110
+ vi.advanceTimersByTime(800);
111
+ expect(speech.source.start).not.toHaveBeenCalled();
112
+ });
113
+ });
114
+ describe('AssistantChoiceCards', () => {
115
+ it('renders offered options and reports the pick and "None of these"', async () => {
116
+ const onchoose = vi.fn();
117
+ const ondismiss = vi.fn();
118
+ render(AssistantChoiceCards, {
119
+ props: {
120
+ choices: [
121
+ {
122
+ id: 's1',
123
+ sourceId: 'crop',
124
+ title: 'Pick a shape',
125
+ status: 'waiting',
126
+ options: [
127
+ { id: 'wide', label: 'Wide' },
128
+ { id: 'square', label: 'Square' },
129
+ ],
130
+ },
131
+ ],
132
+ onchoose,
133
+ ondismiss,
134
+ },
135
+ });
136
+ await userEvent.click(screen.getByRole('button', { name: 'Square' }));
137
+ expect(onchoose).toHaveBeenCalledWith('s1', 'square');
138
+ await userEvent.click(screen.getByRole('button', { name: 'None of these' }));
139
+ expect(ondismiss).toHaveBeenCalledWith('s1');
140
+ });
141
+ });
142
+ describe('AssistantComposer recording fallback', () => {
143
+ afterEach(() => vi.unstubAllGlobals());
144
+ function stubRecorder() {
145
+ class FakeMediaRecorder extends EventTarget {
146
+ static isTypeSupported = (type) => type === 'audio/webm;codecs=opus';
147
+ state = 'inactive';
148
+ mimeType;
149
+ constructor(_stream, options) {
150
+ super();
151
+ this.mimeType = options?.mimeType ?? '';
152
+ }
153
+ start() {
154
+ this.state = 'recording';
155
+ }
156
+ stop() {
157
+ this.state = 'inactive';
158
+ this.dispatchEvent(Object.assign(new Event('dataavailable'), {
159
+ data: new Blob(['opus'], { type: this.mimeType }),
160
+ }));
161
+ this.dispatchEvent(new Event('stop'));
162
+ }
163
+ }
164
+ const track = { stop: vi.fn() };
165
+ vi.stubGlobal('MediaRecorder', FakeMediaRecorder);
166
+ Object.defineProperty(navigator, 'mediaDevices', {
167
+ configurable: true,
168
+ value: {
169
+ getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
170
+ },
171
+ });
172
+ return track;
173
+ }
174
+ it('records with only `transcribe` (Firefox), writes it down, and puts the text in the box', async () => {
175
+ const track = stubRecorder();
176
+ let finish;
177
+ const transcribe = vi.fn(() => new Promise((resolve) => (finish = resolve)));
178
+ render(AssistantComposer, {
179
+ props: { onsend: vi.fn(), onupload: vi.fn(), transcribe },
180
+ });
181
+ const mic = screen.getByRole('button', { name: 'Speak instead of typing' });
182
+ await userEvent.click(mic);
183
+ await screen.findByText(/Listening/);
184
+ await userEvent.click(screen.getByRole('button', { name: 'Stop listening' }));
185
+ await screen.findByText('Writing it down…');
186
+ expect(track.stop).toHaveBeenCalled();
187
+ expect(transcribe).toHaveBeenCalledWith(expect.any(Blob), {
188
+ mimeType: 'audio/webm;codecs=opus',
189
+ language: expect.any(String),
190
+ durationMs: expect.any(Number),
191
+ signal: expect.any(AbortSignal),
192
+ });
193
+ finish('Show me the arena');
194
+ const box = screen.getByRole('textbox', { name: /message/i });
195
+ await vi.waitFor(() => expect(box.value).toBe('Show me the arena'));
196
+ });
197
+ });
@@ -0,0 +1,224 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Choices whose options take a while (generated pictures): the offer shows
4
+ * "Making…" placeholders, options are added as they finish, the person can
5
+ * pick any that arrived, and a failure is said plainly.
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 { choiceToolName, createAssistantChoiceSourceRegistry, } from '../assistant-choices.svelte.js';
12
+ import { createAssistantDockController } from '../create-assistant-dock-controller.svelte.js';
13
+ function message(id, role, content) {
14
+ return {
15
+ id,
16
+ threadId: 't1',
17
+ role,
18
+ content,
19
+ createdAt: new Date(0).toISOString(),
20
+ };
21
+ }
22
+ /** Every send asks for one choice tool call; resuming answers in text. */
23
+ function transportCalling(call) {
24
+ const resumed = [];
25
+ let sends = 0;
26
+ const transport = {
27
+ resumed,
28
+ async listThreads() {
29
+ return [{ id: 't1', title: 'T', isResolved: false, messageCount: 0 }];
30
+ },
31
+ async createThread() {
32
+ return { id: 't1', title: 'T', isResolved: false, messageCount: 0 };
33
+ },
34
+ async loadMessages() {
35
+ return [];
36
+ },
37
+ async uploadAttachment() {
38
+ throw new Error('no');
39
+ },
40
+ async sendMessage(input) {
41
+ sends += 1;
42
+ return {
43
+ inProgress: false,
44
+ userMessage: message(`u${sends}`, 'user', input.content),
45
+ clientToolCalls: {
46
+ continuationId: `cont-${sends}`,
47
+ calls: [{ ...call, id: sends > 1 ? `${call.id}-${sends}` : call.id }],
48
+ },
49
+ };
50
+ },
51
+ async resumeTurn(input) {
52
+ resumed.push(input);
53
+ return {
54
+ inProgress: false,
55
+ assistantMessage: message('a1', 'assistant', 'Making them now.'),
56
+ };
57
+ },
58
+ };
59
+ return transport;
60
+ }
61
+ /** A source whose versions arrive when the test says so. */
62
+ function slowSource() {
63
+ let update;
64
+ let signal;
65
+ let finish = () => { };
66
+ const apply = vi.fn(async () => 'Used the snowy one.');
67
+ const source = {
68
+ id: 'picture_edit',
69
+ description: 'Make new versions of the picture.',
70
+ offer: () => ({
71
+ title: 'Snowy versions',
72
+ options: [],
73
+ pending: {
74
+ message: 'Making versions… this takes a minute or two.',
75
+ expected: 3,
76
+ fill: (u, s) => new Promise((resolve, reject) => {
77
+ update = u;
78
+ signal = s;
79
+ finish = (err) => (err ? reject(err) : resolve());
80
+ }),
81
+ },
82
+ }),
83
+ apply,
84
+ };
85
+ return {
86
+ source,
87
+ apply,
88
+ add: (id) => update?.add([{ id, label: `Version ${id}`, imageUrl: `/v/${id}.jpg` }]),
89
+ finish: (err) => finish(err),
90
+ signal: () => signal,
91
+ };
92
+ }
93
+ async function until(check) {
94
+ for (let i = 0; i < 50 && !check(); i += 1) {
95
+ await new Promise((resolve) => setTimeout(resolve, 0));
96
+ }
97
+ }
98
+ async function offered(slow) {
99
+ const registry = createAssistantChoiceSourceRegistry();
100
+ registry.register(slow.source);
101
+ const transport = transportCalling({
102
+ id: 'e1',
103
+ name: choiceToolName('picture_edit'),
104
+ args: {},
105
+ effect: 'read',
106
+ });
107
+ const controller = createAssistantDockController({
108
+ transport,
109
+ registry: createDataSurfaceRegistry(),
110
+ choiceSources: registry,
111
+ });
112
+ await controller.loadThreads();
113
+ await controller.openThread('t1');
114
+ await controller.send('make it snowing');
115
+ await until(() => Boolean(slow.signal()));
116
+ return { controller, transport };
117
+ }
118
+ describe('pending choices', () => {
119
+ it('tells the model versions are coming, fills them in, and applies the pick', async () => {
120
+ const slow = slowSource();
121
+ const { controller, transport } = await offered(slow);
122
+ expect(JSON.parse(transport.resumed[0].results[0].result ?? '{}')).toEqual({
123
+ offered: true,
124
+ waitingForUser: true,
125
+ stillMaking: true,
126
+ progress: 'Making versions… this takes a minute or two.',
127
+ options: [],
128
+ });
129
+ expect(controller.choices[0]).toMatchObject({
130
+ status: 'waiting',
131
+ pending: { expected: 3 },
132
+ });
133
+ expect(controller.status.label).toBe('Making versions… this takes a minute or two.');
134
+ slow.add('a');
135
+ expect(controller.choices[0].options.map((o) => o.id)).toEqual(['a']);
136
+ expect(controller.choices[0].pending?.expected).toBe(2);
137
+ expect(controller.status.label).toBe('Pick one of the options');
138
+ // The person picks the first version while the others are still coming.
139
+ await controller.chooseOption('e1', 'a');
140
+ expect(slow.apply).toHaveBeenCalledTimes(1);
141
+ expect(controller.choices[0]).toMatchObject({ status: 'applied' });
142
+ expect(controller.choices[0].pending).toBeUndefined();
143
+ expect(slow.signal()?.aborted).toBe(true);
144
+ // Late versions are ignored.
145
+ slow.add('b');
146
+ expect(controller.choices[0].options).toHaveLength(1);
147
+ });
148
+ it('keeps the offer when the person sends another message; None of these stops it', async () => {
149
+ const slow = slowSource();
150
+ const { controller } = await offered(slow);
151
+ const firstSignal = slow.signal();
152
+ await controller.send('thanks');
153
+ expect(controller.choices[0]).toMatchObject({
154
+ id: 'e1',
155
+ status: 'waiting',
156
+ });
157
+ expect(firstSignal?.aborted).toBe(false);
158
+ controller.dismissChoices('e1');
159
+ expect(controller.choices[0].status).toBe('dismissed');
160
+ expect(firstSignal?.aborted).toBe(true);
161
+ });
162
+ it('ends ready with what arrived, and notes a partial failure', async () => {
163
+ const slow = slowSource();
164
+ const { controller } = await offered(slow);
165
+ slow.add('a');
166
+ slow.add('b');
167
+ slow.finish(new Error('One version could not be made.'));
168
+ await until(() => !controller.choices[0].pending);
169
+ expect(controller.choices[0]).toMatchObject({
170
+ status: 'waiting',
171
+ note: 'One version could not be made.',
172
+ });
173
+ expect(controller.choices[0].options).toHaveLength(2);
174
+ });
175
+ it('is unavailable, and holds nothing, when no version could be made', async () => {
176
+ const slow = slowSource();
177
+ const { controller } = await offered(slow);
178
+ slow.finish(new Error('The picture service is busy. Try again later.'));
179
+ await until(() => controller.choices[0].status !== 'waiting');
180
+ expect(controller.choices[0]).toMatchObject({
181
+ status: 'unavailable',
182
+ error: 'The picture service is busy. Try again later.',
183
+ });
184
+ expect(controller.status.label).not.toBe('Pick one of the options');
185
+ });
186
+ it('shows placeholders, then the versions as cards', async () => {
187
+ const slow = slowSource();
188
+ const registry = createAssistantChoiceSourceRegistry();
189
+ registry.register(slow.source);
190
+ const transport = transportCalling({
191
+ id: 'e1',
192
+ name: 'assistant_offer_picture_edit',
193
+ args: {},
194
+ effect: 'read',
195
+ });
196
+ let controller;
197
+ render(AssistantDock, {
198
+ props: {
199
+ transport,
200
+ registry: createDataSurfaceRegistry(),
201
+ choiceSources: registry,
202
+ oncontroller: (c) => {
203
+ controller = c;
204
+ },
205
+ },
206
+ });
207
+ await until(() => Boolean(controller));
208
+ await controller?.openThread('t1');
209
+ await controller?.send('make it snowing');
210
+ const group = await screen.findByRole('group', { name: 'Snowy versions' });
211
+ expect(group.getAttribute('aria-busy')).toBe('true');
212
+ expect(screen.getAllByText('Making…')).toHaveLength(3);
213
+ expect(screen.getAllByText('Making versions… this takes a minute or two.')
214
+ .length).toBeGreaterThan(0);
215
+ slow.add('a');
216
+ await screen.findByRole('button', { name: /Version a/ });
217
+ expect(screen.getAllByText('Making…')).toHaveLength(2);
218
+ slow.finish();
219
+ await until(() => screen.queryAllByText('Making…').length === 0);
220
+ expect(group.getAttribute('aria-busy')).toBeNull();
221
+ await userEvent.click(screen.getByRole('button', { name: /Version a/ }));
222
+ expect(await screen.findByText('Used the snowy one.')).toBeTruthy();
223
+ });
224
+ });