Skip to content
Open
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
43 changes: 43 additions & 0 deletions e2e/session-rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,49 @@ describe('Session REST API', () => {
const data: any = await response.json()
expect(data.error).toBe('Session not found')
})

it('loads recent history by complete turns and pages backwards without overlap', async () => {
const createRes = await fetch(`${server.url}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, title: 'Paginated session' }),
})
const created: any = await createRes.json()
const sessionId = created.session.id as string
const { emitUserMessage, emitAssistantMessageStart, emitMessageDelta, emitMessageDone } =
await import('../src/server/events/session.js')

for (let turn = 1; turn <= 22; turn++) {
emitUserMessage(sessionId, `User ${turn}`)
const assistantId = emitAssistantMessageStart(sessionId)
emitMessageDelta(sessionId, assistantId, `Assistant ${turn}`)
emitMessageDone(sessionId, assistantId)
}

const recentRes = await fetch(`${server.url}/api/sessions/${sessionId}?history=recent`)
const recent: any = await recentRes.json()
expect(recent.messages).toHaveLength(20)
expect(recent.messages[0].content).toBe('User 13')
expect(recent.messages.at(-1).content).toBe('Assistant 22')
expect(recent.hiddenCount).toBe(24)

const olderRes = await fetch(
`${server.url}/api/sessions/${sessionId}/messages?before=${encodeURIComponent(recent.messages[0].id)}`,
)
const older: any = await olderRes.json()
expect(older.messages).toHaveLength(20)
expect(older.messages[0].content).toBe('User 3')
expect(older.messages.at(-1).content).toBe('Assistant 12')
expect(older.hiddenCount).toBe(4)

const recentIds = new Set(recent.messages.map((entry: any) => entry.id))
expect(older.messages.some((entry: any) => recentIds.has(entry.id))).toBe(false)

const fullRes = await fetch(`${server.url}/api/sessions/${sessionId}?full=true`)
const full: any = await fullRes.json()
expect(full.messages).toHaveLength(44)
expect(full.hiddenCount).toBe(0)
})
})

describe('DELETE /api/sessions/:id', () => {
Expand Down
40 changes: 38 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,11 +888,44 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
}
})

app.get('/api/sessions/:id/messages', async (req, res) => {
const { getSession } = await import('./db/sessions.js')
if (!getSession(req.params.id)) {
return res.status(404).json({ error: 'Session not found' })
}

const { getEventStore, combineEventsWithSnapshot } = await import('./events/index.js')
const { buildMessagesFromStoredEvents } = await import('./events/folding.js')
const { paginateMessages, DEFAULT_HISTORY_PAGE_MAX_ITEMS } = await import('./session/message-pagination.js')

const eventStore = getEventStore()
const { snapshot, events: eventsSinceSnapshot } = eventStore.getEventsSinceSnapshot(req.params.id)
const events = combineEventsWithSnapshot(req.params.id, snapshot, eventsSinceSnapshot)
const allMessages = buildMessagesFromStoredEvents(events).messages
const requestedMaxItems = Number(req.query['maxItems'])
const maxItems =
Number.isInteger(requestedMaxItems) && requestedMaxItems > 0
? Math.min(requestedMaxItems, DEFAULT_HISTORY_PAGE_MAX_ITEMS)
: DEFAULT_HISTORY_PAGE_MAX_ITEMS

try {
res.json(
paginateMessages(allMessages, {
...(typeof req.query['before'] === 'string' ? { beforeMessageId: req.query['before'] } : {}),
maxItems,
}),
)
} catch (error) {
res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid message cursor' })
}
})

app.get('/api/sessions/:id', async (req, res) => {
const { getEventStore, combineEventsWithSnapshot } = await import('./events/index.js')
const { buildMessagesFromStoredEvents, foldPendingConfirmations } = await import('./events/folding.js')
const { getPendingQuestionsForSession } = await import('./tools/index.js')
const { getMaxVisibleItems } = await import('./db/settings.js')
const { paginateMessages } = await import('./session/message-pagination.js')

const session = sessionManager.getSession(req.params.id)
if (!session) {
Expand All @@ -906,8 +939,11 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
const { snapshot, events: eventsSinceSnapshot } = eventStore.getEventsSinceSnapshot(req.params.id)
const events = combineEventsWithSnapshot(req.params.id, snapshot, eventsSinceSnapshot)

const maxVisibleItems = req.query['full'] === 'true' ? undefined : getMaxVisibleItems() || undefined
const { messages, hiddenCount } = buildMessagesFromStoredEvents(events, maxVisibleItems)
const fullHistory = req.query['full'] === 'true'
const recentHistory = !fullHistory && req.query['history'] === 'recent'
const maxVisibleItems = fullHistory || recentHistory ? undefined : getMaxVisibleItems() || undefined
const folded = buildMessagesFromStoredEvents(events, maxVisibleItems)
const { messages, hiddenCount } = recentHistory ? paginateMessages(folded.messages) : folded
const contextState = sessionManager.getContextState(req.params.id)
const queueState = sessionManager.getQueueState(req.params.id)
const pendingQuestions = getPendingQuestionsForSession(req.params.id)
Expand Down
80 changes: 80 additions & 0 deletions src/server/session/message-pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import type { Message } from '../../shared/types.js'
import { paginateMessages } from './message-pagination.js'

function message(id: string, role: Message['role'], content = id): Message {
return {
id,
role,
content,
timestamp: '2026-08-22T00:00:00.000Z',
}
}

function turns(count: number): Message[] {
return Array.from({ length: count }, (_, index) => {
const turn = index + 1
return [message(`user-${turn}`, 'user'), message(`assistant-${turn}`, 'assistant')]
}).flat()
}

describe('paginateMessages', () => {
it('returns the ten most recent complete turns by default', () => {
const page = paginateMessages(turns(12))

expect(page.messages).toHaveLength(20)
expect(page.messages[0]!.id).toBe('user-3')
expect(page.messages.at(-1)!.id).toBe('assistant-12')
expect(page.hiddenCount).toBe(4)
})

it('does not split a turn when the item limit is reached', () => {
const page = paginateMessages(turns(5), { maxItems: 5, maxTurns: 10 })

expect(page.messages.map((entry) => entry.id)).toEqual(['user-4', 'assistant-4', 'user-5', 'assistant-5'])
expect(page.hiddenCount).toBe(6)
})

it('uses the serialized byte budget without dropping the newest complete turn', () => {
const messages = [
message('user-1', 'user'),
message('assistant-1', 'assistant', 'a'.repeat(600_000)),
message('user-2', 'user'),
message('assistant-2', 'assistant', 'b'.repeat(600_000)),
]

const page = paginateMessages(messages, { maxBytes: 1_000_000 })

expect(page.messages.map((entry) => entry.id)).toEqual(['user-2', 'assistant-2'])
expect(page.hiddenCount).toBe(2)
})

it('loads the page immediately before a stable message cursor without overlap', () => {
const messages = turns(6)
const newest = paginateMessages(messages, { maxTurns: 2 })
const older = paginateMessages(messages, {
beforeMessageId: newest.messages[0]!.id,
maxTurns: 2,
})

expect(newest.messages.map((entry) => entry.id)).toEqual(['user-5', 'assistant-5', 'user-6', 'assistant-6'])
expect(older.messages.map((entry) => entry.id)).toEqual(['user-3', 'assistant-3', 'user-4', 'assistant-4'])
expect(older.hiddenCount).toBe(4)
})

it('keeps system and tool messages attached to their surrounding turn', () => {
const messages = [
message('system-1', 'system'),
message('user-1', 'user'),
message('assistant-1', 'assistant'),
message('tool-1', 'tool'),
message('user-2', 'user'),
message('assistant-2', 'assistant'),
]

const page = paginateMessages(messages, { maxTurns: 1 })

expect(page.messages.map((entry) => entry.id)).toEqual(['user-2', 'assistant-2'])
expect(page.hiddenCount).toBe(4)
})
})
90 changes: 90 additions & 0 deletions src/server/session/message-pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Buffer } from 'node:buffer'
import type { Message } from '../../shared/types.js'

export const DEFAULT_HISTORY_PAGE_MAX_TURNS = 10
export const DEFAULT_HISTORY_PAGE_MAX_ITEMS = 30
export const DEFAULT_HISTORY_PAGE_MAX_BYTES = 1024 * 1024

export interface MessagePageOptions {
beforeMessageId?: string
maxTurns?: number
maxItems?: number
maxBytes?: number
}

export interface MessagePage {
messages: Message[]
hiddenCount: number
}

interface MessageTurn {
start: number
messages: Message[]
bytes: number
}

function positiveInteger(value: number | undefined, fallback: number): number {
return value !== undefined && Number.isInteger(value) && value > 0 ? value : fallback
}

function groupIntoTurns(messages: Message[]): MessageTurn[] {
const turns: MessageTurn[] = []

for (let index = 0; index < messages.length; index++) {
const entry = messages[index]!
let turn = turns.at(-1)

if (!turn || entry.role === 'user') {
turn = { start: index, messages: [], bytes: 0 }
turns.push(turn)
}

turn.messages.push(entry)
turn.bytes += Buffer.byteLength(JSON.stringify(entry), 'utf8')
}

return turns
}

/**
* Select a bottom-anchored page without splitting a user turn. Limits are
* soft for the newest eligible turn so one oversized response remains usable.
*/
export function paginateMessages(messages: Message[], options: MessagePageOptions = {}): MessagePage {
const maxTurns = positiveInteger(options.maxTurns, DEFAULT_HISTORY_PAGE_MAX_TURNS)
const maxItems = positiveInteger(options.maxItems, DEFAULT_HISTORY_PAGE_MAX_ITEMS)
const maxBytes = positiveInteger(options.maxBytes, DEFAULT_HISTORY_PAGE_MAX_BYTES)

let end = messages.length
if (options.beforeMessageId !== undefined) {
end = messages.findIndex((entry) => entry.id === options.beforeMessageId)
if (end < 0) {
throw new Error(`Message cursor not found: ${options.beforeMessageId}`)
}
}

if (end === 0) return { messages: [], hiddenCount: 0 }

const turns = groupIntoTurns(messages.slice(0, end))
const selected: MessageTurn[] = []
let itemCount = 0
let byteCount = 0

for (let index = turns.length - 1; index >= 0; index--) {
const turn = turns[index]!
const exceedsLimit =
selected.length >= maxTurns || itemCount + turn.messages.length > maxItems || byteCount + turn.bytes > maxBytes

if (selected.length > 0 && exceedsLimit) break

selected.unshift(turn)
itemCount += turn.messages.length
byteCount += turn.bytes
}

const first = selected[0]
return {
messages: selected.flatMap((turn) => turn.messages),
hiddenCount: first?.start ?? 0,
}
}
Loading