-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix: bug-PR merge train batch 6 (tool-call id remint, native Chat reset replacement, test stability) #5918
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
fix: bug-PR merge train batch 6 (tool-call id remint, native Chat reset replacement, test stability) #5918
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e5adb72
fix(adapters): remint duplicate tool call ids on the openai-chat lane…
moseoridev 95813aa
fix(chat-native): refetch on zero-output mid-stream socket reset (#5882)
Yum-wu bde7a6d
test: stabilize full-suite isolation and integration budgets (#5849)
lzfxxx 5f336b7
test(chat-native): prove the replacement request copy is released mid…
lidge-jun 5ce30b3
fix(upstream-retry): require the resend gate on the zero-output wrapper
lidge-jun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # Bug-PR merge train batch 6 — plan | ||
|
|
||
| Branch `codex/bug-train-6` from `origin/dev` at `e807e1e27b`. One PR to `dev`; each carried PR is one | ||
| squashed commit with the contributor as author and a `Co-authored-by` trailer. Integration fixes are | ||
| separate commits after the carried ones. | ||
|
|
||
| ## Carried | ||
|
|
||
| | PR | Change | Why it is in | | ||
| |---|---|---| | ||
| | #5914 (@moseoridev) | `withUniqueToolCallIds` wraps the `openai-chat` adapter and remints only tool-call ids that repeat the caller's history or an id already emitted in the response (`-<n>` suffix). | Real infinite-loop bug with Claude Code behind upstreams that mint positional ids (`call-0-0`). Adapter-local, no credential path. | | ||
| | #5882 (@Yum-wu) | Native Chat refetches once on a zero-output mid-stream socket reset, gated by the ambiguous-resend allowance; replacement send releases its retained request copy. | Fixes dropped native Chat turns on reset. Maintainer blocker (retained request bytes after reselection) answered by `318520ebd6`; audit must confirm. | | ||
| | #5849 (@lzfxxx) | Test-only isolation and budget fixes (serial lane additions, fixture executable, timeouts, launcher wait for config injection). | Removes known flakes on macOS/Linux runners; no runtime change. Needs conflict resolution against current `dev`. | | ||
|
|
||
| ## Left out, with reason | ||
|
|
||
| - #5539 — flips deliberate "preserve caller spelling" tests for unpinned native Chat; a policy change the author marked `[WRONG BRANCH]`. | ||
| - #5916, #5831, #5911, #5915 — OAuth / main-account credential paths; they need a written security review (batch 7 candidate). | ||
| - #5782, #5800, #5497, #4222 — large feature-sized or conflicting, hygiene failures. | ||
|
|
||
| ## Build steps | ||
|
|
||
| 1. `git merge --squash pr-<n>` per PR in order 5914, 5882, 5849; resolve conflicts against `dev`. | ||
| 2. Check file-size ratchet, test-layout registries, structure docs for each carried file set. | ||
| 3. Integration commits only if a gate fails. | ||
|
|
||
| ## Check | ||
|
|
||
| - `bun x tsc --noEmit`, `bun run structure:check`, `bun run privacy:scan`. | ||
| - Focused: `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`, `tests/responses/chat-native-spend.test.ts`, | ||
| `tests/responses/responses-reset-replay.test.ts`, `tests/lib/upstream-retry-zero-output.test.ts`, test-layout guards, | ||
| file-size ratchet, the files #5849 touches. | ||
| - Exact-head hosted CI on the batch PR, then `gh pr merge --squash --admin --match-head-commit`. | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # Unique tool-call ids for positionally-minting chat upstreams | ||
|
|
||
| Unit opened 2026-09-26. | ||
|
|
||
| - [010_remint.md](010_remint.md) — the `openai-chat` lane. **DONE.** | ||
|
|
||
| Origin: a DeepSeek V4.1 Flash conversation through this proxy looped with a thinking-only turn that | ||
| never terminated, while the same client against the same gateway on a GLM model was unaffected. | ||
| The two models differ in the id they mint (positional vs random), which is the whole defect. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # Unique tool-call ids for positionally-minting chat upstreams | ||
|
|
||
| Defect: `src/adapters/registry.ts` `openai-chat` entry. The adapter forwards | ||
| `tool_calls[].id` upstream-verbatim, which is correct for an upstream that mints a fresh random id | ||
| per call (measured: `zai-org/glm-5.3-flash` mints `call_<24 hex>`, never repeating). An upstream | ||
| that derives the id from the call's **position in its response** instead mints the same `call-0-0` | ||
| on every turn of a conversation (measured: `deepseek-ai/deepseek-v4.1-flash` behind the same | ||
| gateway — three sequential turns, `call-0-0` every time; non-streaming mints `call-<toolIdx>`). | ||
| A Messages client has already paired that id with an earlier call, drops the duplicate, and is left | ||
| with a tool call carrying no result: the turn folds to an assistant message with no content, the | ||
| model re-issues the same call, and the conversation loops without ever erroring. The client's own | ||
| debug log confirms it sees no duplicate — `tool_uses=[call-0-0]` / `tool_results=[call-0-0]` with | ||
| zero `api_retry` — so the loop is not a retry storm but a silently-dropped pairing. | ||
|
|
||
| Reproduced with real Claude Code against a mock upstream that always mints `call-0-0`: | ||
| **unpatched, 2186 stream events, 1 unique id, exit 124 (loop); patched, 3 unique ids | ||
| (`call-0-0`, `call-0-0-2`, `call-0-0-3`), `subtype: success`, exit 0.** | ||
|
|
||
| Change: | ||
|
|
||
| - New leaf `src/adapters/openai-chat/tool-call-id-remint.ts`: `createToolCallIdReminter(reserved)` and | ||
| `reservedToolCallIdsFromHistory(messages)`. First occurrence of an id is emitted byte-identical — | ||
| prompt-cache keys, reasoning-replay lookups and already-unique upstreams are untouched; only a | ||
| **repeat** is rewritten, to the smallest unused suffix that fits the 64-char Anthropic id bound. | ||
| The suffix is `-<n>`, never `_<n>`: an id extending another as `<earlier>_<digits>` is read by the | ||
| client as batch sub-call N of `<earlier>`, which pairs the second call's result to the first. That | ||
| shape was measured separately: with an `_<n>` remint the same harness accumulated 10 placeholder | ||
| results; `-<n>` produced none. | ||
| - New wrapper `src/adapters/unique-tool-call-ids.ts`: remints `tool_call_start` on both the | ||
| streaming and buffered paths, seeded from `parsed.context.messages` in `buildRequest` — the only | ||
| point that sees the caller's history, which is the authority on which ids are taken because it is | ||
| the side that discards duplicates. Emission-only: ingest-time rewriting would strip a pending | ||
| streamed call of the identity its own delta fragments match against. | ||
| - `src/adapters/registry.ts`: the `openai-chat` factory now wraps in `withUniqueToolCallIds`, the | ||
| same shape as the existing `withClinePassDeepSeekV4ToolReplayCompatibility` wrapper. | ||
| - `src/adapters/openai-chat.ts` is **untouched**: it sits at its 822-line ratchet cap with zero | ||
| headroom, and the remedy is a sibling file, not a raised number (`AGENTS.md:255-271`). | ||
|
|
||
| Tests (new `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`, registered in both | ||
| `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`): a positional | ||
| upstream driven through three real turns emits three distinct ids; a first turn with no history | ||
| emits the upstream id byte-identical; the suffix shape is asserted directly; repeated occurrences | ||
| within one response stay distinct; a reserved suffix is skipped; every rewrite stays conforming and | ||
| within the length bound; a non-conforming id is sanitized rather than dropped; the history scan | ||
| reads both the assistant call and the tool result. Verified to fail against a pass-through wrapper | ||
| (`["call-0-0","call-0-0","call-0-0"]`) and pass with the fix. | ||
|
|
||
| Docs: `structure/providers-and-adapters.md` gains the `src/adapters/unique-tool-call-ids.ts` row and | ||
| the `tool-call-id-remint.ts` leaf. `structure/providers/chat-compat.md` would have been the natural | ||
| home but sits at exactly its 600-line budget. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { isConformingToolCallId, MAX_TOOL_CALL_ID_LENGTH } from "../tool-call-id"; | ||
| import type { OcxMessage } from "../../types"; | ||
|
|
||
| /** | ||
| * Remint a tool-call id the client's history already carries. | ||
| * | ||
| * Some upstreams mint tool-call ids deterministically per RESPONSE rather than per call: the same | ||
| * `call-0-0` on every turn of a conversation. Anthropic's wire requires `tool_use.id` to identify | ||
| * one call, and a client that has already stored that id for an earlier call cannot pair the new | ||
| * result to the new call — it drops one of them, the turn becomes an assistant message whose | ||
| * tool call has no result, and the model re-issues the same call forever. | ||
| * | ||
| * The first occurrence of an id is emitted byte-identical, so prompt-cache keys, reasoning-replay | ||
| * lookups, and every upstream that already mints unique ids stay untouched. Only a repeat — against | ||
| * the history the caller seeded, or against a call already emitted in this response — is rewritten, | ||
| * to the smallest unused suffix that still fits Anthropic's id bound. | ||
| */ | ||
| export function createToolCallIdReminter(reservedIds: Iterable<string>): (rawId: string) => string { | ||
| const occupied = new Set(reservedIds); | ||
| return rawId => { | ||
| if (!occupied.has(rawId)) { | ||
| occupied.add(rawId); | ||
| return rawId; | ||
| } | ||
| // A non-conforming source is sanitized, never dropped: the wire still needs an id, and the | ||
| // occupied check below covers a sanitized form that now equals some other call's id. | ||
| const base = isConformingToolCallId(rawId) ? rawId : rawId.replace(/[^a-zA-Z0-9_-]/g, "_"); | ||
| for (let n = 2; ; n++) { | ||
| // Hyphen, not underscore: an id that extends another id as `<earlier>_<digits>` is parsed by | ||
| // at least one client as a batch sub-call of `<earlier>`, which pairs the second call's | ||
| // result to the first call. A `-<n>` suffix is in the same id family without that reading. | ||
| const suffix = `-${n}`; | ||
| const candidate = base.slice(0, Math.max(1, MAX_TOOL_CALL_ID_LENGTH - suffix.length)) + suffix; | ||
| if (!occupied.has(candidate)) { | ||
| occupied.add(candidate); | ||
| return candidate; | ||
| } | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Tool-call ids the client's own history has already fixed: every assistant tool call it kept, plus | ||
| * every tool result that answered one. A response repeating any of them is the collision this | ||
| * module exists for. | ||
| * | ||
| * Read from the client's history rather than from earlier responses because the client is the | ||
| * authority on uniqueness here: it is the side that drops duplicates, so the proxy cannot observe | ||
| * the ids it discarded — a dropped id only ever exists as the absence it caused. A Messages client | ||
| * sends the whole conversation on every turn, which is why one turn's history is a complete picture | ||
| * of the ids that may not be reused. | ||
| */ | ||
| export function reservedToolCallIdsFromHistory(messages: readonly OcxMessage[]): Set<string> { | ||
| const ids = new Set<string>(); | ||
| for (const message of messages) { | ||
| if (message.role === "assistant") { | ||
| for (const part of message.content) { | ||
| if (part.type === "toolCall") ids.add(part.id); | ||
| } | ||
| continue; | ||
| } | ||
| if (message.role === "toolResult") ids.add(message.toolCallId); | ||
| } | ||
| return ids; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import type { ProviderAdapter } from "./base"; | ||
| import type { AdapterEvent, OcxMessage } from "../types"; | ||
| import { createToolCallIdReminter, reservedToolCallIdsFromHistory } from "./openai-chat/tool-call-id-remint"; | ||
|
|
||
| /** | ||
| * Give every tool call in a conversation a wire id the client has not already stored. | ||
| * | ||
| * The openai-chat adapter forwards `tool_calls[].id` upstream-verbatim, which is correct for an | ||
| * upstream that mints a fresh random id per call. An upstream that derives the id from the call's | ||
| * position in its response mints the same `call-0-0` on every turn. The client has already paired | ||
| * that id with an earlier call, so it drops the duplicate; the dropped call has no result, the turn | ||
| * reads as an assistant message with no content, and the model re-issues the same call forever. | ||
| * | ||
| * Applied as a wrapper rather than inside the adapter so the adapter's own id handling stays | ||
| * untouched, matching how this repository already scopes a wire-compatibility policy | ||
| * (`withClinePassDeepSeekV4ToolReplayCompatibility`). | ||
| * | ||
| * Reminting happens at emission — on the events the adapter yields — never at ingestion: ingestion | ||
| * matches streamed deltas and continuation fragments against the id the upstream sent, so rewriting | ||
| * there would strip a pending call of its own identity mid-stream. The ids to avoid come from the | ||
| * caller's own history, captured where the request is built because that is the only point that sees | ||
| * the inbound conversation; the client is the authority on which ids are taken, since it is the side | ||
| * that discards the duplicates the proxy would otherwise never observe. | ||
| * | ||
| * The first occurrence of an id is emitted byte-identical, so prompt-cache keys, reasoning-replay | ||
| * lookups, and upstreams that already mint unique ids are unaffected. | ||
| */ | ||
| export function withUniqueToolCallIds(adapter: ProviderAdapter): ProviderAdapter { | ||
| // Set where the request is built and consumed by the two emission paths below — the same | ||
| // build-into-parse handoff the openai-chat adapter already uses for its requested model id. The | ||
| // identity default means a parse that runs without a build cannot fail: it has no history to | ||
| // collide with. | ||
| let remintToolCallId: (rawId: string) => string = id => id; | ||
|
|
||
| const remintEvents = (events: AdapterEvent[]): AdapterEvent[] => | ||
| events.map(event => event.type === "tool_call_start" ? { ...event, id: remintToolCallId(event.id) } : event); | ||
|
|
||
| return { | ||
| ...adapter, | ||
|
|
||
| async buildRequest(parsed, incoming) { | ||
| const history: OcxMessage[] | undefined = parsed.context?.messages; | ||
| remintToolCallId = createToolCallIdReminter( | ||
| Array.isArray(history) ? reservedToolCallIdsFromHistory(history) : [], | ||
| ); | ||
| return adapter.buildRequest(parsed, incoming); | ||
| }, | ||
|
|
||
| async *parseStream(response, budget, tierMetadata): AsyncGenerator<AdapterEvent> { | ||
| for await (const event of adapter.parseStream(response, budget, tierMetadata)) { | ||
| yield event.type === "tool_call_start" ? { ...event, id: remintToolCallId(event.id) } : event; | ||
| } | ||
| }, | ||
|
|
||
| ...(adapter.parseResponse | ||
| ? { | ||
| async parseResponse(response, budget, tierMetadata) { | ||
| return remintEvents(await adapter.parseResponse!(response, budget, tierMetadata)); | ||
| }, | ||
| } | ||
| : {}), | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 11391
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 22545
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 22455
Cancel a replacement returned after downstream cancellation.
pullcan remain pending whilerefetchAfterProtocolSafeResetawaitsdoFetch. If a direct caller cancels without abortingopts.abortSignal,cancelonly cancels the original reader. The wrapper can then install the replacement reader on a cancelled stream without cancelling the replacement body.The native chat caller aborts
upstream, so this leak is not reachable through that current production path. Keep the wrapper safe for other direct callers.🔧 Suggested fix
🤖 Prompt for AI Agents