fix(responses): carry server-tool work through the image loop - #159
Merged
Merged
Conversation
The image-generation loop rebuilds every response it hands back, but three things were keyed to the response rather than carried alongside it, so the rebuild silently dropped them. Server-tool executions are stored in a WeakMap keyed on Response identity (web-search-loop.ts). The loop rebuilt the response before the caller read them, so `getServerToolExecutions` always returned []: a turn that searched and drew a picture reported the image and dropped the search. They now travel on the loop result. The rebuild also copied the upstream's framing headers while swapping in a different body. Folding earlier hops' prose changes the length, so a copied `content-length` truncates it, and a copied `content-encoding: gzip` tells the client to decompress plaintext. Both are now dropped, along with `transfer-encoding`. When the iteration cap ends the loop, the closing hop's calls have already been executed and reported as `image_generation_call` items. Its payload still carried them, so the client also got a `function_call` for work already done, and its prose was appended twice. The closing hop is now cleared before folding. Finally, the buffered streaming path replayed text only in `response.completed`, with no `output_text.delta`. A client that renders as it reads subscribes to deltas and saw nothing until the turn ended. This affected any request declaring the tool, not just ones that used it, because buffering is decided by the declaration. The replay now emits the same message-item sequence the live path does. Also removes a self-assignment no-op on prepared.defaults.tools.
orangeboyChen
enabled auto-merge (squash)
September 17, 2026 11:58
orangeboyChen
disabled auto-merge
September 17, 2026 11:58
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 838218e150
ℹ️ 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".
orangeboyChen
enabled auto-merge (squash)
September 17, 2026 12:01
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #159 +/- ##
==========================================
+ Coverage 95.48% 95.57% +0.08%
==========================================
Files 37 37
Lines 6650 6738 +88
Branches 1912 1944 +32
==========================================
+ Hits 6350 6440 +90
+ Misses 300 298 -2
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
Addresses two review comments on #159. Clearing the capped hop removed every tool_call, not just the image calls the loop had executed. A hop can also carry a client-declared function, which is still the client's to resolve — so a turn that mixed an image call with a client function silently lost the client call entirely. Only calls matching `isImageGenerationToolCall` are removed now. The buffered replay also emitted the generic added/done pair for server-tool items, while the live path narrates a web search with `web_search_call.in_progress`, `.searching`, and `.completed`. A consumer watching for those never saw them on the buffered path. The replay now emits the same sequence, announcing the item as in-progress first. Also brings changed-branch coverage to 100% (25/25), up from 57.89%. The coverage gate failed the build even though every test, the typecheck, and lint passed. Most of the gap was defensive branches that are hard to reach through the loop, so `clearClosingHop` is exported and unit-tested directly; the remaining two are the no-message replay path.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Fixes four logic bugs in the
image_generation_callpath added by #156. All were independently reproduced with probe tests before fixing, and each fix has a regression test that I confirmed fails without the change.1. Server-tool executions were dropped (the search vanished)
getServerToolExecutionsreads a WeakMap keyed onResponseidentity (web-search-loop.ts:785), but the image loop rebuilds the response before the caller reads it. SogetServerToolExecutions(imageResponse)atresponses.tsalways returned[].A turn that searched and drew a picture reported the image and silently dropped the
web_search_call. The search ran and was billed; the client was never told. Measured 1 execution in → 0 out.Fix: executions are read off the upstream response inside the loop and carried on
ImageGenerationLoopResult.serverToolExecutions.2. Rebuilt responses kept the upstream's framing headers
rebuildResponsecopiedresponse.headersverbatim while swapping in a re-serialized body of a different length. Measured:content-length: 51on a 217-byte body, pluscontent-encoding: gzipon plaintext. The former truncates, the latter makes the client try to decompress plaintext.Fix: drop
content-length,content-encoding, andtransfer-encoding.3. Cap-exit reported a dangling call and repeated the prose
When the 3-iteration cap ends the loop, the closing hop's calls have already been executed. The payload still carried them, so the client got both an
image_generation_calland afunction_callfor the same work — and the closing hop's prose was appended twice ("prose 3\n\nprose 3").That dangling
tool_callalso persisted into the stored transcript with no matching tool message, which most upstreams reject on the next turn.Fix:
clearClosingHopempties the closing hop before folding.4. Buffered streaming emitted no text deltas
The replay shipped text only inside
response.completed. A client that renders as it reads subscribes tooutput_text.deltaand saw nothing until the turn ended.This was broader than it looks: buffering is decided by the tool declaration, not by whether the model used it. Verified — a plain "tell me about cats" with the tool merely declared also lost its deltas.
Fix: the replay emits the message-item sequence the live path does (
output_item.added→output_text.delta→output_text.done→output_item.done). Nocontent_partevents, matching the live path.Also removes the no-op
prepared.defaults.tools = prepared.defaults.tools.Verification
tsc --noEmitandeslint --max-warnings=0cleanNot changed
Two findings from the earlier review I deliberately left alone:
revised_promptcarries the original prompt rather than a model-revised one. The value is still useful and renaming it would break clients for no gain.completedwithresult: null, so the image is unreachable. Fixing it needs a non-standard field onimage_generation_call, and existing tests pin the current shape — better as its own change.Both are worth tracking separately if you want them.