pi-openai-codex-compat 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,62 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.0.14 - 2026-09-22
6
+
7
+ ### Changed
8
+
9
+ - Require Pi `>=0.87.0 <0.88.0` and use Pi 0.87.0 as the development and
10
+ packaged-CLI integration baseline. The load-time host check now also
11
+ requires Pi 0.87's session projection API.
12
+ - Honour Pi 0.87 `context_edit` entries when building request history from
13
+ the branch, so omitted or replaced messages match what Pi's own adapters
14
+ send.
15
+ - Preserve mid-conversation prompt updates when filtering compaction summaries.
16
+ - Skip percentage compaction when nothing has been sampled yet. Pi 0.87
17
+ estimates the prompt and tool declarations before the first response, so a
18
+ low threshold could trigger an empty compaction request.
19
+ - Send Pi's leading system message as the prompt and later system messages as
20
+ inline developer items, exactly as Pi AI's Codex adapter does. Previously
21
+ every system message was replayed into one `instructions` string, so any
22
+ prompt-section change rewrote the stable prefix. Models without
23
+ mid-conversation system-message support still receive one collapsed prompt.
24
+ - Keep every developer and system message in the native compaction checkpoint,
25
+ in place and outside the 64k retained-context budget, so prompt updates
26
+ survive compaction. Only user messages are charged to the budget.
27
+
28
+ ### Fixed
29
+
30
+ - Keep the conversation in overflow-recovery compaction on Pi 0.87. Pi now
31
+ records the failed attempt's omission as a session entry whose parent is
32
+ that attempt; removing the attempt from the branch copy broke the entry
33
+ chain and the compaction request contained only the recovered prefix.
34
+ - Hold a system message that lands between a tool call and its results until
35
+ after those results in replayed history, matching Pi AI.
36
+ - Send the branch's leading system message as compaction `instructions`
37
+ instead of Pi's fully replayed prompt, so Pi-driven compaction shares the
38
+ turn requests' prompt prefix.
39
+ - Reuse the last turn's exact tool declarations for `/compact` and threshold
40
+ compaction while they still describe the active tools. Rebuilding them from
41
+ the tool registry lost Pi's strict schemas for built-in tools, so every
42
+ Pi-driven compaction request missed the turn requests' prompt-cache prefix.
43
+
44
+ ## 0.0.13 - 2026-09-21
45
+
46
+ ### Fixed
47
+
48
+ - Declare the complete current tool set in the top-level `tools` field on
49
+ every request and never inside `input`, as the official Codex client does.
50
+ Tools added mid-session were replayed as inline `additional_tools` items;
51
+ the Codex backend then often treated other tools as unavailable, and after a
52
+ percentage-triggered compaction the inline declarations were gone entirely,
53
+ so the model reported that it had no callable tools. A mid-session tool
54
+ change now costs one prompt-cache miss instead.
55
+
56
+ ### Changed
57
+
58
+ - Use Pi 0.86.1 as the development and packaged-CLI integration baseline. The
59
+ supported range stays `>=0.86.0 <0.87.0`.
60
+
5
61
  ## 0.0.12 - 2026-09-20
6
62
 
7
63
  ### Fixed
package/README.md CHANGED
@@ -22,7 +22,7 @@ Pi provides the Codex OAuth flow and model catalog. At session start, this packa
22
22
  ## Requirements
23
23
 
24
24
  - Node.js 22.19 or newer
25
- - Pi `>=0.86.0 <0.87.0`
25
+ - Pi `>=0.87.0 <0.88.0`
26
26
  - An OpenAI Codex login in Pi
27
27
 
28
28
  Restart Pi after upgrading its runtime. `/reload` reloads extensions but cannot
@@ -67,8 +67,8 @@ The compatibility baseline is official Codex CLI `0.149.1`, released August 24,
67
67
  | Session storage | Pi remains the canonical session owner. Opaque Codex compaction checkpoints are stored in Pi `compaction` entries, and otherwise lossy Responses output is stored in sparse custom native-response entries. Official Codex owns a rollout/thread store directly. |
68
68
  | Branching | Checkpoints and native response overrides follow Pi's active session branch. Official Codex uses its own thread, turn, rollback, fork, and context-window lineage. |
69
69
  | Model switching | This package rejects model switches while the active Pi branch contains a native Codex checkpoint because the checkpoint is model-specific. |
70
- | System instructions | Replay Pi's system-message sections into the complete current prompt. Responses Lite models prepend it as developer input after `additional_tools`; other models send it through Responses `instructions`. Do not replay older Pi system-prompt text alongside that current prompt. `/reload`, forced prompts, compaction snapshots, and resumed sessions use the current state without rewriting old checkpoints. |
71
- | Dynamic tool declarations | Replay additive `toolsAdded` system messages in place as `additional_tools` or tool-search pairs on capable models. Tool removal or redefinition uses the complete current top-level tool set instead. Compaction rebases declarations so tools remain available after their history is replaced. |
70
+ | System instructions | Send the leading Pi system message as the prompt, as Pi AI's Codex adapter does: Responses Lite models prepend it as developer input after `additional_tools`; other models send it through Responses `instructions`. Later Pi system messages (`/reload`, section changes) travel inline as developer items on models that accept mid-conversation system messages and collapse into the leading prompt otherwise. A forced prompt replaces the leading message. Compaction snapshots and resumed sessions lead with Pi's replayed prompt without rewriting old checkpoints. |
71
+ | Dynamic tool declarations | Send the complete current tool set, replayed from Pi's `toolsAdded` and `toolsRemoved` system messages, in the top-level `tools` field on every request, as the official Codex client does. Tool declarations never travel inside `input`: inline `additional_tools` items and synthetic tool-search pairs made the Codex backend unreliable about which tools exist. A mid-session tool change costs one prompt-cache miss, and native checkpoints need no declaration rebase. |
72
72
  | Turn metadata | Requests send a persisted installation id plus Pi-derived session, thread, context-window, turn, source, sandbox, request-kind, and nested compaction-operation metadata in `client_metadata` and compatible headers. The in-memory context-window number advances after successful compaction. One turn id is reused throughout a Pi agent run, while prewarm has its own id. First-party requests also carry Codex's model-and-tier routing hint. The provider captures the server-issued `x-codex-turn-state` once per agent run, replays it on WebSocket retries, SSE requests, and WebSocket-to-SSE fallback, and records all identity values in transport diagnostics. Pi does not reconstruct prior window number after extension reload/session resume or reproduce workspace Git/parent/subagent/Code Mode metadata. Each marked Pi tree branch receives its own persisted thread UUID. |
73
73
  | Cache preparation | Before the first cache-enabled WebSocket turn, the package prewarms only the stable instruction/tool prefix: ordinary Responses uses empty `input`, while Responses Lite uses `additional_tools` plus the developer instructions. The first generated request then contributes only dynamic conversation input to the continuation. No explicit prompt-cache breakpoints are added. |
74
74
  | Mid-turn compaction | Provider-boundary percentage compaction preserves a successful `end_turn:false` prefix as its own Pi assistant message, installs a checkpoint, and continues without synthetic model input. Pi threshold compaction normally runs after the agent response; after Codex output-token truncation, the extension queues a hidden continuation so threshold compaction completes before sampling resumes. Official Codex owns this sampling and compaction loop directly. |
@@ -76,7 +76,7 @@ The compatibility baseline is official Codex CLI `0.149.1`, released August 24,
76
76
  | Transport recovery | WebSocket failures before model-visible output receive five fresh-connection retries before sticky SSE fallback; SSE transport failures receive five retries. Official Codex `0.149.1` separately retries sampling connection-establishment failures indefinitely with delays capped at 60 seconds. The package remains bounded because Fetch does not expose Reqwest's narrower connection-error category, and a phase-only approximation could indefinitely repeat requests that reached the server. |
77
77
  | Compaction lifecycle events | Pre-turn percentage compaction writes through Pi's mutable session manager and cannot emit Pi's internal `session_compact` event through the public extension API. Mid-response percentage boundaries and manual, threshold, or overflow compactions initiated by Pi emit the normal lifecycle. |
78
78
  | Header hooks | An internal percentage-compaction request reuses the already transformed provider headers. It cannot independently rerun Pi's `before_provider_headers` hook. |
79
- | Native retained context | Deliberately differs from current Codex. The package retains recent user/developer/system messages under a text-only 64k budget before the opaque compaction item. Current Codex applies a second installed-history filter that drops developer/system wrappers and non-real-user messages, can retain eligible structured agent commentary, and trims oversized function outputs before compaction. Codex `0.149.1` also adds a default-disabled mode that charges retained images to the budget and keeps each image with adjacent harness labels as an atomic boundary unit. Pi keeps its existing checkpoint shape by design. |
79
+ | Native retained context | Deliberately differs from current Codex. The package retains recent user messages under a text-only 64k budget before the opaque compaction item, and keeps every developer and system message in place outside that budget so prompt updates are never compacted away. Current Codex applies a second installed-history filter that drops developer/system wrappers and non-real-user messages, can retain eligible structured agent commentary, and trims oversized function outputs before compaction. Codex `0.149.1` also adds a default-disabled mode that charges retained images to the budget and keeps each image with adjacent harness labels as an atomic boundary unit. Pi keeps its existing checkpoint shape by design. |
80
80
  | Tool namespaces | Responses Lite groups Pi's ordinary function/custom declarations into upstream's canonical `functions` namespace and maps that default namespace back to bare Pi names. Pi registers dotted names such as `web.run` as exact flat identifiers, so the provider converts only the fixed extension-owned allowlist into non-default Responses namespace/member identities and rejects unknown or ambiguously flat namespaced calls. |
81
81
  | Capability gating | Tool activation is based on the selected `openai-codex` provider plus package settings. It does not reproduce every official model-metadata, plan, feature-stage, executor, mode, or account gate. |
82
82
  | Sandbox and approvals | Pi extensions run with full process permissions. Command tools, `apply_patch`, local image reads, generated-image writes, and sibling Codex endpoints do not use Codex's sandbox, permission-profile, environment, or approval lifecycle. |
@@ -115,19 +115,19 @@ The package implements the Codex-specific pieces that fit a provider compatibili
115
115
 
116
116
  The following official Codex facilities are not exact equivalents in this package:
117
117
 
118
- | Official Codex facility | Pi/package behavior |
119
- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
- | `view_image` | Pi `read` already accepts images; no canonical `view_image` alias is registered. |
121
- | `update_plan`, `request_user_input`, permissions, and environment tools | Not implemented by this package. |
122
- | `send_user_message_async` | Not implemented; Pi owns visible assistant output and user-message history, while this package does not own Codex's asynchronous message-injection lifecycle. |
123
- | Context-window and clock tools | Not implemented. Compaction remains host/provider managed rather than model managed. |
124
- | MCP resources and dynamic MCP tools | Pi does not provide this package with Codex's MCP runtime. |
125
- | Plugin/connector installation | Not implemented; package installation remains an explicit Pi/user operation. |
126
- | Searchable `tool_search` catalog | Pi can replay additive tool-search history for capable models, but this package does not implement Codex's searchable deferred-tool catalog and ranking runtime. |
127
- | Multi-agent V1/V2 coordination | Out of scope; no Codex agent tree, mailbox, task-path, or fork-depth runtime is implemented. |
128
- | JavaScript Code Mode and yielded cells | Out of scope; no V8 isolate, nested tool namespace, cell storage, or `wait` lifecycle is implemented. |
129
- | Goals, memories, and remote skill-resource tools | Not implemented; Pi's sessions, files, and native skills remain separate systems. |
130
- | Remote/deferred execution environments | Not implemented. |
118
+ | Official Codex facility | Pi/package behavior |
119
+ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
+ | `view_image` | Pi `read` already accepts images; no canonical `view_image` alias is registered. |
121
+ | `update_plan`, `request_user_input`, permissions, and environment tools | Not implemented by this package. |
122
+ | `send_user_message_async` | Not implemented; Pi owns visible assistant output and user-message history, while this package does not own Codex's asynchronous message-injection lifecycle. |
123
+ | Context-window and clock tools | Not implemented. Compaction remains host/provider managed rather than model managed. |
124
+ | MCP resources and dynamic MCP tools | Pi does not provide this package with Codex's MCP runtime. |
125
+ | Plugin/connector installation | Not implemented; package installation remains an explicit Pi/user operation. |
126
+ | Searchable `tool_search` catalog | This package declares every tool at the top level and does not implement Codex's searchable deferred-tool catalog and ranking runtime. |
127
+ | Multi-agent V1/V2 coordination | Out of scope; no Codex agent tree, mailbox, task-path, or fork-depth runtime is implemented. |
128
+ | JavaScript Code Mode and yielded cells | Out of scope; no V8 isolate, nested tool namespace, cell storage, or `wait` lifecycle is implemented. |
129
+ | Goals, memories, and remote skill-resource tools | Not implemented; Pi's sessions, files, and native skills remain separate systems. |
130
+ | Remote/deferred execution environments | Not implemented. |
131
131
 
132
132
  See [`OFFICIAL_CODEX_CLI_TOOL_CATALOG.md`](OFFICIAL_CODEX_CLI_TOOL_CATALOG.md) for the complete researched Codex tool inventory, and [`CUSTOM_CODEX_PROVIDER_WEB_REFERENCES.md`](CUSTOM_CODEX_PROVIDER_WEB_REFERENCES.md) for the unimplemented citation/reference design.
133
133
 
@@ -327,7 +327,8 @@ The extension handles native compaction for `openai-codex`. It follows the Codex
327
327
 
328
328
  1. Send normal Responses history followed by `{ "type": "compaction_trigger" }`.
329
329
  2. Validate the returned opaque `compaction` item.
330
- 3. Retain approximately 64,000 tokens of recent user, developer, and system context.
330
+ 3. Retain approximately 64,000 tokens of recent user messages, plus every developer and
331
+ system message outside that budget.
331
332
  4. Persist the opaque checkpoint in the Pi session and replay it on later requests.
332
333
 
333
334
  Ordinary responses and compaction use the same extension-managed SSE/WebSocket transport. Before the first WebSocket turn for a session and model, the provider performs a best-effort v2 `generate: false` prewarm of the static instruction/tool prefix, then generates the dynamic conversation input from its continuation. With `responsesLite: false`, supported models use the ordinary Responses envelope and receive their own ordinary-prefix prewarm.
@@ -587,7 +588,7 @@ mise run test:live:codex
587
588
  The task obtains the local Codex bearer token and runs the tests with
588
589
  `gpt-5.6-luna` at medium reasoning effort.
589
590
  It also packs the extension and loads that archive through the shipped Pi
590
- 0.86.0 CLI. Ordinary Responses and Responses Lite tests exercise tool calls,
591
+ 0.87.0 CLI. Ordinary Responses and Responses Lite tests exercise tool calls,
591
592
  prompt reload, native compaction, and persisted-session resume against Codex.
592
593
  Test credentials stay in memory and child-process environments. Test sessions
593
594
  and configuration are isolated from the user's agent directory.
@@ -606,7 +607,9 @@ search, and compaction continuation.
606
607
  2. The command builds the exact package locally and runs live CLI tests against that archive. It also runs the existing live SDK tests against checkout source. Both suites must pass before it records the archive's SHA-256 in an SSH-signed release commit, proves a clean rebuild is reproducible, and creates a lightweight tag. Missing credentials or failing live tests stop the release.
607
608
  3. Inspect the result, then push atomically with `git push --atomic origin main vX.Y.Z`.
608
609
  4. A read-only GitHub Actions job validates and packs the package. After approval in the tag-restricted `npm-publish` environment, a separate GitHub-owned job verifies the signature and signed digest before attesting and staging that exact archive through npm trusted publishing.
609
- 5. Approve the staged package on npmjs.com, or with `npm stage approve <stage-id>`.
610
+ 5. A final job creates the immutable GitHub release for the tag from the same verified archive, its
611
+ checksum, and the version's `CHANGELOG.md` section (`Unreleased` for prereleases).
612
+ 6. Approve the staged package on npmjs.com, or with `npm stage approve <stage-id>`.
610
613
 
611
614
  Stable releases use `latest`; prereleases derive their npm dist-tag from the first prerelease identifier.
612
615
 
@@ -276,17 +276,23 @@ function shortenMessage(
276
276
  : undefined;
277
277
  }
278
278
 
279
- function retainedRole(item: ResponsesInputItem): boolean {
280
- return (
281
- Value.Check(RESPONSES_MESSAGE_ITEM_SCHEMA, item) &&
282
- (item.role === "user" || item.role === "developer" || item.role === "system")
283
- );
279
+ function messageRole(item: ResponsesInputItem): string | undefined {
280
+ return Value.Check(RESPONSES_MESSAGE_ITEM_SCHEMA, item) ? item.role : undefined;
281
+ }
282
+
283
+ /** Developer and system messages carry prompt state, not conversation history. */
284
+ function isInstructionMessage(item: ResponsesInputItem): boolean {
285
+ const role = messageRole(item);
286
+ return role === "developer" || role === "system";
284
287
  }
285
288
 
286
289
  /**
287
- * Reproduce Codex remote-compaction-v2 history installation: select recent
288
- * user/developer/system messages from newest to oldest, then restore their
289
- * chronological order. The oldest selected message may be truncated.
290
+ * Reproduce Codex remote-compaction-v2 history installation for user messages:
291
+ * select recent user messages from newest to oldest under the budget, then
292
+ * restore chronological order. The oldest selected user message may be
293
+ * truncated. Developer and system messages are prompt updates rather than
294
+ * conversation history: every one of them is retained in place, outside the
295
+ * budget, and never truncated.
290
296
  */
291
297
  export function selectRetainedContext(
292
298
  history: readonly ResponsesInputItem[],
@@ -295,10 +301,14 @@ export function selectRetainedContext(
295
301
  let remaining = budget;
296
302
  const newestFirst: ResponsesInputItem[] = [];
297
303
 
298
- for (let index = history.length - 1; index >= 0 && remaining > 0; index--) {
304
+ for (let index = history.length - 1; index >= 0; index--) {
299
305
  const item = history[index];
300
306
  if (item === undefined) continue;
301
- if (!retainedRole(item)) continue;
307
+ if (isInstructionMessage(item)) {
308
+ newestFirst.push(structuredClone(item));
309
+ continue;
310
+ }
311
+ if (remaining <= 0 || messageRole(item) !== "user") continue;
302
312
 
303
313
  const tokens = Math.max(1, messageTextTokens(item));
304
314
  if (tokens <= remaining) {
@@ -1,4 +1,4 @@
1
- import type { SessionEntry, ToolInfo } from "@earendil-works/pi-coding-agent";
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import type { Api, Model } from "@earendil-works/pi-ai";
3
3
  import { providerHistory, type GrammarToolInputProperties } from "../compaction-checkpoint.ts";
4
4
  import type { ImageDetail } from "../config.ts";
@@ -37,7 +37,6 @@ export function splitUnsampledUserInput(options: {
37
37
  branch: readonly SessionEntry[];
38
38
  history: readonly ResponsesInputItem[];
39
39
  model: Model<Api>;
40
- allTools: readonly ToolInfo[];
41
40
  grammarToolInputProperties: GrammarToolInputProperties;
42
41
  imageDetail: ImageDetail;
43
42
  }):
@@ -56,10 +55,8 @@ export function splitUnsampledUserInput(options: {
56
55
  const encoded = providerHistory({
57
56
  branch: options.branch.slice(unsampledIndex),
58
57
  wireModel: options.model,
59
- allTools: options.allTools,
60
58
  grammarToolInputProperties: options.grammarToolInputProperties,
61
59
  imageDetail: options.imageDetail,
62
- anchorsToolAdditions: false,
63
60
  });
64
61
  if (encoded.length === 0 || encoded.length > options.history.length) return { kind: "unsafe" };
65
62
 
@@ -5,10 +5,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  clampThinkingLevel,
7
7
  createAssistantMessageEventStream,
8
- getCurrentSystemPrompt,
8
+ getCurrentTools,
9
9
  getDeclaredTools,
10
- normalizeContext,
11
- resolveTranscriptTools,
12
10
  type Api,
13
11
  type AssistantMessage,
14
12
  type AssistantMessageEventStream,
@@ -30,6 +28,8 @@ import {
30
28
  checkpointData,
31
29
  providerHistory,
32
30
  remoteCompactionMarkerSummary,
31
+ requestInstructions,
32
+ resolveRequestTranscript,
33
33
  responsesCompatibility,
34
34
  searchCheckpoint,
35
35
  type CheckpointData,
@@ -495,7 +495,6 @@ export class CodexProviderRuntime {
495
495
  context: TranscriptContext,
496
496
  grammarToolInputProperties: GrammarToolInputProperties,
497
497
  sessionId: string | undefined,
498
- anchorsToolAdditions: boolean,
499
498
  ): ResponsesInputItem[] {
500
499
  const scope = sessionId ? this.scopes.get(sessionId) : undefined;
501
500
  const compat = responsesCompatibility(model.compat);
@@ -503,10 +502,9 @@ export class CodexProviderRuntime {
503
502
  const nativeItems = new Map<string, ResponsesOutputItem[]>();
504
503
  const serializationOptions: NonNullable<Parameters<typeof convertResponsesMessages>[3]> = {
505
504
  includeSystemPrompt: false,
506
- includeSystemUpdates: false,
507
- supportsMidConvoSystemMessages: true,
508
- supportsAdditionalTools: anchorsToolAdditions && (compat.supportsAdditionalTools ?? false),
509
- supportsToolSearch: anchorsToolAdditions && (compat.supportsToolSearch ?? false),
505
+ supportsMidConvoSystemMessages: compat.supportsMidConvoSystemMessages ?? false,
506
+ supportsAdditionalTools: false,
507
+ supportsToolSearch: false,
510
508
  grammarToolInputProperties,
511
509
  toolOptions: {
512
510
  strict: false,
@@ -525,10 +523,8 @@ export class CodexProviderRuntime {
525
523
  return providerHistory({
526
524
  branch: scope.manager.getBranch(),
527
525
  wireModel: model,
528
- allTools: this.pi.getAllTools(),
529
526
  grammarToolInputProperties,
530
527
  imageDetail: scope.config.imageDetail,
531
- anchorsToolAdditions,
532
528
  });
533
529
  }
534
530
 
@@ -543,39 +539,21 @@ export class CodexProviderRuntime {
543
539
  turnId,
544
540
  } = options;
545
541
  const compat = responsesCompatibility(model.compat);
546
- const scope = runtimeSessionId ? this.scopes.get(runtimeSessionId) : undefined;
547
- const threshold = scope?.config.autoCompactAtPercent;
548
- // Percentage compaction drops historical tool additions. Rebase declarations
549
- // before serialization so both the compaction and its continuation stay valid.
550
- const compactionPending =
551
- threshold !== undefined &&
552
- scope?.contextPercent !== null &&
553
- scope?.contextPercent !== undefined &&
554
- scope.contextPercent >= threshold;
555
- const toolPlacement = resolveTranscriptTools(
556
- context.messages,
557
- !compactionPending &&
558
- ((compat.supportsAdditionalTools ?? false) || (compat.supportsToolSearch ?? false)),
559
- );
560
- // A forced prompt can project all tools into a single leading system message.
561
- // Do not replay additions from the stored branch after that projection.
562
- const anchorsToolAdditions =
563
- toolPlacement.anchorsAdditions &&
564
- context.messages
565
- .slice(1)
566
- .some((message) => message.role === "system" && (message.toolsAdded?.length ?? 0) > 0);
542
+ // Every request declares the complete current tool set at the top level,
543
+ // as the official Codex client does. Replaying additions inside the input
544
+ // made the Codex backend unreliable about which tools exist, and a native
545
+ // checkpoint drops such history anyway. A mid-session tool change costs one
546
+ // cache miss.
547
+ const requestTools = getCurrentTools(context.messages);
548
+ // The leading system message is the prompt; later system messages travel
549
+ // inline in `input` (or collapsed into the leading message by
550
+ // resolveRequestTranscript), as Pi AI's Codex adapter does.
567
551
  let body: JsonRecord = {
568
552
  model: model.id,
569
553
  store: false,
570
554
  stream: true,
571
- instructions: getCurrentSystemPrompt(context.messages) || "You are a helpful assistant.",
572
- input: this.wireHistory(
573
- model,
574
- context,
575
- grammarToolInputProperties,
576
- runtimeSessionId,
577
- anchorsToolAdditions,
578
- ),
555
+ instructions: requestInstructions(context) || "You are a helpful assistant.",
556
+ input: this.wireHistory(model, context, grammarToolInputProperties, runtimeSessionId),
579
557
  text: { verbosity: requestOptions.textVerbosity ?? "low" },
580
558
  include: ["reasoning.encrypted_content"],
581
559
  tool_choice: requestOptions.toolChoice ?? "auto",
@@ -594,8 +572,8 @@ export class CodexProviderRuntime {
594
572
  ),
595
573
  });
596
574
  if (requestOptions.serviceTier !== undefined) body.service_tier = requestOptions.serviceTier;
597
- if (toolPlacement.requestTools.length > 0) {
598
- body.tools = convertResponsesTools(toolPlacement.requestTools, {
575
+ if (requestTools.length > 0) {
576
+ body.tools = convertResponsesTools(requestTools, {
599
577
  strict: false,
600
578
  supportsStrictMode: compat.supportsStrictMode ?? true,
601
579
  supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools ?? false,
@@ -758,7 +736,6 @@ export class CodexProviderRuntime {
758
736
  branch,
759
737
  history,
760
738
  model,
761
- allTools: this.pi.getAllTools(),
762
739
  grammarToolInputProperties,
763
740
  imageDetail: scope.config.imageDetail,
764
741
  });
@@ -769,6 +746,10 @@ export class CodexProviderRuntime {
769
746
  );
770
747
  return body;
771
748
  }
749
+ // Pi 0.87 estimates the prompt and tool declarations before any response
750
+ // usage exists, so a low threshold can be reached before anything was
751
+ // sampled. There is nothing to compact until the first response.
752
+ if (split.history.length === 0) return body;
772
753
 
773
754
  const compacted = await this.performCompaction({
774
755
  model,
@@ -778,7 +759,7 @@ export class CodexProviderRuntime {
778
759
  template: withoutConversationInput(body),
779
760
  instructions: isString(body.instructions)
780
761
  ? body.instructions
781
- : getCurrentSystemPrompt(context.messages) || "You are a helpful assistant.",
762
+ : requestInstructions(context) || "You are a helpful assistant.",
782
763
  grammarToolInputProperties,
783
764
  priority: scope.config.fastMode,
784
765
  compactionMetadata: responsesCompactionV2Metadata("auto", "context_limit", "pre_turn"),
@@ -820,7 +801,7 @@ export class CodexProviderRuntime {
820
801
  input: Context,
821
802
  options?: OpenAICodexResponsesOptions,
822
803
  ): AssistantMessageEventStream {
823
- const context = normalizeContext(input);
804
+ const context = resolveRequestTranscript(model, input);
824
805
  const stream = createAssistantMessageEventStream();
825
806
  const requestOptions = transportOptions(options);
826
807
  const output: AssistantMessage = {
@@ -2,12 +2,24 @@ import { isBoolean, isString } from "./value-contracts.ts";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import {
4
4
  buildSessionContext,
5
+ buildSessionProjection,
5
6
  convertToLlm,
6
7
  sessionEntryToContextMessages,
7
8
  type SessionEntry,
8
9
  type ToolInfo,
9
10
  } from "@earendil-works/pi-coding-agent";
10
- import type { Api, Message, Model, SystemMessage } from "@earendil-works/pi-ai";
11
+ import {
12
+ getInitialSystemMessage,
13
+ getSystemMessageText,
14
+ normalizeContext,
15
+ resolveTranscript,
16
+ type Api,
17
+ type Context,
18
+ type Message,
19
+ type Model,
20
+ type SystemMessage,
21
+ type TranscriptContext,
22
+ } from "@earendil-works/pi-ai";
11
23
  import { Value } from "typebox/value";
12
24
  import { APPLY_PATCH_LARK_GRAMMAR, APPLY_PATCH_TOOL_NAME } from "./apply-patch.ts";
13
25
  import { CODEX_TOOL_CALL_PROVIDERS } from "./codex-identifiers.ts";
@@ -34,6 +46,8 @@ import {
34
46
  import type { ResponsesToolDefinition } from "./responses-tool-schema.ts";
35
47
  import { convertResponsesMessages } from "./vendor/pi-ai/openai-responses-serialization.ts";
36
48
 
49
+ type AgentMessages = ReturnType<typeof sessionEntryToContextMessages>;
50
+
37
51
  export const CHECKPOINT_ENTRY_TYPE = "openai-codex-compat-remote-compaction";
38
52
  export const CHECKPOINT_FORMAT_VERSION = 1;
39
53
 
@@ -58,17 +72,16 @@ export type CheckpointSearch =
58
72
  export type GrammarToolInputProperties = ReadonlyMap<string, string>;
59
73
 
60
74
  export interface ResponsesCompatibility {
61
- supportsAdditionalTools?: boolean;
75
+ supportsMidConvoSystemMessages?: boolean;
62
76
  supportsOpenAIGrammarTools?: boolean;
63
77
  supportsStrictMode?: boolean;
64
- supportsToolSearch?: boolean;
65
78
  }
66
79
 
67
80
  export function responsesCompatibility(value: unknown): ResponsesCompatibility {
68
81
  const compatibility: ResponsesCompatibility = {};
69
82
  if (!isObject(value)) return compatibility;
70
- if (isBoolean(value["supportsAdditionalTools"])) {
71
- compatibility.supportsAdditionalTools = value["supportsAdditionalTools"];
83
+ if (isBoolean(value["supportsMidConvoSystemMessages"])) {
84
+ compatibility.supportsMidConvoSystemMessages = value["supportsMidConvoSystemMessages"];
72
85
  }
73
86
  if (isBoolean(value["supportsOpenAIGrammarTools"])) {
74
87
  compatibility.supportsOpenAIGrammarTools = value["supportsOpenAIGrammarTools"];
@@ -76,12 +89,37 @@ export function responsesCompatibility(value: unknown): ResponsesCompatibility {
76
89
  if (isBoolean(value["supportsStrictMode"])) {
77
90
  compatibility.supportsStrictMode = value["supportsStrictMode"];
78
91
  }
79
- if (isBoolean(value["supportsToolSearch"])) {
80
- compatibility.supportsToolSearch = value["supportsToolSearch"];
81
- }
82
92
  return compatibility;
83
93
  }
84
94
 
95
+ /**
96
+ * Resolve Pi's transcript the way Pi AI's Codex adapter does before building a
97
+ * request: models that accept mid-conversation system messages keep later
98
+ * system messages in place, and every other model collapses them into the
99
+ * leading system message.
100
+ */
101
+ export function resolveRequestTranscript(model: Model<Api>, context: Context): TranscriptContext {
102
+ return resolveTranscript(
103
+ normalizeContext(context),
104
+ responsesCompatibility(model.compat).supportsMidConvoSystemMessages ?? false,
105
+ );
106
+ }
107
+
108
+ /** The leading system message of a resolved transcript, rendered as Responses `instructions`. */
109
+ export function requestInstructions(context: TranscriptContext): string {
110
+ const initial = getInitialSystemMessage(context.messages);
111
+ return initial ? getSystemMessageText(initial) : "";
112
+ }
113
+
114
+ /** `instructions` for a request built from a session branch rather than Pi's live transcript. */
115
+ export function branchInstructions(model: Model<Api>, branch: readonly SessionEntry[]): string {
116
+ return requestInstructions(
117
+ resolveRequestTranscript(model, {
118
+ messages: convertToLlm(buildSessionContext([...branch]).messages),
119
+ }),
120
+ );
121
+ }
122
+
85
123
  function responsesToolParameters(tool: ToolInfo): JsonRecord {
86
124
  if (!isObject(tool.parameters)) {
87
125
  throw new Error(`Tool ${tool.name} must have JSON object parameters.`);
@@ -143,6 +181,43 @@ export function activeResponsesTools(
143
181
  : undefined;
144
182
  }
145
183
 
184
+ /**
185
+ * Whether a serialized Responses `tools` list declares exactly the active tools,
186
+ * by name and description. Namespace members count as `namespace.member`.
187
+ *
188
+ * A cached turn template carries the exact declarations the turn requests sent,
189
+ * including strict schema conversion that `ToolInfo` cannot reproduce. Reusing
190
+ * it keeps the compaction request on the turn requests' prompt-cache prefix.
191
+ */
192
+ export function declaresActiveTools(
193
+ serialized: readonly JsonRecord[],
194
+ allTools: readonly ToolInfo[],
195
+ activeNames: readonly string[],
196
+ ): boolean {
197
+ const declared = new Map<string, string | undefined>();
198
+ for (const item of serialized) {
199
+ if (item.type === "namespace" && isString(item.name) && Array.isArray(item.tools)) {
200
+ for (const member of item.tools) {
201
+ if (!isObject(member) || !isString(member.name)) return false;
202
+ declared.set(
203
+ `${item.name}.${member.name}`,
204
+ isString(member["description"]) ? member["description"] : undefined,
205
+ );
206
+ }
207
+ } else if ((item.type === "function" || item.type === "custom") && isString(item.name)) {
208
+ declared.set(item.name, isString(item["description"]) ? item["description"] : undefined);
209
+ } else {
210
+ return false;
211
+ }
212
+ }
213
+ const enabled = new Set(activeNames);
214
+ const active = allTools.filter((tool) => enabled.has(tool.name));
215
+ return (
216
+ declared.size === active.length &&
217
+ active.every((tool) => declared.has(tool.name) && declared.get(tool.name) === tool.description)
218
+ );
219
+ }
220
+
146
221
  export function remoteCompactionMarkerSummary(): string {
147
222
  return `OpenAI Codex remote compaction checkpoint (${randomUUID()}).`;
148
223
  }
@@ -150,37 +225,44 @@ export function remoteCompactionMarkerSummary(): string {
150
225
  type EncodeMessagesOptions = {
151
226
  model: Model<Api>;
152
227
  messages: Message[];
153
- allTools: readonly ToolInfo[];
154
228
  grammarToolInputProperties: GrammarToolInputProperties;
155
229
  imageDetail: ImageDetail;
156
- anchorsToolAdditions?: boolean;
157
230
  nativeAssistantItems?: ReadonlyMap<string, readonly ResponsesOutputItem[]>;
158
231
  };
159
232
 
160
233
  export type EncodeSessionEntriesOptions = {
161
234
  model: Model<Api>;
162
235
  entries: readonly SessionEntry[];
163
- allTools: readonly ToolInfo[];
164
236
  grammarToolInputProperties?: GrammarToolInputProperties;
165
237
  imageDetail?: ImageDetail;
166
238
  nativeAssistantItems?: ReadonlyMap<string, readonly ResponsesOutputItem[]>;
167
239
  initialSystemMessage?: SystemMessage;
168
- anchorsToolAdditions?: boolean;
240
+ /** Model-visible messages per entry. Defaults to the entry's raw messages. */
241
+ entryMessages?: (entry: SessionEntry) => AgentMessages;
169
242
  };
170
243
 
171
- /** Encode Pi's canonical messages using Pi AI's OpenAI Responses serializer. */
244
+ /**
245
+ * Encode Pi's canonical messages using Pi AI's OpenAI Responses serializer.
246
+ *
247
+ * The leading system message travels as `instructions`, so it is excluded here.
248
+ * Later system messages become inline developer items on models that accept
249
+ * mid-conversation system messages and collapse into the leading message
250
+ * otherwise, exactly as Pi AI's Codex adapter does.
251
+ *
252
+ * Tool declarations never enter the input: every request carries the complete
253
+ * current tool set at the top level, as the official Codex client does. Inline
254
+ * `additional_tools` items and synthetic tool-search pairs made the Codex
255
+ * backend unreliable about which tools exist, so neither is emitted.
256
+ */
172
257
  function encodeMessages(options: EncodeMessagesOptions): ResponsesInputItem[] {
173
258
  const { model, messages, grammarToolInputProperties, imageDetail, nativeAssistantItems } =
174
259
  options;
175
260
  const compat = responsesCompatibility(model.compat);
176
261
  const serializationOptions: NonNullable<Parameters<typeof convertResponsesMessages>[3]> = {
177
262
  includeSystemPrompt: false,
178
- includeSystemUpdates: false,
179
- supportsMidConvoSystemMessages: true,
180
- supportsAdditionalTools:
181
- options.anchorsToolAdditions !== false && (compat.supportsAdditionalTools ?? false),
182
- supportsToolSearch:
183
- options.anchorsToolAdditions !== false && (compat.supportsToolSearch ?? false),
263
+ supportsMidConvoSystemMessages: compat.supportsMidConvoSystemMessages ?? false,
264
+ supportsAdditionalTools: false,
265
+ supportsToolSearch: false,
184
266
  grammarToolInputProperties,
185
267
  toolOptions: {
186
268
  strict: false,
@@ -203,27 +285,24 @@ export function encodeSessionEntries(options: EncodeSessionEntriesOptions): Resp
203
285
  const {
204
286
  model,
205
287
  entries,
206
- allTools,
207
288
  grammarToolInputProperties = new Map(),
208
289
  imageDetail = "auto",
209
290
  nativeAssistantItems,
291
+ entryMessages = sessionEntryToContextMessages,
210
292
  } = options;
211
- const messages = entries.flatMap((entry) => sessionEntryToContextMessages(entry));
293
+ const messages = entries.flatMap((entry) => entryMessages(entry));
212
294
  const encodeOptions: EncodeMessagesOptions = {
213
295
  model,
214
296
  // A partial tail can start with a system update. Seed its prior state so
215
- // the serializer does not mistake that update for the leading declaration.
297
+ // the serializer renders that update inline instead of dropping it as the
298
+ // leading system message that `instructions` already carries.
216
299
  messages: [
217
300
  options.initialSystemMessage ?? { role: "system", content: "", timestamp: 0 },
218
301
  ...convertToLlm(messages),
219
302
  ],
220
- allTools,
221
303
  grammarToolInputProperties,
222
304
  imageDetail,
223
305
  };
224
- if (options.anchorsToolAdditions !== undefined) {
225
- encodeOptions.anchorsToolAdditions = options.anchorsToolAdditions;
226
- }
227
306
  if (nativeAssistantItems) encodeOptions.nativeAssistantItems = nativeAssistantItems;
228
307
  return encodeMessages(encodeOptions);
229
308
  }
@@ -339,40 +418,52 @@ export function checkpointData(
339
418
  /**
340
419
  * Materialize provider history for the active branch. Checkpoint history
341
420
  * replaces everything before its entry; later branch entries form the tail.
421
+ *
422
+ * Ordinary requests read the session projection, so `context_edit` omissions
423
+ * and replacements apply exactly as they do for Pi's own adapters. Recovery
424
+ * compaction reads the raw entries instead: Pi 0.87 omits the failed or
425
+ * truncated attempt through a `context_edit` child of that entry before it
426
+ * runs `session_before_compact`. The serializer already skips error and
427
+ * aborted assistants, and a truncated boundary is committed progress that the
428
+ * checkpoint must keep. Entries are never removed from the branch copy because
429
+ * the omission entry's parent chain would break and the history would vanish.
342
430
  */
343
431
  export function providerHistory(options: {
344
432
  branch: readonly SessionEntry[];
345
433
  wireModel: Model<Api>;
346
- allTools: readonly ToolInfo[];
347
434
  grammarToolInputProperties?: GrammarToolInputProperties;
348
435
  imageDetail?: ImageDetail;
349
436
  recoverLatestOverflowPrefix?: boolean;
350
- anchorsToolAdditions?: boolean;
351
437
  }): ResponsesInputItem[] {
352
438
  const branch = [...options.branch];
353
439
  let recoveredPrefix: ResponsesInputItem[] = [];
354
440
  if (options.recoverLatestOverflowPrefix) {
355
- const index = branch.findLastIndex(
356
- (entry) => entry.type === "message" && entry.message.role === "assistant",
441
+ const entry = branch.findLast(
442
+ (candidate) => candidate.type === "message" && candidate.message.role === "assistant",
357
443
  );
358
- const entry = index >= 0 ? branch[index] : undefined;
359
444
  if (
360
445
  entry?.type === "message" &&
361
446
  entry.message.role === "assistant" &&
362
- (entry.message.stopReason === "error" || entry.message.stopReason === "aborted")
447
+ entry.message.stopReason === "error" &&
448
+ entry.message.responseId
363
449
  ) {
364
- if (entry.message.stopReason === "error" && entry.message.responseId) {
365
- recoveredPrefix =
366
- nativeCommittedPrefixBeforeOverflow(
367
- branch,
368
- options.wireModel.id,
369
- entry.message.responseId,
370
- ) ?? [];
371
- }
372
- branch.splice(index, 1);
450
+ recoveredPrefix =
451
+ nativeCommittedPrefixBeforeOverflow(
452
+ branch,
453
+ options.wireModel.id,
454
+ entry.message.responseId,
455
+ ) ?? [];
373
456
  }
374
457
  }
375
458
 
459
+ const projection = buildSessionProjection(branch);
460
+ const projected = new Map<string, AgentMessages>();
461
+ for (const entry of projection.entries) projected.set(entry.sourceEntry.id, entry.messages);
462
+ const entryMessages = (entry: SessionEntry): AgentMessages =>
463
+ options.recoverLatestOverflowPrefix
464
+ ? sessionEntryToContextMessages(entry)
465
+ : (projected.get(entry.id) ?? []);
466
+
376
467
  const checkpoint = searchCheckpoint(branch);
377
468
  const nativeAssistantItems = nativeResponseOverrides(branch, options.wireModel.id);
378
469
  if (checkpoint.kind === "corrupt") {
@@ -392,31 +483,25 @@ export function providerHistory(options: {
392
483
  ...encodeSessionEntries({
393
484
  model: options.wireModel,
394
485
  entries: branch.slice(checkpoint.entryIndex + 1),
395
- allTools: options.allTools,
396
486
  grammarToolInputProperties: options.grammarToolInputProperties ?? new Map(),
397
487
  imageDetail: options.imageDetail ?? "auto",
398
488
  nativeAssistantItems,
489
+ entryMessages,
399
490
  ...(initialSystemMessage ? { initialSystemMessage } : {}),
400
- ...(options.anchorsToolAdditions === undefined
401
- ? {}
402
- : { anchorsToolAdditions: options.anchorsToolAdditions }),
403
491
  }),
404
492
  ...recoveredPrefix,
405
493
  ];
406
494
  }
407
495
 
408
- const context = buildSessionContext(branch);
409
496
  return [
410
497
  ...encodeMessages({
411
498
  model: options.wireModel,
412
- messages: convertToLlm(context.messages),
413
- allTools: options.allTools,
499
+ messages: convertToLlm(
500
+ projection.entries.flatMap((entry) => entryMessages(entry.sourceEntry)),
501
+ ),
414
502
  grammarToolInputProperties: options.grammarToolInputProperties ?? new Map(),
415
503
  imageDetail: options.imageDetail ?? "auto",
416
504
  nativeAssistantItems,
417
- ...(options.anchorsToolAdditions === undefined
418
- ? {}
419
- : { anchorsToolAdditions: options.anchorsToolAdditions }),
420
505
  }),
421
506
  ...recoveredPrefix,
422
507
  ];
@@ -306,7 +306,7 @@ export default function registerImageGeneration(
306
306
  },
307
307
  });
308
308
  const config = resolveConfig(ctx);
309
- const history = codexToolHistory(pi, ctx, model, config.imageDetail);
309
+ const history = codexToolHistory(ctx, model, config.imageDetail);
310
310
  const request = await imageRequest(params, history);
311
311
  const authentication = await codexToolAuthentication(ctx, model);
312
312
  const callId = toolCallId.split("|")[0] || toolCallId;
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
3
4
  import { requirePiTranscriptRuntime } from "./pi-runtime.ts";
4
5
  import { DEFAULT_CONFIG, loadConfig, type CodexCompatConfig } from "./config.ts";
5
6
  import type { ConfigContext } from "./config-context.ts";
@@ -23,7 +24,7 @@ export {
23
24
  } from "./codex-transport.ts";
24
25
 
25
26
  export default function registerOpenAICodexCompat(pi: ExtensionAPI): void {
26
- requirePiTranscriptRuntime(piAi);
27
+ requirePiTranscriptRuntime(piAi, piCodingAgent);
27
28
  let activeConfig: CodexCompatConfig | undefined;
28
29
  const resolveConfig = (ctx: ConfigContext): CodexCompatConfig => {
29
30
  activeConfig ??= loadConfig(ctx.cwd, ctx.isProjectTrusted());
@@ -1,19 +1,45 @@
1
- const TRANSCRIPT_APIS = [
2
- "normalizeContext",
3
- "getCurrentSystemPrompt",
1
+ /**
2
+ * Every Pi 0.86 transcript API this extension imports as a value from
3
+ * `@earendil-works/pi-ai`. Pi's extension loader turns a missing named import
4
+ * into `undefined` rather than a load error, so an older host fails at first use
5
+ * unless the surface is checked up front. Keep this list equal to the value
6
+ * imports in `codex-provider/codex-provider-runtime.ts`, `compaction-checkpoint.ts`,
7
+ * and `vendor/pi-ai/openai-responses-serialization.ts`.
8
+ */
9
+ export const TRANSCRIPT_APIS = [
10
+ "getCurrentTools",
4
11
  "getDeclaredTools",
5
12
  "getInitialSystemMessage",
13
+ "getSystemMessageText",
14
+ "normalizeContext",
15
+ "renderSystemMessageUpdate",
16
+ "resolveTranscript",
6
17
  "resolveTranscriptTools",
7
18
  ] as const;
8
19
 
20
+ /**
21
+ * Every Pi 0.87 session API this extension imports as a value from
22
+ * `@earendil-works/pi-coding-agent`. Keep this list equal to the value imports
23
+ * in `compaction-checkpoint.ts`.
24
+ */
25
+ export const SESSION_APIS = ["buildSessionProjection"] as const;
26
+
27
+ export const REQUIRED_PI = "Pi 0.87.x";
28
+
9
29
  /** Check the loaded host, not package metadata that PI_PACKAGE_DIR can override. */
10
- export function requirePiTranscriptRuntime(api: Record<string, unknown>): void {
11
- const missing = TRANSCRIPT_APIS.filter((name) => typeof api[name] !== "function");
30
+ export function requirePiTranscriptRuntime(
31
+ transcriptApi: Record<string, unknown>,
32
+ sessionApi: Record<string, unknown>,
33
+ ): void {
34
+ const missing = [
35
+ ...TRANSCRIPT_APIS.filter((name) => typeof transcriptApi[name] !== "function"),
36
+ ...SESSION_APIS.filter((name) => typeof sessionApi[name] !== "function"),
37
+ ];
12
38
  if (missing.length > 0) {
13
39
  throw new Error(
14
- "pi-openai-codex-compat requires a running Pi 0.86.x runtime. " +
40
+ `pi-openai-codex-compat requires a running ${REQUIRED_PI} runtime. ` +
15
41
  `Missing host APIs: ${missing.join(", ")}. ` +
16
- "Exit Pi and start Pi 0.86.x after updating. /reload cannot upgrade the running " +
42
+ `Exit Pi and start ${REQUIRED_PI} after updating. /reload cannot upgrade the running ` +
17
43
  "runtime, and PI_PACKAGE_DIR can make an older executable report a newer version.",
18
44
  );
19
45
  }
@@ -2,7 +2,7 @@ import { isString } from "./value-contracts.ts";
2
2
  import type {
3
3
  BeforeProviderHeadersEvent,
4
4
  CompactionResult,
5
- ContextEvent,
5
+ ContextWithSystemEvent,
6
6
  ExtensionAPI,
7
7
  ExtensionContext,
8
8
  SessionBeforeCompactEvent,
@@ -18,6 +18,8 @@ import { addRemoteCompactionFeature, isObject, type JsonRecord } from "./codex-p
18
18
  import { CODEX_API, CODEX_PROVIDER } from "./codex-identifiers.ts";
19
19
  import {
20
20
  activeResponsesTools,
21
+ branchInstructions,
22
+ declaresActiveTools,
21
23
  providerHistory,
22
24
  remoteCompactionMarkerSummary,
23
25
  responsesCompatibility,
@@ -50,9 +52,9 @@ export type RemoteCompactionLifecycleHandler = (
50
52
  ) => void;
51
53
 
52
54
  export type RemoteCompactionContextHandler = (
53
- event: Pick<ContextEvent, "messages">,
55
+ event: Pick<ContextWithSystemEvent, "messages">,
54
56
  ctx: RemoteCompactionContext,
55
- ) => { messages: ContextEvent["messages"] } | undefined;
57
+ ) => { messages: ContextWithSystemEvent["messages"] } | undefined;
56
58
 
57
59
  export type RemoteCompactionHeadersHandler = (
58
60
  event: Pick<BeforeProviderHeadersEvent, "headers">,
@@ -114,7 +116,12 @@ export function remoteCompactionApi(pi: ExtensionAPI): RemoteCompactionApi {
114
116
  getAllTools: () => pi.getAllTools(),
115
117
  onBeforeProviderHeaders: (handler) =>
116
118
  pi.on("before_provider_headers", (event, ctx) => handler(event, context(ctx))),
117
- onContext: (handler) => pi.on("context", (event, ctx) => handler(event, context(ctx))),
119
+ // The full-transcript event returns messages verbatim. A changed `context`
120
+ // result would fold every later system message into the leading one, so
121
+ // `instructions` would no longer be the branch's leading prompt and the
122
+ // inline developer items in `input` would repeat the folded updates.
123
+ onContext: (handler) =>
124
+ pi.on("context_with_system", (event, ctx) => handler(event, context(ctx))),
118
125
  onSessionBeforeCompact: (handler) =>
119
126
  pi.on("session_before_compact", (event, ctx) => handler(event, context(ctx))),
120
127
  onSessionShutdown: (handler) =>
@@ -245,17 +252,23 @@ export default function registerRemoteCompaction(
245
252
  const history = providerHistory({
246
253
  branch: event.branchEntries,
247
254
  wireModel: ctx.model,
248
- allTools,
249
255
  grammarToolInputProperties,
250
256
  imageDetail: config.imageDetail,
251
257
  recoverLatestOverflowPrefix: event.reason === "overflow" && event.willRetry,
252
- anchorsToolAdditions: false,
253
258
  });
254
- // A cached turn template can contain only the initial declarations.
255
- // Compaction rebases history, so use the complete current tool set.
259
+ // Reuse the last turn's exact declarations when they still describe the
260
+ // active tools, so the compaction request shares the turn requests'
261
+ // prompt-cache prefix. Rebuild from the registry only when the active
262
+ // set changed since that turn or no turn template exists.
256
263
  const template: JsonRecord = { ...matching?.payload };
264
+ const activeNames = pi.getActiveTools();
265
+ const cachedTools = Array.isArray(matching?.payload.tools)
266
+ ? matching.payload.tools.filter(isObject)
267
+ : undefined;
257
268
  template.tools =
258
- activeResponsesTools(allTools, pi.getActiveTools(), grammarToolInputProperties) ?? [];
269
+ cachedTools && declaresActiveTools(cachedTools, allTools, activeNames)
270
+ ? cachedTools
271
+ : (activeResponsesTools(allTools, activeNames, grammarToolInputProperties) ?? []);
259
272
  const requestOptions: OpenAICodexResponsesOptions = {
260
273
  ...matching?.requestOptions,
261
274
  apiKey: authentication.apiKey,
@@ -267,7 +280,14 @@ export default function registerRemoteCompaction(
267
280
  model: ctx.model,
268
281
  requestOptions,
269
282
  history,
270
- instructions: instructionsForCompaction(ctx.getSystemPrompt(), event.customInstructions),
283
+ // Turn requests send the branch's leading system message as `instructions`
284
+ // and carry later system messages inline in the history. A branch without
285
+ // a leading system message (a pre-0.86 session) falls back to Pi's
286
+ // current prompt so the compaction still sees instructions.
287
+ instructions: instructionsForCompaction(
288
+ branchInstructions(ctx.model, event.branchEntries) || ctx.getSystemPrompt(),
289
+ event.customInstructions,
290
+ ),
271
291
  grammarToolInputProperties:
272
292
  matching?.grammarToolInputProperties ??
273
293
  requestGrammarToolInputProperties(template, allTools),
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
3
3
  import { providerHistory } from "./compaction-checkpoint.ts";
4
4
  import type { ImageDetail } from "./config.ts";
@@ -12,7 +12,6 @@ export interface CodexToolAuthentication {
12
12
  export type ToolAuthenticationContext = {
13
13
  modelRegistry: Pick<ExtensionContext["modelRegistry"], "getApiKeyAndHeaders">;
14
14
  };
15
- export type ToolHistoryApi = Pick<ExtensionAPI, "getAllTools">;
16
15
  export type ToolHistoryContext = {
17
16
  sessionManager: Pick<ExtensionContext["sessionManager"], "getBranch">;
18
17
  };
@@ -32,7 +31,6 @@ export async function codexToolAuthentication(
32
31
  }
33
32
 
34
33
  export function codexToolHistory(
35
- pi: ToolHistoryApi,
36
34
  ctx: ToolHistoryContext,
37
35
  model: Model<Api>,
38
36
  imageDetail: ImageDetail = "auto",
@@ -40,7 +38,6 @@ export function codexToolHistory(
40
38
  return providerHistory({
41
39
  branch: ctx.sessionManager.getBranch(),
42
40
  wireModel: model,
43
- allTools: pi.getAllTools(),
44
41
  imageDetail,
45
42
  });
46
43
  }
@@ -8,14 +8,15 @@ Pi AI's transcript helpers replay system sections and tool declarations.
8
8
  `supportsAdditionalTools` and `supportsToolSearch` select the dynamic tool
9
9
  history representation for additive `toolsAdded` system messages:
10
10
  message-anchored `additional_tools` or a `tool_search_call`/`tool_search_output`
11
- pair. Tool removal, redefinition, or a model without either capability requires
12
- the complete current top-level tool list.
13
-
14
- Compat uses the extension-owned `includeSystemUpdates: false` option alongside
15
- `includeSystemPrompt: false`. It sends the complete current prompt separately
16
- and preserves only supported tool additions in system-message positions.
17
- This keeps old prompt text from overriding current instructions after
18
- compaction, forced-prompt projection, reload, or resume.
11
+ pair. The copy keeps both options so the wire-equivalence tests hold, but Compat
12
+ always passes `false`: it declares every tool at the top level.
13
+
14
+ System messages follow Pi AI exactly. Compat passes `includeSystemPrompt: false`
15
+ because the leading system message travels as `instructions`, and forwards the
16
+ model's `supportsMidConvoSystemMessages` flag so later system messages render
17
+ inline through `renderSystemMessageUpdate` or collapse into the leading message.
18
+ A system message that lands between a tool call and its results is held until
19
+ after those results, as upstream `transformMessages` does.
19
20
 
20
21
  The optional `namespacedToolNames`, `textContentItemToolResultNames`, and `toolResultImageDetail` paths are extension-owned additions. Without those options, serialization must continue to match Pi AI. `namespacedToolNames` groups only the fixed Codex allowlist into native Responses namespaces and replays namespace/member call identities. `textContentItemToolResultNames` preserves Codex tools such as `web.run` whose successful text output is transported as an `input_text` content-item array instead of Pi's usual plain string. `toolResultImageDetail` overrides the otherwise canonical `auto` detail used for image tool-result content.
21
22
 
@@ -53,8 +53,6 @@ type ToolResultOutput =
53
53
 
54
54
  type ConvertResponsesMessagesOptions = {
55
55
  includeSystemPrompt?: boolean;
56
- /** Compat sends the complete current prompt outside replay history. */
57
- includeSystemUpdates?: boolean;
58
56
  grammarToolInputProperties?: ReadonlyMap<string, string>;
59
57
  supportsMidConvoSystemMessages?: boolean;
60
58
  supportsAdditionalTools?: boolean;
@@ -551,25 +549,33 @@ function transformMessages(
551
549
  const result: Message[] = [];
552
550
  let pendingToolCalls: ToolCall[] = [];
553
551
  let existingToolResultIds = new Set<string>();
554
- const insertSyntheticToolResults = () => {
555
- for (const toolCall of pendingToolCalls) {
556
- if (existingToolResultIds.has(toolCall.id)) continue;
557
- result.push({
558
- role: "toolResult",
559
- toolCallId: toolCall.id,
560
- toolName: toolCall.name,
561
- content: [{ type: "text", text: "No result provided" }],
562
- isError: true,
563
- timestamp: Date.now(),
564
- } satisfies ToolResultMessage);
552
+ // System messages are transparent to tool-call accounting: one that lands between a
553
+ // tool call and its results is held back and emitted after the results (synthetic
554
+ // ones included), so it never causes a duplicate result for a call answered later.
555
+ const heldSystemMessages: Message[] = [];
556
+ const closePendingToolCalls = () => {
557
+ if (pendingToolCalls.length > 0) {
558
+ for (const toolCall of pendingToolCalls) {
559
+ if (existingToolResultIds.has(toolCall.id)) continue;
560
+ result.push({
561
+ role: "toolResult",
562
+ toolCallId: toolCall.id,
563
+ toolName: toolCall.name,
564
+ content: [{ type: "text", text: "No result provided" }],
565
+ isError: true,
566
+ timestamp: Date.now(),
567
+ } satisfies ToolResultMessage);
568
+ }
569
+ pendingToolCalls = [];
570
+ existingToolResultIds = new Set();
565
571
  }
566
- pendingToolCalls = [];
567
- existingToolResultIds = new Set();
572
+ result.push(...heldSystemMessages);
573
+ heldSystemMessages.length = 0;
568
574
  };
569
575
 
570
576
  for (const message of transformed) {
571
577
  if (message.role === "assistant") {
572
- insertSyntheticToolResults();
578
+ closePendingToolCalls();
573
579
  const assistantMessage = message;
574
580
  if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") {
575
581
  continue;
@@ -585,14 +591,17 @@ function transformMessages(
585
591
  } else if (message.role === "toolResult") {
586
592
  existingToolResultIds.add(message.toolCallId);
587
593
  result.push(message);
594
+ } else if (message.role === "system") {
595
+ if (pendingToolCalls.length > 0) heldSystemMessages.push(message);
596
+ else result.push(message);
588
597
  } else if (message.role === "user") {
589
- insertSyntheticToolResults();
598
+ closePendingToolCalls();
590
599
  result.push(message);
591
600
  } else {
592
601
  result.push(message);
593
602
  }
594
603
  }
595
- insertSyntheticToolResults();
604
+ closePendingToolCalls();
596
605
  return result;
597
606
  }
598
607
 
@@ -737,7 +746,7 @@ export function convertResponsesMessages(
737
746
  tools: convertResponsesTools(addedTools, { ...toolOptions, deferLoading: true }),
738
747
  });
739
748
  }
740
- if (isLeadingSystemMessage ? includeSystemPrompt : options?.includeSystemUpdates !== false) {
749
+ if (!isLeadingSystemMessage || includeSystemPrompt) {
741
750
  const text = isLeadingSystemMessage
742
751
  ? getSystemMessageText(message)
743
752
  : renderSystemMessageUpdate(message);
@@ -176,7 +176,7 @@ export default function registerWebRun(
176
176
  }
177
177
  const authentication = await codexToolAuthentication(ctx, model);
178
178
  const config = resolveConfig(ctx);
179
- const history = codexToolHistory(pi, ctx, model, config.imageDetail);
179
+ const history = codexToolHistory(ctx, model, config.imageDetail);
180
180
  const input = recentSearchInput(history);
181
181
  const body: JsonRecord = {
182
182
  id: ctx.sessionManager.getSessionId(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-codex-compat",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "OpenAI Codex compatibility for Pi with native compaction, fast mode, and Codex-optimized capabilities",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -51,9 +51,9 @@
51
51
  "devDependencies": {
52
52
  "@2h2d/oxlint-config": "0.1.1",
53
53
  "@2h2d/ts-config": "0.0.1",
54
- "@earendil-works/pi-ai": "0.86.0",
55
- "@earendil-works/pi-coding-agent": "0.86.0",
56
- "@earendil-works/pi-tui": "0.86.0",
54
+ "@earendil-works/pi-ai": "0.87.0",
55
+ "@earendil-works/pi-coding-agent": "0.87.0",
56
+ "@earendil-works/pi-tui": "0.87.0",
57
57
  "@types/node": "22.20.1",
58
58
  "oxfmt": "0.64.0",
59
59
  "oxlint": "1.79.0",
@@ -62,9 +62,9 @@
62
62
  "typescript": "7.0.2"
63
63
  },
64
64
  "peerDependencies": {
65
- "@earendil-works/pi-ai": ">=0.86.0 <0.87.0",
66
- "@earendil-works/pi-coding-agent": ">=0.86.0 <0.87.0",
67
- "@earendil-works/pi-tui": ">=0.86.0 <0.87.0",
65
+ "@earendil-works/pi-ai": ">=0.87.0 <0.88.0",
66
+ "@earendil-works/pi-coding-agent": ">=0.87.0 <0.88.0",
67
+ "@earendil-works/pi-tui": ">=0.87.0 <0.88.0",
68
68
  "typebox": "*"
69
69
  },
70
70
  "engines": {