From f2e6eec595d990760af4dadf6333685384805aaf Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:20:06 +0200 Subject: [PATCH 1/2] fix(pi): avoid SDK-only buildContextEntries for oh-my-pi hosts oh-my-pi bundles its own pi-coding-agent whose SessionManager lacks buildContextEntries(), so the turn_start effort-history handler crashed extension load. Port the compaction-aware context builder locally and read entries via getEntries()/getLeafId(), which both hosts provide. Entry shapes (compaction.firstKeptEntryId, thinking_level_change, custom_message) are identical across hosts. Co-authored-by: randomvariable --- packages/pi/src/effort-history.ts | 51 +++++++++++++++++++++++++++++++ packages/pi/src/index.ts | 10 ++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/pi/src/effort-history.ts b/packages/pi/src/effort-history.ts index 5fe70775..a55a2a16 100644 --- a/packages/pi/src/effort-history.ts +++ b/packages/pi/src/effort-history.ts @@ -88,3 +88,54 @@ export function collectPiEffortHistory( return transitions } + +type SessionEntryWithParent = SessionEntryLike & { + id: string + parentId?: string | null + firstKeptEntryId?: unknown +} + +/** + * 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. + */ +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) { + 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..a9e5b18c 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -17,7 +17,10 @@ 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, +} from './effort-history.ts' import { streamCortexKitAnthropic } from './stream.ts' async function loginAnthropic( @@ -72,7 +75,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(), + ctx.sessionManager.getLeafId?.() ?? null, + ), ctx.sessionManager.getBranch(), ) effortHistoryBySession.delete(sessionId) From 0317d7686ef1ffdcd8b8ec781b14933177234de5 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:20:06 +0200 Subject: [PATCH 2/2] fix(pi): keep an absent session leaf distinct from an explicit null in the context port The SDK treats undefined as an unknown leaf and falls back to the latest entry, while null explicitly selects no leaf and returns an empty context. Coalescing an absent getLeafId method into null therefore discarded the entire effort history on hosts without that method. Co-authored-by: randomvariable --- packages/pi/src/effort-history.ts | 23 +++- packages/pi/src/index.ts | 3 +- packages/pi/src/tests/effort-history.test.ts | 106 ++++++++++++++++++- 3 files changed, 126 insertions(+), 6 deletions(-) diff --git a/packages/pi/src/effort-history.ts b/packages/pi/src/effort-history.ts index a55a2a16..c8cbfb1b 100644 --- a/packages/pi/src/effort-history.ts +++ b/packages/pi/src/effort-history.ts @@ -95,11 +95,27 @@ type SessionEntryWithParent = SessionEntryLike & { 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[], @@ -107,10 +123,9 @@ export function buildContextEntries( ): SessionEntryWithParent[] { const byId = new Map(entries.map((entry) => [entry.id, entry])) let leaf: SessionEntryWithParent | undefined - if (leafId !== null) { - if (leafId) leaf = byId.get(leafId) - leaf ??= entries.at(-1) - } + 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 diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index a9e5b18c..d137b85b 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -20,6 +20,7 @@ import { registerCommands } from './commands.ts' import { buildContextEntries, collectPiEffortHistory, + resolveSessionLeafId, } from './effort-history.ts' import { streamCortexKitAnthropic } from './stream.ts' @@ -77,7 +78,7 @@ export default function cortexKitPiAnthropicAuth(pi: ExtensionAPI) { const transitions = collectPiEffortHistory( buildContextEntries( ctx.sessionManager.getEntries(), - ctx.sessionManager.getLeafId?.() ?? null, + resolveSessionLeafId(ctx.sessionManager), ), ctx.sessionManager.getBranch(), ) 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) + }) +})