@open-mercato/ui 0.6.1-develop.3227.1.2b7b8ab258 → 0.6.1-develop.3256.1.fe3dec2464

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 (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -0
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +226 -58
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiDock.js +3 -2
  8. package/dist/ai/AiDock.js.map +2 -2
  9. package/dist/ai/ModelPicker.js +186 -0
  10. package/dist/ai/ModelPicker.js.map +7 -0
  11. package/dist/ai/index.js +3 -1
  12. package/dist/ai/index.js.map +2 -2
  13. package/dist/ai/useAiChat.js +12 -16
  14. package/dist/ai/useAiChat.js.map +2 -2
  15. package/dist/backend/operations/LastOperationBanner.js +15 -9
  16. package/dist/backend/operations/LastOperationBanner.js.map +2 -2
  17. package/dist/backend/operations/store.js +64 -8
  18. package/dist/backend/operations/store.js.map +2 -2
  19. package/dist/backend/utils/bulkDelete.js +46 -0
  20. package/dist/backend/utils/bulkDelete.js.map +7 -0
  21. package/package.json +3 -3
  22. package/src/ai/AiAssistantLauncher.tsx +8 -0
  23. package/src/ai/AiChat.tsx +229 -11
  24. package/src/ai/AiDock.tsx +3 -2
  25. package/src/ai/ModelPicker.tsx +248 -0
  26. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
  27. package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
  28. package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
  29. package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
  30. package/src/ai/__tests__/AiChat.test.tsx +180 -0
  31. package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
  32. package/src/ai/__tests__/AiDock.test.tsx +12 -1
  33. package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
  34. package/src/ai/index.ts +1 -0
  35. package/src/ai/useAiChat.ts +29 -16
  36. package/src/backend/operations/LastOperationBanner.tsx +19 -11
  37. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +46 -1
  38. package/src/backend/operations/__tests__/store.test.ts +142 -0
  39. package/src/backend/operations/store.ts +76 -7
  40. package/src/backend/utils/__tests__/bulkDelete.test.ts +103 -0
  41. package/src/backend/utils/bulkDelete.ts +77 -0
@@ -158,3 +158,19 @@ describe('<AiChat> conversationId threading (Step 5.15)', () => {
158
158
  expect(parsedBody.conversationId).toBe('conv-body-xyz')
159
159
  })
160
160
  })
161
+
162
+ // <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
163
+ // on first render via apiCall. Stub apiCall with a no-providers response so this files
164
+ // chat-flow assertions remain scoped to the dispatcher mock above.
165
+ jest.mock('../../backend/utils/apiCall', () => ({
166
+ apiCall: jest.fn(async () => ({
167
+ ok: true,
168
+ status: 200,
169
+ result: {
170
+ allowRuntimeModelOverride: false,
171
+ defaultProviderId: 'openai',
172
+ defaultModelId: 'gpt-5-mini',
173
+ providers: [],
174
+ },
175
+ })),
176
+ }))
@@ -150,3 +150,19 @@ describe('<AiChat> debug panel (Step 4.6)', () => {
150
150
  ).toBeInTheDocument()
151
151
  })
152
152
  })
153
+
154
+ // <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
155
+ // on first render via apiCall. Stub apiCall with a no-providers response so this files
156
+ // chat-flow assertions remain scoped to the dispatcher mock above.
157
+ jest.mock('../../backend/utils/apiCall', () => ({
158
+ apiCall: jest.fn(async () => ({
159
+ ok: true,
160
+ status: 200,
161
+ result: {
162
+ allowRuntimeModelOverride: false,
163
+ defaultProviderId: 'openai',
164
+ defaultModelId: 'gpt-5-mini',
165
+ providers: [],
166
+ },
167
+ })),
168
+ }))
@@ -211,3 +211,19 @@ describe('<AiChat> × UI-part registry', () => {
211
211
  expect(screen.getByTestId('global-card')).toBeInTheDocument()
212
212
  })
213
213
  })
214
+
215
+ // <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
216
+ // on first render via apiCall. Stub apiCall with a no-providers response so this files
217
+ // chat-flow assertions remain scoped to the dispatcher mock above.
218
+ jest.mock('../../backend/utils/apiCall', () => ({
219
+ apiCall: jest.fn(async () => ({
220
+ ok: true,
221
+ status: 200,
222
+ result: {
223
+ allowRuntimeModelOverride: false,
224
+ defaultProviderId: 'openai',
225
+ defaultModelId: 'gpt-5-mini',
226
+ providers: [],
227
+ },
228
+ })),
229
+ }))
@@ -36,9 +36,54 @@ jest.mock('../../backend/utils/api', () => ({
36
36
  apiFetch: jest.fn(),
37
37
  }))
38
38
 
39
+ // <AiChat> mounts `useAgentModels` (Phase 4b) which calls apiCall against
40
+ // /api/ai_assistant/ai/agents/<id>/models on first render. The chat-flow
41
+ // tests in this file don't exercise the picker, so stub apiCall with a
42
+ // no-providers response — keeps `apiFetch.mock.calls` scoped to the
43
+ // dispatcher and lets the existing `mockResolvedValueOnce` setup drive the
44
+ // assertion path without having to special-case the models endpoint.
45
+ jest.mock('../../backend/utils/apiCall', () => ({
46
+ apiCall: jest.fn(async () => ({
47
+ ok: true,
48
+ status: 200,
49
+ result: {
50
+ agentId: 'customers.account_assistant',
51
+ allowRuntimeModelOverride: false,
52
+ defaultProviderId: 'openai',
53
+ defaultModelId: 'gpt-5-mini',
54
+ providers: [],
55
+ },
56
+ })),
57
+ }))
58
+
39
59
  import { apiFetch } from '../../backend/utils/api'
60
+ import { apiCall } from '../../backend/utils/apiCall'
40
61
  import { AiChat } from '../AiChat'
41
62
 
63
+ let lastResizeObserver: MockResizeObserver | null = null
64
+
65
+ class MockResizeObserver {
66
+ observe = jest.fn()
67
+ disconnect = jest.fn()
68
+
69
+ constructor(
70
+ private readonly callback: ResizeObserverCallback,
71
+ ) {
72
+ lastResizeObserver = this
73
+ }
74
+
75
+ trigger(width: number) {
76
+ this.callback(
77
+ [
78
+ {
79
+ contentRect: { width },
80
+ } as ResizeObserverEntry,
81
+ ],
82
+ this as unknown as ResizeObserver,
83
+ )
84
+ }
85
+ }
86
+
42
87
  const dict = {
43
88
  'ai_assistant.chat.assistantRoleLabel': 'Assistant',
44
89
  'ai_assistant.chat.cancel': 'Cancel streaming response',
@@ -108,6 +153,7 @@ describe('<AiChat>', () => {
108
153
  beforeEach(() => {
109
154
  jest.clearAllMocks()
110
155
  window.localStorage.clear()
156
+ lastResizeObserver = null
111
157
  })
112
158
 
113
159
  it('renders the composer with the i18n placeholder and region labels', () => {
@@ -120,6 +166,140 @@ describe('<AiChat>', () => {
120
166
  expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled()
121
167
  })
122
168
 
169
+ it('replaces a stale stored model picker value with the first available model', async () => {
170
+ const apiCallMock = apiCall as unknown as jest.Mock
171
+ apiCallMock.mockResolvedValueOnce({
172
+ ok: true,
173
+ status: 200,
174
+ result: {
175
+ agentId: 'customers.account_assistant',
176
+ allowRuntimeModelOverride: true,
177
+ defaultProviderId: 'openai',
178
+ defaultModelId: 'gpt-5-mini',
179
+ providers: [
180
+ {
181
+ id: 'openai',
182
+ name: 'OpenAI',
183
+ isDefault: true,
184
+ models: [
185
+ {
186
+ id: 'gpt-5-mini',
187
+ name: 'GPT-5 Mini',
188
+ isDefault: true,
189
+ },
190
+ ],
191
+ },
192
+ ],
193
+ },
194
+ })
195
+ window.localStorage.setItem(
196
+ 'om-ai-model-picker:customers.account_assistant',
197
+ JSON.stringify({ providerId: 'openai', modelId: 'gpt-4o' }),
198
+ )
199
+
200
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
201
+
202
+ await waitFor(() => {
203
+ expect(screen.getByRole('button', { name: 'Select AI model' })).toHaveAttribute(
204
+ 'title',
205
+ 'OpenAI / GPT-5 Mini',
206
+ )
207
+ })
208
+ expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).toBe(
209
+ JSON.stringify({ providerId: 'openai', modelId: 'gpt-5-mini' }),
210
+ )
211
+ })
212
+
213
+ it('renders a compact footer before a constrained host is measured', async () => {
214
+ const apiCallMock = apiCall as unknown as jest.Mock
215
+ apiCallMock.mockResolvedValueOnce({
216
+ ok: true,
217
+ status: 200,
218
+ result: {
219
+ agentId: 'customers.account_assistant',
220
+ allowRuntimeModelOverride: true,
221
+ defaultProviderId: 'openai',
222
+ defaultModelId: 'gpt-5-mini',
223
+ providers: [
224
+ {
225
+ id: 'openai',
226
+ name: 'OpenAI',
227
+ isDefault: true,
228
+ models: [
229
+ {
230
+ id: 'gpt-5-mini',
231
+ name: 'GPT-5 Mini',
232
+ isDefault: true,
233
+ },
234
+ ],
235
+ },
236
+ ],
237
+ },
238
+ })
239
+
240
+ renderWithProviders(<AiChat agent="customers.account_assistant" defaultCompactFooter />, { dict })
241
+
242
+ const footer = document.querySelector('[data-ai-chat-footer=""]')
243
+ expect(footer).toHaveAttribute('data-ai-chat-footer-compact', 'true')
244
+ expect(screen.getByText('Press Cmd/Ctrl+Enter to send, Escape to cancel.')).toHaveClass('hidden')
245
+
246
+ await waitFor(() => {
247
+ expect(screen.getByRole('button', { name: 'Select AI model' })).toHaveClass('w-8')
248
+ })
249
+ expect(screen.getByRole('button', { name: 'Send message' })).toHaveClass('w-8')
250
+ })
251
+
252
+ it('lets a default compact footer expand after resize measurement', async () => {
253
+ const apiCallMock = apiCall as unknown as jest.Mock
254
+ apiCallMock.mockResolvedValueOnce({
255
+ ok: true,
256
+ status: 200,
257
+ result: {
258
+ agentId: 'customers.account_assistant',
259
+ allowRuntimeModelOverride: true,
260
+ defaultProviderId: 'openai',
261
+ defaultModelId: 'gpt-5-mini',
262
+ providers: [
263
+ {
264
+ id: 'openai',
265
+ name: 'OpenAI',
266
+ isDefault: true,
267
+ models: [
268
+ {
269
+ id: 'gpt-5-mini',
270
+ name: 'GPT-5 Mini',
271
+ isDefault: true,
272
+ },
273
+ ],
274
+ },
275
+ ],
276
+ },
277
+ })
278
+ const originalResizeObserver = globalThis.ResizeObserver
279
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
280
+
281
+ try {
282
+ renderWithProviders(<AiChat agent="customers.account_assistant" defaultCompactFooter />, { dict })
283
+
284
+ const footer = document.querySelector('[data-ai-chat-footer=""]')
285
+ expect(footer).toHaveAttribute('data-ai-chat-footer-compact', 'true')
286
+
287
+ await act(async () => {
288
+ lastResizeObserver?.trigger(720)
289
+ })
290
+
291
+ await waitFor(() => {
292
+ expect(footer).toHaveAttribute('data-ai-chat-footer-compact', 'false')
293
+ })
294
+ await waitFor(() => {
295
+ expect(screen.getByRole('button', { name: 'Select AI model' })).not.toHaveClass('w-8')
296
+ })
297
+ expect(screen.getByRole('button', { name: 'Send message' })).not.toHaveClass('w-8')
298
+ } finally {
299
+ globalThis.ResizeObserver = originalResizeObserver
300
+ }
301
+ })
302
+
123
303
  it('submits the message on Cmd+Enter and streams assistant text into the transcript', async () => {
124
304
  const fetchMock = apiFetch as unknown as jest.Mock
125
305
  fetchMock.mockResolvedValueOnce(
@@ -293,3 +293,19 @@ describe('<AiChat> unmount-mid-stream persistence (issue #1816)', () => {
293
293
  unmountB()
294
294
  })
295
295
  })
296
+
297
+ // <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
298
+ // on first render via apiCall. Stub apiCall with a no-providers response so this files
299
+ // chat-flow assertions remain scoped to the dispatcher mock above.
300
+ jest.mock('../../backend/utils/apiCall', () => ({
301
+ apiCall: jest.fn(async () => ({
302
+ ok: true,
303
+ status: 200,
304
+ result: {
305
+ allowRuntimeModelOverride: false,
306
+ defaultProviderId: 'openai',
307
+ defaultModelId: 'gpt-5-mini',
308
+ providers: [],
309
+ },
310
+ })),
311
+ }))
@@ -11,10 +11,11 @@ import { AiDockProvider, useAiDock, type AiDockedAssistant } from '../AiDock'
11
11
  jest.mock('../AiChat', () => {
12
12
  const ReactModule = require('react') as typeof import('react')
13
13
  return {
14
- AiChat: ({ agent }: { agent: string }) =>
14
+ AiChat: ({ agent, defaultCompactFooter }: { agent: string; defaultCompactFooter?: boolean }) =>
15
15
  ReactModule.createElement('div', {
16
16
  'data-testid': 'ai-chat',
17
17
  'data-agent': agent,
18
+ 'data-default-compact-footer': defaultCompactFooter ? 'true' : 'false',
18
19
  }),
19
20
  }
20
21
  })
@@ -121,4 +122,14 @@ describe('<AiDockProvider>', () => {
121
122
  })
122
123
  expect(document.querySelector('[data-ai-dock-panel=""]')).not.toBeInTheDocument()
123
124
  })
125
+
126
+ it('starts the chat footer compact inside the dock', async () => {
127
+ renderWithProviders(<Harness />)
128
+
129
+ fireEvent.click(screen.getByRole('button', { name: 'Dock assistant' }))
130
+
131
+ await waitFor(() => {
132
+ expect(screen.getByTestId('ai-chat')).toHaveAttribute('data-default-compact-footer', 'true')
133
+ })
134
+ })
124
135
  })
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { ModelPicker } from '../ModelPicker'
8
+ import type { ModelPickerProvider, ModelPickerValue } from '../ModelPicker'
9
+
10
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
11
+ useT: () => (key: string, fallback: string) => fallback,
12
+ }))
13
+
14
+ jest.mock('@open-mercato/shared/lib/utils', () => ({
15
+ cn: (...args: string[]) => args.filter(Boolean).join(' '),
16
+ }))
17
+
18
+ const providers: ModelPickerProvider[] = [
19
+ {
20
+ id: 'anthropic',
21
+ name: 'Anthropic',
22
+ isDefault: true,
23
+ models: [
24
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', isDefault: true },
25
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', isDefault: false },
26
+ ],
27
+ },
28
+ {
29
+ id: 'openai',
30
+ name: 'OpenAI',
31
+ isDefault: false,
32
+ models: [{ id: 'gpt-4o', name: 'GPT-4o', isDefault: false }],
33
+ },
34
+ ]
35
+
36
+ function ControlledPicker({
37
+ initial = null,
38
+ onChangeSpy,
39
+ }: {
40
+ initial?: ModelPickerValue | null
41
+ onChangeSpy?: jest.Mock
42
+ }) {
43
+ const [value, setValue] = React.useState<ModelPickerValue | null>(initial)
44
+ const handleChange = React.useCallback(
45
+ (next: ModelPickerValue | null) => {
46
+ setValue(next)
47
+ onChangeSpy?.(next)
48
+ },
49
+ [onChangeSpy],
50
+ )
51
+ return (
52
+ <ModelPicker
53
+ agentId="catalog.merchandising_assistant"
54
+ value={value}
55
+ onChange={handleChange}
56
+ availableProviders={providers}
57
+ />
58
+ )
59
+ }
60
+
61
+ describe('<ModelPicker>', () => {
62
+ it('renders the trigger button', () => {
63
+ render(<ControlledPicker />)
64
+ expect(screen.getByRole('button', { name: 'Select AI model' })).toBeInTheDocument()
65
+ })
66
+
67
+ it('shows the effective default model name when no runtime override is selected', () => {
68
+ render(
69
+ <ModelPicker
70
+ agentId="catalog.merchandising_assistant"
71
+ value={null}
72
+ onChange={jest.fn()}
73
+ availableProviders={providers}
74
+ defaultLabel="OpenAI / GPT-5 Mini"
75
+ />,
76
+ )
77
+
78
+ expect(screen.getByRole('button', { name: 'Select AI model' })).toHaveTextContent(
79
+ 'Default: OpenAI / GPT-5 Mini',
80
+ )
81
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
82
+ expect(screen.getByText('Use agent default: OpenAI / GPT-5 Mini')).toBeInTheDocument()
83
+ })
84
+
85
+ it('does not render when availableProviders is empty', () => {
86
+ const { container } = render(
87
+ <ModelPicker
88
+ agentId="catalog.merchandising_assistant"
89
+ value={null}
90
+ onChange={jest.fn()}
91
+ availableProviders={[]}
92
+ />,
93
+ )
94
+ expect(container.firstChild).toBeNull()
95
+ })
96
+
97
+ it('opens the dropdown on trigger click', () => {
98
+ render(<ControlledPicker />)
99
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
100
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
101
+ })
102
+
103
+ it('closes the dropdown on second trigger click', () => {
104
+ render(<ControlledPicker />)
105
+ const trigger = screen.getByRole('button', { name: 'Select AI model' })
106
+ fireEvent.click(trigger)
107
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
108
+ fireEvent.click(trigger)
109
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
110
+ })
111
+
112
+ it('closes the dropdown when Escape is pressed', () => {
113
+ render(<ControlledPicker />)
114
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
115
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
116
+ fireEvent.keyDown(document, { key: 'Escape' })
117
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
118
+ })
119
+
120
+ it('closes the dropdown when clicking outside', () => {
121
+ render(
122
+ <div>
123
+ <ControlledPicker />
124
+ <button type="button">Outside</button>
125
+ </div>,
126
+ )
127
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
128
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
129
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Outside' }))
130
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
131
+ })
132
+
133
+ it('lists all providers and their models', () => {
134
+ render(<ControlledPicker />)
135
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
136
+
137
+ expect(screen.getByText('Anthropic')).toBeInTheDocument()
138
+ expect(screen.getByText('Claude Haiku 4.5')).toBeInTheDocument()
139
+ expect(screen.getByText('Claude Sonnet 4.5')).toBeInTheDocument()
140
+ expect(screen.getByText('OpenAI')).toBeInTheDocument()
141
+ expect(screen.getByText('GPT-4o')).toBeInTheDocument()
142
+ })
143
+
144
+ it('calls onChange with the selected model and closes the dropdown', () => {
145
+ const onChangeSpy = jest.fn()
146
+ render(<ControlledPicker onChangeSpy={onChangeSpy} />)
147
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
148
+
149
+ fireEvent.click(screen.getByText('GPT-4o'))
150
+
151
+ expect(onChangeSpy).toHaveBeenCalledWith({ providerId: 'openai', modelId: 'gpt-4o' })
152
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
153
+ })
154
+
155
+ it('calls onChange with null when "Use agent default" is clicked', () => {
156
+ const onChangeSpy = jest.fn()
157
+ const initial: ModelPickerValue = { providerId: 'openai', modelId: 'gpt-4o' }
158
+ render(<ControlledPicker initial={initial} onChangeSpy={onChangeSpy} />)
159
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
160
+
161
+ fireEvent.click(screen.getByText('Use agent default'))
162
+
163
+ expect(onChangeSpy).toHaveBeenCalledWith(null)
164
+ })
165
+
166
+ it('selects a model via Enter key', () => {
167
+ const onChangeSpy = jest.fn()
168
+ render(<ControlledPicker onChangeSpy={onChangeSpy} />)
169
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
170
+
171
+ const gpt4oOption = screen.getByText('GPT-4o').closest('[role="option"]') as HTMLElement
172
+ fireEvent.keyDown(gpt4oOption, { key: 'Enter' })
173
+
174
+ expect(onChangeSpy).toHaveBeenCalledWith({ providerId: 'openai', modelId: 'gpt-4o' })
175
+ })
176
+
177
+ it('selects a model via Space key', () => {
178
+ const onChangeSpy = jest.fn()
179
+ render(<ControlledPicker onChangeSpy={onChangeSpy} />)
180
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
181
+
182
+ const claudeHaikuOption = screen.getByText('Claude Haiku 4.5').closest(
183
+ '[role="option"]',
184
+ ) as HTMLElement
185
+ fireEvent.keyDown(claudeHaikuOption, { key: ' ' })
186
+
187
+ expect(onChangeSpy).toHaveBeenCalledWith({
188
+ providerId: 'anthropic',
189
+ modelId: 'claude-haiku-4-5',
190
+ })
191
+ })
192
+
193
+ it('marks the currently selected model with aria-selected', () => {
194
+ const initial: ModelPickerValue = { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' }
195
+ render(<ControlledPicker initial={initial} />)
196
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
197
+
198
+ const selectedOption = screen
199
+ .getByText('Claude Sonnet 4.5')
200
+ .closest('[role="option"]') as HTMLElement
201
+ expect(selectedOption).toHaveAttribute('aria-selected', 'true')
202
+
203
+ const unselectedOption = screen
204
+ .getByText('Claude Haiku 4.5')
205
+ .closest('[role="option"]') as HTMLElement
206
+ expect(unselectedOption).toHaveAttribute('aria-selected', 'false')
207
+ })
208
+
209
+ it('marks the "Use default" option as selected when value is null', () => {
210
+ render(<ControlledPicker initial={null} />)
211
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
212
+
213
+ const defaultOption = screen
214
+ .getByText('Use agent default')
215
+ .closest('[role="option"]') as HTMLElement
216
+ expect(defaultOption).toHaveAttribute('aria-selected', 'true')
217
+ })
218
+
219
+ it('does not open when disabled', () => {
220
+ render(
221
+ <ModelPicker
222
+ agentId="test.agent"
223
+ value={null}
224
+ onChange={jest.fn()}
225
+ availableProviders={providers}
226
+ disabled
227
+ />,
228
+ )
229
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
230
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
231
+ })
232
+
233
+ it('attaches data-ai-model-picker attribute with the agentId', () => {
234
+ const { container } = render(<ControlledPicker />)
235
+ const root = container.querySelector(
236
+ '[data-ai-model-picker="catalog.merchandising_assistant"]',
237
+ )
238
+ expect(root).toBeInTheDocument()
239
+ })
240
+
241
+ it('attaches data-ai-model-picker-model attributes', () => {
242
+ render(<ControlledPicker />)
243
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
244
+
245
+ expect(
246
+ document.querySelector('[data-ai-model-picker-model="anthropic:claude-haiku-4-5"]'),
247
+ ).toBeInTheDocument()
248
+ expect(
249
+ document.querySelector('[data-ai-model-picker-model="openai:gpt-4o"]'),
250
+ ).toBeInTheDocument()
251
+ })
252
+ })
package/src/ai/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { AiChat, type AiChatProps, type AiChatDebugTool, type AiChatDebugPromptSection } from './AiChat'
2
2
  export {
3
3
  AiAssistantLauncher,
4
+ AI_ASSISTANT_LAUNCHER_OPEN_EVENT,
4
5
  type AiAssistantLauncherAgent,
5
6
  type AiAssistantLauncherProps,
6
7
  } from './AiAssistantLauncher'
@@ -70,6 +70,18 @@ export interface UseAiChatInput {
70
70
  * props at any time to reset the conversation.
71
71
  */
72
72
  conversationId?: string
73
+ /**
74
+ * Runtime provider override (4b.2). Forwarded as `?provider=` query param
75
+ * on every POST to the dispatcher. Undefined/null means use the agent's
76
+ * configured default (no override sent).
77
+ */
78
+ providerOverride?: string | null
79
+ /**
80
+ * Runtime model override (4b.2). Forwarded as `?model=` query param
81
+ * on every POST to the dispatcher. Undefined/null means use the agent's
82
+ * configured default (no override sent).
83
+ */
84
+ modelOverride?: string | null
73
85
  }
74
86
 
75
87
  export interface AiChatErrorEnvelope {
@@ -238,7 +250,12 @@ function clearPersistedSession(agent: string, conversationId?: string | null): v
238
250
  }
239
251
  }
240
252
 
241
- function getTransportEndpoint(agent: string, apiPath?: string): string {
253
+ function getTransportEndpoint(
254
+ agent: string,
255
+ apiPath?: string,
256
+ providerOverride?: string | null,
257
+ modelOverride?: string | null,
258
+ ): string {
242
259
  // Reuse the transport factory so UI consumers share the dispatcher URL
243
260
  // convention with server-side callers (e.g. runAiAgentText / Playwright
244
261
  // fixtures). The factory returns a ChatTransport<UI_MESSAGE> whose internal
@@ -252,7 +269,14 @@ function getTransportEndpoint(agent: string, apiPath?: string): string {
252
269
  void transport
253
270
  const base = apiPath && apiPath.length > 0 ? apiPath : '/api/ai_assistant/ai/chat'
254
271
  const separator = base.includes('?') ? '&' : '?'
255
- return `${base}${separator}agent=${encodeURIComponent(agent)}`
272
+ let url = `${base}${separator}agent=${encodeURIComponent(agent)}`
273
+ if (providerOverride) {
274
+ url += `&provider=${encodeURIComponent(providerOverride)}`
275
+ }
276
+ if (modelOverride) {
277
+ url += `&model=${encodeURIComponent(modelOverride)}`
278
+ }
279
+ return url
256
280
  }
257
281
 
258
282
  interface AssistantBuilderState {
@@ -549,7 +573,7 @@ async function readErrorEnvelope(response: Response): Promise<AiChatErrorEnvelop
549
573
  }
550
574
 
551
575
  export function useAiChat(input: UseAiChatInput): UseAiChatResult {
552
- const { agent, apiPath, pageContext, attachmentIds, debug, initialMessages, onError, conversationId: conversationIdInput } = input
576
+ const { agent, apiPath, pageContext, attachmentIds, debug, initialMessages, onError, conversationId: conversationIdInput, providerOverride, modelOverride } = input
553
577
 
554
578
  // Minted once on mount when the caller does not supply a conversationId.
555
579
  // The ref keeps the id stable across re-renders and is reused for every
@@ -763,7 +787,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
763
787
  const controller = new AbortController()
764
788
  abortRef.current = controller
765
789
 
766
- const url = getTransportEndpoint(agent, apiPath)
790
+ const url = getTransportEndpoint(agent, apiPath, providerOverride, modelOverride)
767
791
  const body = {
768
792
  messages: outgoingHistory.map((message) => ({
769
793
  role: message.role,
@@ -966,18 +990,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
966
990
  setStatus('idle')
967
991
  }
968
992
  },
969
- [
970
- agent,
971
- apiPath,
972
- attachmentIds,
973
- debug,
974
- effectiveConversationId,
975
- emitError,
976
- pageContext,
977
- sessionStorageKey,
978
- setStatus,
979
- updateMessages,
980
- ],
993
+ [agent, apiPath, attachmentIds, debug, effectiveConversationId, emitError, messages, modelOverride, pageContext, providerOverride],
981
994
  )
982
995
 
983
996
  React.useEffect(() => {