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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions devlog/_plan/260927_directive_marker_bridge/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Codex App visualization references for models that cannot see private-use characters

## Objective

A routed model must be able to show a Codex App inline visualization. Today only models whose
provider preserves Basic Multilingual Plane private-use characters can, because the reference the
bundled Visualize skill teaches is `U+E200 visualize U+E202 {json} U+E201`.

Observed on 2026-09-27 against the local proxy (2.68.0, `a1285fc648`): asked to list the code
points of `[A U+E200 B U+E202 C U+E201]`, `gpt-6-luna` and `xai/grok-4.7` return all six, while
`anthropic/claude-opus-5-5` and `cursor/claude-opus-5-5` return only `A B C`. OpenCodex's own
Anthropic request body still contains the three characters (checked by building the request in
process), so they are removed after the proxy. Claude therefore reads the skill template as
`visualize{"path":...}`, writes that plain text back, and the app shows it verbatim.

## Constraints

- Native OpenAI/ChatGPT passthrough stays byte-identical: it serializes `_rawBody`.
- Stored request history stays raw (`adapter-delivery.ts` hands `_rawBody` to `state.ts`).
- The citation filter semantics from #3150, #3843 and #6040 are unchanged.
- No new dependency, no Lab import on the request path, no file-size ratchet increase.

## Work-phase map

| Work-phase | Doc | Outcome |
|---|---|---|
| wp1 | this directory, 000-001 | Evidence and roadmap (docs only) |
| wp2 | [010](010_wp2_visualization_directive_normalization.md) | Parser-side normalization, tests, PR, CI, squash merge |
| wp3 | [020](020_wp3_rebuild_and_live_verify.md) | Local app rebuild from merged dev with a fresh sidecar, live Claude check |

wp2 depends on the app evidence in [001](001_app_and_external_evidence.md); wp3 depends on wp2 being on `dev`.

## Architect consultation (formal P)

Architect: gpt-6-astra subagent `01a0e0ce-58fd-7fd3-8186-12e3752ec8f6` (Fermat), read-only.
Proposal D1-D14 received 2026-09-27. Main dispositions:

| Id | Proposal | Disposition |
|---|---|---|
| D1 | New pure module `src/responses/visualization-directives.ts`, copy-on-change | Accepted |
| D2 | Hook at the parser's final `context` | Accepted; covers the replay expansion and the encrypted-agent reparse |
| D3 | Normalize conversation text only (system prompt, message strings, text parts), including fenced code | Accepted |
| D4 | Match only complete literal `U+E200visualize U+E202 … U+E201` spans | Accepted |
| D5 | Payload rules mirror the app's `f2` exactly | Accepted |
| D6 | Single template exception for `<absolute-path>/<title>.html` | Accepted |
| D7 | `codex-live-vis` for `type:"live"`, `mode="wide"` only for inline `mode:"wide"` | Accepted |
| D8 | Raw-body passthrough excluded | Accepted. Whether every raw-body route preserves the characters is not established; the observed failures are both on context-built adapters |
| D9 | No persistence change; history stays raw | Accepted |
| D10 | Deterministic output; Cursor checkpoint digest will differ once for affected prefixes | Accepted; the digest mismatch invalidates the old checkpoint, which is the safe direction. No dedicated Cursor test, recorded as residual |
| D11 | No output repair of bare `visualize{json}` | Accepted; revisit only if live checks show a model still emitting the bare form |
| D12 | Alternatives | Rejected: adapter hooks duplicate the rule per adapter and miss provider switches mid-thread; a request-prepare hook misses the encrypted-agent reparse and direct parser callers; changing the upstream skill text does not reach installed plugins or existing history |
| D13 | Sibling test file registered in both layout manifests | Accepted; replay-after-restart and Cursor checkpoint tests narrowed to raw-body and idempotence assertions |
| D14 | Live UI confirmation of the ASCII directive still pending | Tracked in wp3 |

Limitations carried forward: raw-body passthrough routes are not normalized; replies already stored as bare `visualize{...}` are not repaired; a future template variant needs its own fixture; the live render of the ASCII form is confirmed only in wp3.

Reflection: recorded in [010](010_wp2_visualization_directive_normalization.md#architect-reflection).
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Evidence: how the Codex App reads visualization references

## App bundle (primary)

Source: `/Applications/ChatGPT.app` (bundle `com.openai.codex`, 26.924.22138), `Contents/Resources/app.asar`
extracted read-only to `.tmp/asar/app`. Paths below are inside `webview/assets/`.

- `app-shared-36eae88777f2.js`, function `f2`: every span matching
`/U+E200visualize U+E202([^U+E201]+)U+E201/g` outside markdown code tokens is rewritten to
`::codex-inline-vis{path="<abs>" title="…" mode="wide"}` before rendering. Payload handling:
- payload starting with `{` is `JSON.parse`d (failure keeps the span); otherwise it is `{path: payload}`;
- schema `{path: string|null, title?: string, type?: "inline"|"live", mode?: "wide", wide?: boolean}`;
- `path: null` becomes `::codex-live-vis{}` for `type:"live"` and is otherwise kept;
- the span is kept when the path has a `..` segment, a double quote or CR/LF, when its basename does
not match `/^[a-z0-9]+(?:-[a-z0-9]+)*\.html$/`, or when it is not absolute and is either JSON or not a bare basename;
- attribute is `path` for an absolute path and `file` for a bare basename;
- `title` is emitted only without quotes or CR/LF; `mode="wide"` only for inline with `mode:"wide"`.
- Same file, function `Err`: a message is a visualization when it contains the private-use form OR the
literal `::codex-inline-vis`, and parsing `f2(text)` yields a `codexDirective` named
`codex-inline-vis`. The plain ASCII directive is therefore the app's canonical form.
- Same file, `sj` (absolute path): `/…` but not `//…`, `/^[A-Za-z]:[\\/]/`, `/^\\\\[^\\]+\\[^\\]+/`, `/^\/\/[^/]+\/[^/]+/`.
- `app-primary-cca0c1a58f0f.js`, `i3e`: registers `renderElement` for the inline directive and reads
its `path` or `file` attribute; `o8e` attaches it whenever `renderInlineVisualizations` is set, which
`conversation-blocks-1768e325fd75.js` (`qg`) sets for assistant message blocks.
- `app-primary-cca0c1a58f0f.js`, `a3e`: while streaming, a trailing partial `U+E200visualize U+E202` is
hidden until it closes.

## External sources

| Claim | Source | Status |
|---|---|---|
| OpenAI documents the private-use citation grammar (`U+E200 cite U+E202 … U+E201`) | https://developers.openai.com/api/docs/guides/citation-formatting | primary |
| Claude Code strips BMP private-use characters (U+E000-U+F8FF) from tool input and output; supplementary PUA-A survives | https://github.com/anthropics/claude-code/issues/44525 (2026-04-07) | primary report |
| Same class reported earlier for U+E0A0 | https://github.com/anthropics/claude-code/issues/31849 (2026-03-07) | primary report |
| Streamed citation markers leak into third-party output | https://community.openai.com/t/streamed-web-search-citations-leaking-citation-markers-into-text-output/1390157 (2026-08-12) | lead |
| Codex desktop instructions use plain `::name{…}` directives (`::code-comment`, `::created-thread`) | Codex App system prompt in this session; community reproduction | primary (session) |

Luna lanes: Plato, Ampere, Avicenna (`gpt-5.6-luna`). Aside exec session `boOE2Tp2gdGCg2Do`,
report under `~/.aside/u/0/artifacts/ocx-directive-research/`.

## Conclusion

Converting the private-use reference into `::codex-inline-vis{…}` before the model sees it gives a model
that cannot see private-use characters an ASCII instruction it can read and repeat. The bundle parses that
form directly (`Err`, `f2`); the live render is confirmed in wp3.


## Session observation

A commentary message in this thread that contained `::codex-inline-vis{…}` reached the rollout only as a
`reasoning` summary, not as an assistant `message`, so the user saw plain text. Other commentary blocks
in the same thread show the same pattern. The render test therefore uses a final answer. How routed
Claude text becomes a reasoning item is outside this unit.
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# wp2: normalize visualization references in model-visible text

## Scope

IN: `src/responses/visualization-directives.ts` (new), `src/responses/parser.ts` (one call at the
return), `tests/responses/visualization-directives.test.ts` (new), `scripts/test-layout/layout.json`,
`tests/fixtures/test-layout-expected.json`, `structure/transports/responses.md`,
`docs-site/src/content/docs/guides/codex-integration.md` (new subsection after "Routed local tools").
OUT: adapters, raw-body passthrough, response-side repair, persistence, citation filter.

## Files

### NEW `src/responses/visualization-directives.ts`

Exports:

- `normalizeVisualizationText(text: string): string` — returns `text` unchanged (same reference)
unless it contains the exact prefix `"\uE200visualize\uE202"` (no spaces). It produces the same
matches as the app's regex `/\uE200visualize\uE202([^\uE201]+)\uE201/g`, but with a linear scanner:
find the next prefix with `indexOf`; the payload runs to the next END (`indexOf` from the prefix end);
an empty payload resumes the prefix search one character later; no END after a prefix means no later
match exists, so scanning stops. Each match becomes `toAsciiDirective(payload)` or stays as is.
Unterminated spans are copied through. App-regex parity supersedes D4's opaque-payload rule: a
`visualize` span inside another keyword's payload is converted, as the app would convert it.

Intentional differences from `f2`: code tokens are normalized too (the skill's example is fenced),
and the template exception below. Using the same regex means a `visualize` span that appears after
a malformed START of another keyword is converted exactly as the app would convert it.
- `normalizeVisualizationContext(context: OcxContext): OcxContext` — copy-on-change over
`systemPrompt`, string `content`, and `type:"text"` parts of every message role; returns the same
object when nothing changed.

`toAsciiDirective(payload)` follows [001](001_app_and_external_evidence.md) rule for rule: every valid
`type:"live"` payload becomes `::codex-live-vis{…}` with no `mode`; `wide:true` is validated and
ignored; `mode="wide"` only for inline. Template exception: a JSON payload whose `path` is exactly
`<absolute-path>/<title>.html` skips path validation and always uses the `path` attribute (the app's
`sj` would pick `file` for it); schema, type, title and mode rules still apply. Both skill templates
have exact-output assertions.

### MODIFY `src/responses/parser.ts`

```diff
+import { normalizeVisualizationContext } from "./visualization-directives";
@@ return {
- context,
+ context: normalizeVisualizationContext(context),
```

### NEW `tests/responses/visualization-directives.test.ts`

Cases: absolute JSON path; title; wide; live with path and with null path; bare absolute path; bare
basename (`file=`); both skill templates; spans inside a fenced block; rejected payloads kept (invalid
JSON, relative JSON path, `..`, quote, bad basename, missing path); other keywords and unterminated
spans untouched; Windows drive and UNC paths; live null path with a title; malformed optional fields
(`title` number, `type` other, `mode` other, `wide` string); a decoded compaction summary carrying the
reference; 20,000 repeated unterminated prefixes normalized in under 200 ms; exact output for a
`visualize` span nested in a `cite` payload and for a malformed `visualize` prefix followed by a valid
one; idempotence; every
role and content shape through `parseRequest`, with image parts, tool-call arguments and reasoning
parts deep-equal to an unnormalized parse; a frozen input body (deep `Object.freeze`) parses without
throwing and `_rawBody` is the same object; an expanded continuation body (prior assistant output
with the private-use reference plus a new user turn) normalizes both turns; the Anthropic adapter
body contains the ASCII directive and no private-use character.

### MODIFY layout manifests and `structure/transports/responses.md`

Register the test under `responses`; add one paragraph to the transport doc naming the module and the
raw-body exclusion.

### MODIFY `docs-site/src/content/docs/guides/codex-integration.md`

New `### Inline visualizations with routed models` after `### Routed local tools`: the private-use
reference is rewritten to `::codex-inline-vis{…}` for context-built routes, raw-body passthrough routes
are unchanged, and replies already stored in the bare `visualize{…}` form stay as they are. Locale
copies gain nothing, so they do not contradict the English page.

## Verification (PLAN-VERIFIER-REAL-01)

| Command | Reads the target |
|---|---|
| `bun test tests/responses/visualization-directives.test.ts` | direct argument |
| `bun test tests/responses/citation-markers.test.ts tests/adapters/bridge.test.ts` | callers of the response path |
| `bun test tests/responses/responses-parser.test.ts tests/responses/responses-parser-agent-message.test.ts tests/responses/responses-parser-malformed-content.test.ts tests/responses/parser-content-audio.test.ts tests/responses/responses-state.test.ts` (run locally) | parser and replay consumers |
| `bun test tests/lab/core-lab-boundary.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | Lab boundary, ratchet, layout manifests (source-reading guards `test:changed` cannot see) |
| `bun run test:changed` | import graph from `parser.ts`; local run is limited by the `~/.codex` checkout guard, the full suite is left to CI |
| `bun run typecheck`, `bun run structure:check`, `bun run privacy:scan` | whole tree |

Activation scenarios: a rejected payload (C runs the "kept" cases and sees the private-use span unchanged);
a live null path (C sees `::codex-live-vis{}`).

## Architect reflection

Fermat returned MISALIGNED on the first plan revision with five gaps. Dispositions:

1. Exact prefix — folded. Opaque unknown spans — superseded in the second reflection by app-regex
parity (the app converts a nested span, so the model should see the same thing); nested and
malformed-prefix fixtures pin the exact output.
2. Live selection, ignored `wide`, template exception scope — folded above.
3. Opaque-field, frozen-input and replay assertions — folded above. Cursor checkpoint rejection test —
rebutted: the Cursor builder compares a digest of the context it is given, and this change only
alters that context's text, so an old checkpoint mismatches and is discarded, which is the existing
safe path; recorded as residual instead of a new Cursor fixture.
4. Existing suites and source-reading guards named explicitly — folded above.
5. Universal claims removed; alternatives' reasons and limitations recorded in 000 and 001 — folded.


Second reflection (wp2 P): MISALIGNED on regex cost (quadratic on repeated unterminated prefixes,
measured 16/63/251 ms for 2k/4k/8k) and on the opaque-span claim. Both folded above: linear scanner with
the regex's semantics, parity recorded explicitly, adversarial and nested fixtures added.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# wp3: rebuild the local app and verify with a Claude-routed model

1. In the primary checkout, fast-forward `dev` to the merge commit.
2. Force a fresh standalone sidecar: `bun run build:standalone --target bun-darwin-arm64`.
`desktop/scripts/prepare-sidecar.ts` reuses `dist/standalone/*/ocx` when it exists, which shipped a
stale 2.61.0 proxy inside the 2.68.0 app on 2026-09-27.
3. `bun run build:gui`, then in `desktop/`: `bun run prepare-sidecar`, `bun run prepare-widget`, and
`bun run build:local` with the shared `.git/config` `core.bare` set to `false` only for the build
(Cargo reads it) and restored afterwards.
4. Extract `OpenCodex.app` from the DMG, verify `codesign --verify --deep --strict`, and check that
the bundled `ocx` contains `codex-inline-vis`.
5. Protocol check against the packaged sidecar once the user has reinstalled the app: confirm
`/healthz` reports the rebuilt version and that the running binary is the reinstalled bundle, then send a
Claude-routed request whose input carries the private-use reference and confirm the reply contains
`::codex-inline-vis{path="…"}` and no private-use character.
6. Render check in the Codex App: in this Claude-routed thread, write a real HTML fragment under the
thread's visualization directory and put `::codex-inline-vis{path="…"}` on its own line in a
**final answer** (a commentary block can be recorded as a reasoning summary, which the app does not
render as markdown directives; observed 2026-09-27). The user reports whether it renders.
Computer Use cannot inspect `com.openai.codex` (blocked by its safety policy), so rendering stays
"pending user confirmation" until that report.

Shared `.git/config` note: Cargo (libgit2) ignores `config.worktree`, so the build flips `core.bare` for
the shortest possible window and restores it; do not run it while another task is using the checkout's
git config.
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,19 @@ mode unchanged.
After `ocx sync` changes this metadata, restart Codex App and open a fresh task. Existing app-server
processes and tasks may retain the catalog and tool plan they loaded at startup.

### Inline visualizations with routed models

The Codex App's Visualize plugin asks the model to reply with a reference wrapped in private-use
characters (U+E200 … U+E201). Some providers remove those characters before the model sees them —
every Claude route we checked does — so the model used to answer with a bare
`visualize{"path":…}` line that Codex App printed as text.

opencodex rewrites those references into the directive the app itself renders,
`::codex-inline-vis{path="/absolute/path/chart.html"}`, in the conversation text sent to routed
models. Any model can read and repeat that form, so the visualization renders inline. Native OpenAI
passthrough requests are forwarded unchanged. Replies that were already saved in the bare
`visualize{…}` form stay as they are; ask for the visualization again in a new reply.

### Custom model display names

A custom model can carry a human-readable **display name** that overrides the label Codex shows in
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@
"ci-structure-gate.test.ts": "ci-workflows",
"ci-workflows.test.ts": "ci-workflows",
"citation-markers.test.ts": "responses",
"visualization-directives.test.ts": "responses",
"cl01-claude-outbound-review-regressions.test.ts": "routing",
"cl01-openai-chat-review-regressions.test.ts": "routing",
"cl01-review-regressions.test.ts": "routing",
Expand Down
5 changes: 4 additions & 1 deletion src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { isObj, inputContentParts, outputTextOf, outputToToolResultContent, tool
import { mapToolChoice, buildTools, customToolNamespaces } from "./parser-tools";
import { parseTextFormat } from "./parser-text-format";
import { externalTaskInputContent } from "./task-input";
import { normalizeVisualizationContext } from "./visualization-directives";

/**
* Wrap a remembered proxy-side signature as provider metadata for a replayed tool call.
Expand Down Expand Up @@ -644,7 +645,9 @@ export function parseRequest(
return {
modelId: data.model,
...(data.previous_response_id ? { previousResponseId: data.previous_response_id } : {}),
context,
// Codex App visualization references in the private-use form are invisible to some models;
// hand every model the app's ASCII directive instead (visualization-directives.ts).
context: normalizeVisualizationContext(context),
stream: data.stream === true,
options,
_rawBody: body,
Expand Down
Loading
Loading