@open-mercato/ui 0.6.4-develop.3949.1.adc3d0b3b1 → 0.6.4-develop.3962.1.70f30e284c

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.4-develop.3949.1.adc3d0b3b1",
3
+ "version": "0.6.4-develop.3962.1.70f30e284c",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -141,13 +141,13 @@
141
141
  "remark-gfm": "^4.0.1"
142
142
  },
143
143
  "peerDependencies": {
144
- "@open-mercato/shared": "0.6.4-develop.3949.1.adc3d0b3b1",
144
+ "@open-mercato/shared": "0.6.4-develop.3962.1.70f30e284c",
145
145
  "react": ">=18.0.0",
146
146
  "react-dom": ">=18.0.0",
147
147
  "react-is": ">=18.0.0"
148
148
  },
149
149
  "devDependencies": {
150
- "@open-mercato/shared": "0.6.4-develop.3949.1.adc3d0b3b1",
150
+ "@open-mercato/shared": "0.6.4-develop.3962.1.70f30e284c",
151
151
  "@testing-library/dom": "^10.4.1",
152
152
  "@testing-library/jest-dom": "^6.9.1",
153
153
  "@testing-library/react": "^16.3.1",
package/src/ai/AiChat.tsx CHANGED
@@ -309,6 +309,14 @@ export interface AiChatProps {
309
309
  headerActions?: React.ReactNode
310
310
  /** Called once on mount (and on reset) with the effective conversation id. */
311
311
  onConversationIdChange?: (conversationId: string) => void
312
+ /**
313
+ * Called when the server returns 404 for the active `conversationId`,
314
+ * e.g. when the id was carried over from a previous tenant/org scope
315
+ * and no longer exists. The host (typically the AI dock) should remove
316
+ * the corresponding session entry so the stale tab disappears instead
317
+ * of staying empty.
318
+ */
319
+ onConversationNotFound?: () => void
312
320
  }
313
321
 
314
322
  interface ServerEmittedUiPartRef {
@@ -1147,6 +1155,7 @@ export function AiChat({
1147
1155
  defaultCompactFooter = false,
1148
1156
  headerActions,
1149
1157
  onConversationIdChange,
1158
+ onConversationNotFound,
1150
1159
  }: AiChatProps) {
1151
1160
  const t = useT()
1152
1161
  const onConversationIdChangeRef = React.useRef(onConversationIdChange)
@@ -1277,6 +1286,7 @@ export function AiChat({
1277
1286
  conversationId,
1278
1287
  providerOverride: effectiveModelPickerValue?.providerId ?? null,
1279
1288
  modelOverride: effectiveModelPickerValue?.modelId ?? null,
1289
+ onConversationNotFound,
1280
1290
  })
1281
1291
 
1282
1292
  const isStreaming = chat.status === 'streaming'
@@ -20,6 +20,10 @@
20
20
  */
21
21
 
22
22
  import * as React from 'react'
23
+ import {
24
+ getCurrentOrganizationScope,
25
+ subscribeOrganizationScopeChanged,
26
+ } from '@open-mercato/shared/lib/frontend/organizationEvents'
23
27
  import {
24
28
  createAiServerConversation,
25
29
  listAiServerConversations,
@@ -27,9 +31,27 @@ import {
27
31
  type AiServerConversation,
28
32
  } from './conversation-store'
29
33
 
30
- const STORAGE_KEY = 'om-ai-chat-sessions-v1'
34
+ /**
35
+ * Legacy app-global storage key used before tenant/org scoping.
36
+ *
37
+ * Kept only as a documented constant for grep / debugging. No code path
38
+ * reads or writes it any more — the legacy entry is intentionally
39
+ * abandoned (not migrated) because the origin scope of any data stored
40
+ * under this key is unknown; silently importing it into the wrong scope
41
+ * is worse than empty state. `listAiServerConversations` repopulates
42
+ * sessions from the authoritative server source on first load.
43
+ */
44
+ export const LEGACY_STORAGE_KEY = 'om-ai-chat-sessions-v1'
45
+ const STORAGE_KEY_PREFIX = 'om-ai-chat-sessions-v1'
31
46
  const HISTORY_LIMIT = 50
32
47
 
48
+ function getScopedStorageKey(
49
+ tenantId: string | null | undefined,
50
+ organizationId: string | null | undefined,
51
+ ): string {
52
+ return `${STORAGE_KEY_PREFIX}:${tenantId ?? 'no-tenant'}:${organizationId ?? 'no-org'}`
53
+ }
54
+
33
55
  export type AiChatSessionStatus = 'open' | 'closed'
34
56
 
35
57
  export interface AiChatSession {
@@ -86,10 +108,10 @@ function makeId(): string {
86
108
  return `${Date.now().toString(16)}-${rand()}-${rand()}`
87
109
  }
88
110
 
89
- function readPersisted(): AiChatSessionsState {
111
+ function readPersisted(storageKey: string): AiChatSessionsState {
90
112
  if (typeof window === 'undefined') return { sessions: [], activeByAgent: {} }
91
113
  try {
92
- const raw = window.localStorage.getItem(STORAGE_KEY)
114
+ const raw = window.localStorage.getItem(storageKey)
93
115
  if (!raw) return { sessions: [], activeByAgent: {} }
94
116
  const parsed = JSON.parse(raw) as Partial<AiChatSessionsState> | null
95
117
  const sessions = Array.isArray(parsed?.sessions)
@@ -130,10 +152,10 @@ function readPersisted(): AiChatSessionsState {
130
152
  }
131
153
  }
132
154
 
133
- function writePersisted(state: AiChatSessionsState): void {
155
+ function writePersisted(storageKey: string, state: AiChatSessionsState): void {
134
156
  if (typeof window === 'undefined') return
135
157
  try {
136
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
158
+ window.localStorage.setItem(storageKey, JSON.stringify(state))
137
159
  } catch {
138
160
  /* quota / privacy mode — drop silently */
139
161
  }
@@ -194,11 +216,34 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
194
216
  // server (no `window`), so SSR stays consistent with the bare-bones
195
217
  // shell — actual session-dependent UI only renders after a user
196
218
  // interaction opens a chat surface.
197
- const [state, setState] = React.useState<AiChatSessionsState>(() => readPersisted())
219
+ //
220
+ // The storage key is scoped to the current tenant/organization so a
221
+ // scope switch in the topbar does not surface another tenant's
222
+ // sessions. `getCurrentOrganizationScope()` reads module-level state
223
+ // populated by the topbar before the provider mounts; a `null` scope
224
+ // (pre-resolution) lands in a harmless `no-tenant:no-org` bucket that
225
+ // the scope-change subscription below immediately corrects when the
226
+ // real scope arrives.
227
+ const [storageKey, setStorageKey] = React.useState<string>(() => {
228
+ const scope = getCurrentOrganizationScope()
229
+ return getScopedStorageKey(scope.tenantId, scope.organizationId)
230
+ })
231
+ const [state, setState] = React.useState<AiChatSessionsState>(() => readPersisted(storageKey))
232
+
233
+ React.useEffect(() => {
234
+ return subscribeOrganizationScopeChanged((detail) => {
235
+ const nextKey = getScopedStorageKey(detail.tenantId, detail.organizationId)
236
+ if (nextKey === storageKey) return
237
+ // React 18 batches both updates within the synchronous handler so
238
+ // there is no intermediate render with a mismatched key/state pair.
239
+ setStorageKey(nextKey)
240
+ setState(readPersisted(nextKey))
241
+ })
242
+ }, [storageKey])
198
243
 
199
244
  React.useEffect(() => {
200
- writePersisted(state)
201
- }, [state])
245
+ writePersisted(storageKey, state)
246
+ }, [storageKey, state])
202
247
 
203
248
  React.useEffect(() => {
204
249
  let cancelled = false
@@ -209,7 +254,7 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
209
254
  return () => {
210
255
  cancelled = true
211
256
  }
212
- }, [])
257
+ }, [storageKey])
213
258
 
214
259
  const update = React.useCallback(
215
260
  (mutator: (prev: AiChatSessionsState) => AiChatSessionsState) => {
package/src/ai/AiDock.tsx CHANGED
@@ -476,6 +476,16 @@ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
476
476
  if (!session) sessions.ensureSession(assistant.agent)
477
477
  }, [assistant.agent, session, sessions])
478
478
 
479
+ const activeSessionId = session?.id ?? null
480
+ const handleConversationNotFound = React.useCallback(() => {
481
+ // The server returned 404 for this session's conversationId. The
482
+ // conversation does not exist in the current tenant/org scope (most
483
+ // commonly the user just switched scope and a stale id rode along in
484
+ // localStorage). Close the tab so the dock surfaces the empty state
485
+ // for the new scope rather than a perpetual blank chat.
486
+ if (activeSessionId) sessions.closeSession(activeSessionId)
487
+ }, [activeSessionId, sessions])
488
+
479
489
  return (
480
490
  <>
481
491
  <ChatPaneTabs agentId={assistant.agent} className="border-b" />
@@ -499,6 +509,7 @@ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
499
509
  welcomeTitle={assistant.welcomeTitle}
500
510
  welcomeDescription={assistant.welcomeDescription}
501
511
  headerActions={<ConversationShareButton conversationId={session.conversationId} />}
512
+ onConversationNotFound={handleConversationNotFound}
502
513
  />
503
514
  </React.Suspense>
504
515
  ) : null}
@@ -158,6 +158,103 @@ describe('<AiChat> conversationId threading (Step 5.15)', () => {
158
158
  expect(parsedBody.conversationId).toBe('conv-body-xyz')
159
159
  })
160
160
 
161
+ it('invokes onConversationNotFound and does not import local messages when server returns 404', async () => {
162
+ const originalFetch = globalThis.fetch
163
+ const fetchMock = jest.fn((input: string | URL | Request) => {
164
+ const url = typeof input === 'string' ? input : input.toString()
165
+ if (url.includes('/api/ai_assistant/ai/conversations/conv-stale')) {
166
+ return Promise.resolve({
167
+ ok: false,
168
+ status: 404,
169
+ json: async () => ({ error: 'not found' }),
170
+ } as unknown as Response)
171
+ }
172
+ // Any /import call would be a regression we must catch:
173
+ if (url.includes('/import')) {
174
+ return Promise.resolve({
175
+ ok: true,
176
+ status: 200,
177
+ json: async () => ({}),
178
+ } as unknown as Response)
179
+ }
180
+ return Promise.resolve({
181
+ ok: true,
182
+ status: 200,
183
+ json: async () => ({}),
184
+ } as unknown as Response)
185
+ }) as unknown as typeof fetch
186
+ globalThis.fetch = fetchMock
187
+
188
+ const onConversationNotFound = jest.fn()
189
+
190
+ try {
191
+ renderWithProviders(
192
+ <AiChat
193
+ agent="customers.account_assistant"
194
+ conversationId="conv-stale"
195
+ onConversationNotFound={onConversationNotFound}
196
+ />,
197
+ { dict },
198
+ )
199
+
200
+ // hydrate effect is async — wait one tick for the 404 to land
201
+ await act(async () => {
202
+ await new Promise((resolve) => setTimeout(resolve, 0))
203
+ })
204
+
205
+ expect(onConversationNotFound).toHaveBeenCalledTimes(1)
206
+ // No /import call must happen on 404 — cross-tenant data write guard.
207
+ const importCalls = fetchMock.mock.calls.filter(([url]) => {
208
+ const u = typeof url === 'string' ? url : (url as { toString(): string }).toString()
209
+ return u.includes('/conversations/import')
210
+ })
211
+ expect(importCalls).toHaveLength(0)
212
+ } finally {
213
+ globalThis.fetch = originalFetch
214
+ }
215
+ })
216
+
217
+ it('does NOT invoke onConversationNotFound on transient transport failure (503)', async () => {
218
+ const originalFetch = globalThis.fetch
219
+ const fetchMock = jest.fn((input: string | URL | Request) => {
220
+ const url = typeof input === 'string' ? input : input.toString()
221
+ if (url.includes('/api/ai_assistant/ai/conversations/conv-503')) {
222
+ return Promise.resolve({
223
+ ok: false,
224
+ status: 503,
225
+ json: async () => ({ error: 'upstream' }),
226
+ } as unknown as Response)
227
+ }
228
+ return Promise.resolve({
229
+ ok: true,
230
+ status: 200,
231
+ json: async () => ({}),
232
+ } as unknown as Response)
233
+ }) as unknown as typeof fetch
234
+ globalThis.fetch = fetchMock
235
+
236
+ const onConversationNotFound = jest.fn()
237
+
238
+ try {
239
+ renderWithProviders(
240
+ <AiChat
241
+ agent="customers.account_assistant"
242
+ conversationId="conv-503"
243
+ onConversationNotFound={onConversationNotFound}
244
+ />,
245
+ { dict },
246
+ )
247
+
248
+ await act(async () => {
249
+ await new Promise((resolve) => setTimeout(resolve, 0))
250
+ })
251
+
252
+ expect(onConversationNotFound).not.toHaveBeenCalled()
253
+ } finally {
254
+ globalThis.fetch = originalFetch
255
+ }
256
+ })
257
+
161
258
  it('hydrates the transcript from server storage for an existing conversation', async () => {
162
259
  const originalFetch = globalThis.fetch
163
260
  const fetchMock = jest.fn(async () => ({
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, screen, waitFor } from '@testing-library/react'
7
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
+ import {
9
+ emitOrganizationScopeChanged,
10
+ } from '@open-mercato/shared/lib/frontend/organizationEvents'
11
+ import {
12
+ AiChatSessionsProvider,
13
+ LEGACY_STORAGE_KEY,
14
+ useAiChatSessions,
15
+ } from '../AiChatSessions'
16
+
17
+ jest.mock('../conversation-store', () => {
18
+ const actual = jest.requireActual('../conversation-store') as Record<string, unknown>
19
+ return {
20
+ ...actual,
21
+ listAiServerConversations: jest.fn(async () => null),
22
+ createAiServerConversation: jest.fn(async () => null),
23
+ updateAiServerConversation: jest.fn(async () => null),
24
+ }
25
+ })
26
+
27
+ import {
28
+ listAiServerConversations,
29
+ createAiServerConversation,
30
+ } from '../conversation-store'
31
+
32
+ const listMock = listAiServerConversations as jest.MockedFunction<typeof listAiServerConversations>
33
+ const createMock = createAiServerConversation as jest.MockedFunction<typeof createAiServerConversation>
34
+
35
+ const KEY_PREFIX = 'om-ai-chat-sessions-v1'
36
+
37
+ function scopedKey(tenantId: string | null, organizationId: string | null): string {
38
+ return `${KEY_PREFIX}:${tenantId ?? 'no-tenant'}:${organizationId ?? 'no-org'}`
39
+ }
40
+
41
+ function CreateSessionButton({ agentId }: { agentId: string }) {
42
+ const sessions = useAiChatSessions()
43
+ return (
44
+ <button type="button" onClick={() => sessions.createSession(agentId)}>
45
+ Create session
46
+ </button>
47
+ )
48
+ }
49
+
50
+ function SessionCount({ agentId }: { agentId: string }) {
51
+ const sessions = useAiChatSessions()
52
+ const open = sessions.getOpenSessions(agentId)
53
+ return <span data-testid="session-count">{open.length}</span>
54
+ }
55
+
56
+ function Harness({ agentId }: { agentId: string }) {
57
+ return (
58
+ <AiChatSessionsProvider>
59
+ <CreateSessionButton agentId={agentId} />
60
+ <SessionCount agentId={agentId} />
61
+ </AiChatSessionsProvider>
62
+ )
63
+ }
64
+
65
+ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
66
+ beforeEach(() => {
67
+ window.localStorage.clear()
68
+ listMock.mockReset()
69
+ listMock.mockResolvedValue(null)
70
+ createMock.mockReset()
71
+ createMock.mockResolvedValue(null)
72
+ // Reset scope to a known starting point. The module-level state in
73
+ // organizationEvents persists across tests in the same file.
74
+ act(() => {
75
+ emitOrganizationScopeChanged({ tenantId: 'T1', organizationId: 'O1' })
76
+ })
77
+ })
78
+
79
+ afterEach(() => {
80
+ window.localStorage.clear()
81
+ })
82
+
83
+ it('does not read or write the legacy unscoped key', async () => {
84
+ window.localStorage.setItem(
85
+ LEGACY_STORAGE_KEY,
86
+ JSON.stringify({
87
+ sessions: [
88
+ {
89
+ id: 'legacy-session',
90
+ agentId: 'assistant',
91
+ conversationId: 'legacy-conv',
92
+ createdAt: 1,
93
+ lastUsedAt: 1,
94
+ status: 'open',
95
+ },
96
+ ],
97
+ activeByAgent: { assistant: 'legacy-session' },
98
+ }),
99
+ )
100
+
101
+ renderWithProviders(<Harness agentId="assistant" />)
102
+
103
+ await waitFor(() => {
104
+ expect(screen.getByTestId('session-count').textContent).toBe('0')
105
+ })
106
+
107
+ expect(window.localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull()
108
+ })
109
+
110
+ it('writes new sessions to the tenant/org scoped key', async () => {
111
+ renderWithProviders(<Harness agentId="assistant" />)
112
+
113
+ act(() => {
114
+ screen.getByRole('button', { name: 'Create session' }).click()
115
+ })
116
+
117
+ await waitFor(() => {
118
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
119
+ })
120
+
121
+ const scoped = window.localStorage.getItem(scopedKey('T1', 'O1'))
122
+ expect(scoped).not.toBeNull()
123
+ const parsed = JSON.parse(scoped!) as { sessions: Array<{ agentId: string }> }
124
+ expect(parsed.sessions).toHaveLength(1)
125
+ expect(parsed.sessions[0].agentId).toBe('assistant')
126
+
127
+ expect(window.localStorage.getItem(scopedKey('T2', 'O2'))).toBeNull()
128
+ })
129
+
130
+ it('rehydrates from the new scope on scope change (no flash of previous-scope data)', async () => {
131
+ // Seed BOTH scopes' buckets with distinct session counts so we can
132
+ // observe the swap.
133
+ window.localStorage.setItem(
134
+ scopedKey('T1', 'O1'),
135
+ JSON.stringify({
136
+ sessions: [
137
+ {
138
+ id: 's1',
139
+ agentId: 'assistant',
140
+ conversationId: 'c1',
141
+ createdAt: 1,
142
+ lastUsedAt: 1,
143
+ status: 'open',
144
+ },
145
+ {
146
+ id: 's2',
147
+ agentId: 'assistant',
148
+ conversationId: 'c2',
149
+ createdAt: 2,
150
+ lastUsedAt: 2,
151
+ status: 'open',
152
+ },
153
+ ],
154
+ activeByAgent: { assistant: 's1' },
155
+ }),
156
+ )
157
+ window.localStorage.setItem(
158
+ scopedKey('T2', 'O2'),
159
+ JSON.stringify({
160
+ sessions: [
161
+ {
162
+ id: 's3',
163
+ agentId: 'assistant',
164
+ conversationId: 'c3',
165
+ createdAt: 3,
166
+ lastUsedAt: 3,
167
+ status: 'open',
168
+ },
169
+ ],
170
+ activeByAgent: { assistant: 's3' },
171
+ }),
172
+ )
173
+
174
+ renderWithProviders(<Harness agentId="assistant" />)
175
+
176
+ await waitFor(() => {
177
+ expect(screen.getByTestId('session-count').textContent).toBe('2')
178
+ })
179
+
180
+ act(() => {
181
+ emitOrganizationScopeChanged({ tenantId: 'T2', organizationId: 'O2' })
182
+ })
183
+
184
+ await waitFor(() => {
185
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
186
+ })
187
+
188
+ // T1/O1 bucket must remain untouched after the swap.
189
+ const t1Raw = window.localStorage.getItem(scopedKey('T1', 'O1'))
190
+ expect(t1Raw).not.toBeNull()
191
+ const t1Parsed = JSON.parse(t1Raw!) as { sessions: unknown[] }
192
+ expect(t1Parsed.sessions).toHaveLength(2)
193
+ })
194
+
195
+ it('refires the server conversation list on scope change', async () => {
196
+ renderWithProviders(<Harness agentId="assistant" />)
197
+
198
+ await waitFor(() => {
199
+ expect(listMock).toHaveBeenCalledTimes(1)
200
+ })
201
+
202
+ act(() => {
203
+ emitOrganizationScopeChanged({ tenantId: 'T2', organizationId: 'O2' })
204
+ })
205
+
206
+ await waitFor(() => {
207
+ expect(listMock).toHaveBeenCalledTimes(2)
208
+ })
209
+ })
210
+
211
+ it('falls back to the no-tenant/no-org bucket when scope is unresolved', async () => {
212
+ act(() => {
213
+ emitOrganizationScopeChanged({ tenantId: null, organizationId: null })
214
+ })
215
+
216
+ renderWithProviders(<Harness agentId="assistant" />)
217
+
218
+ act(() => {
219
+ screen.getByRole('button', { name: 'Create session' }).click()
220
+ })
221
+
222
+ await waitFor(() => {
223
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
224
+ })
225
+
226
+ expect(window.localStorage.getItem(scopedKey(null, null))).not.toBeNull()
227
+ })
228
+ })
@@ -11,11 +11,21 @@ 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, defaultCompactFooter }: { agent: string; defaultCompactFooter?: boolean }) =>
14
+ AiChat: ({
15
+ agent,
16
+ defaultCompactFooter,
17
+ onConversationNotFound,
18
+ }: {
19
+ agent: string
20
+ defaultCompactFooter?: boolean
21
+ onConversationNotFound?: () => void
22
+ }) =>
15
23
  ReactModule.createElement('div', {
16
24
  'data-testid': 'ai-chat',
17
25
  'data-agent': agent,
18
26
  'data-default-compact-footer': defaultCompactFooter ? 'true' : 'false',
27
+ 'data-has-conversation-not-found-handler': typeof onConversationNotFound === 'function' ? 'true' : 'false',
28
+ onClick: () => onConversationNotFound?.(),
19
29
  }),
20
30
  }
21
31
  })
@@ -132,4 +142,24 @@ describe('<AiDockProvider>', () => {
132
142
  expect(screen.getByTestId('ai-chat')).toHaveAttribute('data-default-compact-footer', 'true')
133
143
  })
134
144
  })
145
+
146
+ it('wires onConversationNotFound so a stale session closes (no perpetual blank chat)', async () => {
147
+ renderWithProviders(<Harness />)
148
+
149
+ fireEvent.click(screen.getByRole('button', { name: 'Dock assistant' }))
150
+
151
+ const chat = await waitFor(() => screen.getByTestId('ai-chat'))
152
+ expect(chat).toHaveAttribute('data-has-conversation-not-found-handler', 'true')
153
+
154
+ // Fire the not-found callback via the mocked AiChat's onClick.
155
+ fireEvent.click(chat)
156
+
157
+ // After closeSession, the active session disappears; DockedChatBody
158
+ // schedules ensureSession in an effect, so for one tick the AiChat
159
+ // surface should be torn down before the new session mounts.
160
+ await waitFor(() => {
161
+ const next = screen.queryByTestId('ai-chat')
162
+ if (next === chat) throw new Error('stale chat surface should have unmounted')
163
+ })
164
+ })
135
165
  })
@@ -1,4 +1,4 @@
1
- import { listAiServerConversations } from '../conversation-store'
1
+ import { listAiServerConversations, loadAiServerTranscript } from '../conversation-store'
2
2
 
3
3
  describe('AI conversation store', () => {
4
4
  const originalFetch = global.fetch
@@ -26,4 +26,77 @@ describe('AI conversation store', () => {
26
26
  }),
27
27
  )
28
28
  })
29
+
30
+ describe('loadAiServerTranscript', () => {
31
+ it('returns ok=true with parsed data on 200', async () => {
32
+ const payload = {
33
+ conversation: {
34
+ conversationId: 'conv-1',
35
+ agentId: 'agent-1',
36
+ title: null,
37
+ status: 'open',
38
+ visibility: 'private',
39
+ pageContext: null,
40
+ createdAt: '2026-05-28T00:00:00Z',
41
+ updatedAt: '2026-05-28T00:00:00Z',
42
+ lastMessageAt: null,
43
+ importedFromLocalAt: null,
44
+ isOwner: true,
45
+ },
46
+ messages: [],
47
+ nextCursor: null,
48
+ }
49
+ global.fetch = jest.fn(async () => new Response(JSON.stringify(payload), { status: 200 })) as unknown as typeof fetch
50
+
51
+ const result = await loadAiServerTranscript('conv-1')
52
+
53
+ expect(result).toEqual({ ok: true, data: payload })
54
+ })
55
+
56
+ it('returns ok=false notFound=true on 404 (stale conversationId from previous scope)', async () => {
57
+ global.fetch = jest.fn(async () => new Response('{"error":"not found"}', { status: 404 })) as unknown as typeof fetch
58
+
59
+ const result = await loadAiServerTranscript('conv-from-old-scope')
60
+
61
+ expect(result).toEqual({ ok: false, notFound: true })
62
+ })
63
+
64
+ it('returns ok=false notFound=false on 503 (transient transport failure)', async () => {
65
+ global.fetch = jest.fn(async () => new Response('{"error":"upstream"}', { status: 503 })) as unknown as typeof fetch
66
+
67
+ const result = await loadAiServerTranscript('conv-1')
68
+
69
+ expect(result).toEqual({ ok: false, notFound: false })
70
+ })
71
+
72
+ it('returns ok=false notFound=false on 403 (no scope membership)', async () => {
73
+ global.fetch = jest.fn(async () => new Response('{"error":"forbidden"}', { status: 403 })) as unknown as typeof fetch
74
+
75
+ const result = await loadAiServerTranscript('conv-1')
76
+
77
+ expect(result).toEqual({ ok: false, notFound: false })
78
+ })
79
+
80
+ it('returns ok=false notFound=false when fetch throws', async () => {
81
+ global.fetch = jest.fn(async () => {
82
+ throw new TypeError('Failed to fetch')
83
+ }) as unknown as typeof fetch
84
+
85
+ const result = await loadAiServerTranscript('conv-1')
86
+
87
+ expect(result).toEqual({ ok: false, notFound: false })
88
+ })
89
+
90
+ it('forwards the limit option as a query string', async () => {
91
+ const fetchMock = jest.fn(async () => new Response('{}', { status: 404 }))
92
+ global.fetch = fetchMock as unknown as typeof fetch
93
+
94
+ await loadAiServerTranscript('conv-1', { limit: 25 })
95
+
96
+ expect(fetchMock).toHaveBeenCalledWith(
97
+ '/api/ai_assistant/ai/conversations/conv-1?limit=25',
98
+ expect.any(Object),
99
+ )
100
+ })
101
+ })
29
102
  })