@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
+ })