Archived
Page chat history at turn boundaries
This commit is contained in:
@@ -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;
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
export interface RawMessagePage {
|
export interface RawMessagePage {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const DEFAULT_LIMIT = 100;
|
||||||
|
const MAX_LIMIT = 500;
|
||||||
|
|
||||||
|
export interface MessagePageRequest {
|
||||||
|
before?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessagePageResult<T> {
|
||||||
|
messages: T[];
|
||||||
|
start: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageMessagesAtSafeBoundary<T>(messages: T[], page?: MessagePageRequest): T[] | MessagePageResult<T> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type CreateAgentSessionRuntimeFactory,
|
type CreateAgentSessionRuntimeFactory,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
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 type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||||
import { SessionCommandService } from "./sessionCommandService.js";
|
import { SessionCommandService } from "./sessionCommandService.js";
|
||||||
@@ -138,13 +139,7 @@ export class PiSessionService {
|
|||||||
|
|
||||||
async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
||||||
const session = await this.getOrOpen(sessionId);
|
const session = await this.getOrOpen(sessionId);
|
||||||
const messages = historyMessages(session);
|
return pageMessagesAtSafeBoundary(historyMessages(session), page);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async status(sessionId: string): Promise<ClientSessionStatus> {
|
async status(sessionId: string): Promise<ClientSessionStatus> {
|
||||||
@@ -534,11 +529,6 @@ function historyMessages(session: AgentSession): unknown[] {
|
|||||||
return messages;
|
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 {
|
function toClientEvent(event: unknown): SessionUiEvent {
|
||||||
const eventType = getString(event, "type");
|
const eventType = getString(event, "type");
|
||||||
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
||||||
|
|||||||
Reference in New Issue
Block a user