@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/dist/ai/AiChat.js +4 -2
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiChatSessions.js +30 -9
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/AiDock.js +6 -1
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/conversation-store.js +2 -1
- package/dist/ai/conversation-store.js.map +2 -2
- package/dist/ai/useAiChat.js +15 -4
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +3 -1
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/package.json +3 -3
- package/src/ai/AiChat.tsx +10 -0
- package/src/ai/AiChatSessions.tsx +54 -9
- package/src/ai/AiDock.tsx +11 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +97 -0
- package/src/ai/__tests__/AiChatSessions.test.tsx +228 -0
- package/src/ai/__tests__/AiDock.test.tsx +31 -1
- package/src/ai/__tests__/conversation-store.test.ts +74 -1
- package/src/ai/conversation-store.ts +18 -2
- package/src/ai/useAiChat.ts +35 -4
- package/src/backend/dashboard/DashboardScreen.tsx +3 -1
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.4-develop.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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: ({
|
|
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
|
})
|