import type { ReadonlyJSONObject } from "assistant-stream/utils"; import type { AssistantCloud } from "./AssistantCloud"; import type { CloudMessage } from "./AssistantCloudThreadMessages"; import { CloudAPIError } from "./AssistantCloudAPI"; const CLOUD_MESSAGE_PAGE_SIZE = 200; /** * Appends, updates and loads cloud messages while mapping local ids to cloud * ids and chaining parent_id. A parent that is still being created is awaited, * so concurrent appends land under the right parent. */ export class CloudMessagePersistence { private idMapping = new Map>(); private getCloud: () => AssistantCloud; constructor(cloud: AssistantCloud); constructor(getCloud: () => AssistantCloud); constructor(cloud: AssistantCloud | (() => AssistantCloud)) { this.getCloud = typeof cloud === "function" ? cloud : () => cloud; } /** * Persist a message to the cloud. * * @param threadId - Remote thread ID * @param messageId - Local message ID (used for tracking) * @param parentId - Local parent message ID (or null for first message) * @param format - Message format (e.g., "aui/v0", "ai-sdk/v6") * @param content - Message content (format-specific) */ async append( threadId: string, messageId: string, parentId: string | null, format: string, content: ReadonlyJSONObject, ): Promise { const cloud = this.getCloud(); const existing = this.idMapping.get(messageId); if (existing instanceof Promise) { await existing; return; } const task = (async () => { const parentEntry = parentId ? this.idMapping.get(parentId) : undefined; const resolvedParentId = parentId ? ((await parentEntry) ?? parentId) : null; const { message_id } = await cloud.threads.messages.create(threadId, { parent_id: resolvedParentId, format, content, }); return message_id; })(); this.idMapping.set(messageId, task); try { const remoteId = await task; if (this.idMapping.get(messageId) === task) { this.idMapping.set(messageId, remoteId); } } catch (err) { if (this.idMapping.get(messageId) === task) { this.idMapping.delete(messageId); } throw err; } } /** * Update an already-persisted message in the cloud. */ async update( threadId: string, messageId: string, _format: string, content: ReadonlyJSONObject, ): Promise { const cloud = this.getCloud(); const remoteId = await this.getRemoteId(messageId); if (!remoteId) { console.warn( `Skipping update for message ${messageId}: no remote id is mapped.`, ); return; } await cloud.threads.messages.update(threadId, remoteId, { content }); } /** * Delete mapped messages from the cloud and clear their local ID mappings. */ async delete(threadId: string, messageIds: string[]): Promise { const cloud = this.getCloud(); const mapping = this.idMapping; const resolved: { localId: string; entry: string | Promise; remoteId: string; }[] = []; for (const localId of messageIds) { const entry = mapping.get(localId); if (!entry) continue; try { resolved.push({ localId, entry, remoteId: await entry }); } catch { continue; } } if (resolved.length === 0) return; try { await cloud.threads.messages.delete( threadId, resolved.map(({ remoteId }) => remoteId), ); } catch (error) { if (!(error instanceof CloudAPIError && error.status === 404)) throw error; } for (const { localId, entry, remoteId } of resolved) { const current = mapping.get(localId); if (current === entry || current === remoteId) mapping.delete(localId); } } /** * Check if a message has been persisted (or is currently being persisted). */ isPersisted(messageId: string): boolean { return this.idMapping.has(messageId); } /** * Get the remote ID for a local message ID (resolved). * Returns undefined if not persisted. */ async getRemoteId(messageId: string): Promise { const entry = this.idMapping.get(messageId); if (!entry) return undefined; return entry; } getResolvedRemoteId(messageId: string): string | undefined { const entry = this.idMapping.get(messageId); return typeof entry === "string" ? entry : undefined; } record(localId: string, remoteId: string): void { this.idMapping.set(localId, remoteId); } /** * Load messages from the cloud and populate the ID mapping. * * The list endpoint caps a response at 200 rows, so pages are followed by * message ID cursor until a short page and concatenated in server order. * * The ID mapping is populated so that `isPersisted()` returns true for * loaded messages, preventing re-persistence of already-stored messages. * * A loaded ID that an append already maps keeps the remote ID from that append, and falls back to the loaded ID if the append fails. * * @param threadId - Remote thread ID * @param format - Optional format filter * @returns Array of cloud messages */ async load(threadId: string, format?: string) { const idMapping = this.idMapping; const cloud = this.getCloud(); const messages: CloudMessage[] = []; const seen = new Set(); let after: string | undefined; while (true) { const page = await cloud.threads.messages.list(threadId, { ...(format ? { format } : undefined), limit: CLOUD_MESSAGE_PAGE_SIZE, ...(after ? { after } : undefined), }); const last = page.messages.at(-1); if (!last) break; // A cursor the server cannot resolve drops the keyset filter and replays // an earlier page, so already-seen rows end the walk instead of repeating. const fresh = page.messages.filter((m) => !seen.has(m.id)); if (fresh.length === 0) break; for (const m of fresh) seen.add(m.id); messages.push(...fresh); if (page.messages.length < CLOUD_MESSAGE_PAGE_SIZE) break; after = last.id; } if (this.idMapping === idMapping) { for (const m of messages) { const entry = idMapping.get(m.id); if (entry === undefined) { idMapping.set(m.id, m.id); } else if (entry instanceof Promise) { void entry.catch(() => { const current = idMapping.get(m.id); if (current === undefined || current === entry) { idMapping.set(m.id, m.id); } }); } } } return messages; } /** * Reset the ID mapping (call when switching threads). * * Pending `load()` and `append()` calls are not cancelled and still settle * normally, but their results no longer populate the ID mapping. */ reset() { this.idMapping = new Map(); } }