mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 14:03:49 +00:00
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:
parent
d96375b5e5
commit
9bba388ec5
14 changed files with 145 additions and 177 deletions
|
|
@ -21,9 +21,13 @@
|
|||
- `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId`
|
||||
- `prepareCompaction()` now returns `firstKeptEntryId` in its result
|
||||
- **Hook types**:
|
||||
- `SessionEventResult.compactionEntry` replaced with `SessionEventResult.compaction` (content only, SessionManager adds id/parentId)
|
||||
- `before_compact` event now includes `firstKeptEntryId` field for hooks that return custom compaction
|
||||
- `SessionEventBase` now passes `sessionManager` and `modelRegistry` instead of `entries`, `sessionFile`, `previousSessionFile`
|
||||
- `before_compact` event passes `preparation: CompactionPreparation` and `previousCompactions: CompactionEntry[]` (newest first)
|
||||
- `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile`
|
||||
- Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`)
|
||||
- Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction)
|
||||
- **SessionManager**:
|
||||
- `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions)
|
||||
|
||||
### Added
|
||||
|
||||
|
|
|
|||
|
|
@ -59,15 +59,17 @@ Reference: [session-tree.md](./session-tree.md)
|
|||
### Compaction Refactor
|
||||
|
||||
- [x] Use `CompactionResult` type for hook return value
|
||||
- [ ] Make `CompactionEntry<T>` generic with optional `details?: T` field for hook-specific data
|
||||
- [ ] Make `CompactionResult<T>` generic to match
|
||||
- [ ] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields
|
||||
- [ ] Update `before_compact` event:
|
||||
- [x] Make `CompactionEntry<T>` generic with optional `details?: T` field for hook-specific data
|
||||
- [x] Make `CompactionResult<T>` generic to match
|
||||
- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields
|
||||
- [x] Update `before_compact` event:
|
||||
- Pass `preparation: CompactionPreparation` instead of individual fields
|
||||
- Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string`
|
||||
- Keep: `customInstructions`, `model`, `signal`
|
||||
- Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries`
|
||||
- [ ] Update hook example `custom-compaction.ts` to use new API
|
||||
- [x] Update hook example `custom-compaction.ts` to use new API
|
||||
- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions
|
||||
- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile`
|
||||
|
||||
Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ export default function (pi: HookAPI) {
|
|||
}
|
||||
|
||||
// Find the last assistant message for commit context
|
||||
const entries = event.sessionManager.getEntries();
|
||||
let lastAssistantText = "";
|
||||
for (let i = event.entries.length - 1; i >= 0; i--) {
|
||||
const entry = event.entries[i];
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const content = entry.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* Demonstrates how to cancel session events using the before_* variants.
|
||||
*/
|
||||
|
||||
import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
|
|
@ -28,7 +29,10 @@ export default function (pi: HookAPI) {
|
|||
if (!ctx.hasUI) return;
|
||||
|
||||
// Check if there are unsaved changes (messages since last assistant response)
|
||||
const hasUnsavedWork = event.entries.some((e) => e.type === "message" && e.message.role === "user");
|
||||
const entries = event.sessionManager.getEntries();
|
||||
const hasUnsavedWork = entries.some(
|
||||
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
|
||||
);
|
||||
|
||||
if (hasUnsavedWork) {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
|
|
|
|||
|
|
@ -23,15 +23,11 @@ export default function (pi: HookAPI) {
|
|||
|
||||
ctx.ui.notify("Custom compaction hook triggered", "info");
|
||||
|
||||
const {
|
||||
messagesToSummarize,
|
||||
messagesToKeep,
|
||||
previousSummary,
|
||||
tokensBefore,
|
||||
resolveApiKey,
|
||||
entries: _,
|
||||
signal,
|
||||
} = event;
|
||||
const { preparation, previousCompactions, modelRegistry, signal } = event;
|
||||
const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId } = preparation;
|
||||
|
||||
// Get previous summary from most recent compaction (if any)
|
||||
const previousSummary = previousCompactions[0]?.summary;
|
||||
|
||||
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
|
|
@ -41,7 +37,7 @@ export default function (pi: HookAPI) {
|
|||
}
|
||||
|
||||
// Resolve API key for the summarization model
|
||||
const apiKey = await resolveApiKey(model);
|
||||
const apiKey = await modelRegistry.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
|
||||
return;
|
||||
|
|
@ -102,11 +98,11 @@ Format the summary as structured markdown with clear sections.`,
|
|||
}
|
||||
|
||||
// Return compaction content - SessionManager adds id/parentId
|
||||
// Use firstKeptEntryId from event to keep recent messages
|
||||
// Use firstKeptEntryId from preparation to keep recent messages
|
||||
return {
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryId: event.firstKeptEntryId,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -384,9 +384,8 @@ export class InteractiveMode {
|
|||
// Emit session event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: this.session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
sessionManager: this.session.sessionManager,
|
||||
modelRegistry: this.session.modelRegistry,
|
||||
reason: "start",
|
||||
});
|
||||
}
|
||||
|
|
@ -1156,12 +1155,10 @@ export class InteractiveMode {
|
|||
// Emit shutdown event to hooks
|
||||
const hookRunner = this.session.hookRunner;
|
||||
if (hookRunner?.hasHandlers("session")) {
|
||||
const entries = this.sessionManager.getEntries();
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: this.session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
sessionManager: this.session.sessionManager,
|
||||
modelRegistry: this.session.modelRegistry,
|
||||
reason: "shutdown",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,9 +46,8 @@ export async function runPrintMode(
|
|||
// Emit session event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
sessionManager: session.sessionManager,
|
||||
modelRegistry: session.modelRegistry,
|
||||
reason: "start",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,9 +145,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
// Emit session event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
sessionManager: session.sessionManager,
|
||||
modelRegistry: session.modelRegistry,
|
||||
reason: "start",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,24 +13,23 @@ describe("Documentation example", () => {
|
|||
if (event.reason !== "before_compact") return;
|
||||
|
||||
// After narrowing, these should all be accessible
|
||||
const messages = event.messagesToSummarize;
|
||||
const messagesToKeep = event.messagesToKeep;
|
||||
const cutPoint = event.cutPoint;
|
||||
const tokensBefore = event.tokensBefore;
|
||||
const model = event.model;
|
||||
const resolveApiKey = event.resolveApiKey;
|
||||
const firstKeptEntryId = event.firstKeptEntryId;
|
||||
const { preparation, previousCompactions, sessionManager, modelRegistry, model } = event;
|
||||
const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId, cutPoint } = preparation;
|
||||
|
||||
// Get previous summary from most recent compaction
|
||||
const _previousSummary = previousCompactions[0]?.summary;
|
||||
|
||||
// Verify types
|
||||
expect(Array.isArray(messages)).toBe(true);
|
||||
expect(Array.isArray(messagesToSummarize)).toBe(true);
|
||||
expect(Array.isArray(messagesToKeep)).toBe(true);
|
||||
expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); // cutPoint still uses index
|
||||
expect(typeof cutPoint.firstKeptEntryIndex).toBe("number");
|
||||
expect(typeof tokensBefore).toBe("number");
|
||||
expect(model).toBeDefined();
|
||||
expect(typeof resolveApiKey).toBe("function");
|
||||
expect(typeof sessionManager.getEntries).toBe("function");
|
||||
expect(typeof modelRegistry.getApiKey).toBe("function");
|
||||
expect(typeof firstKeptEntryId).toBe("string");
|
||||
|
||||
const summary = messages
|
||||
const summary = messagesToSummarize
|
||||
.filter((m) => m.role === "user")
|
||||
.map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`)
|
||||
.join("\n");
|
||||
|
|
@ -57,12 +56,11 @@ describe("Documentation example", () => {
|
|||
|
||||
// After narrowing, these should all be accessible
|
||||
const entry = event.compactionEntry;
|
||||
const tokensBefore = event.tokensBefore;
|
||||
const fromHook = event.fromHook;
|
||||
|
||||
expect(entry.type).toBe("compaction");
|
||||
expect(typeof entry.summary).toBe("string");
|
||||
expect(typeof tokensBefore).toBe("number");
|
||||
expect(typeof entry.tokensBefore).toBe("number");
|
||||
expect(typeof fromHook).toBe("boolean");
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
|||
});
|
||||
|
||||
function createHook(
|
||||
onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compactionEntry?: any } | undefined,
|
||||
onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compaction?: any } | undefined,
|
||||
onCompact?: (event: SessionEvent) => void,
|
||||
): LoadedHook {
|
||||
const handlers = new Map<string, ((event: any, ctx: any) => Promise<any>)[]>();
|
||||
|
|
@ -98,7 +98,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
|||
},
|
||||
false,
|
||||
);
|
||||
hookRunner.setSessionFile(sessionManager.getSessionFile());
|
||||
hookRunner.setSessionFile(sessionManager.getSessionFile() ?? null);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
|
|
@ -131,20 +131,21 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
|||
|
||||
const beforeEvent = beforeCompactEvents[0];
|
||||
if (beforeEvent.reason === "before_compact") {
|
||||
expect(beforeEvent.cutPoint).toBeDefined();
|
||||
expect(beforeEvent.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(beforeEvent.messagesToSummarize).toBeDefined();
|
||||
expect(beforeEvent.messagesToKeep).toBeDefined();
|
||||
expect(beforeEvent.tokensBefore).toBeGreaterThanOrEqual(0);
|
||||
expect(beforeEvent.preparation).toBeDefined();
|
||||
expect(beforeEvent.preparation.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(beforeEvent.preparation.messagesToSummarize).toBeDefined();
|
||||
expect(beforeEvent.preparation.messagesToKeep).toBeDefined();
|
||||
expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0);
|
||||
expect(beforeEvent.model).toBeDefined();
|
||||
expect(beforeEvent.resolveApiKey).toBeDefined();
|
||||
expect(beforeEvent.sessionManager).toBeDefined();
|
||||
expect(beforeEvent.modelRegistry).toBeDefined();
|
||||
}
|
||||
|
||||
const afterEvent = compactEvents[0];
|
||||
if (afterEvent.reason === "compact") {
|
||||
expect(afterEvent.compactionEntry).toBeDefined();
|
||||
expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0);
|
||||
expect(afterEvent.tokensBefore).toBeGreaterThanOrEqual(0);
|
||||
expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0);
|
||||
expect(afterEvent.fromHook).toBe(false);
|
||||
}
|
||||
}, 120000);
|
||||
|
|
@ -162,18 +163,16 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
|||
expect(compactEvents.length).toBe(0);
|
||||
}, 120000);
|
||||
|
||||
it("should allow hooks to provide custom compactionEntry", async () => {
|
||||
it("should allow hooks to provide custom compaction", async () => {
|
||||
const customSummary = "Custom summary from hook";
|
||||
|
||||
const hook = createHook((event) => {
|
||||
if (event.reason === "before_compact") {
|
||||
return {
|
||||
compactionEntry: {
|
||||
type: "compaction" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
compaction: {
|
||||
summary: customSummary,
|
||||
firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex,
|
||||
tokensBefore: event.tokensBefore,
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -215,7 +214,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
|||
|
||||
const afterEvent = compactEvents[0];
|
||||
if (afterEvent.reason === "compact") {
|
||||
const hasCompactionEntry = afterEvent.entries.some((e) => e.type === "compaction");
|
||||
const entries = afterEvent.sessionManager.getEntries();
|
||||
const hasCompactionEntry = entries.some((e) => e.type === "compaction");
|
||||
expect(hasCompactionEntry).toBe(true);
|
||||
}
|
||||
}, 120000);
|
||||
|
|
@ -337,35 +337,34 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
|||
|
||||
expect(capturedBeforeEvent).not.toBeNull();
|
||||
const event = capturedBeforeEvent!;
|
||||
expect(event.cutPoint).toHaveProperty("firstKeptEntryIndex");
|
||||
expect(event.cutPoint).toHaveProperty("isSplitTurn");
|
||||
expect(event.cutPoint).toHaveProperty("turnStartIndex");
|
||||
expect(event.preparation.cutPoint).toHaveProperty("firstKeptEntryIndex");
|
||||
expect(event.preparation.cutPoint).toHaveProperty("isSplitTurn");
|
||||
expect(event.preparation.cutPoint).toHaveProperty("turnStartIndex");
|
||||
|
||||
expect(Array.isArray(event.messagesToSummarize)).toBe(true);
|
||||
expect(Array.isArray(event.messagesToKeep)).toBe(true);
|
||||
expect(Array.isArray(event.preparation.messagesToSummarize)).toBe(true);
|
||||
expect(Array.isArray(event.preparation.messagesToKeep)).toBe(true);
|
||||
|
||||
expect(typeof event.tokensBefore).toBe("number");
|
||||
expect(typeof event.preparation.tokensBefore).toBe("number");
|
||||
|
||||
expect(event.model).toHaveProperty("provider");
|
||||
expect(event.model).toHaveProperty("id");
|
||||
|
||||
expect(typeof event.resolveApiKey).toBe("function");
|
||||
expect(typeof event.modelRegistry.getApiKey).toBe("function");
|
||||
|
||||
expect(Array.isArray(event.entries)).toBe(true);
|
||||
expect(event.entries.length).toBeGreaterThan(0);
|
||||
const entries = event.sessionManager.getEntries();
|
||||
expect(Array.isArray(entries)).toBe(true);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
}, 120000);
|
||||
|
||||
it("should use hook compactionEntry even with different firstKeptEntryIndex", async () => {
|
||||
const customSummary = "Custom summary with modified index";
|
||||
it("should use hook compaction even with different values", async () => {
|
||||
const customSummary = "Custom summary with modified values";
|
||||
|
||||
const hook = createHook((event) => {
|
||||
if (event.reason === "before_compact") {
|
||||
return {
|
||||
compactionEntry: {
|
||||
type: "compaction" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
compaction: {
|
||||
summary: customSummary,
|
||||
firstKeptEntryIndex: 0,
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: 999,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue