diff --git a/assistant/src/daemon/handlers/sessions.ts b/assistant/src/daemon/handlers/sessions.ts index 2c980a79e3d..548e6ddea8e 100644 --- a/assistant/src/daemon/handlers/sessions.ts +++ b/assistant/src/daemon/handlers/sessions.ts @@ -549,7 +549,9 @@ export function handleHistoryRequest( socket: net.Socket, ctx: HandlerContext, ): void { - const limit = msg.limit ?? 50; + // Default to unlimited when callers don't specify a limit, preserving + // backward-compatible behavior of returning full conversation history. + const limit = msg.limit; // Resolve include flags: explicit flags override mode, mode provides defaults. // Default mode is 'light' when no mode and no include flags are specified. @@ -562,6 +564,7 @@ export function handleHistoryRequest( msg.sessionId, limit, msg.beforeTimestamp, + msg.beforeMessageId, ); const parsed: ParsedHistoryMessage[] = dbMessages.map((m) => { @@ -694,6 +697,9 @@ export function handleHistoryRequest( }); const oldestTimestamp = historyMessages.length > 0 ? historyMessages[0].timestamp : undefined; + // Provide the oldest message ID as a tie-breaker cursor so clients can + // paginate without skipping same-millisecond messages at page boundaries. + const oldestMessageId = historyMessages.length > 0 ? historyMessages[0].id : undefined; ctx.send(socket, { type: 'history_response', @@ -701,6 +707,7 @@ export function handleHistoryRequest( messages: historyMessages, hasMore, ...(oldestTimestamp !== undefined ? { oldestTimestamp } : {}), + ...(oldestMessageId ? { oldestMessageId } : {}), }); // Surfaces are now included directly in the history_response message (in the surfaces array), diff --git a/assistant/src/daemon/ipc-contract/sessions.ts b/assistant/src/daemon/ipc-contract/sessions.ts index ee1c49d2a01..a171bef4099 100644 --- a/assistant/src/daemon/ipc-contract/sessions.ts +++ b/assistant/src/daemon/ipc-contract/sessions.ts @@ -88,10 +88,12 @@ export interface ImageGenModelSetRequest { export interface HistoryRequest { type: 'history_request'; sessionId: string; - /** Max messages to return. Defaults to 50. */ + /** Max messages to return. When omitted, all messages are returned (unlimited). */ limit?: number; /** Pagination cursor: return messages with timestamp before this value. */ beforeTimestamp?: number; + /** Pagination cursor tie-breaker: exclude this message ID when beforeTimestamp matches. */ + beforeMessageId?: string; /** Include attachment base64 data. Defaults to false in light mode. */ includeAttachments?: boolean; /** Include tool screenshot base64 data. Defaults to false in light mode. */ @@ -279,6 +281,8 @@ export interface HistoryResponse { hasMore: boolean; /** Timestamp of the oldest message in the response (client uses as next pagination cursor). */ oldestTimestamp?: number; + /** ID of the oldest message in the response (tie-breaker for same-millisecond cursors). */ + oldestMessageId?: string; } export interface UndoComplete { diff --git a/assistant/src/memory/conversation-store.ts b/assistant/src/memory/conversation-store.ts index e6c392ff2c8..12320cf674d 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, lt, sql } from 'drizzle-orm'; +import { and, asc, count, desc, eq, inArray, isNull, lt, lte, ne, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { z } from 'zod'; @@ -332,16 +332,45 @@ export interface PaginatedMessagesResult { /** * Paginated variant of getMessages. Returns the most recent `limit` messages * (optionally before a cursor timestamp), in chronological order. + * + * When `limit` is undefined, all matching messages are returned (no pagination). + * When `beforeMessageId` is provided alongside `beforeTimestamp`, it acts as a + * tie-breaker to avoid skipping messages that share the same millisecond timestamp + * at page boundaries. */ export function getMessagesPaginated( conversationId: string, - limit: number, + limit: number | undefined, beforeTimestamp?: number, + beforeMessageId?: string, ): PaginatedMessagesResult { const db = getDb(); const conditions = [eq(messages.conversationId, conversationId)]; if (beforeTimestamp !== undefined) { - conditions.push(lt(messages.createdAt, beforeTimestamp)); + if (beforeMessageId) { + // Use lte + ne as a compound cursor: include messages at the same + // millisecond but exclude the specific boundary message already seen. + conditions.push(lte(messages.createdAt, beforeTimestamp)); + conditions.push(ne(messages.id, beforeMessageId)); + } else { + // Legacy callers without a message ID tie-breaker: use strict lt. + // This may skip same-millisecond messages at boundaries, but avoids + // re-fetching the boundary message. New callers should prefer the + // compound cursor (beforeTimestamp + beforeMessageId). + conditions.push(lt(messages.createdAt, beforeTimestamp)); + } + } + + if (limit === undefined) { + // Unlimited: return all messages in chronological order, no pagination. + const rows = db + .select() + .from(messages) + .where(and(...conditions)) + .orderBy(asc(messages.createdAt)) + .all() + .map(parseMessage); + return { messages: rows, hasMore: false }; } // Fetch limit+1 rows ordered newest-first so we can detect hasMore diff --git a/clients/shared/IPC/Generated/IPCContractGenerated.swift b/clients/shared/IPC/Generated/IPCContractGenerated.swift index 109695ab360..daaf1bd71ea 100644 --- a/clients/shared/IPC/Generated/IPCContractGenerated.swift +++ b/clients/shared/IPC/Generated/IPCContractGenerated.swift @@ -1940,10 +1940,12 @@ 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. + /// Max messages to return. When omitted, all messages are returned (unlimited). public let limit: Double? /// Pagination cursor: return messages with timestamp before this value. public let beforeTimestamp: Double? + /// Pagination cursor tie-breaker: exclude this message ID when beforeTimestamp matches. + public let beforeMessageId: String? /// 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. @@ -1953,11 +1955,12 @@ public struct IPCHistoryRequest: Codable, Sendable { /// Shorthand: 'light' = all include flags false (default), 'full' = all include flags true. public let mode: 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) { + public init(type: String, sessionId: String, limit: Double? = nil, beforeTimestamp: Double? = nil, beforeMessageId: String? = 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.beforeMessageId = beforeMessageId self.includeAttachments = includeAttachments self.includeToolImages = includeToolImages self.includeSurfaceData = includeSurfaceData @@ -1973,13 +1976,16 @@ public struct IPCHistoryResponse: Codable, Sendable { public let hasMore: Bool /// Timestamp of the oldest message in the response (client uses as next pagination cursor). public let oldestTimestamp: Double? + /// ID of the oldest message in the response (tie-breaker for same-millisecond cursors). + public let oldestMessageId: String? - public init(type: String, sessionId: String, messages: [IPCHistoryResponseMessage], hasMore: Bool, oldestTimestamp: Double? = nil) { + public init(type: String, sessionId: String, messages: [IPCHistoryResponseMessage], hasMore: Bool, oldestTimestamp: Double? = nil, oldestMessageId: String? = nil) { self.type = type self.sessionId = sessionId self.messages = messages self.hasMore = hasMore self.oldestTimestamp = oldestTimestamp + self.oldestMessageId = oldestMessageId } }