Refactor SessionEventBase to pass sessionManager and modelRegistry

Breaking changes to hook types:
- SessionEventBase now passes sessionManager and modelRegistry directly
- before_compact: passes preparation, previousCompactions (newest first)
- before_switch: has targetSessionFile; switch: has previousSessionFile
- Removed resolveApiKey (use modelRegistry.getApiKey())
- getSessionFile() returns string | undefined for in-memory sessions

Updated:
- All session event emissions in agent-session.ts
- Hook examples (custom-compaction.ts, auto-commit-on-exit.ts, confirm-destructive.ts)
- Tests (compaction-hooks.test.ts, compaction-hooks-example.test.ts)
- export-html.ts guards for in-memory sessions
This commit is contained in:
Mario Zechner 2025-12-26 22:22:43 +01:00
parent d96375b5e5
commit 9bba388ec5
14 changed files with 145 additions and 177 deletions

View file

@ -395,7 +395,7 @@ export class AgentSession {
/** Current session file path, or null if sessions are disabled */
get sessionFile(): string | null {
return this.sessionManager.isPersisted() ? this.sessionManager.getSessionFile() : null;
return this.sessionManager.getSessionFile() ?? null;
}
/** Current session ID */
@ -515,15 +515,13 @@ export class AgentSession {
*/
async reset(): Promise<boolean> {
const previousSessionFile = this.sessionFile;
const entries = this.sessionManager.getEntries();
// Emit before_new event (can be cancelled)
if (this._hookRunner?.hasHandlers("session")) {
const result = (await this._hookRunner.emit({
type: "session",
entries,
sessionFile: this.sessionFile,
previousSessionFile: null,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "before_new",
})) as SessionEventResult | undefined;
@ -544,9 +542,8 @@ export class AgentSession {
this._hookRunner.setSessionFile(this.sessionFile);
await this._hookRunner.emit({
type: "session",
entries: [],
sessionFile: this.sessionFile,
previousSessionFile,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "new",
});
}
@ -762,34 +759,22 @@ export class AgentSession {
throw new Error("Nothing to compact (session too small)");
}
// Find previous compaction summary if any
let previousSummary: string | undefined;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
previousSummary = (entries[i] as CompactionEntry).summary;
break;
}
}
let hookCompaction: CompactionResult | undefined;
let fromHook = false;
if (this._hookRunner?.hasHandlers("session")) {
// Get previous compactions, newest first
const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse();
const result = (await this._hookRunner.emit({
type: "session",
entries,
sessionFile: this.sessionFile,
previousSessionFile: null,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "before_compact",
cutPoint: preparation.cutPoint,
firstKeptEntryId: preparation.firstKeptEntryId,
previousSummary,
messagesToSummarize: [...preparation.messagesToSummarize],
messagesToKeep: [...preparation.messagesToKeep],
tokensBefore: preparation.tokensBefore,
preparation,
previousCompactions,
customInstructions,
model: this.model,
resolveApiKey: async (m: Model<any>) => (await this._modelRegistry.getApiKey(m)) ?? undefined,
signal: this._compactionAbortController.signal,
})) as SessionEventResult | undefined;
@ -847,12 +832,10 @@ export class AgentSession {
if (this._hookRunner && savedCompactionEntry) {
await this._hookRunner.emit({
type: "session",
entries: newEntries,
sessionFile: this.sessionFile,
previousSessionFile: null,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "compact",
compactionEntry: savedCompactionEntry,
tokensBefore,
fromHook,
});
}
@ -948,34 +931,22 @@ export class AgentSession {
return;
}
// Find previous compaction summary if any
let previousSummary: string | undefined;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
previousSummary = (entries[i] as CompactionEntry).summary;
break;
}
}
let hookCompaction: CompactionResult | undefined;
let fromHook = false;
if (this._hookRunner?.hasHandlers("session")) {
// Get previous compactions, newest first
const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse();
const hookResult = (await this._hookRunner.emit({
type: "session",
entries,
sessionFile: this.sessionFile,
previousSessionFile: null,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "before_compact",
cutPoint: preparation.cutPoint,
firstKeptEntryId: preparation.firstKeptEntryId,
previousSummary,
messagesToSummarize: [...preparation.messagesToSummarize],
messagesToKeep: [...preparation.messagesToKeep],
tokensBefore: preparation.tokensBefore,
preparation,
previousCompactions,
customInstructions: undefined,
model: this.model,
resolveApiKey: async (m: Model<any>) => (await this._modelRegistry.getApiKey(m)) ?? undefined,
signal: this._autoCompactionAbortController.signal,
})) as SessionEventResult | undefined;
@ -1034,12 +1005,10 @@ export class AgentSession {
if (this._hookRunner && savedCompactionEntry) {
await this._hookRunner.emit({
type: "session",
entries: newEntries,
sessionFile: this.sessionFile,
previousSessionFile: null,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "compact",
compactionEntry: savedCompactionEntry,
tokensBefore,
fromHook,
});
}
@ -1337,16 +1306,15 @@ export class AgentSession {
*/
async switchSession(sessionPath: string): Promise<boolean> {
const previousSessionFile = this.sessionFile;
const oldEntries = this.sessionManager.getEntries();
// Emit before_switch event (can be cancelled)
if (this._hookRunner?.hasHandlers("session")) {
const result = (await this._hookRunner.emit({
type: "session",
entries: oldEntries,
sessionFile: this.sessionFile,
previousSessionFile: null,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "before_switch",
targetSessionFile: sessionPath,
})) as SessionEventResult | undefined;
if (result?.cancel) {
@ -1362,7 +1330,6 @@ export class AgentSession {
this.sessionManager.setSessionFile(sessionPath);
// Reload messages
const entries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
// Emit session event to hooks
@ -1370,10 +1337,10 @@ export class AgentSession {
this._hookRunner.setSessionFile(sessionPath);
await this._hookRunner.emit({
type: "session",
entries,
sessionFile: sessionPath,
previousSessionFile,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "switch",
previousSessionFile,
});
}
@ -1428,9 +1395,8 @@ export class AgentSession {
if (this._hookRunner?.hasHandlers("session")) {
const result = (await this._hookRunner.emit({
type: "session",
entries,
sessionFile: this.sessionFile,
previousSessionFile: null,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "before_branch",
targetTurnIndex: entryIndex,
})) as SessionEventResult | undefined;
@ -1454,7 +1420,6 @@ export class AgentSession {
}
// Reload messages from entries (works for both file and in-memory mode)
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
// Emit branch event to hooks (after branch completes)
@ -1462,9 +1427,8 @@ export class AgentSession {
this._hookRunner.setSessionFile(newSessionFile);
await this._hookRunner.emit({
type: "session",
entries: newEntries,
sessionFile: newSessionFile,
previousSessionFile,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
reason: "branch",
targetTurnIndex: entryIndex,
});

View file

@ -1343,6 +1343,9 @@ export function exportSessionToHtml(
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const sessionFile = sessionManager.getSessionFile();
if (!sessionFile) {
throw new Error("Cannot export in-memory session to HTML");
}
const content = readFileSync(sessionFile, "utf8");
const data = parseSessionFile(content);

View file

@ -7,8 +7,9 @@
import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { CompactionResult, CutPointResult } from "../compaction.js";
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
import type { CompactionPreparation, CompactionResult } from "../compaction.js";
import type { ModelRegistry } from "../model-registry.js";
import type { CompactionEntry, SessionManager } from "../session-manager.js";
import type {
BashToolDetails,
FindToolDetails,
@ -95,12 +96,10 @@ export interface HookEventContext {
*/
interface SessionEventBase {
type: "session";
/** All session entries (including pre-compaction history) */
entries: SessionEntry[];
/** Current session file path, or null in --no-session mode */
sessionFile: string | null;
/** Previous session file path, or null for "start" and "new" */
previousSessionFile: string | null;
/** Session manager instance - use for entries, session file, etc. */
sessionManager: SessionManager;
/** Model registry - use for API key resolution */
modelRegistry: ModelRegistry;
}
/**
@ -120,7 +119,17 @@ interface SessionEventBase {
*/
export type SessionEvent =
| (SessionEventBase & {
reason: "start" | "switch" | "new" | "before_switch" | "before_new" | "shutdown";
reason: "start" | "new" | "before_new" | "shutdown";
})
| (SessionEventBase & {
reason: "before_switch";
/** Session file we're switching to */
targetSessionFile: string;
})
| (SessionEventBase & {
reason: "switch";
/** Session file we came from */
previousSessionFile: string | null;
})
| (SessionEventBase & {
reason: "branch" | "before_branch";
@ -129,27 +138,20 @@ export type SessionEvent =
})
| (SessionEventBase & {
reason: "before_compact";
cutPoint: CutPointResult;
/** ID of first entry to keep (for hooks that return CompactionEntry) */
firstKeptEntryId: string;
/** Summary from previous compaction, if any. Include this in your summary to preserve context. */
previousSummary?: string;
/** Messages that will be summarized and discarded */
messagesToSummarize: AppMessage[];
/** Messages that will be kept after the summary (recent turns) */
messagesToKeep: AppMessage[];
tokensBefore: number;
/** Compaction preparation with cut point, messages to summarize/keep, etc. */
preparation: CompactionPreparation;
/** Previous compaction entries, newest first. Use for iterative summarization. */
previousCompactions: CompactionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Current model */
model: Model<any>;
/** Resolve API key for any model (checks settings, OAuth, env vars) */
resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
})
| (SessionEventBase & {
reason: "compact";
compactionEntry: CompactionEntry;
tokensBefore: number;
/** Whether the compaction entry was provided by a hook */
fromHook: boolean;
});

View file

@ -421,7 +421,7 @@ export function findMostRecentSession(sessionDir: string): string | null {
*/
export class SessionManager {
private sessionId: string = "";
private sessionFile: string = "";
private sessionFile: string | undefined;
private sessionDir: string;
private cwd: string;
private persist: boolean;
@ -434,10 +434,10 @@ export class SessionManager {
private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) {
this.cwd = cwd;
this.sessionDir = sessionDir;
this.persist = persist;
if (persist && sessionDir && !existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
this.persist = persist;
if (sessionFile) {
this.setSessionFile(sessionFile);
@ -479,8 +479,8 @@ export class SessionManager {
this.byId.clear();
this.leafId = "";
this.flushed = false;
// Only generate filename if not already set (e.g., via --session flag)
if (!this.sessionFile) {
// Only generate filename if persisting and not already set (e.g., via --session flag)
if (this.persist && !this.sessionFile) {
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
}
@ -505,7 +505,7 @@ export class SessionManager {
}
private _rewriteFile(): void {
if (!this.persist) return;
if (!this.persist || !this.sessionFile) return;
const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
writeFileSync(this.sessionFile, content);
}
@ -526,12 +526,12 @@ export class SessionManager {
return this.sessionId;
}
getSessionFile(): string {
getSessionFile(): string | undefined {
return this.sessionFile;
}
_persist(entry: SessionEntry): void {
if (!this.persist) return;
if (!this.persist || !this.sessionFile) return;
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
if (!hasAssistant) return;