diff --git a/packages/pi/src/effort-history.ts b/packages/pi/src/effort-history.ts index 5fe70775..c8cbfb1b 100644 --- a/packages/pi/src/effort-history.ts +++ b/packages/pi/src/effort-history.ts @@ -88,3 +88,69 @@ export function collectPiEffortHistory( return transitions } + +type SessionEntryWithParent = SessionEntryLike & { + id: string + parentId?: string | null + firstKeptEntryId?: unknown +} + +type SessionManagerLeaf = { + getLeafId?: () => string | null + [key: string]: unknown +} + +export function resolveSessionLeafId( + sessionManager: SessionManagerLeaf, +): string | null | undefined { + return sessionManager.getLeafId?.() +} + +/** + * Compaction-aware active entry list, mirroring the host SDK's + * buildContextEntries: latest compaction plus kept entries replace the + * summarized prefix. Implemented locally because some Pi-compatible hosts + * (oh-my-pi) lack the SDK method while sharing the entry model. + * + * `undefined` means the leaf is unknown and selects the latest entry; `null` + * means an explicit no-leaf state and returns an empty list; a string selects + * that leaf. Callers must preserve the distinction and not coalesce an absent + * leaf method into `null`. + */ +export function buildContextEntries( + entries: readonly SessionEntryWithParent[], + leafId?: string | null, +): SessionEntryWithParent[] { + const byId = new Map(entries.map((entry) => [entry.id, entry])) + let leaf: SessionEntryWithParent | undefined + if (leafId === null) return [] + if (leafId) leaf = byId.get(leafId) + leaf ??= entries.at(-1) + if (!leaf) return [] + const path: SessionEntryWithParent[] = [] + let current: SessionEntryWithParent | undefined = leaf + while (current) { + path.push(current) + current = current.parentId ? byId.get(current.parentId) : undefined + } + path.reverse() + + let compaction: SessionEntryWithParent | undefined + for (const entry of path) { + if (entry.type === 'compaction') compaction = entry + } + if (!compaction) return path + + const compactionIndex = path.findIndex((entry) => entry.id === compaction.id) + if (compactionIndex < 0) return path + const contextEntries: SessionEntryWithParent[] = [compaction] + let foundFirstKept = false + for (let index = 0; index < compactionIndex; index++) { + const entry = path[index] + if (!entry) continue + if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true + if (foundFirstKept) contextEntries.push(entry) + } + contextEntries.push(...path.slice(compactionIndex + 1)) + return contextEntries +} diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 669a2640..d137b85b 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -17,7 +17,11 @@ import type { import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' import { registerCommands } from './commands.ts' -import { collectPiEffortHistory } from './effort-history.ts' +import { + buildContextEntries, + collectPiEffortHistory, + resolveSessionLeafId, +} from './effort-history.ts' import { streamCortexKitAnthropic } from './stream.ts' async function loginAnthropic( @@ -72,7 +76,10 @@ export default function cortexKitPiAnthropicAuth(pi: ExtensionAPI) { const sessionId = ctx.sessionManager.getSessionId() if (!sessionId) return const transitions = collectPiEffortHistory( - ctx.sessionManager.buildContextEntries(), + buildContextEntries( + ctx.sessionManager.getEntries(), + resolveSessionLeafId(ctx.sessionManager), + ), ctx.sessionManager.getBranch(), ) effortHistoryBySession.delete(sessionId) diff --git a/packages/pi/src/tests/effort-history.test.ts b/packages/pi/src/tests/effort-history.test.ts index 236bdc21..63fc3b95 100644 --- a/packages/pi/src/tests/effort-history.test.ts +++ b/packages/pi/src/tests/effort-history.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { collectPiEffortHistory } from '../effort-history.ts' +import { + buildContextEntries, + collectPiEffortHistory, + resolveSessionLeafId, +} from '../effort-history.ts' const entry = ( id: string, @@ -52,3 +56,103 @@ describe('Pi Fable 5.1 effort history', () => { ]) }) }) + +describe('buildContextEntries', () => { + const contextEntry = ( + id: string, + parentId: string | null, + type = 'message', + extra: Record = {}, + ) => ({ id, parentId, type, ...extra }) + + test('uses the latest entry when the host has no leaf method', () => { + const entries = [contextEntry('a', null), contextEntry('b', 'a')] + expect( + buildContextEntries(entries, undefined).map((entry) => entry.id), + ).toEqual(['a', 'b']) + }) + + test('preserves an absent host leaf method as undefined', () => { + const host = { getEntries: () => [] } + expect(resolveSessionLeafId(host)).toBeUndefined() + expect( + buildContextEntries( + [contextEntry('a', null), contextEntry('b', 'a')], + resolveSessionLeafId(host), + ).map((entry) => entry.id), + ).toEqual(['a', 'b']) + }) + + test('preserves an explicit null host leaf', () => { + const host = { getLeafId: () => null } + expect(resolveSessionLeafId(host)).toBeNull() + }) + + test('returns no entries for an explicit null leaf', () => { + const entries = [contextEntry('a', null)] + expect(buildContextEntries(entries, null)).toEqual([]) + }) + + test('follows a leaf path from the root', () => { + const entries = [ + contextEntry('a', null), + contextEntry('b', 'a'), + contextEntry('c', 'b'), + contextEntry('other', 'a'), + ] + expect(buildContextEntries(entries, 'c').map((entry) => entry.id)).toEqual([ + 'a', + 'b', + 'c', + ]) + }) + + test('keeps a compaction and entries starting at firstKeptEntryId', () => { + const entries = [ + contextEntry('a', null), + contextEntry('kept', 'a'), + contextEntry('drop', 'kept'), + contextEntry('compact', 'drop', 'compaction', { + firstKeptEntryId: 'kept', + }), + contextEntry('after', 'compact'), + ] + expect( + buildContextEntries(entries, 'after').map((entry) => entry.id), + ).toEqual(['compact', 'kept', 'drop', 'after']) + }) + + test('returns the full path when there is no compaction', () => { + const entries = [contextEntry('a', null), contextEntry('b', 'a')] + expect(buildContextEntries(entries, 'b').map((entry) => entry.id)).toEqual([ + 'a', + 'b', + ]) + }) + + test('uses only the latest compaction on the path', () => { + const entries = [ + contextEntry('a', null), + contextEntry('kept1', 'a'), + contextEntry('compact1', 'kept1', 'compaction', { + firstKeptEntryId: 'kept1', + }), + contextEntry('kept2', 'compact1'), + contextEntry('compact2', 'kept2', 'compaction', { + firstKeptEntryId: 'kept2', + }), + contextEntry('after', 'compact2'), + ] + expect( + buildContextEntries(entries, 'after').map((entry) => entry.id), + ).toEqual(['compact2', 'kept2', 'after']) + }) + + test('passes branch summaries through unchanged', () => { + const summary = contextEntry('summary', 'a', 'branch_summary', { + summary: 'branch', + }) + const entries = [contextEntry('a', null), summary] + expect(buildContextEntries(entries, 'summary')).toEqual(entries) + }) +})