From 5b87538bbcf6174c2535c3618f2e2410947ce74c Mon Sep 17 00:00:00 2001 From: Khang H Le Date: Sun, 20 Sep 2026 12:12:11 -0500 Subject: [PATCH] fix(pi): read the prompt and tools from Pi 0.86's transcript context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi 0.86 hands `streamSimple` a normalized `TranscriptContext` — `{ messages }` only. The host system prompt and the tool declarations are folded into a leading `role: "system"` message (later system messages may carry `toolsAdded` / `toolsRemoved` / `sections` deltas) and are meant to be read back with pi-ai's `getCurrentSystemPrompt` / `getCurrentTools` (packages/coding-agent/docs/custom-provider.md § Custom Streaming API). `buildAnthropicRequest` and the `tool_use` name un-mapping in `stream.ts` read `context.systemPrompt` / `context.tools`, which are `undefined` on that shape, so every request on Pi >= 0.86 went out with the billing header and the Claude Code identity as the whole system prompt and no tools. HTTP 200, a fluent reply from a bare model that says it has no bash tool. `resolveRequestContext()` resolves the prompt, tools, and message list from either shape: raw `Context` fields when the transcript has no system message (Pi < 0.86, unchanged); pi-ai's transcript helpers when it does. The helpers are reached through the namespace import and a `typeof` check rather than named imports, so the extension still loads on a pi-ai that lacks them; if system messages are present without the helpers (only reachable on an older pi-ai, e.g. this repo's lockfile) a minimal local replay keeps the prompt and tools instead of dropping them. Tests: raw Context unchanged; prompt + tools read from the leading system message; later system messages replayed (appended text, tool add/remove); buildAnthropicRequest sends both from a transcript context. Verified green against the lockfile's pi-ai 0.85.1 (local replay branch) and against pi-ai 0.86.1 in a scratch install (helper branch — breaking the local replay there changes nothing, so the helpers ran). Closes #244 --- CHANGELOG.md | 1 + packages/pi/src/convert.ts | 132 ++++++++++++++++++++++- packages/pi/src/stream.ts | 18 +++- packages/pi/src/tests/convert.test.ts | 146 +++++++++++++++++++++++++- 4 files changed, 287 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8f9a67..47e1d4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Patch Changes +- Read the Pi system prompt and tool declarations from the normalized transcript that Pi 0.86 hands `streamSimple` (`role: "system"` messages, via pi-ai's `getCurrentSystemPrompt` / `getCurrentTools`), so requests on Pi >= 0.86 carry the host prompt and tools again instead of sending neither; raw `Context` fields still win on older Pi (#244). - Enroll newly bound Claustrum OAuth accounts into the OpenCode routing pool at startup or live without a restart, after exact credential-ID and provider-account verification; immediately prime quota for sticky-balanced routing, persist only secret-free tombstone rows, preserve disabled accounts, and recover missed manifest watch events with one process-shared metadata poll. - Remove obsolete root-level build output before workspace builds so stale pre-custody CLI artifacts cannot bypass current account and Claustrum safeguards. - Give consecutive Desktop fallback notices distinct, pre-registered message IDs before the assistant; defer delivery when safe ordering is unavailable and bound notice tracking across sessions (#230). diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index b2ad0efc..79ba7457 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -31,6 +31,129 @@ import type { Tool, ToolResultMessage, } from '@earendil-works/pi-ai' +import * as piAi from '@earendil-works/pi-ai' + +/** + * What `streamSimple` is handed, across pi versions. + * + * pi < 0.86 passes a raw `Context`: `systemPrompt` and `tools` populated, no + * `role: 'system'` messages. pi >= 0.86 passes a normalized `TranscriptContext` + * (`{ messages }` only): the prompt and the tool declarations are folded into a + * leading system message (`content` + `toolsAdded`), and later system messages + * may carry `toolsAdded` / `toolsRemoved` / `sections` deltas. Reading + * `context.systemPrompt` / `context.tools` on that shape yields `undefined` for + * both, so the request goes out with no host prompt and no tools — HTTP 200 and + * a reply from a bare model that says it has no bash tool. + */ +export type RequestContext = { + messages: Message[] + systemPrompt?: unknown + tools?: Tool[] +} + +export type ResolvedRequestContext = { + systemPrompt: unknown + tools: Tool[] + messages: Message[] +} + +type TranscriptSystemMessage = { + role: 'system' + content?: unknown + toolsAdded?: Tool[] + toolsRemoved?: { name: string }[] +} + +function isTranscriptSystemMessage( + message: unknown, +): message is TranscriptSystemMessage { + return ( + typeof message === 'object' && + message !== null && + (message as { role?: unknown }).role === 'system' + ) +} + +/** + * Resolve the host prompt, tool list, and message list from either context shape. + * + * On pi >= 0.86 the transcript helpers exported by pi-ai (`collapseSystemMessages`, + * `getCurrentSystemPrompt`, `getCurrentTools`) replay every system message into + * the current prompt and tool set; they are the same functions pi's built-in + * providers use, so `sections` patches and tool removals resolve identically. + * They are reached through the namespace import and a `typeof` check rather + * than named imports: on pi-ai < 0.86 the exports do not exist, and a missing + * named export fails the whole extension at load. + * + * If a transcript carries system messages but the helpers are absent (only + * reachable in tests pinned to an older pi-ai), a minimal replay keeps the + * prompt and tools rather than dropping them: system text concatenated in + * order, `toolsRemoved` then `toolsAdded` applied per message. + * + * The raw `Context` fields win when there is no system message, so pi < 0.86 + * is unchanged. + */ +/** + * The pi-ai transcript helpers, as an optional set. Production reads them off + * the `@earendil-works/pi-ai` namespace, which pi's extension loader aliases to + * the host's own bundled pi-ai — so their presence tracks the running pi, not + * this package's lockfile. Tests pass a stub (or `{}`) to pin which branch runs. + */ +export type TranscriptHelpers = { + collapseSystemMessages?: (context: { messages: Message[] }) => { + messages: Message[] + } + getCurrentSystemPrompt?: (messages: readonly { role: string }[]) => string + getCurrentTools?: (messages: readonly { role: string }[]) => Tool[] +} + +export function resolveRequestContext( + context: RequestContext, + helpers: TranscriptHelpers = piAi as unknown as TranscriptHelpers, +): ResolvedRequestContext { + const hasSystemMessages = context.messages.some(isTranscriptSystemMessage) + if (!hasSystemMessages) { + return { + systemPrompt: context.systemPrompt, + tools: context.tools ?? [], + messages: context.messages, + } + } + + if ( + typeof helpers.collapseSystemMessages === 'function' && + typeof helpers.getCurrentSystemPrompt === 'function' && + typeof helpers.getCurrentTools === 'function' + ) { + const transcript = helpers.collapseSystemMessages({ + messages: context.messages, + }) + return { + systemPrompt: helpers.getCurrentSystemPrompt(transcript.messages), + tools: helpers.getCurrentTools(transcript.messages), + messages: transcript.messages.filter( + (message) => !isTranscriptSystemMessage(message), + ), + } + } + + const parts: string[] = [] + const tools = new Map() + for (const message of context.messages as unknown[]) { + if (!isTranscriptSystemMessage(message)) continue + const text = systemPromptText(message.content) + if (text.length > 0) parts.push(text) + for (const removed of message.toolsRemoved ?? []) tools.delete(removed.name) + for (const added of message.toolsAdded ?? []) tools.set(added.name, added) + } + return { + systemPrompt: parts.join('\n\n'), + tools: [...tools.values()], + messages: context.messages.filter( + (message) => !isTranscriptSystemMessage(message), + ), + } +} // Anchor identifying Pi's documentation paragraph — the only part of the prompt // that Anthropic currently rejects in system[]. Unknown prompt shapes take the @@ -545,7 +668,7 @@ function applyCacheMode( export async function buildAnthropicRequest( modelId: string, - context: Context, + context: Context | RequestContext, options: SimpleStreamOptions | undefined, cache: { enabled: boolean; mode: Cache1hMode }, fastModeEnabled = false, @@ -555,7 +678,8 @@ export async function buildAnthropicRequest( thinkingPrefixMismatchBehavior?: ThinkingPrefixMismatchBehavior } = {}, ): Promise<{ body: AnthropicRequestBody; bodyText: string }> { - const messages = convertMessages(context.messages, modelId) + const request = resolveRequestContext(context) + const messages = convertMessages(request.messages, modelId) // Strip trailing assistant messages — Anthropic rejects prefill on some models while ( messages.length && @@ -577,7 +701,7 @@ export async function buildAnthropicRequest( }, { type: 'text', text: CLAUDE_CODE_IDENTITY }, ] - const systemPrompt = systemPromptText(context.systemPrompt) + const systemPrompt = systemPromptText(request.systemPrompt) if (systemPrompt.trim()) { // Pi's prompt cannot sit whole in the top-level system[] array: two lines of // its documentation paragraph (the docs/*.md enumeration and the "follow .md @@ -618,7 +742,7 @@ export async function buildAnthropicRequest( messages, } - const tools = convertTools(context.tools) + const tools = convertTools(request.tools) if (tools?.length) body.tools = tools if (fastModeEnabled && isFastModeSupportedModel(modelId)) { diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index 9be15212..050b71fa 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -71,7 +71,12 @@ import { type ToolCall, } from '@earendil-works/pi-ai' -import { buildAnthropicRequest, fromClaudeCodeToolName } from './convert.ts' +import { + buildAnthropicRequest, + fromClaudeCodeToolName, + type RequestContext, + resolveRequestContext, +} from './convert.ts' import { getPiAccountStoragePath } from './paths.ts' function errorText(error: unknown) { @@ -359,7 +364,7 @@ export async function* parseSse( async function sendAnthropicRequest(options: { model: Model - context: Context + context: Context | RequestContext streamOptions?: SimpleStreamOptions accessToken?: string apiAccount?: ApiKeyAccount @@ -525,7 +530,7 @@ async function firstStreamingError( async function executeWithFallback(options: { model: Model - context: Context + context: Context | RequestContext streamOptions?: SimpleStreamOptions primaryAccessToken: string storagePath: string @@ -1191,7 +1196,7 @@ async function executeWithFallback(options: { export function streamCortexKitAnthropic( model: Model, - context: Context, + context: Context | RequestContext, options?: SimpleStreamOptions, effortTransitions?: readonly MidConversationEffortTransition[], ): AssistantMessageEventStream { @@ -1205,6 +1210,9 @@ export function streamCortexKitAnthropic( const accessToken = options?.apiKey ?? '' if (!accessToken) throw new Error('Missing Anthropic OAuth access token') + // pi >= 0.86 carries the tool declarations in the transcript's system + // messages, not on `context.tools` (see resolveRequestContext). + const { tools: contextTools } = resolveRequestContext(context) const storagePath = getPiAccountStoragePath() const response = await executeWithFallback({ model, @@ -1267,7 +1275,7 @@ export function streamCortexKitAnthropic( output.content.push({ type: 'toolCall', id: String(block.id), - name: fromClaudeCodeToolName(String(block.name), context.tools), + name: fromClaudeCodeToolName(String(block.name), contextTools), arguments: {}, partialJson: '', index: event.index, diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index 55fb1990..3ce0fe3b 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -4,7 +4,7 @@ import { type ProviderAccountUuid, } from '@cortexkit/anthropic-auth-core' import type { Context, Message } from '@earendil-works/pi-ai' -import { buildAnthropicRequest } from '../convert' +import { buildAnthropicRequest, resolveRequestContext } from '../convert' function userMsg(text: string): Message { return { role: 'user', content: text, timestamp: 0 } @@ -1242,3 +1242,147 @@ describe('buildAnthropicRequest — cache breakpoint budget', () => { }, ) }) + +// pi >= 0.86 hands streamSimple a normalized TranscriptContext: `{ messages }` +// only, with the host prompt and tool declarations folded into a leading +// role: 'system' message and later system messages carrying tool deltas. +describe('resolveRequestContext — pi 0.86 transcript shape', () => { + const readTool = { + name: 'read', + description: 'Read a file', + parameters: { type: 'object', properties: {}, required: [] }, + } as any + const bashTool = { + name: 'bash', + description: 'Run a command', + parameters: { type: 'object', properties: {}, required: [] }, + } as any + + function systemMsg( + content: string, + extra: Record = {}, + ): Message { + return { role: 'system', content, timestamp: 0, ...extra } as any + } + + test('raw Context (pi < 0.86) is returned unchanged', () => { + const context = { + messages: [userMsg('hello')], + systemPrompt: 'test prompt', + tools: [readTool], + } + const resolved = resolveRequestContext(context as any) + expect(resolved.systemPrompt).toBe('test prompt') + expect(resolved.tools).toEqual([readTool]) + expect(resolved.messages).toBe(context.messages) + }) + + test('reads the prompt and tools out of the leading system message', () => { + const resolved = resolveRequestContext({ + messages: [ + systemMsg('test prompt', { toolsAdded: [readTool] }), + userMsg('hello'), + ], + }) + expect(resolved.systemPrompt).toBe('test prompt') + expect(resolved.tools).toEqual([readTool]) + expect(resolved.messages).toEqual([userMsg('hello')]) + }) + + test('replays later system messages: appended text, tool additions and removals', () => { + const resolved = resolveRequestContext({ + messages: [ + systemMsg('base', { toolsAdded: [readTool, bashTool] }), + userMsg('hello'), + assistantMsg('hi'), + systemMsg('more', { toolsRemoved: [{ name: 'read' }] }), + userMsg('again'), + ], + }) + expect(resolved.systemPrompt).toBe('base\n\nmore') + expect(resolved.tools.map((tool) => tool.name)).toEqual(['bash']) + expect(resolved.messages.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'user', + ]) + }) + + // Which branch runs is decided by the host pi (its loader aliases pi-ai to the + // host's bundle), not by this package's lockfile, so both are pinned here with + // explicit helper sets rather than left to whatever pi-ai bun installed. + test('uses the pi-ai transcript helpers when the host exports them', () => { + const calls: string[] = [] + const helpers = { + collapseSystemMessages: (context: { messages: Message[] }) => { + calls.push('collapse') + return { + messages: [ + systemMsg('from helpers', { toolsAdded: [bashTool] }), + ...context.messages.filter( + (message) => (message as { role: string }).role !== 'system', + ), + ], + } + }, + getCurrentSystemPrompt: () => { + calls.push('prompt') + return 'from helpers (rendered)' + }, + getCurrentTools: () => { + calls.push('tools') + return [bashTool] + }, + } + const resolved = resolveRequestContext( + { + messages: [ + systemMsg('ignored by the stub', { toolsAdded: [readTool] }), + userMsg('hello'), + ], + }, + helpers, + ) + expect(calls).toEqual(['collapse', 'prompt', 'tools']) + expect(resolved.systemPrompt).toBe('from helpers (rendered)') + expect(resolved.tools).toEqual([bashTool]) + expect(resolved.messages).toEqual([userMsg('hello')]) + }) + + test('falls back to the local replay when the host has no helpers', () => { + const resolved = resolveRequestContext( + { + messages: [ + systemMsg('base', { toolsAdded: [readTool] }), + userMsg('hello'), + systemMsg('more', { toolsAdded: [bashTool] }), + ], + }, + {}, + ) + expect(resolved.systemPrompt).toBe('base\n\nmore') + expect(resolved.tools.map((tool) => tool.name)).toEqual(['read', 'bash']) + }) + + test('buildAnthropicRequest sends the prompt and tools from a transcript context', async () => { + const { body } = await buildAnthropicRequest( + TEST_MODEL_ID, + { + messages: [ + systemMsg(PI_PROMPT, { toolsAdded: [bashTool] }), + userMsg('hello'), + ], + }, + undefined, + defaultCache, + ) + // system[] = billing header, identity, then the recognized host prompt. + expect(body.system).toHaveLength(3) + expect(String(body.system?.[2]?.text)).toContain('KEEP ONE') + expect(body.tools?.map((tool) => tool.name)).toEqual(['Bash']) + // The system message itself never reaches messages[]. + expect(body.messages.every((message) => message.role !== 'system')).toBe( + true, + ) + }) +})