fix: tolerate Anthropic-style streams that omit text payloads - #2925
fix: tolerate Anthropic-style streams that omit text payloads#2925Yorha9e wants to merge 2 commits into
Conversation
- Anthropic stream parsers (kosong + agent-core-v2) coerce missing text on content_block_start / text_delta to empty strings - TUI handleAssistantDelta coerces a missing delta payload instead of crashing on .trim() of undefined - Add malformed-stream and missing-delta regression tests
🦋 Changeset detectedLatest commit: a9823cf The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 081439f997
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Malformed/provider-quirk wire records can arrive without a delta payload | ||
| // (e.g. an Anthropic content_block_start whose text block omits `text` — | ||
| // JSON serialization then drops the key). Coerce instead of crashing. | ||
| const delta = typeof event.delta === 'string' ? event.delta : ''; |
There was a problem hiding this comment.
Normalize child assistant deltas too
When a malformed assistant.delta belongs to a child agent (agentId !== 'main'), handleEvent returns from routeChildAgentEvent before reaching this guard, and the child-agent paths still pass event.delta through directly (the AgentSwarm progress path reads input.delta.length). That means the same missing-delta wire record this fallback is meant to tolerate can still crash or corrupt the TUI during subagent output from a replayed/older malformed event; normalize the delta before child routing or apply the same coercion in the subagent handlers.
Useful? React with 👍 / 👎.
- kosong + agent-core-v2 generate(): filter empty text parts when the message has tool calls or any non-empty content, so a tool-use round never replays an empty text block to strict Anthropic endpoints; all-empty responses are kept intact for downstream settleStep removal - agent-core-v2 loopService: coerce missing text/think payloads at the delta publish choke point - agent-core-v2 anthropic: remove dead ?? '' under a non-null guard - TUI: typeof-coerce thinking.delta and hook.result content; guard formatHookResultBody so all callers survive a missing content field - tests: pin the new filtering behavior, restore exact assertions for provider-level coercion, cover malformed thinking/hook payloads
Related Issue
Resolve #2924
Problem
See linked issue. In short: when an Anthropic-compatible endpoint (typically a relay/proxy) streams
content_block_start/text_deltaevents that omit thetextfield, the engine produces text parts withtext: undefined; JSON serialization then drops thedeltakey from theassistant.deltaevent, and the TUI crashes onevent.delta.trim().What changed
packages/kosongandpackages/agent-core-v2Anthropic bases): coerce missingtextoncontent_block_start,text_delta, and the non-stream text block to''— the same pattern already used forthinking ?? ''right below.handleAssistantDelta): coerce a non-stringevent.deltato''before use, so a malformed wire record can never crash the handler regardless of engine version.assistant.deltaevent (TUI handler).The empty-string coercion preserves existing behavior for compliant providers (Anthropic already emits
text: ''on block start), and matches how thinking deltas are handled today.Round 2: assembly-layer filtering + boundary hardening
Review surfaced one real follow-up risk and a few defense gaps; this round addresses them:
kosong/src/generate.ts,agent-core-v2/src/kosong/contract/generate.ts): aftermergeInPlace, droptext === ''parts only when the message has tool calls or any non-empty content. A part that stays empty after merging means the provider sent a bare vacuous block; replaying it on the next tool-use round makes strict Anthropic endpoints answer400: text content blocks must be non-empty. All-empty responses are deliberately kept intact so the existingsettleStepremoval path handles them unchanged (filtering them tocontent: []would instead trip the retryableAPIEmptyResponseError).agent-core-v2/src/agent/loop/loopService.ts):part.text ?? ''/part.think ?? ''where delta events are published, protecting every event consumer independent of provider.agent-core-v2anthropic base): remove?? ''under an existing!== undefinedguard.typeofcoercion forthinking.deltaandhook.resultcontent;formatHookResultBodynow tolerates a missingcontentfield, which also covers the subagent/btw panel renderers that share it.thinking.delta/hook.resultpayloads. One pre-existing regression pin (standalone empty TextPart is kept in content) is intentionally inverted to match the new assembly contract.Deliberate non-goals
agent-swarm-progress.tsappendModelDelta) has the same class of exposure on a missingdelta, but it is a separate display path; left untouched to keep this PR focused. Happy to follow up if you want it covered here.Checklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.