Fix SessionEntry type to exclude SessionHeader

- SessionEntry now only contains conversation entries (messages, compaction, etc.)
- SessionHeader is separate, not part of SessionEntry
- FileEntry = SessionHeader | SessionEntry (for file storage)
- getEntries() filters out header, returns SessionEntry[]
- Added getHeader() for accessing session metadata
- Updated compaction and tests to not expect header in entries
- Updated mom package to use FileEntry for internal storage
This commit is contained in:
Mario Zechner 2025-12-26 00:31:53 +01:00
parent 251fea752c
commit 9478a3c1f5
6 changed files with 50 additions and 75 deletions

View file

@ -759,7 +759,7 @@ export class AgentSession {
if (lastEntry?.type === "compaction") { if (lastEntry?.type === "compaction") {
throw new Error("Already compacted"); throw new Error("Already compacted");
} }
throw new Error("Nothing to compact (session too small or needs migration)"); throw new Error("Nothing to compact (session too small)");
} }
// Find previous compaction summary if any // Find previous compaction summary if any

View file

@ -9,7 +9,7 @@ import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai";
import { messageTransformer } from "./messages.js"; import { messageTransformer } from "./messages.js";
import type { CompactionEntry, ConversationEntry, SessionEntry } from "./session-manager.js"; import type { CompactionEntry, SessionEntry } from "./session-manager.js";
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */ /** Result from compact() - SessionManager adds uuid/parentUuid when saving */
export interface CompactionResult { export interface CompactionResult {
@ -251,7 +251,7 @@ export function findCutPoint(
while (cutIndex > startIndex) { while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1]; const prevEntry = entries[cutIndex - 1];
// Stop at session header or compaction boundaries // Stop at session header or compaction boundaries
if (prevEntry.type === "session" || prevEntry.type === "compaction") { if (prevEntry.type === "compaction") {
break; break;
} }
if (prevEntry.type === "message") { if (prevEntry.type === "message") {
@ -370,13 +370,10 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS
// Get UUID of first kept entry // Get UUID of first kept entry
const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex]; const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex];
if (firstKeptEntry.type === "session") { if (!firstKeptEntry?.id) {
return null; // Can't compact if first kept is header
}
const firstKeptEntryId = (firstKeptEntry as ConversationEntry).id;
if (!firstKeptEntryId) {
return null; // Session needs migration return null; // Session needs migration
} }
const firstKeptEntryId = firstKeptEntry.id;
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
@ -515,10 +512,7 @@ export async function compact(
// Get UUID of first kept entry // Get UUID of first kept entry
const firstKeptEntry = entries[cutResult.firstKeptEntryIndex]; const firstKeptEntry = entries[cutResult.firstKeptEntryIndex];
if (firstKeptEntry.type === "session") { const firstKeptEntryId = firstKeptEntry.id;
throw new Error("Cannot compact: first kept entry is session header");
}
const firstKeptEntryId = (firstKeptEntry as ConversationEntry).id;
if (!firstKeptEntryId) { if (!firstKeptEntryId) {
throw new Error("First kept entry has no UUID - session may need migration"); throw new Error("First kept entry has no UUID - session may need migration");
} }

View file

@ -87,16 +87,19 @@ export type ModelChangeEntry = TreeNode & ModelChangeContent;
export type CompactionEntry = TreeNode & CompactionContent; export type CompactionEntry = TreeNode & CompactionContent;
export type BranchSummaryEntry = TreeNode & BranchSummaryContent; export type BranchSummaryEntry = TreeNode & BranchSummaryContent;
/** Conversation entry - has id/parentId for tree structure */ /** Session entry - has id/parentId for tree structure */
export type ConversationEntry = export type SessionEntry =
| SessionMessageEntry | SessionMessageEntry
| ThinkingLevelChangeEntry | ThinkingLevelChangeEntry
| ModelChangeEntry | ModelChangeEntry
| CompactionEntry | CompactionEntry
| BranchSummaryEntry; | BranchSummaryEntry;
/** Any session entry (header or conversation) */ /** @deprecated Use SessionEntry */
export type SessionEntry = SessionHeader | ConversationEntry; export type ConversationEntry = SessionEntry;
/** Raw file entry (includes header) */
export type FileEntry = SessionHeader | SessionEntry;
export interface SessionContext { export interface SessionContext {
messages: AppMessage[]; messages: AppMessage[];
@ -135,7 +138,7 @@ export function createSummaryMessage(summary: string): AppMessage {
* Migrate v1 entries to v2 format by adding id/parentId fields. * Migrate v1 entries to v2 format by adding id/parentId fields.
* Mutates entries in place. Safe to call on already-migrated entries. * Mutates entries in place. Safe to call on already-migrated entries.
*/ */
export function migrateSessionEntries(entries: SessionEntry[]): void { export function migrateSessionEntries(entries: FileEntry[]): void {
// Check if already migrated // Check if already migrated
const firstConv = entries.find((e) => e.type !== "session"); const firstConv = entries.find((e) => e.type !== "session");
if (firstConv && "id" in firstConv && firstConv.id) { if (firstConv && "id" in firstConv && firstConv.id) {
@ -171,7 +174,7 @@ export function migrateSessionEntries(entries: SessionEntry[]): void {
} }
/** Exported for compaction.test.ts */ /** Exported for compaction.test.ts */
export function parseSessionEntries(content: string): SessionEntry[] { export function parseSessionEntries(content: string): FileEntry[] {
const entries: SessionEntry[] = []; const entries: SessionEntry[] = [];
const lines = content.trim().split("\n"); const lines = content.trim().split("\n");
@ -203,26 +206,18 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
* Handles compaction and branch summaries along the path. * Handles compaction and branch summaries along the path.
*/ */
export function buildSessionContext(entries: SessionEntry[], leafId?: string): SessionContext { export function buildSessionContext(entries: SessionEntry[], leafId?: string): SessionContext {
// Build uuid index for conversation entries // Build uuid index
const byId = new Map<string, ConversationEntry>(); const byId = new Map<string, SessionEntry>();
for (const entry of entries) { for (const entry of entries) {
if (entry.type !== "session") { byId.set(entry.id, entry);
byId.set(entry.id, entry);
}
} }
// Find leaf // Find leaf
let leaf: ConversationEntry | undefined; let leaf: SessionEntry | undefined;
if (leafId) { if (leafId) {
leaf = byId.get(leafId); leaf = byId.get(leafId);
} else { } else {
// Find last conversation entry leaf = entries[entries.length - 1];
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type !== "session") {
leaf = entries[i] as ConversationEntry;
break;
}
}
} }
if (!leaf) { if (!leaf) {
@ -230,8 +225,8 @@ export function buildSessionContext(entries: SessionEntry[], leafId?: string): S
} }
// Walk from leaf to root, collecting path // Walk from leaf to root, collecting path
const path: ConversationEntry[] = []; const path: SessionEntry[] = [];
let current: ConversationEntry | undefined = leaf; let current: SessionEntry | undefined = leaf;
while (current) { while (current) {
path.unshift(current); path.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined; current = current.parentId ? byId.get(current.parentId) : undefined;
@ -316,7 +311,7 @@ function getDefaultSessionDir(cwd: string): string {
return sessionDir; return sessionDir;
} }
function loadEntriesFromFile(filePath: string): SessionEntry[] { function loadEntriesFromFile(filePath: string): FileEntry[] {
if (!existsSync(filePath)) return []; if (!existsSync(filePath)) return [];
const content = readFileSync(filePath, "utf8"); const content = readFileSync(filePath, "utf8");
@ -359,7 +354,7 @@ export class SessionManager {
private cwd: string; private cwd: string;
private persist: boolean; private persist: boolean;
private flushed: boolean = false; private flushed: boolean = false;
private inMemoryEntries: SessionEntry[] = []; private inMemoryEntries: FileEntry[] = [];
// Tree structure (v2) // Tree structure (v2)
private byId: Map<string, ConversationEntry> = new Map(); private byId: Map<string, ConversationEntry> = new Map();
@ -570,11 +565,19 @@ export class SessionManager {
} }
/** /**
* Get all session entries. Returns a defensive copy. * Get session header.
*/
getHeader(): SessionHeader | null {
const h = this.inMemoryEntries.find((e) => e.type === "session");
return h ? (h as SessionHeader) : null;
}
/**
* Get all session entries (excludes header). Returns a defensive copy.
* Use buildSessionContext() if you need the messages for the LLM. * Use buildSessionContext() if you need the messages for the LLM.
*/ */
getEntries(): SessionEntry[] { getEntries(): SessionEntry[] {
return [...this.inMemoryEntries]; return this.inMemoryEntries.filter((e): e is SessionEntry => e.type !== "session");
} }
// ========================================================================= // =========================================================================
@ -606,12 +609,12 @@ export class SessionManager {
return entry.id; return entry.id;
} }
createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null { createBranchedSessionFromEntries(entries: FileEntry[], branchBeforeIndex: number): string | null {
const newSessionId = uuidv4(); const newSessionId = uuidv4();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const newSessionFile = join(this.getSessionDir(), `${timestamp}_${newSessionId}.jsonl`); const newSessionFile = join(this.getSessionDir(), `${timestamp}_${newSessionId}.jsonl`);
const newEntries: SessionEntry[] = []; const newEntries: FileEntry[] = [];
for (let i = 0; i < branchBeforeIndex; i++) { for (let i = 0; i < branchBeforeIndex; i++) {
const entry = entries[i]; const entry = entries[i];

View file

@ -116,6 +116,7 @@ export {
type ConversationEntry, type ConversationEntry,
CURRENT_SESSION_VERSION, CURRENT_SESSION_VERSION,
createSummaryMessage, createSummaryMessage,
type FileEntry,
getLatestCompactionEntry, getLatestCompactionEntry,
type MessageContent, type MessageContent,
type ModelChangeContent, type ModelChangeContent,

View file

@ -34,7 +34,7 @@ function loadLargeSessionEntries(): SessionEntry[] {
const content = readFileSync(sessionPath, "utf-8"); const content = readFileSync(sessionPath, "utf-8");
const entries = parseSessionEntries(content); const entries = parseSessionEntries(content);
migrateSessionEntries(entries); // Add id/parentId for v1 fixtures migrateSessionEntries(entries); // Add id/parentId for v1 fixtures
return entries; return entries.filter((e): e is SessionEntry => e.type !== "session");
} }
function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage { function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage {
@ -78,16 +78,6 @@ beforeEach(() => {
resetEntryCounter(); resetEntryCounter();
}); });
function createSessionHeader() {
return {
type: "session" as const,
version: 2,
id: "test-session",
timestamp: "",
cwd: "",
};
}
function createMessageEntry(message: AppMessage): SessionMessageEntry { function createMessageEntry(message: AppMessage): SessionMessageEntry {
const id = `test-id-${entryCounter++}`; const id = `test-id-${entryCounter++}`;
const entry: SessionMessageEntry = { const entry: SessionMessageEntry = {
@ -298,12 +288,6 @@ describe("createSummaryMessage", () => {
describe("buildSessionContext", () => { describe("buildSessionContext", () => {
it("should load all messages when no compaction", () => { it("should load all messages when no compaction", () => {
const entries: SessionEntry[] = [ const entries: SessionEntry[] = [
{
type: "session",
id: "1",
timestamp: "",
cwd: "",
},
createMessageEntry(createUserMessage("1")), createMessageEntry(createUserMessage("1")),
createMessageEntry(createAssistantMessage("a")), createMessageEntry(createAssistantMessage("a")),
createMessageEntry(createUserMessage("2")), createMessageEntry(createUserMessage("2")),
@ -326,7 +310,7 @@ describe("buildSessionContext", () => {
const u3 = createMessageEntry(createUserMessage("3")); const u3 = createMessageEntry(createUserMessage("3"));
const a3 = createMessageEntry(createAssistantMessage("c")); const a3 = createMessageEntry(createAssistantMessage("c"));
const entries: SessionEntry[] = [createSessionHeader(), u1, a1, u2, a2, compaction, u3, a3]; const entries: SessionEntry[] = [u1, a1, u2, a2, compaction, u3, a3];
const loaded = buildSessionContext(entries); const loaded = buildSessionContext(entries);
// summary + kept (u2, a2) + after (u3, a3) = 5 // summary + kept (u2, a2) + after (u3, a3) = 5
@ -350,7 +334,7 @@ describe("buildSessionContext", () => {
const u4 = createMessageEntry(createUserMessage("4")); const u4 = createMessageEntry(createUserMessage("4"));
const d = createMessageEntry(createAssistantMessage("d")); const d = createMessageEntry(createAssistantMessage("d"));
const entries: SessionEntry[] = [createSessionHeader(), u1, a1, compact1, u2, b, u3, c, compact2, u4, d]; const entries: SessionEntry[] = [u1, a1, compact1, u2, b, u3, c, compact2, u4, d];
const loaded = buildSessionContext(entries); const loaded = buildSessionContext(entries);
// summary + kept from u3 (u3, c) + after (u4, d) = 5 // summary + kept from u3 (u3, c) + after (u4, d) = 5
@ -365,7 +349,7 @@ describe("buildSessionContext", () => {
const u2 = createMessageEntry(createUserMessage("2")); const u2 = createMessageEntry(createUserMessage("2"));
const b = createMessageEntry(createAssistantMessage("b")); const b = createMessageEntry(createAssistantMessage("b"));
const entries: SessionEntry[] = [createSessionHeader(), u1, a1, compact1, u2, b]; const entries: SessionEntry[] = [u1, a1, compact1, u2, b];
const loaded = buildSessionContext(entries); const loaded = buildSessionContext(entries);
// summary + all messages (u1, a1, u2, b) = 5 // summary + all messages (u1, a1, u2, b) = 5
@ -374,12 +358,6 @@ describe("buildSessionContext", () => {
it("should track model and thinking level changes", () => { it("should track model and thinking level changes", () => {
const entries: SessionEntry[] = [ const entries: SessionEntry[] = [
{
type: "session",
id: "1",
timestamp: "",
cwd: "",
},
createMessageEntry(createUserMessage("1")), createMessageEntry(createUserMessage("1")),
createModelChangeEntry("openai", "gpt-4"), createModelChangeEntry("openai", "gpt-4"),
createMessageEntry(createAssistantMessage("a")), createMessageEntry(createAssistantMessage("a")),
@ -466,7 +444,7 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => {
// Simulate appending compaction to entries by creating a proper entry // Simulate appending compaction to entries by creating a proper entry
const lastEntry = entries[entries.length - 1]; const lastEntry = entries[entries.length - 1];
const parentId = lastEntry.type === "session" ? null : lastEntry.id; const parentId = lastEntry.id;
const compactionEntry: CompactionEntry = { const compactionEntry: CompactionEntry = {
type: "compaction", type: "compaction",
id: "compaction-test-id", id: "compaction-test-id",

View file

@ -14,6 +14,7 @@ import type { AppMessage } from "@mariozechner/pi-agent-core";
import { import {
buildSessionContext, buildSessionContext,
type CompactionEntry, type CompactionEntry,
type FileEntry,
type LoadedSession, type LoadedSession,
type MessageContent, type MessageContent,
type ModelChangeContent, type ModelChangeContent,
@ -52,7 +53,7 @@ export class MomSessionManager {
private logFile: string; private logFile: string;
private channelDir: string; private channelDir: string;
private flushed: boolean = false; private flushed: boolean = false;
private inMemoryEntries: SessionEntry[] = []; private inMemoryEntries: FileEntry[] = [];
private leafId: string | null = null; private leafId: string | null = null;
constructor(channelDir: string) { constructor(channelDir: string) {
@ -259,17 +260,17 @@ export class MomSessionManager {
return null; return null;
} }
private loadEntriesFromFile(): SessionEntry[] { private loadEntriesFromFile(): FileEntry[] {
if (!existsSync(this.contextFile)) return []; if (!existsSync(this.contextFile)) return [];
const content = readFileSync(this.contextFile, "utf8"); const content = readFileSync(this.contextFile, "utf8");
const entries: SessionEntry[] = []; const entries: FileEntry[] = [];
const lines = content.trim().split("\n"); const lines = content.trim().split("\n");
for (const line of lines) { for (const line of lines) {
if (!line.trim()) continue; if (!line.trim()) continue;
try { try {
const entry = JSON.parse(line) as SessionEntry; const entry = JSON.parse(line) as FileEntry;
entries.push(entry); entries.push(entry);
} catch { } catch {
// Skip malformed lines // Skip malformed lines
@ -313,10 +314,8 @@ export class MomSessionManager {
loadEntries(): SessionEntry[] { loadEntries(): SessionEntry[] {
// Re-read from file to get latest state // Re-read from file to get latest state
if (existsSync(this.contextFile)) { const entries = existsSync(this.contextFile) ? this.loadEntriesFromFile() : this.inMemoryEntries;
return this.loadEntriesFromFile(); return entries.filter((e): e is SessionEntry => e.type !== "session");
}
return [...this.inMemoryEntries];
} }
getSessionId(): string { getSessionId(): string {