Skip to content

fix(channels): return only final ACP response text - #6615

Merged
wenshao merged 7 commits into
QwenLM:mainfrom
qqqys:codex/issue-6602-acp-final-response
Jul 10, 2026
Merged

fix(channels): return only final ACP response text#6615
wenshao merged 7 commits into
QwenLM:mainfrom
qqqys:codex/issue-6602-acp-final-response

Conversation

@qqqys

@qqqys qqqys commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR changes channel ACP prompt collection so intermediate text from earlier tool-call turns is discarded before the final response is returned to channel adapters. It also treats plan updates and permission requests as turn boundaries, and propagates that boundary through the channel bridge so buffered block-streaming text is dropped before delivery.

Why it's needed

Channel integrations send the string returned by the ACP prompt as the completed response. When a prompt performs multiple tool-call rounds, the previous behavior accumulated every streamed text chunk across the whole loop, so group chat users could receive a long message containing both intermediate narration and the actual final answer. Fixes #6602.

Reviewer Test Plan

How to verify

Ask a channel-backed ACP session a question that requires multiple tool calls or permission approval. The delivered final message should contain only the final answer text, not earlier narration such as searching, reading, or permission status text. For TodoWrite-only intermediate rounds, any text before the plan update should also be excluded from the completed response.

Evidence (Before & After)

Before: the new regression tests would receive concatenated intermediate and final text, for example Let me search. Now I will read. Final answer.. After: npx vitest run src/AcpBridge.test.ts and npx vitest run src/ChannelBase.test.ts in packages/channels/base pass and assert that only post-boundary final text is returned or delivered from buffered block streaming.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Local Node/npm workspace after npm install.

Risk & Scope

  • Main risk or tradeoff: Plan updates, pending tool calls, and permission requests now clear the prompt response buffer for the active session before later final text is collected.
  • Not validated / out of scope: Live DingTalk/Feishu/Telegram E2E with real credentials.
  • Breaking changes / migration notes: None expected.
中文说明

这个 PR 做了什么

这个 PR 调整了频道 ACP prompt 的文本收集逻辑:当较早的 tool-call 轮次结束并进入后续轮次时,会丢弃这些中间文本,只把最终回复返回给频道适配器。同时把 plan 更新和 permission request 也视为轮次边界,并把这个边界事件传到频道层,用于清理尚未发送的 block streaming 缓冲。

为什么需要

频道集成会把 ACP prompt 返回的字符串作为完整回复发送出去。当一次 prompt 需要多轮工具调用时,旧逻辑会累积整个循环里的所有流式文本,因此群聊用户可能收到一条很长的消息,里面既有中间说明,也有真正的最终答案。Fixes #6602

Reviewer 测试计划

如何验证

在使用 channel 的 ACP session 中询问一个需要多次工具调用或权限确认的问题。最终发送出去的消息应该只包含最终答案,不包含较早的搜索、读取、权限确认等状态说明。对于 TodoWrite-only 的中间轮次,plan 更新前的说明文字也不应该出现在完整回复里。

证据(Before & After)

修复前:新增回归测试会拿到中间文本和最终文本拼接后的结果,例如 Let me search. Now I will read. Final answer.。修复后:在 packages/channels/base 执行 npx vitest run src/AcpBridge.test.tsnpx vitest run src/ChannelBase.test.ts 通过,并断言返回或缓冲发送的文本只保留边界之后的最终内容。

测试平台

OS 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境(可选)

执行过 npm install 的本地 Node/npm 工作区。

风险与范围

  • 主要风险或权衡:plan 更新、pending tool call 和 permission request 现在会清空当前 session 的 prompt 回复 buffer,然后再收集后续最终文本。
  • 未验证 / 不在范围内:需要真实凭据的 DingTalk/Feishu/Telegram E2E。
  • 破坏性变更 / 迁移说明:预计没有。

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! (Re-run — author addressed prior feedback.)

Template looks good ✓

Problem: The bug in #6602 is real and well-documented — AcpBridge.prompt() concatenates all textChunk events across every tool-call turn, so channel users get pages of intermediate narration before the actual answer. Clear reproduction in the issue.

Direction: Aligned. Channel response quality is a real user-facing problem, and the responseBoundary event pattern is the right abstraction for clearing accumulated text at turn boundaries.

Size: All changes are in packages/channels/ — no core module paths touched. 106 production lines + 584 test lines. Stage 0 not applicable.

Approach: Scope concerns from the prior run are fully resolved. The A2A infrastructure and the unrelated autofix workflow test edits have been removed. This is now a focused, single-purpose PR — exactly what was requested.

Moving on to code review. 🔍

中文说明

感谢贡献!(Re-run — 作者已回应之前的反馈。)

模板完整 ✓

问题: Issue #6602 中的 bug 真实且有文档——AcpBridge.prompt() 把所有 textChunk 事件拼接在一起,导致频道用户会收到大量中间过程叙述文本。Issue 中有清晰的复现步骤。

方向: 对齐。频道回复质量是真实的用户问题,responseBoundary 事件模式是在轮次边界清空累积文本的正确抽象。

规模: 所有变更在 packages/channels/ 中——未触及核心模块路径。106 行生产代码 + 584 行测试代码。Stage 0 不适用。

方案: 上次审查的范围问题已完全解决。A2A 基础设施和不相关的 autofix workflow 测试编辑已移除。现在是一个聚焦的单一目的 PR——正是之前建议的。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qqqys
qqqys force-pushed the codex/issue-6602-acp-final-response branch from 096be4c to 987edc5 Compare July 9, 2026 14:43
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Code Review

The implementation is clean and correct. The responseBoundary event pattern is consistent across both bridges:

  • AcpBridge: adds responseBoundary emission at pending/in-progress tool calls, plan updates, and permission requests. The prompt() method listens for the event and clears the chunks array. Properly cleaned up in finally.
  • DaemonChannelBridge: mirrors the same pattern for the daemon session path. The clearChunks listener on responseBoundary resets the chunks array with a session ID guard.
  • ChannelBase: wires responseBoundary in both the channel loop and block-streaming paths, with proper cancel-state guards (cancelled / cancelPending). Calls the new onResponseBoundary hook for adapter overrides.
  • FeishuAdapter: clears accumulatedText and pending card update timers on boundary.
  • QQChannel: clears stream state, timers, and flush markers on boundary.

I independently arrived at the same approach before reading the diff. Nothing missed, nothing extra. No critical issues, no AGENTS.md violations.

Testing

All 530 tests pass across 5 test files. Build and typecheck succeed.

packages/channels/base:
  AcpBridge.test.ts           25 passed
  ChannelBase.test.ts        362 passed
  DaemonChannelBridge.test.ts 31 passed
packages/channels/feishu:
  adapter.test.ts              69 passed
packages/channels/qqbot:
  stream.test.ts               43 passed

New regression tests cover: tool calls (pending + auto-approved), plan updates as turn boundaries, permission requests as boundaries, block-streaming buffer clearing, cancel-state boundary suppression, adapter-specific buffer clearing (Feishu card state, QQ stream state).

Tmux real-scenario testing: not feasible — channel ACP sessions require live DingTalk/Feishu/Telegram credentials. The unit tests comprehensively mock the multi-turn event flow and assert the exact behavior described in the PR. This is the appropriate testing level for channel infrastructure changes.

中文说明

代码审查

实现干净且正确。responseBoundary 事件模式在两个 bridge 中一致:

  • AcpBridge:在 pending/in-progress 的 tool call、plan 更新和 permission request 时发出 responseBoundaryprompt() 方法监听该事件并清空 chunks 数组。在 finally 中正确清理。
  • DaemonChannelBridge:为 daemon session 路径镜像了相同模式。clearChunks 监听器在 responseBoundary 时用 session ID 守卫重置 chunks 数组。
  • ChannelBase:在 channel loop 和 block-streaming 两条路径中都接入了 responseBoundary,带有正确的 cancel 状态守卫。调用新的 onResponseBoundary hook 供适配器覆写。
  • FeishuAdapter:在边界时清空 accumulatedText 和待处理的卡片更新定时器。
  • QQChannel:在边界时清空 stream state、定时器和 flush markers。

我在阅读 diff 之前独立提出了相同方案。没有遗漏,没有多余。无关键问题,无 AGENTS.md 违规。

测试

5 个测试文件共 530 个测试全部通过。Build 和 typecheck 均成功。

Tmux 真实场景测试: 不可行——频道 ACP session 需要真实的 DingTalk/Feishu/Telegram 凭据。单元测试全面 mock 了多轮事件流并断言了 PR 中描述的精确行为。对于频道基础设施变更,这是合适的测试层级。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

This is a clean, well-scoped bug fix on re-run. The problem in #6602 is real and clearly reproduced — channel users get walls of intermediate narration text because AcpBridge.prompt() accumulates all textChunk events across tool-call turns.

The implementation introduces a responseBoundary event — 106 lines of production code across 6 files, with 584 lines of tests. The pattern is consistent across both bridges (AcpBridge and DaemonChannelBridge), properly wired through ChannelBase's two prompt paths, and correctly handled by both adapter overrides (Feishu card state, QQ stream state). All 530 tests pass, build and typecheck succeed.

Both scope concerns from the prior run are resolved — the A2A infrastructure and the autofix workflow test edits are gone. The maintainer's feedback about the autofix test file has been addressed.

Approving. ✅

中文说明

Re-run 上这是一个干净、范围合理的 bug 修复。Issue #6602 中的问题真实且复现清晰——频道用户收到大量中间过程叙述文本,因为 AcpBridge.prompt() 跨工具调用轮次累积了所有 textChunk 事件。

实现引入了 responseBoundary 事件——6 个文件共 106 行生产代码,584 行测试。该模式在两个 bridge(AcpBridge 和 DaemonChannelBridge)中一致,正确接入了 ChannelBase 的两条 prompt 路径,并被两个适配器覆写正确处理(Feishu 卡片状态,QQ 流状态)。530 个测试全部通过,build 和 typecheck 均成功。

上次审查的两个范围问题都已解决——A2A 基础设施和 autofix workflow 测试编辑已移除。Maintainer 关于 autofix 测试文件的反馈已被处理。

批准合入。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot 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, looks ready to ship. ✅

@qqqys
qqqys force-pushed the codex/issue-6602-acp-final-response branch from 987edc5 to 732bee8 Compare July 9, 2026 15:01

@qwen-code-ci-bot qwen-code-ci-bot 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.

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Suggestions — commit aee6a87fe8c67fd01ed33409be56e39318e7b24e

File Issue Suggested fix
packages/channels/base/src/AcpBridge.ts:399, packages/channels/base/src/DaemonChannelBridge.ts:781 Zero logging when responseBoundary fires or chunks are cleared. When production responses come back empty or wrong, there's no log trail to diagnose whether the boundary fired, how many chunks were dropped, or whether it was suppressed by cancel state. Add process.stderr.write debug logging in emitResponseBoundary and adapter boundary handlers, consistent with existing logging patterns in these files.
packages/channels/base/src/AcpBridge.ts:332, packages/channels/base/src/DaemonChannelBridge.ts:591 plan sessionUpdate emits responseBoundary unconditionally (no status guard unlike tool_call). If the agent's final action is todo_write preceded by response text, all text is cleared and prompt() returns "". Consider adding a guard — e.g., only emit boundary when plan entries actually change, or track whether text has been emitted since the last boundary and skip if so.

— qwen3.7-max via Qwen Code /review

this.activePromptControllers.delete(sessionId);
}

private emitResponseBoundary(sessionId: string): void {

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.

[Critical] DaemonChannelBridge.handlePermissionRequest() does not call emitResponseBoundary(sessionId) before emitting permissionRequest, unlike AcpBridge.requestPermission() (line ~393) which does. In daemon-mode sessions (the production path for DingTalk, the channel named in issue (6602)), intermediate text chunks emitted before a permission prompt will NOT be cleared and will leak into the final channel response.

Suggested change
private emitResponseBoundary(sessionId: string): void {
private emitResponseBoundary(sessionId: string): void {
this.emit('responseBoundary', sessionId);
}

Add this.emitResponseBoundary(sessionId); in handlePermissionRequest() before the this.emit('permissionRequest', ...) call, mirroring the AcpBridge implementation. Also add a corresponding daemon test mirroring the ACP permission boundary test.

— qwen3.7-max via Qwen Code /review

releaseHeldChunks();
}
};
const onResponseBoundary = (sid: string) => {

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.

[Critical] Adapter-level state is not cleared on response boundary. The boundary clears heldChunks here, but adapters with onResponseChunk overrides (QQBot's streamState.buffer, Feishu's cardState.accumulatedText) accumulate intermediate text in their own state. When onResponseComplete fires, QQBot uses state.buffer (all accumulated text including intermediate) instead of fullText (boundary-filtered final text). For non-block-streaming adapters (the default for QQBot and Feishu), users still receive "Let me search. Now I will read. Final answer." instead of just "Final answer." — the bridge-level fix is bypassed at the adapter layer.

Consider adding a protected onResponseBoundary() hook to ChannelBase that adapters can override to clear their own accumulated state, and invoke it from this handler. Alternatively, have onResponseComplete prefer fullText over state.buffer when a boundary was observed during the turn.

— qwen3.7-max via Qwen Code /review

return;
}
heldChunks.length = 0;
};

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.

[Critical] onResponseBoundary unconditionally clears heldChunks without checking promptState.cancelPending. During the cancel-pending window, onChunk correctly holds chunks (guarded by if (!promptState.cancelPending) releaseHeldChunks()), but if a responseBoundary fires while cancel is in flight (e.g., the agent emits a pending tool_call before the cancel RPC settles), heldChunks is wiped. If the cancel then fails, releaseHeldChunks() replays an empty array — text that should have been delivered to the user is silently lost.

Apply the same cancelPending guard at both onResponseBoundary locations (line ~795 and ~3558):

Suggested change
};
const onResponseBoundary = (sid: string) => {
if (sid !== sessionId || promptState.cancelled || promptState.cancelPending) {
return;
}
heldChunks.length = 0;
};

When the cancel succeeds, chunks are discarded via cancelled anyway; when it fails, they're preserved for replay.

— qwen3.7-max via Qwen Code /review

return;
}
heldChunks.length = 0;
streamer?.stop();

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.

[Critical] onResponseBoundary clears heldChunks at the ChannelBase level but does not notify adapters to reset their own text accumulators. In non-block-streaming mode, releaseHeldChunks() feeds adapter overrides via this.onResponseChunk():

  • Feishu: cardState.accumulatedText (FeishuAdapter.ts:905)
  • QQBot: streamState.buffer (QQChannel.ts:494)

When the boundary fires, heldChunks is cleared but the adapter's accumulated intermediate text persists. The final response from bridge.prompt() is then delivered via onResponseComplete — the user sees both the intermediate card text and the final answer, which is the exact bug this PR aims to fix leaking through a different path.

In block-streaming mode this is not an issue: Feishu's onResponseChunk returns early (if (this.config.blockStreaming === 'on') return) and QQBot similarly skips.

Suggested fix: Add an adapter-level reset hook (e.g., a protected onResponseBoundary(sessionId) method on ChannelBase that adapters can override) and call it from the boundary handler. Feishu should reset cardState.accumulatedText; QQBot should reset streamState.buffer. This applies to both onResponseBoundary locations.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Suggestions — commit 0cf0112837dcad54a5cbb0fc2bbd7e722907e614

File Issue Suggested fix
scripts/tests/qwen-autofix-workflow.test.js:307 Unrelated autofix workflow test changes bundled into commit cf18ad4d5 ("fix(channels): align daemon response boundaries"). These assertions about concurrency group naming and issue routing have nothing to do with the responseBoundary event system. Move into a separate commit or PR with its own commit message.
DaemonChannelBridge.ts:566 tool_call_update with status: 'pending' fires responseBoundary identically to tool_call. A tool_call_update with pending status is a status regression, not a new turn boundary — it could clear legitimate text produced between updates. Scope boundary emission to type === 'tool_call' only: if (type === 'tool_call' && event.status === 'pending').
AcpBridge.ts:315 AcpBridge handles only tool_call (not tool_call_update) while DaemonChannelBridge handles both. This pre-existing asymmetry is now more consequential: a missing tool_call_update means the text buffer is never cleared at that boundary. Add case 'tool_call_update': to AcpBridge's switch, mirroring DaemonChannelBridge.
ChannelBase.ts:795 No test for the channel loop path's onResponseBoundary handler (only the block-streaming path at line ~3558 is tested). Add a test in the channel loop test section that emits responseBoundary and verifies heldChunks are cleared.
AcpBridge.ts:400 Zero logging for responseBoundary emission and chunk clearing. When debugging empty or truncated channel responses, there's no trace of whether the boundary fired or how many chunks were discarded. Add process.stderr.write with session ID and discarded chunk count in emitResponseBoundary.

— qwen3.7-max via Qwen Code /review

Comment thread packages/channels/base/src/AcpBridge.ts Outdated
status: (update['status'] as string) || 'pending',
rawInput: update['rawInput'] as Record<string, unknown> | undefined,
};
if (event.status === 'pending') {

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.

[Critical] Auto-approved tools bypass the response boundary. Session.ts:5113-5117 emits tool_call with status: 'in_progress' for auto-approved tools (YOLO mode, AUTO_EDIT, L3/L4 allowlist). Since the boundary only fires on status === 'pending', intermediate text is never cleared in YOLO/auto mode — the exact bug from #6602 persists.

The same gap exists in DaemonChannelBridge.ts:585.

Suggested change
if (event.status === 'pending') {
if (event.status === 'pending' || event.status === 'in_progress') {
this.emitResponseBoundary(sessionId);
}

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot 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.

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

@wenshao

wenshao commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Please drop this PR's edit to scripts/tests/qwen-autofix-workflow.test.js and take main's

#6608 merged as 55311c3b5 and fixed the stale autofix test at the source: it reworked .github/workflows/qwen-autofix.yml so an issues: assigned event feeds the existing label_is_trigger gate instead of bypassing it, and it kept the guard assertion that #6609 (a6902c5d4) had broken. main is now green on that suite:

$ git worktree add /tmp/probe origin/main --detach   # 55311c3b5
$ npx vitest run scripts/tests/qwen-autofix-workflow.test.js --config scripts/tests/vitest.config.ts
  Tests  48 passed (48)

This branch still carries its own edit to that file, inside cf18ad4d5 (fix(channels): align daemon response boundaries), bundled with the DaemonChannelBridge changes. That edit no longer matches the workflow on main. Running this PR's version of the test against the current main workflow:

× qwen-autofix workflow > keeps label-triggered issue routing guarded and diagnosable
AssertionError: expected 'name: \'Qwen Autofix\'…' to contain 'issue #${ISSUE_NUMBER} assigned to ${AUTOFIX_BOT} → issue phase'
  Tests  1 failed | 47 passed (48)

It also conflicts textually — merging this branch into 55311c3b5 leaves scripts/tests/qwen-autofix-workflow.test.js in conflict.

Worth flagging the substance and not just the conflict: the assertion this PR deletes — expect(routeStep).not.toContain('ROUTE_ISSUE="${ISSUE_NUMBER}"') — is a security guard. It asserts that the route step never derives a routing target from an event-supplied issue number. #6609 broke it by adding a path that, in its own comment, "bypasses label gates". Relaxing the test to accept that ratifies the bypass; #6608 removed the bypass instead.

Suggested resolution — after picking up main, just take main's copy of the file:

git fetch origin main
git merge origin/main          # or: git rebase origin/main
git checkout origin/main -- scripts/tests/qwen-autofix-workflow.test.js

Nothing else in this PR touches the autofix workflow, so this should be a clean removal.

中文版

#6608 已合入(55311c3b5),它从根源上修好了这个测试:把 issues: assigned 事件改为走已有的 label_is_trigger 门禁,而不是绕过它,并保留了被 #6609a6902c5d4)破坏的守卫断言。当前 main 上该套件 48/48 全绿。

本 PR 里仍带着自己对该文件的改动(在 cf18ad4d5 中,与 DaemonChannelBridge 的改动混在一起)。把本 PR 的测试版本对着当前 main 的 workflow 跑,会失败(1 failed / 47 passed),并且合入 55311c3b5 时该文件会冲突。

更重要的是:本 PR 删掉的 expect(routeStep).not.toContain('ROUTE_ISSUE="${ISSUE_NUMBER}"') 是一条安全守卫断言——它保证 route 步骤不会用事件传入的 issue 号作为路由目标。#6609 加的那条路径注释里明确写着 "bypasses label gates"。放松测试等于追认这个绕过;#6608 的做法是把绕过去掉。

建议:合并/rebase main 后,直接取 main 的版本:
git checkout origin/main -- scripts/tests/qwen-autofix-workflow.test.js

# Conflicts:
#	scripts/tests/qwen-autofix-workflow.test.js
if (!inboundMsgId) return;
const cardState = this.cardSessions.get(inboundMsgId);
if (!cardState || cardState.stopped) return;
cardState.accumulatedText = '';

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.

[Critical] onResponseBoundary clears accumulatedText but does not clear pendingUpdateTimer. Every other card-reset path (onResponseComplete L1226, handleStop L1791, cleanup L1866) clears this timer. When the boundary fires while a throttled card update is pending (up to 1500ms), the timer callback reads the now-empty accumulatedText and calls updateCard with empty content — the streaming card flashes blank before the final answer arrives.

Suggested change
cardState.accumulatedText = '';
protected override onResponseBoundary(
_chatId: string,
sessionId: string,
): void {
if (this.config.blockStreaming === 'on') return;
const inboundMsgId = this.sessionToInboundMsg.get(sessionId);
if (!inboundMsgId) return;
const cardState = this.cardSessions.get(inboundMsgId);
if (!cardState || cardState.stopped) return;
if (cardState.pendingUpdateTimer) {
clearTimeout(cardState.pendingUpdateTimer);
cardState.pendingUpdateTimer = undefined;
}
cardState.accumulatedText = '';
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f41e881. The response boundary now cancels and clears the pending card update timer before clearing accumulated text. Verified with cd packages/channels/feishu && npx vitest run src/adapter.test.ts (69 passed) and npm run build.

}
this.streamState.delete(sessionId);
this.pendingStreamDelete.delete(sessionId);
this.flushedSessions.delete(sessionId);

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.

[Critical] onResponseBoundary omits flushingSessions cleanup. Compare with onSessionDied (L739) which correctly clears all four maps. When an idle-flush send is in flight at boundary time, the stale flushingSessions entry causes onResponseComplete (L717) to defer, and the old send's .finally() identity guard skips cleanup — flushingSessions becomes permanently stuck and the final response is never delivered.

Suggested change
this.flushedSessions.delete(sessionId);
protected override onResponseBoundary(
_chatId: string,
sessionId: string,
): void {
const state = this.streamState.get(sessionId);
if (state?.timer) {
clearTimeout(state.timer);
}
this.streamState.delete(sessionId);
this.flushingSessions.delete(sessionId);
this.pendingStreamDelete.delete(sessionId);
this.flushedSessions.delete(sessionId);
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f41e881. The response boundary now clears the in-flight flush marker with the rest of the QQ stream state. Verified with cd packages/channels/qqbot && npx vitest run src/stream.test.ts (43 passed) and npm run build.

@yiliang114

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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, looks ready to ship. ✅

@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

@wenshao
wenshao added this pull request to the merge queue Jul 10, 2026
Merged via the queue into QwenLM:main with commit 59bba8e Jul 10, 2026
81 of 84 checks passed

@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. 引入 responseBoundary 事件统一处理中间文本丢弃,比后处理干净。迭代中已修复 auto-approved tool、adapter 层状态清理、cancel-pending race 等关键问题。测试覆盖充分。

Nit: 两处 onResponseBoundary handler(~L788, ~L3537)逻辑近似(block-streaming path 多 streamer?.stop()),后续可考虑抽 helper 减少重复,不阻塞。

@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.

Reviewed — no blockers. Suggestions are inline.

: undefined,
};
if (event.status === 'pending' || event.status === 'in_progress') {
this.emitResponseBoundary(sessionId);

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] Both tool_call and tool_call_update share this case block, so when a tool transitions as tool_call(status='pending')tool_call_update(status='in_progress'), two boundaries fire. Any text chunk arriving between these events is silently discarded. AcpBridge does NOT handle tool_call_update, so this double-emission is unique to the daemon path.

Consider tracking which tool-call IDs have already emitted a boundary, or only emitting on pending status:

if (event.status === 'pending') {
  this.emitResponseBoundary(sessionId);
}

— qwen3.7-max via Qwen Code /review

@@ -383,6 +396,10 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
});
}

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] emitResponseBoundary and the clearChunks callback silently zero the chunks array with no log, counter, or diagnostic. When debugging boundary-related issues (e.g., empty responses), there is no way to distinguish "agent generated no text" from "boundaries discarded text" from "bug swallowed chunks" without attaching a debugger.

Consider adding a debug log:

private emitResponseBoundary(sessionId: string): void {
  process.stderr.write(`[${this.constructor.name}] responseBoundary for session ${sessionId}\n`);
  this.emit('responseBoundary', sessionId);
}

— qwen3.7-max via Qwen Code /review

@@ -220,6 +220,260 @@ describe('DaemonChannelBridge', () => {
bridge.stop();

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] AcpBridge has a test asserting boundary does NOT fire on completed status ("preserves text when tool calls are not pending"), but DaemonChannelBridge has its own independent handleSessionUpdate implementation with the same conditional logic. A copy-paste error or divergence in the daemon bridge's status check would go undetected.

Consider adding a test mirroring AcpBridge's "preserves text when tool calls are not pending" case, using a daemon session_update with sessionUpdate: 'tool_call' and status: 'completed'.

— qwen3.7-max via Qwen Code /review

_chatId: string,
sessionId: string,
): void {
if (this.config.blockStreaming === 'on') 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] The blockStreaming === 'on' early return is correct — BlockStreamer owns delivery in that mode — but both existing boundary tests use the default config (blockStreaming off). If someone removes or inverts this guard, card text would be wiped mid-stream during block streaming, and no unit test would catch it.

Consider adding a test with { blockStreaming: 'on' } that asserts accumulatedText is unchanged after onResponseBoundary.

— 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.

channel: AcpBridge.prompt() concatenates intermediate turn text into final response

4 participants