fix: message.part.delta streaming, .info shape for tokens/finish, parts fallback for empty content#59
Merged
Merged
Conversation
…nish, parts fallback for content
Found three compounding bugs in runAgentTurn() while testing real
multi-turn tool-calling conversations (continuing after a prior tool
call/result in history) against a live OpenCode server - not caught by
existing unit tests since their mocks matched the same wrong
assumptions as the code they were testing:
1. Real incremental streaming text arrives via a message.part.delta
event - a completely separate event type with flat properties
(sessionID, partID, field, delta) - not via event.properties.delta
on message.part.updated, which the code exclusively listened to.
message.part.updated only ever carries status/snapshot updates
(confirmed still correct and unchanged for tool-call detection).
2. client.session.messages() returns items shaped like
{ info: Message, parts: Part[] } - matching what
client.session.prompt() (the non-tool-calling path) already
returns and what extractAssistantText() already expects - not a
flat { role, tokens, finish, ... } shape. Reading assistantMsg.role/
.tokens/.finish directly meant the role filter never matched
anything, so tokens silently always fell back to all-zero usage and
finish was always undefined for every single tool-calling request,
not just the scenario below.
3. For some turns - observed specifically when continuing a
conversation with prior tool calls/results in history, i.e. real
multi-step agentic tool use, not simple one-shot prompts - OpenCode
never emits message.part.delta at all for the final reply, only
message.part.updated snapshots on the same part id. Content stayed
permanently empty in exactly this case: not a hypothetical edge
case, but the core "read a tool result and continue" pattern any
real tool-using agent depends on.
Fix:
- runAgentTurn() now handles message.part.delta for real-time
onChunk/content accumulation (fixes streaming + the common case).
- Reads tokens/finish from assistantEntry.info instead of the item
directly (fixes usage always being zero and finish always being
undefined on natural, non-tool-call completions).
- Falls back to extractAssistantText(assistantEntry.parts) for the
final content when the live stream produced nothing, using the same
helper the non-tool-calling path already relies on (fixes empty
content on multi-step turns).
index.test.js:
- Fixed createToolCallClient/createStreamingClient's session.messages
mock to the real { info, parts } shape (previously flat, matching
the bug rather than the real API - all 9 dependent tests still pass
since none asserted specific token values, but now they're actually
exercising correct behavior).
- Converted all message.part.updated+delta text-streaming events in
existing tests to message.part.delta, matching the corrected event
handling.
- Two new tests: accumulating content from message.part.delta events,
and falling back to the message's parts (plus reading real
tokens/finish from .info) when message.part.delta never fires.
Testing:
- npm test - 143 passed (141 + 2 new)
- npm run lint - clean
- Manually verified against a live OpenCode server: the exact
multi-turn scenario (search tool call -> tool result in history ->
continue) that previously returned empty content and all-zero usage
now correctly returns the real answer and real token counts.
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
Found and fixed three compounding bugs in
runAgentTurn()while testing real multi-turn tool-calling conversations (continuing after a prior tool call/result in history) against a live OpenCode server. None of these were caught by the existing unit tests, since their mocks happened to match the same wrong assumptions as the code they were testing.The bugs
message.part.delta- a completely separate event type with flat properties (sessionID,partID,field,delta) - not viaevent.properties.deltaonmessage.part.updated, which is all the code listened to.message.part.updatedonly ever carries status/snapshot updates (confirmed still correct and unchanged for tool-call detection).client.session.messages()returns items shaped like{ info: Message, parts: Part[] }- matching whatclient.session.prompt()(the non-tool-calling path) already returns, and whatextractAssistantText()already expects - not a flat{ role, tokens, finish, ... }shape. ReadingassistantMsg.role/.tokens/.finishdirectly meant the role filter never matched anything, so token usage silently always fell back to all-zero, andfinishwas alwaysundefined, for every single tool-calling request - not just the scenario below.message.part.deltaat all for the final reply - onlymessage.part.updatedsnapshots on the same part id. Observed specifically when continuing a conversation with prior tool calls/results in history - i.e. real multi-step agentic tool use, not simple one-shot prompts. Content stayed permanently empty in exactly this case: not a hypothetical edge case, but the core "read a tool result and continue" pattern any real tool-using agent depends on.Repro
Before:
{"finish_reason":"stop","message":{"role":"assistant","content":""}},"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}(always zero)After:
{"finish_reason":"stop","message":{"role":"assistant","content":"\"Agentic AI startups raise record funding in 2026\""}},"usage":{"prompt_tokens":2,"completion_tokens":21,"total_tokens":23}The fix
runAgentTurn()now handlesmessage.part.deltafor real-timeonChunk/content accumulation (fixes streaming + the common case)tokens/finishfromassistantEntry.infoinstead of the item directly (fixes usage always being zero andfinishalways beingundefinedon natural, non-tool-call completions)extractAssistantText(assistantEntry.parts)for the final content when the live stream produced nothing, reusing the same helper the non-tool-calling path already relies on (fixes empty content on multi-step turns)Test changes
createToolCallClient/createStreamingClient'ssession.messagesmock to the real{ info, parts }shape (previously flat, matching the bug rather than the real API - all 9 dependent tests still pass since none asserted specific token values, but they're now actually exercising correct behavior)message.part.updated+deltatext-streaming events in existing tests tomessage.part.delta, matching the corrected event handlingmessage.part.deltaevents, and falling back to the message's parts (plus reading real tokens/finish from.info) whenmessage.part.deltanever firesTesting
npm test- 143 passed (141 + 2 new)npm run lint- cleanRelated