diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index f4951d6397..88825cb50d 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -214,7 +214,7 @@ jobs: run: | $tests = @( @{Name="chatcmd-auth"; Filter="FullyQualifiedName~ChatCommandAuthExportCoverageTests"}, - @{Name="errors-permission"; Filter="FullyQualifiedName~ApiErrorRecoveryE2ETests|FullyQualifiedName~StreamInterruptionE2ETests|FullyQualifiedName~PermissionDenialE2ETests"} + @{Name="errors-permission"; Filter="FullyQualifiedName~ApiErrorRecoveryE2ETests|FullyQualifiedName~StreamInterruptionE2ETests|FullyQualifiedName~PermissionDenialE2ETests|FullyQualifiedName~StreamingIncrementalE2ETests"} ) foreach ($t in $tests) { $name = $t.Name diff --git a/docs/proposed/adr/component_strategy/0146-streaming-incremental-passthrough-fallback-boundary.md b/docs/proposed/adr/component_strategy/0146-streaming-incremental-passthrough-fallback-boundary.md new file mode 100644 index 0000000000..3e5a6a802d --- /dev/null +++ b/docs/proposed/adr/component_strategy/0146-streaming-incremental-passthrough-fallback-boundary.md @@ -0,0 +1,44 @@ +# ADR 0146 — 流式增量透传与 fallback 首事件边界 + +> 状态:accepted(2026-10-22 落地:单元红→绿 + llm.tests 605 全绿 + 实弹 E2E StreamingIncrementalE2ETests 绿,随 TASK043 实施) +> 关联:TASK043(modules/llm/docs/task/TASK043-流式增量透传与fallback边界任务.md)、ADR 0080(手动 exe 验收) + +## 上下文 + +`StreamingFallbackDecorator.GetStreamEventContentsAsync` 因 C# 迭代器限制(CS1626/CS1631:try-catch 内禁止 `yield return`)采用「全量收集后整发」实现:`CollectWithFallbackAsync` 把整条流收进 `List` 再统一 yield。后果(TASK043 真机实证): + +- **逐字流式完全失效**:GUI/TUI 消费方在流结束前收不到任何事件(700 分片 14s 流,10s 采样文本长度恒定) +- **首字延迟 = 整轮生成时间**(长回答可达数十秒) +- **停止语义异常**:取消瞬间累积正文批量闪现(2423 字一次性补发) +- 放大面:`ServiceRegistration` 默认 `StreamingFallbackConfig.Enabled=true` 包装**全部**流式调用 + +## 决策 + +**两阶段结构(预热可 fallback / 透传不可 fallback)+ 手动 enumerator 消除 CS1626**: + +1. **预热阶段(TryPrimeAsync,非迭代器可 try-catch)**:拉取首个上游事件。此阶段失败(529/超时/IOException/看门狗超时/空流 IncompleteStream)→ 仍走非流式 fallback——对齐真实场景:连接建立期错误拿不到任何事件,降级重试无损(消费方零感知)。 +2. **透传阶段(迭代器,仅 try-finally 禁 try-catch)**:`yield return` 首事件后逐个 `MoveNextAsync` + `yield`。**已向消费方产出后不再 fallback**——中途失败/看门狗超时/用户取消 OCE 全部上抛(已 yield 内容无法回收,非流式重试会造成重复输出)。 +3. **所有权转移**:预热成功时 watchdog + enumerator 从 TryPrimeAsync 转移给调用方,由迭代器 `finally`(finally 中 `await DisposeAsync` 合法)统一释放;fallback/取消路径由 TryPrimeAsync 自释放。 +4. 保留 `LastRequestFellBack` / `OnStreamingFallback` 回调语义;`Enabled=false` 直通路径不变。 + +### 语义变化(有意为之,无后向兼容负担) + +| 场景 | 旧(全量收集) | 新(两阶段) | +|------|--------------|-------------| +| 首事件前失败 | fallback 非流式 | fallback 非流式(不变) | +| 中途失败 | fallback(丢弃已收部分) | **上抛**(已产出保留) | +| 用户取消(透传中) | 吞 OCE 返回已收集事件 | **OCE 上抛**(标准流式语义;GUI 实时收增量后不再依赖批量补发) | +| 看门狗超时(透传中) | fallback | **上抛** | +| 空流 | fallback(IncompleteStream) | fallback(IncompleteStream)(不变) | + +## 替代方案(弃用理由) + +- **① 消费方侧加缓冲窗口**:治标——缓冲解除"整轮不出现"但首字延迟仍在;每消费方各自实现,重复逻辑。 +- **② 取消 fallback 装饰器**:丢失降级能力(529 过载/网关不支持流式 404 等真实场景需要非流式重试)。 +- **③ 全流 fallback + 中途失败也重试**:已 yield 内容与新流拼接会重复输出,消费方需去重——复杂度爆炸。 + +## 验证 + +- 单元红→绿:首事件在上游流完成前到达(门闩确定性时序,禁 Task.Delay);中途失败上抛且已 yield 事件保留、LastRequestFellBack=false;首事件前失败仍 fallback(现有用例改"失败于首事件前"构造——对齐连接级错误真实形态) +- 回归:llm.tests 全量;现有 fallback 触发用例(529/IOException/超时/空流)语义保留 +- 真机 E2E:f1-webui-flow.mjs 15/15(留用户/CI 验证) diff --git "a/docs/proposed/task/TASK063-\346\227\240\344\272\272\345\200\274\345\256\210\346\211\271\346\254\241P7P8\344\270\216TASK043\346\265\201\345\274\217\347\274\272\351\231\267.md" "b/docs/proposed/task/TASK063-\346\227\240\344\272\272\345\200\274\345\256\210\346\211\271\346\254\241P7P8\344\270\216TASK043\346\265\201\345\274\217\347\274\272\351\231\267.md" new file mode 100644 index 0000000000..0f555773c0 --- /dev/null +++ "b/docs/proposed/task/TASK063-\346\227\240\344\272\272\345\200\274\345\256\210\346\211\271\346\254\241P7P8\344\270\216TASK043\346\265\201\345\274\217\347\274\272\351\231\267.md" @@ -0,0 +1,123 @@ +# TASK063 — 无人值守批次:P7/P8 收尾 + TASK043 流式透传缺陷 + +> 状态:**implemented(2026-10-22 无人值守批次全部完成——提交链 853d9e75b(P7) / c3c5ec77a(P8) / a9236b754(E))** +> 模式:无人值守(用户睡眠)——主路径→边缘场景顺序推进,不停批不询问,每任务走「文档→代码→测试→真实运行验证→提交」全流程 +> 唯余:F 用户真机验收清单(见文末,醒后执行) + +## 任务池盘点(2026-10-22 核实) + +| 任务 | 核实结果 | 证据 | +|------|---------|------| +| ~~A 结构化消息格式~~ | **✅ 已 implemented 勿重做** | docs/proposed/task/TASK062-结构化消息设计.md 状态 implemented(817+64 全绿,2026-10-21 落地);唯余用户真机验收(思考模型场景) | +| ~~D DecidePreflight 接线~~ | **✅ 已接线勿重做** | QueryLoopMiddleware.cs:130-133/222-223(TASK061 批次落地) | +| **B TASK062 P7/P8** | 待做(本批第 1 项) | docs/proposed/task/TASK062-引擎侧问题登记与守卫强化.md P7/P8 悬置 | +| **E TASK043 流式透传缺陷** | 待做(本批第 2 项) | ~2s TaskCanceledException + StreamingFallbackDecorator 全量收集根因;CI E2E 白名单登记(PR #435) | +| **F 用户真机验收汇总** | 留用户(醒后执行) | 见文末验收清单 | + +## 任务 B:TASK062 P7 + P8(收尾完善) + +### P7 — tokenGain 真实值(子代理工具活动事件 token 增量) + +**现状**:`SessionController.RouteSubagentEvent` 的工具事件分支 tokenGain 恒 0 兜底(批1 实施时 ChatStreamEvent 无 token 语义字段)。 + +**调研先行**(动手前核实数据源): +- 引擎侧候选:子代理 ProgressTracker.RecordTokenUsage 原子累加(AgentServiceImpl.GetAgentProgressAsync 可查)/ ChatStreamEvent.Complete.Usage(仅完成态有总额)/ AgentStreamChunk.Usage(仅 Complete chunk) +- 选型判据:逐活动增量的可得性 vs 完成时总额补发(协议 SubagentFinished 无 token 字段需扩展) + +**实施方向**(按调研结论二选一): +1. 逐活动增量:工具事件发射点取该子代理当前 token 累计,消费侧维护上次值取差 +2. 完成态总额:SubagentFinished 扩展 TokenTotal 字段(协议+双消费者+TUI 统计列接线——组统计 TokenCount 已有累计语义,直接对齐) + +**验收标准**: + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| token 数据源接线(完成态权威校正) | RouteSubagentEvent / SubagentFinished 投影 | ✅ 853d9e75b | ✅ 单元:BuildCompletionChunk usage 透传×2 + SubagentGroupTests 权威校正(200→350 覆盖/0 保持) | +| 协议字段(方案2 TokenTotal) | BridgeEvent.SubagentFinished + 双消费者 + BridgeJsonContractTests | ✅ | ✅ JSON 快照锁定(tokenTotal:1250) | +| E2E 集成断言 | SubAgentEventMainAgentPathE2ETests | ✅ | ✅ 实弹 E2E:tokenTotal>0 断言通过(连带根因双修:QueryEngine 孤儿 key 改读 provider 统一 metadata["Usage"] + 跨轮累计) | + +### P7 实施记录(2026-10-22) + +- **选型落定方案2(完成态权威校正)**:引擎逐活动 token 增量不存在(RecordTokenUsage 仅完成时调用),AgentFinished 携带 Usage.TotalTokens 为权威源 +- **E2E 红暴露双根因**:①QueryEngine 读孤儿 key `Metadata["InputTokens"/"OutputTokens"]`(全仓无生产者——OpenAI/Anthropic QueryService 统一写 `metadata["Usage"]` TokenUsage JSON)②BuildCompletionChunk 不带 usage 参数 +- **修复**:QueryEngine 改读 metadata["Usage"](对齐 ChatStreamChunkProcessor 先例反序列化)+ 主循环跨轮累计(metadata 未提供时保持 null 禁伪 0)+ BuildCompletionChunk 补 usage 参数 +- **意外收获**:ChatService 路径 E2E(SubAgentEventStreamE2ETests,TASK043 嫌疑红)连带转绿——3/3 全绿 + +### P8 — Completion Run 前占位语义(边界守卫) + +**现状**:`McpPluginBootstrapHandle.Completion` Run 前返回 Task.CompletedTask——DI 有 Handle 但 McpInitModule 未跑 ConfigureAsync 的极端组装下,消费方 await Completion 立即返回误判"就绪"。 + +**实施方向**:契约文档化 + 守卫测试补强(Run 未调用时 Completion 应表达"无可等之物"而非"就绪"——语义边界写进 xml 注释与专用测试;如需行为修正则加 `_started` 门闩等待)。 + +**验收标准**: + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| Completion 契约明确(注释+测试) | E2E/宿主等待方 | ✅ c3c5ec77a | ✅ 守卫测试:Run 前占位立即完成(无可等之物≠就绪)+ Run 后 Completion 切换为链 Task 实例(引用变更守卫防退化恒假完成);13/13 绿 | + +### P8 实施记录(2026-10-22) + +- 契约明确化而非语义变更(Run 前占位是设计决定):xml 注释写全"无可等之物 ≠ 就绪"+ 误用形态(Run 前假就绪→空工具表→Tool not found 假象)+ 正确写法诱导(对齐 AGENTS 规则9) +- 新增契约锁测试 `Completion_BeforeRun_Placeholder_NotReadinessSignal`(Assert.NotSame 守卫引用切换) + +## 任务 E:TASK043 流式透传缺陷 + +**已知症状**(记忆线索): +1. ~2s `TaskCanceledException`——连接/发送级取消非 100s 整体超时(流式 HttpClient 路径) +2. StreamingFallbackDecorator **全量收集后整发**——流式增量透传断裂(GUI 会话流 E2E 曾见"流中串话/超时回错") +3. CI E2E 白名单登记(PR #435 提交说明明示) + +**调研先行**: +- 定位 StreamingFallbackDecorator(GUI 侧装饰器)与流式链路(QueryEngine→IChatService 流式管道→消费端) +- 复现路径:MockServer 流式脚本(chunked 逐段返回)→ 断言消费端逐段收到(非全量后到) +- ~2s 取消:HttpClient 配置链(连接超时/首个 chunk 超时)排查 + +**验收标准**: + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| 流式增量透传修复 | StreamingFallbackDecorator(两阶段重构 ADR 0146) | ✅ a9236b754 | ✅ 单元:门闩确定性增量红→绿 + 中途失败上抛守卫;实弹 E2E:StreamingIncrementalE2ETests(MockServer 50 分片×20ms,首末 Content 时间差反映真实流时长) | +| ~2s 取消根因修复(若复现) | HttpClient 流式配置 | ✅ 未复现 | ✅ StreamInterruption/ApiErrorRecovery E2E 全绿(含 P7 metadata 修复后 3/3 子代理 E2E 连带转绿——原嫌疑症状消失) | +| CI 白名单 | ci-e2e 配置 | ✅ | ✅ 新 E2E 已进 errors-permission 组白名单持续跑(TASK053 F1-x 真机脚本未入库,以实弹 E2E 等价覆盖) | + +### E 实施记录(2026-10-22) + +- **ADR 0146**(proposed→accepted):两阶段结构 = 预热(TryPrimeAsync 拉"首事件",非迭代器可 try-catch,失败仍 fallback——连接级错误无损降级)+ 透传(迭代器仅 try-finally,已 yield 后失败上抛防重复输出);watchdog/enumerator 所有权经 PrimeOutcome 转移由 finally 统一释放 +- **语义变化**(有意,无后向兼容负担):中途失败 fallback→上抛;透传期用户取消吞 OCE 返已收事件→标准 OCE 上抛 +- **测试改造**:现有 fallback 用例(529/超时/IOException/空流)从"先 partial 后 throw"构造切到 FailAtStart 首事件前失败形态(对齐真实连接错误);新增门闩增量红测试 + 中途上抛守卫 +- **回归**:llm.tests 605 + brain.other 412 + brain.context 933 + host.tests 1382 + E2E(子代理 3 + StreamInterruption + ApiErrorRecovery)11 全绿 + +## 任务 F:用户真机验收清单(醒后执行) + +| # | 验收点 | 来源任务 | +|---|--------|---------| +| 1 | agents 场景真实引擎 spawn 分组显示(agents / agents 300) | DSG041 | +| 2 | Shift+↑ 进入消息导航→↑↓ 导航→Enter 展开分组→Esc 两级退出 | 交互层 Step1+2 | +| 3 | 思考模型场景:`⎿ 思考 3.2s · ctrl+o 展开推理` + Ctrl+O 展开 CoT | 结构化消息 | +| 4 | Host.exe 启动提速体感(~1.6s)+ 退出零噪音 | 启动优化 A 项 | + +## 执行顺序与无人值守说明 + +按 B → E → F(F 留用户)顺序推进。B 完成即编译+测试+提交;E 调研先行(StreamingFallbackDecorator 定位+复现)后分批实施。每任务完成更新本文档验收表。遇不可逆操作(删除/push 冲突)按 AGENTS.md 规则挂起留用户,其余自主决策。 + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs index 62583a75ef..1068bdb32f 100644 --- a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs +++ b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs @@ -69,7 +69,10 @@ void OnSubagentToolUse(string subagentId, string toolName, string summary, int t /// /// 子代理标识 /// 是否失败 - void OnSubagentFinished(string subagentId, bool isError) { } + /// 子代理总令牌消耗(TASK062 P7 — AgentFinished 事件 Usage.TotalTokens 权威值; + /// 完成态一次性校正优于逐活动增量 — 引擎数据流中 token 仅回合/完成粒度存在(RecordTokenUsage 亦完成时调用); + /// 0 = 无 usage 数据(FinalizePending 等兜底路径),消费方保持原累计值) + void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) { } } /// diff --git a/modules/apps/engine/adapters/BridgeEventConsumer.cs b/modules/apps/engine/adapters/BridgeEventConsumer.cs index b3fb501f1d..8b8e4d0f54 100644 --- a/modules/apps/engine/adapters/BridgeEventConsumer.cs +++ b/modules/apps/engine/adapters/BridgeEventConsumer.cs @@ -88,9 +88,9 @@ public void OnSubagentToolUse(string subagentId, string toolName, string summary WriteEvent(new Cli.Bridge.BridgeEvent.SubagentToolUse(subagentId, toolName, summary, tokenGain)); } - /// 子代理完成 → subagent_finished(条目定格,isError 聚合组头)。 - public void OnSubagentFinished(string subagentId, bool isError) { - WriteEvent(new Cli.Bridge.BridgeEvent.SubagentFinished(subagentId, isError)); + /// 子代理完成 → subagent_finished(条目定格,isError 聚合组头;tokenTotal 完成态权威校正)。 + public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) { + WriteEvent(new Cli.Bridge.BridgeEvent.SubagentFinished(subagentId, isError, tokenTotal)); } /// 思考结束结算(进入产出或回合结束时调用,幂等)。 diff --git a/modules/apps/engine/adapters/SessionController.cs b/modules/apps/engine/adapters/SessionController.cs index 026475a18f..0ce87479d4 100644 --- a/modules/apps/engine/adapters/SessionController.cs +++ b/modules/apps/engine/adapters/SessionController.cs @@ -243,7 +243,7 @@ private bool RouteSubagentEvent(ChatStreamEvent evt) { _consumer.OnSubagentStarted(agentId, evt.AgentName ?? string.Empty, evt.AgentDescription ?? string.Empty); return true; case ChatStreamEventType.AgentFinished: - _consumer.OnSubagentFinished(agentId, isError: evt.AgentSuccess is not true); + _consumer.OnSubagentFinished(agentId, isError: evt.AgentSuccess is not true, tokenTotal: evt.Usage?.TotalTokens ?? 0); return true; case ChatStreamEventType.ToolCallStart: _consumer.OnSubagentToolUse(agentId, evt.ToolName ?? string.Empty, evt.ToolArguments ?? string.Empty, tokenGain: 0); diff --git a/modules/apps/engine/app/modules/McpPluginBootstrap.cs b/modules/apps/engine/app/modules/McpPluginBootstrap.cs index 49b61a7530..b661cb717f 100644 --- a/modules/apps/engine/app/modules/McpPluginBootstrap.cs +++ b/modules/apps/engine/app/modules/McpPluginBootstrap.cs @@ -86,8 +86,13 @@ public void Run(Func background) _background = background(_cts.Token); } - /// 插件就绪完成信号(邮箱/通知语义)— Run 前为已完成 Task(无可等之物); - /// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。消费方等此信号确认工具注册就绪, + /// 插件就绪完成信号(邮箱/通知语义)— Run 前为占位 Task.CompletedTask(无可等之物 ≠ 就绪!); + /// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。 + /// ⚠️ 契约(TASK063 P8 文档化):Run 前 await 此属性立即通过,但此时插件链尚未启动 — + /// 误用形态:DI 解析后立刻 await Completion(Run 由 ConfigureAsync 稍后调用)→ 假就绪 → + /// 查工具表只剩同步注册子集(gh_pr_* 缺失)→ "Tool not found" 假象。 + /// ✅ 正确写法:等待方必须确保 Run 已发生(宿主启动序列 ConfigureAsync 先于消费方查询, + /// 或消费方经宿主协调拿到"已 Run"保证);E2E 等就绪在宿主启动完成后 await。 /// 替代时间性等待(Task.Delay 固定延时在 CI 快慢机上均不可靠)。两类消费方: /// E2E/宿主等就绪;CLI 单次命令(gh/mcp_call/mcp_list)在查询工具前等待 — /// 后台化后单次进程不等插件加载完成即查 registry,工具表只剩同步注册的 15 个(gh_pr_* 全缺失); diff --git a/modules/apps/engine/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs index 18d570a4dd..7d0b1394e7 100644 --- a/modules/apps/engine/bridge/BridgeProtocol.cs +++ b/modules/apps/engine/bridge/BridgeProtocol.cs @@ -123,8 +123,9 @@ public sealed record SubagentStarted(string SubagentId, string AgentType, string /// 子代理工具活动(DSG041 批3 — 原地更新组条目不逐条刷屏;TokenGain 为本次活动新增令牌)。 public sealed record SubagentToolUse(string SubagentId, string ToolName, string Summary, int TokenGain) : BridgeEvent; - /// 子代理完成(DSG041 批3 — 条目定格,isError 聚合组头 ● 红)。 - public sealed record SubagentFinished(string SubagentId, bool IsError) : BridgeEvent; + /// 子代理完成(DSG041 批3 — 条目定格,isError 聚合组头 ● 红;TokenTotal = 总令牌消耗 + /// 完成态权威校正 TASK062 P7 — 与 native_tui 侧逐字段镜像)。 + public sealed record SubagentFinished(string SubagentId, bool IsError, int TokenTotal) : BridgeEvent; /// 完整助手消息(追加滚动区)。 public sealed record AssistantMessage(string Text) : BridgeEvent; diff --git a/modules/cognition/brain.other.tests/query/core/QueryEngineTests.cs b/modules/cognition/brain.other.tests/query/core/QueryEngineTests.cs index 8c3d7b7653..d8f1f78a53 100644 --- a/modules/cognition/brain.other.tests/query/core/QueryEngineTests.cs +++ b/modules/cognition/brain.other.tests/query/core/QueryEngineTests.cs @@ -268,6 +268,24 @@ public void BuildCompletionChunk_WithNullCacheSafeParams_ShouldAllowNull() { chunk.CacheSafeParams.Should().BeNull(); } + [Fact] + public void BuildCompletionChunk_ShouldCarryUsage_ForSubagentTokenCorrection() { + var usage = new JoinCode.Abstractions.LLM.Chat.TokenUsage(promptTokens: 300, completionTokens: 50); + var chunk = QueryEngine.BuildCompletionChunk("done", 100L, 0, 0m, null, usage); + chunk.Usage.Should().BeSameAs(usage); + if (chunk.Usage is { } carried) { + carried.TotalTokens.Should().Be(350); + } else { + throw new Xunit.Sdk.XunitException("Usage 应随 Complete chunk 携带(P7 权威校正数据源断裂)"); + } + } + + [Fact] + public void BuildCompletionChunk_WithoutUsage_ShouldKeepNull() { + var chunk = QueryEngine.BuildCompletionChunk("done", 100L, 0, 0m, null); + chunk.Usage.Should().BeNull(); + } + [Fact] public void IsRetryable_HttpRequestException_ShouldReturnTrue() { IsRetryableWithEnvCleared(new HttpRequestException()).Should().BeTrue(); diff --git a/modules/cognition/brain/query/query2/token_budget/core/QueryEngine.cs b/modules/cognition/brain/query/query2/token_budget/core/QueryEngine.cs index fea77d2246..fdc884dcb3 100644 --- a/modules/cognition/brain/query/query2/token_budget/core/QueryEngine.cs +++ b/modules/cognition/brain/query/query2/token_budget/core/QueryEngine.cs @@ -150,6 +150,8 @@ public async IAsyncEnumerable QueryAsync( /// private async Task ExecuteCoreLoopAsync(QueryMiddlewareContext context, CancellationToken cancellationToken) { var retryCount = 0; + var totalInputTokens = 0; + var totalOutputTokens = 0; while (context.TotalToolCalls < _config.MaxToolCallIterations) { cancellationToken.ThrowIfCancellationRequested(); @@ -192,9 +194,11 @@ private async Task ExecuteCoreLoopAsync(QueryMiddlewareContext context, Cancella context.OutputChunks.Add(chunk); } - // 更新上下文的 Token 使用信息 + // 更新上下文的 Token 使用信息(最后一轮值 — 钩子消费语义不变)+ 跨轮累计(P7 权威校正数据源) context.InputTokens = iterationResult.InputTokens; context.OutputTokens = iterationResult.OutputTokens; + totalInputTokens += iterationResult.InputTokens; + totalOutputTokens += iterationResult.OutputTokens; context.ToolName = iterationResult.ToolCall?.ToolName; context.HasToolCall = iterationResult.ToolCall is not null; @@ -216,7 +220,11 @@ private async Task ExecuteCoreLoopAsync(QueryMiddlewareContext context, Cancella _logger?.LogInformation("[QueryEngine] 查询完成,耗时 {ElapsedMs}ms", context.Stopwatch.ElapsedMilliseconds); var content = iterationResult.Content ?? string.Empty; - context.OutputChunks.Add(BuildCompletionChunk(content, context.Stopwatch.ElapsedMilliseconds, context.TotalToolCalls, context.TotalCostUsd, BuildCacheSafeParams(context))); + // P7:累计 usage 随 Complete chunk 透传 — metadata 未提供 token 数时保持 null(禁伪 0) + var usage = (totalInputTokens > 0 || totalOutputTokens > 0) + ? new JoinCode.Abstractions.LLM.Chat.TokenUsage(totalInputTokens, totalOutputTokens) + : null; + context.OutputChunks.Add(BuildCompletionChunk(content, context.Stopwatch.ElapsedMilliseconds, context.TotalToolCalls, context.TotalCostUsd, BuildCacheSafeParams(context), usage)); return; } @@ -295,22 +303,24 @@ internal static QueryStreamChunk BuildRetryExhaustedErrorChunk(int maxRetries) { } /// - /// 构建查询完成的 chunk — 纯函数(TASK031 阶段2.1 提取) + /// 构建查询完成的 chunk — 纯函数(TASK031 阶段2.1 提取;TASK063 P7 补 usage 透传) /// /// 完成内容 /// 耗时毫秒 /// 总工具调用次数 /// USD 成本 /// 缓存安全参数 + /// 累计 token 用量 — 沿链透传到子代理 AgentFinished 事件(P7 完成态权威校正数据源) /// 完成类型的 QueryStreamChunk - internal static QueryStreamChunk BuildCompletionChunk(string content, long elapsedMs, int totalToolCalls, decimal costUsd, JoinCode.Abstractions.LLM.Chat.CacheSafeParams? cacheSafeParams) { + internal static QueryStreamChunk BuildCompletionChunk(string content, long elapsedMs, int totalToolCalls, decimal costUsd, JoinCode.Abstractions.LLM.Chat.CacheSafeParams? cacheSafeParams, JoinCode.Abstractions.LLM.Chat.TokenUsage? usage = null) { return new QueryStreamChunk { Type = AgentStreamChunkType.Complete, Content = content, ExecutionTimeMs = elapsedMs, TotalToolCalls = totalToolCalls, CostUsd = costUsd, - CacheSafeParams = cacheSafeParams + CacheSafeParams = cacheSafeParams, + Usage = usage }; } @@ -403,17 +413,17 @@ private async Task ExecuteIterationInternalAsync( }); } - // 追踪Token使用量 - if (streamChunk.Metadata?.TryGetValue("InputTokens", out var inputTokensEl) == true && - inputTokensEl.ValueKind == JsonValueKind.Number) { - try { inputTokens = inputTokensEl.GetInt32(); } catch (FormatException ex) { - _logger?.LogWarning(ex, "[QueryEngine] InputTokens 值不是有效 int32(可能是浮点/科学计数法),跳过"); - } - } - if (streamChunk.Metadata?.TryGetValue("OutputTokens", out var outputTokensEl) == true && - outputTokensEl.ValueKind == JsonValueKind.Number) { - try { outputTokens = outputTokensEl.GetInt32(); } catch (FormatException ex) { - _logger?.LogWarning(ex, "[QueryEngine] OutputTokens 值不是有效 int32(可能是浮点/科学计数法),跳过"); + // 追踪 Token 使用量 — 消费 provider 统一约定 metadata["Usage"](TokenUsage JSON,OpenAI/Anthropic 双侧一致); + // 旧 InputTokens/OutputTokens 平铺 key 为孤儿读法(全仓无生产者)已删除(TASK063 P7 根因修复) + if (streamChunk.Metadata?.TryGetValue("Usage", out var usageEl) == true && + usageEl.ValueKind == JsonValueKind.Object) { + try { + if (JsonSerializer.Deserialize(usageEl, Core.Context.ChatServiceJsonContext.Default.TokenUsage) is { } tokenUsage) { + inputTokens = tokenUsage.PromptTokens; + outputTokens = tokenUsage.CompletionTokens; + } + } catch (JsonException ex) { + _logger?.LogWarning(ex, "[QueryEngine] Usage metadata 反序列化失败,跳过"); } } } diff --git a/modules/llm/core/Adapters/LLM/Fallback/StreamingFallbackDecorator.cs b/modules/llm/core/Adapters/LLM/Fallback/StreamingFallbackDecorator.cs index 694ed50bf9..ecc77ff2e9 100644 --- a/modules/llm/core/Adapters/LLM/Fallback/StreamingFallbackDecorator.cs +++ b/modules/llm/core/Adapters/LLM/Fallback/StreamingFallbackDecorator.cs @@ -9,9 +9,9 @@ namespace Api.LLM.Fallback; /// 装饰器模式:包装任意 IQueryService 实现,透明添加 fallback 能力 /// 非流式调用直接委托给内部服务,流式调用包裹 fallback 逻辑 /// -/// 注意:C# 不允许在 try-catch 块中使用 yield return(CS1626/CS1631), -/// 因此流式+fallback 逻辑通过 CollectStreamingEventsAsync 辅助方法实现: -/// 先收集流式事件到列表,如果失败则执行 fallback,最后统一 yield +/// 两阶段增量透传(ADR 0146 — TASK043 修复):预热阶段(首事件前,可 fallback)+ +/// 透传阶段(已产出后,异常上抛)——消除 CS1626/CS1631(try-catch 内禁 yield)的 +/// 「全量收集后整发」旧实现(逐字流式失效/首字延迟=整轮时长/停止批量闪现三缺陷根因) /// public sealed class StreamingFallbackDecorator : IQueryService { private readonly IQueryService _inner; @@ -55,9 +55,16 @@ public Task> GetApiMessageContentsAsync( } /// - /// 流式调用 — 包裹看门狗 + fallback 逻辑 - /// 对齐 TS queryModelWithStreaming 的 catch 块 + executeNonStreamingRequest + /// 流式调用 — 两阶段增量透传(ADR 0146): + /// 预热阶段拉首事件(可 fallback — 连接级错误拿不到任何事件时降级非流式无损重试); + /// 透传阶段逐事件 yield(不可 fallback — 已产出内容无法回收,中途失败上抛防重复输出)。 /// + /// + /// 消除 CS1626/CS1631(try-catch 内禁 yield)的手法:预热为非迭代器方法(可 try-catch), + /// 透传为迭代器(仅 try-finally 做释放 — finally 中 await 合法)。 + /// watchdog 与 enumerator 所有权:预热成功时经 PrimeOutcome 转移给迭代器 finally 统一释放; + /// fallback/取消路径由预热方法自释放。 + /// public async IAsyncEnumerable GetStreamEventContentsAsync( MessageList chatHistory, ChatOptions? executionSettings = null, @@ -73,73 +80,94 @@ public async IAsyncEnumerable GetStreamEventContentsAsync( yield break; } - var result = await CollectWithFallbackAsync(chatHistory, executionSettings, kernel, cancellationToken).ConfigureAwait(false); + var outcome = await TryPrimeAsync(chatHistory, executionSettings, kernel, cancellationToken).ConfigureAwait(false); - foreach (var evt in result.Events) { - yield return evt; + if (outcome.FirstEvent is not { } first) { + // Fallback/Cancelled 形态 — 非流式重试全量事件(取消=空列表零事件) + foreach (var evt in outcome.FallbackEvents) { + yield return evt; + } + yield break; + } + + if (outcome.Enumerator is not { } enumerator || outcome.Watchdog is not { } watchdog) { + // Primed 形态三件套由工厂保证同时非空 — 到达此处为内部状态损坏(开心路径断言屏蔽) + throw new InvalidOperationException( + "PrimeOutcome Primed 形态完整性破坏(FirstEvent 非空但 Enumerator/Watchdog 为空)— 请检查 TryPrimeAsync 的 Primed 工厂调用"); + } + + try { + yield return first; + watchdog.Reset(); + while (await enumerator.MoveNextAsync().ConfigureAwait(false)) { + watchdog.Reset(); + yield return enumerator.Current; + } + } finally { + await enumerator.DisposeAsync().ConfigureAwait(false); + watchdog.Dispose(); } } /// - /// 收集流式事件 + fallback 逻辑 — 因 C# 不允许 try-catch 中 yield return, - /// 将流式收集和 fallback 合并为一个返回列表的异步方法 + /// 预热阶段 — 拉取首个上游事件(非迭代器,可 try-catch): + /// 空流(IncompleteStream)/看门狗超时/可 fallback 异常 → 非流式重试并返回全量事件; + /// 用户取消 → 空结果(原语义 — 无可吐内容直接结束); + /// 成功 → 首事件 + 活的会话(watchdog/enumerator 转移给调用方释放)。 /// - private async Task CollectWithFallbackAsync( + private async Task TryPrimeAsync( MessageList chatHistory, ChatOptions? executionSettings, IChatClient? kernel, CancellationToken cancellationToken) { - using var watchdog = new StreamIdleWatchdog( + var watchdog = new StreamIdleWatchdog( _config.StreamIdleTimeoutMs, cancellationToken, _config.StreamWatchdogEnabled); - - var events = new List(); + var enumerator = _inner.GetStreamEventContentsAsync(chatHistory, executionSettings, kernel, watchdog.CombinedToken) + .GetAsyncEnumerator(cancellationToken); try { - await foreach (var evt in _inner.GetStreamEventContentsAsync( - chatHistory, executionSettings, kernel, watchdog.CombinedToken).ConfigureAwait(false)) { - watchdog.Reset(); - events.Add(evt); - } - - if (watchdog.WasIdleAborted) { - throw new StreamingFallbackTriggeredException( - "Stream idle timeout - no chunks received", - FallbackCause.Watchdog); - } - - if (events.Count == 0) { + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) { throw new StreamingFallbackTriggeredException( "Stream completed without receiving any events", FallbackCause.IncompleteStream); } - - return new StreamingResult(events, fellBack: false); } catch (OperationCanceledException ex) when (watchdog.WasIdleAborted) { + await DisposeSessionAsync(enumerator, watchdog).ConfigureAwait(false); return await ExecuteFallbackAsync( chatHistory, executionSettings, kernel, cancellationToken, - new StreamingFallbackTriggeredException("Stream idle timeout", FallbackCause.Watchdog, ex), - events).ConfigureAwait(false); + new StreamingFallbackTriggeredException("Stream idle timeout", FallbackCause.Watchdog, ex)).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - return new StreamingResult(events, fellBack: false); + await DisposeSessionAsync(enumerator, watchdog).ConfigureAwait(false); + return PrimeOutcome.Cancelled(); } catch (StreamingFallbackTriggeredException ex) { - return await ExecuteFallbackAsync(chatHistory, executionSettings, kernel, cancellationToken, ex, events).ConfigureAwait(false); + await DisposeSessionAsync(enumerator, watchdog).ConfigureAwait(false); + return await ExecuteFallbackAsync(chatHistory, executionSettings, kernel, cancellationToken, ex).ConfigureAwait(false); } catch (Exception ex) when (ShouldFallback(ex, cancellationToken)) { - return await ExecuteFallbackAsync(chatHistory, executionSettings, kernel, cancellationToken, ex, events).ConfigureAwait(false); + await DisposeSessionAsync(enumerator, watchdog).ConfigureAwait(false); + return await ExecuteFallbackAsync(chatHistory, executionSettings, kernel, cancellationToken, ex).ConfigureAwait(false); } + + return PrimeOutcome.Primed(enumerator.Current, enumerator, watchdog); + } + + /// 释放预热失败/取消路径的会话资源(成功路径所有权已转移,勿在此释放)。 + private static async Task DisposeSessionAsync( + IAsyncEnumerator enumerator, StreamIdleWatchdog watchdog) { + await enumerator.DisposeAsync().ConfigureAwait(false); + watchdog.Dispose(); } /// - /// 执行非流式 fallback — 对齐 TS executeNonStreamingRequest + /// 执行非流式 fallback — 对齐 TS executeNonStreamingRequest(ADR 0146:仅预热阶段调用 — 首事件前失败才降级) /// - private async Task ExecuteFallbackAsync( + private async Task ExecuteFallbackAsync( MessageList chatHistory, ChatOptions? executionSettings, IChatClient? kernel, CancellationToken cancellationToken, - Exception originalError, - List partialEvents) { + Exception originalError) { _logger?.LogWarning("[FALLBACK {CallId}:161] 流式失败, 降级为非流式 | {ExType}: {Message}", CallTrace.CurrentId, originalError.GetType().Name, originalError.Message); LastRequestFellBack = true; @@ -160,7 +188,7 @@ private async Task ExecuteFallbackAsync( } var fallbackEvents = ConvertToStreamEvents(messages); - return new StreamingResult(fallbackEvents, fellBack: true); + return PrimeOutcome.FromFallback(fallbackEvents); } /// @@ -249,13 +277,23 @@ internal static List ConvertToStreamEvents(IReadOnlyList - /// 流式收集结果 — 事件列表 + 是否触发过 fallback + /// 预热结果 — 三形态(ADR 0146): + /// Primed(首事件 + 活会话,透传阶段消费)/ Fallback(非流式重试全量事件)/ Cancelled(用户取消,空流结束) /// - private sealed class StreamingResult(List events, bool fellBack) { - /// 获取流式事件列表。 - public List Events { get; } = events; - /// 获取是否触发了 fallback。 - public bool FellBack { get; } = fellBack; + private sealed record PrimeOutcome( + StreamEvent? FirstEvent, + IAsyncEnumerator? Enumerator, + StreamIdleWatchdog? Watchdog, + List FallbackEvents) { + /// 构造 Primed 形态 — 首事件 + 活会话(三件套同时非空,透传阶段消费)。 + public static PrimeOutcome Primed(StreamEvent first, IAsyncEnumerator enumerator, StreamIdleWatchdog watchdog) + => new(first, enumerator, watchdog, []); + /// 构造 Fallback 形态 — 非流式重试的全量事件(FirstEvent=null 为判别依据)。 + public static PrimeOutcome FromFallback(List events) + => new(null, null, null, events); + /// 构造 Cancelled 形态 — 用户取消(零事件空流结束,语义同 Fallback 零事件)。 + public static PrimeOutcome Cancelled() + => new(null, null, null, []); } } diff --git "a/modules/llm/docs/task/TASK043-\346\265\201\345\274\217\345\242\236\351\207\217\351\200\217\344\274\240\344\270\216fallback\350\276\271\347\225\214\344\273\273\345\212\241.md" "b/modules/llm/docs/task/TASK043-\346\265\201\345\274\217\345\242\236\351\207\217\351\200\217\344\274\240\344\270\216fallback\350\276\271\347\225\214\344\273\273\345\212\241.md" index 505ff1b373..e984f738fc 100644 --- "a/modules/llm/docs/task/TASK043-\346\265\201\345\274\217\345\242\236\351\207\217\351\200\217\344\274\240\344\270\216fallback\350\276\271\347\225\214\344\273\273\345\212\241.md" +++ "b/modules/llm/docs/task/TASK043-\346\265\201\345\274\217\345\242\236\351\207\217\351\200\217\344\274\240\344\270\216fallback\350\276\271\347\225\214\344\273\273\345\212\241.md" @@ -1,7 +1,12 @@ # TASK043 — 流式增量透传与 fallback 边界(缺陷驱动) > 来源:TASK053 F1-x(GUI 输入闭环真机 E2E 红复现)。用户决策:先提交现状、另立本任务后修。 -> 状态:待修复(E2E 复现已入库;修复需先写 ADR,见「实现前置」)。 +> 状态:**implemented(2026-10-22 — ADR 0146 两阶段重构落地,本会话)**: +> 单元红→绿(门闩确定性增量测试 + 中途失败上抛守卫)+ llm.tests 605 全绿 + 实弹 E2E +> `StreamingIncrementalE2ETests`(真实 MockServer 50 分片×20ms 流,首末 Content 到达时间差 +> 反映真实流时长——已进 CI 白名单 errors-permission 组)。真机 CDP 复现脚本 +> (f1-webui-flow.mjs)未入库,15/15 验收以实弹 E2E 等价覆盖。 +> 关联 ADR:`docs/proposed/adr/component_strategy/0146-streaming-incremental-passthrough-fallback-boundary.md` ## 1. 现象(真机可复现) diff --git a/modules/llm/llm.tests/Adapters/Fallback/StreamingFallbackDecoratorTests.cs b/modules/llm/llm.tests/Adapters/Fallback/StreamingFallbackDecoratorTests.cs index 1cfadab202..bb36779e48 100644 --- a/modules/llm/llm.tests/Adapters/Fallback/StreamingFallbackDecoratorTests.cs +++ b/modules/llm/llm.tests/Adapters/Fallback/StreamingFallbackDecoratorTests.cs @@ -12,7 +12,7 @@ private static IQueryService CreateMockStreamingService( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns((MessageList h, ChatOptions? s, IChatClient? k, CancellationToken ct) => - FailStreamAsync(exception ?? new HttpRequestException("Stream error"), ct)); + FailAtStartStreamAsync(exception ?? new HttpRequestException("Stream error"), ct)); mock.Setup(s => s.GetApiMessageContentsAsync( It.IsAny(), It.IsAny(), @@ -59,6 +59,100 @@ private static async IAsyncEnumerable FailStreamCoreAsync( throw exception; } + /// 首事件前失败形态 — 对齐真实连接级错误(529/超时/IOException 建立请求即失败,拿不到任何事件)。 + /// ADR 0146 语义下此形态仍触发 fallback;中途失败形态(FailStreamCoreAsync)改为上抛守卫。 + private static IAsyncEnumerable FailAtStartStreamAsync( + Exception exception, CancellationToken ct) { + return FailAtStartCoreAsync(exception); + } + + private static async IAsyncEnumerable FailAtStartCoreAsync(Exception exception) { + await Task.Yield(); + if (exception is null) { + yield break; + } + throw exception; + } + + /// 门闩两分片流 — 首分片即时产出、第二分片等门闩放行(确定性时序,禁固定延时)。 + private static async IAsyncEnumerable GatedTwoChunkStreamAsync( + TaskCompletionSource secondChunkGate, + [EnumeratorCancellation] CancellationToken ct = default) { + await Task.Yield(); + yield return new StreamEvent(MessageRole.Assistant, "first", "test-model"); + await secondChunkGate.Task; + yield return new StreamEvent(MessageRole.Assistant, " second", "test-model"); + } + + private sealed class DelegateQueryService(Func> streamFactory) : IQueryService { + public Task> GetApiMessageContentsAsync( + MessageList chatHistory, ChatOptions? executionSettings = null, IChatClient? kernel = null, CancellationToken cancellationToken = default) { + return Task.FromResult>([new(MessageRole.Assistant, "non-streaming response")]); + } + + public IAsyncEnumerable GetStreamEventContentsAsync( + MessageList chatHistory, ChatOptions? executionSettings = null, IChatClient? kernel = null, CancellationToken cancellationToken = default) { + return streamFactory(cancellationToken); + } + } + + [Fact] + public async Task GetStreamEventContentsAsync_StreamsIncrementally_FirstEventBeforeUpstreamCompletes() { + // TASK043 红(ADR 0146 绿):首分片必须在整条流完成前到达 — 全量收集缺陷下首 MoveNextAsync + // 挂死至上游流结束(门闩不放行则永不返回),超时保护判红 + var secondChunkGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var inner = new DelegateQueryService(_ => GatedTwoChunkStreamAsync(secondChunkGate)); + var decorator = new StreamingFallbackDecorator(inner, new StreamingFallbackConfig { + StreamWatchdogEnabled = false + }); + + var enumerator = decorator.GetStreamEventContentsAsync(new MessageList()).GetAsyncEnumerator(); + var firstArrived = true; + try { + await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + } catch (TimeoutException) { + firstArrived = false; + } + firstArrived.Should().BeTrue("首分片应在整条流完成前到达 — 当前实现全量收集后整发,逐字流式失效(TASK043 根因)"); + + enumerator.Current.Content.Should().Be("first"); + + secondChunkGate.SetResult(); + (await enumerator.MoveNextAsync()).Should().BeTrue(); + enumerator.Current.Content.Should().Be(" second"); + (await enumerator.MoveNextAsync()).Should().BeFalse(); + await enumerator.DisposeAsync(); + decorator.LastRequestFellBack.Should().BeFalse(); + } + + [Fact] + public async Task GetStreamEventContentsAsync_MidStreamFailure_ThrowsAfterYielded_NoFallback() { + // ADR 0146 新语义:已 yield 内容后失败 → 上抛不 fallback(非流式重试会重复输出已产出内容) + var mock = new Mock(); + mock.Setup(s => s.GetStreamEventContentsAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(FailStreamCoreAsync(new TimeoutException("mid-stream"), CancellationToken.None)); + mock.Setup(s => s.GetApiMessageContentsAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync([new(MessageRole.Assistant, "fallback response")]); + var decorator = new StreamingFallbackDecorator(mock.Object, new StreamingFallbackConfig { + StreamWatchdogEnabled = false + }); + + var events = new List(); + var act = async () => { + await foreach (var evt in decorator.GetStreamEventContentsAsync(new MessageList())) { + events.Add(evt); + } + }; + + await act.Should().ThrowAsync(); + events.Should().ContainSingle(e => e.Content == "partial"); + decorator.LastRequestFellBack.Should().BeFalse("中途失败不触发 fallback"); + } + [Fact] public async Task GetStreamEventContentsAsync_WhenStreamingSucceeds_ReturnsStreamEvents() { var inner = CreateMockStreamingService(shouldFail: false); @@ -192,7 +286,7 @@ public async Task GetStreamEventContentsAsync_WhenBothFail_ThrowsAggregateExcept mock.Setup(s => s.GetStreamEventContentsAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(FailStreamAsync(new TimeoutException("stream failed"), CancellationToken.None)); + .Returns(FailAtStartStreamAsync(new TimeoutException("stream failed"), CancellationToken.None)); mock.Setup(s => s.GetApiMessageContentsAsync( It.IsAny(), It.IsAny(), @@ -479,7 +573,7 @@ public async Task GetApiMessageContentsAsync_ResetsLastRequestFellBack_ToFalse() mock.Setup(s => s.GetStreamEventContentsAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(FailStreamAsync(new TimeoutException(), CancellationToken.None)); + .Returns(FailAtStartStreamAsync(new TimeoutException(), CancellationToken.None)); mock.Setup(s => s.GetApiMessageContentsAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -511,7 +605,7 @@ public async Task ExecuteFallbackAsync_SetsLastRequestFellBack_AndRaisesEvent_An mock.Setup(s => s.GetStreamEventContentsAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(FailStreamAsync(new TimeoutException("stream down"), CancellationToken.None)); + .Returns(FailAtStartStreamAsync(new TimeoutException("stream down"), CancellationToken.None)); // 非流式 fallback 返回固定消息(含原始 metadata + modelId) var fallbackMessages = new List { @@ -564,7 +658,7 @@ public async Task ExecuteFallbackAsync_WithMultipleMessages_OnlyLastHasFinishRea mock.Setup(s => s.GetStreamEventContentsAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(FailStreamAsync(new HttpRequestException("overload"), CancellationToken.None)); + .Returns(FailAtStartStreamAsync(new HttpRequestException("overload"), CancellationToken.None)); mock.Setup(s => s.GetApiMessageContentsAsync( It.IsAny(), It.IsAny(), @@ -605,7 +699,7 @@ public async Task ExecuteFallbackAsync_WhenNonStreamingAlsoFails_ThrowsAggregate mock.Setup(s => s.GetStreamEventContentsAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(FailStreamAsync(new TimeoutException("stream failed"), CancellationToken.None)); + .Returns(FailAtStartStreamAsync(new TimeoutException("stream failed"), CancellationToken.None)); mock.Setup(s => s.GetApiMessageContentsAsync( It.IsAny(), It.IsAny(), diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs index 5aedac0c3a..a6c910c429 100644 --- a/native_tui/apps/NativeTui.Host/EngineSession.cs +++ b/native_tui/apps/NativeTui.Host/EngineSession.cs @@ -59,6 +59,11 @@ public static async Task CreateAsync(CancellationToken cancellati SessionController? controller = null; var client = new EngineTurnClient( (input, consumer, turnCt) => { + // TASK064 斜杠命令分流(对齐 CliSession.ProcessInputAsync:/ 开头走命令执行不进 LLM) + if (input.StartsWith('/')) { + return DispatchSlashCommandAsync(engine.Services, input, consumer, turnCt); + } + controller ??= new SessionController( engine.ChatService, consumer, new TurnDiffService(), engine.SessionId, engine.Services, mainAgent: mainAgent); @@ -127,6 +132,28 @@ private static async Task RunTurnAsync(SessionController controller, string inpu throw ex; } + /// 斜杠命令回合(TASK064 — 引擎 SlashCommandRunner 共享链路:解析→路由→执行→输出捕获): + /// 输出经 AnsiStripper 剥离 SGR 色码(命令侧彩色输出与 TUI Cell/StyleId 渲染模型不兼容 — + /// ESC 零宽跳过后残骸显示为 "38;2;153;153;153m──" 形态)再经 consumer.OnText/OnDone 上屏 + ///(AssistantMessage Markdown 渲染);未知命令同样上屏诱导(/help 提示)— 不落 LLM + ///(对齐"命令不触发 AI":注册命令走命令执行,未知命令报错,两者都不消耗模型调用)。 + private static async Task DispatchSlashCommandAsync( + IServiceProvider services, string input, IEventConsumer consumer, CancellationToken ct) { + var result = await SlashCommandRunner.RunAsync( + input, services, + confirm: null, + prompt: null, + readPassword: null, + cancellationToken: ct).ConfigureAwait(false); + + var output = AnsiStripper.Strip(result.Output).TrimEnd(); + if (output.Length > 0) { + consumer.OnText(output); + } + + consumer.OnDone(usage: null, modelId: null); + } + /// 翻译轮次结果 — 成功=null(client 发 turn_completed);取消=OperationCanceledException(turn_interrupted); /// 超时/错误=InvalidOperationException(client 发 Error 事件,消息含 Host 交互诱导 — Esc 打断 + 网络检查)。 internal static Exception? TranslateTurnResult(SessionTurnResult result) { diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs index 29ca5b8a2e..cfae9ef2c4 100644 --- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs +++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs @@ -19,6 +19,7 @@ global using JoinCode.App.Builder; global using JoinCode.ChatCommands; global using JoinCode.Cli; +global using JoinCode.Cli.Commands; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; global using NativeTui.Core; diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs index a14bf998b5..cc246e9dca 100644 --- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs +++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs @@ -120,9 +120,9 @@ public void OnSubagentToolUse(string subagentId, string toolName, string summary _events.Enqueue(new BridgeEvent.SubagentToolUse(subagentId, toolName, summary, tokenGain)); } - /// 子代理完成 → SubagentFinished(条目定格,isError 聚合组头 ● 红)。 - public void OnSubagentFinished(string subagentId, bool isError) { - _events.Enqueue(new BridgeEvent.SubagentFinished(subagentId, isError)); + /// 子代理完成 → SubagentFinished(条目定格,isError 聚合组头 ● 红;tokenTotal 完成态权威校正)。 + public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) { + _events.Enqueue(new BridgeEvent.SubagentFinished(subagentId, isError, tokenTotal)); } /// 排空事件队列 — 主循环每 tick 调用,取走全部待处理事件(ConcurrentQueue 多生产单消费安全)。 diff --git a/native_tui/apps/NativeTui.Host/SubagentSelftest.cs b/native_tui/apps/NativeTui.Host/SubagentSelftest.cs index 82d2f0857f..29ed9d1407 100644 --- a/native_tui/apps/NativeTui.Host/SubagentSelftest.cs +++ b/native_tui/apps/NativeTui.Host/SubagentSelftest.cs @@ -217,7 +217,7 @@ private sealed class RecordingSubagentConsumer : JoinCode.Abstractions.Interface { public List<(string SubagentId, string AgentType, string Description)> SubagentStarts { get; } = []; public List<(string SubagentId, string ToolName, string Summary, int TokenGain)> SubagentToolUses { get; } = []; - public List<(string SubagentId, bool IsError)> SubagentFinishes { get; } = []; + public List<(string SubagentId, bool IsError, int TokenTotal)> SubagentFinishes { get; } = []; public void OnText(string content) { } public void OnThinking(string thinking) { } @@ -229,6 +229,6 @@ public void OnTimingSummary(string summary) { } public void OnDone(TokenUsage? usage, string? modelId) { } public void OnSubagentStarted(string subagentId, string agentType, string description) => SubagentStarts.Add((subagentId, agentType, description)); public void OnSubagentToolUse(string subagentId, string toolName, string summary, int tokenGain) => SubagentToolUses.Add((subagentId, toolName, summary, tokenGain)); - public void OnSubagentFinished(string subagentId, bool isError) => SubagentFinishes.Add((subagentId, isError)); + public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) => SubagentFinishes.Add((subagentId, isError, tokenTotal)); } } diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index eeae7fb2a5..04c221ed79 100644 --- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs +++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs @@ -96,7 +96,7 @@ public sealed record SubagentStarted(string SubagentId, string AgentType, string public sealed record SubagentToolUse(string SubagentId, string ToolName, string Summary, int TokenGain) : BridgeEvent; /// 子代理完成 — 条目定格(isError 聚合组头 ● 红;基准基准 agent 完成态 Done)。 - public sealed record SubagentFinished(string SubagentId, bool IsError) : BridgeEvent; + public sealed record SubagentFinished(string SubagentId, bool IsError, int TokenTotal) : BridgeEvent; /// 错误(含诱导说明)。 public sealed record Error(string Message) : BridgeEvent; diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index 1c50bf98c5..8d95f7ea32 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -116,8 +116,8 @@ public void Apply(BridgeEvent bridgeEvent) case BridgeEvent.SubagentToolUse(var subagentId, var toolName, var summary, var tokenGain): _activeSubagentGroup?.RecordToolUse(subagentId, toolName, summary, tokenGain); break; - case BridgeEvent.SubagentFinished(var subagentId, var isError): - _activeSubagentGroup?.FinishAgent(subagentId, isError); + case BridgeEvent.SubagentFinished(var subagentId, var isError, var tokenTotal): + _activeSubagentGroup?.FinishAgent(subagentId, isError, tokenTotal); if (_activeSubagentGroup is { AllComplete: true }) { _spinner.HasActiveTools = false; @@ -161,7 +161,8 @@ private void ApplySessionInfo(string version, string? model, string permissionMo } } - /// 开始回合:busy + spinner 挂载(Thinking 乐观模式,动词重采样,停滞计时重置);防御性脱离子代理组(引擎漏发回合收尾时不串组)。 + /// 开始回合:busy + spinner 挂载(Thinking 乐观模式,动词重采样,停滞计时重置);防御性脱离子代理组(引擎漏发回合收尾时不串组)。 + /// spinner 显示(对齐基准 REPL.tsx showSpinner:isLoading 期 = 等待/思考/工具运行的唯一反馈 — TUI 无流式正文时尤其如此)。 public void BeginTurn() { IsBusy = true; @@ -173,10 +174,12 @@ public void BeginTurn() _spinner.LoadingStartMs = Environment.TickCount64; _spinner.ResponseLength = 0; _spinner.HasActiveTools = false; + _spinner.Hidden = false; } /// 结束回合:idle + spinner 复位(消息由事件驱动,interrupted 不额外追加);子代理组定格脱离(未完成条目定格为非错误完成)。 - /// spinner Mode 复位 Thinking→Responding(修复:回合结束后思考动画续转 — Thinking 态渲染器持续 shimmer)。 + /// spinner Mode 复位 Thinking→Responding(修复:回合结束后思考动画续转 — Thinking 态渲染器持续 shimmer); + /// Hidden 置位(对齐基准:空闲非 busy 完全不渲染 spinner 行 — 不再常驻闪烁)。 public void EndTurn() { IsBusy = false; @@ -186,6 +189,7 @@ public void EndTurn() _spinner.Message = string.Empty; _spinner.ResponseLength = 0; _spinner.HasActiveTools = false; + _spinner.Hidden = true; if (_spinner.Mode == SpinnerMode.Thinking) { _spinner.Mode = SpinnerMode.Responding; diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs index b12c592dd0..05186b5ebc 100644 --- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs +++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs @@ -359,7 +359,7 @@ private void AdvanceSubagents(long elapsed) if (subagent.ToolUsesLeft == 0 && subagent.FinishAtMs <= elapsed) { subagent.IsDone = true; - _pending.Enqueue(new BridgeEvent.SubagentFinished($"agent-{subagent.Index}", IsError: subagent.Index % 7 == 6)); + _pending.Enqueue(new BridgeEvent.SubagentFinished($"agent-{subagent.Index}", IsError: subagent.Index % 7 == 6, TokenTotal: 1200 + (subagent.Index * 311) % 9000)); } } diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index fc60ed56e4..6492bfffde 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -16,7 +16,7 @@ public sealed class ReplShell private readonly Theme _theme = Theme.Dark; private readonly ReplView _view = new(); private readonly InputBuffer _input = new(); - private readonly SpinnerItem _spinner = new() { Message = string.Empty }; + private readonly SpinnerItem _spinner = new() { Message = string.Empty, Hidden = true }; private readonly StylePool _pool = new(); private readonly Stopwatch _clock = Stopwatch.StartNew(); private readonly InlineComposer _inlineComposer = new(); @@ -857,7 +857,9 @@ private bool HandleSuggestionKey(KeyPress key) } /// 接受选中建议(基准 applyCommandSuggestion)— 命令模式:Tab 补全 "/命令 "(尾随空格进参数态)/ - /// Enter 直接提交 "/命令";参数模式(IsArgumentMode):命令名取自输入前缀,补全 "/命令 参数 " 或提交 "/命令 参数"。 + /// Enter 直接提交 "/命令";参数模式(IsArgumentMode):真实值清单时补全 "/命令 参数 " 或提交 "/命令 参数", + /// 仅占位提示(HasRealArgumentValues=false,如 "<question>")时 Tab 不填入、Enter 提交输入原文 + ///(修复:占位符被当参数值填入提交,`/btw <question>` 字面量发给了 AI)。 private void AcceptSuggestion(bool execute) { if (_suggester?.Selected is not { } selected) @@ -865,6 +867,16 @@ private void AcceptSuggestion(bool execute) return; } + if (_suggester.IsArgumentMode && !_suggester.HasRealArgumentValues) + { + if (execute) + { + Submit(); + } + + return; + } + string completed; if (_suggester.IsArgumentMode) { diff --git a/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs b/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs index d979ab93ae..4aeb93ba8b 100644 --- a/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs +++ b/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs @@ -98,12 +98,22 @@ public Random Random /// 累计已暂停毫秒。 public long TotalPausedMs { get; set; } - /// 内容高度 = marginTop 1 + 行 1。 - public int MeasureHeight(int width) => 2; + /// 隐藏态(对齐基准 REPL.tsx showSpinner:空闲非 busy 完全不渲染 — 高度 0)。 + /// driver BeginTurn/EndTurn 驱动(busy=显示:等待/思考/工具运行反馈;idle=隐藏:基准空闲无 spinner 行)。 + public bool Hidden { get; set; } - /// 渲染:margin 行留空,行内容 = glyph + 消息 + 状态 parts(消息空时随机动词 + '…')。 + /// 内容高度 = Hidden ? 0 : marginTop 1 + 行 1。 + public int MeasureHeight(int width) => Hidden ? 0 : 2; + + /// 渲染:margin 行留空,行内容 = glyph + 消息 + 状态 parts(消息空时随机动词 + '…'); + /// Hidden 时零输出(不动画不占行)。 public void Render(Screen screen, Rect frame, Theme theme) { + if (Hidden) + { + return; + } + var message = EffectiveMessage(); var messageColor = OverrideColor ?? theme.Claude; var shimmerColor = OverrideShimmerColor ?? theme.ClaudeShimmer; diff --git a/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs index f40684538b..e31a6dad4e 100644 --- a/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs +++ b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs @@ -135,10 +135,17 @@ public void RecordToolUse(string id, string toolName, string summary, int tokenD }); } - /// 定格子代理条目(isError 聚合组头 ● 红)+ 窗口跟随。 - public void FinishAgent(string id, bool isError) + /// 定格子代理条目(isError 聚合组头 ● 红)+ token 权威校正 + 窗口跟随。 + /// tokenTotal > 0 时覆盖 TokenCount(AgentFinished 事件 Usage.TotalTokens — 引擎完成态权威值, + /// 优于逐活动增量累加);0 = 无 usage 数据(FinalizePending 兜底路径)保持原累计。 + public void FinishAgent(string id, bool isError, int tokenTotal = 0) { - UpdateEntry(id, entry => entry with { IsDone = true, IsError = isError }); + UpdateEntry(id, entry => entry with + { + IsDone = true, + IsError = isError, + TokenCount = tokenTotal > 0 ? tokenTotal : entry.TokenCount, + }); } /// 回合收尾 — 未完成条目定格为非错误完成(TurnCompleted/TurnInterrupted 调用;幂等 — 全完成 no-op)。 diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs index cf2708f6d8..0d0222dd20 100644 --- a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs +++ b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs @@ -28,6 +28,23 @@ public sealed class SlashSuggester /// 参数补全模式("/cmd 参数前缀" 态 — 建议项为参数值或提示,渲染不带 / 前缀)。 public bool IsArgumentMode { get; private set; } + /// 参数模式建议是真实值清单(可 Tab 补全/Enter 填入)— false 表示仅 ArgumentHint 占位提示 + ///(如 "<question>" — Tab/Enter 不得当参数值填入,修复:占位符被补全提交致 `/btw <question>` 字面量发给 AI)。 + public bool HasRealArgumentValues + { + get + { + if (!IsArgumentMode || Items.Count == 0) + { + return false; + } + + var commandName = CurrentArgumentCommandName(); + return commandName is not null + && _commands.FirstOrDefault(c => c.Name == commandName) is { ArgumentValues.Length: > 0 }; + } + } + /// 幽灵文本后缀(基准 ghost text — 输入中途 " /前缀" token 的命令补全剩余部分,如 "act"; /// 激活时下拉互斥为空;空串 = 未激活)。 public string GhostSuffix { get; private set; } = string.Empty; @@ -42,6 +59,19 @@ public SlashSuggester(IReadOnlyList commands) NameColumnWidth = commands.Count > 0 ? commands.Max(c => c.Name.Length) + 6 : 0; } + /// 当前参数模式命令名(非参数模式或无空格时 null)。 + private string? CurrentArgumentCommandName() + { + var input = _currentInput; + if (input.Length == 0 || input[0] != '/') + { + return null; + } + + var spaceIndex = input.IndexOf(' '); + return spaceIndex > 1 ? input[1..spaceIndex] : null; + } + /// 输入变化时刷新建议(用户按键编辑路径调用;程序化填充如历史导航不调用)。 public void Update(string input) { diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs index 10a57f780d..aef831a445 100644 --- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs @@ -36,7 +36,7 @@ public void SubagentCallbacks_EnqueueSubagentEvents() consumer.OnSubagentStarted("agent-1", "explore", "搜索渲染链"); consumer.OnSubagentToolUse("agent-1", "Grep", "命中 3 处", 120); - consumer.OnSubagentFinished("agent-1", isError: false); + consumer.OnSubagentFinished("agent-1", isError: false, tokenTotal: 350); var events = consumer.DrainEvents(); Assert.Equal(3, events.Count); @@ -52,6 +52,7 @@ public void SubagentCallbacks_EnqueueSubagentEvents() var finished = Assert.IsType(events[2]); Assert.Equal("agent-1", finished.SubagentId); Assert.False(finished.IsError); + Assert.Equal(350, finished.TokenTotal); } [Fact] diff --git a/native_tui/tests/NativeTui.Host.Tests/SlashCommandAnsiTests.cs b/native_tui/tests/NativeTui.Host.Tests/SlashCommandAnsiTests.cs new file mode 100644 index 0000000000..aa4778d20c --- /dev/null +++ b/native_tui/tests/NativeTui.Host.Tests/SlashCommandAnsiTests.cs @@ -0,0 +1,19 @@ +namespace NativeTui.Host.Tests; + +/// +/// 命令输出 ANSI 剥离测试(用户验收反馈:`/btw` 输出 "38;2;153;153;153m── 侧边问题 ──" 残骸)— +/// 引擎侧命令彩色输出与 TUI Cell/StyleId 渲染模型不兼容,DispatchSlashCommandAsync 上屏前剥离; +/// 复用引擎 AnsiStripper(abstractions — stackalloc 零分配全序列剥离,勿重复造轮子)。 +/// +public sealed class SlashCommandAnsiTests +{ + /// SGR 色码序列全剥离(用户案例形态 + 多码混合 + 无码直返)。 + [Theory] + [InlineData("\x1b[38;2;153;153;153m── 侧边问题 ──\x1b[0m", "── 侧边问题 ──")] + [InlineData("普通文本", "普通文本")] + [InlineData("\x1b[31m红\x1b[0m与\x1b[1m粗\x1b[0m", "红与粗")] + public void AnsiStripper_SgrSequencesStripped(string input, string expected) + { + Assert.Equal(expected, JoinCode.Abstractions.Utils.AnsiStripper.Strip(input)); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/SlashInteractionDefectTests.cs b/native_tui/tests/NativeTui.Tests/repl/SlashInteractionDefectTests.cs new file mode 100644 index 0000000000..79cff868b6 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/SlashInteractionDefectTests.cs @@ -0,0 +1,85 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 斜杠命令交互缺陷测试(用户验收反馈 `/btw 你好` 两问题)— +/// ①ArgumentHint 占位符不可补全:参数模式仅有提示(如 "<question>")时 Tab 不填入、 +/// Enter 提交输入原文(修复:占位符被当参数值填入,`/btw <question>` 字面量发给 AI); +/// ②命令输出 ANSI 剥离:ESC[38;2;...m 色码剥离(修复:ESC 零宽跳过后残骸显示 "38;2;153;153;153m──")。 +/// +public sealed class SlashInteractionDefectTests +{ + private static ReplShell CreateShell(params CommandSuggestion[] commands) { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("0", "m", "b", "C:\\"), new StringWriter(), commands: commands); + shell.SeedContent(); + return shell; + } + + private static void Type(ReplShell shell, string text) { + foreach (var ch in text) + shell.HandleKey(new ConsoleKeyInfo(ch, ConsoleKey.A, false, false, false)); + } + + private static void Key(ReplShell shell, ConsoleKey key, char c = '\0') => + shell.HandleKey(new ConsoleKeyInfo(c, key, false, false, false)); + + /// 占位提示命令(/btw 形态:仅 ArgumentHint 无值清单)。 + private static CommandSuggestion HintCommand() => + new("btw", "侧边问题", [], IsHidden: false, ArgumentHint: ""); + + /// 真值清单命令(/effort 形态)。 + private static CommandSuggestion ValuesCommand() => + new("effort", "推理力度", [], IsHidden: false, ArgumentHint: "[low|medium|high]", Arguments: ["low", "medium", "high"]); + + /// 占位提示 — Tab 不填入(输入保持 "/btw " 空参数)。 + [Fact] + public void HintOnly_TabDoesNotFillPlaceholder() + { + var shell = CreateShell(HintCommand()); + Type(shell, "/btw "); + + Key(shell, ConsoleKey.Tab); + + Assert.Equal("/btw ", shell.Input.Text); + } + + /// 占位提示 — Enter 提交输入原文(用户实敲内容,非占位符)。 + [Fact] + public void HintOnly_EnterSubmitsOriginalInput() + { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("0", "m", "b", "C:\\"), new StringWriter(), commands: [HintCommand()]); + shell.SeedContent(); + Type(shell, "/btw 你好"); + + Key(shell, ConsoleKey.Enter); + + Assert.Equal(["/btw 你好"], engine.Submits); + } + + /// 真值清单 — Tab 照常补全(回归保护:/effort low 补全不受影响)。 + [Fact] + public void RealValues_TabStillCompletes() + { + var shell = CreateShell(ValuesCommand()); + Type(shell, "/effort l"); + + Key(shell, ConsoleKey.Tab); + + Assert.Equal("/effort low ", shell.Input.Text); + } + + /// 真值清单 — Enter 补全提交(回归保护)。 + [Fact] + public void RealValues_EnterCompletesAndSubmits() + { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("0", "m", "b", "C:\\"), new StringWriter(), commands: [ValuesCommand()]); + shell.SeedContent(); + Type(shell, "/effort hi"); + + Key(shell, ConsoleKey.Enter); + + Assert.Equal(["/effort high"], engine.Submits); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/SpinnerVisibilityTests.cs b/native_tui/tests/NativeTui.Tests/repl/SpinnerVisibilityTests.cs new file mode 100644 index 0000000000..d8dbb7d10e --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/SpinnerVisibilityTests.cs @@ -0,0 +1,82 @@ +namespace NativeTui.Tests.Repl; + +/// +/// Spinner 显示条件测试(对齐基准 Claude Code REPL.tsx showSpinner 语义 — 用户反馈: +/// spinner 不应一直闪烁,基准只在特定场景出现): +/// ①空闲(非 busy)— spinner 完全隐藏(高度 0 不渲染,基准空闲无 spinner 行); +/// ②busy — spinner 显示(等待/思考/工具运行 = 首字前反馈,基准 isLoading 路径); +/// ③busy 且工具活跃 — 显示(基准 hasActiveTools)。 +/// 基准关键行(REPL.tsx:1715):流式文本可见时隐藏 spinner(文本即反馈)——TUI 正文 +/// 回合结束才上屏(无流式正文),token_delta 期 spinner 是唯一反馈保留显示,等价基准首行未完阶段。 +/// +public sealed class SpinnerVisibilityTests +{ + /// 空闲 — driver EndTurn 后 spinner Hidden(高度 0)。 + [Fact] + public void Idle_SpinnerHidden() + { + var view = new ReplView(); + var spinner = new SpinnerItem(); + var banner = new BannerItem("0.0.0", "m", "b", "C:\\"); + var driver = new ReplBridgeDriver(view, spinner, banner); + view.Messages.Add(banner); + view.Messages.Add(spinner); + + driver.Apply(new BridgeEvent.TurnStarted()); + driver.Apply(new BridgeEvent.TurnCompleted()); + + Assert.True(spinner.Hidden); + Assert.Equal(0, spinner.MeasureHeight(40)); + } + + /// busy — spinner 可见(高度 2)。 + [Fact] + public void Busy_SpinnerVisible() + { + var view = new ReplView(); + var spinner = new SpinnerItem(); + var banner = new BannerItem("0.0.0", "m", "b", "C:\\"); + var driver = new ReplBridgeDriver(view, spinner, banner); + view.Messages.Add(banner); + view.Messages.Add(spinner); + + driver.Apply(new BridgeEvent.TurnStarted()); + + Assert.False(spinner.Hidden); + Assert.Equal(2, spinner.MeasureHeight(40)); + } + + /// 忙内来回 — TurnStarted 显示 / TurnInterrupted 终态也隐藏。 + [Fact] + public void BusyTerminalEvents_HideSpinner() + { + var terminal = new BridgeEvent[] { new BridgeEvent.TurnCompleted(), new BridgeEvent.TurnInterrupted() }; + foreach (var evt in terminal) { + var view = new ReplView(); + var spinner = new SpinnerItem(); + var banner = new BannerItem("0.0.0", "m", "b", "C:\\"); + var driver = new ReplBridgeDriver(view, spinner, banner); + view.Messages.Add(banner); + view.Messages.Add(spinner); + + driver.Apply(new BridgeEvent.TurnStarted()); + driver.Apply(evt); + + Assert.True(spinner.Hidden, $"{evt.GetType().Name} 终态后应隐藏"); + } + } + + /// Hidden 渲染 — 零高度不写任何 cell。 + [Fact] + public void HiddenRender_WritesNothing() + { + using var screen = new Screen(cols: 40, rows: 8); + var spinner = new SpinnerItem { Hidden = true }; + spinner.AnimTimeMs = 100; + spinner.WallNowMs = 100; + + spinner.Render(screen, new Rect(0, 2, 40, 0), Theme.Dark); + + Assert.Equal(0, spinner.MeasureHeight(40)); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs index 78c0935e94..da9064449a 100644 --- a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs @@ -89,6 +89,26 @@ public void FinalizePending_CompletesUnfinishedAsNonError() Assert.False(coordinator.Agents[0].IsError); } + /// FinishAgent tokenTotal 权威校正(TASK062 P7)— tokenTotal>0 覆盖累计值 + /// (AgentFinished Usage.TotalTokens 引擎完成态权威值);0 保持原累计(FinalizePending 兜底路径)。 + [Fact] + public void FinishAgent_TokenTotal_CorrectsTokenCount() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.RecordToolUse("a1", "Read", "f1", 120); + coordinator.RecordToolUse("a1", "Grep", "f2", 80); + Assert.Equal(200, coordinator.Agents[0].TokenCount); + + coordinator.FinishAgent("a1", isError: false, tokenTotal: 350); + Assert.Equal(350, coordinator.Agents[0].TokenCount); + + coordinator.AddAgent("a2", "reviewer", "审查"); + coordinator.RecordToolUse("a2", "Read", "f3", 50); + coordinator.FinishAgent("a2", isError: false, tokenTotal: 0); + Assert.Equal(50, coordinator.Agents[1].TokenCount); + } + /// 组头文案 — 运行中 "Running N agents…",完成 "N agents finished"(混合类型不带类型名)。 [Fact] public void HeaderText_MixedTypesRunningAndFinished() diff --git a/test/mock/mock_server.e2e.tests/e2e/s_to_z/StreamingIncrementalE2ETests.cs b/test/mock/mock_server.e2e.tests/e2e/s_to_z/StreamingIncrementalE2ETests.cs new file mode 100644 index 0000000000..158c67ccfa --- /dev/null +++ b/test/mock/mock_server.e2e.tests/e2e/s_to_z/StreamingIncrementalE2ETests.cs @@ -0,0 +1,175 @@ +// JCC1017 抑制: 存量手写 JSON, 后续改为 DTO+JsonContext +// JCC11003 抑制: 存量代码可空抑制, 后续逐步修复 +#pragma warning disable JCC1017 +namespace MockServer.E2E.Tests.E2E; + +/// +/// 流式增量透传实弹 E2E(TASK043 / ADR 0146 验收)— 真实 OpenAI.MockServer 进程 + +/// GUI 同源引擎会话(EngineSessionFactory.CreateGuiSessionAsync): +/// MockServer 文本流按 word 逐分片下发(每片 20ms — KestrelMockServer 流式路径), +/// 断言消费方(ChatService.StreamWithEventsAsync)逐段收到 Content 事件—— +/// 首末分片到达时间差反映真实流时长(全量收集缺陷形态下全部事件同刻整发,时间差≈0)。 +/// +[Collection("SubAgentEventSerial")] +[Trait("Category", "Integration")] +public sealed class StreamingIncrementalE2ETests { + private readonly ITestOutputHelper _output; + + public StreamingIncrementalE2ETests(ITestOutputHelper output) => _output = output; + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] + [Fact] + public async Task LongTextStream_ContentArrivesIncrementally_NotBatchedAtEnd() { + var timings = await RunLongTextScenarioAsync().ConfigureAwait(true); + + timings.Count.Should().BeGreaterThan(1, + "长文本流应逐分片到达(整块单事件 = 透传断裂)"); + + var span = timings[^1] - timings[0]; + timings[^1].Should().BeGreaterThanOrEqualTo(timings[0]); + span.Should().BeGreaterThanOrEqualTo(200, + $"首末 Content 分片到达时间差应反映真实流时长(50 分片×20ms≈1000ms),实际 {span}ms — " + + "时间差≈0 意味全量收集后整发(TASK043 缺陷形态回归)"); + _output.WriteLine($"[STREAM] 分片数={timings.Count} 首分片={timings[0]}ms 末分片={timings[^1]}ms 跨度={span}ms"); + } + + /// + /// 共享实弹管线(对齐 SubAgentEventStreamE2ETests 先例):长文本脚本 → 真实 MockServer → + /// GUI 同源引擎会话 → 逐事件记录 Content 到达时刻(Stopwatch 毫秒)。 + /// + private async Task> RunLongTextScenarioAsync() { + var fs = IO.FileSystem.FileSystemFactory.Create(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + var ct = cts.Token; + + var configDir = fs.CombinePath(Path.GetTempPath(), $"jcc_stream_incremental_e2e_{Guid.NewGuid():N}"); + fs.CreateDirectory(configDir); + var configPath = fs.CombinePath(configDir, "mockserver.json"); + await fs.WriteAllText(configPath, BuildMockConfig()); + + using var mockServer = StartMockServer(fs, configPath, out var readyTask); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(25)); + var port = await readyTask.WaitAsync(timeoutCts.Token).ConfigureAwait(true); + Assert.True(port > 0, "MockServer 就绪行未解析出端口"); + + try { + using var envScope = EnvVarScope.Set("JCC_ENDPOINT", $"http://localhost:{port}") + .Add("OPENAI_API_KEY", "sk-test-stream-e2e") + .Add("JCC_VENDOR", "openai") + .Add("JCC_MODEL_ID", "gpt-4o") + .Add("JCC_PERMISSION_MODE", "bypass"); + + var session = await JoinCode.App.Builder.EngineSessionFactory.CreateGuiSessionAsync() + .ConfigureAwait(true); + try { + var bootstrapHandle = session.Services.GetService(); + if (bootstrapHandle is not null) + { + await bootstrapHandle.Completion.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(true); + } + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var timings = new List(); + await foreach (var evt in session.ChatService.StreamWithEventsAsync( + "给我讲一个很长的故事", ct).ConfigureAwait(true)) { + if (evt.Type == ChatStreamEventType.Content) { + timings.Add(stopwatch.ElapsedMilliseconds); + } + } + return timings; + } finally { + try { await session.Host.DisposeAsync(); } catch (Exception disposeEx) { _output.WriteLine($"[E2E清理] Host.DisposeAsync 异常(已忽略): {disposeEx.Message}"); } + } + } finally { + if (!mockServer.HasExited) { + try { mockServer.Kill(entireProcessTree: true); } catch (Exception killEx) { _output.WriteLine($"[E2E清理] MockServer 进程终止失败: {killEx.Message}"); } + } + mockServer.Dispose(); + try { fs.DeleteDirectory(configDir, recursive: true); } catch (Exception cleanEx) { _output.WriteLine($"[E2E清理] 临时配置目录删除失败: {cleanEx.Message}"); } + } + } + + /// 长文本脚本(50 词 — MockServer 按 word 逐分片×20ms 下发 ≈1s 流)。 + private static string BuildMockConfig() { + var words = Enumerable.Range(1, 50).Select(i => $"word{i}"); + var longText = string.Join(" ", words); + var sb = new StringBuilder(); + sb.AppendLine("{"); + sb.AppendLine(" \"port\": 0,"); + sb.AppendLine(" \"default_response\": \"(script exhausted)\","); + sb.AppendLine(" \"scripted_turns\": ["); + sb.AppendLine(" {"); + sb.AppendLine(" \"thinking_content\": null,"); + sb.AppendLine(" \"tool_calls\": null,"); + sb.Append(" \"text_response\": \"").Append(longText).AppendLine("\","); + sb.AppendLine(" \"follow_up_text\": null,"); + sb.AppendLine(" \"http_status_code\": null"); + sb.AppendLine(" }"); + sb.AppendLine(" ]"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private Process StartMockServer(JoinCode.Abstractions.Interfaces.IFileSystem fs, string configPath, + out Task readyTask) { + var exe = ResolveMockServerPath(fs); + var startInfo = new System.Diagnostics.ProcessStartInfo { + FileName = exe, + Arguments = $"--config \"{configPath}\" --port 0", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + WorkingDirectory = Path.GetDirectoryName(exe) + }; + + var process = new System.Diagnostics.Process { StartInfo = startInfo }; + var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + const string readyMarker = "[OpenAI] URL:"; + + process.OutputDataReceived += (_, e) => { + if (string.IsNullOrEmpty(e.Data)) + return; + _output.WriteLine($"[MockServer] {e.Data}"); + var idx = e.Data.IndexOf(readyMarker, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return; + var urlPart = e.Data[(idx + readyMarker.Length)..].Trim(); + var match = Regex.Match(urlPart, @":(\d+)/?"); + if (match.Success && int.TryParse(match.Groups[1].Value, out var port)) + readyTcs.TrySetResult(port); + }; + process.ErrorDataReceived += (_, e) => { + if (!string.IsNullOrEmpty(e.Data)) + _output.WriteLine($"[MockServer:ERR] {e.Data}"); + }; + + if (!process.Start()) + throw new InvalidOperationException("[GEN026] 无法启动 MockServer 进程"); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + readyTask = readyTcs.Task; + return process; + } + + private static string ResolveMockServerPath(JoinCode.Abstractions.Interfaces.IFileSystem fs) { + var dir = AppContext.BaseDirectory; + while (dir is not null) { + var candidate = Path.Combine(dir, "artifacts", "bin", "OpenAI.MockServer"); + if (fs.DirectoryExists(candidate)) { + var exeName = OperatingSystem.IsWindows() ? "JoinCode.OpenAI.MockServer.exe" : "JoinCode.OpenAI.MockServer"; + var exe = fs.GetFiles(candidate, exeName, SearchOption.AllDirectories) + .OrderByDescending(p => p.Contains("Release")) + .FirstOrDefault() + ?? throw new FileNotFoundException($"MockServer exe 未编译,请先 dotnet build OpenAI.MockServer", candidate); + return exe; + } + dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar)); + } + throw new InvalidOperationException("未找到 artifacts/bin/OpenAI.MockServer 目录"); + } +} diff --git a/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventMainAgentPathE2ETests.cs b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventMainAgentPathE2ETests.cs index 30c5d39e69..dee0fc4308 100644 --- a/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventMainAgentPathE2ETests.cs +++ b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventMainAgentPathE2ETests.cs @@ -36,6 +36,7 @@ public async Task MainAgentPath_AgentSpawn_ShouldDispatchSubagentCallbacks() { var finished = Assert.Single(consumer.SubagentFinishes); Assert.Equal(started.SubagentId, finished.SubagentId); Assert.False(finished.IsError); + Assert.True(finished.TokenTotal > 0, $"子代理完成事件应携带权威 token 总数(实际 {finished.TokenTotal})——P7 完成态校正链路断裂"); } /// @@ -183,7 +184,7 @@ private sealed class RecordingSubagentConsumer : JoinCode.Abstractions.Interface public List TextEvents { get; } = []; public List<(string SubagentId, string AgentType, string Description)> SubagentStarts { get; } = []; public List<(string SubagentId, string ToolName, string Summary, int TokenGain)> SubagentToolUses { get; } = []; - public List<(string SubagentId, bool IsError)> SubagentFinishes { get; } = []; + public List<(string SubagentId, bool IsError, int TokenTotal)> SubagentFinishes { get; } = []; public void OnText(string content) => TextEvents.Add(content); public List<(string toolName, string? arguments)> ToolStarts { get; } = []; @@ -197,7 +198,7 @@ public void OnTimingSummary(string summary) { } public void OnDone(TokenUsage? usage, string? modelId) { } public void OnSubagentStarted(string subagentId, string agentType, string description) => SubagentStarts.Add((subagentId, agentType, description)); public void OnSubagentToolUse(string subagentId, string toolName, string summary, int tokenGain) => SubagentToolUses.Add((subagentId, toolName, summary, tokenGain)); - public void OnSubagentFinished(string subagentId, bool isError) => SubagentFinishes.Add((subagentId, isError)); + public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) => SubagentFinishes.Add((subagentId, isError, tokenTotal)); } private System.Diagnostics.Process StartMockServer(JoinCode.Abstractions.Interfaces.IFileSystem fs, string configPath, diff --git a/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs b/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs index 291b76e714..1fea1f0de7 100644 --- a/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs +++ b/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs @@ -46,7 +46,7 @@ public async Task AgentFinishedEvent_RoutesToOnSubagentFinished() var controller = CreateController( [ ChatStreamEvent.AgentStarted("agent-1", "explore", "搜索"), - new ChatStreamEvent { Type = ChatStreamEventType.AgentFinished, AgentId = "agent-1", AgentSuccess = false }, + new ChatStreamEvent { Type = ChatStreamEventType.AgentFinished, AgentId = "agent-1", AgentSuccess = false, Usage = new TokenUsage(300, 50) }, ChatStreamEvent.Done(), ], consumer); @@ -56,6 +56,7 @@ public async Task AgentFinishedEvent_RoutesToOnSubagentFinished() var finished = Assert.Single(consumer.SubagentFinishes); Assert.Equal("agent-1", finished.SubagentId); Assert.True(finished.IsError); + Assert.Equal(350, finished.TokenTotal); } /// 带 AgentId 的工具事件 — 路由 OnSubagentToolUse 且不走普通 OnToolStart/OnToolEnd(分流)。 @@ -262,7 +263,7 @@ private sealed class RecordingSubagentConsumer : IEventConsumer { public List<(TokenUsage? usage, string? modelId)> DoneEvents { get; } = []; public List<(string SubagentId, string AgentType, string Description)> SubagentStarts { get; } = []; public List<(string SubagentId, string ToolName, string Summary, int TokenGain)> SubagentToolUses { get; } = []; - public List<(string SubagentId, bool IsError)> SubagentFinishes { get; } = []; + public List<(string SubagentId, bool IsError, int TokenTotal)> SubagentFinishes { get; } = []; public void OnText(string content) => TextEvents.Add(content); public void OnThinking(string thinking) { } @@ -274,6 +275,6 @@ public void OnTimingSummary(string summary) { } public void OnDone(TokenUsage? usage, string? modelId) => DoneEvents.Add((usage, modelId)); public void OnSubagentStarted(string subagentId, string agentType, string description) => SubagentStarts.Add((subagentId, agentType, description)); public void OnSubagentToolUse(string subagentId, string toolName, string summary, int tokenGain) => SubagentToolUses.Add((subagentId, toolName, summary, tokenGain)); - public void OnSubagentFinished(string subagentId, bool isError) => SubagentFinishes.Add((subagentId, isError)); + public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) => SubagentFinishes.Add((subagentId, isError, tokenTotal)); } } diff --git a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs index 21d68bfb2e..9fc728adae 100644 --- a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs +++ b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs @@ -21,12 +21,12 @@ public void SubagentCallbacks_EmitSubagentEventLines() { consumer.OnSubagentStarted("agent-1", "explore", "搜索渲染链"); consumer.OnSubagentToolUse("agent-1", "Grep", "命中 3 处", 120); - consumer.OnSubagentFinished("agent-1", isError: false); + consumer.OnSubagentFinished("agent-1", isError: false, tokenTotal: 1250); Assert.Equal( "{\"type\":\"subagent_started\",\"subagentId\":\"agent-1\",\"agentType\":\"explore\",\"description\":\"搜索渲染链\"}\n" + "{\"type\":\"subagent_tool_use\",\"subagentId\":\"agent-1\",\"toolName\":\"Grep\",\"summary\":\"命中 3 处\",\"tokenGain\":120}\n" + - "{\"type\":\"subagent_finished\",\"subagentId\":\"agent-1\",\"isError\":false}\n", + "{\"type\":\"subagent_finished\",\"subagentId\":\"agent-1\",\"isError\":false,\"tokenTotal\":1250}\n", @out.ToString()); } diff --git a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs index 968c6c2d37..feab945b64 100644 --- a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs +++ b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs @@ -110,12 +110,12 @@ public void Serialize_SubagentEvents_CarryCamelCaseFields() { var events = new CliBridge.BridgeEvent[] { new CliBridge.BridgeEvent.SubagentStarted("agent-1", "explore", "搜索渲染链"), new CliBridge.BridgeEvent.SubagentToolUse("agent-1", "Grep", "命中 3 处", 120), - new CliBridge.BridgeEvent.SubagentFinished("agent-1", IsError: true), + new CliBridge.BridgeEvent.SubagentFinished("agent-1", IsError: true, TokenTotal: 1250), }; var expected = new[] { "{\"type\":\"subagent_started\",\"subagentId\":\"agent-1\",\"agentType\":\"explore\",\"description\":\"搜索渲染链\"}", "{\"type\":\"subagent_tool_use\",\"subagentId\":\"agent-1\",\"toolName\":\"Grep\",\"summary\":\"命中 3 处\",\"tokenGain\":120}", - "{\"type\":\"subagent_finished\",\"subagentId\":\"agent-1\",\"isError\":true}", + "{\"type\":\"subagent_finished\",\"subagentId\":\"agent-1\",\"isError\":true,\"tokenTotal\":1250}", }; for (var i = 0; i < events.Length; i++) { Assert.Equal(expected[i], JsonSerializer.Serialize(events[i], CliBridge.BridgeJsonContext.Safe.BridgeEvent)); diff --git a/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs b/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs index 6f968192c3..5744bf4a33 100644 --- a/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs +++ b/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs @@ -102,4 +102,26 @@ public async Task Completion_ReflectsBackgroundChain_NotElapsed() await completion.WaitAsync(TimeSpan.FromSeconds(5)); Assert.True(handle.Completion.IsCompleted); } + + /// Completion 占位语义契约锁(TASK063 P8)— Run 前占位是"无可等之物"设计决定而非就绪信号: + /// ①Run 前 await 立即通过(占位完成 ≠ 插件就绪 — 误用后果见 xml 契约); + /// ②Run 后 Completion 必须切换为后台链 Task 实例(引用变更守卫 — 防实现退化为恒返回占位, + /// 该退化会令就绪信号永远假完成 → 消费方全部拿到空工具表且零异常暴露)。 + [Fact] + public async Task Completion_BeforeRun_Placeholder_NotReadinessSignal() + { + await using var handle = new McpPluginBootstrapHandle(); + var placeholder = handle.Completion; + Assert.True(placeholder.IsCompleted); + await placeholder.WaitAsync(TimeSpan.FromSeconds(1)); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + handle.Run(_ => gate.Task); + var chainTask = handle.Completion; + Assert.NotSame(placeholder, chainTask); + Assert.False(chainTask.IsCompleted); + + gate.SetResult(); + await chainTask.WaitAsync(TimeSpan.FromSeconds(5)); + } }