Skip to content

fix(daemon): preserve parentToolCallId in compaction engine for parallel subagent streams#4765

Merged
doudouOUC merged 6 commits into
daemon_mode_b_mainfrom
daemon_mode_b_dev
Jun 4, 2026
Merged

fix(daemon): preserve parentToolCallId in compaction engine for parallel subagent streams#4765
doudouOUC merged 6 commits into
daemon_mode_b_mainfrom
daemon_mode_b_dev

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

Summary

  • 根因: TurnBoundaryCompactionEngine.mergeTextSlot 将所有连续同类 chunk 合并为一个 slot,不区分 parentToolCallId,导致并行子 agent 的 thought/content 混成乱码、归属丢失
  • 修复: 双路径合并逻辑——子 agent chunk 按 (kind, parentToolCallId) indexed merge,主 agent chunk 保持原有连续合并;同父 tool_call 到达时驱逐 textSlotIndex 条目,保持 compacted replay 与 live stream 的 block 分段一致
  • 防御性保障: compactCurrentTurn 输出时确保 _meta.parentToolCallId 不丢失;seed() 加固清理 in-flight state

Test plan

  • 新增 9 个测试用例覆盖:不同 parentToolCallId 分离、交错合并、主/子 agent 隔离、同 subagent thought+text 分离、同父 tool 边界分段、非父 tool 不驱逐、三方交错、9 并行压力测试、防御性回填
  • 全量 35/35 测试通过,0 回归
  • 用实际会话数据手动验证 load 返回中每个子 agent 的 thought/message 独立且带正确 parentToolCallId

🤖 Generated with Qwen Code

…lel subagent streams

TurnBoundaryCompactionEngine.mergeTextSlot merged all consecutive
agent_thought_chunk / agent_message_chunk events into a single slot
regardless of parentToolCallId, destroying per-subagent attribution.
When 9+ parallel subagents streamed concurrently, the compacted replay
contained garbled text with no parentToolCallId — the downstream
transcript reducer (fixed in #4689) could not route blocks to the
correct subagent tool call.

- Add parentToolCallId-aware dual-path merging: subagent chunks use an
  indexed lookup (textSlotIndex) to merge by (kind, parentToolCallId)
  even when interleaved; top-level chunks preserve the original
  consecutive-only merge to maintain text segmentation around tool calls.
- Evict textSlotIndex entries when a same-parent tool_call arrives,
  mirroring the transcript reducer's clearActiveText(parentToolCallId)
  so compacted replay block segmentation matches live behavior.
- Defensive backfill: ensure parentToolCallId survives in the compacted
  event's _meta even if the last chunk's _meta lost it.
- Harden seed() to clear in-flight state (slots, indexes, liveJournal).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Copilot AI review requested due to automatic review settings June 4, 2026 07:35
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR fixes a critical bug in the TurnBoundaryCompactionEngine.mergeTextSlot where parallel subagent streams were being incorrectly merged, causing text/thought garbling and loss of parentToolCallId attribution. The implementation introduces a dual-path merge logic that properly separates subagent streams by (kind, parentToolCallId) while preserving the original behavior for top-level chunks. The fix is well-tested with 9 comprehensive test cases covering edge cases and parallel stress scenarios.

🔍 General Feedback

  • Clean dual-path architecture: The separation between subagent path (indexed merge by parentToolCallId) and top-level path (consecutive merge) is a sound design that mirrors the transcript reducer's behavior.
  • Comprehensive test coverage: The 9 new test cases cover critical scenarios including interleaved streams, parallel stress (9 subagents), defensive backfill, and boundary conditions.
  • Defensive programming: The defensive backfill in compactCurrentTurn ensures parentToolCallId is preserved in output even if lost from the last chunk's _meta.
  • State cleanup: Proper clearing of textSlotIndex in seed(), close(), and compactCurrentTurn() prevents state leakage across turns.

🎯 Specific Feedback

🟡 High

  • File: compactionEngine.ts:218-226 - The slot eviction logic on tool calls uses string concatenation for slot keys (text::${toolParent}), but the same pattern in mergeTextSlot (line 204) uses the identical format. Consider extracting this key format into a constant or helper function to prevent future drift:

    // Suggested extraction
    const makeSlotKey = (kind: 'text' | 'thought', parentToolCallId: string) => 
      `${kind}::${parentToolCallId}`;
  • File: compactionEngine.ts:269-276 - The defensive backfill logic in compactCurrentTurn checks extractParentToolCallIdFromMeta(meta) !== slot.parentToolCallId. This comparison could be simplified since extractParentToolCallIdFromMeta already handles null/undefined cases. Consider documenting why this check is needed (i.e., when _meta might lose parentToolCallId between chunk ingestion and compaction).

🟢 Medium

  • File: compactionEngine.ts:44-49 - The CompactedSlot type now uses a union with parentToolCallId?: string for both text and thought kinds. This is correct, but consider whether parentToolCallId should be part of the discriminated union variant itself to make the type more self-documenting:

    type CompactedSlot =
      | { kind: 'text' | 'thought'; parentToolCallId: string; chunks: string[]; ... }  // subagent
      | { kind: 'text' | 'thought'; parentToolCallId?: undefined; chunks: string[]; ... }  // top-level
      | { kind: 'tool'; ... }
      | { kind: 'misc'; ... }
      | { kind: 'latestWins'; ... };
  • File: compactionEngine.test.ts:886-897 - The defensive backfill test verifies that chunks without parentToolCallId in _meta go to the top-level path. However, the test comment says "The chunk without parentToolCallId goes to the top-level path" — this is actually testing a different scenario than the code comment describes (defensive backfill when last chunk loses _meta). Consider clarifying the test to explicitly test the defensive backfill case where the slot HAS parentToolCallId but the last chunk's _meta doesn't.

🔵 Low

  • File: compactionEngine.ts:344-351 - The extractParentToolCallIdFromMeta function checks val.length > 0 for string validation. Consider using a more explicit check or extracting this to a helper for consistency with other string validation in the codebase.

  • File: compactionEngine.ts:108-113 - The seed() method resets liveJournal to an empty array. This is correct for replay scenarios, but consider adding a comment explaining why liveJournal is cleared (i.e., it's transient state for the current turn, not persisted across snapshots).

  • File: compactionEngine.test.ts:684-688 - The UpdatePayload type is defined inside the describe block. While this is fine for test organization, consider moving it to the top of the file with other helper types for better discoverability.

✅ Highlights

  • Excellent test design: The 9-parallel-subagent stress test (handles 9 parallel subagent thought streams without garbling) is particularly well-crafted, verifying that each subagent's stream is reconstructed correctly without cross-contamination.
  • Correct handling of tool call boundaries: The eviction logic (textSlotIndex.delete on tool calls with parent) correctly mirrors the transcript reducer's clearActiveText(parentToolCallId) behavior, ensuring compacted replay matches live stream segmentation.
  • Minimal invasive change: The fix modifies only the mergeTextSlot method and adds the textSlotIndex map, keeping the change focused and reducing regression risk.
  • Type-safe implementation: The use of TypeScript's discriminated unions and proper type narrowing in compactCurrentTurn ensures type safety throughout the refactoring.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a compaction bug in the daemon’s TurnBoundaryCompactionEngine where text/thought chunks from parallel subagent streams could be merged together and lose correct parentToolCallId association during replay compaction, resulting in garbled output and incorrect attribution on session load.

Changes:

  • Extend text/thought slot compaction to track parentToolCallId and merge subagent chunks by (kind, parentToolCallId) instead of blindly merging by kind.
  • Evict subagent text/thought merge indices when a tool call with the same parentToolCallId arrives, keeping compaction segmentation aligned with live transcript behavior.
  • Add a focused test suite covering parentToolCallId-aware merging scenarios and boundary cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
packages/acp-bridge/src/compactionEngine.ts Adds parentToolCallId-aware slotting/merging and eviction logic for parallel subagent text/thought streams.
packages/acp-bridge/src/compactionEngine.test.ts Adds new unit tests validating subagent separation/merging behavior under parallel/interleaved streams.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/acp-bridge/src/compactionEngine.ts
Comment thread packages/acp-bridge/src/compactionEngine.test.ts Outdated
Copilot review correctly noted the "defensive backfill" test name
was inaccurate — it actually tests that chunks without
parentToolCallId separate into the top-level path.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Comment thread packages/acp-bridge/src/compactionEngine.test.ts Outdated
Comment thread packages/acp-bridge/src/compactionEngine.ts Outdated
Comment thread packages/acp-bridge/src/compactionEngine.ts Outdated
…e, backfill tests

- Use bracket notation for _meta access in test helpers (TS4111 fix)
- Move textSlotIndex eviction into the new-tool-only branch so
  tool_call_update does not over-segment subagent text
- Add tests for meta backfill and tool_call_update non-eviction

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review round — wenshao (fe46e37)

# File Finding Action
1 test:161,173 [Critical] TS4111 — dot notation on index signature Fixed: switched to bracket notation
2 ts:165 [Suggestion] Eviction fires on tool_call_update (over-segments) Fixed: moved to new-tool-only branch
3 ts:268 [Suggestion] Meta backfill lacks test coverage Fixed: added 2 tests

All 3 items addressed. 38/38 tests passing.

Comment thread packages/acp-bridge/src/compactionEngine.ts
Verify that seed() clears in-flight slots, liveJournal, and index
maps so stale pre-seed data does not leak into post-seed compaction.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review round 2 — wenshao (5be1f3d)

# File Finding Action
4 ts:108 [Suggestion] seed() slot cleanup lacks test coverage Fixed: added test exercising seed() after in-flight state

1/1 item addressed. 39/39 tests passing.

wenshao
wenshao previously approved these changes Jun 4, 2026
@wenshao

wenshao commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

PR #4765 Local Verification Report

Branch: daemon_mode_b_dev | Commits: 87fb47ab5be1f3d6 (4 commits) | Base: daemon_mode_b_main

Changes Summary

  • packages/acp-bridge/src/compactionEngine.ts (+96 −25) — Dual-path text merging: subagent chunks merge by (kind, parentToolCallId) via indexed lookup; top-level chunks preserve consecutive-only merge. Adds textSlotIndex eviction on same-parent tool_call, defensive parentToolCallId meta backfill, and seed() cleanup hardening.
  • packages/acp-bridge/src/compactionEngine.test.ts (+465 −6) — 12 new test cases covering parentToolCallId-aware merging, interleaving, eviction, meta backfill, 9-parallel pressure test, and seed cleanup.

Test Results

Check Result Details
Unit Tests ✅ PASS 39/39 tests passed (compactionEngine.test.ts, --no-cache)
ESLint ✅ PASS 0 warnings, 0 errors
TypeCheck 🟡 Pre-existing 2 errors in bridge.tsDAEMON_TRACEPARENT_META_KEY / DAEMON_TRACESTATE_META_KEY not exported from core. Same 2 errors exist on base branch daemon_mode_b_main; 0 introduced by this PR.
Build 🟡 Pre-existing Fails due to the same 2 typecheck errors above. Same failure on base branch.
Whitespace ✅ PASS git diff --check clean

Pre-existing Errors (on daemon_mode_b_main)

  • bridge.ts:22-23DAEMON_TRACEPARENT_META_KEY / DAEMON_TRACESTATE_META_KEY not yet exported from @qwen-code/qwen-code-core (pending core-side merge)

Verdict

Ready to merge. All 39 unit tests pass (including 12 new parentToolCallId-aware tests + 9-parallel pressure test). Lint clean. Zero new type errors — the 2 typecheck/build failures are pre-existing on daemon_mode_b_main. The dual-path merging logic is well-tested and correctly preserves subagent stream attribution.


Verified by wenshao

Comment thread packages/acp-bridge/src/compactionEngine.ts Outdated
Comment thread packages/acp-bridge/src/compactionEngine.ts
Comment thread packages/acp-bridge/src/compactionEngine.ts Outdated
… comment, add thought eviction test

- Remove dead parentToolCallId fallback in tool eviction (emitters
  always use _meta), aligning with mergeTextSlot extraction
- Reword eviction comment to be self-describing
- Add thought slot eviction test coverage

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review round 3 — wenshao (5f88fcd)

# File Finding Action
5 ts:168 [Suggestion] Remove dead fallback + add thought eviction test Fixed
6 ts:211 [Suggestion] Subagent merge path unreachable Pushed back — SubAgentTracker already passes subagentMeta (5th arg)
7 ts:164 [Suggestion] Comment references non-existent function Fixed — reworded to self-describing

2/3 fixed, 1 pushed back. 40/40 tests passing.

@doudouOUC doudouOUC requested a review from wenshao June 4, 2026 14:06
wenshao
wenshao previously approved these changes Jun 4, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

R4 review (5f88fcd): all R1-R3 findings resolved. Dual-source fallback removed, thought eviction test added. 40/40 tests pass, ESLint clean, zero new type errors. No high-confidence issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

Comment thread packages/acp-bridge/src/compactionEngine.ts Outdated
The defensive parentToolCallId backfill in compactCurrentTurn was
unreachable: the routing invariant in mergeTextSlot guarantees that
any chunk reaching the subagent path has parentToolCallId in _meta,
so slot.lastMeta always contains it. Remove the dead code and rename
tests to describe what they actually verify.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review round 4 — wenshao (fde8474)

# File Finding Action
8 ts:266 [Suggestion] Meta backfill is unreachable dead code Agreed — removed backfill, renamed tests

1/1 addressed. 40/40 tests passing. Code simplified.

@doudouOUC doudouOUC requested a review from wenshao June 4, 2026 15:27

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@doudouOUC doudouOUC merged commit 9b16bdc into daemon_mode_b_main Jun 4, 2026
4 checks passed
@doudouOUC doudouOUC deleted the daemon_mode_b_dev branch June 4, 2026 15:37
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
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.

4 participants