Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion assistant/src/daemon/handlers/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -562,6 +564,7 @@ export function handleHistoryRequest(
msg.sessionId,
limit,
msg.beforeTimestamp,
msg.beforeMessageId,
);

const parsed: ParsedHistoryMessage[] = dbMessages.map((m) => {
Expand Down Expand Up @@ -694,13 +697,17 @@ 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',
sessionId: msg.sessionId,
messages: historyMessages,
hasMore,
...(oldestTimestamp !== undefined ? { oldestTimestamp } : {}),
...(oldestMessageId ? { oldestMessageId } : {}),
});

// Surfaces are now included directly in the history_response message (in the surfaces array),
Expand Down
6 changes: 5 additions & 1 deletion assistant/src/daemon/ipc-contract/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 32 additions & 3 deletions assistant/src/memory/conversation-store.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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));
Comment thread
ashleeradka marked this conversation as resolved.
Comment thread
ashleeradka marked this conversation as resolved.
} 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
Expand Down
12 changes: 9 additions & 3 deletions clients/shared/IPC/Generated/IPCContractGenerated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
}
}

Expand Down
Loading