pi-openai-codex-compat 0.0.13 → 0.0.15

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,57 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.0.15 - 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - Preserve earlier message omissions and replacements during overflow-recovery
10
+ compaction. Recover only the latest attempt Pi omitted for recovery, keeping
11
+ its committed progress without reviving unrelated content. Stored native
12
+ responses no longer override later assistant-message replacements.
13
+ - Rebuild the compaction request's tool declarations when a tool's parameter
14
+ schema changes under an unchanged name and description, instead of reusing a
15
+ stale schema from the previous turn.
16
+
17
+ ## 0.0.14 - 2026-09-22
18
+
19
+ ### Changed
20
+
21
+ - Require Pi `>=0.87.0 <0.88.0` and use Pi 0.87.0 as the development and
22
+ packaged-CLI integration baseline. The load-time host check now also
23
+ requires Pi 0.87's session projection API.
24
+ - Honour Pi 0.87 `context_edit` entries when building request history from
25
+ the branch, so omitted or replaced messages match what Pi's own adapters
26
+ send.
27
+ - Preserve mid-conversation prompt updates when filtering compaction summaries.
28
+ - Skip percentage compaction when nothing has been sampled yet. Pi 0.87
29
+ estimates the prompt and tool declarations before the first response, so a
30
+ low threshold could trigger an empty compaction request.
31
+ - Send Pi's leading system message as the prompt and later system messages as
32
+ inline developer items, exactly as Pi AI's Codex adapter does. Previously
33
+ every system message was replayed into one `instructions` string, so any
34
+ prompt-section change rewrote the stable prefix. Models without
35
+ mid-conversation system-message support still receive one collapsed prompt.
36
+ - Keep every developer and system message in the native compaction checkpoint,
37
+ in place and outside the 64k retained-context budget, so prompt updates
38
+ survive compaction. Only user messages are charged to the budget.
39
+
40
+ ### Fixed
41
+
42
+ - Keep the conversation in overflow-recovery compaction on Pi 0.87. Pi now
43
+ records the failed attempt's omission as a session entry whose parent is
44
+ that attempt; removing the attempt from the branch copy broke the entry
45
+ chain and the compaction request contained only the recovered prefix.
46
+ - Hold a system message that lands between a tool call and its results until
47
+ after those results in replayed history, matching Pi AI.
48
+ - Send the branch's leading system message as compaction `instructions`
49
+ instead of Pi's fully replayed prompt, so Pi-driven compaction shares the
50
+ turn requests' prompt prefix.
51
+ - Reuse the last turn's exact tool declarations for `/compact` and threshold
52
+ compaction while they still describe the active tools. Rebuilding them from
53
+ the tool registry lost Pi's strict schemas for built-in tools, so every
54
+ Pi-driven compaction request missed the turn requests' prompt-cache prefix.
55
+
5
56
  ## 0.0.13 - 2026-09-21
6
57
 
7
58
  ### 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,7 +67,7 @@ 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. |
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
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. |
@@ -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. |
@@ -327,9 +327,22 @@ 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. Repeated prompt updates (for example several
332
+ `/reload` runs or prompt-section changes) each remain in place as their own inline
333
+ developer item; they accumulate across checkpoints and are never dropped, merged, or
334
+ deduplicated, because each one is a distinct instruction the model already saw.
331
335
  4. Persist the opaque checkpoint in the Pi session and replay it on later requests.
332
336
 
337
+ Overflow and length recovery follow Pi 0.87's `context_edit` semantics. Every request
338
+ reads Pi's session projection, so earlier omissions and replacements (an elided tool
339
+ output, an omitted reply) apply exactly as they do for Pi's own adapters. Before an
340
+ overflow-recovery compaction, Pi omits the latest failed or truncated attempt and its tool
341
+ results; the checkpoint request projects only those entries as they were before that
342
+ omission, so a truncated response and its tool results survive as committed progress while
343
+ every unrelated earlier edit stays in force. A failed attempt contributes only the native
344
+ output committed before the overflowing subrequest.
345
+
333
346
  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.
334
347
 
335
348
  Healthy session WebSockets remain available until the server closes them or Pi tears down the session. Retryable WebSocket failures before model-visible output receive up to five fresh-connection retries before the session switches to sticky SSE. Retryable SSE HTTP failures and dropped streams receive up to five same-request resampling attempts before model-visible output. Both transports use Codex-style exponential backoff with ±10% jitter and preserve the same prompt-cache, session, account, installation, and window identities. Server metadata is not considered model-visible output, so a routing-state-only response can still be retried safely. Transport failures after model-visible output fail closed rather than risk duplicate text or tool calls. Explicit retryable `response.failed`/`response.incomplete` protocol terminals instead return to the provider-owned sampling loop, which preserves completed output items as the next request's history.
@@ -575,6 +588,15 @@ mise run check
575
588
  npm run pack:dry
576
589
  ```
577
590
 
591
+ `mise run check` runs the linters, formatters, type checks, and the offline
592
+ test suite; `mise run test` runs only the offline suite. Both tasks bind
593
+ `PI_PACKAGE_DIR` to `node_modules/@earendil-works/pi-coding-agent`, so tests
594
+ that load Pi in-process read this repository's Pi 0.87.0 resources even when a
595
+ global `PI_PACKAGE_DIR` points at another installation. Ordinary `pi` launches
596
+ outside these tasks are unaffected. Offline tests also exercise
597
+ `scripts/release.ts` with every child process mocked: they never run Git, npm,
598
+ Mise, or provider calls.
599
+
578
600
  Run the credentialed Pi/Codex integration tests separately. They load the real
579
601
  extension into headless Pi sessions, use the real WebSocket service, and ask
580
602
  the model to report all prior history markers after each text and tool
@@ -587,11 +609,26 @@ mise run test:live:codex
587
609
  The task obtains the local Codex bearer token and runs the tests with
588
610
  `gpt-5.6-luna` at medium reasoning effort.
589
611
  It also packs the extension and loads that archive through the shipped Pi
590
- 0.86.1 CLI. Ordinary Responses and Responses Lite tests exercise tool calls,
612
+ 0.87.0 CLI. Ordinary Responses and Responses Lite tests exercise tool calls,
591
613
  prompt reload, native compaction, and persisted-session resume against Codex.
592
614
  Test credentials stay in memory and child-process environments. Test sessions
593
615
  and configuration are isolated from the user's agent directory.
594
616
 
617
+ The packaged-CLI test selects its archive and executable as follows:
618
+
619
+ - Without `PI_CODEX_PACKAGE_ARCHIVE`, it packs the current worktree into a
620
+ temporary directory. With `PI_CODEX_PACKAGE_ARCHIVE`, it loads exactly that
621
+ archive: the value resolves against the working directory, must be an
622
+ existing regular file that `tar` can list, and an empty, missing, directory,
623
+ or invalid value fails the test instead of falling back to a fresh pack.
624
+ - `PI_CODEX_CLI_PATH` selects another Pi `cli.js`; the default is this
625
+ repository's Pi 0.87.0 dependency. The test asserts that the selected
626
+ executable reports version 0.87.0, the only Pi version the packaged CLI test
627
+ is run against. The supported range is `>=0.87.0 <0.88.0`.
628
+ - Each Pi child process receives `PI_PACKAGE_DIR` bound to the selected
629
+ executable's package directory, so the runtime under test reads its own
630
+ metadata.
631
+
595
632
  The public package entrypoint is `extensions/index.ts`; implementation modules remain under
596
633
  `extensions/openai-codex-compat/`. The focused Pi AI serializer copy lives under
597
634
  `extensions/openai-codex-compat/vendor/pi-ai/`. The custom Codex provider transport and stream
@@ -605,10 +642,74 @@ search, and compaction continuation.
605
642
  1. Run `npm run release -- X.Y.Z` from a clean, synchronized `main`.
606
643
  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
644
  3. Inspect the result, then push atomically with `git push --atomic origin main vX.Y.Z`.
608
- 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. A final job creates the immutable GitHub release for the tag from the same verified archive, its
610
- checksum, and the version's `CHANGELOG.md` section (`Unreleased` for prereleases).
611
- 6. Approve the staged package on npmjs.com, or with `npm stage approve <stage-id>`.
645
+ 4. A read-only GitHub Actions job validates and packs the package. After approval
646
+ in the tag-restricted `npm-publish` environment, a separate GitHub-owned job
647
+ verifies the signature and signed digest before attesting and staging that
648
+ exact archive through npm trusted publishing.
649
+ 5. A final job creates the immutable GitHub release from the verified archive,
650
+ its checksum, and the version's changelog section (`Unreleased` for prereleases).
651
+ 6. Approve the staged package on npmjs.com or with `npm stage approve <stage-id>`.
652
+
653
+ The release command runs these steps in order:
654
+
655
+ 1. Refuse to start unless the branch is `main`, `git status --porcelain` is
656
+ empty, `CHANGELOG.md` has a section for the version, `HEAD` equals
657
+ `origin/main` after a fetch, and tag `vX.Y.Z` does not exist. Nothing has
658
+ changed when one of these checks fails.
659
+ 2. Write the version into `package.json` and `package-lock.json` and stage
660
+ only those two files.
661
+ 3. Check out the staged index into a temporary directory, run
662
+ `npm ci --ignore-scripts` and `npm pack` there, validate the package
663
+ identity, file list, and manifest, then run `mise run test:live:codex` in
664
+ the repository with `PI_CODEX_PACKAGE_ARCHIVE` set to that archive. This is
665
+ the exact archive release gate: the live CLI tests load that archive and
666
+ nothing else. A live process that cannot start, a nonzero exit, or a failed
667
+ package validation stops the release here.
668
+ 4. Create the SSH-signed `release: vX.Y.Z` commit with the archive's SHA-256
669
+ as its `Npm-Artifact-SHA256` trailer and verify the signature and trailer.
670
+ 5. Rebuild the package from the committed tree without repeating the live
671
+ tests and require the same digest.
672
+ 6. Create the lightweight tag and verify that it points at the release commit.
673
+
674
+ ### Recovering from a failed release command
675
+
676
+ Do not run any blanket `git restore`, `git reset`, `git checkout --`, or
677
+ `git clean`. Inspect first, then undo only what the failed command produced.
678
+
679
+ If the command fails during the version update, version changes can remain
680
+ in the worktree. Once the update and `git add` succeed, package, live-test,
681
+ and signing failures leave those changes staged. No release commit or tag
682
+ was created by that attempt. Inspect the state:
683
+
684
+ ```sh
685
+ git status --short
686
+ git diff -- package.json package-lock.json
687
+ git diff --cached -- package.json package-lock.json
688
+ ```
689
+
690
+ Undo only this attempt's version edits in the worktree and index. Preserve
691
+ concurrent changes, including edits in those same files. Do not stage whole
692
+ files containing unrelated edits. Fix the cause and confirm that `main` is
693
+ clean and synchronized before rerunning the release command.
694
+
695
+ If the command fails after the release commit (steps 4 to 6), a local signed
696
+ `release: vX.Y.Z` commit exists on `main`, and a local tag may exist. Do not rerun
697
+ the release command: it would refuse the unsynchronized `HEAD`, and a second
698
+ run on top of the existing commit would produce a duplicate release commit.
699
+ Do not push the commit or tag until signature verification, the reproducibility
700
+ check, and tag verification are complete. Inspect the commit and the digest:
701
+
702
+ ```sh
703
+ git log -1 --format='%H%n%s%n%(trailers:key=Npm-Artifact-SHA256,valueonly)' HEAD
704
+ git diff origin/main..HEAD -- package.json package-lock.json
705
+ git tag --list 'vX.Y.Z'
706
+ ```
707
+
708
+ Removing the local release commit or tag changes local refs. Confirm that
709
+ they were never pushed, record their hashes and a recovery path, and obtain
710
+ explicit approval before changing them. Never replace a published tag.
711
+ Fix the underlying cause before starting a new release from clean,
712
+ synchronized `main`.
612
713
 
613
714
  Stable releases use `latest`; prereleases derive their npm dist-tag from the first prerelease identifier.
614
715
 
@@ -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) {
@@ -47,6 +47,8 @@ export type RuntimeScope = {
47
47
  export type RequestTemplate = {
48
48
  modelId: string;
49
49
  payload: JsonRecord;
50
+ /** Fingerprint of the raw tool definitions that produced `payload.tools`. */
51
+ toolFingerprint: string;
50
52
  grammarToolInputProperties: GrammarToolInputProperties;
51
53
  requestOptions: OpenAICodexResponsesOptions;
52
54
  };
@@ -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,7 +55,6 @@ 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
60
  });
@@ -5,10 +5,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  clampThinkingLevel,
7
7
  createAssistantMessageEventStream,
8
- getCurrentSystemPrompt,
9
8
  getCurrentTools,
10
9
  getDeclaredTools,
11
- normalizeContext,
12
10
  type Api,
13
11
  type AssistantMessage,
14
12
  type AssistantMessageEventStream,
@@ -30,8 +28,11 @@ import {
30
28
  checkpointData,
31
29
  providerHistory,
32
30
  remoteCompactionMarkerSummary,
31
+ requestInstructions,
32
+ resolveRequestTranscript,
33
33
  responsesCompatibility,
34
34
  searchCheckpoint,
35
+ toolDefinitionFingerprint,
35
36
  type CheckpointData,
36
37
  type CompactionDecision,
37
38
  type GrammarToolInputProperties,
@@ -502,8 +503,7 @@ export class CodexProviderRuntime {
502
503
  const nativeItems = new Map<string, ResponsesOutputItem[]>();
503
504
  const serializationOptions: NonNullable<Parameters<typeof convertResponsesMessages>[3]> = {
504
505
  includeSystemPrompt: false,
505
- includeSystemUpdates: false,
506
- supportsMidConvoSystemMessages: true,
506
+ supportsMidConvoSystemMessages: compat.supportsMidConvoSystemMessages ?? false,
507
507
  supportsAdditionalTools: false,
508
508
  supportsToolSearch: false,
509
509
  grammarToolInputProperties,
@@ -524,7 +524,6 @@ export class CodexProviderRuntime {
524
524
  return providerHistory({
525
525
  branch: scope.manager.getBranch(),
526
526
  wireModel: model,
527
- allTools: this.pi.getAllTools(),
528
527
  grammarToolInputProperties,
529
528
  imageDetail: scope.config.imageDetail,
530
529
  });
@@ -547,11 +546,14 @@ export class CodexProviderRuntime {
547
546
  // checkpoint drops such history anyway. A mid-session tool change costs one
548
547
  // cache miss.
549
548
  const requestTools = getCurrentTools(context.messages);
549
+ // The leading system message is the prompt; later system messages travel
550
+ // inline in `input` (or collapsed into the leading message by
551
+ // resolveRequestTranscript), as Pi AI's Codex adapter does.
550
552
  let body: JsonRecord = {
551
553
  model: model.id,
552
554
  store: false,
553
555
  stream: true,
554
- instructions: getCurrentSystemPrompt(context.messages) || "You are a helpful assistant.",
556
+ instructions: requestInstructions(context) || "You are a helpful assistant.",
555
557
  input: this.wireHistory(model, context, grammarToolInputProperties, runtimeSessionId),
556
558
  text: { verbosity: requestOptions.textVerbosity ?? "low" },
557
559
  include: ["reasoning.encrypted_content"],
@@ -735,7 +737,6 @@ export class CodexProviderRuntime {
735
737
  branch,
736
738
  history,
737
739
  model,
738
- allTools: this.pi.getAllTools(),
739
740
  grammarToolInputProperties,
740
741
  imageDetail: scope.config.imageDetail,
741
742
  });
@@ -746,6 +747,10 @@ export class CodexProviderRuntime {
746
747
  );
747
748
  return body;
748
749
  }
750
+ // Pi 0.87 estimates the prompt and tool declarations before any response
751
+ // usage exists, so a low threshold can be reached before anything was
752
+ // sampled. There is nothing to compact until the first response.
753
+ if (split.history.length === 0) return body;
749
754
 
750
755
  const compacted = await this.performCompaction({
751
756
  model,
@@ -755,7 +760,7 @@ export class CodexProviderRuntime {
755
760
  template: withoutConversationInput(body),
756
761
  instructions: isString(body.instructions)
757
762
  ? body.instructions
758
- : getCurrentSystemPrompt(context.messages) || "You are a helpful assistant.",
763
+ : requestInstructions(context) || "You are a helpful assistant.",
759
764
  grammarToolInputProperties,
760
765
  priority: scope.config.fastMode,
761
766
  compactionMetadata: responsesCompactionV2Metadata("auto", "context_limit", "pre_turn"),
@@ -797,7 +802,7 @@ export class CodexProviderRuntime {
797
802
  input: Context,
798
803
  options?: OpenAICodexResponsesOptions,
799
804
  ): AssistantMessageEventStream {
800
- const context = normalizeContext(input);
805
+ const context = resolveRequestTranscript(model, input);
801
806
  const stream = createAssistantMessageEventStream();
802
807
  const requestOptions = transportOptions(options);
803
808
  const output: AssistantMessage = {
@@ -867,6 +872,7 @@ export class CodexProviderRuntime {
867
872
  this.templates.set(runtimeSessionId, {
868
873
  modelId: model.id,
869
874
  payload: replacementValues ? {} : withoutConversationInput(body),
875
+ toolFingerprint: toolDefinitionFingerprint(getCurrentTools(context.messages)),
870
876
  grammarToolInputProperties,
871
877
  requestOptions: { ...requestOptions },
872
878
  });
@@ -1,13 +1,26 @@
1
1
  import { isBoolean, isString } from "./value-contracts.ts";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import {
4
4
  buildSessionContext,
5
+ buildSessionProjection,
5
6
  convertToLlm,
6
7
  sessionEntryToContextMessages,
8
+ type ContextEditEntry,
7
9
  type SessionEntry,
8
10
  type ToolInfo,
9
11
  } from "@earendil-works/pi-coding-agent";
10
- import type { Api, Message, Model, SystemMessage } from "@earendil-works/pi-ai";
12
+ import {
13
+ getInitialSystemMessage,
14
+ getSystemMessageText,
15
+ normalizeContext,
16
+ resolveTranscript,
17
+ type Api,
18
+ type Context,
19
+ type Message,
20
+ type Model,
21
+ type SystemMessage,
22
+ type TranscriptContext,
23
+ } from "@earendil-works/pi-ai";
11
24
  import { Value } from "typebox/value";
12
25
  import { APPLY_PATCH_LARK_GRAMMAR, APPLY_PATCH_TOOL_NAME } from "./apply-patch.ts";
13
26
  import { CODEX_TOOL_CALL_PROVIDERS } from "./codex-identifiers.ts";
@@ -19,6 +32,7 @@ import {
19
32
  type JsonRecord,
20
33
  } from "./codex-protocol.ts";
21
34
  import { nativeCommittedPrefixBeforeOverflow, nativeResponseOverrides } from "./native-history.ts";
35
+ import { stableResponsesJson } from "./responses-replay.ts";
22
36
  import {
23
37
  CODEX_NAMESPACED_TOOL_NAMES,
24
38
  CODEX_TEXT_CONTENT_ITEM_TOOL_RESULT_NAMES,
@@ -34,6 +48,8 @@ import {
34
48
  import type { ResponsesToolDefinition } from "./responses-tool-schema.ts";
35
49
  import { convertResponsesMessages } from "./vendor/pi-ai/openai-responses-serialization.ts";
36
50
 
51
+ type AgentMessages = ReturnType<typeof sessionEntryToContextMessages>;
52
+
37
53
  export const CHECKPOINT_ENTRY_TYPE = "openai-codex-compat-remote-compaction";
38
54
  export const CHECKPOINT_FORMAT_VERSION = 1;
39
55
 
@@ -58,17 +74,16 @@ export type CheckpointSearch =
58
74
  export type GrammarToolInputProperties = ReadonlyMap<string, string>;
59
75
 
60
76
  export interface ResponsesCompatibility {
61
- supportsAdditionalTools?: boolean;
77
+ supportsMidConvoSystemMessages?: boolean;
62
78
  supportsOpenAIGrammarTools?: boolean;
63
79
  supportsStrictMode?: boolean;
64
- supportsToolSearch?: boolean;
65
80
  }
66
81
 
67
82
  export function responsesCompatibility(value: unknown): ResponsesCompatibility {
68
83
  const compatibility: ResponsesCompatibility = {};
69
84
  if (!isObject(value)) return compatibility;
70
- if (isBoolean(value["supportsAdditionalTools"])) {
71
- compatibility.supportsAdditionalTools = value["supportsAdditionalTools"];
85
+ if (isBoolean(value["supportsMidConvoSystemMessages"])) {
86
+ compatibility.supportsMidConvoSystemMessages = value["supportsMidConvoSystemMessages"];
72
87
  }
73
88
  if (isBoolean(value["supportsOpenAIGrammarTools"])) {
74
89
  compatibility.supportsOpenAIGrammarTools = value["supportsOpenAIGrammarTools"];
@@ -76,12 +91,37 @@ export function responsesCompatibility(value: unknown): ResponsesCompatibility {
76
91
  if (isBoolean(value["supportsStrictMode"])) {
77
92
  compatibility.supportsStrictMode = value["supportsStrictMode"];
78
93
  }
79
- if (isBoolean(value["supportsToolSearch"])) {
80
- compatibility.supportsToolSearch = value["supportsToolSearch"];
81
- }
82
94
  return compatibility;
83
95
  }
84
96
 
97
+ /**
98
+ * Resolve Pi's transcript the way Pi AI's Codex adapter does before building a
99
+ * request: models that accept mid-conversation system messages keep later
100
+ * system messages in place, and every other model collapses them into the
101
+ * leading system message.
102
+ */
103
+ export function resolveRequestTranscript(model: Model<Api>, context: Context): TranscriptContext {
104
+ return resolveTranscript(
105
+ normalizeContext(context),
106
+ responsesCompatibility(model.compat).supportsMidConvoSystemMessages ?? false,
107
+ );
108
+ }
109
+
110
+ /** The leading system message of a resolved transcript, rendered as Responses `instructions`. */
111
+ export function requestInstructions(context: TranscriptContext): string {
112
+ const initial = getInitialSystemMessage(context.messages);
113
+ return initial ? getSystemMessageText(initial) : "";
114
+ }
115
+
116
+ /** `instructions` for a request built from a session branch rather than Pi's live transcript. */
117
+ export function branchInstructions(model: Model<Api>, branch: readonly SessionEntry[]): string {
118
+ return requestInstructions(
119
+ resolveRequestTranscript(model, {
120
+ messages: convertToLlm(buildSessionContext([...branch]).messages),
121
+ }),
122
+ );
123
+ }
124
+
85
125
  function responsesToolParameters(tool: ToolInfo): JsonRecord {
86
126
  if (!isObject(tool.parameters)) {
87
127
  throw new Error(`Tool ${tool.name} must have JSON object parameters.`);
@@ -143,6 +183,81 @@ export function activeResponsesTools(
143
183
  : undefined;
144
184
  }
145
185
 
186
+ /** The raw tool definition fields that both Pi AI's `Tool` and Pi's `ToolInfo` carry. */
187
+ export type ToolDeclarationSource = Pick<ToolInfo, "name" | "description" | "parameters">;
188
+
189
+ /**
190
+ * Fingerprint raw tool definitions by name, description, and parameter schema,
191
+ * independent of declaration order. Both the transcript tools a turn request
192
+ * declared and the registry's active `ToolInfo` list produce the same value for
193
+ * the same definitions, so a cached turn template can be compared with the
194
+ * current tools without reversing the strict schema conversion applied on the
195
+ * wire.
196
+ */
197
+ export function toolDefinitionFingerprint(tools: readonly ToolDeclarationSource[]): string {
198
+ const declarations = tools
199
+ .map((tool) => {
200
+ // The JSON round-trip drops typebox symbol keys and undefined fields, as
201
+ // Pi AI's own declaration comparison does.
202
+ const parameters: unknown = JSON.parse(JSON.stringify(tool.parameters));
203
+ return { name: tool.name, description: tool.description, parameters };
204
+ })
205
+ .sort((left, right) => left.name.localeCompare(right.name));
206
+ return createHash("sha256").update(stableResponsesJson(declarations)).digest("hex");
207
+ }
208
+
209
+ /** Fingerprint of the registry tools that are currently active. */
210
+ export function activeToolFingerprint(
211
+ allTools: readonly ToolInfo[],
212
+ activeNames: readonly string[],
213
+ ): string {
214
+ const enabled = new Set(activeNames);
215
+ return toolDefinitionFingerprint(allTools.filter((tool) => enabled.has(tool.name)));
216
+ }
217
+
218
+ /**
219
+ * Whether a cached turn template still declares exactly the active tools.
220
+ *
221
+ * The serialized `tools` list must name the active set (namespace members
222
+ * count as `namespace.member`), and the raw definitions that produced it must
223
+ * fingerprint identically to the active registry tools. Comparing names and
224
+ * descriptions alone missed parameter-schema changes under an unchanged name.
225
+ *
226
+ * A cached turn template carries the exact declarations the turn requests sent,
227
+ * including strict schema conversion that `ToolInfo` cannot reproduce. Reusing
228
+ * it keeps the compaction request on the turn requests' prompt-cache prefix.
229
+ */
230
+ export function declaresActiveTools(
231
+ serialized: readonly JsonRecord[],
232
+ cachedToolFingerprint: string,
233
+ allTools: readonly ToolInfo[],
234
+ activeNames: readonly string[],
235
+ ): boolean {
236
+ if (cachedToolFingerprint !== activeToolFingerprint(allTools, activeNames)) return false;
237
+ const declared = new Map<string, string | undefined>();
238
+ for (const item of serialized) {
239
+ if (item.type === "namespace" && isString(item.name) && Array.isArray(item.tools)) {
240
+ for (const member of item.tools) {
241
+ if (!isObject(member) || !isString(member.name)) return false;
242
+ declared.set(
243
+ `${item.name}.${member.name}`,
244
+ isString(member["description"]) ? member["description"] : undefined,
245
+ );
246
+ }
247
+ } else if ((item.type === "function" || item.type === "custom") && isString(item.name)) {
248
+ declared.set(item.name, isString(item["description"]) ? item["description"] : undefined);
249
+ } else {
250
+ return false;
251
+ }
252
+ }
253
+ const enabled = new Set(activeNames);
254
+ const active = allTools.filter((tool) => enabled.has(tool.name));
255
+ return (
256
+ declared.size === active.length &&
257
+ active.every((tool) => declared.has(tool.name) && declared.get(tool.name) === tool.description)
258
+ );
259
+ }
260
+
146
261
  export function remoteCompactionMarkerSummary(): string {
147
262
  return `OpenAI Codex remote compaction checkpoint (${randomUUID()}).`;
148
263
  }
@@ -150,7 +265,6 @@ export function remoteCompactionMarkerSummary(): string {
150
265
  type EncodeMessagesOptions = {
151
266
  model: Model<Api>;
152
267
  messages: Message[];
153
- allTools: readonly ToolInfo[];
154
268
  grammarToolInputProperties: GrammarToolInputProperties;
155
269
  imageDetail: ImageDetail;
156
270
  nativeAssistantItems?: ReadonlyMap<string, readonly ResponsesOutputItem[]>;
@@ -159,16 +273,22 @@ type EncodeMessagesOptions = {
159
273
  export type EncodeSessionEntriesOptions = {
160
274
  model: Model<Api>;
161
275
  entries: readonly SessionEntry[];
162
- allTools: readonly ToolInfo[];
163
276
  grammarToolInputProperties?: GrammarToolInputProperties;
164
277
  imageDetail?: ImageDetail;
165
278
  nativeAssistantItems?: ReadonlyMap<string, readonly ResponsesOutputItem[]>;
166
279
  initialSystemMessage?: SystemMessage;
280
+ /** Model-visible messages per entry. Defaults to the entry's raw messages. */
281
+ entryMessages?: (entry: SessionEntry) => AgentMessages;
167
282
  };
168
283
 
169
284
  /**
170
285
  * Encode Pi's canonical messages using Pi AI's OpenAI Responses serializer.
171
286
  *
287
+ * The leading system message travels as `instructions`, so it is excluded here.
288
+ * Later system messages become inline developer items on models that accept
289
+ * mid-conversation system messages and collapse into the leading message
290
+ * otherwise, exactly as Pi AI's Codex adapter does.
291
+ *
172
292
  * Tool declarations never enter the input: every request carries the complete
173
293
  * current tool set at the top level, as the official Codex client does. Inline
174
294
  * `additional_tools` items and synthetic tool-search pairs made the Codex
@@ -180,8 +300,7 @@ function encodeMessages(options: EncodeMessagesOptions): ResponsesInputItem[] {
180
300
  const compat = responsesCompatibility(model.compat);
181
301
  const serializationOptions: NonNullable<Parameters<typeof convertResponsesMessages>[3]> = {
182
302
  includeSystemPrompt: false,
183
- includeSystemUpdates: false,
184
- supportsMidConvoSystemMessages: true,
303
+ supportsMidConvoSystemMessages: compat.supportsMidConvoSystemMessages ?? false,
185
304
  supportsAdditionalTools: false,
186
305
  supportsToolSearch: false,
187
306
  grammarToolInputProperties,
@@ -206,21 +325,21 @@ export function encodeSessionEntries(options: EncodeSessionEntriesOptions): Resp
206
325
  const {
207
326
  model,
208
327
  entries,
209
- allTools,
210
328
  grammarToolInputProperties = new Map(),
211
329
  imageDetail = "auto",
212
330
  nativeAssistantItems,
331
+ entryMessages = sessionEntryToContextMessages,
213
332
  } = options;
214
- const messages = entries.flatMap((entry) => sessionEntryToContextMessages(entry));
333
+ const messages = entries.flatMap((entry) => entryMessages(entry));
215
334
  const encodeOptions: EncodeMessagesOptions = {
216
335
  model,
217
336
  // A partial tail can start with a system update. Seed its prior state so
218
- // the serializer does not mistake that update for the leading declaration.
337
+ // the serializer renders that update inline instead of dropping it as the
338
+ // leading system message that `instructions` already carries.
219
339
  messages: [
220
340
  options.initialSystemMessage ?? { role: "system", content: "", timestamp: 0 },
221
341
  ...convertToLlm(messages),
222
342
  ],
223
- allTools,
224
343
  grammarToolInputProperties,
225
344
  imageDetail,
226
345
  };
@@ -336,14 +455,73 @@ export function checkpointData(
336
455
  return checkpoint;
337
456
  }
338
457
 
458
+ /**
459
+ * The entries Pi 0.87 omits for overflow or length recovery: the latest
460
+ * assistant attempt and the tool results of that turn, each targeted by a
461
+ * `context_edit` omission appended after the attempt. `leafId` is the parent of
462
+ * the first such omission, so a projection built up to it reflects every edit
463
+ * that existed before Pi omitted the attempt.
464
+ *
465
+ * Pi only selects a recovery attempt while it is still projected, so an
466
+ * omission of the latest assistant found here is Pi's recovery omission and
467
+ * not an unrelated edit.
468
+ */
469
+ function recoveryOmissions(
470
+ branch: readonly SessionEntry[],
471
+ ): { entryIds: ReadonlySet<string>; leafId: string } | undefined {
472
+ const attemptIndex = branch.findLastIndex(
473
+ (entry) => entry.type === "message" && entry.message.role === "assistant",
474
+ );
475
+ const attempt = branch[attemptIndex];
476
+ if (attempt?.type !== "message" || attempt.message.role !== "assistant") return undefined;
477
+
478
+ const toolCallIds = new Set(
479
+ attempt.message.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])),
480
+ );
481
+ const candidates = new Set([attempt.id]);
482
+ for (const entry of branch.slice(attemptIndex + 1)) {
483
+ if (entry.type !== "message") continue;
484
+ if (entry.message.role !== "toolResult" || !toolCallIds.has(entry.message.toolCallId)) break;
485
+ candidates.add(entry.id);
486
+ }
487
+
488
+ const latestEdits = new Map<string, ContextEditEntry>();
489
+ for (const entry of branch.slice(attemptIndex + 1)) {
490
+ if (entry.type === "context_edit" && candidates.has(entry.targetId)) {
491
+ latestEdits.set(entry.targetId, entry);
492
+ }
493
+ }
494
+ if (latestEdits.get(attempt.id)?.replacement !== null) return undefined;
495
+ const omissions = new Set(
496
+ [...latestEdits.values()].filter((entry) => entry.replacement === null),
497
+ );
498
+ const first = branch.find((entry) => entry.type === "context_edit" && omissions.has(entry));
499
+ if (first?.type !== "context_edit" || first.parentId === null) return undefined;
500
+ return {
501
+ entryIds: new Set([...omissions].map((entry) => entry.targetId)),
502
+ leafId: first.parentId,
503
+ };
504
+ }
505
+
339
506
  /**
340
507
  * Materialize provider history for the active branch. Checkpoint history
341
508
  * replaces everything before its entry; later branch entries form the tail.
509
+ *
510
+ * Every request reads the session projection, so `context_edit` omissions and
511
+ * replacements apply exactly as they do for Pi's own adapters. Recovery
512
+ * compaction differs only for the latest attempt: Pi 0.87 omits the failed or
513
+ * truncated assistant and its tool results through `context_edit` entries
514
+ * before it runs `session_before_compact`. Those entries are projected as they
515
+ * were before Pi's omission, so a truncated boundary stays as committed
516
+ * progress while every earlier omission or replacement remains in force. The
517
+ * serializer skips error and aborted assistants; a failed attempt contributes
518
+ * only the native prefix committed before the overflowing subrequest. Entries
519
+ * are never removed from the branch copy because the omission entry's parent
520
+ * chain would break and the history would vanish.
342
521
  */
343
522
  export function providerHistory(options: {
344
523
  branch: readonly SessionEntry[];
345
524
  wireModel: Model<Api>;
346
- allTools: readonly ToolInfo[];
347
525
  grammarToolInputProperties?: GrammarToolInputProperties;
348
526
  imageDetail?: ImageDetail;
349
527
  recoverLatestOverflowPrefix?: boolean;
@@ -351,29 +529,51 @@ export function providerHistory(options: {
351
529
  const branch = [...options.branch];
352
530
  let recoveredPrefix: ResponsesInputItem[] = [];
353
531
  if (options.recoverLatestOverflowPrefix) {
354
- const index = branch.findLastIndex(
355
- (entry) => entry.type === "message" && entry.message.role === "assistant",
532
+ const entry = branch.findLast(
533
+ (candidate) => candidate.type === "message" && candidate.message.role === "assistant",
356
534
  );
357
- const entry = index >= 0 ? branch[index] : undefined;
358
535
  if (
359
536
  entry?.type === "message" &&
360
537
  entry.message.role === "assistant" &&
361
- (entry.message.stopReason === "error" || entry.message.stopReason === "aborted")
538
+ entry.message.stopReason === "error" &&
539
+ entry.message.responseId
362
540
  ) {
363
- if (entry.message.stopReason === "error" && entry.message.responseId) {
364
- recoveredPrefix =
365
- nativeCommittedPrefixBeforeOverflow(
366
- branch,
367
- options.wireModel.id,
368
- entry.message.responseId,
369
- ) ?? [];
541
+ recoveredPrefix =
542
+ nativeCommittedPrefixBeforeOverflow(
543
+ branch,
544
+ options.wireModel.id,
545
+ entry.message.responseId,
546
+ ) ?? [];
547
+ }
548
+ }
549
+
550
+ const projection = buildSessionProjection(branch);
551
+ const projected = new Map<string, AgentMessages>();
552
+ for (const entry of projection.entries) projected.set(entry.sourceEntry.id, entry.messages);
553
+ const recovery = options.recoverLatestOverflowPrefix ? recoveryOmissions(branch) : undefined;
554
+ if (recovery) {
555
+ for (const entry of buildSessionProjection(branch, recovery.leafId).entries) {
556
+ if (recovery.entryIds.has(entry.sourceEntry.id)) {
557
+ projected.set(entry.sourceEntry.id, entry.messages);
370
558
  }
371
- branch.splice(index, 1);
372
559
  }
373
560
  }
561
+ const entryMessages = (entry: SessionEntry): AgentMessages => projected.get(entry.id) ?? [];
374
562
 
375
563
  const checkpoint = searchCheckpoint(branch);
376
- const nativeAssistantItems = nativeResponseOverrides(branch, options.wireModel.id);
564
+ const nativeAssistantItems = new Map(nativeResponseOverrides(branch, options.wireModel.id));
565
+ for (const entry of branch) {
566
+ if (
567
+ entry.type === "message" &&
568
+ entry.message.role === "assistant" &&
569
+ entry.message.responseId &&
570
+ entryMessages(entry).some((message) => message !== entry.message)
571
+ ) {
572
+ // A replacement keeps the response ID but changes the model-visible content.
573
+ // Its original native output must not override the canonical replacement.
574
+ nativeAssistantItems.delete(entry.message.responseId);
575
+ }
576
+ }
377
577
  if (checkpoint.kind === "corrupt") {
378
578
  throw new Error("The latest Codex compaction checkpoint is corrupt.");
379
579
  }
@@ -391,22 +591,22 @@ export function providerHistory(options: {
391
591
  ...encodeSessionEntries({
392
592
  model: options.wireModel,
393
593
  entries: branch.slice(checkpoint.entryIndex + 1),
394
- allTools: options.allTools,
395
594
  grammarToolInputProperties: options.grammarToolInputProperties ?? new Map(),
396
595
  imageDetail: options.imageDetail ?? "auto",
397
596
  nativeAssistantItems,
597
+ entryMessages,
398
598
  ...(initialSystemMessage ? { initialSystemMessage } : {}),
399
599
  }),
400
600
  ...recoveredPrefix,
401
601
  ];
402
602
  }
403
603
 
404
- const context = buildSessionContext(branch);
405
604
  return [
406
605
  ...encodeMessages({
407
606
  model: options.wireModel,
408
- messages: convertToLlm(context.messages),
409
- allTools: options.allTools,
607
+ messages: convertToLlm(
608
+ projection.entries.flatMap((entry) => entryMessages(entry.sourceEntry)),
609
+ ),
410
610
  grammarToolInputProperties: options.grammarToolInputProperties ?? new Map(),
411
611
  imageDetail: options.imageDetail ?? "auto",
412
612
  nativeAssistantItems,
@@ -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 = [
4
10
  "getCurrentTools",
5
11
  "getDeclaredTools",
6
12
  "getInitialSystemMessage",
13
+ "getSystemMessageText",
14
+ "normalizeContext",
15
+ "renderSystemMessageUpdate",
16
+ "resolveTranscript",
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,15 +252,26 @@ 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
258
  });
253
- // 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 when the active set or
262
+ // any definition (including a parameter schema under an unchanged name)
263
+ // changed since that turn, or when no turn template exists.
254
264
  const template: JsonRecord = { ...matching?.payload };
265
+ const activeNames = pi.getActiveTools();
266
+ const cachedTools = Array.isArray(matching?.payload.tools)
267
+ ? matching.payload.tools.filter(isObject)
268
+ : undefined;
255
269
  template.tools =
256
- activeResponsesTools(allTools, pi.getActiveTools(), grammarToolInputProperties) ?? [];
270
+ cachedTools &&
271
+ matching &&
272
+ declaresActiveTools(cachedTools, matching.toolFingerprint, allTools, activeNames)
273
+ ? cachedTools
274
+ : (activeResponsesTools(allTools, activeNames, grammarToolInputProperties) ?? []);
257
275
  const requestOptions: OpenAICodexResponsesOptions = {
258
276
  ...matching?.requestOptions,
259
277
  apiKey: authentication.apiKey,
@@ -265,7 +283,14 @@ export default function registerRemoteCompaction(
265
283
  model: ctx.model,
266
284
  requestOptions,
267
285
  history,
268
- instructions: instructionsForCompaction(ctx.getSystemPrompt(), event.customInstructions),
286
+ // Turn requests send the branch's leading system message as `instructions`
287
+ // and carry later system messages inline in the history. A branch without
288
+ // a leading system message (a pre-0.86 session) falls back to Pi's
289
+ // current prompt so the compaction still sees instructions.
290
+ instructions: instructionsForCompaction(
291
+ branchInstructions(ctx.model, event.branchEntries) || ctx.getSystemPrompt(),
292
+ event.customInstructions,
293
+ ),
269
294
  grammarToolInputProperties:
270
295
  matching?.grammarToolInputProperties ??
271
296
  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.13",
3
+ "version": "0.0.15",
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.1",
55
- "@earendil-works/pi-coding-agent": "0.86.1",
56
- "@earendil-works/pi-tui": "0.86.1",
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": {