@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.
- package/AGENTS.md +19 -2
- package/README.md +13 -0
- package/dist/assistant-turn-events.d.ts +152 -0
- package/dist/assistant-turn-events.d.ts.map +1 -0
- package/dist/assistant-turn-events.js +2 -0
- package/dist/assistant-turn.d.ts +236 -0
- package/dist/assistant-turn.d.ts.map +1 -0
- package/dist/chunks/{ChatService-D48KBDZy.js → ChatService-jJi2JJJi.js} +2 -2
- package/dist/chunks/ChatService-jJi2JJJi.js.map +1 -0
- package/dist/chunks/assistant-turn-events-BbZUf-Ju.js +77 -0
- package/dist/chunks/assistant-turn-events-BbZUf-Ju.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +776 -104
- package/dist/index.js.map +1 -1
- package/dist/internal/agent-runtime.js +1 -1
- package/dist/manifest.json +6 -2
- package/dist/models/AgentSession.d.ts +11 -1
- package/dist/models/AgentSession.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +7 -13
- package/dist/svelte/components/assistant/AssistantChoiceCards.svelte +309 -0
- package/dist/svelte/components/assistant/AssistantChoiceCards.svelte.d.ts +19 -0
- package/dist/svelte/components/assistant/AssistantChoiceCards.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/AssistantComposer.svelte +116 -14
- package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts +28 -0
- package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts.map +1 -1
- package/dist/svelte/components/assistant/AssistantDock.svelte +196 -1
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts +38 -1
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts.map +1 -1
- package/dist/svelte/components/assistant/AssistantThreadList.svelte +13 -1
- package/dist/svelte/components/assistant/__tests__/AssistantDock.test.js +16 -16
- package/dist/svelte/components/assistant/__tests__/assistant-composer-dictation.test.js +197 -0
- package/dist/svelte/components/assistant/__tests__/assistant-dock-choices-pending.test.js +224 -0
- package/dist/svelte/components/assistant/__tests__/assistant-dock-choices-preview.test.js +312 -0
- package/dist/svelte/components/assistant/__tests__/assistant-dock-choices.test.js +338 -0
- package/dist/svelte/components/assistant/__tests__/assistant-dock-client-tools.test.js +442 -0
- package/dist/svelte/components/assistant/__tests__/assistant-dock-run.test.js +303 -0
- package/dist/svelte/components/assistant/__tests__/assistant-dock-settle.test.js +146 -0
- package/dist/svelte/components/assistant/assistant-choices.svelte.d.ts +251 -0
- package/dist/svelte/components/assistant/assistant-choices.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/assistant-choices.svelte.js +520 -0
- package/dist/svelte/components/assistant/assistant-transport.d.ts +54 -1
- package/dist/svelte/components/assistant/assistant-transport.d.ts.map +1 -1
- package/dist/svelte/components/assistant/assistant-transport.js +64 -0
- package/dist/svelte/components/assistant/client-tools.d.ts +85 -0
- package/dist/svelte/components/assistant/client-tools.d.ts.map +1 -0
- package/dist/svelte/components/assistant/client-tools.js +61 -0
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts +159 -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 +15 -0
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +17 -1
- package/dist/svelte/index.d.ts +6 -2
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +8 -1
- package/dist/tool-allow-list.d.ts +8 -0
- package/dist/tool-allow-list.d.ts.map +1 -0
- package/dist/tool-loop.d.ts +191 -3
- package/dist/tool-loop.d.ts.map +1 -1
- package/package.json +19 -15
- package/dist/chunks/ChatService-D48KBDZy.js.map +0 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Previewable choices: a click previews a card in place (nothing applied),
|
|
4
|
+
* another click swaps the preview, "Original" shows the page as it was, and
|
|
5
|
+
* only the commit button applies. Cancel, Escape, dismissing and disposing
|
|
6
|
+
* restore the original.
|
|
7
|
+
*/
|
|
8
|
+
import { createDataSurfaceRegistry } from '@happyvertical/smrt-ui/data-surface';
|
|
9
|
+
import { render, screen, userEvent } from '@happyvertical/smrt-vitest/svelte';
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import AssistantDock from '../AssistantDock.svelte';
|
|
12
|
+
import { createAssistantChoiceSourceRegistry, } from '../assistant-choices.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
|
+
function suspendingTransport(calls) {
|
|
23
|
+
const sent = [];
|
|
24
|
+
const resumed = [];
|
|
25
|
+
const transport = {
|
|
26
|
+
sent,
|
|
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
|
+
sent.push(input);
|
|
42
|
+
return {
|
|
43
|
+
inProgress: false,
|
|
44
|
+
userMessage: message('u1', 'user', input.content),
|
|
45
|
+
clientToolCalls: {
|
|
46
|
+
continuationId: `cont-${sent.length}`,
|
|
47
|
+
calls: calls.map((call) => ({
|
|
48
|
+
...call,
|
|
49
|
+
id: sent.length > 1 ? `${call.id}-${sent.length}` : call.id,
|
|
50
|
+
})),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
async resumeTurn(input) {
|
|
55
|
+
resumed.push(input);
|
|
56
|
+
const reply = message('a1', 'assistant', 'Pick one of these.');
|
|
57
|
+
return { inProgress: false, assistantMessage: reply };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return transport;
|
|
61
|
+
}
|
|
62
|
+
async function until(check) {
|
|
63
|
+
for (let i = 0; i < 50 && !check(); i += 1) {
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const ORIGINAL = { id: 'original', label: 'Original', imageUrl: '/p/1?orig' };
|
|
68
|
+
function previewSource() {
|
|
69
|
+
const shown = [];
|
|
70
|
+
const apply = vi.fn(async () => 'Changed it.');
|
|
71
|
+
const source = {
|
|
72
|
+
id: 'picture_adjust',
|
|
73
|
+
description: 'Quick changes.',
|
|
74
|
+
offer: async () => ({
|
|
75
|
+
title: 'Pick a version',
|
|
76
|
+
options: [
|
|
77
|
+
{ id: 'a', label: 'Brighter', imageUrl: '/p/1?a' },
|
|
78
|
+
{ id: 'b', label: 'Much brighter', imageUrl: '/p/1?b' },
|
|
79
|
+
],
|
|
80
|
+
original: ORIGINAL,
|
|
81
|
+
commitLabel: 'Use this picture',
|
|
82
|
+
cancelLabel: 'Keep the original',
|
|
83
|
+
}),
|
|
84
|
+
apply,
|
|
85
|
+
preview: (option) => {
|
|
86
|
+
shown.push(option?.id === 'original' || !option ? null : (option.imageUrl ?? null));
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return { source, shown, apply };
|
|
90
|
+
}
|
|
91
|
+
async function mount(source) {
|
|
92
|
+
const registry = createAssistantChoiceSourceRegistry();
|
|
93
|
+
registry.register(source);
|
|
94
|
+
const transport = suspendingTransport([
|
|
95
|
+
{
|
|
96
|
+
id: 'c1',
|
|
97
|
+
name: 'assistant_offer_picture_adjust',
|
|
98
|
+
args: {},
|
|
99
|
+
effect: 'read',
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
let controller;
|
|
103
|
+
const view = render(AssistantDock, {
|
|
104
|
+
props: {
|
|
105
|
+
transport,
|
|
106
|
+
registry: createDataSurfaceRegistry(),
|
|
107
|
+
choiceSources: registry,
|
|
108
|
+
oncontroller: (c) => {
|
|
109
|
+
controller = c;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
await until(() => Boolean(controller));
|
|
114
|
+
await controller?.openThread('t1');
|
|
115
|
+
await controller?.send('brighter');
|
|
116
|
+
await screen.findByRole('group', { name: 'Pick a version' });
|
|
117
|
+
return { controller: controller, view };
|
|
118
|
+
}
|
|
119
|
+
describe('previewable choices', () => {
|
|
120
|
+
it('previews a click, swaps the preview, shows the original, and applies nothing', async () => {
|
|
121
|
+
const { source, shown, apply } = previewSource();
|
|
122
|
+
const { controller } = await mount(source);
|
|
123
|
+
// The Original card comes first.
|
|
124
|
+
const cards = screen
|
|
125
|
+
.getAllByRole('button')
|
|
126
|
+
.filter((b) => b.hasAttribute('aria-pressed'));
|
|
127
|
+
expect(cards.map((b) => b.textContent?.trim())).toEqual([
|
|
128
|
+
'Original',
|
|
129
|
+
'Brighter',
|
|
130
|
+
'Much brighter',
|
|
131
|
+
]);
|
|
132
|
+
await userEvent.click(screen.getByRole('button', { name: /^Brighter/ }));
|
|
133
|
+
await until(() => shown.length === 1);
|
|
134
|
+
expect(shown).toEqual(['/p/1?a']);
|
|
135
|
+
expect(screen
|
|
136
|
+
.getByRole('button', { name: /^Brighter/ })
|
|
137
|
+
.getAttribute('aria-pressed')).toBe('true');
|
|
138
|
+
await userEvent.click(screen.getByRole('button', { name: /^Much brighter/ }));
|
|
139
|
+
await until(() => shown.length === 2);
|
|
140
|
+
expect(shown).toEqual(['/p/1?a', '/p/1?b']);
|
|
141
|
+
expect(screen
|
|
142
|
+
.getByRole('button', { name: /^Brighter/ })
|
|
143
|
+
.getAttribute('aria-pressed')).toBe('false');
|
|
144
|
+
await userEvent.click(screen.getByRole('button', { name: /^Original/ }));
|
|
145
|
+
await until(() => shown.length === 3);
|
|
146
|
+
expect(shown).toEqual(['/p/1?a', '/p/1?b', null]);
|
|
147
|
+
expect(apply).not.toHaveBeenCalled();
|
|
148
|
+
expect(controller.choices[0].status).toBe('waiting');
|
|
149
|
+
});
|
|
150
|
+
it('keeps the commit button off until a version is previewed, then applies only the previewed one and resolves', async () => {
|
|
151
|
+
const { source, apply } = previewSource();
|
|
152
|
+
const { controller } = await mount(source);
|
|
153
|
+
const use = screen.getByRole('button', {
|
|
154
|
+
name: 'Use this picture',
|
|
155
|
+
});
|
|
156
|
+
expect(use.disabled).toBe(true);
|
|
157
|
+
await userEvent.click(screen.getByRole('button', { name: /^Much brighter/ }));
|
|
158
|
+
await until(() => !use.disabled);
|
|
159
|
+
expect(use.disabled).toBe(false);
|
|
160
|
+
await userEvent.click(use);
|
|
161
|
+
await until(() => apply.mock.calls.length > 0);
|
|
162
|
+
expect(apply).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(apply).toHaveBeenCalledWith(expect.objectContaining({ id: 'b' }));
|
|
164
|
+
expect(await screen.findByText('Changed it.')).toBeTruthy();
|
|
165
|
+
expect(controller.choices[0]).toMatchObject({
|
|
166
|
+
status: 'applied',
|
|
167
|
+
chosenOptionId: 'b',
|
|
168
|
+
});
|
|
169
|
+
// The cards collapse once resolved.
|
|
170
|
+
expect(screen.queryByRole('button', { name: /^Brighter/ })).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
it('waits for a slow preview: cards and commit are off until the page shows it', async () => {
|
|
173
|
+
const { source, apply } = previewSource();
|
|
174
|
+
const releases = [];
|
|
175
|
+
const shown = [];
|
|
176
|
+
const slow = {
|
|
177
|
+
...source,
|
|
178
|
+
preview: (option) => new Promise((resolve) => {
|
|
179
|
+
releases.push(() => {
|
|
180
|
+
shown.push(option?.id ?? 'none');
|
|
181
|
+
resolve();
|
|
182
|
+
});
|
|
183
|
+
}),
|
|
184
|
+
};
|
|
185
|
+
const { controller } = await mount(slow);
|
|
186
|
+
const brighter = screen.getByRole('button', { name: /^Brighter/ });
|
|
187
|
+
const muchBrighter = screen.getByRole('button', {
|
|
188
|
+
name: /^Much brighter/,
|
|
189
|
+
});
|
|
190
|
+
const use = screen.getByRole('button', {
|
|
191
|
+
name: 'Use this picture',
|
|
192
|
+
});
|
|
193
|
+
await userEvent.click(brighter);
|
|
194
|
+
await until(() => releases.length === 1);
|
|
195
|
+
// While the page is still changing, nothing else can be picked or committed.
|
|
196
|
+
expect(muchBrighter.disabled).toBe(true);
|
|
197
|
+
expect(use.disabled).toBe(true);
|
|
198
|
+
await userEvent.click(muchBrighter);
|
|
199
|
+
expect(releases).toHaveLength(1);
|
|
200
|
+
releases[0]();
|
|
201
|
+
await until(() => !muchBrighter.disabled);
|
|
202
|
+
expect(shown).toEqual(['a']);
|
|
203
|
+
expect(controller.choices[0].previewOptionId).toBe('a');
|
|
204
|
+
expect(use.disabled).toBe(false);
|
|
205
|
+
await userEvent.click(use);
|
|
206
|
+
await until(() => apply.mock.calls.length > 0);
|
|
207
|
+
expect(apply).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' }));
|
|
208
|
+
});
|
|
209
|
+
it('back-to-back previews queue, the last one shows, and it is what commits', async () => {
|
|
210
|
+
const { source, apply } = previewSource();
|
|
211
|
+
const shown = [];
|
|
212
|
+
const releases = [];
|
|
213
|
+
const slow = {
|
|
214
|
+
...source,
|
|
215
|
+
preview: (option) => new Promise((resolve) => {
|
|
216
|
+
releases.push(() => {
|
|
217
|
+
shown.push(option?.id ?? 'none');
|
|
218
|
+
resolve();
|
|
219
|
+
});
|
|
220
|
+
}),
|
|
221
|
+
};
|
|
222
|
+
const { controller } = await mount(slow);
|
|
223
|
+
const setId = controller.choices[0].id;
|
|
224
|
+
// Two programmatic previews (a double click that beat the disabled
|
|
225
|
+
// state): they queue, a superseded one never reaches the page, the page
|
|
226
|
+
// and the offer both end on the second, and a commit while a preview is
|
|
227
|
+
// pending does nothing.
|
|
228
|
+
const first = controller.previewOption(setId, 'a');
|
|
229
|
+
const second = controller.previewOption(setId, 'b');
|
|
230
|
+
await until(() => releases.length === 1);
|
|
231
|
+
await controller.commitOption(setId);
|
|
232
|
+
expect(apply).not.toHaveBeenCalled();
|
|
233
|
+
releases[0]();
|
|
234
|
+
await Promise.all([first, second]);
|
|
235
|
+
expect(releases).toHaveLength(1);
|
|
236
|
+
expect(shown).toEqual(['b']);
|
|
237
|
+
expect(controller.choices[0].previewOptionId).toBe('b');
|
|
238
|
+
expect(controller.choices[0].previewPendingId).toBeUndefined();
|
|
239
|
+
await controller.commitOption(setId);
|
|
240
|
+
expect(apply).toHaveBeenCalledTimes(1);
|
|
241
|
+
expect(apply).toHaveBeenCalledWith(expect.objectContaining({ id: 'b' }));
|
|
242
|
+
});
|
|
243
|
+
it('cancelling during a slow preview restores the page after it, never before', async () => {
|
|
244
|
+
const { source } = previewSource();
|
|
245
|
+
const shown = [];
|
|
246
|
+
const releases = [];
|
|
247
|
+
const slow = {
|
|
248
|
+
...source,
|
|
249
|
+
preview: (option) => new Promise((resolve) => {
|
|
250
|
+
releases.push(() => {
|
|
251
|
+
shown.push(option?.id === 'original' || !option ? null : option.id);
|
|
252
|
+
resolve();
|
|
253
|
+
});
|
|
254
|
+
}),
|
|
255
|
+
};
|
|
256
|
+
const { controller } = await mount(slow);
|
|
257
|
+
const setId = controller.choices[0].id;
|
|
258
|
+
const pending = controller.previewOption(setId, 'a');
|
|
259
|
+
await until(() => releases.length === 1);
|
|
260
|
+
await userEvent.click(screen.getByRole('button', { name: 'Keep the original' }));
|
|
261
|
+
releases[0]();
|
|
262
|
+
await pending;
|
|
263
|
+
await until(() => releases.length === 2);
|
|
264
|
+
releases[1]();
|
|
265
|
+
await until(() => shown.length === 2);
|
|
266
|
+
expect(shown).toEqual(['a', null]);
|
|
267
|
+
expect(controller.choices[0].status).toBe('dismissed');
|
|
268
|
+
});
|
|
269
|
+
it('cancel restores the original and closes without applying', async () => {
|
|
270
|
+
const { source, shown, apply } = previewSource();
|
|
271
|
+
await mount(source);
|
|
272
|
+
await userEvent.click(screen.getByRole('button', { name: /^Brighter/ }));
|
|
273
|
+
await until(() => shown.length === 1);
|
|
274
|
+
await userEvent.click(screen.getByRole('button', { name: 'Keep the original' }));
|
|
275
|
+
await until(() => shown.length === 2);
|
|
276
|
+
expect(shown).toEqual(['/p/1?a', null]);
|
|
277
|
+
expect(screen.queryByRole('group', { name: 'Pick a version' })).toBeNull();
|
|
278
|
+
expect(apply).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
it('Escape cancels an open preview', async () => {
|
|
281
|
+
const { source, shown, apply } = previewSource();
|
|
282
|
+
await mount(source);
|
|
283
|
+
const card = screen.getByRole('button', { name: /^Brighter/ });
|
|
284
|
+
await userEvent.click(card);
|
|
285
|
+
await until(() => shown.length === 1);
|
|
286
|
+
card.focus();
|
|
287
|
+
await userEvent.keyboard('{Escape}');
|
|
288
|
+
await until(() => shown.length === 2);
|
|
289
|
+
expect(shown).toEqual(['/p/1?a', null]);
|
|
290
|
+
expect(screen.queryByRole('group', { name: 'Pick a version' })).toBeNull();
|
|
291
|
+
expect(apply).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
it('puts the page back when the conversation is disposed with an uncommitted preview', async () => {
|
|
294
|
+
const { source, shown, apply } = previewSource();
|
|
295
|
+
const { controller } = await mount(source);
|
|
296
|
+
await userEvent.click(screen.getByRole('button', { name: /^Brighter/ }));
|
|
297
|
+
await until(() => shown.length === 1);
|
|
298
|
+
controller.dispose();
|
|
299
|
+
await until(() => shown.length === 2);
|
|
300
|
+
expect(shown).toEqual(['/p/1?a', null]);
|
|
301
|
+
expect(apply).not.toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
it('a source without preview still applies on click', async () => {
|
|
304
|
+
const apply = vi.fn(async () => 'Done.');
|
|
305
|
+
const { source } = previewSource();
|
|
306
|
+
await mount({ ...source, preview: undefined, apply });
|
|
307
|
+
await userEvent.click(screen.getByRole('button', { name: /^Brighter/ }));
|
|
308
|
+
await until(() => apply.mock.calls.length > 0);
|
|
309
|
+
expect(apply).toHaveBeenCalledTimes(1);
|
|
310
|
+
expect(screen.queryByRole('button', { name: 'Use this picture' })).toBeNull();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Choices: the assistant offers a few options (from a page-registered
|
|
4
|
+
* source, never from the model), the person clicks one, and only that click
|
|
5
|
+
* applies it.
|
|
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, normalizeChoiceOptions, safeChoiceImageUrl, } 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
|
+
function suspendingTransport(calls) {
|
|
23
|
+
const sent = [];
|
|
24
|
+
const resumed = [];
|
|
25
|
+
const transport = {
|
|
26
|
+
sent,
|
|
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
|
+
sent.push(input);
|
|
42
|
+
return {
|
|
43
|
+
inProgress: false,
|
|
44
|
+
userMessage: message('u1', 'user', input.content),
|
|
45
|
+
clientToolCalls: {
|
|
46
|
+
continuationId: `cont-${sent.length}`,
|
|
47
|
+
calls: calls.map((call) => ({
|
|
48
|
+
...call,
|
|
49
|
+
id: sent.length > 1 ? `${call.id}-${sent.length}` : call.id,
|
|
50
|
+
})),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
async resumeTurn(input) {
|
|
55
|
+
resumed.push(input);
|
|
56
|
+
const reply = message('a1', 'assistant', 'Pick one of these.');
|
|
57
|
+
return { inProgress: false, assistantMessage: reply };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return transport;
|
|
61
|
+
}
|
|
62
|
+
function cropSource(apply = vi.fn(async () => 'Cropped to a square.')) {
|
|
63
|
+
const offer = vi.fn(async () => ({
|
|
64
|
+
title: 'Pick a crop',
|
|
65
|
+
options: [
|
|
66
|
+
{ id: 'wide', label: 'Wide', imageUrl: '/p/1?w=16', value: { w: 16 } },
|
|
67
|
+
{ id: 'square', label: 'Square', imageUrl: '/p/1?w=1' },
|
|
68
|
+
{ id: 'evil', label: 'Remote', imageUrl: 'https://evil.test/x.png' },
|
|
69
|
+
],
|
|
70
|
+
}));
|
|
71
|
+
const source = {
|
|
72
|
+
id: 'picture_crop',
|
|
73
|
+
description: 'Offer crops of the selected picture.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: { tighter: { type: 'boolean' } },
|
|
77
|
+
},
|
|
78
|
+
offer,
|
|
79
|
+
apply,
|
|
80
|
+
};
|
|
81
|
+
return { source, offer, apply };
|
|
82
|
+
}
|
|
83
|
+
async function until(check) {
|
|
84
|
+
for (let i = 0; i < 50 && !check(); i += 1) {
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
describe('choice helpers', () => {
|
|
89
|
+
it('only allows same-origin image paths', () => {
|
|
90
|
+
expect(safeChoiceImageUrl('/sites/a/p.jpg?size=card')).toBe('/sites/a/p.jpg?size=card');
|
|
91
|
+
for (const bad of [
|
|
92
|
+
'//evil.test/x',
|
|
93
|
+
'https://x.test/a.jpg',
|
|
94
|
+
'/\\evil',
|
|
95
|
+
'javascript:alert(1)',
|
|
96
|
+
'/a b',
|
|
97
|
+
5,
|
|
98
|
+
]) {
|
|
99
|
+
expect(safeChoiceImageUrl(bad)).toBeNull();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
it('keeps at most four unique, labelled options', () => {
|
|
103
|
+
const options = normalizeChoiceOptions([
|
|
104
|
+
{ id: 'a', label: 'A' },
|
|
105
|
+
{ id: 'a', label: 'dupe' },
|
|
106
|
+
{ id: '', label: 'no id' },
|
|
107
|
+
{ id: 'b', label: '' },
|
|
108
|
+
{ id: 'c', label: 'C', imageUrl: 'https://x.test/c.jpg' },
|
|
109
|
+
{ id: 'd', label: 'D' },
|
|
110
|
+
{ id: 'e', label: 'E' },
|
|
111
|
+
{ id: 'f', label: 'F' },
|
|
112
|
+
]);
|
|
113
|
+
expect(options.map((o) => o.id)).toEqual(['a', 'c', 'd', 'e']);
|
|
114
|
+
expect(options[1].imageUrl).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
it('rejects bad source ids and unregisters', () => {
|
|
117
|
+
const registry = createAssistantChoiceSourceRegistry();
|
|
118
|
+
expect(() => registry.register({ ...cropSource().source, id: 'Bad Id' })).toThrow();
|
|
119
|
+
const off = registry.register(cropSource().source);
|
|
120
|
+
expect(registry.list()).toHaveLength(1);
|
|
121
|
+
off();
|
|
122
|
+
expect(registry.list()).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('dock choices', () => {
|
|
126
|
+
it("offers each source as a read tool, and applies only on the person's pick", async () => {
|
|
127
|
+
const registry = createAssistantChoiceSourceRegistry();
|
|
128
|
+
const { source, offer, apply } = cropSource();
|
|
129
|
+
registry.register(source);
|
|
130
|
+
const transport = suspendingTransport([
|
|
131
|
+
{
|
|
132
|
+
id: 'c1',
|
|
133
|
+
name: choiceToolName('picture_crop'),
|
|
134
|
+
args: { tighter: true },
|
|
135
|
+
effect: 'read',
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
138
|
+
const controller = createAssistantDockController({
|
|
139
|
+
transport,
|
|
140
|
+
registry: createDataSurfaceRegistry(),
|
|
141
|
+
choiceSources: registry,
|
|
142
|
+
});
|
|
143
|
+
await controller.loadThreads();
|
|
144
|
+
await controller.openThread('t1');
|
|
145
|
+
await controller.send('crop this tighter');
|
|
146
|
+
const declared = transport.sent[0].clientTools ?? [];
|
|
147
|
+
expect(declared.map((tool) => tool.name)).toContain('assistant_offer_picture_crop');
|
|
148
|
+
expect(declared.find((tool) => tool.name === 'assistant_offer_picture_crop')
|
|
149
|
+
?.effect).toBe('read');
|
|
150
|
+
expect(offer).toHaveBeenCalledWith({ tighter: true }, expect.anything());
|
|
151
|
+
// The model learns the options by id and label only; nothing applied yet.
|
|
152
|
+
const result = transport.resumed[0].results[0];
|
|
153
|
+
expect(result.ok).toBe(true);
|
|
154
|
+
expect(JSON.parse(result.result ?? '{}')).toEqual({
|
|
155
|
+
offered: true,
|
|
156
|
+
waitingForUser: true,
|
|
157
|
+
options: [
|
|
158
|
+
{ id: 'wide', label: 'Wide' },
|
|
159
|
+
{ id: 'square', label: 'Square' },
|
|
160
|
+
{ id: 'evil', label: 'Remote' },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
expect(apply).not.toHaveBeenCalled();
|
|
164
|
+
expect(controller.choices[0].status).toBe('waiting');
|
|
165
|
+
expect(controller.choices[0].options[2].imageUrl).toBeUndefined();
|
|
166
|
+
expect(controller.status.label).toBe('Pick one of the options');
|
|
167
|
+
// A supervised run waits for the person's pick.
|
|
168
|
+
expect(controller.run?.state).toBe('waiting');
|
|
169
|
+
expect(controller.run?.waitingFor).toMatchObject({
|
|
170
|
+
kind: 'choice',
|
|
171
|
+
label: 'Pick a crop',
|
|
172
|
+
});
|
|
173
|
+
await controller.chooseOption('c1', 'square');
|
|
174
|
+
expect(apply).toHaveBeenCalledWith(expect.objectContaining({ id: 'square' }));
|
|
175
|
+
expect(controller.choices[0]).toMatchObject({
|
|
176
|
+
status: 'applied',
|
|
177
|
+
chosenOptionId: 'square',
|
|
178
|
+
outcome: 'Cropped to a square.',
|
|
179
|
+
});
|
|
180
|
+
expect(controller.run?.waitingFor?.kind).not.toBe('choice');
|
|
181
|
+
// A second pick is ignored once applied.
|
|
182
|
+
await controller.chooseOption('c1', 'wide');
|
|
183
|
+
expect(apply).toHaveBeenCalledTimes(1);
|
|
184
|
+
});
|
|
185
|
+
it('reports a source error to the model and shows nothing', async () => {
|
|
186
|
+
const registry = createAssistantChoiceSourceRegistry();
|
|
187
|
+
registry.register({
|
|
188
|
+
id: 'picture_find',
|
|
189
|
+
description: 'Find pictures.',
|
|
190
|
+
offer: () => {
|
|
191
|
+
throw new Error('No pictures match "arena".');
|
|
192
|
+
},
|
|
193
|
+
apply: () => undefined,
|
|
194
|
+
});
|
|
195
|
+
const transport = suspendingTransport([
|
|
196
|
+
{
|
|
197
|
+
id: 'c1',
|
|
198
|
+
name: 'assistant_offer_picture_find',
|
|
199
|
+
args: {},
|
|
200
|
+
effect: 'read',
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
const controller = createAssistantDockController({
|
|
204
|
+
transport,
|
|
205
|
+
registry: createDataSurfaceRegistry(),
|
|
206
|
+
choiceSources: registry,
|
|
207
|
+
});
|
|
208
|
+
await controller.loadThreads();
|
|
209
|
+
await controller.openThread('t1');
|
|
210
|
+
await controller.send('find the arena');
|
|
211
|
+
expect(transport.resumed[0].results[0]).toEqual({
|
|
212
|
+
id: 'c1',
|
|
213
|
+
ok: false,
|
|
214
|
+
error: 'No pictures match "arena".',
|
|
215
|
+
});
|
|
216
|
+
expect(controller.choices).toHaveLength(0);
|
|
217
|
+
});
|
|
218
|
+
it('marks a failed apply so the person can pick again, and fails when the page is gone', async () => {
|
|
219
|
+
const registry = createAssistantChoiceSourceRegistry();
|
|
220
|
+
const apply = vi
|
|
221
|
+
.fn()
|
|
222
|
+
.mockRejectedValueOnce(new Error('The picture is not in the story'))
|
|
223
|
+
.mockResolvedValueOnce(undefined);
|
|
224
|
+
const { source } = cropSource(apply);
|
|
225
|
+
const off = registry.register(source);
|
|
226
|
+
const transport = suspendingTransport([
|
|
227
|
+
{
|
|
228
|
+
id: 'c1',
|
|
229
|
+
name: 'assistant_offer_picture_crop',
|
|
230
|
+
args: {},
|
|
231
|
+
effect: 'read',
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
const controller = createAssistantDockController({
|
|
235
|
+
transport,
|
|
236
|
+
registry: createDataSurfaceRegistry(),
|
|
237
|
+
choiceSources: registry,
|
|
238
|
+
});
|
|
239
|
+
await controller.loadThreads();
|
|
240
|
+
await controller.openThread('t1');
|
|
241
|
+
await controller.send('crop');
|
|
242
|
+
await controller.chooseOption('c1', 'wide');
|
|
243
|
+
expect(controller.choices[0]).toMatchObject({
|
|
244
|
+
status: 'failed',
|
|
245
|
+
error: 'The picture is not in the story',
|
|
246
|
+
});
|
|
247
|
+
await controller.chooseOption('c1', 'square');
|
|
248
|
+
expect(controller.choices[0].status).toBe('applied');
|
|
249
|
+
// A newer request replaces an offer still waiting, not an applied one.
|
|
250
|
+
await controller.send('crop again');
|
|
251
|
+
expect(controller.choices.map((s) => s.status)).toEqual([
|
|
252
|
+
'applied',
|
|
253
|
+
'waiting',
|
|
254
|
+
]);
|
|
255
|
+
await controller.send('and again');
|
|
256
|
+
expect(controller.choices.map((s) => s.status)).toEqual([
|
|
257
|
+
'applied',
|
|
258
|
+
'dismissed',
|
|
259
|
+
'waiting',
|
|
260
|
+
]);
|
|
261
|
+
// The page went away: a pick fails plainly instead of applying.
|
|
262
|
+
off();
|
|
263
|
+
await controller.chooseOption('c1-3', 'wide');
|
|
264
|
+
expect(controller.choices[2]).toMatchObject({
|
|
265
|
+
status: 'failed',
|
|
266
|
+
error: 'This is no longer on the page.',
|
|
267
|
+
});
|
|
268
|
+
expect(apply).toHaveBeenCalledTimes(2);
|
|
269
|
+
});
|
|
270
|
+
it('renders picture cards and applies the clicked one', async () => {
|
|
271
|
+
const registry = createAssistantChoiceSourceRegistry();
|
|
272
|
+
const { source, apply } = cropSource();
|
|
273
|
+
registry.register(source);
|
|
274
|
+
const transport = suspendingTransport([
|
|
275
|
+
{
|
|
276
|
+
id: 'c1',
|
|
277
|
+
name: 'assistant_offer_picture_crop',
|
|
278
|
+
args: {},
|
|
279
|
+
effect: 'read',
|
|
280
|
+
},
|
|
281
|
+
]);
|
|
282
|
+
let controller;
|
|
283
|
+
render(AssistantDock, {
|
|
284
|
+
props: {
|
|
285
|
+
transport,
|
|
286
|
+
registry: createDataSurfaceRegistry(),
|
|
287
|
+
choiceSources: registry,
|
|
288
|
+
oncontroller: (c) => {
|
|
289
|
+
controller = c;
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
await until(() => Boolean(controller));
|
|
294
|
+
await controller?.openThread('t1');
|
|
295
|
+
await controller?.send('crop this');
|
|
296
|
+
const group = await screen.findByRole('group', { name: 'Pick a crop' });
|
|
297
|
+
const images = group.querySelectorAll('img');
|
|
298
|
+
expect(images).toHaveLength(2);
|
|
299
|
+
expect(images[0].getAttribute('src')).toBe('/p/1?w=16');
|
|
300
|
+
expect(screen.getByText('Pick one to use it.')).toBeTruthy();
|
|
301
|
+
await userEvent.click(screen.getByRole('button', { name: /Square/ }));
|
|
302
|
+
await until(() => apply.mock.calls.length > 0);
|
|
303
|
+
expect(await screen.findByText('Cropped to a square.')).toBeTruthy();
|
|
304
|
+
expect(screen
|
|
305
|
+
.getByRole('button', { name: /Square/ })
|
|
306
|
+
.getAttribute('aria-pressed')).toBe('true');
|
|
307
|
+
});
|
|
308
|
+
it('dismisses with None of these', async () => {
|
|
309
|
+
const registry = createAssistantChoiceSourceRegistry();
|
|
310
|
+
const { source, apply } = cropSource();
|
|
311
|
+
registry.register(source);
|
|
312
|
+
const transport = suspendingTransport([
|
|
313
|
+
{
|
|
314
|
+
id: 'c1',
|
|
315
|
+
name: 'assistant_offer_picture_crop',
|
|
316
|
+
args: {},
|
|
317
|
+
effect: 'read',
|
|
318
|
+
},
|
|
319
|
+
]);
|
|
320
|
+
let controller;
|
|
321
|
+
render(AssistantDock, {
|
|
322
|
+
props: {
|
|
323
|
+
transport,
|
|
324
|
+
registry: createDataSurfaceRegistry(),
|
|
325
|
+
choiceSources: registry,
|
|
326
|
+
oncontroller: (c) => {
|
|
327
|
+
controller = c;
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
await until(() => Boolean(controller));
|
|
332
|
+
await controller?.openThread('t1');
|
|
333
|
+
await controller?.send('crop this');
|
|
334
|
+
await userEvent.click(await screen.findByRole('button', { name: 'None of these' }));
|
|
335
|
+
expect(screen.queryByRole('group', { name: 'Pick a crop' })).toBeNull();
|
|
336
|
+
expect(apply).not.toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
});
|