Skip to content

fix(responses): strip ChatGPT citation control markers before the client - #3195

Merged
lidge-jun merged 1 commit into
devfrom
codex/3150-citation-markers
Sep 1, 2026
Merged

lidge-jun merged 1 commit into
devfrom
codex/3150-citation-markers

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Codex CLI routed through OpenCodex to github-copilot/gpt-5.6-sol rendered assistant text as:

The setting is supported. citeturn1view0turn1view1

Those are Unicode private-use characters — U+E200 opens a citation span, U+E202 separates references, U+E201 closes it. The desktop client renders them as source chips; the Codex TUI prints them literally, in both commentary and the final answer, and they persist into the saved transcript.

Where they come from. rg across src/ for those codepoints, citeturn, and private-use handling returns nothing: OpenCodex neither emits nor recognizes this grammar. Its citation support is entirely structured (OcxUrlCitation, takeWebAnnotations()). So of the three origins the reporter proposed, it is the first — the markers are already literal text in the upstream response, because Copilot's backend is ChatGPT-derived.

That still makes it ours to fix. The proxy is the last place that can see this text before a client that cannot render it.

The part that is easy to get wrong

Assistant text reaches the client through two paths, and a span can straddle a delta boundary — \uE200cite in one chunk, the rest in the next. A stateless per-delta strip emits the tail of a span it never recognized.

So the streaming path uses a stateful filter that withholds an unterminated tail and releases it at close. Withholding rather than dropping matters: if a stream dies mid-marker, the bytes still reach the user instead of vanishing.

The accumulated text is stripped separately, because closeCurrentMessage() re-sends it in output_text.done, content_part.done, and output_item.done — filtering only the deltas would leave the markers in the transcript even with a clean-looking stream.

Scope

Strip only. turn1view0 is a turn-scoped opaque id and the response carries no mapping to a URL, so there is nothing to convert it into — the reporter's option 3 is the honest one. Structured url_citation annotations are untouched, so desktop Sources chips keep working.

Closes #3150.

Verification

Exact head 00d1bce34:

  • bun test ./tests/citation-markers.test.ts — 10 pass, 0 fail (unit: whole-string, multi-span, unchanged plain text, unterminated span preserved, stray delimiters left alone, one-character-at-a-time deltas, mid-span stream end, prefix emitted immediately).
  • bun test ./tests/bridge.test.ts — 66 pass, 0 fail, including three new end-to-end cases asserting the codepoints and turn1view0 are absent from every emitted event, not just the deltas.
  • bun test ./tests/bridge-lifecycle.test.ts ./tests/bridge-live-delivery.test.ts ./tests/bridge-nonstreaming-terminal.test.ts ./tests/bridge-terminal-singleness.test.ts ./tests/responses-parser.test.ts — 84 pass, 0 fail.
  • bun x tsc --noEmit — clean for the touched files.

Red-green on both failure modes, since each protects a different half:

  • Stripping the deltas but not the accumulated text → the end-to-end case goes red (transcript leak).
  • Using a stateless per-delta strip → the same case goes red (split-marker leak).

One honesty note: I could not obtain a live Copilot citation response, so this is verified against synthesized marker text matching the exact grammar the reporter documented, plus the real bridge end-to-end. If a live capture shows a variant shape, that is worth a follow-up.

Checklist

  • Targets dev
  • Origin established by search rather than assumed
  • Regressions cover the split-delta and transcript-leak failure modes, both proven red-green
  • Structured url_citation path untouched; desktop rendering unaffected
  • No credential, auth, workflow, or release-automation surface touched

The ChatGPT backend delimits inline citations with private-use characters
(U+E200 open, U+E202 separate, U+E201 close). The desktop client renders them as
source chips; the Codex TUI prints them literally, so a Copilot-routed answer
showed citeturn1view0turn1view1 and the markers persisted into the saved
transcript.

OpenCodex neither emits nor understands that grammar - it arrives as ordinary
assistant text - but the proxy is the last place that can remove it before a
client that cannot render it.

A span can straddle a delta boundary, so the streaming path uses a stateful
filter that withholds an unterminated tail and releases it at close: a stream
dying mid-marker must not swallow words the model produced. The accumulated text
is stripped separately, because closeCurrentMessage re-sends it in
output_text.done, content_part.done and output_item.done.

Strip only. The turnNviewN ids are turn-scoped and opaque with no URL mapping in
the response, so there is nothing to convert them into. Structured url_citation
annotations are untouched, so desktop Sources chips keep working.

Closes #3150
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 1, 2026 17:59
@lidge-jun
lidge-jun merged commit f3bcc67 into dev Sep 1, 2026
6 checks passed
@lidge-jun
lidge-jun deleted the codex/3150-citation-markers branch September 1, 2026 17:59
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-01T18:03:16.523763Z 00d1bce PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00d1bce344

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bridge.ts
// A citation span can straddle a delta boundary, so the filter withholds an
// unterminated tail and releases it at close (#3150). The accumulator above
// keeps the raw text; it is stripped once in closeCurrentMessage.
const visible = currentMsg.citationFilter.push(event.text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply filtering to the native Copilot passthrough

For the reported github-copilot/gpt-5.6-sol route, src/providers/registry.ts:2872-2879 selects the openai-responses adapter, whose passthrough: true causes src/server/responses/core.ts:3611 to return through the native passthrough branch. That branch applies createGithubCopilotResponsesBlockRewrite at src/server/responses/core.ts:4615-4617 and never calls bridgeToResponsesSSE, so this new filter is bypassed and the citation markers still reach the Codex client in the exact scenario this commit claims to fix. Add equivalent stateful filtering to the Copilot passthrough block rewrite, including delta, done, and completed snapshot text fields.

Useful? React with 👍 / 👎.

Comment on lines +86 to +92
const start = combined.lastIndexOf(CITATION_MARKER_START);
if (start === -1) return stripCitationMarkers(combined);
const endAfterStart = combined.indexOf(CITATION_MARKER_END, start + 1);
if (endAfterStart !== -1) return stripCitationMarkers(combined);
// The trailing span is still open: emit everything before it, hold the rest.
held = combined.slice(start);
return stripCitationMarkers(combined.slice(0, start));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the earliest unmatched citation opener buffered

When an open span receives another START before an END, such as chunks `${S}a` and `${S}b${E}`, lastIndexOf selects the second opener and stripCitationMarkers(combined.slice(0, start)) emits the first opener and a verbatim. The suffix is then removed, while closeCurrentMessage strips the entire raw span from its done snapshot, so live deltas leak a private-use marker and disagree with the finalized transcript. Preserve the already-held earliest opener, or otherwise make streamed output match stripCitationMarkers for every chunking.

Useful? React with 👍 / 👎.

tarunravi pushed a commit to tarunravi/opencodex that referenced this pull request Sep 14, 2026
…ent (lidge-jun#3195)

The ChatGPT backend delimits inline citations with private-use characters
(U+E200 open, U+E202 separate, U+E201 close). The desktop client renders them as
source chips; the Codex TUI prints them literally, so a Copilot-routed answer
showed citeturn1view0turn1view1 and the markers persisted into the saved
transcript.

OpenCodex neither emits nor understands that grammar - it arrives as ordinary
assistant text - but the proxy is the last place that can remove it before a
client that cannot render it.

A span can straddle a delta boundary, so the streaming path uses a stateful
filter that withholds an unterminated tail and releases it at close: a stream
dying mid-marker must not swallow words the model produced. The accumulated text
is stripped separately, because closeCurrentMessage re-sends it in
output_text.done, content_part.done and output_item.done.

Strip only. The turnNviewN ids are turn-scoped and opaque with no URL mapping in
the response, so there is nothing to convert them into. Structured url_citation
annotations are untouched, so desktop Sources chips keep working.

Closes lidge-jun#3150

Co-authored-by: jun <jun@lidge.dev>
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…ent (lidge-jun#3195)

The ChatGPT backend delimits inline citations with private-use characters
(U+E200 open, U+E202 separate, U+E201 close). The desktop client renders them as
source chips; the Codex TUI prints them literally, so a Copilot-routed answer
showed citeturn1view0turn1view1 and the markers persisted into the saved
transcript.

OpenCodex neither emits nor understands that grammar - it arrives as ordinary
assistant text - but the proxy is the last place that can remove it before a
client that cannot render it.

A span can straddle a delta boundary, so the streaming path uses a stateful
filter that withholds an unterminated tail and releases it at close: a stream
dying mid-marker must not swallow words the model produced. The accumulated text
is stripped separately, because closeCurrentMessage re-sends it in
output_text.done, content_part.done and output_item.done.

Strip only. The turnNviewN ids are turn-scoped and opaque with no URL mapping in
the response, so there is nothing to convert them into. Structured url_citation
annotations are untouched, so desktop Sources chips keep working.

Closes lidge-jun#3150

Co-authored-by: jun <jun@lidge.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant