Skip to content

fix(daemon): isolate parallel subAgent text streams in transcript reducer#4689

Open
doudouOUC wants to merge 6 commits into
daemon_mode_b_mainfrom
worktree-wiggly-meandering-lightning
Open

fix(daemon): isolate parallel subAgent text streams in transcript reducer#4689
doudouOUC wants to merge 6 commits into
daemon_mode_b_mainfrom
worktree-wiggly-meandering-lightning

Conversation

@doudouOUC
Copy link
Copy Markdown
Collaborator

Summary

  • 问题:Daemon 模式下并行 subAgent(如 /review 派发的多个 Agent)的文本 chunk 交错合并到同一个 transcript block,导致 WebShell 上显示乱码
  • 根因SubAgentTracker.createStreamTextHandler 发射 agent_message_chunk 时不带 parentToolCallId;normalizer 不提取;transcript reducer 用单一 activeAssistantBlockId 接收所有文本
  • 修复:沿用 tool block 已有的 parentToolCallId 隔离模式,将其扩展到 text/thought 事件。不带 parentToolCallId 的文本完全走现有 scalar 路径(零行为变更)

变更概览

文件 变更
发射 MessageEmitter.ts emitAgentMessage/emitAgentThought/emitMessagesubagentMeta? 参数
发射 SubAgentTracker.ts createStreamTextHandlerthis.getSubagentMeta()
类型 types.ts DaemonUiTextEvent + DaemonTextTranscriptBlockparentToolCallId?;State 加两个 keyed map
归一化 normalizer.ts agent_message_chunk/agent_thought_chunk_meta 提取 parentToolCallId
归约 transcript.ts appendTextDelta per-parent 隔离;finishAssistant 遍历 map 清 streaming;clearActiveText scoped clear;init/clone/trim
状态 store.ts createState 浅拷贝新 map

设计要点

  • per-parent keyed mapactiveAssistantBlockByParent: Record<string, string>parentToolCallId 维护独立的 active block pointer,与现有 scalar activeAssistantBlockId 共存
  • scoped clearActiveText:subAgent 的 tool.update 只清自己 parent 的 text block,不影响其他并行 subAgent
  • finishAssistant 遍历 mapassistant.done 后所有 keyed block 的 streaming 设为 false,防止 zombie spinner
  • per-parent mutual exclusion:assistant↔thought 互斥只在同一 parentToolCallId 内生效
  • 显式取 keyMessageEmitter 只从 subagentMetaparentToolCallId/subagentType,不 spread 整个对象,防止覆盖 timestamp 等保留字段

已知局限(后续 PR)

  • WebUI transcriptToMessages.ts 渲染层需用 parentToolCallId 替代位置启发式
  • shell/permission/status 事件在并行 subAgent 流期间触发 global clear(不丢文本,仅多一次 block 切换)

Closes #4687

Test plan

  • 211 个 daemonUi 测试通过(原 200 + 新增 11),覆盖:
    • T1: 带 parentToolCallId 的交替 delta → 独立 block
    • T2: scoped clearActiveText — A 的 tool call 不打断 B 的 text
    • T3: assistant.done 后所有 map block streaming=false
    • T4: 回归保护 — 无 parentToolCallId 走 scalar 路径
    • T5: keyed/scalar 路径共存互不干扰
    • T6: trim 后 map 条目被清理
    • T7: 用户消息清空 map
    • 4 个 normalizer 测试:提取/省略/非 string 值处理
  • SubAgentTracker 测试验证 _metaparentToolCallId
  • TypeScript 编译零错误(sdk-typescript + cli)

🤖 Generated with Qwen Code

Copilot AI review requested due to automatic review settings June 2, 2026 03:46
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 daemon-mode transcript corruption when multiple subagents stream text concurrently by propagating parentToolCallId through emission → normalization → transcript reduction, then routing assistant/thought deltas into per-parent “active block” pointers to prevent cross-subagent interleaving.

Changes:

  • Add subagentMeta support to message emission and ensure streamed subagent text chunks carry _meta.parentToolCallId.
  • Extend daemon UI event/block types and normalizer to extract and forward parentToolCallId for agent_message_chunk / agent_thought_chunk.
  • Update the transcript reducer to maintain per-parent active assistant/thought blocks (plus scoped clearing/finishing) and add unit tests covering parallel streaming scenarios.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts Adds subagentMeta to emitted agent message/thought chunks via _meta fields.
packages/cli/src/acp-integration/session/SubAgentTracker.ts Passes getSubagentMeta() into streamed text emission to stamp parent linkage.
packages/cli/src/acp-integration/session/SubAgentTracker.test.ts Verifies _meta includes parentToolCallId / subagentType for streamed output.
packages/sdk-typescript/src/daemon/ui/types.ts Adds optional parentToolCallId to text events and transcript text blocks; adds per-parent active-block maps to state.
packages/sdk-typescript/src/daemon/ui/normalizer.ts Extracts parentToolCallId from _meta for agent message/thought chunk updates.
packages/sdk-typescript/src/daemon/ui/transcript.ts Routes deltas per parentToolCallId, adds scoped clearActiveText, and ensures streaming flags are cleared across keyed blocks.
packages/sdk-typescript/src/daemon/ui/store.ts Ensures new per-parent maps are initialized/cloned in store state creation.

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

Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

📋 Review Summary

This PR addresses a critical bug in daemon mode where parallel subAgent text streams were interleaved and merged into a single transcript block, causing garbled output in the WebShell. The fix implements per-parent isolation using a keyed map approach that extends the existing tool block isolation pattern to text/thought events. The implementation is well-designed with comprehensive test coverage (211 daemon UI tests).

🔍 General Feedback

  • Excellent problem analysis: The PR body clearly identifies the problem, root cause, and solution approach with a detailed change overview table
  • Consistent design pattern: The fix elegantly extends the existing parentToolCallId isolation pattern used for tool blocks to text/thought streams
  • Backward compatible: Changes are designed so that text without parentToolCallId follows the existing scalar path (zero behavior change for non-daemon cases)
  • Comprehensive test coverage: 211 daemon UI tests including 11 new tests covering the parallel subAgent scenarios
  • Well-structured architecture: Clean separation across emission → normalization → reduction layers

🎯 Specific Feedback

🟡 High

  • File: packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts:117-119 - The emitUsageMetadata method spreads subagentMeta directly into the meta object, which could potentially overwrite reserved fields like timestamp. This is inconsistent with the explicit field extraction approach used in emitAgentThought and emitAgentMessage (lines 75-82, 104-111).

    Recommendation: Apply the same explicit field extraction pattern:

    const meta: Record<string, unknown> = {};
    if (subagentMeta?.parentToolCallId) {
      meta['parentToolCallId'] = subagentMeta.parentToolCallId;
    }
    if (subagentMeta?.subagentType) {
      meta['subagentType'] = subagentMeta.subagentType;
    }
    if (typeof durationMs === 'number') {
      meta['usage'] = usage;
      meta['durationMs'] = durationMs;
    } else {
      meta['usage'] = usage;
    }

🟢 Medium

  • File: packages/sdk-typescript/src/daemon/ui/transcript.ts:386-388 - The finishAssistant function clears both activeAssistantBlockByParent and activeThoughtBlockByParent maps, but this only handles the assistant.done case. The comment in the PR mentions that shell/permission/status events can trigger global clear during parallel subAgent streams.

    Recommendation: Consider adding a comment or TODO at line 1055 (in clearActiveText) noting this known limitation for future cleanup when shell/permission events are also made parent-scoped.

  • File: packages/sdk-typescript/src/daemon/ui/transcript.ts:418-426 - The mutual exclusion logic between assistant and thought blocks within the same parent is correct, but the asymmetry (assistant deletes thought, thought deletes assistant) could benefit from a clarifying comment explaining why this is needed.

    Recommendation: Add a brief comment:

    // Per-parent mutual exclusion: only one active text block per parent at a time
    if (parentId != null) {
      if (kind === 'assistant') {
        delete state.activeThoughtBlockByParent[parentId];
      }
      if (kind === 'thought') {
        delete state.activeAssistantBlockByParent[parentId];
      }
    }

🔵 Low

  • File: packages/sdk-typescript/src/daemon/ui/types.ts:634 - The parentToolCallId field is added to DaemonTextTranscriptBlock but there's no JSDoc comment explaining its purpose, unlike the detailed comments on DaemonUiToolUpdateEvent.parentToolCallId (lines 125-136).

    Recommendation: Add documentation:

    /**
     * When set, this text block was produced by a sub-agent running under the
     * specified parent tool call. Used to isolate parallel sub-agent text streams
     * in the transcript reducer.
     */
    parentToolCallId?: string;
  • File: packages/sdk-typescript/src/daemon/ui/normalizer.ts:401-413 - The extraction of parentToolCallId from agent_message_chunk uses inline type guards and getString() helper, which is good. However, the same pattern is duplicated for agent_thought_chunk (lines 415-430).

    Recommendation: Consider extracting a helper function to reduce duplication:

    function extractParentToolCallId(meta: Record<string, unknown> | undefined): string | undefined {
      return meta ? getString(meta, 'parentToolCallId') : undefined;
    }
  • File: packages/cli/src/acp-integration/session/SubAgentTracker.ts:279-280 - The undefined parameter for timestamp seems intentional but could use a comment explaining why subAgent text doesn't use server-side timestamps.

    Recommendation: Add a brief comment:

    void this.messageEmitter.emitMessage(
      event.text,
      'assistant',
      event.thought ?? false,
      undefined, // SubAgent text uses client-side ordering via eventId
      this.getSubagentMeta(),
    );

✅ Highlights

  • Excellent test coverage: The 7 new test scenarios (T1-T7) comprehensively cover:

    • T1: Parallel subAgent text isolation into independent blocks
    • T2: Scoped clearActiveText — one subAgent's tool call doesn't interrupt another's text
    • T3: finishAssistant properly clears streaming state on all keyed blocks
    • T4: Regression protection for non-parent text (scalar path)
    • T5: Coexistence of keyed and scalar paths
    • T6: Trim cleanup of stale map entries
    • T7: User message clears all subAgent text maps
  • Clean API evolution: The subagentMeta parameter is optional with sensible defaults, maintaining backward compatibility across all emitter methods

  • Defensive type handling: The normalizer properly handles missing _meta, non-record _meta, and non-string parentToolCallId values (4 new normalizer tests)

  • Minimal invasive changes: The fix touches exactly the right layers (emission → normalization → reduction) without unnecessary refactoring

  • Clear documentation of known limitations: The PR body explicitly calls out what's not addressed (WebUI rendering layer, shell/permission/status events) with clear follow-up items

Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
@doudouOUC
Copy link
Copy Markdown
Collaborator Author

Review Round 1 — All findings addressed in 43c04c7

# Reviewer Finding Action
1 Copilot user.text.deltaparentToolCallId 会跳过 scalar 互斥 Fixed: 加 kind !== 'user' guard
2 wenshao [Critical] thought 驱逐 assistant 时未 finalize streaming = false Fixed: eviction 前设 streaming = false,加 T8 测试
3 wenshao [Critical] clearActiveText scoped thought 路径缺测试 Fixed: 加 T9 测试覆盖 thought→tool.update→thought 场景

213 tests passing, 0 TypeScript errors.

chiga0
chiga0 previously approved these changes Jun 2, 2026
Copy link
Copy Markdown
Collaborator

@chiga0 chiga0 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 Overview (AI Generated)

PR: #4689 fix(daemon): isolate parallel subAgent text streams in transcript reducer
Type: Bug Fix
Change size: +578/-16 across 8 files, 2 commits

Multi-Round Review (Rounds 0-6): Clean — 0 findings

Round 0 (Design): Correct approach — extends the existing parentToolCallId isolation pattern from tool blocks to text/thought events. The per-parent keyed maps (activeAssistantBlockByParent, activeThoughtBlockByParent) are a natural extension that maintains backward compatibility for events without parentToolCallId.

Round 1 (Architecture):

  • Clean three-layer flow: emission (MessageEmitter + SubAgentTracker) → normalization (normalizer.ts) → reduction (transcript.ts)
  • parentToolCallId correctly extracted from _meta in normalizer
  • Per-parent maps correctly initialized in createState, copied in cloneTranscriptState, cleaned in trimTranscriptState

Round 2 (Robustness):

  • wenshao's findings (both addressed in commit 2):
    • F1: Cross-eviction now finalizes streaming = false on evicted assistant block + test T8
    • F2: clearActiveText scoped path now has test T9
  • Copilot's finding (addressed in commit 2): Added kind !== 'user' guard to prevent user text events from entering keyed path

Round 3 (CI/Security): N/A — purely UI state management, no security concerns.

Round 4 (Performance): Per-parent map lookups are O(1). finishAssistant iterates keyed blocks — acceptable given typical concurrent subAgent count (< 10).

Round 5 (Bug Fix): Root cause correctly identified and fixed. No regressions — scalar path unchanged for events without parentToolCallId. 11 new tests (T1-T9 + 2 edge cases) provide good coverage.

Round 6 (Undirected): Maps are bounded by transcript size limit via trimTranscriptState. The kind !== 'user' guard correctly prevents the issue Copilot identified.

LGTM!

This review was generated by QoderWork AI

Comment thread packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/types.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/test/unit/daemonUi.test.ts
@doudouOUC
Copy link
Copy Markdown
Collaborator Author

Review Round 2 — All findings addressed in bb35252

# Finding Action
1 [Suggestion] emitAgentThought/emitAgentMessage 重复 _meta 构建 Fixed: 提取 buildChunkMeta() 私有方法
2 [Suggestion] text block parentToolCallId 无下游消费者 Fixed: 加 JSDoc 说明用途
3 [Suggestion] keyed/scalar 路径独立性缺文档 Fixed: 加不变量注释
4 [Suggestion] 缺 4 条测试路径 Fixed: 加 T10-T13 覆盖 thought trim/clear/eviction/streaming

217 tests passing, 0 TypeScript errors.

@doudouOUC doudouOUC requested review from chiga0 and wenshao June 2, 2026 15:36
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts Outdated
@doudouOUC
Copy link
Copy Markdown
Collaborator Author

Review Round 3 — Addressed in 646a094

# Finding Action
1 [Suggestion] normalizer: duplicated _meta extraction in message/thought chunks Fixed: extracted extractParentToolCallId helper

217 tests passing, 0 TypeScript errors.

Comment thread packages/sdk-typescript/test/unit/daemonUi.test.ts
Comment thread packages/sdk-typescript/test/unit/daemonUi.test.ts
Comment thread packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts
@doudouOUC
Copy link
Copy Markdown
Collaborator Author

Review Round 4 — Addressed in 9997a76

# Finding Action
1 [Suggestion] scoped clearActiveText preserves scalar Fixed: T14 added
2 [Suggestion] finishAssistant finalizes scalar + keyed Fixed: T15 added
3 [Suggestion] buildChunkMeta isolated unit test Not taking: already covered by SubAgentTracker integration test

219 tests passing, 0 TypeScript errors.

Comment thread packages/sdk-typescript/test/unit/daemonUi.test.ts
@wenshao
Copy link
Copy Markdown
Collaborator

wenshao commented Jun 2, 2026

Maintainer Verification Report

Tested on: macOS Darwin 25.4.0
Branch: worktree-wiggly-meandering-lightning @ ca63fce
Base: daemon_mode_b_main (117c9b2)


1. TypeScript Compilation

Package Result Notes
sdk-typescript 1 pre-existing error DaemonClient.ts@qwen-code/acp-bridge/mcpTimeouts unresolvable; identical on base branch. Not introduced by this PR.
cli 59 pre-existing errors All in serve/server.ts, serve/index.ts, btwCommand.ts — identical count on base branch. Not introduced by this PR.

Verdict: Zero new TypeScript errors introduced.

2. Unit Tests — daemonUi (sdk-typescript)

 ✓ test/unit/daemonUi.test.ts (219 tests) 54ms
 Test Files  1 passed (1)
      Tests  219 passed (219)

PR-specific tests (19/19 passed):

Test Coverage
Normalizer: extracts parentToolCallId from _meta on agent_message_chunk
Normalizer: extracts parentToolCallId from _meta on agent_thought_chunk
Normalizer: omits parentToolCallId when _meta is absent
Normalizer: drops non-string parentToolCallId from _meta
T1: separates text chunks by parentToolCallId into independent blocks
T2: scoped clearActiveText — subAgent A tool does not interrupt subAgent B text
T3: finishAssistant sets streaming=false on all keyed-map blocks
T4: regression — text without parentToolCallId uses scalar path
T5: keyed and scalar paths coexist without interference
T6: trimTranscriptState prunes stale entries from activeAssistantBlockByParent
T7: appendLocalUserTranscriptMessage clears active subAgent text maps
T8: thought evicts assistant block and finalizes streaming for same parent
T9: thought text cleared by scoped clearActiveText from tool.update
T10: trimTranscriptState prunes activeThoughtBlockByParent
T11: finishAssistant clears activeThoughtBlockByParent with entries
T12: assistant evicts thought block for same parent
T13: scoped clearActiveText sets streaming=false on cleared block
T14: scoped clearActiveText preserves scalar activeAssistantBlockId
T15: finishAssistant finalizes both scalar and keyed blocks

3. Unit Tests — SubAgentTracker (cli)

 ✓ SubAgentTracker.test.ts (24 tests) 254ms
 Test Files  1 passed (1)
      Tests  24 passed (24)

Verified: createStreamTextHandler now passes this.getSubagentMeta() and the emitted chunk contains _meta: { parentToolCallId, subagentType }.

4. Code Review Summary

Architecture: Clean per-parent keyed-map approach (activeAssistantBlockByParent / activeThoughtBlockByParent) that extends the existing scalar path without modifying it. Text events without parentToolCallId are fully isolated on the original scalar path — zero behavior change for non-subAgent flows.

Key design strengths:

  • buildChunkMeta() in MessageEmitter explicitly picks parentToolCallId/subagentType from subagentMeta — avoids accidental field spread
  • extractParentToolCallId() helper in normalizer eliminates duplication between agent_message_chunk and agent_thought_chunk
  • clearActiveText() is now scoped by parentToolCallId — subAgent A's tool call only clears A's active text, not B's
  • finishAssistant() iterates both scalar and keyed maps for comprehensive cleanup
  • trimTranscriptState() and cloneTranscriptState() both handle the new maps correctly

Backward compatibility: Verified — keyed path and scalar path are fully independent (comment at line 362-365 in transcript.ts). No changes to any existing public API surface.

No issues found. Ready to merge.


Verification performed locally using tmux parallel build/test sessions.

doudouOUC added 6 commits June 3, 2026 17:05
…ucer

Thread parentToolCallId through the text emission → normalization →
reduction pipeline so concurrent subAgent text chunks accumulate into
independent transcript blocks instead of interleaving into one.

Closes #4687
…n, guard user text, add tests

- Finalize evicted assistant block's `streaming = false` when thought
  text for the same parentToolCallId triggers mutual exclusion
- Guard parentId extraction: skip for `kind === 'user'` to prevent
  accidental keyed-map routing of user text events
- Add T8 (thought evicts assistant + finalizes streaming) and T9
  (thought text cleared by scoped clearActiveText from tool.update)
…4 tests

- Extract buildChunkMeta() to eliminate duplicated _meta construction
- Add JSDoc on DaemonTextTranscriptBlock.parentToolCallId
- Add invariant comment on keyed/scalar path independence
- Add T10-T13: thought map trim, finishAssistant thought clear,
  assistant→thought eviction, scoped clearActiveText streaming=false
Eliminate duplicated _meta extraction between agent_message_chunk
and agent_thought_chunk cases per R3 review.
@doudouOUC doudouOUC force-pushed the worktree-wiggly-meandering-lightning branch from ca63fce to ee53f7e Compare June 3, 2026 09:05
@doudouOUC doudouOUC self-assigned this Jun 3, 2026
@doudouOUC doudouOUC requested a review from wenshao June 3, 2026 09:38
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

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

[Suggestion] SubAgentTracker.test.ts:788agent_thought_chunk 测试缺少 _meta 断言

相邻的 agent_message_chunk 测试(line 719)已断言 _metaparentToolCallId/subagentType,但 thought 测试(line 788)未加。若 emitMessage 的 thought 分支漏传 subagentMeta,现有测试不会发现——thought chunk 会落入 scalar 路径导致并行交错。建议镜像 message 测试的 _meta 断言。

— qwen3.7-max via Qwen Code /review

case 'agent_message_chunk': {
const text = getTextContent(update['content']);
return text ? [{ ...base, type: 'assistant.text.delta', text }] : [];
if (!text) return [];
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.

[Suggestion] 风格不一致:此处使用 early-return guard(if (!text) return []),而同 switch 中 user_message_chunk(line 407)和 shell_output/tool_output 使用三元表达式(return text ? [...] : [])。建议统一为同一种风格。

— qwen3.7-max via Qwen Code /review

next.activeUserBlockId = block.id;
next.activeAssistantBlockId = undefined;
next.activeThoughtBlockId = undefined;
next.activeAssistantBlockByParent = {};
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.

[Suggestion] appendLocalUserTranscriptMessage 清理 keyed map 时未 finalize 流式块

直接 activeAssistantBlockByParent = {} 但未遍历引用的块设置 streaming = false。被孤立的 assistant 块保留 streaming: true,渲染层可能显示永久加载指示器。建议参考 finishAssistant 模式,清理前先 finalize:

for (const blockId of Object.values(next.activeAssistantBlockByParent)) {
  const block = getWritableBlockById(next, blockId);
  if (block?.kind === 'assistant') {
    block.streaming = false;
    block.updatedAt = next.now;
  }
}
next.activeAssistantBlockByParent = {};
next.activeThoughtBlockByParent = {};

— qwen3.7-max via Qwen Code /review

: this.emitAgentMessage(text, timestamp, subagentMeta);
}

private buildChunkMeta(
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.

[Suggestion] 两种不一致的序列化模式

buildChunkMeta(此处)显式选取字段(parentToolCallIdsubagentType),而 emitUsageMetadata(line 126-127)使用对象展开(...subagentMeta)。若 SubagentMeta 增加新字段,emitUsageMetadata 会静默包含,buildChunkMeta 会静默丢弃。建议统一为显式提取或统一使用展开。

— qwen3.7-max via Qwen Code /review

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