Skip to content

fix: message.part.delta streaming, .info shape for tokens/finish, parts fallback for empty content#59

Merged
KochC merged 1 commit into
devfrom
fix/text-part-fallback
Jul 5, 2026
Merged

fix: message.part.delta streaming, .info shape for tokens/finish, parts fallback for empty content#59
KochC merged 1 commit into
devfrom
fix/text-part-fallback

Conversation

@KochC

@KochC KochC commented Jul 5, 2026

Copy link
Copy Markdown
Owner

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

  1. Real incremental streaming text arrives via message.part.delta - a completely separate event type with flat properties (sessionID, partID, field, delta) - not via event.properties.delta on message.part.updated, which is all the code 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 token usage silently always fell back to all-zero, and finish was always undefined, for every single tool-calling request - not just the scenario below.
  3. For some turns, OpenCode never emits message.part.delta at all for the final reply - only message.part.updated snapshots 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

curl http://127.0.0.1:4010/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "github-copilot/claude-sonnet-5",
    "messages": [
      {"role": "user", "content": "Search the web for one recent headline about agentic AI, then give me just the headline."},
      {"role": "assistant", "content": null, "tool_calls": [{"id":"call_1","type":"function","function":{"name":"search","arguments":"{\"query\":\"agentic AI news\"}"}}]},
      {"role": "tool", "tool_call_id": "call_1", "content": "Title: \"Agentic AI startups raise record funding in 2026\" - TechCrunch."}
    ],
    "tools": [
      {"type":"function","function":{"name":"search","description":"Search the web","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}},
      {"type":"function","function":{"name":"format_final_json_response","description":"Final answer","parameters":{"type":"object","properties":{"output":{"type":"string"}}}}}
    ]
  }'

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 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, reusing the same helper the non-tool-calling path already relies on (fixes empty content on multi-step turns)

Test changes

  • 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 they're now 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 end-to-end (see repro above) - confirmed both the content and token-usage fixes work correctly together

Related

…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.
@KochC KochC merged commit 1ce569b into dev Jul 5, 2026
3 checks passed
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