fix(agent-runtime): ignore a stream delta that omits its string field - #888
Merged
Merged
Conversation
A gateway that re-serializes provider events with `omitempty` (Go) or drops `undefined` (JS) sends a text, thinking, or tool-call delta without its string field. The assistant message frame encoder read `.length` off that missing value and threw a bare TypeError, which the runtime classified as a retriable `PROVIDER_ERROR` — no HTTP status and no network cause — so it consumed the shared ten-attempt budget and then showed the V8 text to the user. The trigger is a property of one relay plus one conversation, so the turn failed identically every time, a fork inherited it, and a new session was clean (issue #883). The encoder now reads a non-string payload as the empty string, the only value a dropped field can have been. The two stream decoders that leaked the missing value — `anthropic-messages` (text, thinking, input_json, signature) and `openai-responses-shared` (text, thinking, reasoning summary, tool arguments) — stop passing it through, so a dropped field can no longer corrupt accumulated text into the literal "undefined". `openai-completions` already guarded the same field. The regression test drives the real decoders through a stubbed transport and feeds every event through the real encoder; on the unpatched dependency it fails with the reported TypeError. fixes #883
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.
Summary
A gateway that re-serializes provider events with
omitempty(Go) or dropsundefined(JS) sends a text / thinking / tool-call delta without its stringfield.
AssistantMessageFrameEncoderread.lengthoff that missing valueand threw a bare
TypeError: Cannot read properties of undefined (reading 'length'). The runtime had no HTTP status and no network cause to classify, soit fell through to a retriable
PROVIDER_ERROR: the turn burned the sharedten-attempt budget and then showed the V8 text to the user, and the UI kept
offering "continue" for something that could never succeed.
Because the trigger is a property of one relay plus one conversation, the turn
failed identically every time, a fork of the session inherited it, and a new
session was clean — exactly the shape reported in #883.
Fix
which is the only value a dropped field can have been. This covers the
text_start/thinking_startcoverage counters,toolcall_delta,encodeTextDelta, and the twoclone*Contenthelpers. The degradation islossless and the frame protocol is unchanged.
anthropic-messages(text, thinking,input_json, signature) and
openai-responses-shared(text, thinking,reasoning summary, tool arguments) stop passing a missing field through.
Without this,
block.text += undefinedcorrupts accumulated text into theliteral
"undefined"— a wrong answer instead of an error.openai-completionsalready guarded the same field; verified, not changed.Both live in
patches/@earendil-works__pi-ai@0.86.1.patch, regenerated throughpnpm patch/pnpm patch-commit. Upstream 0.87.0 still has the bare.length, so the guards have to be carried forward on the next bump.Correction to the report
The issue also claims the retry counter accumulates across turns and is only
cleared by a clean turn. That is not the mechanism:
resetRunRecoveryState()(
runtime.ts:7790→5602) resets it at the start of every user turn, so arepeated
retryAttempt: 10means each turn exhausted its own budget. The turnfails every time because the trigger is deterministic, not because the budget
stays spent.
The report also says an OpenAI Chat Completions style relay reproduced it. It
does not: every omitted/empty shape is guarded on that path. The OpenAI-family
decoder that does share the defect is
openai-responses, which this PR fixes.Verification
packages/agent-runtime/src/provider-stream-frame-fields.test.ts(new):drives the real decoders through a stubbed transport and feeds every event
through the real encoder. On the unpatched dependency 4 of 6 cases fail
with the reported
TypeError; both control cases pass. After the patch: 6/6.pi-ai@0.86.1tarball and reproduces the installed package byte-for-byte, sothe pre-existing hosted-search hunks are intact.
pnpm build:js— pass (all packages, includestsctypecheck).pnpm -r --if-present test— pass: agent-runtime 1014, desktop 2659,shared 986, plugin-sdk 328, plugin-devkit 48, agent-host 47, host-runtime 46,
i18n 25, racp 21, pi-host 6.
pnpm lint— pass.node scripts/e2e-hosted-search.mjs— PASS 7/7 (search-next-prompt,search-read,search-read-instruction-change,search-task-delegation,search-persist-restore,invalid-search-container,invalid-search-phase)against the rebuilt production sidecar, whose source/patch/lock fingerprint
stayed stable across the run. This is the relevant E2E suite: hosted-search
frames are the frames this encoder produces.
pnpm check:pr-base— pass.Impact
format changes; the frame types keep their strings.
providers. A relay that drops empty string fields now streams a no-op delta
instead of failing the turn.
pnpm installfor the patch to apply.openai-responses-sharedreadsevent.arguments.startsWith(...)onresponse.function_call_arguments.done;a relay that drops an empty
argumentswould throwreading 'startsWith'there. Same upstream behavior, different symptom, notin the report — left out to keep this diff to the reported defect.
Refs #883