-
Notifications
You must be signed in to change notification settings - Fork 15
fix(pi): derive context entries from the branch both hosts expose #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -102,3 +132,127 @@ describe('cortexKitPiAnthropicAuth provider registration', () => { | |
| }) | ||
|
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)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
It also asserts
|
||
| 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() | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
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