Skip to content

fix(agent-runtime): merge content blocks and support accumulate control#428

Merged
jerryliang64 merged 2 commits intomasterfrom
fix/content-accumulation-merge
Apr 1, 2026
Merged

fix(agent-runtime): merge content blocks and support accumulate control#428
jerryliang64 merged 2 commits intomasterfrom
fix/content-accumulation-merge

Conversation

@jerryliang64
Copy link
Copy Markdown
Contributor

@jerryliang64 jerryliang64 commented Apr 1, 2026

Summary

  • Add accumulate field to AgentStreamMessage — executors set accumulate: false on events like assistant.thinking.delta or tool.delta to forward them via SSE without polluting the final completed message content
  • Add MessageConverter.mergeContentBlocks() — post-processes accumulated content blocks: merges consecutive text fragments into one, backfills tool_use input from subsequent input_json_delta text blocks (with JSON.parse fallback to {})
  • Unify all three run pathsstreamRun (via consumeStreamMessages), syncRun and asyncRun (via extractFromStreamMessages) all apply the same accumulate filtering and merge logic

Before

{
  "content": [
    { "type": "text", "text": { "value": "用户" } },
    { "type": "text", "text": { "value": "要查询" } },
    { "type": "text", "text": { "value": "我来帮你查询。" } },
    { "type": "tool_use", "id": "fc-xxx", "name": "Bash", "input": {} },
    { "type": "text", "text": { "value": "{\"command\": \"curl -s" } },
    { "type": "text", "text": { "value": " --header ...\"}" } },
    { "type": "tool_result", "tool_use_id": "fc-xxx", "content": "..." }
  ]
}

After

{
  "content": [
    { "type": "text", "text": { "value": "我来帮你查询。" } },
    { "type": "tool_use", "id": "fc-xxx", "name": "Bash", "input": { "command": "curl -s --header ..." } },
    { "type": "tool_result", "tool_use_id": "fc-xxx", "content": "..." }
  ]
}

Test plan

  • All 130 existing + new tests pass (npm test --workspace=core/agent-runtime)
  • mergeContentBlocks unit tests: empty input, consecutive text merge, tool_use input backfill, JSON parse fallback, realistic mixed scenario, generic block passthrough
  • extractFromStreamMessages tests updated for merged output + new accumulate: false filtering test
  • streamRun integration tests: accumulate=false exclusion, text merge + tool_use backfill in completed event

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an optional accumulate flag on stream messages so fragments can opt out of being included in final assembled messages.
  • Bug Fixes

    • Merged adjacent text fragments into single consolidated message blocks.
    • Backfilled tool invocation inputs from subsequent message content when available.
    • Non-accumulated fragments are still forwarded as events but not stored in the assembled message.
  • Tests

    • Added/updated tests covering accumulation, text merging, and tool-input backfill.

…ol in stream messages

Content blocks accumulated during streaming were stored as raw fragments,
causing fragmented text, empty tool_use inputs, and mixed thinking content
in thread.message.completed and run output. This fix:

1. Adds `accumulate` field to AgentStreamMessage — executors can set
   `accumulate: false` to forward SSE events without polluting final content
2. Adds MessageConverter.mergeContentBlocks() — merges consecutive text
   blocks and backfills tool_use input from subsequent text (input_json_delta)
3. Applies merge logic to all three run paths (streamRun, syncRun, asyncRun)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cefdbc43-a8d5-4118-9344-a44a61968194

📥 Commits

Reviewing files that changed from the base of the PR and between 47210a2 and 3683839.

📒 Files selected for processing (1)
  • core/mcp-client/src/HeaderUtil.ts

📝 Walkthrough

Walkthrough

Stream message accumulation is now controllable via an added accumulate flag on AgentStreamMessage. The runtime filters which streamed chunks are appended to message content and uses a new MessageConverter.mergeContentBlocks to consolidate consecutive text blocks and backfill tool_use.input from subsequent JSON text deltas before emitting the completed message.

Changes

Cohort / File(s) Summary
Type Definitions
core/types/agent-runtime/AgentRuntime.ts
Added optional accumulate?: boolean to AgentStreamMessage to indicate whether a stream chunk should be accumulated into final message content (default true).
Stream Message Handling
core/agent-runtime/src/AgentRuntime.ts, core/agent-runtime/src/MessageConverter.ts
consumeStreamMessages now skips appending chunks with accumulate === false while still emitting their events. MessageConverter.extractFromStreamMessages accumulates eligible blocks and returns at most one MessageObject whose content is produced by new mergeContentBlocks. mergeContentBlocks merges adjacent text blocks and attempts to parse/merge JSON text fragments into preceding tool_use.input.
Tests
core/agent-runtime/test/AgentRuntime.test.ts, core/agent-runtime/test/MessageConverter.test.ts
Added tests for accumulate=false behavior, text fragment merging, tool_use input backfill from JSON text deltas, parse-failure fallback, and other merge/sequence edge cases.
HTTP Headers Utility
core/mcp-client/src/HeaderUtil.ts
Minor change to header merge: constructs new Headers(...) using a cast (headersInit as Record<string,string>) to satisfy typing when merging headers.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Runtime as AgentRuntime
    participant Converter as MessageConverter
    participant Writer as Writer/Emitter

    Client->>Runtime: send stream chunks (some with accumulate=false)
    loop per incoming chunk
      Runtime->>Writer: writeEvent(chunk.eventType, chunk)
      alt chunk.accumulate !== false
        Runtime->>Converter: collect content block(chunk)
      end
    end
    Runtime->>Converter: mergeContentBlocks(collectedBlocks)
    Converter-->>Runtime: merged content (text merged, tool_use.input backfilled)
    Runtime->>Writer: writeEvent("ThreadMessageCompleted", mergedMessage)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • killagu

Poem

🐰 I nibble streams and stitch them tight,
Fragments join into one bright light,
Tools now find their missing piece,
JSON crumbs make inputs neat,
Hoppity hops — the message’s complete! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: adding content block merging and an accumulate control feature to the agent-runtime.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/content-accumulation-merge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an accumulate flag to AgentStreamMessage to control content inclusion in final messages and adds a mergeContentBlocks utility to consolidate text fragments and backfill tool_use inputs. A review comment points out that JSON.parse could return non-object types, which would cause issues when spreading into the tool input object, and suggests adding a type check to handle this safely.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
core/agent-runtime/src/AgentRuntime.ts (1)

364-374: Inconsistent accumulate handling between custom and standard events.

Custom event messages (line 365) respect accumulate !== false, but standard messages without type (line 374) always accumulate unconditionally. This asymmetry may cause confusion—if an executor yields { message: {...}, accumulate: false } without a type, the content will still be accumulated.

Is this intentional? If standard messages should also respect the flag:

♻️ Proposed fix for consistent accumulate handling
       } else if (msg.message) {
         const contentBlocks = MessageConverter.toContentBlocks(msg.message);
-        content.push(...contentBlocks);
+        if (msg.accumulate !== false) {
+          content.push(...contentBlocks);
+        }

         // event: thread.message.delta
         const delta: MessageDeltaObject = {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/agent-runtime/src/AgentRuntime.ts` around lines 364 - 374, The handler
treats accumulate differently for typed vs standard messages: when msg.message
is present we always push contentBlocks but when msg.type is used we check
msg.accumulate !== false; make them consistent by honoring msg.accumulate for
the standard-path as well—before calling MessageConverter.toContentBlocks and
content.push(...) in the branch that handles msg.message, check if
msg.accumulate !== false (default true) and only push into content and include
content in the writer.writeEvent payload accordingly; update the use of
MessageConverter.toContentBlocks, content.push, and the writer.writeEvent call
to follow the same accumulate logic used for the custom-event branch
(referencing msg.accumulate, contentBlocks, MessageConverter.toContentBlocks,
writer.writeEvent, and msg.message).
core/agent-runtime/src/MessageConverter.ts (2)

166-169: Annotations are discarded when merging text blocks.

The merged text block is created with annotations: [], discarding any annotations from source blocks. Verify this is intentional for your use case.

💡 Alternative: Concatenate annotations from all source blocks
       } else if (isTextBlock(block)) {
         // Merge consecutive text blocks
         const parts: string[] = [ block.text.value ];
+        const allAnnotations: unknown[] = [...block.text.annotations];
         let next = blocks[i + 1];
         while (next && isTextBlock(next)) {
           i++;
           parts.push(next.text.value);
+          allAnnotations.push(...next.text.annotations);
           next = blocks[i + 1];
         }
         merged.push({
           type: ContentBlockType.Text,
-          text: { value: parts.join(''), annotations: [] },
+          text: { value: parts.join(''), annotations: allAnnotations },
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/agent-runtime/src/MessageConverter.ts` around lines 166 - 169, The merge
creates a new text ContentBlock with annotations: [] which discards source
annotations; update MessageConverter's merging logic (where merged.push({ type:
ContentBlockType.Text, text: { value: parts.join(''), annotations: [] } })) to
collect and concatenate annotations from the original text blocks (e.g., gather
annotations arrays from the source blocks you loop over, dedupe/normalize as
needed) and assign that combined annotations array instead of an empty array so
annotations are preserved on the merged block.

145-153: Silent fallback to empty object may hide parsing issues.

When JSON parsing fails (line 150-152), the code silently falls back to {}. This could mask malformed tool input deltas in production. Consider logging a warning or preserving the raw text for debugging purposes.

♻️ Suggested improvement: Add logging for parse failures

The method would need access to a logger, or this could be handled by the caller. Alternatively, consider returning a diagnostic marker:

          try {
            parsedInput = JSON.parse(raw);
          } catch {
+           // Consider logging: `Failed to parse tool_use input JSON: ${raw}`
            parsedInput = {};
          }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/agent-runtime/src/MessageConverter.ts` around lines 145 - 153, The
JSON.parse fallback is currently silent in MessageConverter (inside the block
handling inputFragments → parsedInput), which can hide malformed tool input;
modify the catch to capture the exception (e) and both log a warning (use the
class/logger available in MessageConverter or console.warn if none) and preserve
diagnostics by setting parsedInput to include the raw text and error (e.g. {
_raw: raw, _parseError: String(e) }) so merged.push receives traceable data on
parse failures; update the method signature to accept/propagate a logger if
needed and reference variables inputFragments, raw, parsedInput, block, and
merged when making changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@core/agent-runtime/src/AgentRuntime.ts`:
- Around line 364-374: The handler treats accumulate differently for typed vs
standard messages: when msg.message is present we always push contentBlocks but
when msg.type is used we check msg.accumulate !== false; make them consistent by
honoring msg.accumulate for the standard-path as well—before calling
MessageConverter.toContentBlocks and content.push(...) in the branch that
handles msg.message, check if msg.accumulate !== false (default true) and only
push into content and include content in the writer.writeEvent payload
accordingly; update the use of MessageConverter.toContentBlocks, content.push,
and the writer.writeEvent call to follow the same accumulate logic used for the
custom-event branch (referencing msg.accumulate, contentBlocks,
MessageConverter.toContentBlocks, writer.writeEvent, and msg.message).

In `@core/agent-runtime/src/MessageConverter.ts`:
- Around line 166-169: The merge creates a new text ContentBlock with
annotations: [] which discards source annotations; update MessageConverter's
merging logic (where merged.push({ type: ContentBlockType.Text, text: { value:
parts.join(''), annotations: [] } })) to collect and concatenate annotations
from the original text blocks (e.g., gather annotations arrays from the source
blocks you loop over, dedupe/normalize as needed) and assign that combined
annotations array instead of an empty array so annotations are preserved on the
merged block.
- Around line 145-153: The JSON.parse fallback is currently silent in
MessageConverter (inside the block handling inputFragments → parsedInput), which
can hide malformed tool input; modify the catch to capture the exception (e) and
both log a warning (use the class/logger available in MessageConverter or
console.warn if none) and preserve diagnostics by setting parsedInput to include
the raw text and error (e.g. { _raw: raw, _parseError: String(e) }) so
merged.push receives traceable data on parse failures; update the method
signature to accept/propagate a logger if needed and reference variables
inputFragments, raw, parsedInput, block, and merged when making changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0df1107f-23c7-4b37-a85c-1991da3815de

📥 Commits

Reviewing files that changed from the base of the PR and between 0a338ed and 47210a2.

📒 Files selected for processing (5)
  • core/agent-runtime/src/AgentRuntime.ts
  • core/agent-runtime/src/MessageConverter.ts
  • core/agent-runtime/test/AgentRuntime.test.ts
  • core/agent-runtime/test/MessageConverter.test.ts
  • core/types/agent-runtime/AgentRuntime.ts

Cast headersInit to Record<string, string> to avoid type conflict between
global Headers and undici's Headers iterator types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jerryliang64 jerryliang64 merged commit f4f904e into master Apr 1, 2026
12 checks passed
@jerryliang64 jerryliang64 deleted the fix/content-accumulation-merge branch April 1, 2026 13:01
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