Repository navigation
Conversation
Kiro/AWS CodeWhisperer streams `toolUseEvent.input` as PARTIAL OBJECTS
that grow over successive events (e.g. `{command:"cat /home"}` then
`{command:"cat /home/wxsys"}` then `{command:"cat /home/wxsys/Project"}`).
The executor was calling `JSON.stringify(toolInput)` per event and
emitting the full string as an OpenAI `tool_calls[].function.arguments`
delta. The OpenAI streaming protocol requires arguments deltas to be
**concatenable string fragments** that join into one valid JSON. Emitting
full re-stringified prefixes per chunk produced overlapping garbage:
{"command":"cat /home"}{"command":"cat /home/wxsys"}{"command":"cat /home/wxsys/Project"}
Downstream consumers (opencode, claude-code, etc.) then hit
`Unterminated string` JSON parse errors with a non-deterministic
truncation point matching the prefix length of each successive partial
serialization — variable cutoffs like `{"comm`, `{"comman`, `{"command":`
that look like network truncation but are deterministic protocol
violations.
This pattern is observable in real use: any time Kiro sends a tool with
arguments that grow through multiple events, the final concatenated
arguments string is unparseable. The bug surfaced as random "Unterminated
string" failures on tools with longer string parameters (file paths,
shell commands, multi-line content), with the apparent failure size
varying because Kiro's chunk boundaries vary.
## Fix
- String-form `input` payloads are already concatenable deltas; emit
them incrementally as before, tracking accumulated text per
`toolCallId` in `state.toolArgsEmitted`.
- Object-form `input` payloads are buffered into `state.toolArgsBuffered`
keyed by `toolCallId`. The latest canonical (full JSON of the most
recent partial object) overwrites earlier buffered values — earlier
partials are noise once a more complete one arrives.
- A new helper `flushBufferedToolArgs()` is invoked at all three finish
boundaries (`messageStopEvent`, the dual-event finish path, and the
TransformStream `flush()`) to emit each buffered canonical exactly
once as a complete arguments string.
The result: downstream consumers receive arguments deltas that
concatenate into valid JSON regardless of whether Kiro chose to send
string fragments or growing partial objects.
## Tests
- New regression test `tests/unit/kiro-tool-args-streaming.test.js`
with 7 cases including the exact reported reproduction (4-event
partial-object stream produces 1 valid flush) and the contract test
(re-stringifying partials is unparseable; emitting once is parseable).
- All 14 existing kiro tests (`openai-to-kiro.test.js`, `rtkKiro.test.js`)
continue to pass — no regression in the request-translation or RTK
paths.
- Verified end-to-end against a live patched build by reproducing the
original failure case (substantial `write` tool call) which now
succeeds.
bavuongco10
added a commit
to bavuongco10/9router
that referenced
this pull request
Jun 11, 2026
…arguments deltas
diegosouzapw
pushed a commit
to cheaper-inference/OmniRoute
that referenced
this pull request
Jun 26, 2026
Kiro/CodeWhisperer streams toolUseEvent.input as PARTIAL OBJECTS that grow over
time (e.g. {command:"cat /home"} then {command:"cat /home/wxsys"}). The executor
re-stringified each partial object and emitted it as an OpenAI argument delta, so
the overlapping JSON prefixes concatenated downstream into unparseable garbage
("Unterminated string").
Buffer object-form payloads in state.toolArgsBuffered keyed by toolCallId, keep
only the latest canonical, and flush once at finish boundaries (messageStopEvent
and the terminal flush). String-form payloads remain concatenable incremental
deltas. The flush is idempotent against state.toolArgsEmitted so the dual
boundaries never double-emit.
Co-authored-by: Wahyu Febri <wxsys@local>
Inspired-by: decolua/9router#1573
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
Fixes a streaming protocol bug in the Kiro executor that caused random
Unterminated stringJSON parse errors in downstream consumers (opencode, claude-code, etc.) for tool calls with substantial argument content.Root cause
open-sse/executors/kiro.jsreadstoolUseEvent.inputand emits itsJSON.stringifyas an OpenAItool_calls[].function.argumentsdelta on every event. Kiro/AWS CodeWhisperer streamsinputas partial objects that grow across events:Stringifying each one produces overlapping prefixes:
The OpenAI streaming protocol requires
argumentsdeltas to be concatenable string fragments that join into one valid JSON. Concatenating the three above produces:→
JSON.parsefails withUnexpected non-whitespace character after JSON at position NorUnterminated stringdepending on where the parser bails out.The truncation position is non-deterministic because it tracks the prefix length of whichever partial object Kiro happened to send. This makes the bug present as random "the network truncated my tool call" failures —
{"comm,{"comman,{"command":— when in fact the protocol contract was violated upstream.Reproduction
Any Kiro session that streams a tool whose arguments grow over multiple
toolUseEvents will hit this. Common triggers:contentparameters inwritetoolsI encountered it in opencode while writing a 1500-byte markdown file via the
writetool routed through 9router → Kiro → Claude Opus 4.7. Thewritecall failed withUnterminated stringat variable byte offsets across retries.Fix
String-form
inputpayloads are already concatenable deltas — emit them incrementally as before, tracking accumulated text pertoolCallIdinstate.toolArgsEmitted.Object-form
inputpayloads are buffered intostate.toolArgsBufferedkeyed bytoolCallId. The latest canonical (full JSON of the most recent partial object) overwrites earlier buffered values — earlier partials are noise once a more complete one arrives.New helper
flushBufferedToolArgs()is invoked at all three finish boundaries to emit each buffered canonical exactly once as a complete arguments string:messageStopEventhasMeteringEvent && hasContextUsage)flush()callbackThe result: downstream consumers receive arguments deltas that concatenate into valid JSON regardless of whether Kiro chose to send string fragments or growing partial objects.
Tests
New regression coverage (
tests/unit/kiro-tool-args-streaming.test.js, 7 tests):No regression: all 14 existing kiro tests (
openai-to-kiro.test.js,rtkKiro.test.js) continue to pass.End-to-end verified: built the patched CLI tarball locally, reinstalled
9router@0.4.66, restarted the proxy, and reproduced the original failure case (substantialwritetool call ~1500 bytes) — now succeeds. Multiple test writes confirmed.Notes
translator/response/kiro-to-openai.jshas the same shape of bug but is a separate (non-streaming-executor) code path. If this PR lands, that file deserves a follow-up; happy to include it here if preferred.stateinitialization adds two new Maps (toolArgsEmitted,toolArgsBuffered) — no change to existing field types.flushBufferedToolArgsisexported so the unit tests can target it directly without standing up a full TransformStream pipeline.