diff --git a/assistant/src/daemon/handlers/sessions.ts b/assistant/src/daemon/handlers/sessions.ts index 3b7c3f2e52b..2c980a79e3d 100644 --- a/assistant/src/daemon/handlers/sessions.ts +++ b/assistant/src/daemon/handlers/sessions.ts @@ -549,7 +549,21 @@ export function handleHistoryRequest( socket: net.Socket, ctx: HandlerContext, ): void { - const dbMessages = conversationStore.getMessages(msg.sessionId); + const limit = msg.limit ?? 50; + + // Resolve include flags: explicit flags override mode, mode provides defaults. + // Default mode is 'light' when no mode and no include flags are specified. + const isFullMode = msg.mode === 'full'; + const includeAttachments = msg.includeAttachments ?? isFullMode; + const includeToolImages = msg.includeToolImages ?? isFullMode; + const includeSurfaceData = msg.includeSurfaceData ?? isFullMode; + + const { messages: dbMessages, hasMore } = conversationStore.getMessagesPaginated( + msg.sessionId, + limit, + msg.beforeTimestamp, + ); + const parsed: ParsedHistoryMessage[] = dbMessages.map((m) => { let text = ''; let toolCalls: HistoryToolCall[] = []; @@ -597,52 +611,97 @@ export function handleHistoryRequest( if (m.role === 'assistant' && m.id) { const linked = getAttachmentsForMessage(m.id); if (linked.length > 0) { - // Skip embedding base64 data for large video attachments to keep the - // history_response payload small. Only videos have a lazy-fetch path on - // the client, so non-video attachments always keep their inline data. - const MAX_INLINE_B64_SIZE = 512 * 1024; - attachments = linked.map((a) => { - const isFileBacked = !a.dataBase64; // empty string = file-backed attachment - const omit = isFileBacked || (a.mimeType.startsWith('video/') && a.dataBase64.length > MAX_INLINE_B64_SIZE); - - // Lazily generate thumbnails for existing video attachments on first history load. - // Skip for file-backed attachments — there is no in-memory base64 to generate from. - if (a.mimeType.startsWith('video/') && !a.thumbnailBase64 && a.dataBase64) { - const attachmentId = a.id; - const base64 = a.dataBase64; - silentlyWithLog( - generateVideoThumbnail(base64).then((thumb) => { - if (thumb) setAttachmentThumbnail(attachmentId, thumb); - }), - 'video thumbnail generation', - ); - } - - return { + if (includeAttachments) { + // Full attachment data: same behavior as before + const MAX_INLINE_B64_SIZE = 512 * 1024; + attachments = linked.map((a) => { + const isFileBacked = !a.dataBase64; + const omit = isFileBacked || (a.mimeType.startsWith('video/') && a.dataBase64.length > MAX_INLINE_B64_SIZE); + + if (a.mimeType.startsWith('video/') && !a.thumbnailBase64 && a.dataBase64) { + const attachmentId = a.id; + const base64 = a.dataBase64; + silentlyWithLog( + generateVideoThumbnail(base64).then((thumb) => { + if (thumb) setAttachmentThumbnail(attachmentId, thumb); + }), + 'video thumbnail generation', + ); + } + + return { + id: a.id, + filename: a.originalFilename, + mimeType: a.mimeType, + data: omit ? '' : a.dataBase64, + ...(omit ? { sizeBytes: a.sizeBytes } : {}), + ...(a.thumbnailBase64 ? { thumbnailData: a.thumbnailBase64 } : {}), + }; + }); + } else { + // Light mode: metadata only, strip base64 data + attachments = linked.map((a) => ({ id: a.id, filename: a.originalFilename, mimeType: a.mimeType, - data: omit ? '' : a.dataBase64, - ...(omit ? { sizeBytes: a.sizeBytes } : {}), + data: '', + sizeBytes: a.sizeBytes, ...(a.thumbnailBase64 ? { thumbnailData: a.thumbnailBase64 } : {}), - }; - }); + })); + } } } + + // In light mode, strip imageData from tool calls + const filteredToolCalls = m.toolCalls.length > 0 + ? (includeToolImages + ? m.toolCalls + : m.toolCalls.map((tc) => { + if (tc.imageData) { + const { imageData: _, ...rest } = tc; + return rest; + } + return tc; + })) + : m.toolCalls; + + // In light mode, strip full data from surfaces (keep metadata) + const filteredSurfaces = m.surfaces.length > 0 + ? (includeSurfaceData + ? m.surfaces + : m.surfaces.map((s) => ({ + surfaceId: s.surfaceId, + surfaceType: s.surfaceType, + title: s.title, + data: {} as Record, + ...(s.actions ? { actions: s.actions } : {}), + ...(s.display ? { display: s.display } : {}), + }))) + : m.surfaces; + return { ...(m.id ? { id: m.id } : {}), role: m.role, text: m.text, timestamp: m.timestamp, - ...(m.toolCalls.length > 0 ? { toolCalls: m.toolCalls, toolCallsBeforeText: m.toolCallsBeforeText } : {}), + ...(filteredToolCalls.length > 0 ? { toolCalls: filteredToolCalls, toolCallsBeforeText: m.toolCallsBeforeText } : {}), ...(attachments ? { attachments } : {}), ...(m.textSegments.length > 0 ? { textSegments: m.textSegments } : {}), ...(m.contentOrder.length > 0 ? { contentOrder: m.contentOrder } : {}), - ...(m.surfaces.length > 0 ? { surfaces: m.surfaces } : {}), + ...(filteredSurfaces.length > 0 ? { surfaces: filteredSurfaces } : {}), ...(m.subagentNotification ? { subagentNotification: m.subagentNotification } : {}), }; }); - ctx.send(socket, { type: 'history_response', sessionId: msg.sessionId, messages: historyMessages }); + + const oldestTimestamp = historyMessages.length > 0 ? historyMessages[0].timestamp : undefined; + + ctx.send(socket, { + type: 'history_response', + sessionId: msg.sessionId, + messages: historyMessages, + hasMore, + ...(oldestTimestamp !== undefined ? { oldestTimestamp } : {}), + }); // Surfaces are now included directly in the history_response message (in the surfaces array), // so we no longer emit separate ui_surface_show messages during history loading. diff --git a/assistant/src/daemon/ipc-contract/sessions.ts b/assistant/src/daemon/ipc-contract/sessions.ts index 0b06732b5a6..ee1c49d2a01 100644 --- a/assistant/src/daemon/ipc-contract/sessions.ts +++ b/assistant/src/daemon/ipc-contract/sessions.ts @@ -88,6 +88,18 @@ export interface ImageGenModelSetRequest { export interface HistoryRequest { type: 'history_request'; sessionId: string; + /** Max messages to return. Defaults to 50. */ + limit?: number; + /** Pagination cursor: return messages with timestamp before this value. */ + beforeTimestamp?: number; + /** Include attachment base64 data. Defaults to false in light mode. */ + includeAttachments?: boolean; + /** Include tool screenshot base64 data. Defaults to false in light mode. */ + includeToolImages?: boolean; + /** Include surface HTML payloads. Defaults to false in light mode. */ + includeSurfaceData?: boolean; + /** Shorthand: 'light' = all include flags false (default), 'full' = all include flags true. */ + mode?: 'light' | 'full'; } export interface UndoRequest { @@ -263,6 +275,10 @@ export interface HistoryResponse { conversationId?: string; }; }>; + /** Whether older messages exist beyond the returned page. */ + hasMore: boolean; + /** Timestamp of the oldest message in the response (client uses as next pagination cursor). */ + oldestTimestamp?: number; } export interface UndoComplete { diff --git a/assistant/src/memory/conversation-store.ts b/assistant/src/memory/conversation-store.ts index f4062e1f94c..e6c392ff2c8 100644 --- a/assistant/src/memory/conversation-store.ts +++ b/assistant/src/memory/conversation-store.ts @@ -1,4 +1,4 @@ -import { and, asc, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; +import { and, asc, count, desc, eq, inArray, isNull, lt, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { z } from 'zod'; @@ -323,6 +323,46 @@ export function getMessages(conversationId: string): MessageRow[] { .map(parseMessage); } +export interface PaginatedMessagesResult { + messages: MessageRow[]; + /** Whether older messages exist beyond the returned page. */ + hasMore: boolean; +} + +/** + * Paginated variant of getMessages. Returns the most recent `limit` messages + * (optionally before a cursor timestamp), in chronological order. + */ +export function getMessagesPaginated( + conversationId: string, + limit: number, + beforeTimestamp?: number, +): PaginatedMessagesResult { + const db = getDb(); + const conditions = [eq(messages.conversationId, conversationId)]; + if (beforeTimestamp !== undefined) { + conditions.push(lt(messages.createdAt, beforeTimestamp)); + } + + // Fetch limit+1 rows ordered newest-first so we can detect hasMore + const rows = db + .select() + .from(messages) + .where(and(...conditions)) + .orderBy(desc(messages.createdAt)) + .limit(limit + 1) + .all() + .map(parseMessage); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + // Return in chronological order (oldest first) for the client + page.reverse(); + + return { messages: page, hasMore }; +} + export function updateConversationTitle(id: string, title: string, isAutoTitle?: number): void { const db = getDb(); const set: Record = { title, updatedAt: Date.now() }; diff --git a/clients/shared/IPC/Generated/IPCContractGenerated.swift b/clients/shared/IPC/Generated/IPCContractGenerated.swift index 9c8e56874e9..109695ab360 100644 --- a/clients/shared/IPC/Generated/IPCContractGenerated.swift +++ b/clients/shared/IPC/Generated/IPCContractGenerated.swift @@ -1940,10 +1940,28 @@ public struct IPCHeartbeatRunsListResponseRun: Codable, Sendable { public struct IPCHistoryRequest: Codable, Sendable { public let type: String public let sessionId: String + /// Max messages to return. Defaults to 50. + public let limit: Double? + /// Pagination cursor: return messages with timestamp before this value. + public let beforeTimestamp: Double? + /// Include attachment base64 data. Defaults to false in light mode. + public let includeAttachments: Bool? + /// Include tool screenshot base64 data. Defaults to false in light mode. + public let includeToolImages: Bool? + /// Include surface HTML payloads. Defaults to false in light mode. + public let includeSurfaceData: Bool? + /// Shorthand: 'light' = all include flags false (default), 'full' = all include flags true. + public let mode: String? - public init(type: String, sessionId: String) { + public init(type: String, sessionId: String, limit: Double? = nil, beforeTimestamp: Double? = nil, includeAttachments: Bool? = nil, includeToolImages: Bool? = nil, includeSurfaceData: Bool? = nil, mode: String? = nil) { self.type = type self.sessionId = sessionId + self.limit = limit + self.beforeTimestamp = beforeTimestamp + self.includeAttachments = includeAttachments + self.includeToolImages = includeToolImages + self.includeSurfaceData = includeSurfaceData + self.mode = mode } } @@ -1951,11 +1969,17 @@ public struct IPCHistoryResponse: Codable, Sendable { public let type: String public let sessionId: String public let messages: [IPCHistoryResponseMessage] + /// Whether older messages exist beyond the returned page. + public let hasMore: Bool + /// Timestamp of the oldest message in the response (client uses as next pagination cursor). + public let oldestTimestamp: Double? - public init(type: String, sessionId: String, messages: [IPCHistoryResponseMessage]) { + public init(type: String, sessionId: String, messages: [IPCHistoryResponseMessage], hasMore: Bool, oldestTimestamp: Double? = nil) { self.type = type self.sessionId = sessionId self.messages = messages + self.hasMore = hasMore + self.oldestTimestamp = oldestTimestamp } }