
qwen-code Channel Delivery Final-Only 语义修复只投递最终无工具响应块的实现剖析【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code导读qwen-code 的 Channel Delivery V1 允许定时任务、daemon Prompt 以及 Notify API 将文本投递到指定的 IM 目标。本文围绕docs/plans/2026-07-22-channel-delivery-final-only.md这一实现计划剖析最终交付修复Final-Only Repair的核心机制如何确保只有最后一个无工具调用的助手响应块被投递、成功但内容为空Empty Final时如何以skipped会话事件上报、以及 daemon 授权如何在投递前被消费以防止伪造。读完本文你将掌握 qwen-code 中 Prompt/定时任务投递语义的完整调用链、turn-local 响应块捕获的实现细节以及授权消费与 SSE 事件发布的源码级原理。一、背景为什么需要Final-Only修复Channel Delivery V1见 channel-delivery-v1.md的定位是让定时任务、daemon Prompt 和直接 Notify API 将文本发送到 Channel Worker 所持有的显式 IM 目标。投递是即时且尽力而为的——没有持久化 outbox、重试、回放或全局 final-answer 钩子。在实际运行中一个 Prompt 回合turn往往包含多次模型发送模型先输出一段我要检查一下的文字并调用工具拿到工具结果后再次输出最终给出final answer。旧实现的问题在于中间文本被累积投递回合期间的所有非思考文本块被收集在一起导致 IM 收到包含工具调用中间叙述的拼接文本空 final 被静默吞掉当最终回复为空时没有投递结果事件无法区分成功但无内容与本就不该投递。2026-07-22-channel-delivery-final-only.md这份实现计划的目标即Deliver only the final tool-free assistant response block for Prompt and scheduled turns, and report successful empty finals as an authorizedskippedsession event.对 Prompt 与定时回合只投递最后一个无工具的助手响应块并将成功的空 final 上报为经过授权的skipped会话事件。围绕该目标计划拆分为 4 个任务本文按源码实际落地形态逐一展开。二、核心语义每轮只投递最后一个无工具响应块2.1 公共契约与内部请求归一化对外公共契约中delivery是可选的顶层字段定义在 channel-delivery-v1.mdinterface ChannelDelivery { kind: channel; target: { channelName: string; type: user | chat; id: string; }; }daemon 在信任边界trust boundary将公共目标归一化为内部 worker 请求{ deliveryId, channelName, target: { type, id }, text }。其中发送给 worker 的文本必须非空且以 UTF-16 code unit 计不超过 100,000 个字符。而 Prompt / scheduled 反向控制允许携带空字符串其含义是本回合成功但没有可投递的最终答案对应skipped状态——该路径永不触达 worker IPC。这一归一化与截断逻辑在 channel-delivery.ts 中落地parseChannelDelivery(value)严格校验kind channel、target 字段完备性channelName / type / id并校验字符串长度上限MAX_CHANNEL_DELIVERY_NAME_LENGTH与MAX_CHANNEL_DELIVERY_TARGET_ID_LENGTHnormalizeChannelDeliveryText(text)超过MAX_CHANNEL_DELIVERY_TEXT_LENGTH100,000时截断并追加[Channel delivery truncated because the result exceeded the delivery size limit.]后缀截断前还会做代理对surrogate pair边界保护避免把 emoji 等 4 字节字符截成半个normalizeChannelDelivery(...)对实际发往 worker 的文本做非空校验空文本直接抛出ChannelDeliveryError(channel_delivery_invalid, ...)——这就是非空文本才走 worker IPC的强制点。2.2 执行边界Session 只捕获携带投递元数据的回合设计文档明确定时任务与 Prompt 拥有自己的 final-answer 语义。Session 只在当前调用携带投递元数据_meta中的qwen.daemon.channel-delivery时捕获文本。每次模型发送send独占一个响应块response block块内的非思考流式分片被拼接非延续性重试non-continuation retry或模型回退model fallback丢弃被取代的分片任何请求工具的块都是中间块不能成为投递负载后续的自动延续automatic continuation会替换更早的终态候选。直到整个回合到达成功的end_turnSession 才提交恰好一次反向控制请求其text为最后一个无工具助手响应块。工具之间的旁白inter-tool narration与所有更早的响应块一律排除。三、Session 侧实现Turn-Local 捕获机制3.1 捕获结构与生命周期在 Session.ts 中投递捕获被建模为AgentResponseCapture.channelDeliveryinterface AgentResponseCapture { channelDelivery?: { finalText: string; }; turnResult?: { finalText: string }; agentOutput: AgentOutputMessageCapture; }channelDelivery字段只在解析到投递元数据时创建Session.ts#L4995-L5000const channelDelivery parsePromptChannelDelivery(params); const responseCapture: AgentResponseCapture { ...(channelDelivery ? { channelDelivery: { finalText: } } : {}), ... };这就是计划中将 Session 级收集器字段替换为 turn-local capture仅在存在投递元数据时创建的落地。parsePromptChannelDelivery读取_meta[DAEMON_CHANNEL_DELIVERY_META_KEY]解析出deliveryId与target见 Session.ts#L1639-L1654。3.2 响应块的开始、追加、回滚与提交围绕响应块定义了四个纯函数Session.ts#L1586-L1637function beginChannelDeliveryResponseBlock(capture): ChannelDeliveryResponseBlock | undefined function appendChannelDeliveryResponseText(responseBlock, text): void function rewindChannelDeliveryResponseBlock(responseBlock, checkpoint): void function commitChannelDeliveryResponseBlock(capture, responseBlock, hasFunctionCalls): void关键设计点begin 时清空候选每开始一个新块capture.channelDelivery.finalText 被重置——后一个块清除前一个候选Session.ts#L1590。capChars仅在没有 channel delivery 的回合设置TURN_RESULT_TEXT_MAX_CHARS 1有投递时不做截断上限因为投递需要完整文本append 只接受非思考分片思考thought分片不进入块rewind 只回滚当前块配合非延续性重试/模型回退parts.splice(checkpoint)并同步扣减charscommit 以functionCalls.length 0为提交条件块内无工具调用才把parts.join()写入finalText有工具调用的块不会污染候选Session.ts#L1632-L1636。3.3 提交时机仅成功 end_turn 时投递一次回合成功end_turn后若存在channelDelivery则调用#scheduleChannelDelivery恰好一次Session.ts#L5081-L5092if (channelDelivery completedResult.stopReason end_turn) { this.#scheduleChannelDelivery({ sessionId: this.sessionId, deliveryId: channelDelivery.deliveryId, source: prompt, target: channelDelivery.target, text: normalizeChannelDeliveryText( responseCapture.channelDelivery?.finalText ?? , ), promptId: channelDelivery.deliveryId, }); }注意这里没有空文本守卫——即使finalText为空字符串也会提交反向控制请求这正是成功但空 final 必须区别于从未 eligible的设计。取消cancellation、Agent 错误Agent error、token 上限终止则什么都不提交。四、空 Final 的skipped语义反向控制缝Seam4.1 为什么必须先消费授权再上报 skipped计划第 3 个任务的措辞是Consume authorization before reporting skipped。原因在 channel-delivery-v1.md 中解释得很清楚The host callback consumes the authorization before deciding betweenskippedand worker delivery, so empty finals cannot forge events or leave one-shot/monotonic authorization state unchanged.host 回调在skipped与 worker 投递之间做决定之前就消费授权因此空 final 既不能伪造事件也不能让一次性/单调授权状态保持不变。这意味着即使文本为空skipped也不是在 BridgeClient 侧短路产生的而是必须经过 daemon 侧的真实 host 回调由 daemon 消费授权后返回。在 bridgeClient.ts 中反向控制请求总是调用 host并将结果归一化result normalizeChannelDeliveryHostResult( await this.hostCall(DAEMON_CHANNEL_DELIVERY_METHOD, params), );normalizeChannelDeliveryHostResultbridgeClient.ts#L289-L302对三种状态做白名单归一化delivered/skipped原样返回其余 code 收敛为channel_delivery_failed或既有的错误码集合CHANNEL_DELIVERY_ERROR_CODES中的合法值并截断 error 文本。4.2 Host 结果联合类型扩展ChannelDeliveryHostResult联合类型在 bridgeOptions.ts 中加入了skippedexport type ChannelDeliveryHostResult | { status: delivered } | { status: skipped } | { status: failed; code: ChannelDeliveryErrorCode; error: string }; export type ChannelDeliveryHandler ( info: ChannelDeliveryRequest, ) PromiseChannelDeliveryHostResult;4.3 daemon 侧绑定 Handlerdaemon 侧通过createBoundChannelDeliveryHandler将 workspace、worker manager 与授权存储绑定为一个ChannelDeliveryHandlerrun-qwen-serve.ts#L312-L390。其执行顺序严格遵循先消费授权if (!authorizations.consume(boundWorkspace, info)) { return failed(channel_delivery_invalid, Channel delivery is not authorized.); } if (info.text.trim().length 0) { return { status: skipped }; } const manager getManager(); if (!manager) { return failed(channel_worker_unavailable, Channel worker is not running.); } // ... manager.deliverChannelMessage(...) { status: delivered }三点值得注意授权消费在空文本检查之前空文本走skipped分支时授权已被消费skipped事件是有据可查的skipped在 worker 查找之前返回不 resolve 任何 worker不产生 worker IPCworker 不可用 →channel_worker_unavailablemissing / bootstrapping / draining / stopped / removed 的 owner 都返回该错误没有回退到 primary runtime也没有懒启动 worker。由于授权已消费消费后的瞬时 worker 抖动会永久丢弃这一次投递——这与即时、尽力而为、不重试的契约一致。此外failed辅助函数会写 daemon 日志日志经过sanitizeWorkerDiagnostic与sanitizeLogText脱敏且info.target.id会被redacted替换run-qwen-serve.ts#L327-L357。4.4 授权存储一次性 vs 单调状态授权由 channel-delivery-authorization.ts 中的ChannelDeliveryAuthorizationStore管理authorizePrompt(workspaceCwd, { sessionId, deliveryId, target })Prompt 准入时登记 daemon 签发的 delivery ID 与钉住的目标registerScheduledTask(...)定时投递授权来自持久化的 taskconsume(workspaceCwd, request)按source分流——prompt要求promptId deliveryId命中后从 Map删除一次性scheduled要求deliveryId ${taskId}:${firedAt} 且firedAt lastConsumedAt防重放recurring 任务推进lastConsumedAt单调一次性任务整体删除。测试 bridgeClient.test.ts 验证了空 final 场景host 返回{ status: skipped }BridgeClient 发布channel_delivery_resultSSE 事件且data: { status: skipped }bridgeClient.test.ts#L2203-L2220 则覆盖 scheduled skipped一次 skipped 触发推进 recurring 单调状态并消费一次性状态。这正是计划 Task 3 中Add scheduled coverage proving a skipped fire advances recurring monotonic state and consumes one-shot state的落点。五、SSE 事件与协议层5.1 事件发布BridgeClient 在 host 返回后发布channel_delivery_resultSSE 事件bridgeClient.ts#L1753-L1767。既有 schema 已经接受status: skipped因此协议兼容性得以保留。时序上channel_delivery_result是晚于Agent 完成事件的独立事件Prompt 准入仍为202Agent 完成仍为turn_complete/turn_errorChannel 完成是随后的channel_delivery_result永远不会把 Agent 成功转成turn_error。5.2 事件与日志的隐私边界投递结果事件与日志只包含关联标识correlation identifiers、来源source、状态与脱敏错误数据绝不包含消息文本、目标 ID、凭据或 webhook 密钥。delivered只表示 adapter 的 send Promise 已 resolve并不断言 provider 已接受消息或用户已收到/已读。5.3 能力宣告daemon 在支持相应契约与路由时宣告channel_delivery能力见 capabilities.ts。这是协议支持声明而非任何 worker 或 adapter 的实时健康断言。六、边界与不变式哪些行为明确不改实现计划列出全局约束并最终由 Task 4 的契约文档与验证收口约束说明不改变 Notify / Channel Webhook 的直接执行语义三条投递路径Prompt/scheduled、Notify、Webhook保持独立不新增持久化/outbox/重试/回放/全局 final-answer 钩子V1 无启动回放、历史扫描、幂等保证取消、Agent 错误、token 上限终止不提交任何投递结果空输出因此与本回合不可投递可区分投递失败与诊断日志与 Agent 完成隔离Channel 失败不改变turn_complete/turn_error事件与日志不含消息文本、目标 ID、凭据全部经脱敏Webhook 保持独立异步路径拥有自己的 secret 与202worker 准入契约可复用ChannelBase发送原语与错误分类但不复用Prompt/Notify 的控制流。后台通知提示background notification prompts仍是本地 Agent 工作不会自动发送到 IM。Notify 路径的映射非法输入 →400worker 不可用/占满 →503超时 →504adapter 失败 →502超时结果未知且不重试。另外已有的、未携带 delivery 的任务永远不会发送调度器既有 catch-up 行为不变合成的历史遗漏一次性批次会显式清除 delivery避免启用 Channel 后爆发旧的告警。七、验证与测试路径实现计划以先写失败测试锁定语义开局Task 1对应测试文件 Session.test.tsPrompt 测试I will inspect 工具调用 工具结果 final answer断言投递文本恰好为final answer相同场景的 scheduled 回归测试Prompt 与 scheduled 的成功空 final 测试断言恰好一次qwen/control/channel-delivery调用且text: 初版断言这些测试失败final-only 测试因累积了中间文本而失败empty-final 测试因未发生反向控制调用而失败。运行方式cd packages/cli npx vitest run src/acp-integration/session/Session.test.ts随后 Task 3 在 bridgeClient.test.ts 与 run-qwen-serve.test.ts 中补充空文本必须触发 host 调用旧短路实现会失败、bound-handler 空文本返回skipped且不 resolve worker、scheduled skipped 推进单调状态。IPC 层测试见 channel-delivery-ipc.test.ts 与 channel-delivery.test.ts。收尾验证包括npm run build、npm run typecheck、npm run lint以及真实 IM 的 Prompt/scheduled E2E带工具前奏tool preamble时确认只有最终答案到达 IM空 final 时确认发出skipped且无 provider 流量。八、总结Final-Only 修复的本质是一组纪律性极强的语义锁定捕获侧Session 以 turn-localAgentResponseCapture取代全局收集器每个模型发送一个响应块无工具块才提交后块覆盖前块end_turn时恰好提交一次授权侧ChannelDeliveryAuthorizationStore区分一次性 Prompt 授权与单调/一次性 scheduled 授权host 回调先消费、再判定空文本返回skipped前授权已被消耗杜绝伪造与状态泄漏投递侧非空文本经 100,000 UTF-16 code unit 上限截断后路由到 workspace 专属 worker 组worker 不可用即channel_worker_unavailable不降级不懒启动观测侧channel_delivery_resultSSE 事件只携带关联标识与脱敏状态delivered不夸大语义。这套实现让空 final与不可投递可被明确区分让工具调用回合只把真正的人类可读结论送到 IM同时守住即时、尽力而为、无重试的 V1 契约边界。对想要在 qwen-code 上二次开发投递能力或排查IM 收到了中间过程文本类问题的开发者本文的调用链与源码定位是最直接的入手点。【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考