Skip to content

Commit d0cee64

Browse files
HaroenvclaudeCopilot
authored
fix(chat): persist conversation id (#7297)
* fix(chat): restore the conversation id along with persisted messages The conversation id is stored in sessionStorage next to the messages, so a restored transcript continues under the same id instead of a new one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Potential fix for pull request finding 'Avoid overwriting persisted conversation ID with explicit messages' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor(chat): keep conversation id storage in ChatState ChatState already owns the sessionStorage keys, reads and writes for the messages, so the conversation id now shares its suffix and storage helpers instead of Chat duplicating them. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Apply batched suggestions from code review Co-authored-by: Haroen Viaene <hello@haroen.me> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 7555a5f commit d0cee64

3 files changed

Lines changed: 142 additions & 23 deletions

File tree

‎packages/instantsearch.js/src/lib/chat/__tests__/chat-server-rendering.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @jest-environment @instantsearch/testutils/jest-environment-node.ts
33
*/
4-
import { Chat, CACHE_KEY } from '../chat';
4+
import { Chat, CACHE_KEY, CONVERSATION_ID_CACHE_KEY } from '../chat';
55

66
function installStorageObserver(storedValue?: string) {
77
const reads: string[] = [];
@@ -98,13 +98,19 @@ describe('Chat server rendering', () => {
9898
const chat = new Chat<any>({ agentId, transport: {} as any });
9999

100100
expect(chat.messages).toHaveLength(1);
101-
expect(storage.reads).toEqual([`${CACHE_KEY}-${agentId}`]);
101+
expect(storage.reads).toEqual([
102+
`${CACHE_KEY}-${agentId}`,
103+
`${CONVERSATION_ID_CACHE_KEY}-${agentId}`,
104+
]);
102105

103106
chat.messages = [
104107
{ id: 'live', role: 'user', parts: [{ type: 'text', text: 'L' }] },
105108
] as any;
106109

107-
expect(storage.writes).toEqual([`${CACHE_KEY}-${agentId}`]);
110+
expect(storage.writes).toEqual([
111+
`${CONVERSATION_ID_CACHE_KEY}-${agentId}`,
112+
`${CACHE_KEY}-${agentId}`,
113+
]);
108114
} finally {
109115
storage.restore();
110116
}

‎packages/instantsearch.js/src/lib/chat/__tests__/chat.test.ts‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
33
*/
4-
import { Chat, ChatState, CACHE_KEY } from '../chat';
4+
import { Chat, ChatState, CACHE_KEY, CONVERSATION_ID_CACHE_KEY } from '../chat';
55

66
describe('ChatState', () => {
77
beforeAll(() => {
@@ -191,4 +191,77 @@ describe('ChatState', () => {
191191

192192
sessionStorage.setItem = originalSetItem;
193193
});
194+
describe('conversation id', () => {
195+
const agentId = 'agentID-conversation';
196+
const key = `${CONVERSATION_ID_CACHE_KEY}-${agentId}`;
197+
198+
it('restores the conversation id along with the messages', () => {
199+
const first = new Chat<any>({ agentId });
200+
const second = new Chat<any>({ agentId });
201+
202+
expect(sessionStorage.getItem(key)).toBe(first.id);
203+
expect(second.id).toBe(first.id);
204+
});
205+
206+
it('stores the id per agent', () => {
207+
const first = new Chat<any>({ agentId });
208+
const other = new Chat<any>({ agentId: 'another-agent' });
209+
210+
expect(other.id).not.toBe(first.id);
211+
});
212+
213+
it('persists the rotated id after resetConversationId', () => {
214+
const chat = new Chat<any>({ agentId });
215+
const previousId = chat.id;
216+
217+
chat.resetConversationId();
218+
219+
expect(chat.id).not.toBe(previousId);
220+
expect(new Chat<any>({ agentId }).id).toBe(chat.id);
221+
});
222+
223+
it('persists the id given to setConversationId', () => {
224+
const chat = new Chat<any>({ agentId });
225+
226+
chat.setConversationId('adopted-id');
227+
228+
expect(new Chat<any>({ agentId }).id).toBe('adopted-id');
229+
});
230+
231+
it('lets an explicit id win over the stored one', () => {
232+
const stored = new Chat<any>({ agentId });
233+
const chat = new Chat<any>({ agentId, id: 'explicit-id' });
234+
235+
expect(chat.id).toBe('explicit-id');
236+
expect(sessionStorage.getItem(key)).toBe(stored.id);
237+
});
238+
239+
it('does not restore the id when messages are passed explicitly', () => {
240+
const stored = new Chat<any>({ agentId });
241+
const chat = new Chat<any>({ agentId, messages: [] });
242+
243+
expect(chat.id).not.toBe(stored.id);
244+
expect(sessionStorage.getItem(key)).toBe(stored.id);
245+
});
246+
247+
it('does not read or write the id when persistence is disabled', () => {
248+
sessionStorage.setItem(key, 'stored-id');
249+
250+
const chat = new Chat<any>({ agentId, persistence: false });
251+
chat.resetConversationId();
252+
253+
expect(chat.id).not.toBe('stored-id');
254+
expect(sessionStorage.getItem(key)).toBe('stored-id');
255+
});
256+
257+
it('keeps the messages stored as a bare array', () => {
258+
const message = { role: 'user', content: 'Hello' };
259+
const chat = new Chat<any>({ agentId });
260+
chat.messages = [message];
261+
262+
expect(sessionStorage.getItem(`${CACHE_KEY}-${agentId}`)).toBe(
263+
JSON.stringify([message])
264+
);
265+
});
266+
});
194267
});

‎packages/instantsearch.js/src/lib/chat/chat.ts‎

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,31 +23,50 @@ export type ChatInit<TUiMessage extends UIMessage> =
2323
};
2424

2525
export const CACHE_KEY = 'instantsearch-chat-initial-messages';
26+
export const CONVERSATION_ID_CACHE_KEY = 'instantsearch-chat-conversation-id';
2627

2728
// Message history is a browser concern, and a server render constructs a Chat
2829
// too. Reading storage there throws during rendering; the write below only
2930
// throws into its own `catch`, so gating it just stops a pointless attempt.
30-
function getDefaultInitialMessages<TUIMessage extends UIMessage>(
31-
id?: string
32-
): TUIMessage[] {
33-
return safelyRunOnBrowser<TUIMessage[]>(
31+
function readFromSessionStorage(key: string): string | null {
32+
return safelyRunOnBrowser<string | null>(
3433
() => {
3534
try {
3635
// `sessionStorage` is not available in every environment with a
3736
// `window` (e.g. React Native), and some browsers throw on access
3837
// when storage is disabled.
39-
const initialMessages = sessionStorage.getItem(
40-
CACHE_KEY + (id ? `-${id}` : '')
41-
);
42-
return initialMessages ? JSON.parse(initialMessages) : [];
38+
return sessionStorage.getItem(key);
4339
} catch (e) {
44-
return [];
40+
return null;
4541
}
4642
},
47-
{ fallback: () => [] }
43+
{ fallback: () => null }
4844
);
4945
}
5046

47+
function writeToSessionStorage(key: string, value: string): void {
48+
safelyRunOnBrowser(() => {
49+
try {
50+
sessionStorage.setItem(key, value);
51+
} catch (e) {
52+
// Do nothing if sessionStorage is not available or full
53+
}
54+
});
55+
}
56+
57+
function getDefaultInitialMessages<TUIMessage extends UIMessage>(
58+
id?: string
59+
): TUIMessage[] {
60+
try {
61+
const initialMessages = readFromSessionStorage(
62+
CACHE_KEY + (id ? `-${id}` : '')
63+
);
64+
return initialMessages ? JSON.parse(initialMessages) : [];
65+
} catch (e) {
66+
return [];
67+
}
68+
}
69+
5170
export class ChatState<
5271
TUiMessage extends UIMessage,
5372
> implements BaseChatState<TUiMessage> {
@@ -59,6 +78,8 @@ export class ChatState<
5978
_statusCallbacks = new Set<() => void>();
6079
_errorCallbacks = new Set<() => void>();
6180
private persistenceKey: string | undefined;
81+
private conversationIdKey: string | undefined;
82+
restoredConversationId: string | undefined;
6283

6384
constructor(
6485
id: string | undefined = undefined,
@@ -77,7 +98,13 @@ export class ChatState<
7798
return;
7899
}
79100

80-
this.persistenceKey = CACHE_KEY + (id ? `-${id}` : '');
101+
const suffix = id ? `-${id}` : '';
102+
this.persistenceKey = CACHE_KEY + suffix;
103+
this.conversationIdKey = CONVERSATION_ID_CACHE_KEY + suffix;
104+
if (initialMessages === undefined) {
105+
this.restoredConversationId =
106+
readFromSessionStorage(this.conversationIdKey) || undefined;
107+
}
81108
const saveMessagesInSessionStorage = () => {
82109
if (this.status === 'ready') {
83110
this['~persistMessages']();
@@ -142,13 +169,13 @@ export class ChatState<
142169
const persistenceKey = this.persistenceKey;
143170
if (!persistenceKey) return;
144171

145-
safelyRunOnBrowser(() => {
146-
try {
147-
sessionStorage.setItem(persistenceKey, JSON.stringify(this.messages));
148-
} catch (e) {
149-
// Do nothing if sessionStorage is not available or full
150-
}
151-
});
172+
writeToSessionStorage(persistenceKey, JSON.stringify(this.messages));
173+
};
174+
175+
'~persistConversationId' = (conversationId: string): void => {
176+
if (!this.conversationIdKey) return;
177+
178+
writeToSessionStorage(this.conversationIdKey, conversationId);
152179
};
153180

154181
'~registerMessagesCallback' = (onChange: () => void): (() => void) => {
@@ -199,15 +226,28 @@ export class Chat<
199226
...init
200227
}: ChatInit<TUiMessage>) {
201228
const state = new ChatState(agentId, messages, persistence);
202-
super({ ...init, state });
229+
super({ ...init, id: init.id ?? state.restoredConversationId, state });
203230
this._state = state;
231+
if (init.id === undefined && messages === undefined) {
232+
state['~persistConversationId'](this.id);
233+
}
204234
if (messages === undefined && persistence) {
205235
this.hasRestoredMessages = true;
206236
this.captureRestoredToolCalls();
207237
this.repairRestoredPendingToolParts();
208238
}
209239
}
210240

241+
resetConversationId(): void {
242+
super.resetConversationId();
243+
this._state['~persistConversationId'](this.id);
244+
}
245+
246+
setConversationId(id: string): void {
247+
super.setConversationId(id);
248+
this._state['~persistConversationId'](this.id);
249+
}
250+
211251
'~repairRestoredPendingToolParts' = (): void => {
212252
if (!this.hasRestoredMessages) return;
213253

0 commit comments

Comments
 (0)