Add paged chat history loading

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 15:46:08 +02:00
parent 5430a44561
commit eb59f1eb00
42 changed files with 1894 additions and 333 deletions
+135 -61
View File
@@ -7,15 +7,18 @@ import {
ModelRegistry,
SessionManager,
type AgentSession,
type AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
} from "@mariozechner/pi-coding-agent";
import type { ClientCommand, ClientCommandResult, ClientSession, ClientSessionStatus } from "../types.js";
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionStatus } from "../types.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js";
import type { ActiveSession } from "./sessionRuntimeStore.js";
function noop(): void {
// Intentionally empty default unsubscribe callback.
}
export class PiSessionService {
private readonly active = new Map<string, ActiveSession>();
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
@@ -26,12 +29,15 @@ export class PiSessionService {
private readonly modelRegistry = ModelRegistry.create(this.authStorage);
private readonly createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage: this.authStorage, modelRegistry: this.modelRegistry });
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent });
const options = sessionStartEvent === undefined
? { services, sessionManager }
: { services, sessionManager, sessionStartEvent };
const result = await createAgentSessionFromServices(options);
return { ...result, services, diagnostics: services.diagnostics };
};
constructor(private readonly events: SessionEventHub) {
this.heartbeat = setInterval(() => this.publishHeartbeats(), 2000);
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, 2000);
this.commandService = new SessionCommandService(
(sessionId) => this.getActive(sessionId),
(sessionId, text) => this.prompt(sessionId, text),
@@ -45,7 +51,7 @@ export class PiSessionService {
id: s.id,
path: s.path,
cwd: s.cwd,
name: s.name,
...(s.name === undefined ? {} : { name: s.name }),
created: s.created.toISOString(),
modified: s.modified.toISOString(),
messageCount: s.messageCount,
@@ -67,9 +73,15 @@ export class PiSessionService {
};
}
async messages(sessionId: string): Promise<unknown[]> {
async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
const session = await this.getOrOpen(sessionId);
return session.messages;
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 };
}
async status(sessionId: string): Promise<ClientSessionStatus> {
@@ -80,7 +92,7 @@ export class PiSessionService {
const session = await this.getOrOpen(sessionId);
const commands: ClientCommand[] = [...BUILTIN_COMMANDS];
for (const command of session.extensionRunner.getRegisteredCommands()) {
commands.push({ name: command.invocationName, description: command.description, source: "extension" });
commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
}
for (const template of session.promptTemplates) {
commands.push({ name: template.name, description: template.description, source: "prompt" });
@@ -94,7 +106,7 @@ export class PiSessionService {
async prompt(sessionId: string, text: string): Promise<void> {
const session = await this.getOrOpen(sessionId);
this.publishActivity(session, "prompt accepted", "active");
void session.prompt(text).catch((error) => {
void session.prompt(text).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "error", "error", message);
this.events.publish(sessionId, { type: "session.error", message });
@@ -126,7 +138,7 @@ export class PiSessionService {
});
this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command);
this.publishStatus(session);
}).catch((error) => {
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true });
this.events.publish(session.sessionId, { type: "session.error", message });
@@ -172,9 +184,12 @@ export class PiSessionService {
private async create(sessionManager: SessionManager, cwd: string): Promise<ActiveSession> {
const runtime = await createAgentSessionRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
const active: ActiveSession = { runtime, unsubscribe: () => {} };
const active: ActiveSession = { runtime, unsubscribe: noop };
this.bindRuntime(active);
runtime.setRebindSession(async () => this.bindRuntime(active));
runtime.setRebindSession(() => {
this.bindRuntime(active);
return Promise.resolve();
});
this.active.set(runtime.session.sessionId, active);
this.publishStatus(runtime.session);
return active;
@@ -214,9 +229,11 @@ export class PiSessionService {
return "active";
}
private publishActivityForEvent(session: AgentSession, event: any): void {
if (event.type === "agent_start") return this.publishActivity(session, "agent running", "active");
if (event.type === "agent_end") {
private publishActivityForEvent(session: AgentSession, event: unknown): void {
const eventType = getString(event, "type");
if (eventType === undefined) return;
if (eventType === "agent_start") { this.publishActivity(session, "agent running", "active"); return; }
if (eventType === "agent_end") {
this.publishActivity(session, "idle", "idle");
setTimeout(() => {
this.publishActivity(session, "idle", "idle");
@@ -224,21 +241,26 @@ export class PiSessionService {
}, 250);
return;
}
if (event.type === "turn_end") return this.publishActivity(session, "turn complete", "active");
if (event.type === "message_start") return this.publishActivity(session, "message started", "active");
if (event.type === "message_end") return this.publishActivity(session, "message complete", "idle");
if (event.type === "message_update") return this.publishActivity(session, "receiving response", "active");
if (event.type === "tool_execution_start") return this.publishActivity(session, "running tool", "active", event.toolName);
if (event.type === "tool_execution_end") return this.publishActivity(session, event.isError ? "tool failed" : "tool complete", event.isError ? "error" : "active", event.toolName);
if (event.type === "bash_execution_start") return this.publishActivity(session, "running bash", "active");
if (event.type === "bash_execution_end") return this.publishActivity(session, "bash complete", "active");
this.publishActivity(session, event.type.replaceAll("_", " "), "active");
if (eventType === "turn_end") { this.publishActivity(session, "turn complete", "active"); return; }
if (eventType === "message_start") { this.publishActivity(session, "message started", "active"); return; }
if (eventType === "message_end") { this.publishActivity(session, "message complete", "idle"); return; }
if (eventType === "message_update") { this.publishActivity(session, "receiving response", "active"); return; }
if (eventType === "tool_execution_start") { this.publishActivity(session, "running tool", "active", getString(event, "toolName")); return; }
if (eventType === "tool_execution_end") {
const isError = getBoolean(event, "isError") === true;
this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "active", getString(event, "toolName"));
return;
}
if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; }
if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "active"); return; }
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
}
private publishActivity(session: AgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
const at = new Date().toISOString();
this.activities.set(session.sessionId, { phase, label, detail, at });
const activity = { sessionId: session.sessionId, phase, label, detail, at };
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
this.activities.set(session.sessionId, stored);
const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
this.events.publish(session.sessionId, { type: "activity.update", activity });
this.events.publishGlobal({ type: "activity.update", activity });
}
@@ -251,17 +273,23 @@ export class PiSessionService {
private statusFromSession(session: AgentSession): ClientSessionStatus {
const stats = session.getSessionStats();
return {
sessionId: session.sessionId,
model: session.model
? {
const model = session.model === undefined
? undefined
: (() => {
const name = getString(session.model, "name");
const reasoning = getProperty(session.model, "reasoning");
return {
provider: session.model.provider,
id: session.model.id,
name: (session.model as any).name,
...(name === undefined ? {} : { name }),
contextWindow: session.model.contextWindow,
reasoning: (session.model as any).reasoning,
}
: undefined,
...(reasoning === undefined ? {} : { reasoning }),
};
})();
const contextUsage = session.getContextUsage();
return {
sessionId: session.sessionId,
...(model === undefined ? {} : { model }),
thinkingLevel: session.thinkingLevel,
isStreaming: session.isStreaming,
isCompacting: session.isCompacting,
@@ -269,33 +297,54 @@ export class PiSessionService {
pendingMessageCount: session.pendingMessageCount,
tokens: stats.tokens,
cost: stats.cost,
contextUsage: session.getContextUsage(),
...(contextUsage === undefined ? {} : { contextUsage }),
};
}
}
function toClientEvent(event: any): unknown {
if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
return { type: "assistant.delta", text: event.assistantMessageEvent.delta };
function historyMessages(session: AgentSession): unknown[] {
const messages: unknown[] = [];
for (const entry of session.sessionManager.getBranch()) {
if (entry.type === "message") messages.push(entry.message);
else if (entry.type === "custom_message" && entry.display) messages.push({ role: "custom", content: entry.content, customType: entry.customType, details: entry.details });
else if (entry.type === "compaction") messages.push({ role: "system", content: `Compacted history:\n\n${entry.summary}` });
else if (entry.type === "branch_summary") messages.push({ role: "system", content: `Branch summary:\n\n${entry.summary}` });
}
if (event.type === "tool_execution_start") {
return { type: "tool.start", toolName: event.toolName, toolCallId: event.toolCallId, summary: summarizeToolArgs(event.args) };
}
if (event.type === "tool_execution_end") {
return { type: "tool.end", toolName: event.toolName, toolCallId: event.toolCallId, text: stringifyToolResult(event.result), isError: event.isError };
}
if (event.type === "agent_start") return { type: "agent.start" };
if (event.type === "agent_end") return { type: "agent.end" };
if (event.type === "message_end") return { type: "message.end" };
return { type: "pi.event", eventType: event.type };
return messages;
}
function summarizeToolArgs(args: any): string {
if (!args || typeof args !== "object") return args == null ? "" : String(args);
if (typeof args.command === "string") return args.command;
if (typeof args.path === "string") return args.path;
if (typeof args.oldText === "string" && typeof args.newText === "string") return "edit text replacement";
if (Array.isArray(args.edits)) return `${args.edits.length} edit${args.edits.length === 1 ? "" : "s"}`;
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): unknown {
const eventType = getString(event, "type");
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
}
if (eventType === "tool_execution_start") {
return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(getProperty(event, "args")) };
}
if (eventType === "tool_execution_end") {
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(getProperty(event, "result")), isError: getBoolean(event, "isError") === true };
}
if (eventType === "agent_start") return { type: "agent.start" };
if (eventType === "agent_end") return { type: "agent.end" };
if (eventType === "message_end") return { type: "message.end" };
return { type: "pi.event", eventType: eventType ?? "unknown" };
}
function summarizeToolArgs(args: unknown): string {
if (!isRecord(args)) return stringifyPrimitive(args);
const command = getString(args, "command");
if (command !== undefined) return command;
const path = getString(args, "path");
if (path !== undefined) return path;
if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement";
const edits = args["edits"];
if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
return entries.map(([key, value]) => `${key}: ${shortToolValue(value)}`).join(" · ");
}
@@ -303,18 +352,43 @@ function summarizeToolArgs(args: any): string {
function shortToolValue(value: unknown): string {
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}` : value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
if (typeof value === "object" && value) return "object";
if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
if (typeof value === "object" && value !== null) return "object";
return "";
}
function stringifyToolResult(result: unknown): string {
if (typeof result === "string") return result;
if (Array.isArray(result)) return result.map(stringifyToolResult).filter(Boolean).join("\n");
if (result && typeof result === "object") {
const text = (result as any).text ?? (result as any).content ?? (result as any).output;
if (typeof text === "string") return text;
if (Array.isArray(result)) return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
if (isRecord(result)) {
const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output");
if (text !== undefined) return text;
return JSON.stringify(result, null, 2);
}
return result == null ? "" : String(result);
return stringifyPrimitive(result);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getProperty(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
function getString(value: unknown, key: string): string | undefined {
const property = getProperty(value, key);
return typeof property === "string" ? property : undefined;
}
function getBoolean(value: unknown, key: string): boolean | undefined {
const property = getProperty(value, key);
return typeof property === "boolean" ? property : undefined;
}
function stringifyPrimitive(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
return "";
}