From 68396d6109ead39ce197a1bd037da6c7c5adf871 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 01:19:11 +0800
Subject: [PATCH 1/8] =?UTF-8?q?docs:=20TASK063=20=E6=97=A0=E4=BA=BA?=
=?UTF-8?q?=E5=80=BC=E5=AE=88=E6=89=B9=E6=AC=A1=E4=BB=BB=E5=8A=A1=E7=99=BB?=
=?UTF-8?q?=E8=AE=B0=EF=BC=88P7/P8=20=E6=94=B6=E5=B0=BE+TASK043=20?=
=?UTF-8?q?=E6=B5=81=E5=BC=8F=E7=BC=BA=E9=99=B7=EF=BC=8C=E5=90=AB=E9=AA=8C?=
=?UTF-8?q?=E6=94=B6=E6=A0=87=E5=87=86=EF=BC=9BA/D=20=E7=BB=8F=E6=A0=B8?=
=?UTF-8?q?=E5=AE=9E=E5=B7=B2=E5=AE=8C=E6=88=90=E5=88=92=E6=8E=89=E9=98=B2?=
=?UTF-8?q?=E9=87=8D=E5=81=9A=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...01\345\274\217\347\274\272\351\231\267.md" | 86 +++++++++++++++++++
1 file changed, 86 insertions(+)
create mode 100644 "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"
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 000000000..97433e7b6
--- /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,86 @@
+# TASK063 — 无人值守批次:P7/P8 收尾 + TASK043 流式透传缺陷
+
+> 状态:进行中(2026-10-22 创建;用户指令:"先记录任务到文档,要有验收标准,全部按顺序制作")
+> 模式:无人值守(用户睡眠)——主路径→边缘场景顺序推进,不停批不询问,每任务走「文档→代码→测试→真实运行验证→提交」全流程
+
+## 任务池盘点(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 投影 | ⏳ | ⏳ 单元测试:token 值非零且单调递增(fake 时钟确定性) |
+| 协议字段(若走方案2) | BridgeEvent.SubagentFinished + 双消费者 + BridgeJsonContractTests | ⏳ | ⏳ JSON 快照锁定 |
+| E2E 集成断言 | SubAgentEventMainAgentPathE2ETests | ⏳ | ⏳ MockServer 脚本含工具轮后 tokenGain/TokenTotal > 0 断言 |
+
+### P8 — Completion Run 前占位语义(边界守卫)
+
+**现状**:`McpPluginBootstrapHandle.Completion` Run 前返回 Task.CompletedTask——DI 有 Handle 但 McpInitModule 未跑 ConfigureAsync 的极端组装下,消费方 await Completion 立即返回误判"就绪"。
+
+**实施方向**:契约文档化 + 守卫测试补强(Run 未调用时 Completion 应表达"无可等之物"而非"就绪"——语义边界写进 xml 注释与专用测试;如需行为修正则加 `_started` 门闩等待)。
+
+**验收标准**:
+
+| 基建实现 | 消费点 | 已实现 | 已验收 |
+|---------|--------|--------|--------|
+| Completion 契约明确(注释+测试) | E2E/宿主等待方 | ⏳ | ⏳ 守卫测试:Run 前/后语义各自断言 |
+
+## 任务 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 超时)排查
+
+**验收标准**:
+
+| 基建实现 | 消费点 | 已实现 | 已验收 |
+|---------|--------|--------|--------|
+| 流式增量透传修复 | GUI 会话流/StreamingFallback 链路 | ⏳ | ⏳ E2E:chunked 脚本逐段到达断言(时序戳非全量后到) |
+| ~2s 取消根因修复(若复现) | HttpClient 流式配置 | ⏳ | ⏳ 超时语义测试 |
+| CI 白名单摘除 | ci-e2e 配置 | ⏳ | ⏳ 白名单移除后 CI 绿 |
+
+## 任务 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 规则挂起留用户,其余自主决策。
+
+
+
+
+
From 853d9e75bbd876b3f318a8e55728dc261a7084e5 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 02:18:20 +0800
Subject: [PATCH 2/8] =?UTF-8?q?feat:=20P7=20=E5=AD=90=E4=BB=A3=E7=90=86=20?=
=?UTF-8?q?token=20=E5=AE=8C=E6=88=90=E6=80=81=E6=9D=83=E5=A8=81=E6=A0=A1?=
=?UTF-8?q?=E6=AD=A3=E5=85=A8=E9=93=BE=E8=B4=AF=E9=80=9A=20|=20=E5=86=B3?=
=?UTF-8?q?=E7=AD=96:=20=E6=96=B9=E6=A1=882=E5=AE=8C=E6=88=90=E6=80=81?=
=?UTF-8?q?=E6=9D=83=E5=A8=81=E6=A0=A1=E6=AD=A3=E2=80=94=E2=80=94AgentFini?=
=?UTF-8?q?shed=20=E6=90=BA=E5=B8=A6=20tokenTotal(=E5=BC=95=E6=93=8E=20Usa?=
=?UTF-8?q?ge.TotalTokens),=20TUI=20FinishAgent=20>0=20=E8=A6=86=E7=9B=96/?=
=?UTF-8?q?0=20=E4=BF=9D=E6=8C=81=E5=8E=9F=E7=B4=AF=E8=AE=A1;=20=E6=A0=B9?=
=?UTF-8?q?=E5=9B=A0=E5=8F=8C=E4=BF=AE:=20=E2=91=A0QueryEngine=20=E8=AF=BB?=
=?UTF-8?q?=E5=AD=A4=E5=84=BF=20key(InputTokens/OutputTokens=20=E5=85=A8?=
=?UTF-8?q?=E4=BB=93=E6=97=A0=E7=94=9F=E4=BA=A7=E8=80=85)=E6=94=B9?=
=?UTF-8?q?=E8=AF=BB=20provider=20=E7=BB=9F=E4=B8=80=E7=BA=A6=E5=AE=9A=20m?=
=?UTF-8?q?etadata[Usage]=20=E5=B9=B6=E8=B7=A8=E8=BD=AE=E7=B4=AF=E8=AE=A1?=
=?UTF-8?q?=20=E2=91=A1BuildCompletionChunk=20=E8=A1=A5=20usage=20?=
=?UTF-8?q?=E5=8F=82=E6=95=B0=E9=80=8F=E4=BC=A0;=20=E4=B8=89=E5=8F=82?=
=?UTF-8?q?=E5=8D=8F=E8=AE=AE=E6=89=A9=E6=95=A3=20IEventConsumer/BridgePro?=
=?UTF-8?q?tocol/BridgeEvent/=E5=8F=8C=E6=B6=88=E8=B4=B9=E8=80=85/ReplBrid?=
=?UTF-8?q?geDriver/SubagentGroup=20=E5=85=A8=E6=94=B9=E9=BD=90;=20E2E=20?=
=?UTF-8?q?=E5=AE=9E=E8=AF=81=20tokenTotal>0=20=E4=B8=94=20ChatService=20?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=BF=9E=E5=B8=A6=E8=BD=AC=E7=BB=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../presentation/IEventConsumer.cs | 5 ++-
.../engine/adapters/BridgeEventConsumer.cs | 6 +--
.../apps/engine/adapters/SessionController.cs | 2 +-
modules/apps/engine/bridge/BridgeProtocol.cs | 5 ++-
.../query/core/QueryEngineTests.cs | 18 ++++++++
.../query2/token_budget/core/QueryEngine.cs | 42 ++++++++++++-------
.../NativeTui.Host/InProcessEventConsumer.cs | 6 +--
.../apps/NativeTui.Host/SubagentSelftest.cs | 4 +-
.../src/NativeTui.Core/bridge/BridgeEvent.cs | 2 +-
.../NativeTui.Core/bridge/ReplBridgeDriver.cs | 4 +-
.../NativeTui.Core/bridge/SimulatedEngine.cs | 2 +-
.../src/NativeTui.Core/repl/SubagentGroup.cs | 13 ++++--
.../InProcessEventConsumerTests.cs | 3 +-
.../repl/SubagentGroupTests.cs | 20 +++++++++
.../SubAgentEventMainAgentPathE2ETests.cs | 5 ++-
.../SessionControllerSubagentEventTests.cs | 7 ++--
.../bridge/BridgeEventConsumerTests.cs | 4 +-
.../bridge/BridgeJsonContractTests.cs | 4 +-
18 files changed, 107 insertions(+), 45 deletions(-)
diff --git a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs
index 62583a75e..1068bdb32 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 b3fb501f1..8b8e4d0f5 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 026475a18..0ce87479d 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/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs
index 18d570a4d..7d0b1394e 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 8c3d7b765..d8f1f78a5 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 fea77d224..fdc884dcb 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/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
index a14bf998b..cc246e9dc 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 82d2f0857..29ed9d140 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 eeae7fb2a..04c221ed7 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 1c50bf98c..88799f3c0 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;
diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
index b12c592dd..05186b5eb 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/SubagentGroup.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs
index f40684538..e31a6dad4 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/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
index 10a57f780..aef831a44 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.Tests/repl/SubagentGroupTests.cs b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs
index 78c0935e9..da9064449 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/SubAgentEventMainAgentPathE2ETests.cs b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventMainAgentPathE2ETests.cs
index 30c5d39e6..dee0fc430 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 291b76e71..1fea1f0de 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 21d68bfb2..9fc728ada 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 968c6c2d3..feab945b6 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));
From c3c5ec77aba6b1818491673c66ca9a6645a8fb12 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 02:24:14 +0800
Subject: [PATCH 3/8] =?UTF-8?q?docs:=20P8=20Completion=20Run=20=E5=89=8D?=
=?UTF-8?q?=E5=8D=A0=E4=BD=8D=E8=AF=AD=E4=B9=89=E5=A5=91=E7=BA=A6=E6=96=87?=
=?UTF-8?q?=E6=A1=A3=E5=8C=96+=E5=AE=88=E5=8D=AB=E9=94=81=20|=20=E5=86=B3?=
=?UTF-8?q?=E7=AD=96:=20=E5=A5=91=E7=BA=A6=E6=98=8E=E7=A1=AE=E5=8C=96?=
=?UTF-8?q?=E8=80=8C=E9=9D=9E=E8=AF=AD=E4=B9=89=E5=8F=98=E6=9B=B4=E2=80=94?=
=?UTF-8?q?=E2=80=94xml=20=E6=B3=A8=E9=87=8A=E5=86=99=E5=85=A8'=E6=97=A0?=
=?UTF-8?q?=E5=8F=AF=E7=AD=89=E4=B9=8B=E7=89=A9=E2=89=A0=E5=B0=B1=E7=BB=AA?=
=?UTF-8?q?'+=E8=AF=AF=E7=94=A8=E5=BD=A2=E6=80=81(Run=20=E5=89=8D=E5=81=87?=
=?UTF-8?q?=E5=B0=B1=E7=BB=AA=E2=86=92=E7=A9=BA=E5=B7=A5=E5=85=B7=E8=A1=A8?=
=?UTF-8?q?)+=E6=AD=A3=E7=A1=AE=E5=86=99=E6=B3=95=E8=AF=B1=E5=AF=BC(?=
=?UTF-8?q?=E5=AF=B9=E9=BD=90=E8=A7=84=E5=88=999);=20=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=E5=A5=91=E7=BA=A6=E9=94=81=E6=B5=8B=E8=AF=95=E9=98=B2=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0=E9=80=80=E5=8C=96(=E5=8D=A0=E4=BD=8D=E6=81=92?=
=?UTF-8?q?=E8=BF=94=E5=9B=9E=3D=E5=B0=B1=E7=BB=AA=E4=BF=A1=E5=8F=B7?=
=?UTF-8?q?=E6=B0=B8=E8=BF=9C=E5=81=87=E5=AE=8C=E6=88=90=E7=9A=84=E9=9D=99?=
=?UTF-8?q?=E9=BB=98=E8=87=B4=E5=91=BD=E7=BC=BA=E9=99=B7)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../engine/app/modules/McpPluginBootstrap.cs | 9 ++++++--
.../engine/McpPluginBootstrapHandleTests.cs | 22 +++++++++++++++++++
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/modules/apps/engine/app/modules/McpPluginBootstrap.cs b/modules/apps/engine/app/modules/McpPluginBootstrap.cs
index 49b61a753..b661cb717 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/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs b/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs
index 6f968192c..5744bf4a3 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));
+ }
}
From a9236b754cc475deadbdae5b6ee93adcb06abb24 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 02:47:22 +0800
Subject: [PATCH 4/8] =?UTF-8?q?feat:=20TASK043=20=E6=B5=81=E5=BC=8F?=
=?UTF-8?q?=E5=A2=9E=E9=87=8F=E9=80=8F=E4=BC=A0=E4=B8=A4=E9=98=B6=E6=AE=B5?=
=?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=88ADR=200146=EF=BC=89=20|=20=E5=86=B3?=
=?UTF-8?q?=E7=AD=96:=20=E9=A2=84=E7=83=AD(TryPrimeAsync=20=E5=8F=AF=20fal?=
=?UTF-8?q?lback)+=E9=80=8F=E4=BC=A0(=E8=BF=AD=E4=BB=A3=E5=99=A8=E4=B8=8A?=
=?UTF-8?q?=E6=8A=9B)=E4=B8=A4=E9=98=B6=E6=AE=B5=E6=B6=88=E9=99=A4=20CS162?=
=?UTF-8?q?6=20=E5=85=A8=E9=87=8F=E6=94=B6=E9=9B=86=E2=80=94=E2=80=94?=
=?UTF-8?q?=E9=A6=96=E4=BA=8B=E4=BB=B6=E5=89=8D=E5=A4=B1=E8=B4=A5=E4=BB=8D?=
=?UTF-8?q?=E9=99=8D=E7=BA=A7=E9=9D=9E=E6=B5=81=E5=BC=8F(=E8=BF=9E?=
=?UTF-8?q?=E6=8E=A5=E7=BA=A7=E9=94=99=E8=AF=AF=E6=97=A0=E6=8D=9F=E9=87=8D?=
=?UTF-8?q?=E8=AF=95),=E5=B7=B2=20yield=20=E5=90=8E=E5=A4=B1=E8=B4=A5?=
=?UTF-8?q?=E4=B8=8A=E6=8A=9B=E9=98=B2=E9=87=8D=E5=A4=8D=E8=BE=93=E5=87=BA?=
=?UTF-8?q?;=20watchdog/enumerator=20=E6=89=80=E6=9C=89=E6=9D=83=E7=BB=8F?=
=?UTF-8?q?=20PrimeOutcome=20=E8=BD=AC=E7=A7=BB=E7=94=B1=20finally=20?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=87=8A=E6=94=BE;=20=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E5=8F=96=E6=B6=88=E9=80=8F=E4=BC=A0=E6=9C=9F=E6=94=B9=E6=A0=87?=
=?UTF-8?q?=E5=87=86=20OCE=20=E4=B8=8A=E6=8A=9B=E8=AF=AD=E4=B9=89;=20?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=94=B9=E9=80=A0=3D=E7=8E=B0=E6=9C=89=20fal?=
=?UTF-8?q?lback=20=E7=94=A8=E4=BE=8B=E5=88=87=E9=A6=96=E4=BA=8B=E4=BB=B6?=
=?UTF-8?q?=E5=89=8D=E5=A4=B1=E8=B4=A5=E5=BD=A2=E6=80=81(=E5=AF=B9?=
=?UTF-8?q?=E9=BD=90=E7=9C=9F=E5=AE=9E=E8=BF=9E=E6=8E=A5=E9=94=99=E8=AF=AF?=
=?UTF-8?q?)+=E6=96=B0=E5=A2=9E=E9=97=A8=E9=97=A9=E7=A1=AE=E5=AE=9A?=
=?UTF-8?q?=E6=80=A7=E5=A2=9E=E9=87=8F=E7=BA=A2=E6=B5=8B=E8=AF=95=E4=B8=8E?=
=?UTF-8?q?=E4=B8=AD=E9=80=94=E4=B8=8A=E6=8A=9B=E5=AE=88=E5=8D=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...cremental-passthrough-fallback-boundary.md | 44 ++++++
.../Fallback/StreamingFallbackDecorator.cs | 128 ++++++++++++------
.../StreamingFallbackDecoratorTests.cs | 106 ++++++++++++++-
3 files changed, 227 insertions(+), 51 deletions(-)
create mode 100644 docs/proposed/adr/component_strategy/0146-streaming-incremental-passthrough-fallback-boundary.md
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 000000000..8c23725a7
--- /dev/null
+++ b/docs/proposed/adr/component_strategy/0146-streaming-incremental-passthrough-fallback-boundary.md
@@ -0,0 +1,44 @@
+# ADR 0146 — 流式增量透传与 fallback 首事件边界
+
+> 状态:proposed(2026-10-22;随 TASK043 实施转 accepted)
+> 关联: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/modules/llm/core/Adapters/LLM/Fallback/StreamingFallbackDecorator.cs b/modules/llm/core/Adapters/LLM/Fallback/StreamingFallbackDecorator.cs
index 694ed50bf..ecc77ff2e 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/llm.tests/Adapters/Fallback/StreamingFallbackDecoratorTests.cs b/modules/llm/llm.tests/Adapters/Fallback/StreamingFallbackDecoratorTests.cs
index 1cfadab20..bb36779e4 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(),
From 152578f6f776e14cea8b70e9cfa4df915c1b2beb Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 01:22:11 +0800
Subject: [PATCH 5/8] =?UTF-8?q?fix:=20spinner=20=E5=8F=AA=E5=9C=A8?=
=?UTF-8?q?=E5=9B=9E=E5=90=88=E8=BF=9B=E8=A1=8C=E4=B8=AD=E6=98=BE=E7=A4=BA?=
=?UTF-8?q?=EF=BC=88=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=20showSpinner=20?=
=?UTF-8?q?=E8=AF=AD=E4=B9=89=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20?=
=?UTF-8?q?=E8=B0=83=E7=A0=94=E5=9F=BA=E5=87=86=20REPL.tsx:1704-1717=20?=
=?UTF-8?q?=E5=AE=9E=E8=AF=81=E2=80=94=E2=80=94showSpinner=20=E6=98=AF?=
=?UTF-8?q?=E5=A4=8D=E5=90=88=E6=9D=A1=E4=BB=B6=EF=BC=88isLoading/?=
=?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=98=9F=E5=88=97/=E9=98=9F=E5=8F=8B?=
=?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=89=8D=E6=98=BE=E7=A4=BA=EF=BC=8C=E7=A9=BA?=
=?UTF-8?q?=E9=97=B2=E9=9A=90=E8=97=8F=EF=BC=9B=E6=B5=81=E5=BC=8F=E6=96=87?=
=?UTF-8?q?=E6=9C=AC=E5=8F=AF=E8=A7=81=E6=97=B6=E4=B9=9F=E9=9A=90=E8=97=8F?=
=?UTF-8?q?=E2=80=94=E2=80=94=E6=96=87=E6=9C=AC=E5=8D=B3=E5=8F=8D=E9=A6=88?=
=?UTF-8?q?=EF=BC=89=EF=BC=9BTUI=20=E7=8E=B0=E7=8A=B6=E6=98=AF=20spinner?=
=?UTF-8?q?=20=E5=B8=B8=E9=A9=BB=E6=B8=B2=E6=9F=93=EF=BC=88=E7=A9=BA?=
=?UTF-8?q?=E9=97=B2=E4=B9=9F=E9=97=AA=20Inferring=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?=E6=A0=B9=E5=9B=A0=3D=E6=97=A0=20Hidden=20=E6=A6=82=E5=BF=B5?=
=?UTF-8?q?=EF=BC=9B=E5=AE=9E=E7=8E=B0=20SpinnerItem.Hidden=EF=BC=88Measur?=
=?UTF-8?q?eHeight=200+Render=20=E9=9B=B6=E8=BE=93=E5=87=BA=EF=BC=89?=
=?UTF-8?q?=EF=BC=8Cdriver=20BeginTurn=20=E6=98=BE=E7=A4=BA/EndTurn=20?=
=?UTF-8?q?=E9=9A=90=E8=97=8F=EF=BC=8CReplShell=20=E5=88=9D=E5=A7=8B=20Hid?=
=?UTF-8?q?den=3Dtrue=EF=BC=88=E5=90=AF=E5=8A=A8=E5=8D=B3=E7=A9=BA?=
=?UTF-8?q?=E9=97=B2=EF=BC=89=EF=BC=9BTUI=20=E6=97=A0=E6=B5=81=E5=BC=8F?=
=?UTF-8?q?=E6=AD=A3=E6=96=87=EF=BC=88=E5=9B=9E=E5=90=88=E7=BB=93=E6=9D=9F?=
=?UTF-8?q?=E6=89=8D=E4=B8=8A=E5=B1=8F=EF=BC=89=E6=95=85=20token=5Fdelta?=
=?UTF-8?q?=20=E6=9C=9F=E4=BF=9D=E7=95=99=E6=98=BE=E7=A4=BA=E2=80=94?=
=?UTF-8?q?=E2=80=94=E7=AD=89=E4=BB=B7=E5=9F=BA=E5=87=86=E9=A6=96=E8=A1=8C?=
=?UTF-8?q?=E6=9C=AA=E5=AE=8C=E6=88=90=E9=98=B6=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../NativeTui.Core/bridge/ReplBridgeDriver.cs | 8 +-
.../src/NativeTui.Core/repl/ReplShell.cs | 2 +-
.../src/NativeTui.Core/repl/SpinnerItem.cs | 16 +++-
.../repl/SpinnerVisibilityTests.cs | 82 +++++++++++++++++++
4 files changed, 102 insertions(+), 6 deletions(-)
create mode 100644 native_tui/tests/NativeTui.Tests/repl/SpinnerVisibilityTests.cs
diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
index 88799f3c0..8d95f7ea3 100644
--- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
+++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
@@ -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/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index fc60ed56e..64a39b95b 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();
diff --git a/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs b/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs
index d979ab93a..4aeb93ba8 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/tests/NativeTui.Tests/repl/SpinnerVisibilityTests.cs b/native_tui/tests/NativeTui.Tests/repl/SpinnerVisibilityTests.cs
new file mode 100644
index 000000000..d8dbb7d10
--- /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));
+ }
+}
From 10457ddd4e74ec056a3058fbb5525f0eb8f6b015 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 02:08:57 +0800
Subject: [PATCH 6/8] =?UTF-8?q?fix:=20TUI=20=E6=96=9C=E6=9D=A0=E5=91=BD?=
=?UTF-8?q?=E4=BB=A4=E6=8E=A5=E9=80=9A=E5=BC=95=E6=93=8E=EF=BC=88/effort?=
=?UTF-8?q?=20=E7=AD=89=E4=B8=8D=E5=86=8D=E8=90=BD=20LLM=20=E5=BD=93?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20?=
=?UTF-8?q?=E6=A0=B9=E5=9B=A0=3DTUI=20=E5=9B=9E=E5=90=88=E6=97=A0=E6=96=9C?=
=?UTF-8?q?=E6=9D=A0=E5=88=86=E6=B5=81=EF=BC=88CLI=20=E6=9C=89=20CliSessio?=
=?UTF-8?q?n.ProcessInputAsync=20=E7=9A=84=20StartsWith('/')=20=E5=88=86?=
=?UTF-8?q?=E6=B5=81=EF=BC=8CTUI=20=E7=9B=B4=E6=8E=A5=E5=85=A8=E5=8F=91=20?=
=?UTF-8?q?mainAgent=E2=86=92LLM=EF=BC=8C=E5=91=BD=E4=BB=A4=E8=A2=AB?=
=?UTF-8?q?=E5=BD=93=E8=81=8A=E5=A4=A9=E6=96=87=E6=9C=AC=E8=A7=A6=E5=8F=91?=
=?UTF-8?q?=20AI=EF=BC=89=EF=BC=9B=E4=BF=AE=E5=A4=8D=E8=B5=B0=E5=BC=95?=
=?UTF-8?q?=E6=93=8E=E6=97=A2=E6=9C=89=20SlashCommandRunner=20=E5=85=B1?=
=?UTF-8?q?=E4=BA=AB=E9=93=BE=E8=B7=AF=EF=BC=88=E8=A7=A3=E6=9E=90=E2=86=92?=
=?UTF-8?q?CmdMap=20=E8=B7=AF=E7=94=B1=E2=86=92Console.Out=20=E6=8D=95?=
=?UTF-8?q?=E8=8E=B7=E2=86=92=E8=BE=93=E5=87=BA=EF=BC=89=EF=BC=8CEngineSes?=
=?UTF-8?q?sion=20=E5=9B=9E=E5=90=88=E7=BB=91=E5=AE=9A=E5=B1=82=E5=8A=A0?=
=?UTF-8?q?=20StartsWith('/')=20=E5=88=86=E6=B5=81=EF=BC=8C=E5=91=BD?=
=?UTF-8?q?=E4=BB=A4=E8=BE=93=E5=87=BA=E7=BB=8F=20consumer.OnText/OnDone?=
=?UTF-8?q?=20=E4=B8=8A=E5=B1=8F=E4=B8=BA=20AssistantMessage=EF=BC=88Markd?=
=?UTF-8?q?own=20=E6=B8=B2=E6=9F=93=EF=BC=89=EF=BC=9B=E6=9C=AA=E7=9F=A5?=
=?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=90=8C=E5=B1=8F=E8=AF=B1=E5=AF=BC=EF=BC=88?=
=?UTF-8?q?/help=20=E6=8F=90=E7=A4=BA=EF=BC=89=E4=B8=8D=E8=90=BD=20LLM?=
=?UTF-8?q?=E2=80=94=E2=80=94=E4=B8=A4=E7=B1=BB=E9=83=BD=E4=B8=8D=E6=B6=88?=
=?UTF-8?q?=E8=80=97=E6=A8=A1=E5=9E=8B=E8=B0=83=E7=94=A8=EF=BC=9B=E5=AE=9E?=
=?UTF-8?q?=E6=B5=8B=20/effort=20378ms=20=E8=BF=94=E5=9B=9E=E7=9C=9F?=
=?UTF-8?q?=E5=AE=9E=E5=91=BD=E4=BB=A4=E8=BE=93=E5=87=BA=E3=80=81/nosuchcm?=
=?UTF-8?q?d=20136ms=20=E8=BF=94=E5=9B=9E=E6=9C=AA=E7=9F=A5=E8=AF=B1?=
=?UTF-8?q?=E5=AF=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/NativeTui.Host/EngineSession.cs | 25 +++++++++++++++++++
.../apps/NativeTui.Host/GlobalUsings.cs | 1 +
2 files changed, 26 insertions(+)
diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs
index 5aedac0c3..830f25c81 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,26 @@ private static async Task RunTurnAsync(SessionController controller, string inpu
throw ex;
}
+ /// 斜杠命令回合(TASK064 — 引擎 SlashCommandRunner 共享链路:解析→路由→执行→输出捕获):
+ /// 输出经 consumer.OnText/OnDone 上屏(AssistantMessage);未知命令同样上屏诱导(/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 = 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 29ca5b8a2..cfae9ef2c 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;
From f8414c502942c90be976300e8a328a2ee377a17a Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 02:48:53 +0800
Subject: [PATCH 7/8] =?UTF-8?q?fix:=20/btw=20=E4=B8=A4=E7=BC=BA=E9=99=B7?=
=?UTF-8?q?=EF=BC=88=E5=8D=A0=E4=BD=8D=E7=AC=A6=E8=A1=A5=E5=85=A8+ANSI?=
=?UTF-8?q?=E6=AE=8B=E9=AA=B8=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20?=
=?UTF-8?q?=E2=91=A0=E6=A0=B9=E5=9B=A0=3DAcceptSuggestion=20=E5=8F=82?=
=?UTF-8?q?=E6=95=B0=E6=A8=A1=E5=BC=8F=E6=8A=8A=20ArgumentHint=20=E5=8D=A0?=
=?UTF-8?q?=E4=BD=8D=E7=AC=A6=E5=BD=93=E5=8F=82=E6=95=B0=E5=80=BC=E5=A1=AB?=
=?UTF-8?q?=E5=85=A5=E6=8F=90=E4=BA=A4=EF=BC=88/btw=20=20?=
=?UTF-8?q?=E5=AD=97=E9=9D=A2=E9=87=8F=E5=8F=91=E7=BB=99=20AI=EF=BC=8CAI?=
=?UTF-8?q?=20=E5=9B=9E=E5=A4=8D=E9=97=AE=E9=A2=98=E5=86=85=E5=AE=B9?=
=?UTF-8?q?=E4=B8=BA=E7=A9=BA=EF=BC=89=E2=80=94=E2=80=94SlashSuggester=20?=
=?UTF-8?q?=E5=A2=9E=20HasRealArgumentValues=EF=BC=88=E7=9C=9F=E5=80=BC?=
=?UTF-8?q?=E6=B8=85=E5=8D=95=E5=88=A4=E5=AE=9A=EF=BC=89=EF=BC=8C=E5=8D=A0?=
=?UTF-8?q?=E4=BD=8D=E6=8F=90=E7=A4=BA=E6=80=81=20Tab=20=E4=B8=8D=E5=A1=AB?=
=?UTF-8?q?=E5=85=A5=20Enter=20=E6=8F=90=E4=BA=A4=E8=BE=93=E5=85=A5?=
=?UTF-8?q?=E5=8E=9F=E6=96=87=EF=BC=8C=E7=9C=9F=E5=80=BC=E6=B8=85=E5=8D=95?=
=?UTF-8?q?=E5=91=BD=E4=BB=A4=EF=BC=88/effort=20low=EF=BC=89=E8=A1=A5?=
=?UTF-8?q?=E5=85=A8=E4=B8=8D=E5=8F=97=E5=BD=B1=E5=93=8D=E5=9B=9E=E5=BD=92?=
=?UTF-8?q?=E9=94=81=E5=AE=9A=EF=BC=9B=E2=91=A1=E6=A0=B9=E5=9B=A0=3D?=
=?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=BE=93=E5=87=BA=20ANSI=20=E8=89=B2?=
=?UTF-8?q?=E7=A0=81=E4=B8=8E=20TUI=20Cell/StyleId=20=E6=B8=B2=E6=9F=93?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8D=E5=85=BC=E5=AE=B9=EF=BC=88ESC=20?=
=?UTF-8?q?=E9=9B=B6=E5=AE=BD=E8=B7=B3=E8=BF=87=E6=AE=8B=E9=AA=B8=E6=98=BE?=
=?UTF-8?q?=E7=A4=BA=2038;2;153;153;153m=20=E5=BD=A2=E6=80=81=EF=BC=89?=
=?UTF-8?q?=E2=80=94=E2=80=94DispatchSlashCommandAsync=20=E4=B8=8A?=
=?UTF-8?q?=E5=B1=8F=E5=89=8D=20AnsiStripper.Strip=EF=BC=88=E5=A4=8D?=
=?UTF-8?q?=E7=94=A8=E5=BC=95=E6=93=8E=E7=89=88=20stackalloc=20=E5=85=A8?=
=?UTF-8?q?=E5=BA=8F=E5=88=97=E5=89=A5=E7=A6=BB=EF=BC=8C=E5=88=A0=E8=87=AA?=
=?UTF-8?q?=E5=BB=BA=E9=87=8D=E5=A4=8D=E5=AE=9E=E7=8E=B0=EF=BC=89=EF=BC=9B?=
=?UTF-8?q?=E5=9F=BA=E7=BA=BF=20825+67=20=E5=85=A8=E7=BB=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/NativeTui.Host/EngineSession.cs | 6 +-
.../src/NativeTui.Core/repl/ReplShell.cs | 14 ++-
.../repl/suggestions/SlashSuggester.cs | 30 +++++++
.../SlashCommandAnsiTests.cs | 19 +++++
.../repl/SlashInteractionDefectTests.cs | 85 +++++++++++++++++++
5 files changed, 151 insertions(+), 3 deletions(-)
create mode 100644 native_tui/tests/NativeTui.Host.Tests/SlashCommandAnsiTests.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/SlashInteractionDefectTests.cs
diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs
index 830f25c81..a6c910c42 100644
--- a/native_tui/apps/NativeTui.Host/EngineSession.cs
+++ b/native_tui/apps/NativeTui.Host/EngineSession.cs
@@ -133,7 +133,9 @@ private static async Task RunTurnAsync(SessionController controller, string inpu
}
/// 斜杠命令回合(TASK064 — 引擎 SlashCommandRunner 共享链路:解析→路由→执行→输出捕获):
- /// 输出经 consumer.OnText/OnDone 上屏(AssistantMessage);未知命令同样上屏诱导(/help 提示)— 不落 LLM
+ /// 输出经 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) {
@@ -144,7 +146,7 @@ private static async Task DispatchSlashCommandAsync(
readPassword: null,
cancellationToken: ct).ConfigureAwait(false);
- var output = result.Output.TrimEnd();
+ var output = AnsiStripper.Strip(result.Output).TrimEnd();
if (output.Length > 0) {
consumer.OnText(output);
}
diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index 64a39b95b..6492bfffd 100644
--- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs
+++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
@@ -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/suggestions/SlashSuggester.cs b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs
index cf2708f6d..0d0222dd2 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/SlashCommandAnsiTests.cs b/native_tui/tests/NativeTui.Host.Tests/SlashCommandAnsiTests.cs
new file mode 100644
index 000000000..aa4778d20
--- /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 000000000..79cff868b
--- /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);
+ }
+}
From 532baa471e7d4569169d995805b064550d645e53 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 02:56:42 +0800
Subject: [PATCH 8/8] =?UTF-8?q?test+docs:=20TASK043=20=E5=AE=9E=E5=BC=B9?=
=?UTF-8?q?=E5=A2=9E=E9=87=8F=20E2E=20=E8=BF=9B=20CI=20=E7=99=BD=E5=90=8D?=
=?UTF-8?q?=E5=8D=95=20+=20TASK063=20=E5=85=A8=E9=AA=8C=E6=94=B6=E8=A1=A8?=
=?UTF-8?q?=E6=94=B6=E5=8F=A3=20|=20=E5=86=B3=E7=AD=96:=20=E7=9C=9F?=
=?UTF-8?q?=E6=9C=BA=20CDP=20=E8=84=9A=E6=9C=AC(f1-webui-flow.mjs)?=
=?UTF-8?q?=E6=9C=AA=E5=85=A5=E5=BA=93,=E4=BB=A5=E5=AE=9E=E5=BC=B9=20E2E?=
=?UTF-8?q?=20=E7=AD=89=E4=BB=B7=E8=A6=86=E7=9B=96(MockServer=2050?=
=?UTF-8?q?=E5=88=86=E7=89=87=C3=9720ms=E7=9C=9F=E5=AE=9E=E6=B5=81+?=
=?UTF-8?q?=E5=BC=95=E6=93=8E=E5=85=A8=E7=AE=A1=E9=81=93,=E9=A6=96?=
=?UTF-8?q?=E6=9C=AB=20Content=20=E6=97=B6=E9=97=B4=E5=B7=AE=E5=88=A4?=
=?UTF-8?q?=E6=8D=AE=E2=80=94=E2=80=94=E5=85=A8=E9=87=8F=E6=94=B6=E9=9B=86?=
=?UTF-8?q?=E5=BD=A2=E6=80=81=E4=B8=8B=E2=89=880=20=E5=BF=85=E7=BA=A2);=20?=
=?UTF-8?q?TASK043=20=E7=8A=B6=E6=80=81=E6=94=B9=20implemented,=20ADR=2001?=
=?UTF-8?q?46=20=E8=BD=AC=20accepted,=20TASK063=20P7/P8/E=20=E4=B8=89?=
=?UTF-8?q?=E8=A1=A8=E5=85=A8=E5=8B=BE+Auto=20Decision=20=E4=B8=89?=
=?UTF-8?q?=E5=86=B3=E7=AD=96=E7=95=99=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/ci-e2e.yml | 2 +-
...cremental-passthrough-fallback-boundary.md | 2 +-
...01\345\274\217\347\274\272\351\231\267.md" | 53 +++++-
...71\347\225\214\344\273\273\345\212\241.md" | 7 +-
.../s_to_z/StreamingIncrementalE2ETests.cs | 175 ++++++++++++++++++
5 files changed, 228 insertions(+), 11 deletions(-)
create mode 100644 test/mock/mock_server.e2e.tests/e2e/s_to_z/StreamingIncrementalE2ETests.cs
diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml
index f4951d639..88825cb50 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
index 8c23725a7..3e5a6a802 100644
--- 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
@@ -1,6 +1,6 @@
# ADR 0146 — 流式增量透传与 fallback 首事件边界
-> 状态:proposed(2026-10-22;随 TASK043 实施转 accepted)
+> 状态:accepted(2026-10-22 落地:单元红→绿 + llm.tests 605 全绿 + 实弹 E2E StreamingIncrementalE2ETests 绿,随 TASK043 实施)
> 关联:TASK043(modules/llm/docs/task/TASK043-流式增量透传与fallback边界任务.md)、ADR 0080(手动 exe 验收)
## 上下文
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"
index 97433e7b6..0f555773c 100644
--- "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"
@@ -1,7 +1,8 @@
# TASK063 — 无人值守批次:P7/P8 收尾 + TASK043 流式透传缺陷
-> 状态:进行中(2026-10-22 创建;用户指令:"先记录任务到文档,要有验收标准,全部按顺序制作")
+> 状态:**implemented(2026-10-22 无人值守批次全部完成——提交链 853d9e75b(P7) / c3c5ec77a(P8) / a9236b754(E))**
> 模式:无人值守(用户睡眠)——主路径→边缘场景顺序推进,不停批不询问,每任务走「文档→代码→测试→真实运行验证→提交」全流程
+> 唯余:F 用户真机验收清单(见文末,醒后执行)
## 任务池盘点(2026-10-22 核实)
@@ -31,9 +32,16 @@
| 基建实现 | 消费点 | 已实现 | 已验收 |
|---------|--------|--------|--------|
-| token 数据源接线(调研选型落地) | RouteSubagentEvent / SubagentFinished 投影 | ⏳ | ⏳ 单元测试:token 值非零且单调递增(fake 时钟确定性) |
-| 协议字段(若走方案2) | BridgeEvent.SubagentFinished + 双消费者 + BridgeJsonContractTests | ⏳ | ⏳ JSON 快照锁定 |
-| E2E 集成断言 | SubAgentEventMainAgentPathE2ETests | ⏳ | ⏳ MockServer 脚本含工具轮后 tokenGain/TokenTotal > 0 断言 |
+| 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 前占位语义(边界守卫)
@@ -45,7 +53,12 @@
| 基建实现 | 消费点 | 已实现 | 已验收 |
|---------|--------|--------|--------|
-| Completion 契约明确(注释+测试) | E2E/宿主等待方 | ⏳ | ⏳ 守卫测试:Run 前/后语义各自断言 |
+| 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 流式透传缺陷
@@ -63,9 +76,16 @@
| 基建实现 | 消费点 | 已实现 | 已验收 |
|---------|--------|--------|--------|
-| 流式增量透传修复 | GUI 会话流/StreamingFallback 链路 | ⏳ | ⏳ E2E:chunked 脚本逐段到达断言(时序戳非全量后到) |
-| ~2s 取消根因修复(若复现) | HttpClient 流式配置 | ⏳ | ⏳ 超时语义测试 |
-| CI 白名单摘除 | ci-e2e 配置 | ⏳ | ⏳ 白名单移除后 CI 绿 |
+| 流式增量透传修复 | 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:用户真机验收清单(醒后执行)
@@ -84,3 +104,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 505ff1b37..e984f738f 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/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 000000000..158c67ccf
--- /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 目录");
+ }
+}