Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"extract": "bun scripts/extract-system-prompt.ts",
"check:claustrum-golden": "bun scripts/check-claustrum-golden.ts",
"analyze:cache": "node scripts/analyze-cache-usage.mjs",
"test": "bun run build && cd packages/core && bun test src/tests && cd ../opencode && bun run test",
"test": "bun run build && cd packages/core && bun test src/tests && cd ../opencode && bun run test && cd ../pi && bun run test",
"test:e2e": "cd packages/core && bun run build && cd ../.. && bun run --cwd packages/e2e-tests test",
"typecheck": "cd packages/core && bun run build && cd ../opencode && bun run typecheck && cd ../pi && bun run typecheck && cd ../.. && tsc -p tsconfig.scripts.json",
"types": "bun run typecheck",
Expand Down
40 changes: 40 additions & 0 deletions packages/pi/src/effort-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,49 @@ type SessionEntryLike = {
id?: unknown
type?: unknown
thinkingLevel?: unknown
firstKeptEntryId?: unknown
message?: { role?: unknown }
}

/**
* Reproduce the host's context-entry view — the entries the next request will
* actually carry — from the branch path, ordered root to leaf.
*
* `SessionManager.buildContextEntries()` computes this on Pi hosts but does not
* exist on Oh My Pi 18.x, where calling it threw on every turn (issue #200).
* `getBranch()` is present on both hosts and is the same path that method walks,
* so the compaction trim is applied here instead of asking the host for it.
*
* A branch without compaction is already the context. After a compaction the
* context is the compaction entry, then the tail retained from
* `firstKeptEntryId`, then everything appended after the compaction.
*/
export function deriveContextEntries<Entry extends SessionEntryLike>(
branch: readonly Entry[],
): Entry[] {
let compactionIndex = -1
for (let index = 0; index < branch.length; index++) {
if (branch[index]?.type === 'compaction') compactionIndex = index
}

const compaction = branch[compactionIndex]
if (!compaction) return branch.slice()

const contextEntries: Entry[] = [compaction]
let retaining = false
for (let index = 0; index < compactionIndex; index++) {
const entry = branch[index]
if (!entry) continue
if (entry.id === compaction.firstKeptEntryId) retaining = true
if (retaining) contextEntries.push(entry)
}
for (let index = compactionIndex + 1; index < branch.length; index++) {
const entry = branch[index]
if (entry) contextEntries.push(entry)
}
return contextEntries
}

function entryRole(entry: SessionEntryLike): unknown {
if (entry.type === 'compaction' || entry.type === 'branch_summary') {
return 'user'
Expand Down
26 changes: 21 additions & 5 deletions packages/pi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
collectPiEffortHistory,
deriveContextEntries,
} from './effort-history.ts'
import { streamCortexKitAnthropic } from './stream.ts'

async function loginAnthropic(
Expand Down Expand Up @@ -71,10 +74,23 @@ export default function cortexKitPiAnthropicAuth(pi: ExtensionAPI) {
pi.on('turn_start', async (_event, ctx) => {
const sessionId = ctx.sessionManager.getSessionId()
if (!sessionId) return
const transitions = collectPiEffortHistory(
ctx.sessionManager.buildContextEntries(),
ctx.sessionManager.getBranch(),
)
// This hook only adds mid-conversation effort markers to Fable/Mythos 5.1
// requests. A host whose session entries do not match what this reads must
// cost the session its transitions and nothing else: the handler runs
// before every turn, and an exception here surfaced as a per-turn extension
// error while collecting no effort history at all (issue #200).
//
// The catch stays quiet: `ExtensionAPI` carries no log surface on either
// host, and writing to stdout from a per-turn hook corrupts the host's
// rendering — which is the same per-turn noise this fix removes. The
// degraded state is observable in the request: no effort markers.
let transitions: MidConversationEffortTransition[]
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>

transitions = []
}
effortHistoryBySession.delete(sessionId)
effortHistoryBySession.set(sessionId, transitions)
while (effortHistoryBySession.size > 128) {
Expand Down
65 changes: 63 additions & 2 deletions packages/pi/src/tests/effort-history.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { collectPiEffortHistory } from '../effort-history.ts'
import {
collectPiEffortHistory,
deriveContextEntries,
} from '../effort-history.ts'

const entry = (
id: string,
Expand Down Expand Up @@ -34,7 +37,7 @@ describe('Pi Fable 5.1 effort history', () => {
entry('t1', 'thinking_level_change', { thinkingLevel: 'high' }),
entry('u2', 'message', { message: { role: 'user' } }),
entry('a2', 'message', { message: { role: 'assistant' } }),
entry('compact', 'compaction'),
entry('compact', 'compaction', { firstKeptEntryId: 't1' }),
entry('t2', 'thinking_level_change', { thinkingLevel: 'xhigh' }),
entry('u3', 'message', { message: { role: 'user' } }),
]
Expand All @@ -46,9 +49,67 @@ describe('Pi Fable 5.1 effort history', () => {
fullBranch[7]!,
fullBranch[8]!,
]
expect(deriveContextEntries(fullBranch)).toEqual(contextEntries)
expect(collectPiEffortHistory(contextEntries, fullBranch)).toEqual([
{ afterAssistantMessages: 0, effort: 'high' },
{ afterAssistantMessages: 1, effort: 'xhigh' },
])
})
})

// The host accessor these entries came from — buildContextEntries() — exists on
// Pi but not on Oh My Pi 18.x, so the compaction trim is derived here from the
// branch path both hosts expose (issue #200).
describe('Pi context entries', () => {
test('carries an uncompacted branch through unchanged', () => {
const branch = [
entry('u1', 'message', { message: { role: 'user' } }),
entry('a1', 'message', { message: { role: 'assistant' } }),
]
const derived = deriveContextEntries(branch)
expect(derived).toEqual(branch)
expect(derived).not.toBe(branch)
})

test('drops entries older than the compaction it retains from', () => {
const branch = [
entry('u1', 'message', { message: { role: 'user' } }),
entry('a1', 'message', { message: { role: 'assistant' } }),
entry('u2', 'message', { message: { role: 'user' } }),
entry('compact', 'compaction', { firstKeptEntryId: 'u2' }),
entry('a2', 'message', { message: { role: 'assistant' } }),
]
expect(deriveContextEntries(branch).map((item) => item.id)).toEqual([
'compact',
'u2',
'a2',
])
})

test('retains nothing before a compaction with an unreachable first kept id', () => {
const branch = [
entry('u1', 'message', { message: { role: 'user' } }),
entry('compact', 'compaction', { firstKeptEntryId: 'pruned' }),
entry('u2', 'message', { message: { role: 'user' } }),
]
expect(deriveContextEntries(branch).map((item) => item.id)).toEqual([
'compact',
'u2',
])
})

test('anchors on the last compaction when a branch has several', () => {
const branch = [
entry('u1', 'message', { message: { role: 'user' } }),
entry('c1', 'compaction', { firstKeptEntryId: 'u1' }),
entry('u2', 'message', { message: { role: 'user' } }),
entry('c2', 'compaction', { firstKeptEntryId: 'u2' }),
entry('u3', 'message', { message: { role: 'user' } }),
]
expect(deriveContextEntries(branch).map((item) => item.id)).toEqual([
'c2',
'u2',
'u3',
])
})
})
156 changes: 155 additions & 1 deletion packages/pi/src/tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
import { describe, expect, test } from 'bun:test'
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { saveAccounts } from '@cortexkit/anthropic-auth-core'
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'

import cortexKitPiAnthropicAuth from '../index'

let tempDir: string | undefined
const originalFetch = globalThis.fetch

// Fable 5.1 is the family that carries mid-conversation effort markers, so it
// is the model that can observe what turn_start collected.
const fableModel = {
id: 'claude-fable-5-1',
name: 'Claude Fable 5.1',
api: 'cortexkit-anthropic-messages',
provider: 'anthropic',
baseUrl: 'https://api.anthropic.com',
reasoning: true,
input: ['text'],
cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 },
contextWindow: 1_000_000,
maxTokens: 128_000,
}
const messagesUrl = `${fableModel.baseUrl}/v1/messages`

afterEach(async () => {
globalThis.fetch = originalFetch
delete process.env.PI_ANTHROPIC_AUTH_FILE
if (tempDir) await rm(tempDir, { recursive: true, force: true })
tempDir = undefined
})

function mockPi() {
const providers = new Map<
string,
Expand Down Expand Up @@ -102,3 +132,127 @@ describe('cortexKitPiAnthropicAuth provider registration', () => {
})
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
})
})

// Oh My Pi 18.x dropped SessionManager.buildContextEntries(); calling it threw
// on every turn, so no effort history was ever collected (issue #200). Only
// getSessionId/getBranch are assumed here — the accessors both hosts expose.
describe('cortexKitPiAnthropicAuth turn_start effort history', () => {
test('carries transitions from a getBranch-only host into the request', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'pi-turn-start-effort-'))
const storagePath = join(tempDir, 'anthropic-auth.json')
process.env.PI_ANTHROPIC_AUTH_FILE = storagePath
await saveAccounts(
{
version: 1,
main: { type: 'opencode', provider: 'anthropic' },
accounts: [],
},
storagePath,
)

const { pi, providers, events } = mockPi()
cortexKitPiAnthropicAuth(pi)

// minimal -> low, then xhigh, with one assistant message between them.
const branch = [
{ id: 't0', type: 'thinking_level_change', thinkingLevel: 'minimal' },
{ id: 'u1', type: 'message', message: { role: 'user' } },
{ id: 'a1', type: 'message', message: { role: 'assistant' } },
{ id: 't1', type: 'thinking_level_change', thinkingLevel: 'xhigh' },
{ id: 'u2', type: 'message', message: { role: 'user' } },
]
const handler = events.get('turn_start')
expect(handler).toBeDefined()
await handler?.(
{ type: 'turn_start' },
{
sessionManager: {
getSessionId: () => 'session-omp',
getBranch: () => branch,
getEntries: () => branch,
},
},
)

// Only the messages POST may be captured: if the stream path ever adds
// another request (relay, quota, retry), this must fail loudly rather than
// let the assertions below inspect that body instead.
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = mock(
async (input: string | URL | Request, init?: RequestInit) => {
const url = input.toString()
if (url.includes('/api/claude_cli/bootstrap')) {
return new Response(
JSON.stringify({
oauth_account: { account_uuid: 'pi-turn-start-account' },
}),
)
}
const method = (init?.method ?? 'GET').toUpperCase()
if (method !== 'POST' || !url.startsWith(messagesUrl)) {
throw new Error(`unexpected request: ${method} ${url}`)
}
expect(requestBody).toBeUndefined()
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.

return new Response(
[
'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":1,"output_tokens":0}}}\n\n',
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n',
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
].join(''),
{ status: 200 },
)
},
) as unknown as typeof fetch

const stream = providers.get('anthropic')?.streamSimple?.(
fableModel,
{
systemPrompt: 'test',
tools: [],
messages: [
{ role: 'user', content: 'first', timestamp: 0 },
{
role: 'assistant',
content: [{ type: 'text', text: 'answer' }],
timestamp: 0,
},
{ role: 'user', content: 'second', timestamp: 0 },
],
},
{ apiKey: 'sk-ant-oat-turn-start', sessionId: 'session-omp' },
)
for await (const _event of stream as AsyncIterable<unknown>) {
// Drain the provider stream.
}

// The transitions the handler collected, as the request carries them: the
// opening effort on the body and the later change as its own marker turn.
expect(requestBody).toBeDefined()
const sent = requestBody as { output_config: unknown; messages: unknown[] }
expect(sent.output_config).toEqual({ effort: 'low' })
expect(sent.messages[2]).toEqual({
role: 'system',
content: [],
output_config: { effort: 'xhigh' },
})
})

test('degrades to no transitions when the host session shape is unreadable', async () => {
const { pi, events } = mockPi()
cortexKitPiAnthropicAuth(pi)

const handler = events.get('turn_start')
expect(handler).toBeDefined()
const ctx = {
sessionManager: {
getSessionId: () => 'session-broken',
getBranch: () => {
throw new TypeError('getBranch is not a function')
},
},
}

expect(await handler?.({ type: 'turn_start' }, ctx)).toBeUndefined()
})
})