@open-mercato/ui 0.6.1-develop.3167.1.7aedd7b2ba → 0.6.1-develop.3179.1.9ee0441adc
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.
|
@@ -107,6 +107,7 @@ function createErrorResponse(status: number, payload: Record<string, unknown>):
|
|
|
107
107
|
describe('<AiChat>', () => {
|
|
108
108
|
beforeEach(() => {
|
|
109
109
|
jest.clearAllMocks()
|
|
110
|
+
window.localStorage.clear()
|
|
110
111
|
})
|
|
111
112
|
|
|
112
113
|
it('renders the composer with the i18n placeholder and region labels', () => {
|
|
@@ -153,6 +154,32 @@ describe('<AiChat>', () => {
|
|
|
153
154
|
expect(screen.getByText('Hi there')).toBeInTheDocument()
|
|
154
155
|
})
|
|
155
156
|
|
|
157
|
+
it('submits suggested prompts visibly after React StrictMode mount replay', async () => {
|
|
158
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
159
|
+
fetchMock.mockResolvedValueOnce(createStreamingResponse(['Suggested answer']))
|
|
160
|
+
|
|
161
|
+
renderWithProviders(
|
|
162
|
+
<React.StrictMode>
|
|
163
|
+
<AiChat
|
|
164
|
+
agent="customers.account_assistant"
|
|
165
|
+
suggestions={[{ label: 'Summarize customers', prompt: 'Summarize customers' }]}
|
|
166
|
+
/>
|
|
167
|
+
</React.StrictMode>,
|
|
168
|
+
{ dict },
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
fireEvent.click(screen.getByRole('button', { name: 'Summarize customers' }))
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
175
|
+
})
|
|
176
|
+
expect(screen.getByText('Summarize customers')).toBeInTheDocument()
|
|
177
|
+
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(screen.getByText('Suggested answer')).toBeInTheDocument()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
156
183
|
it('surfaces dispatcher error envelopes via Alert and onError callback', async () => {
|
|
157
184
|
const fetchMock = apiFetch as unknown as jest.Mock
|
|
158
185
|
fetchMock.mockResolvedValueOnce(
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* Regression for issue #1816: when the AI assistant is "Thinking" (mid-stream)
|
|
5
|
+
* and the user closes the dock or switches to another agent, `<AiChat>`
|
|
6
|
+
* unmounts. Without the fix, the partial assistant content was never
|
|
7
|
+
* persisted (the persist effect skipped during streaming) and the abort on
|
|
8
|
+
* unmount discarded it — reopening the assistant showed an empty assistant
|
|
9
|
+
* turn instead of the in-progress reply.
|
|
10
|
+
*
|
|
11
|
+
* After the fix, the unmount cleanup flushes the latest message snapshot —
|
|
12
|
+
* including the partial assistant content — to localStorage so reopening the
|
|
13
|
+
* chat continues from where it left off.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// jsdom polyfills (mirrors AiChat.test.tsx).
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
18
|
+
const nodeUtil = require('node:util') as typeof import('node:util')
|
|
19
|
+
if (typeof globalThis.TextEncoder === 'undefined') {
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
;(globalThis as any).TextEncoder = nodeUtil.TextEncoder
|
|
22
|
+
}
|
|
23
|
+
if (typeof globalThis.TextDecoder === 'undefined') {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
;(globalThis as any).TextDecoder = nodeUtil.TextDecoder as unknown as typeof TextDecoder
|
|
26
|
+
}
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
28
|
+
const nodeStreamWeb = require('node:stream/web') as typeof import('node:stream/web')
|
|
29
|
+
if (typeof (globalThis as unknown as { ReadableStream?: unknown }).ReadableStream === 'undefined') {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
;(globalThis as any).ReadableStream = nodeStreamWeb.ReadableStream
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
import * as React from 'react'
|
|
35
|
+
import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
36
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
37
|
+
|
|
38
|
+
jest.mock('@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport', () => ({
|
|
39
|
+
createAiAgentTransport: jest.fn(() => ({
|
|
40
|
+
sendMessages: jest.fn(),
|
|
41
|
+
reconnectToStream: jest.fn(),
|
|
42
|
+
})),
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
jest.mock('../../backend/utils/api', () => ({
|
|
46
|
+
apiFetch: jest.fn(),
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
import { apiFetch } from '../../backend/utils/api'
|
|
50
|
+
import { AiChat } from '../AiChat'
|
|
51
|
+
|
|
52
|
+
const dict = {
|
|
53
|
+
'ai_assistant.chat.composerLabel': 'Message composer',
|
|
54
|
+
'ai_assistant.chat.composerPlaceholder': 'Message the AI agent...',
|
|
55
|
+
'ai_assistant.chat.regionLabel': 'AI chat',
|
|
56
|
+
'ai_assistant.chat.send': 'Send message',
|
|
57
|
+
'ai_assistant.chat.thinking': 'Thinking...',
|
|
58
|
+
'ai_assistant.chat.transcriptLabel': 'Chat transcript',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ResponseLike = {
|
|
62
|
+
ok: boolean
|
|
63
|
+
status: number
|
|
64
|
+
body: ReadableStream<Uint8Array> | null
|
|
65
|
+
headers?: { get: (name: string) => string | null }
|
|
66
|
+
clone: () => ResponseLike
|
|
67
|
+
json: () => Promise<unknown>
|
|
68
|
+
text: () => Promise<string>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface PendingResponse {
|
|
72
|
+
response: ResponseLike
|
|
73
|
+
emit: (chunk: string) => Promise<void>
|
|
74
|
+
close: () => Promise<void>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createPendingStreamingResponse(): PendingResponse {
|
|
78
|
+
const encoder = new TextEncoder()
|
|
79
|
+
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
80
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
81
|
+
start(controller) {
|
|
82
|
+
controllerRef = controller
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
const headers = {
|
|
86
|
+
get: (name: string) => {
|
|
87
|
+
if (name.toLowerCase() === 'content-type') return 'text/event-stream'
|
|
88
|
+
return null
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
const self: ResponseLike = {
|
|
92
|
+
ok: true,
|
|
93
|
+
status: 200,
|
|
94
|
+
body: stream,
|
|
95
|
+
headers,
|
|
96
|
+
clone: () => ({ ...self, body: null }),
|
|
97
|
+
json: async () => ({}),
|
|
98
|
+
text: async () => '',
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
response: self,
|
|
102
|
+
emit: async (chunk: string) => {
|
|
103
|
+
controllerRef?.enqueue(encoder.encode(chunk))
|
|
104
|
+
// Yield to the microtask queue so the consumer's `reader.read()` resolves
|
|
105
|
+
// before the test inspects state.
|
|
106
|
+
await Promise.resolve()
|
|
107
|
+
await Promise.resolve()
|
|
108
|
+
},
|
|
109
|
+
close: async () => {
|
|
110
|
+
controllerRef?.close()
|
|
111
|
+
await Promise.resolve()
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readPersistedAssistantText(agent: string, conversationId: string): string | null {
|
|
117
|
+
const raw = window.localStorage.getItem(`om-ai-chat:${agent}:${conversationId}`)
|
|
118
|
+
if (!raw) return null
|
|
119
|
+
const parsed = JSON.parse(raw) as { messages?: Array<{ role: string; content: string }> }
|
|
120
|
+
if (!parsed?.messages) return null
|
|
121
|
+
const assistant = [...parsed.messages].reverse().find((entry) => entry?.role === 'assistant')
|
|
122
|
+
return assistant?.content ?? null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SSE_TEXT_DELTA = (delta: string) =>
|
|
126
|
+
`data: ${JSON.stringify({ type: 'text-delta', delta })}\n\n`
|
|
127
|
+
|
|
128
|
+
describe('<AiChat> unmount-mid-stream persistence (issue #1816)', () => {
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
jest.clearAllMocks()
|
|
131
|
+
window.localStorage.clear()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
cleanup()
|
|
136
|
+
window.localStorage.clear()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('persists the partial assistant reply when the chat unmounts mid-stream', async () => {
|
|
140
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
141
|
+
const pending = createPendingStreamingResponse()
|
|
142
|
+
fetchMock.mockResolvedValueOnce(pending.response)
|
|
143
|
+
|
|
144
|
+
const conversationId = 'conv-thinking-unmount-1'
|
|
145
|
+
const { unmount } = renderWithProviders(
|
|
146
|
+
<AiChat agent="customers.account_assistant" conversationId={conversationId} />,
|
|
147
|
+
{ dict },
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
|
|
151
|
+
fireEvent.change(textarea, { target: { value: 'hello' } })
|
|
152
|
+
await act(async () => {
|
|
153
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
154
|
+
})
|
|
155
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
|
156
|
+
|
|
157
|
+
// Stream a partial assistant reply but DO NOT close the stream — the
|
|
158
|
+
// assistant is still "Thinking" from the user's perspective.
|
|
159
|
+
await act(async () => {
|
|
160
|
+
await pending.emit(SSE_TEXT_DELTA('Working on it'))
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// The user closes the assistant / switches agent — `<AiChat>` unmounts
|
|
164
|
+
// while the request is still in flight.
|
|
165
|
+
await act(async () => {
|
|
166
|
+
unmount()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// The partial assistant content must survive in localStorage so the
|
|
170
|
+
// next mount can show it.
|
|
171
|
+
const persisted = readPersistedAssistantText('customers.account_assistant', conversationId)
|
|
172
|
+
expect(persisted).toBe('Working on it')
|
|
173
|
+
await act(async () => {
|
|
174
|
+
await pending.close()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('rehydrates the partial assistant reply when the chat is reopened with the same conversationId', async () => {
|
|
179
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
180
|
+
const pending = createPendingStreamingResponse()
|
|
181
|
+
fetchMock.mockResolvedValueOnce(pending.response)
|
|
182
|
+
|
|
183
|
+
const conversationId = 'conv-thinking-unmount-2'
|
|
184
|
+
const { unmount: unmountA } = renderWithProviders(
|
|
185
|
+
<AiChat agent="customers.account_assistant" conversationId={conversationId} />,
|
|
186
|
+
{ dict },
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
|
|
190
|
+
fireEvent.change(textarea, { target: { value: 'how are you' } })
|
|
191
|
+
await act(async () => {
|
|
192
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
193
|
+
})
|
|
194
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
|
195
|
+
|
|
196
|
+
await act(async () => {
|
|
197
|
+
await pending.emit(SSE_TEXT_DELTA('Almost there'))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
await act(async () => {
|
|
201
|
+
unmountA()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Reopen the same chat — the persisted partial reply should show up
|
|
205
|
+
// in the transcript instead of an empty assistant message.
|
|
206
|
+
const { unmount: unmountB } = renderWithProviders(
|
|
207
|
+
<AiChat agent="customers.account_assistant" conversationId={conversationId} />,
|
|
208
|
+
{ dict },
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
const transcript = screen.getByRole('log', { name: 'Chat transcript' })
|
|
212
|
+
expect(transcript.textContent ?? '').toContain('Almost there')
|
|
213
|
+
unmountB()
|
|
214
|
+
await act(async () => {
|
|
215
|
+
await pending.close()
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('persists the final assistant reply after unmounting before the first token arrives', async () => {
|
|
220
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
221
|
+
const pending = createPendingStreamingResponse()
|
|
222
|
+
fetchMock.mockResolvedValueOnce(pending.response)
|
|
223
|
+
|
|
224
|
+
const conversationId = 'conv-thinking-unmount-3'
|
|
225
|
+
const { unmount } = renderWithProviders(
|
|
226
|
+
<AiChat agent="customers.account_assistant" conversationId={conversationId} />,
|
|
227
|
+
{ dict },
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
|
|
231
|
+
fireEvent.change(textarea, { target: { value: 'answer later' } })
|
|
232
|
+
await act(async () => {
|
|
233
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
234
|
+
})
|
|
235
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
|
236
|
+
|
|
237
|
+
await act(async () => {
|
|
238
|
+
unmount()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
await act(async () => {
|
|
242
|
+
await pending.emit(SSE_TEXT_DELTA('Finished after close'))
|
|
243
|
+
await pending.close()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
const persisted = readPersistedAssistantText('customers.account_assistant', conversationId)
|
|
248
|
+
expect(persisted).toBe('Finished after close')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('updates a reopened chat when the background response completes', async () => {
|
|
253
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
254
|
+
const pending = createPendingStreamingResponse()
|
|
255
|
+
fetchMock.mockResolvedValueOnce(pending.response)
|
|
256
|
+
|
|
257
|
+
const conversationId = 'conv-thinking-unmount-4'
|
|
258
|
+
const { unmount: unmountA } = renderWithProviders(
|
|
259
|
+
<AiChat agent="customers.account_assistant" conversationId={conversationId} />,
|
|
260
|
+
{ dict },
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
|
|
264
|
+
fireEvent.change(textarea, { target: { value: 'keep working' } })
|
|
265
|
+
await act(async () => {
|
|
266
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
267
|
+
})
|
|
268
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
|
269
|
+
|
|
270
|
+
await act(async () => {
|
|
271
|
+
unmountA()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const { unmount: unmountB } = renderWithProviders(
|
|
275
|
+
<AiChat agent="customers.account_assistant" conversationId={conversationId} />,
|
|
276
|
+
{ dict },
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
expect(screen.getByText('Thinking...')).toBeInTheDocument()
|
|
280
|
+
|
|
281
|
+
await act(async () => {
|
|
282
|
+
await pending.emit(SSE_TEXT_DELTA('Background result'))
|
|
283
|
+
await pending.close()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
const transcript = screen.getByRole('log', { name: 'Chat transcript' })
|
|
288
|
+
expect(transcript.textContent ?? '').toContain('Background result')
|
|
289
|
+
})
|
|
290
|
+
await waitFor(() => {
|
|
291
|
+
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument()
|
|
292
|
+
})
|
|
293
|
+
unmountB()
|
|
294
|
+
})
|
|
295
|
+
})
|