Skip to content

fix(responses): carry server-tool work through the image loop - #159

Merged
orangeboyChen merged 2 commits into
mainfrom
fix/image-generation-loop-response-shape
Sep 17, 2026
Merged

orangeboyChen merged 2 commits into
mainfrom
fix/image-generation-loop-response-shape

Conversation

@orangeboyChen

Copy link
Copy Markdown
Owner

Fixes four logic bugs in the image_generation_call path 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)

getServerToolExecutions reads a WeakMap keyed on Response identity (web-search-loop.ts:785), but the image loop rebuilds the response before the caller reads it. So getServerToolExecutions(imageResponse) at responses.ts always 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

rebuildResponse copied response.headers verbatim while swapping in a re-serialized body of a different length. Measured: content-length: 51 on a 217-byte body, plus content-encoding: gzip on plaintext. The former truncates, the latter makes the client try to decompress plaintext.

Fix: drop content-length, content-encoding, and transfer-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_call and a function_call for the same work — and the closing hop's prose was appended twice ("prose 3\n\nprose 3").

That dangling tool_call also persisted into the stored transcript with no matching tool message, which most upstreams reject on the next turn.

Fix: clearClosingHop empties 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 to output_text.delta and 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.addedoutput_text.deltaoutput_text.doneoutput_item.done). No content_part events, matching the live path.

Also removes the no-op prepared.defaults.tools = prepared.defaults.tools.

Verification

  • 730 → 738 tests; full suite green
  • tsc --noEmit and eslint --max-warnings=0 clean
  • Each new test confirmed failing on the pre-fix code (stash the two source files → 5 failures, restore → all pass)

Not changed

Two findings from the earlier review I deliberately left alone:

  • revised_prompt carries the original prompt rather than a model-revised one. The value is still useful and renaming it would break clients for no gain.
  • A URL-only result reports completed with result: null, so the image is unreachable. Fixing it needs a non-standard field on image_generation_call, and existing tests pin the current shape — better as its own change.

Both are worth tracking separately if you want them.

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
orangeboyChen enabled auto-merge (squash) September 17, 2026 11:58

@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: 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".

Comment thread lib/server/proxy/image-generation.ts Outdated
Comment thread lib/server/proxy/responses.ts Outdated
@orangeboyChen
orangeboyChen enabled auto-merge (squash) September 17, 2026 12:01
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.07843% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.57%. Comparing base (ce18a85) to head (21d2b8f).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

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     
Flag Coverage Δ
unittests 95.57% <96.07%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
@orangeboyChen
orangeboyChen merged commit a1856ab into main Sep 17, 2026
7 checks passed
@orangeboyChen
orangeboyChen deleted the fix/image-generation-loop-response-shape branch September 17, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant