Skip to content

fix(pi): derive context entries from the branch both hosts expose - #202

Open
tomolom wants to merge 1 commit into
cortexkit:mainfrom
tomolom:fix/pi-host-agnostic-context-entries
Open

fix(pi): derive context entries from the branch both hosts expose#202
tomolom wants to merge 1 commit into
cortexkit:mainfrom
tomolom:fix/pi-host-agnostic-context-entries

Conversation

@tomolom

@tomolom tomolom commented Sep 8, 2026

Copy link
Copy Markdown

Fixes #200.

Root cause

turn_start resolved session entries through ctx.sessionManager.buildContextEntries(). That method exists on Pi (@earendil-works/pi-coding-agent 0.84.2 — ReadonlySessionManager includes it) but not on Oh My Pi 18.x. Probed on a live @oh-my-pi/pi-coding-agent@18.1.14 SessionManager instance, not just its .d.ts:

buildContextEntries: undefined
getBranch: function
getEntries: function
getSessionId: function

So the handler threw at its first statement on every turn. Measured consequences:

  1. A per-turn Extension "…" error: ctx.sessionManager.buildContextEntries is not a function.
  2. No Fable/Mythos 5.1 mid-conversation effort markers were ever collected — the hook's entire purpose — because the throw preceded every write to the effort map.

I did not establish the mechanism by which the reporter also lost the anthropic provider: in the runner I inspected, handler errors are contained per handler (#runHandlerWithTimeoutemitErrorshowExtensionError) and queued provider registrations are flushed at session init, before any turn. Their omp models before/after evidence stands on its own; this PR does not claim to explain it, and hardens the handler so it cannot contribute either way.

Fix

getBranch() is present on both hosts, and on Pi it returns the same root-to-leaf path buildContextEntries() walks (buildContextEntries(entries, leafId, byId)buildSessionPath(...); both collect leaf-upward and reverse, so the array is root-first — confirmed by the live probe below). The compaction trim it applies is therefore derived locally instead of being requested from the host:

  • new deriveContextEntries(branch) in effort-history.ts — compaction entry, then the tail retained from firstKeptEntryId, then everything appended after the compaction; an uncompacted branch passes through. firstKeptEntryId is present on both hosts' CompactionEntry.
  • turn_start now takes one getBranch() call and feeds both arguments of collectPiEffortHistory, so the two views can no longer disagree.

This is chosen over the optional-call form (buildContextEntries?.() ?? getEntries()) suggested in the issue: getEntries() returns every entry including abandoned branches, so a session with branches would count assistant messages that are not in context and misplace afterAssistantMessages. Deriving from the branch keeps one code path with the host semantics intact.

The handler additionally degrades to no transitions rather than throwing, per the issue's expected behaviour: this hook only annotates requests with effort markers, so an unreadable session shape should cost the session its transitions and nothing else.

Not changed: peerDependencies still names @earendil-works/*. omp resolves those specifiers through its own legacy shim (legacy-pi-ai-shim.ts re-exports @oh-my-pi/pi-ai), so the package names are not what broke here, and a *-ranged peer would not have caught a removed method anyway. Fixing it at the call site is what makes the plugin work on both hosts.

Verification

Live host, driving this branch's built dist/effort-history.js against a real omp SessionManager:

branch entry types: message, thinking_level_change, message, message
derived context types: message, thinking_level_change, message, message
transitions: [{"afterAssistantMessages":0,"effort":"high"},{"afterAssistantMessages":1,"effort":"low"}]
  • packages/pi/src/tests/index.test.ts drives the registered turn_start handler with an omp-shaped session manager (getSessionId/getBranch/getEntries, no buildContextEntries). Reverting only src/index.ts + src/effort-history.ts and re-running reproduces the reported error verbatim: TypeError: ctx.sessionManager.buildContextEntries is not a function — 2 fail. With the fix: 5 pass.
  • deriveContextEntries is covered for the uncompacted branch, the retained tail, an unreachable firstKeptEntryId, and multiple compactions; the existing compaction effort-history test now asserts the derived array equals the hand-written context entries it already used.
  • bun run typecheck clean; bun test src/tests in packages/pi: 100 pass / 0 fail; biome check clean.

bun run test at the root ran core and opencode only, so no pi test could gate a release. It now runs packages/pi too — that is how these regression tests earn their place in CI.

Copilot AI lite review requested due to automatic review settings September 8, 2026 03:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new regression tests can silently pass without executing the turn_start handler due to optional chaining, reducing their ability to prevent future regressions.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes Pi/Oh My Pi host API drift by removing the hard dependency on SessionManager.buildContextEntries() (missing in omp 18.x) and deriving the same “context entries” view directly from the branch path exposed by both hosts, while ensuring the turn_start hook cannot disable provider registration via a thrown exception.

Changes:

  • Add deriveContextEntries(branch) to reproduce Pi’s compaction-aware context trimming without calling host-only APIs.
  • Update the turn_start handler to use getBranch() + derived context entries and to degrade to “no transitions” on unreadable session shapes.
  • Extend Pi test coverage for omp-shaped session managers and ensure root bun run test runs packages/pi tests.
File summaries
File Description
packages/pi/src/index.ts Switch turn_start effort-history collection to getBranch() + deriveContextEntries() and guard against hook exceptions.
packages/pi/src/effort-history.ts Introduce deriveContextEntries to replicate compaction trimming from the branch path.
packages/pi/src/tests/index.test.ts Add regression tests for omp-like session manager shape and hook error-degradation behavior.
packages/pi/src/tests/effort-history.test.ts Add direct unit tests for deriveContextEntries and update compaction fixtures.
package.json Extend root test script to include packages/pi tests.
Review details

Suppressed comments (1)

packages/pi/src/tests/index.test.ts:146

  • This assertion also uses optional chaining when calling the turn_start handler, which would let the test pass even if the handler was never registered. Capture the handler, assert it exists, then assert its return value.
    expect(await events.get('turn_start')?.({ type: 'turn_start' }, ctx)).toBe(
      undefined,
    )
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/pi/src/tests/index.test.ts Outdated
Comment on lines +126 to +129
await events.get('turn_start')?.({ type: 'turn_start' }, ctx)

expect(providers.get('anthropic')).toBeDefined()
})
@tomolom

tomolom commented Sep 8, 2026

Copy link
Copy Markdown
Author

Host-level confirmation against a real @oh-my-pi/pi-coding-agent@18.1.14 SessionManager instance (not just the .d.ts), driving the built dist/effort-history.js from this branch:

host omp SessionManager surface:
  buildContextEntries: undefined
  getBranch: function
  getEntries: function
  getSessionId: function
branch entry types: message, thinking_level_change, message, message
derived context types: message, thinking_level_change, message, message
transitions: [{"afterAssistantMessages":0,"effort":"high"},{"afterAssistantMessages":1,"effort":"low"}]

So the accessor is genuinely absent on the live host object, getBranch() returns the entries this hook needs, and the derived timeline places the minimallow change after the assistant message — the collection that produced nothing at all on omp before this change.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files

Confidence score: 5/5

  • In packages/pi/src/index.ts, the broad catch around getBranch, deriveContextEntries, and collectPiEffortHistory can silently drop data when a host API shape changes, making incompatibilities difficult to diagnose; add logging or narrower error handling.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/pi/src/index.ts">

<violation number="1" location="packages/pi/src/index.ts:86">
P3: The catch swallows all exceptions from getBranch/deriveContextEntries/collectPiEffortHistory with no log. Issue #200 was exactly this class of silent host incompatibility; a future shape change will now silently drop effort markers with zero diagnostic instead of surfacing. Preserve the degraded no-op but log the error so regressions are detectable.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant TurnStart as turn_start handler
    participant SM as SessionManager (Pi / OMP)
    participant EH as effort-history module
    participant MH as effortHistoryBySession store
    participant Provider as Anthropic Provider (Fable/Mythos)

    Note over TurnStart,Provider: Turn start flow - host compatibility fix

    TurnStart->>SM: getSessionId()
    alt No session ID
        SM-->>TurnStart: null
        TurnStart-->>TurnStart: Return early
    else Session ID present
        SM-->>TurnStart: sessionId
        TurnStart->>SM: getBranch()
        alt getBranch() succeeds
            SM-->>TurnStart: branch entries (leaf-to-root)
            TurnStart->>EH: deriveContextEntries(branch)
            Note over EH: Applies compaction trim locally<br/>(host buildContextEntries() not<br/>available on OMP 18.x)
            EH-->>TurnStart: context entries
            TurnStart->>EH: collectPiEffortHistory(contextEntries, branch)
            EH-->>TurnStart: effort transitions
            TurnStart->>MH: delete(sessionId)
            TurnStart->>MH: set(sessionId, transitions)
            Note over MH: LRU cap at 128 sessions
            TurnStart->>Provider: Request with effort markers
            Provider-->>TurnStart: Continue turn
        else getBranch() throws
            SM-->>TurnStart: Error
            TurnStart->>TurnStart: Catch - degrade to empty transitions
            TurnStart->>MH: delete(sessionId)
            TurnStart->>MH: set(sessionId, [])
            TurnStart->>Provider: Request without effort markers (no failure)
            Note over TurnStart,Provider: Provider preserved - no per-turn<br/>extension error (issue #200 fix)
        end
    end

    Note over EH: deriveContextEntries() logic
    EH->>EH: Scan branch for last compaction entry
    alt No compaction entry
        EH-->>TurnStart: Return branch slice unchanged
    else Compaction found
        EH->>EH: Start with compaction entry
        EH->>EH: Retain entries from firstKeptEntryId onward
        EH->>EH: Append all entries after compaction
        EH-->>TurnStart: Derived context entries
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/pi/src/tests/index.test.ts
Comment thread packages/pi/src/index.ts
try {
const branch = ctx.sessionManager.getBranch()
transitions = collectPiEffortHistory(deriveContextEntries(branch), branch)
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The catch swallows all exceptions from getBranch/deriveContextEntries/collectPiEffortHistory with no log. Issue #200 was exactly this class of silent host incompatibility; a future shape change will now silently drop effort markers with zero diagnostic instead of surfacing. Preserve the degraded no-op but log the error so regressions are detectable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/pi/src/index.ts, line 86:

<comment>The catch swallows all exceptions from getBranch/deriveContextEntries/collectPiEffortHistory with no log. Issue #200 was exactly this class of silent host incompatibility; a future shape change will now silently drop effort markers with zero diagnostic instead of surfacing. Preserve the degraded no-op but log the error so regressions are detectable.</comment>

<file context>
@@ -71,10 +74,18 @@ export default function cortexKitPiAnthropicAuth(pi: ExtensionAPI) {
+    try {
+      const branch = ctx.sessionManager.getBranch()
+      transitions = collectPiEffortHistory(deriveContextEntries(branch), branch)
+    } catch {
+      transitions = []
+    }
</file context>

Comment thread packages/pi/src/tests/index.test.ts Outdated
@tomolom
tomolom force-pushed the fix/pi-host-agnostic-context-entries branch 2 times, most recently from 3e3b331 to 38fcaa8 Compare September 8, 2026 03:26
@tomolom

tomolom commented Sep 8, 2026

Copy link
Copy Markdown
Author

Review feedback addressed in the pushed amend.

Optional chaining could pass vacuously (both reviewers) — fixed. Each test now looks the handler up, asserts it exists, and invokes it. Reverting src/index.ts + src/effort-history.ts to main and re-running: 2 fail, both with the reported TypeError: ctx.sessionManager.buildContextEntries is not a function.

The getBranch-only test only asserted "provider defined" (cubic) — agreed, that was too weak. It is now end-to-end: turn_start runs against a getBranch-only session manager whose branch carries minimal then xhigh thinking-level changes with an assistant message between them, then the registered streamSimple is driven with a stubbed fetch (same harness as stream.test.ts) and the captured request body is asserted:

output_config          = { effort: 'low' }      // minimal -> low, opening effort
messages[2]            = { role: 'system', content: [], output_config: { effort: 'xhigh' } }

So the test now fails if the transitions the handler collects never reach the request — not just if the handler throws.

Silent catch (cubic) — considered and deliberately kept silent, now documented at the catch site. ExtensionAPI exposes no log surface on either host (no log/logger member on Pi's ExtensionAPI, and this package has no logging of any kind), so the only channel available is stdout/stderr from inside a per-turn hook — which corrupts the host's rendering and reproduces exactly the per-turn noise this issue is about. The degraded state is not invisible: it is observable in the request as absent effort markers, and is now covered by the two tests above. If you would rather have a diagnostic, say which channel you want it on (a one-shot warning behind an env flag, or a host-specific surface) and I will wire it.

Two wording corrections also pushed, from re-reading my own evidence:

  • getBranch() returns the branch root-to-leaf (both implementations collect leaf-upward then reverse; confirmed by the live-host probe output above). The comment said leaf-to-root.
  • The handler comment no longer claims the throw cost the session its provider. In the runner I inspected, handler errors are contained per handler and queued provider registrations flush at session init, before any turn. The proven consequences are the per-turn extension error and zero effort history; the reporter's provider loss is real but I cannot explain its mechanism, and this PR no longer implies it can.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/pi/src/tests/index.test.ts">

<violation number="1" location="packages/pi/src/tests/index.test.ts:186">
P3: The global fetch mock assigns every non-bootstrap response the same canned SSE success and records the last non-bootstrap `init.body` into `requestBody`, without validating method, URL, or response semantics. If the stream flow ever issues an additional non-bootstrap request (e.g. a relay post, quota check, or a retry), `requestBody` will point at the wrong request and the hard-coded `sent.messages[2]`/`output_config` assertions will inspect the wrong payload. Restrict the mock to match the expected `/v1/messages?beta=true` POST URL so only the real messages request is captured.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

}),
)
}
requestBody = JSON.parse(String(init?.body))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The global fetch mock assigns every non-bootstrap response the same canned SSE success and records the last non-bootstrap init.body into requestBody, without validating method, URL, or response semantics. If the stream flow ever issues an additional non-bootstrap request (e.g. a relay post, quota check, or a retry), requestBody will point at the wrong request and the hard-coded sent.messages[2]/output_config assertions will inspect the wrong payload. Restrict the mock to match the expected /v1/messages?beta=true POST URL so only the real messages request is captured.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/pi/src/tests/index.test.ts, line 186:

<comment>The global fetch mock assigns every non-bootstrap response the same canned SSE success and records the last non-bootstrap `init.body` into `requestBody`, without validating method, URL, or response semantics. If the stream flow ever issues an additional non-bootstrap request (e.g. a relay post, quota check, or a retry), `requestBody` will point at the wrong request and the hard-coded `sent.messages[2]`/`output_config` assertions will inspect the wrong payload. Restrict the mock to match the expected `/v1/messages?beta=true` POST URL so only the real messages request is captured.</comment>

<file context>
@@ -104,34 +133,107 @@ describe('cortexKitPiAnthropicAuth provider registration', () => {
+            }),
+          )
+        }
+        requestBody = JSON.parse(String(init?.body))
+        return new Response(
+          [
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in the pushed amend.

The mock now dispatches explicitly and refuses anything it does not expect:

  • bootstrap URL -> bootstrap response
  • POST https://api.anthropic.com/v1/messages... -> captured, answered with the canned SSE stream
  • anything else -> throw new Error("unexpected request: <method> <url>"), so a relay post, quota fetch or retry fails the test instead of silently overwriting the body under assertion

It also asserts requestBody is still undefined before capturing, so a second messages POST cannot replace the first. That assertion runs in the passing run (11 expect calls in this file, up from 9), which is the evidence that exactly one messages request is made on this path.

packages/pi: 100 pass / 0 fail. Reverting src/index.ts + src/effort-history.ts to main still fails the two regression tests with the reported TypeError. Typecheck and biome clean.

`turn_start` called `SessionManager.buildContextEntries()`, which exists on
Pi but not on Oh My Pi 18.x — on a live 18.1.14 SessionManager instance the
property is `undefined`. The handler threw at its first statement on every
turn, so it surfaced a per-turn extension error and collected no
Fable/Mythos 5.1 mid-conversation effort markers at all.

`getBranch()` is present on both hosts and returns the same root-to-leaf
path `buildContextEntries()` walks, so `deriveContextEntries()` applies the
compaction trim locally: the compaction entry, the tail retained from
`firstKeptEntryId`, then everything appended after it.

The handler now also degrades to no transitions instead of throwing. It
only annotates requests with effort markers, so an unreadable session
shape must cost the session its transitions and nothing else.

Also run the pi package's tests in `bun run test`, which covered core and
opencode only.

Fixes cortexkit#200
@tomolom
tomolom force-pushed the fix/pi-host-agnostic-context-entries branch from 38fcaa8 to 5076052 Compare September 8, 2026 03:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] pi: sessionManager.buildContextEntries() removed in omp 18.x - extension throws every turn and the anthropic provider never registers

2 participants