From d7dc18a6ecd0af48a37e2d705a97bd79bb438214 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 23:04:08 +0200 Subject: [PATCH] Page chat history at turn boundaries --- src/client/src/chatHistoryCache.ts | 2 +- src/server/sessions/messagePaging.test.ts | 42 +++++++++++++++++ src/server/sessions/messagePaging.ts | 55 +++++++++++++++++++++++ src/server/sessions/piSessionService.ts | 14 +----- 4 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 src/server/sessions/messagePaging.test.ts create mode 100644 src/server/sessions/messagePaging.ts diff --git a/src/client/src/chatHistoryCache.ts b/src/client/src/chatHistoryCache.ts index a2a4f79..7cf306c 100644 --- a/src/client/src/chatHistoryCache.ts +++ b/src/client/src/chatHistoryCache.ts @@ -1,4 +1,4 @@ -const CACHE_PREFIX = "pi-web:chat-history:"; +const CACHE_PREFIX = "pi-web:chat-history:v2:"; const CACHE_TTL_MS = 30 * 60 * 1000; export interface RawMessagePage { diff --git a/src/server/sessions/messagePaging.test.ts b/src/server/sessions/messagePaging.test.ts new file mode 100644 index 0000000..7544d15 --- /dev/null +++ b/src/server/sessions/messagePaging.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { expandStartToSafeBoundary, pageMessagesAtSafeBoundary } from "./messagePaging"; + +const user = (content: string) => ({ role: "user", content }); +const assistantText = (text: string) => ({ role: "assistant", content: [{ type: "text", text }] }); +const thinking = (text: string) => ({ role: "assistant", content: [{ type: "thinking", thinking: text }] }); +const toolCall = (name: string) => ({ role: "assistant", content: [{ type: "toolCall", name }] }); +const toolResult = (text: string) => ({ role: "toolResult", content: text }); + +function page(start: number, total: number, messages: unknown[]) { + return { start, total, messages }; +} + +describe("message paging", () => { + it("returns the raw messages when paging is not requested", () => { + const messages = [user("hello")]; + expect(pageMessagesAtSafeBoundary(messages)).toBe(messages); + }); + + it("uses normal bounded paging when the requested start is already a turn boundary", () => { + const messages = [user("a"), assistantText("b"), user("c")]; + expect(pageMessagesAtSafeBoundary(messages, { limit: 1 })).toEqual(page(2, 3, messages.slice(2))); + }); + + it("expands a page start backward to avoid splitting a turn", () => { + const messages = [ + user("prompt"), + thinking("plan"), + toolCall("read"), + toolResult("ok"), + thinking("next"), + assistantText("answer"), + ]; + + expect(pageMessagesAtSafeBoundary(messages, { before: 5, limit: 2 })).toEqual(page(0, 6, messages.slice(0, 5))); + }); + + it("does not split a readable assistant answer from its user prompt", () => { + const messages = [user("prompt"), thinking("plan"), assistantText("answer")]; + expect(expandStartToSafeBoundary(messages, 2)).toBe(0); + }); +}); diff --git a/src/server/sessions/messagePaging.ts b/src/server/sessions/messagePaging.ts new file mode 100644 index 0000000..364de6a --- /dev/null +++ b/src/server/sessions/messagePaging.ts @@ -0,0 +1,55 @@ +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; + +export interface MessagePageRequest { + before?: number; + limit?: number; +} + +export interface MessagePageResult { + messages: T[]; + start: number; + total: number; +} + +export function pageMessagesAtSafeBoundary(messages: T[], page?: MessagePageRequest): T[] | MessagePageResult { + if (page?.before === undefined && page?.limit === undefined) return messages; + const total = messages.length; + const before = clampInteger(page.before ?? total, 0, total); + const limit = clampInteger(page.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT); + const requestedStart = Math.max(0, before - limit); + const start = expandStartToSafeBoundary(messages, requestedStart); + return { messages: messages.slice(start, before), start, total }; +} + +export function expandStartToSafeBoundary(messages: unknown[], requestedStart: number): number { + const start = clampInteger(requestedStart, 0, messages.length); + if (start === 0 || isTurnBoundary(messages[start])) return start; + for (let index = start - 1; index >= 0; index -= 1) { + if (isTurnBoundary(messages[index])) return index; + } + return 0; +} + +function isTurnBoundary(message: unknown): boolean { + return getString(message, "role") === "user"; +} + +function getProperty(value: unknown, key: string): unknown { + if (!isRecord(value)) return undefined; + return value[key]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getString(value: unknown, key: string): string | undefined { + const property = getProperty(value, key); + return typeof property === "string" ? property : undefined; +} + +function clampInteger(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return max; + return Math.max(min, Math.min(max, Math.floor(value))); +} diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index d0e2e37..17d798c 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -11,6 +11,7 @@ import { type CreateAgentSessionRuntimeFactory, } from "@earendil-works/pi-coding-agent"; import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; +import { pageMessagesAtSafeBoundary } from "./messagePaging.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js"; import { SessionCommandService } from "./sessionCommandService.js"; @@ -138,13 +139,7 @@ export class PiSessionService { async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise { const session = await this.getOrOpen(sessionId); - const messages = historyMessages(session); - if (page?.before === undefined && page?.limit === undefined) return messages; - const total = messages.length; - const before = clampInteger(page.before ?? total, 0, total); - const limit = clampInteger(page.limit ?? 100, 1, 500); - const start = Math.max(0, before - limit); - return { messages: messages.slice(start, before), start, total }; + return pageMessagesAtSafeBoundary(historyMessages(session), page); } async status(sessionId: string): Promise { @@ -534,11 +529,6 @@ function historyMessages(session: AgentSession): unknown[] { return messages; } -function clampInteger(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) return max; - return Math.max(min, Math.min(max, Math.floor(value))); -} - function toClientEvent(event: unknown): SessionUiEvent { const eventType = getString(event, "type"); const assistantMessageEvent = getProperty(event, "assistantMessageEvent");