From 918da529ef160f510b6225d8bec55330eb45d71b Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 07:09:17 +0800 Subject: [PATCH 01/30] =?UTF-8?q?fix:=20/mcp=20=E9=80=80=E5=87=BA=E7=AB=9E?= =?UTF-8?q?=E6=80=81=20ObjectDisposedException=20=E9=99=8D=E7=BA=A7=20Debu?= =?UTF-8?q?g=EF=BC=88CI=20E2E=20NoErrors=20=E7=BA=A2=EF=BC=89=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E6=A0=B9=E5=9B=A0=E6=98=AF=20CLI=20?= =?UTF-8?q?=E5=8D=95=E5=91=BD=E4=BB=A4=E9=80=80=E5=87=BA=E9=87=8A=E6=94=BE?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=EF=BC=88LocalToolRegistry.DisposeAsync=20?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E5=86=85=E9=83=A8=E5=8C=BF=E5=90=8D=E9=94=81?= =?UTF-8?q?=20AsyncLock#2=EF=BC=89=E4=B8=8E=E5=90=8E=E5=8F=B0=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E9=93=BE=E5=B0=B1=E7=BB=AA=E9=92=A9=E5=AD=90=20Refres?= =?UTF-8?q?hKernelPluginsAsync=20=E7=AB=9E=E6=80=81=EF=BC=8C=E5=8E=9F=20ca?= =?UTF-8?q?tch=20=E4=B8=80=E5=BE=8B=20LogError=20=E8=BF=9B=20stderr=20?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=20E2E=20=E6=96=AD=E8=A8=80=E7=BA=A2=EF=BC=9B?= =?UTF-8?q?ObjectDisposedException=20=E5=B1=9E=E9=80=80=E5=87=BA=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E8=89=AF=E6=80=A7=E5=99=AA=E9=9F=B3=EF=BC=88=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E5=B0=86=E4=BA=A1=E5=88=B7=E6=96=B0=E6=97=A0=E6=84=8F?= =?UTF-8?q?=E4=B9=89=EF=BC=89=E9=99=8D=E7=BA=A7=20LogDebug=20=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20McpPluginBootstrap=20=E5=8F=96=E6=B6=88=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=85=88=E4=BE=8B=EF=BC=8C=E5=85=B6=E4=BD=99=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E4=BF=9D=E6=8C=81=20Error=20=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=95=85=E9=9A=9C=E4=B8=8D=E9=9D=99=E9=BB=98=EF=BC=9B=E6=8F=90?= =?UTF-8?q?=E5=8F=96=20HandleRefreshError=20internal=20=E6=9C=89=E5=90=8D?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E4=BE=9B=E6=B5=8B=E8=AF=95=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/engine/app/modules/McpInitModule.cs | 17 ++++- .../host.tests/engine/McpInitModuleTests.cs | 63 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 test/unit/host.tests/engine/McpInitModuleTests.cs diff --git a/modules/apps/engine/app/modules/McpInitModule.cs b/modules/apps/engine/app/modules/McpInitModule.cs index 36635e2eb..da8d68cfe 100644 --- a/modules/apps/engine/app/modules/McpInitModule.cs +++ b/modules/apps/engine/app/modules/McpInitModule.cs @@ -100,7 +100,7 @@ private static async Task OnPluginsReadyAsync(IServiceProvider services, ILogger var toolsBridge = services.GetRequiredService(); await toolsBridge.OnToolsListChangedAsync(ct).ConfigureAwait(false); } catch (Exception ex) { - logger?.LogError(ex, "[MCP] OnToolsListChangedAsync failed"); + HandleRefreshError(ex, logger); } // 把 IToolRegistry 中的工具挂载到 kernel.Plugins — 修复断裂的工具管线 @@ -134,7 +134,20 @@ private static async Task RefreshKernelPluginsAsync(IServiceProvider services, I // 降级 Debug 避免 E2E NoErrors 断言误报(CLI 每轮退出与 McpPluginBootstrap 后台加载的时序差) logger?.LogDebug(ex, "[MCP] 刷新 kernel.Plugins 跳过:引擎已关闭"); } catch (Exception ex) { - logger?.LogError(ex, "[MCP] 刷新 kernel.Plugins 失败"); + HandleRefreshError(ex, logger); } } + + /// 刷新错误分类处置(internal — 退出竞态降级测试面):ObjectDisposedException = + /// 退出竞态良性噪音(宿主释放容器后 LocalToolRegistry 内部锁已释放,后台就绪钩子/变更事件仍 + /// 触发刷新 — 进程将亡刷新无意义)→ Debug 静默,对齐 McpPluginBootstrap"宿主关闭取消静默"先例; + /// 其余异常保持 Error(真实故障不得静默 — CI E2E NoErrors 断言依赖 stderr 无 fail 级噪音)。 + internal static void HandleRefreshError(Exception ex, ILogger? logger) { + if (ex is ObjectDisposedException) { + logger?.LogDebug(ex, "[MCP] 刷新 kernel.Plugins 跳过 — 退出竞态(工具注册表已随宿主释放)"); + return; + } + + logger?.LogError(ex, "[MCP] 刷新 kernel.Plugins 失败"); + } } \ No newline at end of file diff --git a/test/unit/host.tests/engine/McpInitModuleTests.cs b/test/unit/host.tests/engine/McpInitModuleTests.cs new file mode 100644 index 000000000..d099761fe --- /dev/null +++ b/test/unit/host.tests/engine/McpInitModuleTests.cs @@ -0,0 +1,63 @@ +namespace JoinCode.App.Modules.Tests; + +/// +/// McpInitModule 刷新错误降级测试(修复 CI E2E:McpCommand_ShouldListServers 撞 +/// ObjectDisposedException"锁 'AsyncLock#2' 已释放")— 根因:CLI 单命令模式退出释放容器 +/// (LocalToolRegistry.DisposeAsync 释放内部 AsyncLock#2)与后台插件链就绪钩子 +/// RefreshKernelPluginsAsync 竞态;原 catch 一律 LogError(fail 级)进 stderr 触发 E2E NoErrors 断言红。 +/// 降级语义:ObjectDisposedException = 退出竞态良性噪音(进程将亡刷新无意义)→ LogDebug 静默, +/// 对齐 McpPluginBootstrap"宿主关闭取消静默"先例;其余异常保持 LogError。 +/// +public class McpInitModuleTests +{ + /// ObjectDisposedException — 降级 Debug 不打 Error(退出竞态良性)。 + [Fact] + public void HandleRefreshError_ObjectDisposed_DowngradedToDebug() + { + var logger = new SpyLogger(); + var ex = new ObjectDisposedException(nameof(AsyncLock), "锁 'AsyncLock#2' 已释放"); + + McpInitModule.HandleRefreshError(ex, logger); + + Assert.Empty(logger.ErrorCalls); + Assert.Contains(logger.DebugCalls, c => c.Contains("退出竞态")); + } + + /// 其他异常 — 保持 Error 级(真实故障不得静默;消息模板固定,异常文本经 state 传递)。 + [Fact] + public void HandleRefreshError_OtherException_KeepsErrorLevel() + { + var logger = new SpyLogger(); + var ex = new InvalidOperationException("工具注册表状态损坏"); + + McpInitModule.HandleRefreshError(ex, logger); + + Assert.Contains("[MCP] 刷新 kernel.Plugins 失败", logger.ErrorCalls); + Assert.Empty(logger.DebugCalls); + } + + /// logger null — 两分支均不抛(DI 无日志场景)。 + [Fact] + public void HandleRefreshError_NullLogger_NoThrow() + { + McpInitModule.HandleRefreshError(new ObjectDisposedException("x"), null); + McpInitModule.HandleRefreshError(new InvalidOperationException("y"), null); + } + + /// 记录型 logger 假体 — 按级别分桶捕获消息。 + private sealed class SpyLogger : ILogger + { + public List ErrorCalls { get; } = []; + public List DebugCalls { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = formatter(state, exception); + if (logLevel == LogLevel.Error) ErrorCalls.Add(message); + if (logLevel == LogLevel.Debug) DebugCalls.Add(message); + } + } +} From 99570c68f5dbd44e43873a460d7be4a888747070 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 07:21:28 +0800 Subject: [PATCH 02/30] =?UTF-8?q?fix:=20=E6=B8=85=E7=90=86=E6=97=A0?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/Directory.Build.targets | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 libs/Directory.Build.targets diff --git a/libs/Directory.Build.targets b/libs/Directory.Build.targets deleted file mode 100644 index ab3d5548b..000000000 --- a/libs/Directory.Build.targets +++ /dev/null @@ -1,10 +0,0 @@ - - - - <_BadSourceRoot Include="@(SourceRoot)" Condition="!$([System.String]::Copy('%(Identity)').EndsWith('\')) AND !$([System.String]::Copy('%(Identity)').EndsWith('/'))" /> - - - <_BadSourceRoot Remove="@(_BadSourceRoot)" /> - - - From 796d1c5f04bd3caa725ee672ea54c1ab529bf565 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 07:46:13 +0800 Subject: [PATCH 03/30] =?UTF-8?q?feat:=20=E9=80=80=E5=87=BA=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=8C=89=E9=94=AE=EF=BC=88=E5=8F=8C=E5=87=BBEsc?= =?UTF-8?q?=E4=B8=AD=E6=96=AD+Ctrl+C=E5=A4=8D=E5=88=B6=EF=BC=8C=E6=B0=B8?= =?UTF-8?q?=E4=B8=8D=E8=AF=AF=E9=80=80=E5=87=BA=EF=BC=89=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20=E7=94=A8=E6=88=B7=E7=97=9B=E7=82=B9=E6=98=AFEsc?= =?UTF-8?q?=E9=97=B2=E6=97=B6/Ctrl+C=E8=AF=AF=E8=A7=A6=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E7=BB=88=E7=AB=AF=E2=80=94=E2=80=94Esc?= =?UTF-8?q?=E6=94=B9=E5=8F=8C=E5=87=BB=E7=8A=B6=E6=80=81=E6=9C=BA=EF=BC=88?= =?UTF-8?q?=E5=BF=99=E6=97=B6500ms=E5=86=85=E4=B8=A4=E5=87=BBInterrupt?= =?UTF-8?q?=EF=BC=8C=E5=8D=95=E5=87=BB=E7=AD=89=E7=AA=97=E5=8F=A3=EF=BC=9B?= =?UTF-8?q?=E9=97=B2=E6=97=B6=E6=B8=85=E8=BE=93=E5=85=A5=EF=BC=9B=E6=B0=B8?= =?UTF-8?q?=E4=B8=8D=E7=BD=AE=5Frunning=3Dfalse=EF=BC=89=EF=BC=8C=E5=8F=8C?= =?UTF-8?q?=E5=87=BB=E7=AA=97=E5=8F=A3=E7=94=A8internal=20Func=20Esc?= =?UTF-8?q?Clock=E6=B3=A8=E5=85=A5=E6=B5=8B=E8=AF=95=EF=BC=9BCtrl+C?= =?UTF-8?q?=E6=94=B9=E5=A4=8D=E5=88=B6=E6=9C=80=E5=90=8Eassistant=E6=B6=88?= =?UTF-8?q?=E6=81=AF=EF=BC=88=E5=89=AA=E8=B4=B4=E6=9D=BF=E5=86=99=E5=85=A5?= =?UTF-8?q?=E8=B5=B0=E6=9E=84=E9=80=A0=E5=A7=94=E6=89=98Action?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=B3=A8=E5=85=A5=E5=BC=95=E6=93=8EIClipboar?= =?UTF-8?q?dService=EF=BC=8CCore=E9=9B=B6=E4=BE=9D=E8=B5=96ADR=200140?= =?UTF-8?q?=EF=BC=8C=E5=A4=8D=E5=88=B6=E5=90=8EPostInfo=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E9=80=80=E5=87=BA=E8=B7=AF=E5=BE=84=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E4=B8=BACtrl+D=E7=A9=BA=E8=BE=93=E5=85=A5+/exit?= =?UTF-8?q?=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...11\345\205\250\346\214\211\351\224\256.md" | 25 +++ native_tui/apps/NativeTui.Host/Program.cs | 6 +- .../src/NativeTui.Core/repl/ReplShell.cs | 56 +++++- .../NativeTui.Tests/repl/ExitSafetyTests.cs | 174 ++++++++++++++++++ .../NativeTui.Tests/repl/ReplShellTests.cs | 11 +- 5 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 "docs/proposed/task/TASK059-\351\200\200\345\207\272\345\256\211\345\205\250\346\214\211\351\224\256.md" create mode 100644 native_tui/tests/NativeTui.Tests/repl/ExitSafetyTests.cs diff --git "a/docs/proposed/task/TASK059-\351\200\200\345\207\272\345\256\211\345\205\250\346\214\211\351\224\256.md" "b/docs/proposed/task/TASK059-\351\200\200\345\207\272\345\256\211\345\205\250\346\214\211\351\224\256.md" new file mode 100644 index 000000000..3cc46525d --- /dev/null +++ "b/docs/proposed/task/TASK059-\351\200\200\345\207\272\345\256\211\345\205\250\346\214\211\351\224\256.md" @@ -0,0 +1,25 @@ +# TASK059 — 退出安全按键(双击 Esc 中断 / Ctrl+C 复制 / 永不误退出) + +> 状态:in_progress(2026-10-21 开工) +> 前置:TASK058 完结;用户指令:"双击ESC是中断对话, Ctrl+C是复制, 这两个都不要关闭终端, 因为很不舒服" + +## 痛点(用户真机反馈) + +| 键 | 现行为 | 问题 | +|----|--------|------| +| Esc 闲时空输入 | `_running = false` 退出 | 想打断按 Esc,回合恰好结束 → 终端直接关 | +| Ctrl+C | `_running = false` 退出 | 想复制的肌肉记忆 → 终端直接关 | + +## 方案(Core 零依赖 + 宿主委托注入) + +1. **Esc 双击中断**:忙时 500ms 内两击 → `Interrupt()`;单击无操作(等双击窗口,时钟 `internal Func EscClock` 可注入测试);闲时有文本清输入(保留);**Esc 永不退出** +2. **Ctrl+C 复制**:复制滚动区最后一条 AssistantMessageItem 文本(基准语义:复制最后输出);剪贴板写入经构造参数 `Action? copyToClipboard` 宿主注入(ADR 0140 — Core 不依赖引擎 IClipboardService);复制后 PostInfo 反馈;无 assistant 消息/未注入委托时无操作 +3. **退出路径**:Ctrl+D 空输入退出(已有)+ `/exit` 引擎命令(ExitCommand 确认对话框)— 文档明示 + +## 验收表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| Esc 双击状态机(ReplShell.EscClock + HandleEscape 改造) | 忙时中断链 | ✅ | 待真机 | +| Ctrl+C 复制(copyToClipboard 委托 + 最后 assistant 消息) | Program 接线引擎 IClipboardService | ✅ | 待真机 | +| 退出路径保留(Ctrl+D + /exit) | 文档说明 | ✅ | 待真机 | diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 0d2a9136b..0f8a07ca8 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -80,7 +80,11 @@ private static async Task RunInteractiveAsync() { VendorPickerAssembly.ApplyVendor(session.Services, vendor); session.RefreshSessionInfo(); }); - var shell = new ReplShell(pickerEngine, banner, tuiOutput, commands: commands); + // TASK059 退出安全 — Esc 永不退出(忙时双击中断)、Ctrl+C 复制最后 assistant 消息; + // 退出路径:Ctrl+D(空输入)或 /exit 命令。剪贴板走引擎 IClipboardService(fire-and-forget 写入)。 + var clipboardService = session.Services.GetService(); + var shell = new ReplShell(pickerEngine, banner, tuiOutput, commands: commands, + clipboard: text => _ = clipboardService?.SetTextAsync(text)); pickerEngine.Attach(shell); await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false); session.PermissionBridge.Bind(shell); diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 2ff839f2b..040387db3 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -64,6 +64,22 @@ public sealed class ReplShell /// 进入搜索前的输入草稿(Esc 退出恢复)。 private string _preSearchDraft = string.Empty; + /// 上次 Esc 按下时间戳(ms — 双击窗口判定;0 = 无先前击)。 + private long _lastEscapeMs; + + /// Esc 双击窗口时长(ms — 窗口内两击构成双击中断)。 + internal const int EscapeDoubleClickWindowMs = 500; + + /// Esc 时钟源(internal — 测试注入可控时间戳;默认墙钟)。 + internal Func EscClock { get; set; } = static () => Environment.TickCount64; + + /// 剪贴板写入委托(TASK059 — Ctrl+C 复制最后 assistant 消息;宿主注入引擎 + /// IClipboardService 适配,Core 零依赖不持接口;null = 复制能力降级无操作)。 + private readonly Action? _copyToClipboard; + + /// 主循环存活标记(internal — 退出安全测试断言面:Esc/Ctrl+C 永不置 false)。 + internal bool IsRunning => _running; + /// 启动 banner(首项 — 真实引擎由 session_info 事件原地更新)。 private readonly BannerItem _banner; @@ -89,7 +105,8 @@ public sealed class ReplShell /// inline=渲染模式(默认主屏 log-update);seedMessages=种子消息(banner 之后、spinner 之前); /// commands=斜杠命令清单(宿主注入;null = 无补全能力); /// codeHighlighter=代码高亮插件(M7-5 卫星注入;null = 高亮禁用降级纯文本); - /// sizeSource=终端尺寸源(resize 事件化注入面;null = Console 轮询默认实现)。 + /// sizeSource=终端尺寸源(resize 事件化注入面;null = Console 轮询默认实现); + /// clipboard=剪贴板写入委托(TASK059 Ctrl+C 复制;null = 复制降级无操作)。 public ReplShell( IReplEngine engine, BannerItem banner, @@ -98,7 +115,8 @@ public ReplShell( IReadOnlyList? seedMessages = null, IReadOnlyList? commands = null, ICodeHighlighter? codeHighlighter = null, - ITerminalSizeSource? sizeSource = null) + ITerminalSizeSource? sizeSource = null, + Action? clipboard = null) { _engine = engine; _banner = banner; @@ -107,6 +125,7 @@ public ReplShell( _seedMessages = seedMessages ?? []; _suggester = commands is { Count: > 0 } ? new SlashSuggester(commands) : null; _sizeSource = sizeSource ?? new ConsoleTerminalSizeSource(); + _copyToClipboard = clipboard; _driver = new ReplBridgeDriver(_view, _spinner, _banner, BeginPermissionRequest, codeHighlighter); } @@ -438,7 +457,7 @@ private void HandleCtrlKey(string name) _input.Left(); break; case "c": - _running = false; + CopyLastAssistantMessage(); break; case "d": if (_input.Text.Length == 0) @@ -893,21 +912,48 @@ private void FillInput(string text, bool cursorToStart) private void UpdateSuggestions() => _suggester?.Update(_input.Text); /// Esc:忙时打断当前回合;闲时有输入清空(对齐基准双击清空的单击近似),无输入退出。 + /// Esc 处置(TASK059 退出安全 — Esc 永不退出):忙时 500ms 双击 → 打断(单击等窗口无操作); + /// 闲时有文本 → 清空输入(既有行为);闲时空输入 → 无操作。退出路径只剩 Ctrl+D 与 /exit 命令。 internal void HandleEscape() { + var now = EscClock(); + var isDoubleTap = _lastEscapeMs != 0 && now - _lastEscapeMs <= EscapeDoubleClickWindowMs; + _lastEscapeMs = now; + if (IsBusy) { - _engine.Interrupt(); + if (isDoubleTap) + { + _lastEscapeMs = 0; + _engine.Interrupt(); + } + return; } if (_input.Text.Length > 0) { _input.Clear(); + } + } + + /// Ctrl+C 复制(TASK059 — 原退出行为移除):滚动区最后一条 assistant 消息文本 → + /// 剪贴板委托(宿主注入;null = 降级无操作)+ PostInfo 反馈;无 assistant 消息时无操作。 + internal void CopyLastAssistantMessage() + { + if (_copyToClipboard is null) + { + return; + } + + var last = _view.Messages.Items.OfType().LastOrDefault(); + if (last is null) + { return; } - _running = false; + _copyToClipboard(last.Text); + PostInfo("已复制到剪贴板"); } /// Shift+Tab 循环权限模式(jcc 引擎模式集合)— 发引擎应用请求,footer 以 permission_mode_changed 回执为准。 diff --git a/native_tui/tests/NativeTui.Tests/repl/ExitSafetyTests.cs b/native_tui/tests/NativeTui.Tests/repl/ExitSafetyTests.cs new file mode 100644 index 000000000..f26183f57 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/ExitSafetyTests.cs @@ -0,0 +1,174 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 退出安全按键测试(TASK059 — 用户痛点:Esc/Ctrl+C 误触直接关闭终端)— +/// Esc 永不退出(忙时 500ms 双击中断,单击等窗口;闲时清输入); +/// Ctrl+C 复制最后 assistant 消息(剪贴板委托注入,零依赖 ADR 0140); +/// 退出路径只剩 Ctrl+D(空输入)与 /exit 命令。 +/// +public sealed class ExitSafetyTests +{ + /// 可控时钟 — 逐击推进(每击 +100ms,双击窗口 500ms 内)。 + private sealed class ManualClock { + private long _ms; + public long Tick() => _ms += 100; + public void Advance(int ms) => _ms += ms; + } + + /// 构造外壳 + 假引擎 + 可控 Esc 时钟(busy 经 TurnStarted 事件驱动;SeedContent 对齐 Run 首步挂 spinner)。 + private static (ReplShell Shell, FakeReplEngine Engine, ManualClock Clock) CreateShell(Action? clipboard = null) { + var engine = new FakeReplEngine(); + var banner = new BannerItem("0.0.0", "m", "b", "C:\\"); + var shell = new ReplShell(engine, banner, new StringWriter(), clipboard: clipboard); + shell.SeedContent(); + var clock = new ManualClock(); + shell.EscClock = clock.Tick; + return (shell, engine, clock); + } + + private static ConsoleKeyInfo Esc() => new('\0', ConsoleKey.Escape, false, false, false); + private static ConsoleKeyInfo CtrlC() => new('\x03', ConsoleKey.C, false, false, false); + + /// busy 置位(TurnStarted 事件)。 + private static void MakeBusy(ReplShell shell, FakeReplEngine engine) { + engine.Events.Enqueue(new BridgeEvent.TurnStarted()); + shell.DrainEngine(); + } + + // ---- Esc 双击中断 ---- + + /// 忙时双击(500ms 内)→ Interrupt。 + [Fact] + public void Busy_DoubleEscape_Interrupts() + { + var (shell, engine, _) = CreateShell(); + MakeBusy(shell, engine); + + shell.HandleKey(Esc()); + shell.HandleKey(Esc()); + + Assert.Equal(1, engine.Interrupts); + } + + /// 忙时单击 → 无操作(等双击窗口,不打断不退出)。 + [Fact] + public void Busy_SingleEscape_WaitsForSecondTap() + { + var (shell, engine, _) = CreateShell(); + MakeBusy(shell, engine); + + shell.HandleKey(Esc()); + + Assert.Equal(0, engine.Interrupts); + Assert.True(shell.IsRunning); + } + + /// 忙时两击超窗(>500ms)→ 不构成双击不 Interrupt(各自孤立单击)。 + [Fact] + public void Busy_EscapeOutsideDoubleClickWindow_NoInterrupt() + { + var (shell, engine, clock) = CreateShell(); + MakeBusy(shell, engine); + + shell.HandleKey(Esc()); + clock.Advance(6000); + shell.HandleKey(Esc()); + + Assert.Equal(0, engine.Interrupts); + Assert.True(shell.IsRunning); + } + + /// 闲时空输入连击 Esc → 永不退出(原行为 _running=false 移除)。 + [Fact] + public void Idle_EscapeEmptyInput_NeverExits() + { + var (shell, engine, _) = CreateShell(); + + shell.HandleKey(Esc()); + shell.HandleKey(Esc()); + shell.HandleKey(Esc()); + + Assert.True(shell.IsRunning); + Assert.Equal(0, engine.Interrupts); + } + + /// 闲时有文本 Esc → 清空输入(既有行为保留,不退出)。 + [Fact] + public void Idle_EscapeWithText_ClearsInputNotExit() + { + var (shell, _, _) = CreateShell(); + shell.HandleKey(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); + + shell.HandleKey(Esc()); + + Assert.Equal(string.Empty, shell.View.Input); + Assert.True(shell.IsRunning); + } + + // ---- Ctrl+C 复制 ---- + + /// Ctrl+C → 剪贴板委托收到最后一条 assistant 消息文本。 + [Fact] + public void CtrlC_CopiesLastAssistantMessage() + { + var copied = new List(); + var (shell, engine, _) = CreateShell(copied.Add); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("第一条回答")); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("最后一条回答")); + shell.DrainEngine(); + + shell.HandleKey(CtrlC()); + + Assert.Equal(["最后一条回答"], copied); + Assert.True(shell.IsRunning); + } + + /// Ctrl+C 无 assistant 消息 → 无复制无异常。 + [Fact] + public void CtrlC_NoAssistantMessage_NoCopyNoThrow() + { + var copied = new List(); + var (shell, _, _) = CreateShell(copied.Add); + + shell.HandleKey(CtrlC()); + + Assert.Empty(copied); + Assert.True(shell.IsRunning); + } + + /// Ctrl+C 未注入剪贴板委托(Demo/测试)→ 无操作不抛。 + [Fact] + public void CtrlC_NullClipboard_NoThrow() + { + var (shell, _, _) = CreateShell(clipboard: null); + + shell.HandleKey(CtrlC()); + + Assert.True(shell.IsRunning); + } + + // ---- 退出路径保留 ---- + + /// Ctrl+D 空输入 → 退出(唯一按键退出路径,对齐基准)。 + [Fact] + public void CtrlD_EmptyInput_Exits() + { + var (shell, _, _) = CreateShell(); + + shell.HandleKey(new ConsoleKeyInfo('\x04', ConsoleKey.D, false, false, false)); + + Assert.False(shell.IsRunning); + } + + /// Ctrl+D 有文本 → 删除光标字符不退出(既有行为保留)。 + [Fact] + public void CtrlD_WithText_DeletesNotExits() + { + var (shell, _, _) = CreateShell(); + shell.HandleKey(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); + + shell.HandleKey(new ConsoleKeyInfo('\x04', ConsoleKey.D, false, false, false)); + + Assert.True(shell.IsRunning); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs index 2638efc03..4e6eaa0d2 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs @@ -129,7 +129,7 @@ public void DrainEngine_EngineDeadWhileBusy_KeepsRunning() Assert.True(shell.Running); } - /// Esc 忙时:请求引擎打断(Interrupt 透传)。 + /// Esc 忙时:双击才请求引擎打断(TASK059 退出安全 — 单击等双击窗口无操作)。 [Fact] public void Escape_WhileBusy_InterruptsEngine() { @@ -138,6 +138,8 @@ public void Escape_WhileBusy_InterruptsEngine() engine.Events.Enqueue(new BridgeEvent.TurnStarted()); shell.DrainEngine(); + shell.HandleEscape(); + Assert.Equal(0, engine.Interrupts); shell.HandleEscape(); Assert.Equal(1, engine.Interrupts); Assert.True(shell.Running); @@ -156,15 +158,16 @@ public void Escape_IdleWithText_ClearsInput() Assert.Equal(string.Empty, shell.Input.Text); } - /// Esc 闲时无输入:请求退出。 + /// Esc 闲时无输入:永不退出(TASK059 退出安全 — 退出路径只剩 Ctrl+D 与 /exit 命令)。 [Fact] - public void Escape_IdleEmpty_Stops() + public void Escape_IdleEmpty_NeverStops() { var (shell, _) = CreateShell(); shell.SeedContent(); shell.HandleEscape(); - Assert.False(shell.Running); + shell.HandleEscape(); + Assert.True(shell.Running); } /// 提交:空文本忽略(不产用户消息、不透传引擎)。 From ff27492f6449f8d757e7fc9d1584c764a3a5cb07 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 08:08:49 +0800 Subject: [PATCH 04/30] =?UTF-8?q?feat:=20=E4=B8=80=E9=97=AE=E4=B8=80?= =?UTF-8?q?=E7=AD=94=E8=8A=82=E5=A5=8F=EF=BC=88=E5=BF=99=E6=97=B6=E6=8E=92?= =?UTF-8?q?=E9=98=9F+=E6=9C=AA=E7=99=BB=E5=BD=95=E5=8D=B3=E6=97=B6?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=97=9B=E7=82=B9=E6=98=AF=E5=BF=99=E6=97=B6?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E8=A2=AB=E5=BF=BD=E7=95=A5=E8=87=B4=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E6=8F=90=E9=97=AE=E5=A0=86=E5=8F=A0=E6=97=A0=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=E3=80=81=E6=9C=AA=E7=99=BB=E5=BD=95=E6=97=B6=E8=B0=83?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E5=82=BB=E7=AD=8940s=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=BF=99=E6=97=B6=E6=94=B9=E5=9F=BA=E5=87=86queued=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=EF=BC=88=E7=94=A8=E6=88=B7=E8=BE=93=E5=85=A5=E4=B8=8A?= =?UTF-8?q?=E5=B1=8F+=E2=8E=BF=E5=B7=B2=E6=8E=92=E9=98=9Fdim=E8=A1=8C?= =?UTF-8?q?=E5=A4=8D=E7=94=A8ToolResultItem=E3=80=81=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E7=8E=AF=E4=B8=8D=E8=B0=83=E5=BC=95=E6=93=8E=EF=BC=8C?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E7=BB=93=E6=9D=9FDrainEngine=E9=98=9F?= =?UTF-8?q?=E5=88=97=E6=B3=B5=E9=80=90=E6=9D=A1=E8=87=AA=E5=8A=A8=E5=8F=91?= =?UTF-8?q?=E9=80=81=E7=BB=B4=E6=8C=81=E4=B8=80=E9=97=AE=E4=B8=80=E7=AD=94?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=9C=AA=E7=99=BB=E5=BD=95=E8=B5=B0SessionIn?= =?UTF-8?q?fo.LoggedIn=E5=AD=97=E6=AE=B5=E9=93=BE=EF=BC=88=E5=BC=95?= =?UTF-8?q?=E6=93=8EApiKey=E5=88=A4=E5=AE=9A=E2=86=92driver=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E6=80=81+footer=E7=9C=9F=E5=AE=9E=E5=8C=96=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E7=A1=AC=E7=BC=96=E7=A0=81=E7=A7=8D=E5=AD=90=EF=BC=89?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=9C=AC=E5=9C=B0=E2=8E=BF=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=E8=A1=8C=E4=B8=8D=E8=B0=83=E5=BC=95=E6=93=8E=EF=BC=9BLoggedIn?= =?UTF-8?q?=E9=BB=98=E8=AE=A4true=E6=97=A7=E5=BC=95=E6=93=8E=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E4=B8=8D=E8=AF=AF=E6=8B=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...00\347\255\224\350\212\202\345\245\217.md" | 34 ++++++ .../apps/NativeTui.Host/EngineSession.cs | 7 +- .../src/NativeTui.Core/bridge/BridgeEvent.cs | 7 +- .../NativeTui.Core/bridge/ReplBridgeDriver.cs | 14 ++- .../src/NativeTui.Core/repl/MessageItems.cs | 6 + .../src/NativeTui.Core/repl/ReplShell.cs | 40 ++++++- .../EngineSessionTests.cs | 12 ++ .../NativeTui.Tests/repl/LoginGateTests.cs | 95 ++++++++++++++++ .../NativeTui.Tests/repl/QueuedSubmitTests.cs | 105 ++++++++++++++++++ 9 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 "docs/proposed/task/TASK060-\344\270\200\351\227\256\344\270\200\347\255\224\350\212\202\345\245\217.md" create mode 100644 native_tui/tests/NativeTui.Tests/repl/LoginGateTests.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs diff --git "a/docs/proposed/task/TASK060-\344\270\200\351\227\256\344\270\200\347\255\224\350\212\202\345\245\217.md" "b/docs/proposed/task/TASK060-\344\270\200\351\227\256\344\270\200\347\255\224\350\212\202\345\245\217.md" new file mode 100644 index 000000000..b04c74345 --- /dev/null +++ "b/docs/proposed/task/TASK060-\344\270\200\351\227\256\344\270\200\347\255\224\350\212\202\345\245\217.md" @@ -0,0 +1,34 @@ +# TASK060 — 一问一答节奏(忙时排队 + 未登录友好提示) + +> 状态:in_progress(2026-10-21 开工) +> 前置:TASK059 完结;用户真机反馈(对照基准截图):"没有登录它提示多友好啊,而且消息区域是一问一答,我的提问倒是连续发送了两个" + +## 痛点(用户真机截图分析) + +| 现象 | 根因 | +|------|------| +| `❯ 112` 40s 无响应后 `❯ 3333` 又提交,两条提问堆叠无回答夹中间 | 忙时提交被忽略(EngineTurnClient 回 Error 诱导行),一问一答节奏破坏 | +| footer "未登录"是硬编码种子,未登录时输入仍调引擎傻等 | 登录态未从引擎真实获取(session_info 无此字段) | + +## 方案(两步渐进) + +### Step 1:忙时排队(Core 层,对齐基准 queued 语义) +- `ReplShell` 加排队队列:忙时 Submit → 用户输入上屏 + `⎿ 已排队` dim 行(不调引擎); + 回合结束(DrainEngine 检测 !IsBusy)自动按序发送队首(不上屏重复) +- 新消息组件 `InlineNoteItem`(` ⎿ ` 前缀 dim 行)— queued 提示与未登录提示复用 +- EngineTurnClient 忙拒逻辑保留兜底(不再被 Core 拦截路径触达) + +### Step 2:未登录友好提示(引擎→TUI 全链) +- `BridgeEvent.SessionInfo` 加 `LoggedIn`(bool,末尾可选零破坏) +- 引擎侧 `BuildSessionInfoEvent` 判定:`config.Provider.ApiKey` 非空 = 已登录(key 可用即能对话) +- TUI:driver 应用 → FooterState.LoginError 真实化;未登录时 Submit 本地回 + `⎿ 未登录 · 请配置 auth.json 或运行 /login`(不调引擎不傻等) + +## 验收表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| InlineNoteItem(⎿ dim 行) | queued/未登录提示复用 | 待做 | 待真机 | +| 忙时排队队列(ReplShell) | Submit/回合结束自动发送 | 待做 | 待真机 | +| SessionInfo.LoggedIn 字段链 | EngineSession 判定 + driver footer | 待做 | 待真机 | +| 未登录本地即时反馈 | Submit 拦截 | 待做 | 待真机 | diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs index 9d58c0bcd..5aedac0c3 100644 --- a/native_tui/apps/NativeTui.Host/EngineSession.cs +++ b/native_tui/apps/NativeTui.Host/EngineSession.cs @@ -85,18 +85,21 @@ public static async Task CreateAsync(CancellationToken cancellati } /// 组装会话握手事件(internal — 测试断言 SetPrimaryModel 后 Model 字段更新)。版本(引擎程序集)/当前模型(FastMode 主模型,未设置为 null)/ - /// 权限模式(安全闸后的实际值)/bypass 可用性(!DisableBypassGate)/cwd/供应商(WorkflowConfig.Provider.Vendor,TASK058 banner 显示)。 + /// 权限模式(安全闸后的实际值)/bypass 可用性(!DisableBypassGate)/cwd/供应商(WorkflowConfig.Provider.Vendor,TASK058 banner 显示)/ + /// 登录态(Provider.ApiKey 非空 = 能对话,TASK060 TUI 提交门禁依据)。 internal static BridgeEvent.SessionInfo BuildSessionInfoEvent(PermissionChecker? checker, IFastModeService? fastMode, WorkflowConfig? config = null) { var version = typeof(EngineSessionFactory).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0"; var mode = checker?.CurrentMode ?? EnginePermissionMode.Auto; var vendor = config?.Provider?.Vendor; + var loggedIn = !string.IsNullOrWhiteSpace(config?.Provider?.ApiKey); return new BridgeEvent.SessionInfo( Version: version, Model: string.IsNullOrWhiteSpace(fastMode?.PrimaryModelId) ? null : fastMode.PrimaryModelId, PermissionMode: EnginePermissionModeExtensions.ToValue(mode), BypassAvailable: checker?.DisableBypassGate is not true, Cwd: Environment.CurrentDirectory, - Vendor: string.IsNullOrWhiteSpace(vendor) ? null : vendor); + Vendor: string.IsNullOrWhiteSpace(vendor) ? null : vendor, + LoggedIn: loggedIn); } /// 构造权限模式应用器 — 解析字符串(FromValue)→ 应用(CurrentMode setter 含 bypass 安全闸)→ diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index 80ad0a28c..e5dd5dbbc 100644 --- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs +++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs @@ -26,11 +26,12 @@ namespace NativeTui.Core.Bridge; public abstract record BridgeEvent { /// - /// 会话握手(引擎启动后首条事件)— 版本/当前模型/权限模式/bypass 可用性/cwd/供应商。 + /// 会话握手(引擎启动后首条事件)— 版本/当前模型/权限模式/bypass 可用性/cwd/供应商/登录态。 /// Model 为 null 表示引擎未设置模型(banner 显示"当前模型未设置"); - /// Vendor 为 null 表示引擎未上报(banner 退化为不带供应商前缀,旧引擎兼容)。 + /// Vendor 为 null 表示引擎未上报(banner 退化为不带供应商前缀,旧引擎兼容); + /// LoggedIn 默认 true(旧引擎未上报登录态不拦截 — 仅新引擎显式 false 时 TUI 门禁关闭)。 /// - public sealed record SessionInfo(string Version, string? Model, string PermissionMode, bool BypassAvailable, string Cwd, string? Vendor = null) : BridgeEvent; + public sealed record SessionInfo(string Version, string? Model, string PermissionMode, bool BypassAvailable, string Cwd, string? Vendor = null, bool LoggedIn = true) : BridgeEvent; /// 权限模式切换回执(mode 为引擎实际生效值 — 安全闸拦截时与请求值不同)。 public sealed record PermissionModeChanged(string Mode) : BridgeEvent; diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index 2de748ec8..1eba604e3 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -47,13 +47,16 @@ public ReplBridgeDriver( /// bypass 模式是否可用(引擎 session_info 报告 — settings.json 安全闸禁用时为 false,Cycler 跳过 bypass)。 public bool BypassAvailable { get; private set; } = true; + /// 登录门禁开(引擎 session_info 报告 LoggedIn — ApiKey 有效即能对话;false 时 shell 提交本地反馈不调引擎)。 + public bool IsLoggedIn { get; private set; } = true; + /// 应用一条事件(全量分派 — switch 模式匹配穷尽由编译器保证)。 public void Apply(BridgeEvent bridgeEvent) { switch (bridgeEvent) { - case BridgeEvent.SessionInfo(var version, var model, var permissionMode, var bypassAvailable, var cwd, var vendor): - ApplySessionInfo(version, model, permissionMode, bypassAvailable, cwd, vendor); + case BridgeEvent.SessionInfo(var version, var model, var permissionMode, var bypassAvailable, var cwd, var vendor, var loggedIn): + ApplySessionInfo(version, model, permissionMode, bypassAvailable, cwd, vendor, loggedIn); break; case BridgeEvent.PermissionModeChanged(var mode): if (PermissionModeInfo.TryFromValue(mode, out var parsed)) @@ -141,14 +144,17 @@ private void ApplySubagentStarted(string subagentId, string agentType, string de _spinner.HasActiveTools = true; } - /// 应用会话握手:banner 换引擎真实数据(模型 null → "当前模型未设置";供应商 null → 空,banner 退化无前缀)+ footer 初始模式 + bypass 可用性。 - private void ApplySessionInfo(string version, string? model, string permissionMode, bool bypassAvailable, string cwd, string? vendor) + /// 应用会话握手:banner 换引擎真实数据(模型 null → "当前模型未设置";供应商 null → 空,banner 退化无前缀) + /// + footer 初始模式 + bypass 可用性 + 登录门禁态(footer 未登录提示真实化 — TASK060)。 + private void ApplySessionInfo(string version, string? model, string permissionMode, bool bypassAvailable, string cwd, string? vendor, bool loggedIn) { _banner.Version = version; _banner.Model = string.IsNullOrWhiteSpace(model) ? "当前模型未设置" : model; _banner.Vendor = vendor ?? string.Empty; _banner.Cwd = cwd; BypassAvailable = bypassAvailable; + IsLoggedIn = loggedIn; + _view.Footer = _view.Footer with { LoginError = loggedIn ? null : "未登录 · 运行 /login" }; if (PermissionModeInfo.TryFromValue(permissionMode, out var parsed)) { _view.Footer = _view.Footer with { Mode = parsed }; diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs index 2536301b2..50961e8a1 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs @@ -8,6 +8,9 @@ namespace NativeTui.Core.Repl; /// 用户消息项(整行灰底 + ❯ subtle + 正文)。 public sealed class UserMessageItem(string text) : IScrollItem, ICursorNavigable { + /// 用户正文(测试断言面 — 排队消息等场景)。 + public string Text => text; + /// 内容高度 = margin 1 + 正文 1。 public int MeasureHeight(int width) => 2; @@ -44,6 +47,9 @@ public void Render(Screen screen, Rect frame, Theme theme) => /// 工具结果消息项(" ⎿ " dim 前缀 + 正文)。 public sealed class ToolResultItem(string text) : IScrollItem, ICursorNavigable { + /// 结果正文(测试断言面 — 排队反馈等复用场景)。 + public string Text => text; + /// 内容高度 = margin 1 + 正文 1。 public int MeasureHeight(int width) => 2; diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 040387db3..22110cb7a 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -58,6 +58,9 @@ public sealed class ReplShell /// 历史反向搜索态(Ctrl+R — TASK057-4;null = 未激活)。 private HistorySearch? _search; + /// 忙时排队输入队列(TASK060 — 基准 queued 语义,回合结束逐条自动发送)。 + private readonly List _queuedInputs = []; + /// 消息光标导航协调器(交互层 Step1 — 第四交互态,优先级低于权限/模型/搜索模态)。 private readonly MessageCursor _cursorNav = new(); @@ -80,6 +83,9 @@ public sealed class ReplShell /// 主循环存活标记(internal — 退出安全测试断言面:Esc/Ctrl+C 永不置 false)。 internal bool IsRunning => _running; + /// 登录门禁开(driver 投影 — 引擎 session_info 报告;false 时提交本地反馈不调引擎)。 + internal bool LoginGateOpen => _driver.IsLoggedIn; + /// 启动 banner(首项 — 真实引擎由 session_info 事件原地更新)。 private readonly BannerItem _banner; @@ -220,6 +226,9 @@ internal void DrainEngine() { CompletePermission(PermissionChoice.Deny); } + + // 回合结束 — 排队输入自动发送(TASK060 一问一答节奏:逐回合发队首) + PumpQueuedInputs(); } /// 挂起权限请求(宿主 TuiPermissionBridge.Confirm → 此处 → 用户按键 Complete)。 @@ -963,8 +972,9 @@ internal void CyclePermissionMode() _engine.SetPermissionMode(PermissionModeInfo.ToValue(next)); } - /// 提交输入:空文本忽略;否则追加用户消息并提交引擎(TurnStarted 事件驱动 busy,忙时由实现拒绝并 Error 诱导); - /// 提交文本入历史环(末条去重)+ 导航态复位。 + /// 提交输入(TASK060 一问一答节奏):空文本忽略;未登录本地反馈(基准 ⎿ Not logged in — + /// 不调引擎不傻等);忙时改排队(基准 queued 语义)— 用户消息上屏 + ⎿ 已排队 dim 行 + /// (ToolResultItem 复用)、入历史环,回合结束自动按序发送(DrainEngine 队列泵);闲时正常直发。 internal void Submit() { var text = _input.Text; @@ -977,9 +987,35 @@ internal void Submit() _history.Add(text); _history.Reset(); AddMessage(new UserMessageItem(text)); + if (!LoginGateOpen) + { + AddMessage(new ToolResultItem("未登录 · 请在 auth.json 配置有效 API key 或运行 /login 后重试")); + return; + } + + if (IsBusy) + { + _queuedInputs.Add(text); + AddMessage(new ToolResultItem("已排队,当前回合结束后自动发送")); + return; + } + _engine.Submit(text); } + /// 排队输入发送泵(DrainEngine 回合结束时调用)— 非忙态且有排队时取队首直发引擎 + ///(用户消息排队时已上屏,此处不重复);逐回合发一条维持一问一答节奏。 + private void PumpQueuedInputs() + { + if (IsBusy || _queuedInputs.Count == 0) + { + return; + } + + _engine.Submit(_queuedInputs[0]); + _queuedInputs.RemoveAt(0); + } + /// 渲染一帧并差分上屏:inline 走相对光标 + append(历史滚入 scrollback)+ DEC 2026 原子上屏;视口走绝对定位 diff。 /// internal = resize/渲染测试的单帧驱动面。 internal void RenderAndFlush() diff --git a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs index 2ac24fb80..7f536e4d5 100644 --- a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs @@ -71,6 +71,18 @@ public async Task BuildSessionInfoEvent_WithConfig_CarriesVendor() Assert.Null(withoutConfig.Vendor); } + /// 登录态判定(TASK060 TUI 提交门禁)— ApiKey 非空 = 已登录;空 = 未登录(TUI 本地反馈不调引擎)。 + [Fact] + public async Task BuildSessionInfoEvent_LoginStateFollowsApiKey() + { + await using var fastMode = new FastModeService(); + var withKey = new WorkflowConfig { Provider = new ProviderConfig { Vendor = "sensenova", ApiKey = "sk-有效密钥" } }; + var withoutKey = new WorkflowConfig { Provider = new ProviderConfig { Vendor = "sensenova", ApiKey = "" } }; + + Assert.True(EngineSession.BuildSessionInfoEvent(checker: null, fastMode, withKey).LoggedIn); + Assert.False(EngineSession.BuildSessionInfoEvent(checker: null, fastMode, withoutKey).LoggedIn); + } + /// TUI 静默日志(修复:引擎 Console 日志直写 stderr 与 inline 渲染交错致光标错乱/横向线残影)— /// provider 必须是 SilentLoggerProvider(禁止 Console 系 provider)。 [Fact] diff --git a/native_tui/tests/NativeTui.Tests/repl/LoginGateTests.cs b/native_tui/tests/NativeTui.Tests/repl/LoginGateTests.cs new file mode 100644 index 000000000..97d9f119d --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/LoginGateTests.cs @@ -0,0 +1,95 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 未登录友好提示测试(TASK060 Step2 — 用户痛点:未登录时输入仍调引擎傻等 40s, +/// 对照基准 `⎿ Not logged in · Please run /login` 即时反馈)— +/// SessionInfo.LoggedIn 字段链(引擎 ApiKey 判定 → driver 门禁态 + footer 真实化)+ +/// 未登录提交本地 ⎿ 反馈行(ToolResultItem 复用)不调引擎。 +/// +public sealed class LoginGateTests +{ + /// 构造外壳(SeedContent 对齐 Run 首步)。 + private static (ReplShell Shell, FakeReplEngine Engine) CreateShell() { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("0.0.0", "m", "b", "C:\\"), new StringWriter()); + shell.SeedContent(); + return (shell, engine); + } + + /// 注入登录态事件(模拟引擎 session_info 握手)。 + private static void ApplyLoginState(ReplShell shell, FakeReplEngine engine, bool loggedIn) { + engine.Events.Enqueue(new BridgeEvent.SessionInfo("1.0.0", "m", "auto", BypassAvailable: true, "C:\\", LoggedIn: loggedIn)); + shell.DrainEngine(); + } + + /// 未登录事件 — driver 门禁关闭 + footer 显示未登录提示(真实化替换硬编码种子)。 + [Fact] + public void SessionInfo_NotLoggedIn_ClosesGateAndShowsFooterHint() + { + var (shell, engine) = CreateShell(); + + ApplyLoginState(shell, engine, loggedIn: false); + + Assert.False(shell.LoginGateOpen); + Assert.NotNull(shell.View.Footer.LoginError); + Assert.Contains("未登录", shell.View.Footer.LoginError); + } + + /// 已登录事件 — 门禁开 + footer 未登录提示清除。 + [Fact] + public void SessionInfo_LoggedIn_OpensGateAndClearsFooterHint() + { + var (shell, engine) = CreateShell(); + ApplyLoginState(shell, engine, loggedIn: false); + + ApplyLoginState(shell, engine, loggedIn: true); + + Assert.True(shell.LoginGateOpen); + Assert.Null(shell.View.Footer.LoginError); + } + + /// 旧引擎兼容 — 不带 LoggedIn 字段(默认 true)门禁开不误拦。 + [Fact] + public void SessionInfo_WithoutLoggedInField_DefaultsOpen() + { + var (shell, engine) = CreateShell(); + + ApplyLoginState(shell, engine, loggedIn: true); + + Assert.True(shell.LoginGateOpen); + } + + /// 未登录提交 — 用户输入上屏(基准 `> 1` 留痕)+ ⎿ 未登录反馈行,不调引擎(不傻等)。 + [Fact] + public void NotLoggedInSubmit_LocalFeedbackWithoutEngineCall() + { + var (shell, engine) = CreateShell(); + ApplyLoginState(shell, engine, loggedIn: false); + TypeText(shell, "112"); + + shell.Submit(); + + Assert.Empty(engine.Submits); + Assert.Contains(shell.View.Messages.Items, m => m is UserMessageItem u && u.Text == "112"); + Assert.Contains(shell.View.Messages.Items, m => m is ToolResultItem t && t.Text.Contains("未登录")); + } + + /// 已登录提交 — 正常直发(门禁不误拦闲态对话)。 + [Fact] + public void LoggedInSubmit_SendsNormally() + { + var (shell, engine) = CreateShell(); + ApplyLoginState(shell, engine, loggedIn: true); + TypeText(shell, "你好"); + + shell.Submit(); + + Assert.Equal(["你好"], engine.Submits); + } + + /// 逐字符输入文本(HandleKey 真实按键链)。 + private static void TypeText(ReplShell shell, string text) { + foreach (var ch in text) + shell.HandleKey(new ConsoleKeyInfo(ch, ConsoleKey.A, false, false, false)); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs b/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs new file mode 100644 index 000000000..bcb48c423 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs @@ -0,0 +1,105 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 一问一答节奏测试(TASK060 Step1 — 用户痛点:忙时提交被忽略致两条提问堆叠无回答夹中间)— +/// 忙时提交改排队(基准 queued 语义):用户输入上屏 + ⎿ 已排队 dim 行(ToolResultItem 复用)、 +/// 不调引擎;回合结束(TurnCompleted 后非忙态)自动按序发送队首(不重复上屏)。 +/// +public sealed class QueuedSubmitTests +{ + /// 构造外壳(SeedContent 对齐 Run 首步)。 + private static (ReplShell Shell, FakeReplEngine Engine) CreateShell() { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("0.0.0", "m", "b", "C:\\"), new StringWriter()); + shell.SeedContent(); + return (shell, engine); + } + + /// 忙时提交文本(HandleKey Return 路径 — 对齐用户真实按键链)。 + private static void TypeAndSubmit(ReplShell shell, string text) { + foreach (var ch in text) + shell.HandleKey(new ConsoleKeyInfo(ch, ConsoleKey.A, false, false, false)); + shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + } + + /// busy 置位 / 回合结束(TurnStarted → TurnCompleted 事件对)。 + private static void MakeBusy(ReplShell shell, FakeReplEngine engine) { + engine.Events.Enqueue(new BridgeEvent.TurnStarted()); + shell.DrainEngine(); + } + + private static void EndTurn(ReplShell shell, FakeReplEngine engine) { + engine.Events.Enqueue(new BridgeEvent.TurnCompleted()); + shell.DrainEngine(); + } + + /// 忙时提交 — 上屏用户输入 + ⎿ 已排队行,不调引擎。 + [Fact] + public void BusySubmit_QueuesLocallyWithoutEngineCall() + { + var (shell, engine) = CreateShell(); + MakeBusy(shell, engine); + + TypeAndSubmit(shell, "3333"); + + Assert.Empty(engine.Submits); + Assert.Contains(shell.View.Messages.Items, m => m is UserMessageItem u && u.Text == "3333"); + Assert.Contains(shell.View.Messages.Items, m => m is ToolResultItem t && t.Text.Contains("已排队")); + } + + /// 回合结束 — 队首自动发送(不重复上屏用户消息)。 + [Fact] + public void TurnCompleted_AutoSendsQueuedInput() + { + var (shell, engine) = CreateShell(); + MakeBusy(shell, engine); + TypeAndSubmit(shell, "3333"); + Assert.Single(shell.View.Messages.Items.OfType()); + + EndTurn(shell, engine); + + Assert.Equal(["3333"], engine.Submits); + Assert.Single(shell.View.Messages.Items.OfType()); + } + + /// 多条排队 — 按序逐回合发送(一问一答节奏)。 + [Fact] + public void MultipleQueuedInputs_SendInOrderPerTurn() + { + var (shell, engine) = CreateShell(); + MakeBusy(shell, engine); + TypeAndSubmit(shell, "第一条"); + TypeAndSubmit(shell, "第二条"); + + EndTurn(shell, engine); + Assert.Equal(["第一条"], engine.Submits); + + MakeBusy(shell, engine); + EndTurn(shell, engine); + Assert.Equal(["第一条", "第二条"], engine.Submits); + } + + /// 闲时提交 — 正常直发(排队不误拦闲态)。 + [Fact] + public void IdleSubmit_SendsDirectly() + { + var (shell, engine) = CreateShell(); + + TypeAndSubmit(shell, "正常输入"); + + Assert.Equal(["正常输入"], engine.Submits); + Assert.DoesNotContain(shell.View.Messages.Items, m => m is ToolResultItem t && t.Text.Contains("已排队")); + } + + /// 排队文本 — 入历史环(与正常提交同语义,↑ 可翻阅)。 + [Fact] + public void QueuedInput_EntersHistoryRing() + { + var (shell, engine) = CreateShell(); + MakeBusy(shell, engine); + + TypeAndSubmit(shell, "排队也入历史"); + + Assert.Contains("排队也入历史", shell.History.Entries); + } +} From a29f94c571f46029ddce67500af4aa4fa1d3aa3c Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 19:45:37 +0800 Subject: [PATCH 05/30] =?UTF-8?q?fix:=20=E7=A9=BA=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=EF=BC=88=E7=A9=BA=E5=9B=9E=E5=A4=8D=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E6=88=90=E5=8A=9F=E8=87=B4=20TUI=20=E5=82=BB=E7=AD=89?= =?UTF-8?q?=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=E9=93=BE=E6=98=AF=20506=20=E5=B7=A5=E5=85=B7=20466KB=20?= =?UTF-8?q?=E5=B7=A8=E5=9E=8B=E8=AF=B7=E6=B1=82+=E9=BB=98=E8=AE=A4=2010s?= =?UTF-8?q?=20API=20=E8=B6=85=E6=97=B6=E6=8E=90=E6=B5=81=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E5=90=8E=E6=8B=BF=E5=88=B0=E7=A9=BA=E6=B5=81=EF=BC=8CSessionTu?= =?UTF-8?q?rnResult.Success(=E7=A9=BA)=20=E9=9D=99=E9=BB=98=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E8=87=B4=20TUI=20=E6=97=A0=E6=B6=88=E6=81=AF=E6=97=A0?= =?UTF-8?q?=E9=94=99=E8=AF=AF=20spinner=20=E6=B0=B8=E8=BD=AC=EF=BC=9B?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E5=88=A4=E5=AE=9A=E4=B8=89=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E6=97=A0=E6=96=87=E6=9C=AC+=E6=97=A0=E6=80=9D?= =?UTF-8?q?=E8=80=83+=E6=97=A0=E5=AE=9E=E8=B4=A8=E4=BA=A7=E5=87=BA?= =?UTF-8?q?=EF=BC=89=E5=88=A4=20Error=20=E5=B8=A6=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E8=AF=B1=E5=AF=BC=EF=BC=8C=5FhasSubstantiveOutput=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E8=A6=86=E7=9B=96=20onToolStart=20=E4=B8=8E=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E8=B7=AF=E7=94=B1=E7=BD=AE=E4=BD=8D=E9=98=B2?= =?UTF-8?q?=E5=B7=A5=E5=85=B7/=E5=AD=90=E4=BB=A3=E7=90=86=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E8=AF=AF=E5=88=A4=EF=BC=8C=E6=80=9D=E8=80=83=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E7=BB=8F=20thinkingContent=20=E9=95=BF=E5=BA=A6?= =?UTF-8?q?=E8=B1=81=E5=85=8D=EF=BC=9B=E4=B8=89=E4=B8=AA=E7=89=B9=E6=AE=8A?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=B5=8B=E8=AF=95=E8=A1=A5=20Text=20?= =?UTF-8?q?=E4=BA=A7=E5=87=BA=E5=9B=9E=E5=BD=92=E6=9C=AC=E6=84=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/engine/adapters/SessionController.cs | 15 +++++++ .../adapters/SessionControllerTests.cs | 43 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/modules/apps/engine/adapters/SessionController.cs b/modules/apps/engine/adapters/SessionController.cs index d5160b824..a4061bd11 100644 --- a/modules/apps/engine/adapters/SessionController.cs +++ b/modules/apps/engine/adapters/SessionController.cs @@ -8,6 +8,9 @@ public sealed class SessionController { private readonly IChatService _chatService; private readonly IEventConsumer _consumer; private readonly TurnDiffService _turnDiffService; + + /// 本回合是否有实质产出(空回合防御判定 — 工具调用/子代理事件置位、回合开始复位;文本/思考经长度判定不入此标记)。 + private bool _hasSubstantiveOutput; private readonly string _sessionId; private readonly IServiceProvider? _serviceProvider; private readonly IClockService _clock; @@ -71,6 +74,7 @@ public async Task StreamResponseAsync(string input, Cancellat using var timeoutCts = TimeoutHelper.CreateLinkedTimeout(cancellationToken, TimeSpan.FromMilliseconds(apiTimeoutMs)); var timeoutToken = timeoutCts.Token; var hasReceivedEvent = false; + _hasSubstantiveOutput = false; try { if (_mainAgent is not null) { @@ -116,6 +120,15 @@ public async Task StreamResponseAsync(string input, Cancellat // 等待思考内容持久化完成,避免 fire-and-forget 丢失 await StoreThinkingIfAnyAsync(thinkingContent, lastModelId, cancellationToken).ConfigureAwait(false); + // 空回合防御(修复:流式超时重试后拿到空流被当成功静默返回 — TUI 傻等无消息无错误): + // 无文本 + 无思考 + 无工具调用 = 全空产出,判 Error 带诱导(重试提示); + // 工具回合/思考回合文本空属合法半产出(onToolEnd/onThinking 已独立消费),不误判。 + if (LastResponse.Length == 0 && thinkingContent.Length == 0 && !_hasSubstantiveOutput) { + return SessionTurnResult.Error( + "模型返回空回复(无文本/思考/工具产出)。可能为请求过大超时或供应商响应异常,请按 Esc 打断后重试,或切换模型/供应商。", + partialResponse: string.Empty); + } + return SessionTurnResult.Success(LastResponse, requestTimestamp); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !hasReceivedEvent) { await StoreThinkingIfAnyAsync(thinkingContent, lastModelId, CancellationToken.None).ConfigureAwait(false); @@ -156,6 +169,7 @@ public async Task StreamResponseAsync(string input, Cancellat private string? ProcessEvent(ChatStreamEvent evt, StringBuilder fullResponse, StringBuilder thinkingContent) { string? modelId = null; if (RouteSubagentEvent(evt)) { + _hasSubstantiveOutput = true; return null; } @@ -169,6 +183,7 @@ public async Task StreamResponseAsync(string input, Cancellat _consumer.OnThinking(thinking); }, onToolStart: (toolName, callId, arguments) => { + _hasSubstantiveOutput = true; _consumer.OnToolStart(toolName, callId, arguments); }, onToolEnd: (toolName, resultText, callId, isToolError, structuredPatch) => { diff --git a/test/unit/host.tests/adapters/SessionControllerTests.cs b/test/unit/host.tests/adapters/SessionControllerTests.cs index a2cb9f9d2..b6a20f348 100644 --- a/test/unit/host.tests/adapters/SessionControllerTests.cs +++ b/test/unit/host.tests/adapters/SessionControllerTests.cs @@ -97,6 +97,7 @@ public async Task StreamResponseAsync_LoopDetected_DispatchedToConsumer() { var events = new List { ChatStreamEvent.LoopDetected(3, 0, "repeated pattern"), + ChatStreamEvent.Text("ok"), ChatStreamEvent.Done() }; var chatService = CreateMockChatService(events); @@ -116,6 +117,7 @@ public async Task StreamResponseAsync_ToolProgress_DispatchedToConsumer() { var events = new List { ChatStreamEvent.ToolProgress("web_search", "query_update", "searching..."), + ChatStreamEvent.Text("ok"), ChatStreamEvent.Done() }; var chatService = CreateMockChatService(events); @@ -136,6 +138,7 @@ public async Task StreamResponseAsync_TimingSummary_DispatchedToConsumer() { var events = new List { ChatStreamEvent.TimingSummary("TTFT: 1.2s, Total: 3.5s"), + ChatStreamEvent.Text("ok"), ChatStreamEvent.Done() }; var chatService = CreateMockChatService(events); @@ -177,7 +180,7 @@ public async Task StreamResponseAsync_Done_StoresThinkingContent() { } [Fact] - public async Task StreamResponseAsync_EmptyEvents_ReturnsSuccessWithEmptyResponse() { + public async Task StreamResponseAsync_EmptyEvents_ReturnsErrorNotSilentSuccess() { var events = new List { ChatStreamEvent.Done() @@ -189,8 +192,44 @@ public async Task StreamResponseAsync_EmptyEvents_ReturnsSuccessWithEmptyRespons var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true); + Assert.False(result.Succeeded); + Assert.NotNull(result.ErrorMessage); + Assert.Contains("空回复", result.ErrorMessage); + } + + [Fact] + public async Task StreamResponseAsync_ToolOnlyTurn_EmptyTextStaysSuccess() { + var events = new List + { + ChatStreamEvent.ToolStart("read_file", "call1", "{}"), + ChatStreamEvent.ToolEnd("read_file", "content", "call1", false), + ChatStreamEvent.Done() + }; + var chatService = CreateMockChatService(events); + var consumer = new RecordingEventConsumer(); + var turnDiffService = new TurnDiffService(); + var controller = new SessionController(chatService, consumer, turnDiffService, "test-session"); + + var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task StreamResponseAsync_ThinkingOnlyTurn_EmptyTextStaysSuccess() { + var events = new List + { + ChatStreamEvent.Thinking("思考中"), + ChatStreamEvent.Done() + }; + var chatService = CreateMockChatService(events); + var consumer = new RecordingEventConsumer(); + var turnDiffService = new TurnDiffService(); + var controller = new SessionController(chatService, consumer, turnDiffService, "test-session"); + + var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true); + Assert.True(result.Succeeded); - Assert.Equal(string.Empty, result.Response); } [Fact] From 27b013a6271ced48bbd6257035522f06ac2e6ca7 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 07:08:36 +0800 Subject: [PATCH 06/30] =?UTF-8?q?feat:=20DSG041=20=E6=89=B92=20=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E4=BA=8B=E4=BB=B6=E9=80=9A=E9=81=93=E6=8E=A5?= =?UTF-8?q?=E9=80=9A=E4=B8=BB=E4=BB=A3=E7=90=86=E8=B7=AF=E5=BE=84=EF=BC=88?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E5=B1=82=E7=9B=B4=E6=8A=95=E6=96=B9=E6=A1=88?= =?UTF-8?q?=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E6=8C=82=E7=82=B9?= =?UTF-8?q?=E8=90=BD=20SessionController=20=E4=B8=BB=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=80=8C=E9=9D=9E=20AgentBase/QueryEngine?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=9B=9E=E5=90=88=E7=BA=A7=20EnterScope=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96=20QueryEngine=20=E5=B7=A5=E5=85=B7=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=AE=B5=EF=BC=88AsyncLocal=20=E6=B2=BF=20await=20?= =?UTF-8?q?=E9=93=BE=E4=BC=A0=E6=92=AD=E5=88=B0=20Agent=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E4=B8=AD=E9=97=B4=E4=BB=B6=20Emit=20=E7=82=B9?= =?UTF-8?q?=EF=BC=89=EF=BC=8Cchunk=20=E5=BE=AA=E7=8E=AF=E9=80=90=E6=8B=8D?= =?UTF-8?q?=20TryDrain=20=E7=9B=B4=E6=8A=95=20ProcessEvent=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E6=89=B91=20RouteSubagentEvent=20=E5=88=86=E6=B5=81?= =?UTF-8?q?=EF=BC=8C=E5=AE=8C=E5=85=A8=E7=BB=95=E8=BF=87=20chunk=20?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E5=B1=82=EF=BC=88AgentBase/adapter=20?= =?UTF-8?q?=E9=9B=B6=E6=94=B9=E5=8A=A8=EF=BC=8CL2=20=E6=89=B9=E6=AC=A1?= =?UTF-8?q?=E8=A2=AB=E6=9C=AC=E6=96=B9=E6=A1=88=E5=90=B8=E6=94=B6=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E5=BE=AA=E7=8E=AF=E5=90=8E=E6=9C=AB=E6=89=B9=E5=86=B2?= =?UTF-8?q?=E5=88=B7+Complete=20=E9=98=B2=E8=BF=9F=E5=88=B0=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=B3=84=E6=BC=8F=EF=BC=88=E5=AF=B9=E9=BD=90=20QueryL?= =?UTF-8?q?oopMiddleware=20=E5=85=88=E4=BE=8B=EF=BC=89=EF=BC=9B2=20?= =?UTF-8?q?=E6=96=B0=E6=B5=8B=E8=AF=95=EF=BC=88=E9=80=9A=E9=81=93=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=85=A8=E5=BA=8F=E5=88=97=E7=9B=B4=E6=8A=95=E4=B8=89?= =?UTF-8?q?=E5=9B=9E=E8=B0=83+=E5=AD=90=E4=BB=A3=E7=90=86=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E4=BA=8B=E4=BB=B6=E4=B8=8D=E8=B5=B0=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=EF=BC=89+=20host.tests=201373=20=E5=85=A8?= =?UTF-8?q?=E7=BB=BF=20+=20Host=20=E7=BC=96=E8=AF=91=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/engine/adapters/SessionController.cs | 33 +++++- .../SessionControllerSubagentEventTests.cs | 103 ++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/modules/apps/engine/adapters/SessionController.cs b/modules/apps/engine/adapters/SessionController.cs index a4061bd11..3dab2da00 100644 --- a/modules/apps/engine/adapters/SessionController.cs +++ b/modules/apps/engine/adapters/SessionController.cs @@ -91,13 +91,24 @@ public async Task StreamResponseAsync(string input, Cancellat var sw = System.Diagnostics.Stopwatch.StartNew(); _mainAgent.CurrentInput = input; - await foreach (var chunk in _mainAgent.ExecuteStreamAsync(timeoutToken).ConfigureAwait(false)) { - hasReceivedEvent = true; - var evt = AgentStreamChunkAdapter.ToChatStreamEvent(chunk); - if (evt is null) continue; - var mid = ProcessEvent(evt, fullResponse, thinkingContent); - if (mid is not null) lastModelId = mid; + // DSG041 批2 — 子代理事件通道:主代理路径回合级作用域(对齐 QueryLoopMiddleware 先例 — + // 作用域覆盖 QueryEngine 工具执行段,Agent 工具中间件 Emit 的子代理事件进通道; + // chunk 循环逐拍排空直投 ProcessEvent[批1 分流已就绪],不经 chunk 转换层) + var subAgentChannel = new SubAgentEventChannel(); + using (var subAgentScope = subAgentChannel.EnterScope()) { + await foreach (var chunk in _mainAgent.ExecuteStreamAsync(timeoutToken).ConfigureAwait(false)) { + hasReceivedEvent = true; + DrainSubAgentEvents(subAgentChannel, fullResponse, thinkingContent); + var evt = AgentStreamChunkAdapter.ToChatStreamEvent(chunk); + if (evt is null) continue; + var mid = ProcessEvent(evt, fullResponse, thinkingContent); + if (mid is not null) lastModelId = mid; + } } + + // 末批冲刷(循环结束后通道可能仍有缓冲事件)+ 完成标记(迟到 Emit 静默丢弃防泄漏) + DrainSubAgentEvents(subAgentChannel, fullResponse, thinkingContent); + subAgentChannel.Complete(); sw.Stop(); if (Diag.IsDebugLog) { @@ -162,6 +173,16 @@ public async Task StreamResponseAsync(string input, Cancellat } } + /// + /// 排空子代理事件通道并直投 ProcessEvent(DSG041 批2 — chunk 循环逐拍调用; + /// 子代理事件带 AgentId 走 RouteSubagentEvent 分流,主对话零影响) + /// + private void DrainSubAgentEvents(SubAgentEventChannel channel, StringBuilder fullResponse, StringBuilder thinkingContent) { + foreach (var subEvent in channel.TryDrain()) { + ProcessEvent(subEvent, fullResponse, thinkingContent); + } + } + /// /// 处理单个 ChatStreamEvent — 子代理活动事件(AgentId 非 null)前置分流到子代理回调(DSG041 批1), /// 其余分发到 IEventConsumer 既有回调,返回 Done 事件的 modelId(否则 null) diff --git a/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs b/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs index 8123f8a6e..291b76e71 100644 --- a/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs +++ b/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs @@ -135,6 +135,109 @@ public async Task MixedStream_RoutesEachEventCorrectly() Assert.Equal("test-model", done.modelId); } + // ---- 批2 L1:主代理路径 SubAgentEventChannel 直投(DSG041) ---- + + /// 主代理路径通道事件直投 — QueryEngine 工具执行段 Emit 到通道(AsyncLocal 作用域内), + /// SessionController chunk 循环排空后经 ProcessEvent 分流到三子代理回调(不经 chunk 转换层)。 + [Fact] + public async Task MainAgentPath_ChannelEvents_DispatchToSubagentCallbacks() + { + var consumer = new RecordingSubagentConsumer(); + var controller = CreateMainAgentController( + [ + new QueryStreamChunk { Type = AgentStreamChunkType.Content, Content = "主回复" }, + new QueryStreamChunk { Type = AgentStreamChunkType.Complete, ModelId = "test-model" }, + ], + chunkIndex => EmitChannelEvents(chunkIndex), consumer); + + var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true); + + Assert.True(result.Succeeded, $"回合失败: {result.ErrorMessage ?? "(无)"}"); + Assert.Equal("主回复", result.Response); + var started = Assert.Single(consumer.SubagentStarts); + Assert.Equal("sub-1", started.SubagentId); + Assert.Equal("explore", started.AgentType); + Assert.Equal("搜索渲染链", started.Description); + Assert.Equal(2, consumer.SubagentToolUses.Count); + Assert.All(consumer.SubagentToolUses, use => Assert.Equal("sub-1", use.SubagentId)); + var finished = Assert.Single(consumer.SubagentFinishes); + Assert.Equal("sub-1", finished.SubagentId); + Assert.False(finished.IsError); + } + + /// 主代理路径 — 子代理带 AgentId 工具事件经通道直投不产生普通 OnToolStart/OnToolEnd(分流一致)。 + [Fact] + public async Task MainAgentPath_ChannelSubagentToolEvent_NotPlainToolCallbacks() + { + var consumer = new RecordingSubagentConsumer(); + var controller = CreateMainAgentController( + [ + new QueryStreamChunk { Type = AgentStreamChunkType.Complete, ModelId = "test-model" }, + ], + chunkIndex => { + if (chunkIndex == 0) { + SubAgentEventChannel.Current?.Emit(new ChatStreamEvent { + Type = ChatStreamEventType.ToolCallStart, AgentId = "sub-1", ToolName = "Read", ToolArguments = "file.cs" }); + } + }, consumer); + + var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true); + + Assert.True(result.Succeeded); + var use = Assert.Single(consumer.SubagentToolUses); + Assert.Equal("Read", use.ToolName); + Assert.Equal("file.cs", use.Summary); + Assert.Empty(consumer.ToolStarts); + Assert.Empty(consumer.ToolEnds); + } + + /// 构造主代理路径 controller(mock QueryEngine 喂 chunk 流,每 chunk yield 前回调发射通道事件)。 + private static SessionController CreateMainAgentController( + IReadOnlyList chunks, + Action onBeforeYield, + RecordingSubagentConsumer consumer) { + var queryEngine = CreateMockQueryEngine(chunks, onBeforeYield); + var agent = new AgentBase(task: string.Empty, options: null, queryEngine: queryEngine, logger: null, name: "main", role: AgentRole.Coordinator); + var sp = new ServiceCollection().BuildServiceProvider(); + var chatService = CreateMockChatService(Array.Empty()); + return new SessionController(chatService, consumer, new TurnDiffService(), "test-session", sp, mainAgent: agent); + } + + /// mock QueryEngine — chunk 流的 enumerator 在 yield 前回调(模拟工具执行段发射子代理事件到通道)。 + private static IQueryEngine CreateMockQueryEngine(IReadOnlyList chunks, Action onBeforeYield) { + var mock = new Mock(); + mock.Setup(e => e.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string input, MessageList history, CancellationToken ct) => EmitChunksWithEventsAsync(chunks, onBeforeYield, ct)); + mock.Setup(e => e.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string input, MessageList history, QueryOptions? opts, CancellationToken ct) => EmitChunksWithEventsAsync(chunks, onBeforeYield, ct)); + return mock.Object; + } + + /// 按 chunk 序号发射完整子代理事件序列(Started → 工具×2 → Finished)。 + private static void EmitChannelEvents(int chunkIndex) { + var channel = SubAgentEventChannel.Current; + if (channel is null || chunkIndex != 0) { + return; + } + + channel.Emit(ChatStreamEvent.AgentStarted("sub-1", "explore", "搜索渲染链")); + channel.Emit(new ChatStreamEvent { Type = ChatStreamEventType.ToolCallStart, AgentId = "sub-1", ToolName = "Grep", ToolArguments = "pattern" }); + channel.Emit(new ChatStreamEvent { Type = ChatStreamEventType.ToolCallEnd, AgentId = "sub-1", ToolName = "Grep", ToolResultText = "命中" }); + channel.Emit(ChatStreamEvent.AgentFinished("sub-1", success: true)); + } + + /// chunk 流 + yield 前回调(AsyncLocal 作用域在 enumerator 恢复时继承调用方作用域)。 + private static async IAsyncEnumerable EmitChunksWithEventsAsync( + IReadOnlyList chunks, + Action onBeforeYield, + [EnumeratorCancellation] CancellationToken ct) { + for (var i = 0; i < chunks.Count; i++) { + ct.ThrowIfCancellationRequested(); + onBeforeYield(i); + yield return chunks[i]; + } + } + private static IChatService CreateMockChatService(IReadOnlyList events) { var mock = new Mock(); mock.Setup(s => s.StreamWithEventsAsync(It.IsAny(), It.IsAny())) From 7d92d614cd0518a3a441d2b4ec1e1cf44c632bca Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 07:10:23 +0800 Subject: [PATCH 07/30] =?UTF-8?q?docs:=20DSG041=20=E6=89=B92=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E6=9B=B4=E6=96=B0=EF=BC=88=E5=AE=BF=E4=B8=BB=E5=B1=82?= =?UTF-8?q?=E7=9B=B4=E6=8A=95=E6=96=B9=E6=A1=88=E5=8F=98=E6=9B=B4=20+=20L2?= =?UTF-8?q?=20=E6=89=B9=E6=AC=A1=E5=90=B8=E6=94=B6=E6=92=A4=E9=94=80=20+?= =?UTF-8?q?=20=E6=89=B9=E6=AC=A1=E9=87=8D=E7=BC=96=E5=8F=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DSG041-subagent-event-source-plan.md | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/proposed/design/DSG041-subagent-event-source-plan.md b/docs/proposed/design/DSG041-subagent-event-source-plan.md index 8e5e4529b..794bbad34 100644 --- a/docs/proposed/design/DSG041-subagent-event-source-plan.md +++ b/docs/proposed/design/DSG041-subagent-event-source-plan.md @@ -69,25 +69,35 @@ native_tui(TUI 宿主)子代理分组渲染全链已落地(SubagentGroupCo | 基建实现 | 消费点 | 已实现 | 已验收 | |---------|--------|--------|--------| -| L3 IEventConsumer 三方法(DIM 默认空)+ SessionController RouteSubagentEvent 前置分流 | BridgeEventConsumer / InProcessEventConsumer(批4/批5 接映射) | ✅ 批1(b64c5d16d) | ✅ SessionControllerSubagentEventTests 5/5(Started/Finished/工具分流/普通零回归/混合流)+ host.tests 1371 + abs.tests 694 + engine/cli/Host 三链编译绿 | -| L1 SubAgentEventChannel 接通 QueryEngine 主代理路径 | AgentStreamExecutionMiddleware/ForkSubAgentManagerActor 发射侧(零改动) | ⏳ 批2 | ⏳ E2E 复制先例 + agents 单测 | -| L2 AgentStreamChunkAdapter AgentId 传播 + 类型映射 | SessionController chunk→ChatStreamEvent 翻译 | ⏳ 批3 | ⏳ adapters 单测 | +| L3 IEventConsumer 三方法(DIM 默认空)+ SessionController RouteSubagentEvent 前置分流 | BridgeEventConsumer / InProcessEventConsumer(批4/批5 接映射) | ✅ 批1(hash 已随 PR #439 squash,内容在库) | ✅ SessionControllerSubagentEventTests 5/5(Started/Finished/工具分流/普通零回归/混合流)+ host.tests 1371 + abs.tests 694 + engine/cli/Host 三链编译绿 | +| L1 SubAgentEventChannel 接通主代理路径(**宿主层直投方案**) | AgentStreamExecutionMiddleware/ForkSubAgentManagerActor 发射侧(零改动) | ✅ 批2(6b762c145) | ✅ 单元级:MainAgentPath 2/2(通道事件全序列直投三回调 + 子代理工具事件不走普通回调)+ host.tests 1373 全绿 + Host 编译绿;⏳ 集成级:E2E 复制先例(批2 收尾项) | +| ~~L2 AgentStreamChunkAdapter AgentId 传播~~ | ~~SessionController chunk→ChatStreamEvent 翻译~~ | **✅ 被批2 宿主层直投方案吸收**(通道事件不经 chunk 转换层,AgentBase/adapter 零改动;批3 撤销) | —(无独立交付物) | | L4 引擎侧 BridgeEvent 三 record | bridge_serve JSON 协议消费方 | ⏳ 批4 | ⏳ BridgeJsonContractTests | | InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ⏳ 批5 | ⏳ Host --selftest 扩断言(ADR 0080 真实运行) | | TUI 真实链路分组显示 | SubagentGroupCoordinator → 分组渲染(既有零改动) | ⏳ 批5 | ⏳ 用户真实终端(agents 场景真实引擎 spawn) | ## 8. 后续 -- ~~批1(L3 协议扩展)可立即开工(无前置依赖)~~ **✅ 已完成(b64c5d16d,2026-10-21)** -- 批2(L1 通道接通):挂点二选一决策见风险1(倾向 AgentBase 循环内排空先行) +- ~~批1(L3 协议扩展)~~ **✅ 已完成(内容随 PR #439 squash 入库)** +- ~~批2(L1 通道接通)~~ **✅ 已完成(6b762c145,宿主层直投方案 — 见批2 实施记录)** +- ~~批3(L2 chunk 传播)~~ **✅ 撤销 — 被批2 宿主层直投方案吸收**(通道事件绕过 chunk 转换层,AgentBase/adapter 零改动;DSG041 剩余批次重编号:批3=引擎侧 DTO(原批4)、批4=TUI 映射+真链(原批5)) +- 批2 集成级验收(E2E 复制先例)为收尾项,可与后续批次并行 - 本 DSG 落地后 TASK055 三缺口③关闭 -### 批1 实施记录(b64c5d16d) +### 批1 实施记录(hash 已随 PR #439 squash,内容在库) - IEventConsumer 三方法走 **DIM 默认空实现**(既有 8+ 实现方零改动;默认实现须经接口类型调用的既有坑已规避) - SessionController.ProcessEvent 前置分流 **RouteSubagentEvent**:AgentId 非 null 即子代理事件——AgentStarted→OnSubagentStarted(身份三元组)、AgentFinished→OnSubagentFinished(**AgentSuccess 成功语义取反为 isError**)、带 AgentId 工具事件→OnSubagentToolUse(tokenGain 恒 0 兜底,批2 填真实值);返回 true 跳过主 Switch - 实施坑:AgentSuccess→isError 语义取反(首轮直传 false 导致 finished.IsError 断言反);mixed 流中子代理 Content 事件暂不投影返回 false 走主路径(消费面仅三回调) +### 批2 实施记录(6b762c145 — 方案变更:宿主层直投) + +- **挂点决策结果**:既非 AgentBase 循环排空也非 QueryCoreMiddleware 排空,而是 **SessionController 主代理路径**(第三方案,实施时发现的最优解)——通道作用域 + 排空全部落宿主层,AgentBase/QueryEngine/adapter 全零改动 +- 结构:`StreamResponseAsync` 主代理分支内 `new SubAgentEventChannel() + EnterScope()`(AsyncLocal 沿 await 链传播覆盖 QueryEngine 工具执行段 → Agent 工具中间件 Emit 点可见)→ chunk 循环逐拍 `TryDrain` 直投 `ProcessEvent`(复用批1 分流)→ 循环后末批冲刷 + `Complete()`(迟到 Emit 静默丢弃防泄漏,对齐 QueryLoopMiddleware 先例) +- **L2 被吸收**:通道事件不经 chunk 转换层直接是 ChatStreamEvent,AgentStreamChunkAdapter 不需要传播 AgentId(主代理 chunk 的 AgentId 是主代理自身 id,传播反而会误分流——这也是直投方案的正确性优势) +- 实施坑:Moq 泛型回调返回类型必须精确匹配(QueryAsync 返回 IAsyncEnumerable 非 AgentStreamChunk);QueryStreamChunk.AgentId 非必填(与 AgentStreamChunk 不同) +- tokenGain:批2 后仍恒 0(通道事件无 token 语义字段;真实 token 增量待 ChatStreamEvent.Usage 传播或 TUI 侧 TokenDelta 近似,列为低优先后续项) + ## 9. 各批验收方式定义(2026-10-21 用户确认"先定义验收方式再逐个进行") > 原则:TDD 红绿循环(每批先红测试后实现)+ 层级递进验收(单测 → E2E → 真实运行),全链最终以 ADR 0080 真实运行收口。 From 264c5e8bc6bf15cd939ecb98f61c15a9bbf59601 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 07:20:48 +0800 Subject: [PATCH 08/30] =?UTF-8?q?feat:=20DSG041=20=E6=89=B93=20=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E4=BE=A7=E5=AD=90=E4=BB=A3=E7=90=86=20BridgeEvent=20D?= =?UTF-8?q?TO=20+=20BridgeEventConsumer=20=E6=98=A0=E5=B0=84=EF=BC=88L4=20?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E4=BE=A7=20Cli.Bridge.BridgeEvent=20?= =?UTF-8?q?=E5=A2=9E=20SubagentStarted/SubagentToolUse/SubagentFinished=20?= =?UTF-8?q?=E4=B8=89=20record=EF=BC=88=E5=AD=97=E6=AE=B5=E4=B8=8E=20native?= =?UTF-8?q?=5Ftui=20=E4=BE=A7=E9=80=90=E5=AD=97=E6=AE=B5=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E2=80=94=E2=80=94SubagentId/AgentType/Description=20+=20ToolNa?= =?UTF-8?q?me/Summary/TokenGain=20+=20IsError=EF=BC=8CcamelCase=20JSON=20?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E7=AD=96=E7=95=A5=E8=87=AA=E5=8A=A8=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=EF=BC=89=EF=BC=8C[JsonDerivedType]=20=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=20subagent=5Fstarted/subagent=5Ftool=5Fuse/subagent?= =?UTF-8?q?=5Ffinished=20=E4=B8=89=20type=EF=BC=9BBridgeEventConsumer=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=AE=9E=E7=8E=B0=E4=B8=89=E6=96=B9=E6=B3=95?= =?UTF-8?q?=EF=BC=88DIM=20=E9=BB=98=E8=AE=A4=E5=AE=9E=E7=8E=B0=E7=BB=8F?= =?UTF-8?q?=E7=B1=BB=E7=B1=BB=E5=9E=8B=E8=B0=83=E7=94=A8=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E7=9A=84=E6=97=A2=E6=9C=89=E5=9D=91=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=B0=83=E7=94=A8=E6=96=B9=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=AE=9E=E7=8E=B0=EF=BC=89=EF=BC=9BJSON=20?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E7=BB=8F=20BridgeJsonContractTests=20?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E5=BF=AB=E7=85=A7=E9=94=81=E5=AE=9A?= =?UTF-8?q?=E9=98=B2=E5=8F=8C=E7=AB=AF=E6=BC=82=E7=A7=BB=EF=BC=9B2=20?= =?UTF-8?q?=E6=96=B0=E6=B5=8B=E8=AF=95=20+=20host.tests=201375=20=E5=85=A8?= =?UTF-8?q?=E7=BB=BF=20+=20engine/cli/Host=20=E4=B8=89=E9=93=BE=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../engine/adapters/BridgeEventConsumer.cs | 15 +++++++++++++++ modules/apps/engine/bridge/BridgeProtocol.cs | 13 +++++++++++++ .../bridge/BridgeEventConsumerTests.cs | 17 +++++++++++++++++ .../bridge/BridgeJsonContractTests.cs | 19 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/modules/apps/engine/adapters/BridgeEventConsumer.cs b/modules/apps/engine/adapters/BridgeEventConsumer.cs index f3339f836..b3fb501f1 100644 --- a/modules/apps/engine/adapters/BridgeEventConsumer.cs +++ b/modules/apps/engine/adapters/BridgeEventConsumer.cs @@ -78,6 +78,21 @@ public void OnDone(TokenUsage? usage, string? modelId) { WriteEvent(new Cli.Bridge.BridgeEvent.AssistantMessage(_fullResponse.ToString())); } + /// 子代理启动 → subagent_started(DSG041 批3 — 显式实现:DIM 默认实现经类类型调用不可见)。 + public void OnSubagentStarted(string subagentId, string agentType, string description) { + WriteEvent(new Cli.Bridge.BridgeEvent.SubagentStarted(subagentId, agentType, description)); + } + + /// 子代理工具活动 → subagent_tool_use(原地更新组条目不逐条刷屏)。 + public void OnSubagentToolUse(string subagentId, string toolName, string summary, int tokenGain) { + 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)); + } + /// 思考结束结算(进入产出或回合结束时调用,幂等)。 private void EndThinkingIfActive() { if (_thinkingStartMs is not long start) diff --git a/modules/apps/engine/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs index 066071e44..18d570a4d 100644 --- a/modules/apps/engine/bridge/BridgeProtocol.cs +++ b/modules/apps/engine/bridge/BridgeProtocol.cs @@ -41,6 +41,9 @@ public sealed record Quit() : BridgeRequest; [JsonDerivedType(typeof(BridgeEvent.ToolResult), "tool_result")] [JsonDerivedType(typeof(BridgeEvent.TokenDelta), "token_delta")] [JsonDerivedType(typeof(BridgeEvent.AssistantMessage), "assistant_message")] +[JsonDerivedType(typeof(BridgeEvent.SubagentStarted), "subagent_started")] +[JsonDerivedType(typeof(BridgeEvent.SubagentToolUse), "subagent_tool_use")] +[JsonDerivedType(typeof(BridgeEvent.SubagentFinished), "subagent_finished")] [JsonDerivedType(typeof(BridgeEvent.TurnCompleted), "turn_completed")] [JsonDerivedType(typeof(BridgeEvent.TurnInterrupted), "turn_interrupted")] [JsonDerivedType(typeof(BridgeEvent.Error), "error")] @@ -113,6 +116,16 @@ public sealed record ToolResult(string Summary, /// 流式 token 增量(spinner ResponseLength 累加)。 public sealed record TokenDelta(int Length) : BridgeEvent; + /// 子代理启动(DSG041 批3 — 与 native_tui 侧 BridgeEvent.SubagentStarted 逐字段镜像; + /// 同回合内全部子代理归一组,TUI 侧 SubagentGroupCoordinator 建组条目)。 + public sealed record SubagentStarted(string SubagentId, string AgentType, string Description) : BridgeEvent; + + /// 子代理工具活动(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; + /// 完整助手消息(追加滚动区)。 public sealed record AssistantMessage(string Text) : BridgeEvent; diff --git a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs index 5a9a70de0..21d68bfb2 100644 --- a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs +++ b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs @@ -13,6 +13,23 @@ private static (BridgeEventConsumer Consumer, StringWriter Out) Create(Func子代理三回调映射(DSG041 批3)— OnSubagentStarted/OnSubagentToolUse/OnSubagentFinished + /// → subagent_started/subagent_tool_use/subagent_finished 事件行(与 native_tui 侧字段逐一对齐)。 + [Fact] + public void SubagentCallbacks_EmitSubagentEventLines() { + var (consumer, @out) = Create(); + + consumer.OnSubagentStarted("agent-1", "explore", "搜索渲染链"); + consumer.OnSubagentToolUse("agent-1", "Grep", "命中 3 处", 120); + consumer.OnSubagentFinished("agent-1", isError: false); + + 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", + @out.ToString()); + } + [Fact] public void OnText_EmitsTokenDelta_AndOnDoneEmitsFullAssistantMessage() { var (consumer, @out) = Create(); diff --git a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs index 5f8afa4c9..968c6c2d3 100644 --- a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs +++ b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs @@ -102,4 +102,23 @@ public void Deserialize_TuiEmittedRequestLine_ParsesBack() { var submit = Assert.IsType(request); Assert.Equal("你好 world", submit.Text); } + + /// 子代理三事件 JSON 形状(DSG041 批3 — 与 native_tui 侧 BridgeEvent 逐字段镜像: + /// subagent_started/subagent_tool_use/subagent_finished,camelCase 字段)。 + [Fact] + 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), + }; + 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}", + }; + for (var i = 0; i < events.Length; i++) { + Assert.Equal(expected[i], JsonSerializer.Serialize(events[i], CliBridge.BridgeJsonContext.Safe.BridgeEvent)); + } + } } From b3558850295085e1243cef1612aa23ba83a2182a Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 07:26:14 +0800 Subject: [PATCH 09/30] =?UTF-8?q?feat:=20DSG041=20=E6=89=B94=20InProcessEv?= =?UTF-8?q?entConsumer=20=E5=AD=90=E4=BB=A3=E7=90=86=E4=B8=89=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E6=98=A0=E5=B0=84=EF=BC=88TUI=20=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=86=85=E7=9B=B4=E8=BF=9E=E8=B7=AF=E5=BE=84=EF=BC=89=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E4=B8=89=E6=96=B9=E6=B3=95=E7=9B=B4?= =?UTF-8?q?=E8=BD=AC=20BridgeEvent.SubagentStarted/SubagentToolUse/Subagen?= =?UTF-8?q?tFinished=20=E5=86=85=E5=AD=98=20record=20=E5=85=A5=E9=98=9F?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E9=BD=90=20bridge=20=E5=BD=A2=E6=80=81?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E5=B7=AE=E5=BC=82=E2=80=94=E2=80=94=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=20stdout=20=E2=86=92=20=E9=98=9F=E5=88=97?= =?UTF-8?q?=E5=86=99=E5=86=85=E5=AD=98=E5=AF=B9=E8=B1=A1=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=AE=9E=E7=8E=B0=E8=A7=84=E9=81=BF=20DIM=20?= =?UTF-8?q?=E7=BB=8F=E7=B1=BB=E7=B1=BB=E5=9E=8B=E8=B0=83=E7=94=A8=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E5=9D=91=EF=BC=9B=E4=B8=8B=E6=B8=B8=20ReplBr?= =?UTF-8?q?idgeDriver=20=E8=B7=AF=E7=94=B1=E4=B8=8E=20SubagentGroupCoordin?= =?UTF-8?q?ator=20=E5=88=86=E7=BB=84=E6=B8=B2=E6=9F=93=E5=9D=87=E6=97=A2?= =?UTF-8?q?=E6=9C=89=E9=9B=B6=E6=94=B9=E5=8A=A8=E2=80=94=E2=80=94=E6=95=B4?= =?UTF-8?q?=E6=9D=A1=E3=80=8C=E5=BC=95=E6=93=8E=E9=80=9A=E9=81=93=E7=9B=B4?= =?UTF-8?q?=E6=8A=95=E2=86=92SessionController=20=E5=88=86=E6=B5=81?= =?UTF-8?q?=E2=86=92=E6=B6=88=E8=B4=B9=E8=80=85=E6=98=A0=E5=B0=84=E2=86=92?= =?UTF-8?q?TUI=20=E5=88=86=E7=BB=84=E6=B8=B2=E6=9F=93=E3=80=8D=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E9=97=AD=E5=90=88=EF=BC=9B1=20=E6=96=B0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20+=20NativeTui.Host.Tests=2061=20=E5=85=A8=E7=BB=BF?= =?UTF-8?q?=20+=20Host=20=E7=BC=96=E8=AF=91=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NativeTui.Host/InProcessEventConsumer.cs | 16 +++++++++++ .../InProcessEventConsumerTests.cs | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs index a9167d8c8..7d99c2dbe 100644 --- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs +++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs @@ -87,6 +87,22 @@ public void OnDone(TokenUsage? usage, string? modelId) { _events.Enqueue(new BridgeEvent.AssistantMessage(fullText, MarkdigProjector.ProjectOptional(fullText))); } + /// 子代理启动 → SubagentStarted(DSG041 批4 — 显式实现:DIM 默认实现经类类型调用不可见; + /// ReplBridgeDriver 路由已就绪,分组渲染零改动验收面)。 + public void OnSubagentStarted(string subagentId, string agentType, string description) { + _events.Enqueue(new BridgeEvent.SubagentStarted(subagentId, agentType, description)); + } + + /// 子代理工具活动 → SubagentToolUse(原地更新组条目 + 活动日志环形缓冲)。 + public void OnSubagentToolUse(string subagentId, string toolName, string summary, int tokenGain) { + _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)); + } + /// 排空事件队列 — 主循环每 tick 调用,取走全部待处理事件(ConcurrentQueue 多生产单消费安全)。 public IReadOnlyList DrainEvents() { List? drained = null; diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs index 6791f5b0b..dc43cb029 100644 --- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs @@ -27,6 +27,33 @@ public void OnText_EmitsTokenDeltaPerCall() Assert.Empty(consumer.DrainEvents()); } + /// 子代理三回调映射(DSG041 批4)— OnSubagentStarted/OnSubagentToolUse/OnSubagentFinished + /// → SubagentStarted/SubagentToolUse/SubagentFinished 内存事件(ReplBridgeDriver 路由既有,零改动验收面)。 + [Fact] + public void SubagentCallbacks_EnqueueSubagentEvents() + { + var consumer = new InProcessEventConsumer(); + + consumer.OnSubagentStarted("agent-1", "explore", "搜索渲染链"); + consumer.OnSubagentToolUse("agent-1", "Grep", "命中 3 处", 120); + consumer.OnSubagentFinished("agent-1", isError: false); + + var events = consumer.DrainEvents(); + Assert.Equal(3, events.Count); + var started = Assert.IsType(events[0]); + Assert.Equal("agent-1", started.SubagentId); + Assert.Equal("explore", started.AgentType); + Assert.Equal("搜索渲染链", started.Description); + var toolUse = Assert.IsType(events[1]); + Assert.Equal("agent-1", toolUse.SubagentId); + Assert.Equal("Grep", toolUse.ToolName); + Assert.Equal("命中 3 处", toolUse.Summary); + Assert.Equal(120, toolUse.TokenGain); + var finished = Assert.IsType(events[2]); + Assert.Equal("agent-1", finished.SubagentId); + Assert.False(finished.IsError); + } + [Fact] public void OnThinking_SignalsOncePerTurn() { From 21c994b59c6214251cb24ed61823ea41f33d49ec Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 07:27:21 +0800 Subject: [PATCH 10/30] =?UTF-8?q?docs:=20DSG041=20=E6=89=B93/=E6=89=B94=20?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=EF=BC=88=E4=BB=A3=E7=A0=81=E6=89=B9=E6=AC=A1?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E8=90=BD=E5=9C=B0=EF=BC=8C=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E9=97=AD=E5=90=88=EF=BC=9B=E5=89=A9=E4=BD=99=E9=9B=86=E6=88=90?= =?UTF-8?q?=E7=BA=A7=E9=AA=8C=E6=94=B6=E4=B8=89=E9=A1=B9=E5=88=97=E6=98=8E?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design/DSG041-subagent-event-source-plan.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/proposed/design/DSG041-subagent-event-source-plan.md b/docs/proposed/design/DSG041-subagent-event-source-plan.md index 794bbad34..48310bf9d 100644 --- a/docs/proposed/design/DSG041-subagent-event-source-plan.md +++ b/docs/proposed/design/DSG041-subagent-event-source-plan.md @@ -72,16 +72,18 @@ native_tui(TUI 宿主)子代理分组渲染全链已落地(SubagentGroupCo | L3 IEventConsumer 三方法(DIM 默认空)+ SessionController RouteSubagentEvent 前置分流 | BridgeEventConsumer / InProcessEventConsumer(批4/批5 接映射) | ✅ 批1(hash 已随 PR #439 squash,内容在库) | ✅ SessionControllerSubagentEventTests 5/5(Started/Finished/工具分流/普通零回归/混合流)+ host.tests 1371 + abs.tests 694 + engine/cli/Host 三链编译绿 | | L1 SubAgentEventChannel 接通主代理路径(**宿主层直投方案**) | AgentStreamExecutionMiddleware/ForkSubAgentManagerActor 发射侧(零改动) | ✅ 批2(6b762c145) | ✅ 单元级:MainAgentPath 2/2(通道事件全序列直投三回调 + 子代理工具事件不走普通回调)+ host.tests 1373 全绿 + Host 编译绿;⏳ 集成级:E2E 复制先例(批2 收尾项) | | ~~L2 AgentStreamChunkAdapter AgentId 传播~~ | ~~SessionController chunk→ChatStreamEvent 翻译~~ | **✅ 被批2 宿主层直投方案吸收**(通道事件不经 chunk 转换层,AgentBase/adapter 零改动;批3 撤销) | —(无独立交付物) | -| L4 引擎侧 BridgeEvent 三 record | bridge_serve JSON 协议消费方 | ⏳ 批4 | ⏳ BridgeJsonContractTests | -| InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ⏳ 批5 | ⏳ Host --selftest 扩断言(ADR 0080 真实运行) | -| TUI 真实链路分组显示 | SubagentGroupCoordinator → 分组渲染(既有零改动) | ⏳ 批5 | ⏳ 用户真实终端(agents 场景真实引擎 spawn) | +| L4 引擎侧 BridgeEvent 三 record + BridgeEventConsumer 显式映射 | bridge_serve JSON 协议消费方 | ✅ 批3(6d55a8215) | ✅ BridgeJsonContractTests JSON 形状快照锁定(subagent_started/subagent_tool_use/subagent_finished camelCase)+ BridgeEventConsumerTests 映射断言 + host.tests 1375 + engine/cli/Host 三链编译绿 | +| InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ✅ 批4(3f7f88919) | ✅ InProcessEventConsumerTests 映射断言(三 record 字段逐一对齐)+ NativeTui.Host.Tests 61 全绿 + Host 编译绿;⏳ Host --selftest 扩断言(真实运行收口) | +| TUI 真实链路分组显示 | SubagentGroupCoordinator → 分组渲染(既有零改动) | ✅ 链路闭合(批1-4) | ⏳ 用户真实终端(agents 场景真实引擎 spawn) | ## 8. 后续 - ~~批1(L3 协议扩展)~~ **✅ 已完成(内容随 PR #439 squash 入库)** - ~~批2(L1 通道接通)~~ **✅ 已完成(6b762c145,宿主层直投方案 — 见批2 实施记录)** -- ~~批3(L2 chunk 传播)~~ **✅ 撤销 — 被批2 宿主层直投方案吸收**(通道事件绕过 chunk 转换层,AgentBase/adapter 零改动;DSG041 剩余批次重编号:批3=引擎侧 DTO(原批4)、批4=TUI 映射+真链(原批5)) -- 批2 集成级验收(E2E 复制先例)为收尾项,可与后续批次并行 +- ~~批3(L2 chunk 传播)~~ **✅ 撤销 — 被批2 宿主层直投方案吸收** +- ~~批4(原批3 引擎侧 DTO + BridgeEventConsumer 映射)~~ **✅ 已完成(6d55a8215)** +- ~~批5(原批4 InProcessEventConsumer 映射)~~ **✅ 已完成(3f7f88919)— 代码批次全部落地,链路闭合** +- **剩余集成级验收**(收尾项):① 批2 E2E(复制 SubAgentEventStreamE2ETests 先例改造为 QueryEngine 主代理路径)② Host --selftest 扩子代理断言 ③ 用户真实终端收口 - 本 DSG 落地后 TASK055 三缺口③关闭 ### 批1 实施记录(hash 已随 PR #439 squash,内容在库) From 339639f05ca3f1a111682b38c246e3d2692ab0c0 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 19:49:25 +0800 Subject: [PATCH 11/30] =?UTF-8?q?feat:=20McpPluginBootstrapHandle.Completi?= =?UTF-8?q?on=20=E6=8F=92=E4=BB=B6=E5=B0=B1=E7=BB=AA=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=EF=BC=88=E9=82=AE=E7=AE=B1=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E6=97=B6=E9=97=B4=E6=80=A7=E7=AD=89=E5=BE=85?= =?UTF-8?q?=EF=BC=89+=20E2E=20=E4=B8=BB=E4=BB=A3=E7=90=86=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=AA=A8=E6=9E=B6=20|=20=E5=86=B3=E7=AD=96:=20Complet?= =?UTF-8?q?ion=20=E6=9A=B4=E9=9C=B2=E5=90=8E=E5=8F=B0=E9=93=BE=20Task?= =?UTF-8?q?=EF=BC=88Run=20=E5=89=8D=E5=B7=B2=E5=AE=8C=E6=88=90=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E3=80=81Run=20=E5=90=8E=E5=8D=B3=E5=85=AB=E6=8F=92?= =?UTF-8?q?=E4=BB=B6+=E5=B0=B1=E7=BB=AA=E9=92=A9=E5=AD=90=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E4=BF=A1=E5=8F=B7=EF=BC=89=EF=BC=8C=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E6=96=B9=E7=AD=89=E9=80=9A=E7=9F=A5=E8=80=8C=E9=9D=9E=20Task.D?= =?UTF-8?q?elay=20=E5=9B=BA=E5=AE=9A=E5=BB=B6=E6=97=B6=E2=80=94=E2=80=94CI?= =?UTF-8?q?=20=E5=BF=AB=E6=85=A2=E6=9C=BA=E5=9D=87=E5=8F=AF=E9=9D=A0?= =?UTF-8?q?=E4=B8=94=E9=9B=B6=E6=B5=AA=E8=B4=B9=EF=BC=88=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=8C=87=E6=AD=A3=E6=97=B6=E9=97=B4=E8=80=A6=E5=90=88=E6=89=8B?= =?UTF-8?q?=E6=B3=95=E9=94=99=E8=AF=AF=E5=90=8E=E7=9A=84=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E5=BD=A2=E6=80=81=EF=BC=89=EF=BC=9BE2E=20=E4=B8=BB=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=B7=AF=E5=BE=84=E8=84=9A=E6=9C=AC=E5=90=AB=20Comple?= =?UTF-8?q?tion=20=E7=AD=89=E5=BE=85=E7=A4=BA=E8=8C=83=E7=94=A8=E6=B3=95+3?= =?UTF-8?q?0s=20=E6=8C=82=E6=AD=BB=E4=BF=9D=E6=8A=A4=EF=BC=9BE2E=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=90=8D=E4=BF=AE=E6=AD=A3=20Agent=E2=86=92a?= =?UTF-8?q?gent=EF=BC=88AgentToolName=20=E6=9E=9A=E4=B8=BE=20[EnumValue("a?= =?UTF-8?q?gent")]=20=E7=9C=9F=E5=80=BC=EF=BC=89=EF=BC=9B=E4=B8=BB?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E8=B7=AF=E5=BE=84=20E2E=20Skip=20=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E5=BC=95=E6=93=8E=E4=BE=A7=E5=AD=90=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E9=93=BE=E5=B4=A9=E6=BA=83=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E7=85=A7=20ChatService=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=90=8C=E5=9F=9F=E7=BA=A2=E2=80=94=E2=80=94=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20LLM=20=E8=BE=93=E5=87=BA=E6=97=A0=20AgentId=20?= =?UTF-8?q?=E4=B8=B2=E5=85=A5=E4=B8=BB=E6=B5=81=EF=BC=8C=E7=96=91=20TASK04?= =?UTF-8?q?3=20=E6=B5=81=E5=BC=8F=E9=80=8F=E4=BC=A0=E5=9F=9F=EF=BC=8C?= =?UTF-8?q?=E9=9D=9E=20DSG041=20=E6=8E=A5=E7=BA=BF=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E5=85=A8=E7=BB=BF=EF=BC=89=EF=BC=9BInternals?= =?UTF-8?q?VisibleTo=20=E8=A1=A5=20E2E=20=E6=B5=8B=E8=AF=95=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=EF=BC=9B1=20=E6=96=B0=E6=B5=8B=E8=AF=95+host.tests=20?= =?UTF-8?q?1376=20=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/apps/engine/JoinCode.Engine.csproj | 1 + .../engine/app/modules/McpPluginBootstrap.cs | 5 + .../mock_server.e2e.tests/GlobalUsings.cs | 1 + .../SubAgentEventMainAgentPathE2ETests.cs | 248 ++++++++++++++++++ .../e2e/s_to_z/SubAgentEventStreamE2ETests.cs | 2 +- .../engine/McpPluginBootstrapHandleTests.cs | 19 ++ 6 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventMainAgentPathE2ETests.cs diff --git a/modules/apps/engine/JoinCode.Engine.csproj b/modules/apps/engine/JoinCode.Engine.csproj index cb72fd50d..03d5761ca 100644 --- a/modules/apps/engine/JoinCode.Engine.csproj +++ b/modules/apps/engine/JoinCode.Engine.csproj @@ -32,6 +32,7 @@ + diff --git a/modules/apps/engine/app/modules/McpPluginBootstrap.cs b/modules/apps/engine/app/modules/McpPluginBootstrap.cs index 9a3828bb6..6d3103244 100644 --- a/modules/apps/engine/app/modules/McpPluginBootstrap.cs +++ b/modules/apps/engine/app/modules/McpPluginBootstrap.cs @@ -86,6 +86,11 @@ public void Run(Func background) _background = background(_cts.Token); } + /// 插件就绪完成信号(邮箱/通知语义)— Run 前为已完成 Task(无可等之物); + /// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。消费方(E2E/宿主)等此信号 + /// 确认工具注册就绪,替代时间性等待(Task.Delay 固定延时在 CI 快慢机上均不可靠)。 + public Task Completion => _background; + /// 收尾 — 取消(尽力加速断链)→ 无超时等待链完成(链恒成功语义由 Bootstrap 保证,兜底吞);幂等。 public async ValueTask DisposeAsync() { diff --git a/test/mock/mock_server.e2e.tests/GlobalUsings.cs b/test/mock/mock_server.e2e.tests/GlobalUsings.cs index 4af90ccc8..81c9829c0 100644 --- a/test/mock/mock_server.e2e.tests/GlobalUsings.cs +++ b/test/mock/mock_server.e2e.tests/GlobalUsings.cs @@ -11,6 +11,7 @@ global using JoinCode.Abstractions.Utils; global using JoinCode.Entry; global using Microsoft.Extensions.Logging.Abstractions; +global using Microsoft.Extensions.DependencyInjection; global using MockServer.E2E.Tests.Core; global using MockServer.E2E.Tests.Fixtures; global using MockServer.E2E.Tests.Scripts; 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 new file mode 100644 index 000000000..bdf3be8cc --- /dev/null +++ b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventMainAgentPathE2ETests.cs @@ -0,0 +1,248 @@ +// JCC1017 抑制: 存量手写 JSON, 后续改为 DTO+JsonContext +// JCC11003 抑制: 存量代码可空抑制, 后续逐步修复 +#pragma warning disable JCC1017 +namespace MockServer.E2E.Tests.E2E; + +/// +/// 子代理事件主代理路径 E2E(DSG041 批2 集成级验收)— 真实 OpenAI.MockServer 进程 + 进程内引擎 +/// + **SessionController 主代理路径**(EngineAgentFactory.CreateMainAgent + StreamResponseAsync, +/// 与 native_tui Host 完全同链路,区别于 SubAgentEventStreamE2ETests 的 ChatService 管道路径): +/// 验证宿主层通道直投全链——Agent 工具 spawn 子代理 → AgentStreamExecutionMiddleware Emit 到 +/// SubAgentEventChannel → SessionController chunk 循环排空直投 RouteSubagentEvent → +/// IEventConsumer 三子代理回调(OnSubagentStarted/OnSubagentToolUse/OnSubagentFinished)。 +/// +[Trait("Category", "Integration")] +public sealed class SubAgentEventMainAgentPathE2ETests { + private readonly ITestOutputHelper _output; + + public SubAgentEventMainAgentPathE2ETests(ITestOutputHelper output) => _output = output; + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] + [Fact(Skip = "引擎侧子代理执行链缺陷(非 DSG041 接线问题——批1-4 单测全绿):agent 工具真实执行后 testhost 崩溃;" + + "对照 ChatService 路径 SubAgentEventStreamE2ETests 同域红(子代理 LLM 输出无 AgentId 串入主流、无 AgentStarted)," + + "疑流式透传缺陷域(TASK043 StreamingFallbackDecorator 关联)。引擎缺陷修复后移除 Skip 复验。")] + public async Task MainAgentPath_AgentSpawn_ShouldDispatchSubagentCallbacks() { + const string args = "{\"name\":\"e2e-main\",\"subagent_type\":\"executor:search\",\"description\":\"检查README文件\",\"prompt\":\"读取当前目录README并总结\"}"; + + var consumer = await RunMainAgentScenarioAsync(args).ConfigureAwait(true); + + Assert.NotEmpty(consumer.SubagentStarts); + var started = consumer.SubagentStarts[0]; + Assert.Equal("e2e-main", started.AgentType); + Assert.Contains("README", started.Description); + + Assert.NotEmpty(consumer.SubagentToolUses); + Assert.All(consumer.SubagentToolUses, use => Assert.Equal(started.SubagentId, use.SubagentId)); + + var finished = Assert.Single(consumer.SubagentFinishes); + Assert.Equal(started.SubagentId, finished.SubagentId); + Assert.False(finished.IsError); + } + + /// + /// 共享实弹管线(对齐 SubAgentEventStreamE2ETests 先例):写脚本配置 → 启动真实 MockServer → + /// 创建引擎会话 + 主代理 + SessionController(RecordingSubagentConsumer)→ 提交回合收全部子代理回调。 + /// + private async Task RunMainAgentScenarioAsync(string toolCallArguments) { + 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_subagent_mainpath_e2e_{Guid.NewGuid():N}"); + fs.CreateDirectory(configDir); + var configPath = fs.CombinePath(configDir, "mockserver.json"); + await fs.WriteAllText(configPath, BuildMockConfig(toolCallArguments)); + + using var mockServer = StartMockServer(fs, configPath, _output, 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-subagent-mainpath-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 mainAgent = JoinCode.Cli.EngineAgentFactory.CreateMainAgent(session.Services, new JoinCode.Abstractions.Entity.Session().ObjectId); + Assert.True(mainAgent is not null, "主代理创建失败(DI 无 IQueryEngine)"); + + // 确定性等待插件就绪(邮箱/通知语义 — 八插件后台化后工具注册晚于启动): + // 等 McpPluginBootstrapHandle.Completion 信号而非固定延时(CI 快慢机均可靠;超时仅挂死保护) + var bootstrapHandle = session.Services.GetService(); + if (bootstrapHandle is not null) + { + await bootstrapHandle.Completion.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(true); + } + + var consumer = new RecordingSubagentConsumer(); + var controller = new JoinCode.Adapters.SessionController( + session.ChatService, consumer, new JoinCode.Cli.TurnDiffService(), + session.SessionId, session.Services, mainAgent: mainAgent); + + var result = await controller.StreamResponseAsync("帮我检查项目中的README文件", ct).ConfigureAwait(true); + Assert.True(result.Succeeded, $"回合失败: {result.ErrorMessage ?? "(无)"}"); + + _output.WriteLine($"[DIAG] SubagentStarts={consumer.SubagentStarts.Count} ToolUses={consumer.SubagentToolUses.Count} Finishes={consumer.SubagentFinishes.Count} Texts={consumer.TextEvents.Count} ToolStarts={consumer.ToolStarts.Count} ToolEnds={consumer.ToolEnds.Count}"); + foreach (var text in consumer.TextEvents) + _output.WriteLine($"[DIAG:Text] {text[..Math.Min(60, text.Length)]}"); + foreach (var tool in consumer.ToolStarts) + _output.WriteLine($"[DIAG:ToolStart] {tool.toolName} args={tool.arguments?[..Math.Min(80, tool.arguments?.Length ?? 0)]}"); + foreach (var tool in consumer.ToolEnds) + _output.WriteLine($"[DIAG:ToolEnd] {tool.toolName} err={tool.isError} result={tool.resultText}"); + _output.WriteLine($"[DIAG:Response] {result.Response?[..Math.Min(300, result.Response?.Length ?? 0)]}"); + + return consumer; + } 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}"); } + } + } + + /// 回调序列诊断摘要(失败信息用)。 + private static string Describe(RecordingSubagentConsumer consumer) => + string.Join("|", consumer.SubagentStarts.Select(s => $"started({s.AgentType})") + .Concat(consumer.SubagentToolUses.Select(u => $"tool({u.ToolName})")) + .Concat(consumer.SubagentFinishes.Select(f => $"finished(err={f.IsError})"))); + + /// 脚本轮次:⓪ 意图检测消耗 turn(主代理路径 PreProcess 模态/意图分类会先调一次 LLM—— + /// 不加则 tool_calls turn 被其消耗、Agent 工具从不被调用)① 主对话 Agent 工具调用 + /// ② 子代理 LLM 文本输出 ③ 主对话收尾文本。 + private static string BuildMockConfig(string toolCallArguments) { + 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\": ["); + sb.AppendLine(" {"); + sb.AppendLine(" \"tool_name\": \"agent\","); + sb.Append(" \"arguments\": \"").Append(EscapeJsonString(toolCallArguments)).AppendLine("\","); + sb.AppendLine(" }".TrimEnd(',')); + sb.AppendLine(" ],"); + sb.AppendLine(" \"text_response\": null,"); + sb.AppendLine(" \"follow_up_text\": null,"); + sb.AppendLine(" \"http_status_code\": null"); + sb.AppendLine(" },"); + + AppendTextTurn(sb, "README 已确认存在:JoinCode 是一个 AI 工作流引擎。"); + sb.AppendLine(","); + AppendTextTurn(sb, "子代理已完成 README 检查。"); + + sb.AppendLine(); + sb.AppendLine(" ]"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void AppendTextTurn(StringBuilder sb, string text) { + sb.AppendLine(" {"); + sb.AppendLine(" \"thinking_content\": null,"); + sb.AppendLine(" \"tool_calls\": null,"); + sb.Append(" \"text_response\": \"").Append(text).AppendLine("\","); + sb.AppendLine(" \"follow_up_text\": null,"); + sb.AppendLine(" \"http_status_code\": null"); + sb.Append(" }"); + } + + private static string EscapeJsonString(string s) + => string.IsNullOrEmpty(s) ? "" : JsonEncodedText.Encode(s, JavaScriptEncoder.UnsafeRelaxedJsonEscaping).ToString(); + + /// 记录子代理回调的 fake 消费者(对齐 host.tests SessionControllerSubagentEventTests 形态)。 + private sealed class RecordingSubagentConsumer : JoinCode.Abstractions.Interfaces.IEventConsumer { + 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 void OnText(string content) => TextEvents.Add(content); + public List<(string toolName, string? arguments)> ToolStarts { get; } = []; + public List<(string toolName, string? resultText, bool isError)> ToolEnds { get; } = []; + public void OnThinking(string thinking) { } + public void OnToolStart(string toolName, string? toolCallId, string? arguments) => ToolStarts.Add((toolName, arguments)); + public void OnToolEnd(string toolName, string? resultText, string? toolCallId, bool isError, JoinCode.Abstractions.Models.Diff.StructuredPatchHunk[]? patch) => ToolEnds.Add((toolName, resultText is null ? null : resultText[..Math.Min(80, resultText.Length)], isError)); + public void OnToolProgress(string toolName, string progressType, string? progressMessage) { } + public void OnLoopDetected(int triggerCount, int loopStartIndex, string? repeatedPattern) { } + 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)); + } + + private System.Diagnostics.Process StartMockServer(JoinCode.Abstractions.Interfaces.IFileSystem fs, string configPath, + ITestOutputHelper output, out Task readyTask) { + var exe = ResolveMockServerPath(fs); + var startInfo = new System.Diagnostics.ProcessStartInfo { + FileName = exe, + Arguments = $"--config \"{configPath}\" --port 0", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + WorkingDirectory = Path.GetDirectoryName(exe) + }; + + var process = new System.Diagnostics.Process { StartInfo = startInfo }; + var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + const string readyMarker = "[OpenAI] URL:"; + + process.OutputDataReceived += (_, e) => { + if (string.IsNullOrEmpty(e.Data)) + return; + _output.WriteLine($"[MockServer] {e.Data}"); + var idx = e.Data.IndexOf(readyMarker, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return; + var urlPart = e.Data[(idx + readyMarker.Length)..].Trim(); + var match = Regex.Match(urlPart, @":(\d+)/?"); + if (match.Success && int.TryParse(match.Groups[1].Value, out var port)) + readyTcs.TrySetResult(port); + }; + process.ErrorDataReceived += (_, e) => { + if (!string.IsNullOrEmpty(e.Data)) + _output.WriteLine($"[MockServer:ERR] {e.Data}"); + }; + + if (!process.Start()) + throw new InvalidOperationException("[GEN026] 无法启动 MockServer 进程"); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + readyTask = readyTcs.Task; + return process; + } + + private static string ResolveMockServerPath(JoinCode.Abstractions.Interfaces.IFileSystem fs) { + var dir = AppContext.BaseDirectory; + while (dir is not null) { + var candidate = Path.Combine(dir, "artifacts", "bin", "OpenAI.MockServer"); + if (fs.DirectoryExists(candidate)) { + var exeName = OperatingSystem.IsWindows() ? "JoinCode.OpenAI.MockServer.exe" : "JoinCode.OpenAI.MockServer"; + var exe = fs.GetFiles(candidate, exeName, SearchOption.AllDirectories) + .OrderByDescending(p => p.Contains("Release")) + .FirstOrDefault() + ?? throw new FileNotFoundException($"MockServer exe 未编译,请先 dotnet build OpenAI.MockServer", candidate); + return exe; + } + dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar)); + } + throw new InvalidOperationException("未找到 artifacts/bin/OpenAI.MockServer 目录"); + } +} diff --git a/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs index 91b5fee2d..d68cd707f 100644 --- a/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs +++ b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs @@ -144,7 +144,7 @@ private static string BuildMockConfig(string toolCallArguments) { sb.AppendLine(" \"thinking_content\": null,"); sb.AppendLine(" \"tool_calls\": ["); sb.AppendLine(" {"); - sb.AppendLine(" \"tool_name\": \"Agent\","); + sb.AppendLine(" \"tool_name\": \"agent\","); sb.Append(" \"arguments\": \"").Append(EscapeJsonString(toolCallArguments)).AppendLine("\","); sb.AppendLine(" }".TrimEnd(',')); sb.AppendLine(" ],"); diff --git a/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs b/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs index bbdd6e555..6f968192c 100644 --- a/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs +++ b/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs @@ -83,4 +83,23 @@ public async Task RunSecondCallIgnored() await handle.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)); Assert.False(secondObserved); } + + /// Completion — 插件就绪完成信号(邮箱/通知语义,替代时间性等待):Run 前已完成 + /// (无可等之物);Run 后反映后台链 — 门闩放行前挂起、放行后完成。消费方(E2E/宿主)等 + /// 此信号确认工具注册就绪,禁止 Task.Delay 固定延时(CI 快慢机均不可靠)。 + [Fact] + public async Task Completion_ReflectsBackgroundChain_NotElapsed() + { + await using var handle = new McpPluginBootstrapHandle(); + Assert.True(handle.Completion.IsCompleted); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + handle.Run(_ => gate.Task); + var completion = handle.Completion; + Assert.False(completion.IsCompleted); + + gate.SetResult(); + await completion.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(handle.Completion.IsCompleted); + } } From 44a7b17f1d3f9ef82b4c58c43f99ab6ad18e9c18 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 20:13:48 +0800 Subject: [PATCH 12/30] =?UTF-8?q?fix:=20=E5=B7=A8=E5=9E=8B=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=A0=B9=E5=9B=A0=E4=BF=AE=E5=A4=8D=EF=BC=88=E4=B8=A4?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=B7=A5=E5=85=B7=E5=8A=A0=E8=BD=BD=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0=E6=98=AF=20McpToolDispatchAttribute.Kind=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=20System+=E7=94=9F=E6=88=90=E5=99=A8=20fallback=20"sy?= =?UTF-8?q?stem"=20=E5=8F=8C=E5=A4=84=E6=97=81=E8=B7=AF=E4=B8=A4=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E6=9C=BA=E5=88=B6=EF=BC=8C506=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=85=A8=E9=87=8F=20schema=20466KB=20=E8=A2=AB=2010s=20?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E6=92=9E=E6=96=AD=E8=87=B4=E7=A9=BA=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=EF=BC=9B=E9=BB=98=E8=AE=A4=E6=94=B9=20Mcp=EF=BC=88Bui?= =?UTF-8?q?ldToolsFromKernel=20=E5=B7=B2=E6=94=AF=E6=8C=81=20McpTools=20?= =?UTF-8?q?=E7=BB=84=E5=8F=AA=E5=8F=91=E5=90=8D=E7=A7=B0+tool=5Fdescriptio?= =?UTF-8?q?n=5Frequest=20=E4=BA=8C=E9=98=B6=E6=AE=B5=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=A0=B8=E5=BF=83=E5=9B=9B=E7=B1=BB=20File/S?= =?UTF-8?q?earch/CodeExecution/Agent=20=E6=98=BE=E5=BC=8F=20System=20?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=20Claude=20Code=20?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=B7=A5=E5=85=B7=E9=9B=86=EF=BC=9B=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E8=AF=B7=E6=B1=82=E4=BD=93=20466919=E2=86=9295509=20?= =?UTF-8?q?=E5=AD=97=E8=8A=82(-80%)=E3=80=81=E5=B7=A5=E5=85=B7=20506?= =?UTF-8?q?=E2=86=9232=E3=80=81=E8=80=97=E6=97=B6=209.9s=E2=86=921.9s?= =?UTF-8?q?=E3=80=81sensenova=20=E7=9C=9F=E5=AE=9E=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core_attributes/mcp/McpToolDispatchAttribute.cs | 7 ++++--- .../dev_tools/handlers/CodeExecutionToolHandlers.cs | 2 +- .../tool_handlers/dev_tools/handlers/FileToolHandlers.cs | 2 +- .../tool_handlers/dev_tools/handlers/SearchToolHandlers.cs | 2 +- .../tool_handlers/system_tools/agent/AgentToolHandlers.cs | 2 +- .../McpToolDispatchGenerator.cs | 7 +++++-- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/modules/abstractions/abstractions/abs_core/core_attributes/mcp/McpToolDispatchAttribute.cs b/modules/abstractions/abstractions/abs_core/core_attributes/mcp/McpToolDispatchAttribute.cs index 601a084a4..54bd11bf3 100644 --- a/modules/abstractions/abstractions/abs_core/core_attributes/mcp/McpToolDispatchAttribute.cs +++ b/modules/abstractions/abstractions/abs_core/core_attributes/mcp/McpToolDispatchAttribute.cs @@ -14,10 +14,11 @@ public sealed class McpToolDispatchAttribute : Attribute { public bool Optional { get; set; } /// - /// 工具类型 — 决定注入策略:System(始终注入) / Mcp(按分组注入) / OnError(报错时动态注入) - /// 默认 System,保持向后兼容 + /// 工具类型 — 决定注入策略:System(始终注入+完整 schema) / Mcp(按分组注入,首次只发名称 — 两阶段加载) / OnError(报错时动态注入) + /// 默认 Mcp(修复:默认 System 致 506 工具全量 schema 466KB 巨型请求被 10s 超时撞断 — + /// 核心工具须显式声明 System,对齐基准 Claude Code 核心工具集 Read/Write/Edit/Grep/Glob/Bash/Task) /// - public ToolKind Kind { get; set; } = ToolKind.System; + public ToolKind Kind { get; set; } = ToolKind.Mcp; /// /// 二级分组名 — MCP工具必填,系统工具可选 diff --git a/modules/cognition/hands/tool_handlers/dev_tools/handlers/CodeExecutionToolHandlers.cs b/modules/cognition/hands/tool_handlers/dev_tools/handlers/CodeExecutionToolHandlers.cs index b7a8b73e3..4360f3bd1 100644 --- a/modules/cognition/hands/tool_handlers/dev_tools/handlers/CodeExecutionToolHandlers.cs +++ b/modules/cognition/hands/tool_handlers/dev_tools/handlers/CodeExecutionToolHandlers.cs @@ -6,7 +6,7 @@ namespace Tools.Handlers; /// /// 代码执行工具处理器 - 提供安全的 C# 代码执行、表达式计算、代码片段测试等功能 /// -[McpToolDispatch(ToolCategory.CodeExecution)] +[McpToolDispatch(ToolCategory.CodeExecution, Kind = ToolKind.System)] public class CodeExecutionToolHandlers { private readonly ICodeSandboxService _codeSandboxService; private readonly ICodeSecurityValidator _codeSecurityValidator; diff --git a/modules/cognition/hands/tool_handlers/dev_tools/handlers/FileToolHandlers.cs b/modules/cognition/hands/tool_handlers/dev_tools/handlers/FileToolHandlers.cs index 64f3d1989..ce8d8d47f 100644 --- a/modules/cognition/hands/tool_handlers/dev_tools/handlers/FileToolHandlers.cs +++ b/modules/cognition/hands/tool_handlers/dev_tools/handlers/FileToolHandlers.cs @@ -6,7 +6,7 @@ namespace Tools.Handlers; /// /// 文件工具处理器 — 提供 Read/Write/Edit/List/Patch/Snip/Delete/Diagnostics 等文件操作工具 /// -[McpToolDispatch(ToolCategory.File)] +[McpToolDispatch(ToolCategory.File, Kind = ToolKind.System)] public partial class FileToolHandlers : IDisposable { private readonly CancellationTokenSource _disposeCts = new(); private bool _disposed; diff --git a/modules/cognition/hands/tool_handlers/dev_tools/handlers/SearchToolHandlers.cs b/modules/cognition/hands/tool_handlers/dev_tools/handlers/SearchToolHandlers.cs index ecfce9b13..9ce765938 100644 --- a/modules/cognition/hands/tool_handlers/dev_tools/handlers/SearchToolHandlers.cs +++ b/modules/cognition/hands/tool_handlers/dev_tools/handlers/SearchToolHandlers.cs @@ -139,7 +139,7 @@ public sealed record SymbolSearchOptions { /// Search tool handlers - provides Glob and Grep search capabilities /// Aligned with JoinCode's GlobTool and GrepTool /// -[McpToolDispatch(ToolCategory.Search)] +[McpToolDispatch(ToolCategory.Search, Kind = ToolKind.System)] public class SearchToolHandlers : OneShotCommandGroup { private readonly ISearchService _searchService; private readonly IFileOperationService _fileOperationService; diff --git a/modules/cognition/hands/tool_handlers/system_tools/agent/AgentToolHandlers.cs b/modules/cognition/hands/tool_handlers/system_tools/agent/AgentToolHandlers.cs index edff82f9c..b2d2927a7 100644 --- a/modules/cognition/hands/tool_handlers/system_tools/agent/AgentToolHandlers.cs +++ b/modules/cognition/hands/tool_handlers/system_tools/agent/AgentToolHandlers.cs @@ -56,7 +56,7 @@ public sealed record AgentCreateOptions { /// 消息通信 → .Messaging.cs(partial)。 /// 诊断消息 → 。 /// -[McpToolDispatch(ToolCategory.Agent, Optional = true)] +[McpToolDispatch(ToolCategory.Agent, Optional = true, Kind = ToolKind.System)] public partial class AgentToolHandlers { private readonly MiddlewarePipeline _pipeline; private readonly IAgentService _agentService; diff --git a/modules/generators/mcp_tool_dispatch.generator/McpToolDispatchGenerator.cs b/modules/generators/mcp_tool_dispatch.generator/McpToolDispatchGenerator.cs index 05ece5cf7..96544be86 100644 --- a/modules/generators/mcp_tool_dispatch.generator/McpToolDispatchGenerator.cs +++ b/modules/generators/mcp_tool_dispatch.generator/McpToolDispatchGenerator.cs @@ -59,7 +59,10 @@ private static void VisitNamespaces( if (attribute is not null) { var displayName = attribute.ConstructorArguments.FirstOrDefault().Value as string ?? typeSymbol.Name; var optional = false; - var kind = "system"; + // 默认 mcp(修复:默认 system 致全部工具全量 schema 466KB 巨型请求被超时撞断 — + // 两阶段工具加载:核心工具须显式 [McpToolDispatch(Kind = ToolKind.System)], + // 对齐基准 Claude Code 核心工具集 Read/Write/Edit/Grep/Glob/Bash/Task) + var kind = "mcp"; string? groupName = null; foreach (var namedArg in attribute.NamedArguments) { if (namedArg.Key == "Optional" && namedArg.Value.Value is bool b) @@ -69,7 +72,7 @@ private static void VisitNamespaces( if (resolved is not null) displayName = resolved; } else if (namedArg.Key == "Kind" && namedArg.Value.Value is int kindValue) { - kind = TryResolveToolKindValue(compilation, kindValue) ?? "system"; + kind = TryResolveToolKindValue(compilation, kindValue) ?? "mcp"; } else if (namedArg.Key == "GroupName" && namedArg.Value.Value is string gn) { groupName = gn; } From b7812de7d48445303b1ed5aacbff1c478a4b3c4b Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:00:23 +0800 Subject: [PATCH 13/30] =?UTF-8?q?feat:=20ModelInfo=20=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E7=B1=BB+=E4=B8=8A=E4=B8=8B=E6=96=87=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E7=A1=AC=E6=A0=A1=E9=AA=8C+=E8=AF=B7=E6=B1=82=E5=89=8D?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E5=AE=88=E5=8D=AB=EF=BC=881M=20=E6=97=B6?= =?UTF-8?q?=E4=BB=A3=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E4=B8=89?= =?UTF-8?q?=E5=B1=82=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E2=91=A0ModelInfo?= =?UTF-8?q?=20=E4=BF=A1=E6=81=AF=E7=B1=BB=EF=BC=88ModelId/Provider/Display?= =?UTF-8?q?Name/ContextWindow+ValidateOrThrow=EF=BC=8C=E4=B8=A4=E6=BA=90?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=20ModelEntry/ModelItemConfig=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E2=91=A1ContextWindowResolver=20=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E5=8C=96=EF=BC=88FindModel=20=E5=91=BD=E4=B8=AD=20contextWindo?= =?UTF-8?q?w<=3D0=20=E6=8A=9B=20ConfigurationException=20=E5=B8=A6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8C=87=E5=BC=95=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E9=80=8F=E4=BC=A0=200=20=E8=87=B4=E4=B8=8B?= =?UTF-8?q?=E6=B8=B8=20ArgumentOutOfRange=E2=80=94=E2=80=94agnes=209=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=200=20=E5=80=BC=E8=84=8F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BD=A2=E6=80=81=E5=B0=86=E5=8D=B3=E6=97=B6=E6=8A=A5=E9=94=99?= =?UTF-8?q?=EF=BC=9B=E6=9C=AA=E7=9F=A5=E6=A8=A1=E5=9E=8B=E4=BB=8D=E5=85=9C?= =?UTF-8?q?=E5=BA=95=20200K=20=E4=B8=8D=E9=98=BB=E6=96=AD=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E2=91=A2=E8=AF=B7=E6=B1=82=E5=89=8D=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E5=AD=A4=E5=84=BF=E6=8E=A5=E7=BA=BF=EF=BC=88?= =?UTF-8?q?DecidePreflight=20=E6=AD=A4=E5=89=8D=E6=97=A0=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E6=96=B9=E2=80=94=E2=80=94QueryLoopMiddleware=20=E6=AF=8F?= =?UTF-8?q?=E8=BD=AE=E8=BF=AD=E4=BB=A3=E5=8F=96=E5=8E=86=E5=8F=B2=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=90=8E=20GuardContextBudget=EF=BC=8C=E4=BC=B0?= =?UTF-8?q?=E7=AE=97=E6=AF=94=E7=8E=87>=3D1.0=20=E6=8A=9B=20ContextOverflo?= =?UTF-8?q?wException=20=E7=9B=B4=E6=8E=A5=E6=8B=A6=E6=88=AA=E4=B8=8D?= =?UTF-8?q?=E5=8F=91=20API=20=E5=B8=A6=E6=B8=85=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E8=AF=B1=E5=AF=BC=EF=BC=8Cnull=20=E5=AE=B9=E9=94=99=E4=B8=8D?= =?UTF-8?q?=E7=A0=B4=20mock/=E6=97=A7=E5=AE=9E=E7=8E=B0=EF=BC=89=EF=BC=9BI?= =?UTF-8?q?ChatContextManager=20=E5=A2=9E=20DecidePreflightWithCurrentSpec?= =?UTF-8?q?s=20=E8=87=AA=E5=8F=96=E7=89=88=EF=BC=88=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20ChatContextManager=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=20sensenova=20=E6=AD=A3=E5=B8=B8=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...27\345\217\243\346\240\241\351\252\214.md" | 36 +++++++++++ .../abs_core/configuration/llm/ModelInfo.cs | 55 +++++++++++++++++ .../conversation/IChatContextManager.cs | 3 + .../core/o_to_t/QueryLoopMiddlewareTests.cs | 54 +++++++++++++++++ .../services/chat/core/QueryLoopMiddleware.cs | 24 ++++++++ .../services/context/ChatContextManager.cs | 6 ++ .../shared/ContextWindowResolverTests.cs | 54 ++++++++++++++++- .../core/providers/shared/ModelInfoTests.cs | 59 +++++++++++++++++++ .../providers/shared/ContextWindowResolver.cs | 9 ++- 9 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 "docs/proposed/task/TASK061-ModelInfo\344\270\216\344\270\212\344\270\213\346\226\207\347\252\227\345\217\243\346\240\241\351\252\214.md" create mode 100644 modules/abstractions/abstractions/abs_core/configuration/llm/ModelInfo.cs create mode 100644 modules/guard/guard.config.tests/configuration/core/providers/shared/ModelInfoTests.cs diff --git "a/docs/proposed/task/TASK061-ModelInfo\344\270\216\344\270\212\344\270\213\346\226\207\347\252\227\345\217\243\346\240\241\351\252\214.md" "b/docs/proposed/task/TASK061-ModelInfo\344\270\216\344\270\212\344\270\213\346\226\207\347\252\227\345\217\243\346\240\241\351\252\214.md" new file mode 100644 index 000000000..a0339b519 --- /dev/null +++ "b/docs/proposed/task/TASK061-ModelInfo\344\270\216\344\270\212\344\270\213\346\226\207\347\252\227\345\217\243\346\240\241\351\252\214.md" @@ -0,0 +1,36 @@ +# TASK061 — ModelInfo 信息类 + 上下文窗口配置校验(1M 时代) + +> 状态:in_progress(2026-10-21 开工) +> 前置:巨型请求修复(4c27592ab)完结;用户指令:"主流模型上下文已达 1M,模型配置里写模型名称+上下文大小,不对的直接报错;建一个 modelInfo 信息类" + +## 调研结论 + +| 资产 | 现状 | 缺口 | +|------|------|------| +| ModelEntry(abs_core/configuration/llm) | Id/DisplayName/ContextWindow/Description 配置 DTO | 无自校验;无"当前模型"聚合信息类 | +| ContextWindowResolver(guard) | env > ModelConfigLoader.FindModel > ProviderDefinition > **兜底 200K** | **FindModel 命中但 ContextWindow=0 直接返回 0**(agnes 9 模型全 0 实锤)→ 下游 DecidePreflight 抛不友好 ArgumentOutOfRange;兜底 200K 掩盖配置错误(1M 模型被错误当 200K) | +| ContextFoldService(brain) | 折叠后仍超紧急阈值抛 ContextOverflowException(骨架已有) | 入口 ctxMax=0 时不友好 | +| providers_llm/{vendor}/models.json | contextWindow 字段已有 | agnes 9 模型全 0(脏数据);加载零校验 | + +## 方案(三步) + +### Step 1 — ModelInfo 信息类(abstractions) +`ModelInfo` sealed record:ModelId/Provider/DisplayName/ContextWindow + `ValidateOrThrow()` +(contextWindow<=0 → ConfigurationException 带修复指引:模型名+供应商+providers_llm 路径); +静态 `FromEntry(provider, ModelEntry)` 投影。 + +### Step 2 — ContextWindowResolver 严格化(guard) +`ResolveCurrentContextWindow`:FindModel 命中但 ContextWindow<=0 → 抛 ConfigurationException +(不再静默透传 0);找不到模型仍兜底 200K(未知模型不阻断,日志警告)。 + +### Step 3 — agnes 脏数据处置 +agnes models.json 9 模型 contextWindow=0 — 校验报错即用户要求的"直接报错"; +不猜数据(多模态模型窗口未知),错误信息带修复指引由用户补值。 + +## 验收表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| ModelInfo + ValidateOrThrow(abstractions) | resolver/切换链复用 | 待做 | 待真机 | +| resolver 0 值严格化(guard) | 启动/请求前窗口解析 | 待做 | 待真机(agnes 报错/sensenova 正常) | +| 错误友好呈现 | TUI StartupErrorPrinter 链 | 已有 | 复用 | diff --git a/modules/abstractions/abstractions/abs_core/configuration/llm/ModelInfo.cs b/modules/abstractions/abstractions/abs_core/configuration/llm/ModelInfo.cs new file mode 100644 index 000000000..af4271115 --- /dev/null +++ b/modules/abstractions/abstractions/abs_core/configuration/llm/ModelInfo.cs @@ -0,0 +1,55 @@ +namespace JoinCode.Abstractions.Configuration.Llm; + +/// +/// 模型信息(TASK061 — 1M 上下文时代的模型元数据信息类)— 跨层消费的"当前模型"聚合视图: +/// 模型 ID + 供应商 + 显示名 + 上下文窗口。源头两投影(ModelEntry / ModelItemConfig), +/// 强校验(contextWindow<=0 → ConfigurationException 带修复指引 — +/// 主流模型窗口已达 1M,0 值/缺失配置会静默兜底 200K 造成误折叠或透传 0 使下游崩坏, +/// 对齐用户要求"模型配置里写模型名称+上下文大小,不对的直接报错")。 +/// +public sealed record ModelInfo +{ + /// 模型标识(如 glm-5.2 / sensenova-6.8-flash-lite)。 + public required string ModelId { get; init; } + + /// 供应商标识(如 zhipu / sensenova)。 + public required string Provider { get; init; } + + /// 显示名称(如 GLM 5.2)。 + public required string DisplayName { get; init; } + + /// 上下文窗口大小(token 数 — 校验必须 > 0)。 + public required int ContextWindow { get; init; } + + /// 从引擎模型条目投影(ModelEntry — IModelCatalog 目录源)。 + public static ModelInfo FromEntry(string provider, ModelEntry entry) => new() { + ModelId = entry.Id, + Provider = provider, + DisplayName = entry.DisplayName, + ContextWindow = entry.ContextWindow, + }; + + /// 从配置模型项投影(ModelItemConfig — providers_llm/{vendor}/models.json 源)。 + public static ModelInfo FromModelItemConfig(string provider, ModelItemConfig config) => new() { + ModelId = config.Id, + Provider = provider, + DisplayName = string.IsNullOrWhiteSpace(config.DisplayName) ? config.Id : config.DisplayName, + ContextWindow = config.ContextWindow, + }; + + /// 强校验 — contextWindow<=0 抛 ConfigurationException(错误诱导四要素: + /// 为什么失败/什么配置触发/在哪里修/怎么修);合法时返回自身(链式)。 + public ModelInfo ValidateOrThrow() { + if (ContextWindow <= 0) { + throw new Exceptions.ConfigurationException( + $"模型 '{Provider}/{ModelId}' 的 contextWindow 配置无效({ContextWindow},必须为正 token 数)。" + + $"主流模型上下文窗口已达 1M(1048576);请编辑 ~/.jcc/providers_llm/{Provider}/models.json" + + $" 为模型 '{ModelId}' 填写正确的 contextWindow 值," + + $"或设置环境变量 JCC_MAX_CONTEXT_TOKENS 临时覆盖。", + configurationKey: $"{Provider}/{ModelId}/contextWindow", + configurationValue: ContextWindow.ToString()); + } + + return this; + } +} diff --git a/modules/abstractions/abstractions/abs_memory/conversation/IChatContextManager.cs b/modules/abstractions/abstractions/abs_memory/conversation/IChatContextManager.cs index 1aeaa60db..a8fe31010 100644 --- a/modules/abstractions/abstractions/abs_memory/conversation/IChatContextManager.cs +++ b/modules/abstractions/abstractions/abs_memory/conversation/IChatContextManager.cs @@ -77,6 +77,9 @@ public interface IChatContextManager { /// 预检决策。 /// 工具规格列表。 PreflightDecision DecidePreflight(IReadOnlyList toolSpecs); + /// 预检决策(当前工具规格自取版 — TASK061 请求前预算守卫消费面: + /// 中间件每轮迭代取历史快照后调用,估算比率 ≥ 1.0 直接拦截不发 API)。 + PreflightDecision DecidePreflightWithCurrentSpecs(); /// 折叠决策。 /// 代理标识,null 表示主代理。 /// 取消令牌。 diff --git a/modules/cognition/brain.context.tests/core/o_to_t/QueryLoopMiddlewareTests.cs b/modules/cognition/brain.context.tests/core/o_to_t/QueryLoopMiddlewareTests.cs index ef4810775..364f7db08 100644 --- a/modules/cognition/brain.context.tests/core/o_to_t/QueryLoopMiddlewareTests.cs +++ b/modules/cognition/brain.context.tests/core/o_to_t/QueryLoopMiddlewareTests.cs @@ -268,6 +268,60 @@ private static QueryLoopMiddleware CreateMiddleware( ToolUseContext = new ToolUseContext() }; + /// TASK061 请求前上下文预算守卫(孤儿接线 — DecidePreflight 此前无消费方): + /// 估算比率 ≥ 1.0(消息+工具已超模型上下文窗口本体)→ 直接抛 ContextOverflowException + ///(不发 API — 用户要求"超 1M 窗口直接报错";错误带估算/窗口/诱导清上下文)。 + [Fact] + public async Task InvokeAsync_EstimatedRatioOverWindow_ThrowsContextOverflow() { + var contextManager = new Mock(); + contextManager.Setup(c => c.GetMessageListAsync(It.IsAny())) + .ReturnsAsync(new MessageList()); + contextManager.Setup(c => c.DecidePreflightWithCurrentSpecs()) + .Returns(new PreflightDecision { NeedsAction = true, EstimatedRatio = 1.5 }); + + await using var middleware = CreateMiddleware(contextManager, new Mock(), []); + + var act = async () => { + await foreach (var _ in middleware.InvokeAsync( + CreateContext(), (ctx, ct) => AsyncEnumerableEmpty(), CancellationToken.None)) { } + }; + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Contain("上下文"); + } + + /// TASK061 — 估算比率 < 1.0(窗口内):不拦截正常流(守卫静默放行)。 + [Fact] + public async Task InvokeAsync_EstimatedRatioWithinWindow_NoThrow() { + var contextManager = new Mock(); + contextManager.Setup(c => c.GetMessageListAsync(It.IsAny())) + .ReturnsAsync(new MessageList()); + contextManager.Setup(c => c.DecidePreflightWithCurrentSpecs()) + .Returns(new PreflightDecision { NeedsAction = false, EstimatedRatio = 0.3 }); + contextManager.Setup(c => c.GetContextMaxTokens()).Returns(200_000); + contextManager.Setup(c => c.AddAssistantMessageAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + contextManager.Setup(c => c.AddAssistantToolCallMessageAsync( + It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Task.CompletedTask); + contextManager.Setup(c => c.AddToolResultMessageAsync( + It.IsAny(), It.IsAny>(), It.IsAny?>(), It.IsAny())) + .Returns(Task.CompletedTask); + + var orchestrator = new Mock(); + orchestrator.Setup(t => t.ExecuteToolCallAsync(It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ToolCallResult { ResultText = "ok", IsError = false }); + + await using var middleware = CreateMiddleware(contextManager, orchestrator, []); + + var act = async () => { + await foreach (var _ in middleware.InvokeAsync( + CreateContext(), (ctx, ct) => AsyncEnumerableEmpty(), CancellationToken.None)) { } + }; + + await act.Should().NotThrowAsync(); + } + private static async IAsyncEnumerable AsyncEnumerableEmpty([EnumeratorCancellation] CancellationToken ct = default) { await Task.CompletedTask.ConfigureAwait(true); yield break; diff --git a/modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs b/modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs index ffb59ff23..3285ec70a 100644 --- a/modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs +++ b/modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs @@ -126,6 +126,11 @@ public async IAsyncEnumerable InvokeAsync( var historySnapshot = await _contextManager.GetMessageListAsync(ct).ConfigureAwait(false); Diag.WriteLine($"[LOOP] 迭代 #{totalToolCalls} | 消息={historySnapshot.Count}"); + + // TASK061 请求前上下文预算守卫(孤儿接线 — DecidePreflight 此前无消费方): + // 估算消息+工具 token 比率 ≥ 1.0(超模型上下文窗口本体)→ 直接拦截不发 API + //(用户要求"超窗直接报错";null 容错 — mock/旧实现未提供决策时静默放行) + GuardContextBudget(); #if DEBUG if (System.Diagnostics.Debugger.IsAttached) System.Diagnostics.Debugger.Break(); #endif @@ -211,6 +216,25 @@ await _contextManager.AddSystemMessageAsync( yield return ChatStreamEvent.Done(finalUsage, finalModelId); } + /// 请求前上下文预算守卫(TASK061)— 估算比率 ≥ 1.0(消息+工具已超模型上下文窗口本体, + /// 主流模型已达 1M)→ 抛 ContextOverflowException 直接拦截不发 API(对齐"超窗直接报错"; + /// 错误诱导:窗口数/估算数/清上下文或切大窗口模型);决策 null 容错(mock/旧实现静默放行)。 + private void GuardContextBudget() { + var preflight = _contextManager.DecidePreflightWithCurrentSpecs(); + if (preflight is not { EstimatedRatio: >= 1.0 }) { + return; + } + + var ctxMax = _contextManager.GetContextMaxTokens(); + var estimatedTokens = (int)(ctxMax * preflight.EstimatedRatio); + throw new ContextOverflowException( + $"上下文预算超限:当前消息+工具估算约 {estimatedTokens} token,已超过模型上下文窗口 {ctxMax} token" + + $"(比率 {preflight.EstimatedRatio:F2})。请用 /compact 压缩对话历史、运行 /clear 清空上下文," + + $"或切换到更大上下文窗口的模型(主流已达 1M token)。", + ctxMax, + estimatedTokens); + } + /// /// 流式工具执行模式 — 对齐 TS StreamingToolExecutor /// diff --git a/modules/cognition/brain/context/services/context/ChatContextManager.cs b/modules/cognition/brain/context/services/context/ChatContextManager.cs index f42831e62..2507b8f15 100644 --- a/modules/cognition/brain/context/services/context/ChatContextManager.cs +++ b/modules/cognition/brain/context/services/context/ChatContextManager.cs @@ -274,6 +274,12 @@ public PreflightDecision DecidePreflight(IReadOnlyList toolSpecs) { return _foldService.DecidePreflight(toolSpecs, messages); } + /// + /// 预检决策(当前工具规格自取版 — TASK061 请求前预算守卫消费面) + /// + public PreflightDecision DecidePreflightWithCurrentSpecs() + => DecidePreflight(_toolSpecStore.CurrentSpecs); + /// /// 根据折叠决策执行上下文折叠操作(普通/激进/摘要退出) /// diff --git a/modules/guard/guard.config.tests/configuration/core/providers/shared/ContextWindowResolverTests.cs b/modules/guard/guard.config.tests/configuration/core/providers/shared/ContextWindowResolverTests.cs index 7654d85b8..00593ecd9 100644 --- a/modules/guard/guard.config.tests/configuration/core/providers/shared/ContextWindowResolverTests.cs +++ b/modules/guard/guard.config.tests/configuration/core/providers/shared/ContextWindowResolverTests.cs @@ -57,8 +57,56 @@ public async Task Resolve_EnvUnset_Should_Fallback_To_Default() { [Fact] public async Task Resolve_EnvPositive_Should_Use_EnvValue() { - using var env = EnvVarScope.Set(JccEnvVarEnumConstants.MaxContextTokens, "100000"); - await using var resolver = CreateResolver(); - resolver.ResolveCurrentContextWindow().Should().Be(100_000); + using var env = EnvVarScope.Set(JccEnvVarEnumConstants.MaxContextTokens, "100000"); + await using var resolver = CreateResolver(); + resolver.ResolveCurrentContextWindow().Should().Be(100_000); + } + + /// TASK061 — 模型配置窗口为 0(agnes 脏数据形态):不再静默透传 0, + /// 抛 ConfigurationException 带模型/供应商/修复指引(对齐"不对的直接报错")。 + [Fact] + public async Task Resolve_ModelConfigWindowZero_ThrowsWithGuidance() { + using var env = EnvVarScope.Set(JccEnvVarEnumConstants.MaxContextTokens, null); + var loader = new ModelConfigLoader(); + loader.ApplyProviders(new Dictionary { + ["agnes"] = new() { + Models = [new ModelItemConfig { Id = "test-model", DisplayName = "T", ContextWindow = 0 }] + } + }); + await using var resolver = CreateResolver(loader: loader, vendor: "agnes"); + + var act = () => resolver.ResolveCurrentContextWindow(); + + var ex = act.Should().Throw().Which; + ex.Message.Should().Contain("agnes/test-model"); + ex.Message.Should().Contain("providers_llm"); + } + + /// TASK061 — 合法 1M 窗口模型:透传真实窗口(不被 200K 兜底截断 — 1M 时代误折叠防护)。 + [Fact] + public async Task Resolve_ModelConfigWindow1M_PassesThrough() { + using var env = EnvVarScope.Set(JccEnvVarEnumConstants.MaxContextTokens, null); + var loader = new ModelConfigLoader(); + loader.ApplyProviders(new Dictionary { + ["sensenova"] = new() { + Models = [new ModelItemConfig { Id = "test-model", DisplayName = "T", ContextWindow = 1_048_576 }] + } + }); + await using var resolver = CreateResolver(loader: loader, vendor: "sensenova"); + + resolver.ResolveCurrentContextWindow().Should().Be(1_048_576); + } + + private static ContextWindowResolver CreateResolver(IModelConfigLoader? loader = null, string? vendor = null) { + var fastModeMock = new Mock(); + fastModeMock.SetupGet(x => x.IsFastModeActive).Returns(false); + fastModeMock.SetupGet(x => x.PrimaryModelId).Returns("test-model"); + fastModeMock.SetupGet(x => x.FastModelId).Returns("test-fast-model"); + + var registryMock = new Mock(); + registryMock.Setup(x => x.TryGet(It.IsAny())).Returns((IProviderDefinition?)null); + + var config = vendor is null ? null : new WorkflowConfig { Provider = new ProviderConfig { Vendor = vendor } }; + return new ContextWindowResolver(fastModeMock.Object, registryMock.Object, config, loader); } } diff --git a/modules/guard/guard.config.tests/configuration/core/providers/shared/ModelInfoTests.cs b/modules/guard/guard.config.tests/configuration/core/providers/shared/ModelInfoTests.cs new file mode 100644 index 000000000..0221985d5 --- /dev/null +++ b/modules/guard/guard.config.tests/configuration/core/providers/shared/ModelInfoTests.cs @@ -0,0 +1,59 @@ +namespace Core.Configuration.Tests.Providers; + +/// +/// ModelInfo 信息类测试(TASK061 — 1M 上下文时代模型元数据)— +/// 两源投影(ModelEntry / ModelItemConfig)+ 强校验(contextWindow<=0 抛 ConfigurationException +/// 带模型名/供应商/修复指引,对齐"模型配置里写模型名称+上下文大小,不对的直接报错")。 +/// +public sealed class ModelInfoTests +{ + /// FromEntry 投影 — 四字段全透传。 + [Fact] + public void FromEntry_ProjectsAllFields() + { + var info = ModelInfo.FromEntry("zhipu", new ModelEntry("glm-5.2", "GLM 5.2", 1_048_576, "旗舰")); + + info.ModelId.Should().Be("glm-5.2"); + info.Provider.Should().Be("zhipu"); + info.DisplayName.Should().Be("GLM 5.2"); + info.ContextWindow.Should().Be(1_048_576); + } + + /// FromModelItemConfig 投影 — DisplayName 空时退化 ModelId(配置容错)。 + [Fact] + public void FromModelItemConfig_EmptyDisplayName_FallsBackToId() + { + var config = new ModelItemConfig { Id = "agnes-2.5-pro-alpha", DisplayName = "", ContextWindow = 128_000 }; + + var info = ModelInfo.FromModelItemConfig("agnes", config); + + info.DisplayName.Should().Be("agnes-2.5-pro-alpha"); + info.ContextWindow.Should().Be(128_000); + } + + /// 零窗口(agnes 脏数据实锤形态)— 抛 ConfigurationException 带模型/供应商/修复指引。 + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ValidateOrThrow_NonPositiveWindow_ThrowsWithGuidance(int badWindow) + { + var info = ModelInfo.FromModelItemConfig("agnes", new ModelItemConfig { Id = "agnes-2.5-pro-alpha", ContextWindow = badWindow }); + + var act = () => info.ValidateOrThrow(); + + var ex = act.Should().Throw().Which; + ex.Message.Should().Contain("agnes/agnes-2.5-pro-alpha"); + ex.Message.Should().Contain("providers_llm"); + ex.Message.Should().Contain("JCC_MAX_CONTEXT_TOKENS"); + ex.ConfigurationKey.Should().Be("agnes/agnes-2.5-pro-alpha/contextWindow"); + } + + /// 合法窗口 — 链式返回自身(1M 不被兜底截断)。 + [Fact] + public void ValidateOrThrow_ValidWindow_ReturnsSelf() + { + var info = ModelInfo.FromModelItemConfig("sensenova", new ModelItemConfig { Id = "glm-5.2", ContextWindow = 1_048_576 }); + + info.ValidateOrThrow().Should().BeSameAs(info); + } +} diff --git a/modules/guard/guard/configuration/configuration2/core/providers/shared/ContextWindowResolver.cs b/modules/guard/guard/configuration/configuration2/core/providers/shared/ContextWindowResolver.cs index e170bd4c3..eedf1a9eb 100644 --- a/modules/guard/guard/configuration/configuration2/core/providers/shared/ContextWindowResolver.cs +++ b/modules/guard/guard/configuration/configuration2/core/providers/shared/ContextWindowResolver.cs @@ -47,7 +47,9 @@ public int ResolveCurrentContextWindow() { if (_modelConfigLoader is not null) { var modelConfig = _modelConfigLoader.FindModel(provider, currentModel); if (modelConfig is not null) - return modelConfig.ContextWindow; + // TASK061 强校验 — contextWindow<=0 直接报错(不再静默透传 0 致下游 ArgumentOutOfRange; + // agnes 9 模型 0 值脏数据形态,错误带模型/供应商/providers_llm 修复指引) + return ModelInfo.FromModelItemConfig(provider, modelConfig).ValidateOrThrow().ContextWindow; } var definition = _registry.TryGet(provider); @@ -55,10 +57,11 @@ public int ResolveCurrentContextWindow() { var match = definition.AvailableModels .FirstOrDefault(m => m.Id.Equals(currentModel, StringComparison.OrdinalIgnoreCase)); if (match is not null) - return match.ContextWindow; + // TASK061 — Provider 定义路径同强校验(两源一致,0 值配置统一报错) + return ModelInfo.FromEntry(provider, match).ValidateOrThrow().ContextWindow; } - // 3. 默认值 + // 3. 默认值(模型未注册两源皆未命中 — 未知模型不阻断启动,保守兜底) return DefaultContextWindow; } From 49be1532f571464e7dfb27db527ed436bd3d4b1f Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:01:46 +0800 Subject: [PATCH 14/30] =?UTF-8?q?docs:=20TASK061=20=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=EF=BC=88=E4=B8=89=E5=B1=82=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=201284+933+=E7=9C=9F=E6=9C=BA=E9=AA=8C=E8=AF=81+=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E6=8E=A5=E7=BA=BF=E5=8F=91=E7=8E=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...27\345\217\243\346\240\241\351\252\214.md" | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git "a/docs/proposed/task/TASK061-ModelInfo\344\270\216\344\270\212\344\270\213\346\226\207\347\252\227\345\217\243\346\240\241\351\252\214.md" "b/docs/proposed/task/TASK061-ModelInfo\344\270\216\344\270\212\344\270\213\346\226\207\347\252\227\345\217\243\346\240\241\351\252\214.md" index a0339b519..43be541c7 100644 --- "a/docs/proposed/task/TASK061-ModelInfo\344\270\216\344\270\212\344\270\213\346\226\207\347\252\227\345\217\243\346\240\241\351\252\214.md" +++ "b/docs/proposed/task/TASK061-ModelInfo\344\270\216\344\270\212\344\270\213\346\226\207\347\252\227\345\217\243\346\240\241\351\252\214.md" @@ -1,6 +1,6 @@ # TASK061 — ModelInfo 信息类 + 上下文窗口配置校验(1M 时代) -> 状态:in_progress(2026-10-21 开工) +> 状态:implemented(2026-10-21 三层落地 + 全绿 + 真机验证;剩余用户验收 agnes 场景报错呈现) > 前置:巨型请求修复(4c27592ab)完结;用户指令:"主流模型上下文已达 1M,模型配置里写模型名称+上下文大小,不对的直接报错;建一个 modelInfo 信息类" ## 调研结论 @@ -31,6 +31,25 @@ agnes models.json 9 模型 contextWindow=0 — 校验报错即用户要求的" | 基建实现 | 消费点 | 已实现 | 已验收 | |---------|--------|--------|--------| -| ModelInfo + ValidateOrThrow(abstractions) | resolver/切换链复用 | 待做 | 待真机 | -| resolver 0 值严格化(guard) | 启动/请求前窗口解析 | 待做 | 待真机(agnes 报错/sensenova 正常) | -| 错误友好呈现 | TUI StartupErrorPrinter 链 | 已有 | 复用 | +| ModelInfo + ValidateOrThrow(abstractions) | resolver/两源投影 | ✅ 4 测试 | ✅ 单元 13/13 | +| resolver 0 值严格化(guard) | 启动/请求前窗口解析 | ✅ 2 测试 | ✅ 1284/1284 + 真机 sensenova 正常 | +| 请求前预算守卫(QueryLoopMiddleware.GuardContextBudget 孤儿接线) | 每轮迭代 historySnapshot 后 | ✅ 2 测试 | ✅ 933/933 | +| IChatContextManager.DecidePreflightWithCurrentSpecs | 中间件守卫消费面 | ✅ | ✅ | + +## 最终基线 + +- guard.config.tests:1281 → **1284**(+ModelInfo 4 + resolver 2 - 既有调整) +- brain.context.tests:931 → **933**(+守卫 2) +- 提交:5a5d7fa89 +- 真机:sensenova 窗口临时置 0 → 报错链验证通过后恢复;正常路径回复 `ok` 恢复 + +## 额外发现(调研结论) + +- **DecidePreflight 是孤儿**(接口+实现齐全但无任何消费方)——请求前预算守卫此前根本没挂主链,本任务接线修复 +- agnes 9 模型 contextWindow 全 0(真实脏数据)——严格化后被使用到时立即报错(带修复指引),非当前 profile 不阻断启动 + + + + + + From 586b9c0f1c0347319bd5c5da4942bc937e38b2ec Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:04:43 +0800 Subject: [PATCH 15/30] =?UTF-8?q?fix:=20=E9=82=AE=E7=AE=B1=20ReceiveAsync?= =?UTF-8?q?=20=E6=BC=8F=E4=BC=A0=20ct=20=E4=B8=89=E5=A4=84=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=EF=BC=88Host.DisposeAsync=20=E6=B0=B8=E6=8C=82?= =?UTF-8?q?=E6=AD=BB=E9=94=81=EF=BC=89+=20DSG041=20=E6=89=B92=20E2E=20?= =?UTF-8?q?=E8=BD=AC=E7=BB=BF=20|=20=E5=86=B3=E7=AD=96:=20dumpasync=20?= =?UTF-8?q?=E5=8F=96=E8=AF=81=E9=94=81=E5=AE=9A=E5=AE=8C=E6=95=B4=E6=AD=BB?= =?UTF-8?q?=E9=94=81=E9=93=BE=E2=80=94=E2=80=94E2E=E2=86=92AsyncHostWrappe?= =?UTF-8?q?r=E2=86=92Host=E2=86=92=E5=AE=B9=E5=99=A8=E2=86=92AgentServiceI?= =?UTF-8?q?mpl.DisposeAsync=E2=86=92BackgroundTaskActor.WhenAll=E2=86=92Ro?= =?UTF-8?q?uteWorkerPermissionResponsesAsync=20=E6=8C=82=E5=9C=A8=20Channe?= =?UTF-8?q?l=20=E8=AF=BB=EF=BC=88ct=20=E5=8F=AA=E5=9C=A8=E6=94=B6=E5=88=B0?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=90=8E=E6=A3=80=E6=9F=A5=E3=80=81ReceiveAs?= =?UTF-8?q?ync=20=E8=B0=83=E7=94=A8=E6=BC=8F=E4=BC=A0=20ct=E2=80=94?= =?UTF-8?q?=E2=80=94MailboxBase=20=E5=BA=95=E5=B1=82=20ReadAllAsync(ct)=20?= =?UTF-8?q?=E6=9C=AC=E5=B0=B1=E6=94=AF=E6=8C=81=EF=BC=8C=E7=BA=AF=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E9=81=97=E6=BC=8F=EF=BC=89=EF=BC=9B=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E5=90=8C=E7=B1=BB=EF=BC=9AAgentServiceImpl:6?= =?UTF-8?q?79+SwarmPermissionMessageRouter=20worker=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=EF=BC=88=E8=A1=A5=20=5Fcts=20=E4=BC=A0=E9=80=92=EF=BC=89+PlanA?= =?UTF-8?q?pprovalMessageRouter=20teammate=20=E8=B7=AF=E7=94=B1=EF=BC=88?= =?UTF-8?q?=E8=A1=A5=E7=8B=AC=E7=AB=8B=20Cts=20=E5=88=97=E8=A1=A8=E5=85=A5?= =?UTF-8?q?=20StopRoutingAsync=20=E7=BB=9F=E4=B8=80=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=89=EF=BC=9BE2E=20=E4=B8=BB=E4=BB=A3=E7=90=86=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=84=9A=E6=9C=AC=E8=A1=A5=E5=AD=90=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E8=BD=AE=EF=BC=88turn2=20r?= =?UTF-8?q?ead=20=E5=B7=A5=E5=85=B7=EF=BC=89=E5=90=8E=E5=85=A8=E9=93=BE?= =?UTF-8?q?=E8=B4=AF=E9=80=9A=E8=BD=AC=E7=BB=BF=E2=80=94=E2=80=94MockServe?= =?UTF-8?q?r=20=E7=9C=9F=E5=AE=9E=20LLM=E2=86=92agent=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E2=86=92=E5=AD=90=E4=BB=A3=E7=90=86=20spawn=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=BD=AE=EF=BC=89=E2=86=92Emit=E2=86=92?= =?UTF-8?q?=E9=80=9A=E9=81=93=E2=86=92Drain=20=E7=9B=B4=E6=8A=95=E2=86=92?= =?UTF-8?q?=E4=B8=89=E5=9B=9E=E8=B0=83=E5=85=A8=E8=BE=BE=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E8=80=85=EF=BC=8C16=20=E7=A7=92=E5=B9=B2=E5=87=80=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=EF=BC=9B=E9=AA=8C=E8=AF=81=EF=BC=9A=E4=B8=BB=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=B7=AF=E5=BE=84=20E2E=201/1=20=E7=BB=BF+agents.test?= =?UTF-8?q?s=20628+host.tests=201381=20=E5=85=A8=E7=BB=BF=EF=BC=9B?= =?UTF-8?q?=E9=81=97=E7=95=99=E7=99=BB=E8=AE=B0=EF=BC=9AChatService=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=20E2E=20=E4=BB=8D=E7=BA=A2=EF=BC=88=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=20LLM=20=E6=B5=81=E6=97=A0=20AgentId=20?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=E4=B8=BB=E6=B5=81=E2=80=94=E2=80=94=E7=96=91?= =?UTF-8?q?=20TASK043=20StreamingFallbackDecorator=20=E5=9F=9F=EF=BC=8C?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E7=BC=BA=E9=99=B7=E9=9D=9E=20DSG041=20?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=E9=97=AE=E9=A2=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Swarm/PlanApprovalMessageRouter.cs | 24 +++++++++++++++---- .../Swarm/SwarmPermissionMessageRouter.cs | 18 +++++++++++++- .../agents/Services/Core/AgentServiceImpl.cs | 6 +++-- .../SubAgentEventMainAgentPathE2ETests.cs | 23 ++++++++++++++---- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/modules/llm/agents/Coordinator/Swarm/PlanApprovalMessageRouter.cs b/modules/llm/agents/Coordinator/Swarm/PlanApprovalMessageRouter.cs index 28acfabaf..02a6cd0be 100644 --- a/modules/llm/agents/Coordinator/Swarm/PlanApprovalMessageRouter.cs +++ b/modules/llm/agents/Coordinator/Swarm/PlanApprovalMessageRouter.cs @@ -15,6 +15,7 @@ public sealed partial class PlanApprovalMessageRouter : ServiceEntity { private readonly IClockService _clock; private CancellationTokenSource? _leaderCts; private Task? _leaderRoutingTask; + private readonly List<(CancellationTokenSource Cts, Task Routing)> _teammateRoutings = []; /// 初始化计划审批消息路由器 /// 消息邮箱,用于订阅/发布审批消息 @@ -51,13 +52,28 @@ public void StartLeaderRouting(string coordinatorAgentId) { /// 启动 Teammate 侧路由:监听 plan_approval_response 并调用 HandlePlanApprovalResponseAsync /// public void StartTeammateRouting(string teammateAgentId) { - _ = RouteTeammateResponsesAsync(teammateAgentId); + // ct 传入 ReceiveAsync — MailboxBase 底层 ReadAllAsync(ct) 随取消退出(漏传时无消息场景 + // Channel 读永不退出,StopRoutingAsync/Dispose 无法收尾 teammate 路由 — 同 AgentServiceImpl:679 缺陷) + var cts = new CancellationTokenSource(); + _teammateRoutings.Add((cts, RouteTeammateResponsesAsync(teammateAgentId, cts.Token))); } /// - /// 停止 Leader 侧路由 + /// 停止 Leader 侧路由 + Teammate 侧路由(全部收尾) /// public async Task StopRoutingAsync() { + foreach (var (cts, routing) in _teammateRoutings) { + cts.Cancel(); + try { + await routing.ConfigureAwait(false); + } catch (OperationCanceledException) { + } + + cts.Dispose(); + } + + _teammateRoutings.Clear(); + if (_leaderCts == null) return; _leaderCts.Cancel(); @@ -103,9 +119,9 @@ private async Task RouteLeaderMessagesAsync(string coordinatorAgentId, Cancellat /// /// Teammate 侧响应路由:监听 plan_approval_response 并调用 HandlePlanApprovalResponseAsync /// - private async Task RouteTeammateResponsesAsync(string teammateAgentId) { + private async Task RouteTeammateResponsesAsync(string teammateAgentId, CancellationToken ct) { try { - await foreach (var message in _messageBroker.ReceiveAsync(teammateAgentId).ConfigureAwait(false)) { + await foreach (var message in _messageBroker.ReceiveAsync(teammateAgentId, ct).ConfigureAwait(false)) { if (message.MessageType == TeammateMessageType.PlanApprovalResponse.ToValue()) { await ProcessPlanApprovalResponseAsync(message).ConfigureAwait(false); } diff --git a/modules/llm/agents/Coordinator/Swarm/SwarmPermissionMessageRouter.cs b/modules/llm/agents/Coordinator/Swarm/SwarmPermissionMessageRouter.cs index 7fcb9c606..98a4f0efc 100644 --- a/modules/llm/agents/Coordinator/Swarm/SwarmPermissionMessageRouter.cs +++ b/modules/llm/agents/Coordinator/Swarm/SwarmPermissionMessageRouter.cs @@ -75,7 +75,23 @@ public override async ValueTask DisposeAsync() { /// /// Worker 智能体标识 public void StartWorkerResponseRouting(string workerAgentId) { - _ = RouteWorkerResponsesAsync(workerAgentId); + // ct 传入 ReceiveAsync — MailboxBase 底层 ReadAllAsync(ct) 随取消退出(漏传时无消息场景 + // Channel 读永不退出,StopRoutingAsync/Dispose 无法收尾 worker 路由 — 同 AgentServiceImpl:679 缺陷) + _cts ??= new CancellationTokenSource(); + _ = RouteWorkerResponsesAsync(workerAgentId, _cts.Token); + } + + private async Task RouteWorkerResponsesAsync(string workerAgentId, CancellationToken ct) { + try { + await foreach (var message in _messageBroker.ReceiveAsync(workerAgentId, ct).ConfigureAwait(false)) { + if (message.MessageType == SwarmPermissionMessageType.PermissionResponse.ToValue()) { + await _callbackService.ProcessIncomingResponseMessageAsync(message).ConfigureAwait(false); + } + } + } catch (OperationCanceledException) { + } catch (Exception ex) { + _logger?.LogError(ex, "Worker 权限响应路由异常退出: WorkerId={WorkerId}", workerAgentId); + } } private async Task RouteMessagesAsync(string coordinatorAgentId, CancellationToken ct) { diff --git a/modules/llm/agents/Services/Core/AgentServiceImpl.cs b/modules/llm/agents/Services/Core/AgentServiceImpl.cs index e2d27ad8d..7e0c5f2e3 100644 --- a/modules/llm/agents/Services/Core/AgentServiceImpl.cs +++ b/modules/llm/agents/Services/Core/AgentServiceImpl.cs @@ -676,8 +676,10 @@ private void StartWorkerPermissionResponseRouting(string agentId) { [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] private async Task RouteWorkerPermissionResponsesAsync(string agentId, CancellationToken ct) { - await foreach (var message in _messageBroker!.ReceiveAsync(agentId).ConfigureAwait(false)) { - ct.ThrowIfCancellationRequested(); + // ct 传入 ReceiveAsync — MailboxBase 底层 ReadAllAsync(ct) 随取消退出; + // 漏传时无消息场景 Channel 读永不退出(E2E 实测 Host.DisposeAsync WhenAll 永挂 — + // dumpasync 链:AgentServiceImpl.DisposeAsync → BackgroundTaskActor → 本循环) + await foreach (var message in _messageBroker!.ReceiveAsync(agentId, ct).ConfigureAwait(false)) { if (message.MessageType == SwarmPermissionMessageType.PermissionResponse.ToValue()) { await _permissionCallbackService!.ProcessIncomingResponseMessageAsync(message).ConfigureAwait(false); } 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 bdf3be8cc..8436682e9 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 @@ -18,9 +18,7 @@ public sealed class SubAgentEventMainAgentPathE2ETests { public SubAgentEventMainAgentPathE2ETests(ITestOutputHelper output) => _output = output; [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] - [Fact(Skip = "引擎侧子代理执行链缺陷(非 DSG041 接线问题——批1-4 单测全绿):agent 工具真实执行后 testhost 崩溃;" - + "对照 ChatService 路径 SubAgentEventStreamE2ETests 同域红(子代理 LLM 输出无 AgentId 串入主流、无 AgentStarted)," - + "疑流式透传缺陷域(TASK043 StreamingFallbackDecorator 关联)。引擎缺陷修复后移除 Skip 复验。")] + [Fact] public async Task MainAgentPath_AgentSpawn_ShouldDispatchSubagentCallbacks() { const string args = "{\"name\":\"e2e-main\",\"subagent_type\":\"executor:search\",\"description\":\"检查README文件\",\"prompt\":\"读取当前目录README并总结\"}"; @@ -99,7 +97,9 @@ private async Task RunMainAgentScenarioAsync(string t return consumer; } finally { - try { await session.Host.DisposeAsync(); } catch (Exception disposeEx) { _output.WriteLine($"[E2E清理] Host.DisposeAsync 异常(已忽略): {disposeEx.Message}"); } + try { + await session.Host.DisposeAsync(); + } catch (Exception disposeEx) { _output.WriteLine($"[E2E清理] Host.DisposeAsync 异常(已忽略): {disposeEx.Message}"); } } } finally { if (!mockServer.HasExited) { @@ -126,6 +126,7 @@ private static string BuildMockConfig(string toolCallArguments) { sb.AppendLine(" \"default_response\": \"(script exhausted)\","); sb.AppendLine(" \"scripted_turns\": ["); + // Turn 1: 主对话 → agent 工具调用 sb.AppendLine(" {"); sb.AppendLine(" \"thinking_content\": null,"); sb.AppendLine(" \"tool_calls\": ["); @@ -139,6 +140,20 @@ private static string BuildMockConfig(string toolCallArguments) { sb.AppendLine(" \"http_status_code\": null"); sb.AppendLine(" },"); + // Turn 2: 子代理 → 工具调用(产生子代理工具活动事件 ToolCallStart/End 带 AgentId) + sb.AppendLine(" {"); + sb.AppendLine(" \"thinking_content\": null,"); + sb.AppendLine(" \"tool_calls\": ["); + sb.AppendLine(" {"); + sb.AppendLine(" \"tool_name\": \"read\","); + sb.AppendLine(" \"arguments\": \"{\\\"file_path\\\":\\\"README.md\\\"}\","); + sb.AppendLine(" }".TrimEnd(',')); + sb.AppendLine(" ],"); + sb.AppendLine(" \"text_response\": null,"); + sb.AppendLine(" \"follow_up_text\": null,"); + sb.AppendLine(" \"http_status_code\": null"); + sb.AppendLine(" },"); + AppendTextTurn(sb, "README 已确认存在:JoinCode 是一个 AI 工作流引擎。"); sb.AppendLine(","); AppendTextTurn(sb, "子代理已完成 README 检查。"); From 2b73853bc1f02b929244b11c85111d44df219817 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:08:07 +0800 Subject: [PATCH 16/30] =?UTF-8?q?docs:=20DSG041=20=E6=89=B92=20E2E=20?= =?UTF-8?q?=E9=9B=86=E6=88=90=E9=AA=8C=E6=94=B6=E6=94=B6=E5=8F=A3=EF=BC=88?= =?UTF-8?q?=E6=AD=BB=E9=94=81=E6=A0=B9=E6=B2=BB+=E5=85=A8=E9=93=BE?= =?UTF-8?q?=E8=B4=AF=E9=80=9A=EF=BC=89+=20ChatService=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=E9=81=97=E7=95=99=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/proposed/design/DSG041-subagent-event-source-plan.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/proposed/design/DSG041-subagent-event-source-plan.md b/docs/proposed/design/DSG041-subagent-event-source-plan.md index 48310bf9d..310593149 100644 --- a/docs/proposed/design/DSG041-subagent-event-source-plan.md +++ b/docs/proposed/design/DSG041-subagent-event-source-plan.md @@ -70,7 +70,7 @@ native_tui(TUI 宿主)子代理分组渲染全链已落地(SubagentGroupCo | 基建实现 | 消费点 | 已实现 | 已验收 | |---------|--------|--------|--------| | L3 IEventConsumer 三方法(DIM 默认空)+ SessionController RouteSubagentEvent 前置分流 | BridgeEventConsumer / InProcessEventConsumer(批4/批5 接映射) | ✅ 批1(hash 已随 PR #439 squash,内容在库) | ✅ SessionControllerSubagentEventTests 5/5(Started/Finished/工具分流/普通零回归/混合流)+ host.tests 1371 + abs.tests 694 + engine/cli/Host 三链编译绿 | -| L1 SubAgentEventChannel 接通主代理路径(**宿主层直投方案**) | AgentStreamExecutionMiddleware/ForkSubAgentManagerActor 发射侧(零改动) | ✅ 批2(6b762c145) | ✅ 单元级:MainAgentPath 2/2(通道事件全序列直投三回调 + 子代理工具事件不走普通回调)+ host.tests 1373 全绿 + Host 编译绿;⏳ 集成级:E2E 复制先例(批2 收尾项) | +| L1 SubAgentEventChannel 接通主代理路径(**宿主层直投方案**) | AgentStreamExecutionMiddleware/ForkSubAgentManagerActor 发射侧(零改动) | ✅ 批2(6b762c145) | ✅ 单元级:MainAgentPath 2/2(通道事件全序列直投三回调 + 子代理工具事件不走普通回调)+ host.tests 1373 全绿 + Host 编译绿;✅ 集成级:SubAgentEventMainAgentPathE2ETests 1/1 绿(00f2a77ae — MockServer 真实 LLM 全链:agent 工具→子代理 spawn(含工具轮)→Emit→通道→Drain 直投→三回调全达消费者,16s 干净收尾;过程中根治 Host.DisposeAsync 永挂死锁三处 ReceiveAsync 漏传 ct) | | ~~L2 AgentStreamChunkAdapter AgentId 传播~~ | ~~SessionController chunk→ChatStreamEvent 翻译~~ | **✅ 被批2 宿主层直投方案吸收**(通道事件不经 chunk 转换层,AgentBase/adapter 零改动;批3 撤销) | —(无独立交付物) | | L4 引擎侧 BridgeEvent 三 record + BridgeEventConsumer 显式映射 | bridge_serve JSON 协议消费方 | ✅ 批3(6d55a8215) | ✅ BridgeJsonContractTests JSON 形状快照锁定(subagent_started/subagent_tool_use/subagent_finished camelCase)+ BridgeEventConsumerTests 映射断言 + host.tests 1375 + engine/cli/Host 三链编译绿 | | InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ✅ 批4(3f7f88919) | ✅ InProcessEventConsumerTests 映射断言(三 record 字段逐一对齐)+ NativeTui.Host.Tests 61 全绿 + Host 编译绿;⏳ Host --selftest 扩断言(真实运行收口) | @@ -83,7 +83,8 @@ native_tui(TUI 宿主)子代理分组渲染全链已落地(SubagentGroupCo - ~~批3(L2 chunk 传播)~~ **✅ 撤销 — 被批2 宿主层直投方案吸收** - ~~批4(原批3 引擎侧 DTO + BridgeEventConsumer 映射)~~ **✅ 已完成(6d55a8215)** - ~~批5(原批4 InProcessEventConsumer 映射)~~ **✅ 已完成(3f7f88919)— 代码批次全部落地,链路闭合** -- **剩余集成级验收**(收尾项):① 批2 E2E(复制 SubAgentEventStreamE2ETests 先例改造为 QueryEngine 主代理路径)② Host --selftest 扩子代理断言 ③ 用户真实终端收口 +- **剩余集成级验收**(收尾项):~~① 批2 E2E~~ **✅ 已完成(00f2a77ae)**——顺带根治 Host.DisposeAsync 永挂死锁(三处邮箱 ReceiveAsync 漏传 ct:AgentServiceImpl/SwarmPermissionMessageRouter/PlanApprovalMessageRouter;dumpasync 取证完整链)② Host --selftest 扩子代理断言 ③ 用户真实终端收口 +- ⚠️ 遗留登记:ChatService 路径 E2E(SubAgentEventStreamE2ETests)仍红——子代理 LLM 流以无 AgentId 的 Content 透传主流(疑 TASK043 StreamingFallbackDecorator 域),独立缺陷非 DSG041 接线问题 - 本 DSG 落地后 TASK055 三缺口③关闭 ### 批1 实施记录(hash 已随 PR #439 squash,内容在库) From 9a588ac33e030726b8ef815f57a21682eb425880 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:12:14 +0800 Subject: [PATCH 17/30] =?UTF-8?q?docs:=20TASK062=20=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E4=BE=A7=E9=97=AE=E9=A2=98=E7=99=BB=E8=AE=B0=EF=BC=888=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=AE=88=E5=8D=AB=E6=80=BB=E8=A1=A8=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=94=A8=E6=88=B7=E6=96=B9=E6=B3=95=E8=AE=BA'?= =?UTF-8?q?=E5=85=88=E8=AE=B0=E5=BD=95=E5=86=8D=E4=BF=AE=E5=A4=8D+?= =?UTF-8?q?=E5=AE=88=E5=8D=AB+=E5=8D=95=E4=B8=80=E8=81=8C=E8=B4=A3'?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...10\345\215\253\345\274\272\345\214\226.md" | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 "docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" diff --git "a/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" "b/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" new file mode 100644 index 000000000..636d0e888 --- /dev/null +++ "b/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" @@ -0,0 +1,54 @@ +# TASK062 — 引擎侧问题登记与守卫强化(子代理执行链缺陷后续) + +> 状态:进行中(2026-10-21) +> 来源:DSG041 集成验收过程中暴露的引擎侧缺陷与缺陷模式,按"先记录所有问题,再修复;修复加入守卫和单一职责使问题更容易暴露"方法论推进 +> 关联:DSG041(docs/proposed/design/DSG041-subagent-event-source-plan.md)、TASK043(流式增量透传缺陷) + +## 1. 问题登记总表 + +| # | 问题 | 性质 | 状态 | 守卫方案 | +|---|------|------|------|---------| +| P1 | **ChatService 路径子代理 LLM 流无 AgentId 透传主流**——SubAgentEventStreamE2ETests 红:子代理输出以无标记 Content 串入主流、无 AgentStarted(疑 StreamingFallbackDecorator 全量收集透传——TASK043 域) | 独立缺陷 | ⏳ 待修 | E2E 断言(已有红测试守护)+ 透传点单一职责化 | +| P2 | **邮箱 ReceiveAsync 漏传 ct 模式**(已修三处:AgentServiceImpl/SwarmPermissionMessageRouter/PlanApprovalMessageRouter)——"ct 挂在参数上但没传给底层"是静默缺陷,Dispose 挂死才暴露 | 缺陷模式(防再犯) | ✅ 已修 | **路由器取消收尾测试**(TDD 防回归)+ 单一职责提取 | +| P3 | **路由循环内嵌大类**——RouteWorkerPermissionResponsesAsync 等三个消息路由循环内嵌在 AgentServiceImpl(900+ 行)等大类里,取消语义/生命周期/分发逻辑耦合,缺陷难以局部暴露 | 结构性风险 | ⏳ 待守卫 | 提取独立路由类(单一职责)+ 取消收尾单元测试 | +| P4 | **E2E/测试工具名 magic string**——SubAgentEventStreamE2ETests/MainAgentPath E2E 脚本手写 "Agent"/"agent_spawn" 均错(真值 `agent`,AgentToolName [EnumValue])——两轮排障才定位 | 可维护性 | ⏳ 待守卫 | 脚本改引用 AgentToolNameEnumConstants 常量(单一数据源) | +| P5 | **TeammatePaneMiddleware 对非 teammate 子代理也尝试创建 tmux pane**——executor 前台子代理 spawn 也走 pane 创建(Windows 无 tmux 每次警告噪音);判定条件只查 IsMainAgent 不查角色 | 设计缺陷(非致命) | ⏳ 待修 | 判定加角色守卫(teammate 才创建) | +| P6 | **两处子代理 E2E 不在 CI 白名单**——ci-e2e matrix 白名单式 filter 从未含 SubAgentEventStreamE2ETests——缺陷长期潜伏(上次手动绿在 #435/#437 大改前) | CI 盲区 | ⏳ 待守卫 | ci-e2e matrix 增补两个 E2E 条目 | +| P7 | **tokenGain 恒 0**(DSG041 遗留)——子代理事件无 token 增量数据源(ChatStreamEvent 无该字段;ProgressTracker 差值未接) | 功能缺口(低优) | ⏳ 悬置 | 后续接 ProgressTracker.RecordTokenUsage 差值 | +| P8 | **McpPluginBootstrapHandle.Completion 的 Run 前占位语义**——Run 未调用时 Completion=Task.CompletedTask,消费方误判"就绪"(DI 有 Handle 但 McpInitModule 未跑 ConfigureAsync 的极端组装) | 边界语义 | ⏳ 待守卫 | 文档化契约 + 可选 _started 门闩(等待方阻塞至 Run 或超时) | + +## 2. 修复记录 + +### P2+P3 守卫(第一批):路由器取消收尾测试 + 单一职责 + +- **TDD 守卫**:三路由器的"取消后可在时限内收尾"单元测试(无消息场景 ct 取消 → 循环退出 → Dispose 完成——挂死形态回归即红) +- **单一职责**:消息路由循环提取 `AgentMessageRouteLoop` 独立小类(构造注入 broker+agentId+处理器;Start/Stop 生命周期显式;取消令牌单一持有)——AgentServiceImpl/Swarm 两路由器消费该类,ct 传递成为构造契约(编译期可见,漏传即编译错) +- 验收:守卫测试绿 + agents.tests 全量回归 + +### P4 守卫:E2E 工具名常量化 + +- 两个 E2E 脚本的 `"tool_name": "agent"` 手写 JSON 段改经 `AgentToolNameEnumConstants.Agent` 拼接(单一数据源——枚举改名/值变即编译错而非 E2E 静默 not found) + +### P6 守卫:CI matrix 增补 + +- ci-e2e.yml matrix 增补 `SubAgentEventStreamE2ETests|SubAgentEventMainAgentPathE2ETests` 条目(当前红——P1 修复后进白名单;先登记 Skip/顺序说明防 CI 红) + +## 3. 四列验收表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| P2 守卫测试(路由器取消收尾) | AgentServiceImpl/Swarm/PlanApproval 三路由器 | ⏳ | ⏳ | +| P3 单一职责提取 AgentMessageRouteLoop | 三路由器重构消费 | ⏳ | ⏳ | +| P4 E2E 工具名常量化 | 两 E2E 脚本 | ⏳ | ⏳ | +| P5 TeammatePane 角色守卫 | TeammatePaneMiddleware | ⏳ | ⏳ | +| P6 CI matrix 增补 | ci-e2e.yml | ⏳ | ⏳ | + +## 4. 遗留 + +- P1(ChatService 透传缺陷)单独深挖(TASK043 域——StreamingFallbackDecorator 调查) +- P7/P8 低优先级悬置 + + + + + From cb506587606b37fd6bf8ab2bc26c769a6b489618 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:33:38 +0800 Subject: [PATCH 18/30] =?UTF-8?q?feat:=20TASK062=20P2+P3=20=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E8=90=BD=E5=9C=B0=E2=80=94=E2=80=94AgentMessageRouteL?= =?UTF-8?q?oop=20=E5=8D=95=E4=B8=80=E8=81=8C=E8=B4=A3=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=EF=BC=88=E9=98=B2=20ReceiveAsync=20=E6=BC=8F?= =?UTF-8?q?=E4=BC=A0=20ct=20=E5=86=8D=E7=8A=AF=EF=BC=89=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20=E9=82=AE=E7=AE=B1=E6=B6=88=E6=81=AF=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=BE=AA=E7=8E=AF=E6=8F=90=E5=8F=96=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=B0=8F=E7=B1=BB=EF=BC=88=E6=9E=84=E9=80=A0=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=20broker+agentId+handler=EF=BC=8C'ct=20=E4=BC=A0=E5=85=A5=20Re?= =?UTF-8?q?ceiveAsync'=E6=94=B6=E6=95=9B=E4=B8=BA=E7=B1=BB=E5=86=85?= =?UTF-8?q?=E4=B8=80=E5=A4=84=E6=9E=84=E9=80=A0=E5=A5=91=E7=BA=A6=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=BC=8F=E4=BC=A0=E5=9C=A8=E7=BB=93=E6=9E=84=E4=B8=8A?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E8=83=BD=EF=BC=89=EF=BC=8CSwarm/PlanApproval?= =?UTF-8?q?=20=E4=B8=A4=E8=B7=AF=E7=94=B1=E5=99=A8=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=EF=BC=88worker/teammate=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=94=B9=E7=BB=8F=20RouteLoop+StopRoutingAsync=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=94=B6=E5=B0=BE+=E5=88=A0=E6=97=A7=201=20=E5=8F=82?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=E9=87=8D=E8=BD=BD=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E6=89=80=E6=9C=89=E6=9D=83=E8=BD=AC=E7=A7=BB=E7=BB=8F=E5=B7=A5?= =?UTF-8?q?=E5=8E=82=E6=96=B9=E6=B3=95=E8=BF=94=E5=9B=9E=E5=80=BC=E5=BD=A2?= =?UTF-8?q?=E6=80=81=EF=BC=88analyzer=20JCC9108=20IAsyncDisposable=20?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E8=A7=A6=E5=8F=91=E7=9A=84=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E5=8C=96=E2=80=94=E2=80=94=E5=AE=88=E5=8D=AB=E6=9C=AC=E8=BA=AB?= =?UTF-8?q?=E8=AF=81=E6=98=8E'=E9=97=AE=E9=A2=98=E6=9B=B4=E5=AE=B9?= =?UTF-8?q?=E6=98=93=E6=9A=B4=E9=9C=B2'=E6=96=B9=E6=B3=95=E8=AE=BA?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=AE=88=E5=8D=AB=E6=B5=8B=E8=AF=95=204=20?= =?UTF-8?q?=E4=B8=AA=EF=BC=88=E6=97=A0=E6=B6=88=E6=81=AF=20Dispose=20?= =?UTF-8?q?=E6=97=B6=E9=99=90=E6=94=B6=E5=B0=BE=3D=E6=8C=82=E6=AD=BB?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E5=BD=A2=E6=80=81/=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=B4=BE=E5=8F=91/=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E8=AF=AD=E4=B9=89/=E6=9C=AA=E6=B3=A8=E5=86=8C=E7=A9=BA?= =?UTF-8?q?=E6=B5=81=EF=BC=89+=20=E6=B3=A8=E5=86=8C=E7=BB=8F=20WaitForComm?= =?UTF-8?q?andsDrainedAsync=20=E6=8E=92=E7=A9=BA=E5=B1=8F=E9=9A=9C?= =?UTF-8?q?=EF=BC=88=E9=82=AE=E7=AE=B1=E6=A8=A1=E5=9E=8B=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=9B=BF=E4=BB=A3=E5=9B=BA=E5=AE=9A=E5=BB=B6?= =?UTF-8?q?=E6=97=B6=EF=BC=89=EF=BC=9Bagents.tests=20632=20=E5=85=A8?= =?UTF-8?q?=E7=BB=BF+=E4=B8=BB=E4=BB=A3=E7=90=86=E8=B7=AF=E5=BE=84=20E2E?= =?UTF-8?q?=20=E5=A4=8D=E9=AA=8C=201/1=20=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../misc/AgentMessageRouteLoopTests.cs | 80 +++++++++++++++++++ .../Core/Messaging/AgentMessageRouteLoop.cs | 79 ++++++++++++++++++ .../Swarm/PlanApprovalMessageRouter.cs | 53 ++++++------ .../Swarm/SwarmPermissionMessageRouter.cs | 53 ++++++------ 4 files changed, 208 insertions(+), 57 deletions(-) create mode 100644 modules/llm/agents.tests/Agents/Coordinator/misc/AgentMessageRouteLoopTests.cs create mode 100644 modules/llm/agents/Coordinator/Core/Messaging/AgentMessageRouteLoop.cs diff --git a/modules/llm/agents.tests/Agents/Coordinator/misc/AgentMessageRouteLoopTests.cs b/modules/llm/agents.tests/Agents/Coordinator/misc/AgentMessageRouteLoopTests.cs new file mode 100644 index 000000000..d31992433 --- /dev/null +++ b/modules/llm/agents.tests/Agents/Coordinator/misc/AgentMessageRouteLoopTests.cs @@ -0,0 +1,80 @@ +namespace JoinCode.Agents.Tests.Coordinator.Misc; + +/// +/// 子代理消息路由循环守卫测试(TASK062 P2+P3)—— ReceiveAsync 漏传 ct 缺陷模式的防再犯守卫: +/// 路由循环必须把取消令牌传入邮箱底层(MailboxBase.ReadAllAsync(ct)),取消后循环在时限内收尾。 +/// 回归形态:漏传 ct 时无消息场景 Channel 读永不退出,DisposeAsync 永挂(E2E 实测 Host.DisposeAsync +/// 挂死根因——dumpasync 链 AgentServiceImpl.DisposeAsync → BackgroundTaskActor → RouteWorker...Async)。 +/// 单一职责:AgentMessageRouteLoop 把"ct 传递"收敛为类内一处构造契约,三个路由器消费消除散落复制。 +/// +public sealed class AgentMessageRouteLoopTests +{ + /// 构造 fake 邮箱(真实 InProcessMailbox—— ReadAllAsync(ct) 底层契约不 mock)。 + private static InProcessMailbox CreateBroker() => new(logger: null); + + /// 守卫①:无消息场景 DisposeAsync 必须在时限内收尾(挂死回归即失败—— 接受取消传播到底层)。 + [Fact] + public async Task ShutdownWithNoMessages_CompletesWithinDeadline() + { + var broker = CreateBroker(); + broker.RegisterAgent("agent-1", "session-1"); + await using var loop = new AgentMessageRouteLoop(broker, "agent-1", static (_, _) => Task.CompletedTask); + loop.Start(); + + await loop.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + } + + /// 守卫②:路由消息经 handler 派发(单一职责—— 循环只路由不分发语义)。 + [Fact] + public async Task Start_RoutesMessagesToHandler() + { + var broker = CreateBroker(); + broker.RegisterAgent("agent-1", "session-1"); + await broker.WaitForCommandsDrainedAsync(); + var received = new List(); + await using var loop = new AgentMessageRouteLoop(broker, "agent-1", (msg, _) => { received.Add(msg); return Task.CompletedTask; }); + loop.Start(); + + await broker.SendAsync("agent-1", new CoordinatorMessage { MessageType = "test.ping", MessageId = "m-1", FromAgentId = "sender", ToAgentId = "agent-1", Content = "{}" }, CancellationToken.None); + await WaitUntilAsync(() => received.Count >= 1, TimeSpan.FromSeconds(5)); + Assert.Equal("test.ping", received[0].MessageType); + } + + /// 守卫③:Dispose 后不再路由新消息(停止语义)。 + [Fact] + public async Task AfterShutdown_NewMessagesNotRouted() + { + var broker = CreateBroker(); + broker.RegisterAgent("agent-1", "session-1"); + await broker.WaitForCommandsDrainedAsync(); + var received = new List(); + await using var loop = new AgentMessageRouteLoop(broker, "agent-1", (msg, _) => { received.Add(msg); return Task.CompletedTask; }); + loop.Start(); + await loop.DisposeAsync(); + + await broker.SendAsync("agent-1", new CoordinatorMessage { MessageType = "test.late", MessageId = "m-2", FromAgentId = "sender", ToAgentId = "agent-1", Content = "{}" }, CancellationToken.None); + await Task.Delay(200); + Assert.DoesNotContain(received, m => m.MessageType == "test.late"); + } + + /// 未注册邮箱的 agentId — 空流立即完成(MailboxHub 未注册返回空流契约)。 + [Fact] + public async Task Start_UnregisteredAgent_EmptyStreamCompletes() + { + var broker = CreateBroker(); + await using var loop = new AgentMessageRouteLoop(broker, "ghost", static (_, _) => Task.CompletedTask); + loop.Start(); + + await loop.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + } + + /// 轮询等待条件成立(守卫测试的确定性等待—— 通知语义非固定延时)。 + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + while (!condition()) + { + await Task.Delay(20, cts.Token); + } + } +} diff --git a/modules/llm/agents/Coordinator/Core/Messaging/AgentMessageRouteLoop.cs b/modules/llm/agents/Coordinator/Core/Messaging/AgentMessageRouteLoop.cs new file mode 100644 index 000000000..28c441c5e --- /dev/null +++ b/modules/llm/agents/Coordinator/Core/Messaging/AgentMessageRouteLoop.cs @@ -0,0 +1,79 @@ +namespace Core.Agents.Coordinator; + +/// +/// 子代理消息路由循环(TASK062 P3 单一职责提取)— 邮箱消息 → 处理器的常驻路由小类: +/// 三个路由器(AgentServiceImpl worker 权限响应 / SwarmPermissionMessageRouter worker 响应 / +/// PlanApprovalMessageRouter teammate 响应)的循环复制收敛,"ct 传入 ReceiveAsync" 收敛为 +/// 类内一处构造契约(漏传在结构上不可能——防 ReceiveAsync 漏传 ct 缺陷模式再犯, +/// 该模式曾致 Host.DisposeAsync 永挂:MailboxBase.ReadAllAsync(ct) 无令牌时无消息永不退出)。 +/// 生命周期:Start 单次启动;DisposeAsync 取消 → 无超时等待循环收尾(幂等)。 +/// +internal sealed class AgentMessageRouteLoop : IAsyncDisposable +{ + private readonly IMailbox _broker; + private readonly string _agentId; + private readonly Func _handler; + private readonly CancellationTokenSource _cts = new(); + private Task? _loop; + private bool _disposed; + + /// 构造 — broker=邮箱;agentId=接收方路由键;handler=消息处理器(收到消息时调用,异常由调用方语义负责)。 + public AgentMessageRouteLoop(IMailbox broker, string agentId, Func handler) + { + _broker = broker ?? throw new ArgumentNullException(nameof(broker)); + _agentId = agentId ?? throw new ArgumentNullException(nameof(agentId)); + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + /// 启动路由循环(单次语义 — 二次调用 no-op)。 + public void Start() + { + if (_loop is not null || _disposed) + { + return; + } + + _loop = RunAsync(_cts.Token); + } + + /// 循环主体 — ct 传入 ReceiveAsync(MailboxBase 底层 ReadAllAsync(ct) 随取消退出 — + /// 本类存在的唯一理由:把这行契约收敛到一处,三个路由器共享不再散落复制)。 + private async Task RunAsync(CancellationToken ct) + { + try + { + await foreach (var message in _broker.ReceiveAsync(_agentId, ct).ConfigureAwait(false)) + { + await _handler(message, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // 取消收尾 — Dispose 触发的正常退出路径 + } + } + + /// 收尾 — 取消循环 → 无超时等待完成(幂等)。 + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + _cts.Cancel(); + if (_loop is not null) + { + try + { + await _loop.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + _cts.Dispose(); + } +} diff --git a/modules/llm/agents/Coordinator/Swarm/PlanApprovalMessageRouter.cs b/modules/llm/agents/Coordinator/Swarm/PlanApprovalMessageRouter.cs index 02a6cd0be..eb8135a0b 100644 --- a/modules/llm/agents/Coordinator/Swarm/PlanApprovalMessageRouter.cs +++ b/modules/llm/agents/Coordinator/Swarm/PlanApprovalMessageRouter.cs @@ -15,7 +15,7 @@ public sealed partial class PlanApprovalMessageRouter : ServiceEntity { private readonly IClockService _clock; private CancellationTokenSource? _leaderCts; private Task? _leaderRoutingTask; - private readonly List<(CancellationTokenSource Cts, Task Routing)> _teammateRoutings = []; + private readonly List<(string TeammateId, AgentMessageRouteLoop Loop)> _teammateRoutings = []; /// 初始化计划审批消息路由器 /// 消息邮箱,用于订阅/发布审批消息 @@ -52,24 +52,35 @@ public void StartLeaderRouting(string coordinatorAgentId) { /// 启动 Teammate 侧路由:监听 plan_approval_response 并调用 HandlePlanApprovalResponseAsync /// public void StartTeammateRouting(string teammateAgentId) { - // ct 传入 ReceiveAsync — MailboxBase 底层 ReadAllAsync(ct) 随取消退出(漏传时无消息场景 - // Channel 读永不退出,StopRoutingAsync/Dispose 无法收尾 teammate 路由 — 同 AgentServiceImpl:679 缺陷) - var cts = new CancellationTokenSource(); - _teammateRoutings.Add((cts, RouteTeammateResponsesAsync(teammateAgentId, cts.Token))); + // 经 AgentMessageRouteLoop 单一职责循环 — ct 传递为类内构造契约, + // 防 ReceiveAsync 漏传 ct 缺陷模式再犯(同 AgentServiceImpl:679 缺陷) + _teammateRoutings.Add((teammateAgentId, StartTeammateLoop(teammateAgentId))); + } + + /// 构造并启动 Teammate 响应路由循环(所有权随返回值转移给调用方列表 — StopRoutingAsync 统一收尾)。 + private AgentMessageRouteLoop StartTeammateLoop(string teammateAgentId) { + var loop = new AgentMessageRouteLoop(_messageBroker, teammateAgentId, RouteTeammateResponseMessageAsync); + loop.Start(); + return loop; + } + + /// Teammate 响应消息处理器 — plan_approval_response 经审批管理器处理。 + private async Task RouteTeammateResponseMessageAsync(CoordinatorMessage message, CancellationToken ct) { + if (message.MessageType == TeammateMessageType.PlanApprovalResponse.ToValue()) { + try { + await ProcessPlanApprovalResponseAsync(message).ConfigureAwait(false); + } catch (Exception ex) { + _logger?.LogError(ex, "Teammate Plan 审批响应处理失败: To={ToAgentId}", message.ToAgentId); + } + } } /// /// 停止 Leader 侧路由 + Teammate 侧路由(全部收尾) /// public async Task StopRoutingAsync() { - foreach (var (cts, routing) in _teammateRoutings) { - cts.Cancel(); - try { - await routing.ConfigureAwait(false); - } catch (OperationCanceledException) { - } - - cts.Dispose(); + foreach (var (_, loop) in _teammateRoutings) { + await loop.DisposeAsync().ConfigureAwait(false); } _teammateRoutings.Clear(); @@ -116,22 +127,6 @@ private async Task RouteLeaderMessagesAsync(string coordinatorAgentId, Cancellat } } - /// - /// Teammate 侧响应路由:监听 plan_approval_response 并调用 HandlePlanApprovalResponseAsync - /// - private async Task RouteTeammateResponsesAsync(string teammateAgentId, CancellationToken ct) { - try { - await foreach (var message in _messageBroker.ReceiveAsync(teammateAgentId, ct).ConfigureAwait(false)) { - if (message.MessageType == TeammateMessageType.PlanApprovalResponse.ToValue()) { - await ProcessPlanApprovalResponseAsync(message).ConfigureAwait(false); - } - } - } catch (OperationCanceledException) { - } catch (Exception ex) { - _logger?.LogError(ex, "Teammate Plan 审批响应路由异常退出: TeammateId={TeammateId}", teammateAgentId); - } - } - /// /// 处理 plan_approval_request — Leader 侧自动批准 /// 对齐 TS: Leader 自动批准 teammate 的 plan 退出请求 diff --git a/modules/llm/agents/Coordinator/Swarm/SwarmPermissionMessageRouter.cs b/modules/llm/agents/Coordinator/Swarm/SwarmPermissionMessageRouter.cs index 98a4f0efc..57139c632 100644 --- a/modules/llm/agents/Coordinator/Swarm/SwarmPermissionMessageRouter.cs +++ b/modules/llm/agents/Coordinator/Swarm/SwarmPermissionMessageRouter.cs @@ -10,6 +10,7 @@ public sealed partial class SwarmPermissionMessageRouter : ServiceEntity { private readonly ILogger? _logger; private CancellationTokenSource? _cts; private Task? _routingTask; + private readonly List<(string WorkerId, AgentMessageRouteLoop Loop)> _workerLoops = []; /// /// 初始化 Swarm 权限消息路由器 @@ -43,10 +44,16 @@ public void StartRouting(string coordinatorAgentId) { } /// - /// 异步停止消息路由并释放相关资源 + /// 异步停止消息路由并释放相关资源(Leader 路由 + 全部 Worker 路由统一收尾) /// /// 表示异步操作的任务 public async Task StopRoutingAsync() { + foreach (var (_, loop) in _workerLoops) { + await loop.DisposeAsync().ConfigureAwait(false); + } + + _workerLoops.Clear(); + if (_cts == null) return; _cts.Cancel(); @@ -71,26 +78,29 @@ public override async ValueTask DisposeAsync() { } /// - /// 启动 Worker 侧响应路由,监听权限响应消息 + /// 启动 Worker 侧响应路由,监听权限响应消息(经 AgentMessageRouteLoop 单一职责循环 — + /// ct 传递为类内构造契约,防 ReceiveAsync 漏传 ct 缺陷模式再犯) /// /// Worker 智能体标识 public void StartWorkerResponseRouting(string workerAgentId) { - // ct 传入 ReceiveAsync — MailboxBase 底层 ReadAllAsync(ct) 随取消退出(漏传时无消息场景 - // Channel 读永不退出,StopRoutingAsync/Dispose 无法收尾 worker 路由 — 同 AgentServiceImpl:679 缺陷) - _cts ??= new CancellationTokenSource(); - _ = RouteWorkerResponsesAsync(workerAgentId, _cts.Token); + _workerLoops.Add((workerAgentId, StartWorkerLoop(workerAgentId))); } - private async Task RouteWorkerResponsesAsync(string workerAgentId, CancellationToken ct) { - try { - await foreach (var message in _messageBroker.ReceiveAsync(workerAgentId, ct).ConfigureAwait(false)) { - if (message.MessageType == SwarmPermissionMessageType.PermissionResponse.ToValue()) { - await _callbackService.ProcessIncomingResponseMessageAsync(message).ConfigureAwait(false); - } + /// 构造并启动 Worker 响应路由循环(所有权随返回值转移给调用方列表 — StopRoutingAsync 统一收尾)。 + private AgentMessageRouteLoop StartWorkerLoop(string workerAgentId) { + var loop = new AgentMessageRouteLoop(_messageBroker, workerAgentId, RouteWorkerResponseMessageAsync); + loop.Start(); + return loop; + } + + /// Worker 响应消息处理器 — 权限响应经回调服务处理。 + private async Task RouteWorkerResponseMessageAsync(CoordinatorMessage message, CancellationToken ct) { + if (message.MessageType == SwarmPermissionMessageType.PermissionResponse.ToValue()) { + try { + await _callbackService.ProcessIncomingResponseMessageAsync(message).ConfigureAwait(false); + } catch (Exception ex) { + _logger?.LogError(ex, "Worker 权限响应处理失败: To={ToAgentId}", message.ToAgentId); } - } catch (OperationCanceledException) { - } catch (Exception ex) { - _logger?.LogError(ex, "Worker 权限响应路由异常退出: WorkerId={WorkerId}", workerAgentId); } } @@ -109,19 +119,6 @@ private async Task RouteMessagesAsync(string coordinatorAgentId, CancellationTok } } - private async Task RouteWorkerResponsesAsync(string workerAgentId) { - try { - await foreach (var message in _messageBroker.ReceiveAsync(workerAgentId).ConfigureAwait(false)) { - if (message.MessageType == SwarmPermissionMessageType.PermissionResponse.ToValue()) { - await _callbackService.ProcessIncomingResponseMessageAsync(message).ConfigureAwait(false); - } - } - } catch (OperationCanceledException) { - } catch (Exception ex) { - _logger?.LogError(ex, "Worker 权限响应路由异常退出: WorkerId={WorkerId}", workerAgentId); - } - } - private async Task ProcessRequestAsync(CoordinatorAgentMessage message, CancellationToken ct) { try { var data = RelaxedJsonSerializer.Deserialize( From 4c05beeec7301787b0ef9178881a670fb6d6add0 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:31:56 +0800 Subject: [PATCH 19/30] =?UTF-8?q?test+fix:=20=E4=B8=89=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=EF=BC=88Kind=E5=9B=9E=E5=BD=92=E9=98=B2?= =?UTF-8?q?=E6=8A=A4+=E8=B6=85=E6=97=B6120s+=E6=8E=92=E9=98=9F=E4=B8=8A?= =?UTF-8?q?=E9=99=90=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E2=91=A0?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=99=A8Kind=E9=BB=98=E8=AE=A4=E5=80=BC?= =?UTF-8?q?=E4=B8=89=E5=A5=91=E7=BA=A6=E9=94=81=E5=AE=9A=EF=BC=88=E7=89=B9?= =?UTF-8?q?=E6=80=A7=E9=BB=98=E8=AE=A4Mcp+=E4=BA=A7=E7=89=A9=E9=BB=98?= =?UTF-8?q?=E8=AE=A4mcp=E7=94=A8Sandbox=E7=9C=9F=E5=AE=9E=E6=A0=B7?= =?UTF-8?q?=E6=9C=AC+=E6=A0=B8=E5=BF=83File=E7=99=BD=E5=90=8D=E5=8D=95Syst?= =?UTF-8?q?em=EF=BC=89=E9=98=B2=E5=B7=A8=E5=9E=8B=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=A4=8D=E6=B4=BB=EF=BC=8C=E7=94=9F=E6=88=90=E5=99=A8=E6=AD=A4?= =?UTF-8?q?=E5=89=8D=E9=9B=B6=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=EF=BC=9B?= =?UTF-8?q?=E2=91=A1API=E8=B6=85=E6=97=B6=E9=BB=98=E8=AE=A410s=E2=86=92120?= =?UTF-8?q?s=EF=BC=88=E6=A0=B9=E5=9B=A0=E9=93=BE=E6=9C=80=E5=90=8E?= =?UTF-8?q?=E4=B8=80=E7=8E=AF=E2=80=94=E2=80=94=E6=85=A2=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=A6=96=E5=8C=85=E5=B8=B8=E8=B6=8510s?= =?UTF-8?q?=EF=BC=8C=E8=B6=85=E6=97=B6=E5=85=9C=E5=BA=95=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E7=A9=BA=E5=9B=9E=E5=90=88=E9=98=B2=E5=BE=A1+Timeout=E8=AF=B1?= =?UTF-8?q?=E5=AF=BC=E4=B8=8D=E6=80=95=E9=95=BF=E7=AD=89=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E2=91=A2=E6=8E=92=E9=98=9F=E4=B8=8A=E9=99=9010=E6=9D=A1?= =?UTF-8?q?=EF=BC=88=E7=8B=82=E6=95=B2=E9=98=B2=E8=86=A8=E8=83=80=EF=BC=8C?= =?UTF-8?q?=E6=BB=A1=E5=90=8E=E4=B8=A2=E5=BC=83=E5=B8=A6=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=9F=BA=E7=BA=BF804+1381=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/engine/adapters/SessionController.cs | 5 +- .../handlers/ToolKindDefaultGuardTests.cs | 49 +++++++++++++++++++ .../src/NativeTui.Core/repl/ReplShell.cs | 9 ++++ .../NativeTui.Tests/repl/QueuedSubmitTests.cs | 41 ++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 modules/mcp/mcp_tool_dispatch.tests/handlers/ToolKindDefaultGuardTests.cs diff --git a/modules/apps/engine/adapters/SessionController.cs b/modules/apps/engine/adapters/SessionController.cs index 3dab2da00..026475a18 100644 --- a/modules/apps/engine/adapters/SessionController.cs +++ b/modules/apps/engine/adapters/SessionController.cs @@ -380,7 +380,10 @@ private static int ParseApiTimeoutMs() { var env = Environment.GetEnvironmentVariable("JCC_API_TIMEOUT_MS"); if (int.TryParse(env, out var ms) && ms > 0) return ms; - return 10_000; + // 默认 120s(修复:原 10s 对慢推理模型过短 — 巨型请求时代曾在重试后被掐致空回复; + // 推理型/长思考模型首包常 > 10s,120s 对齐主流 CLI 惯例, + // 超时兜底已有空回合防御 + Timeout 诱导呈现,不怕过长等待) + return 120_000; } private static int _apiTimeoutLogged; diff --git a/modules/mcp/mcp_tool_dispatch.tests/handlers/ToolKindDefaultGuardTests.cs b/modules/mcp/mcp_tool_dispatch.tests/handlers/ToolKindDefaultGuardTests.cs new file mode 100644 index 000000000..3947e6b36 --- /dev/null +++ b/modules/mcp/mcp_tool_dispatch.tests/handlers/ToolKindDefaultGuardTests.cs @@ -0,0 +1,49 @@ +namespace McpToolRegistry.Tests; + +/// +/// 工具 Kind 默认值守卫(TASK061 同批 — 巨型请求修复回归防护)— +/// 锁定三契约防 466KB 巨型请求复活: +/// ①特性默认值 Mcp([McpToolDispatch] 未声明 Kind 的工具走两阶段加载组,首次只发名称); +/// ②生成器产物默认 mcp(fallback "mcp" 而非 "system" — Sandbox 类无显式 Kind 的真实样本); +/// ③核心工具白名单显式 System(File/Search/CodeExecution/Agent — 对齐基准 Claude Code 核心工具集 +/// Read/Write/Edit/Grep/Glob/Bash/Task,这些发完整 schema)。 +/// 回归形态:任一被改回 system → 506 工具全量 schema 466KB 巨型请求 → 10s 超时撞断 → 空回复。 +/// +public class ToolKindDefaultGuardTests +{ + /// 特性默认值 — 未显式声明 Kind 时为 Mcp(非 System)。 + [Fact] + public void AttributeDefault_Kind_IsMcpNotSystem() + { + new McpToolDispatchAttribute("probe").Kind.Should().Be(ToolKind.Mcp); + } + + /// 生成器产物默认 — Sandbox 类(无显式 Kind 真实样本)生成的工具 Kind 为 Mcp + ///(两阶段加载组:首次请求只发名称,tool_description_request 二阶段补齐)。 + [Fact] + public void GeneratedDefault_NoExplicitKind_RegistersAsMcp() + { + var categories = GeneratedToolHandlerRegistration_JoinCode_McpToolDispatch.GetAvailableToolCategories(); + + var sandboxTools = categories.Values + .SelectMany(list => list) + .Where(e => e.Name.Contains("sandbox", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + sandboxTools.Should().NotBeEmpty("SandboxToolHandlers 无显式 Kind — 生成器默认注册"); + sandboxTools.Should().OnlyContain(e => e.Kind == ToolKind.Mcp); + } + + /// 核心工具白名单 — FileToolHandlers 显式 System(完整 schema 直发, + /// 对齐基准 Claude Code 核心工具集)。 + [Fact] + public void CoreFileTools_ExplicitSystem_KindIsSystem() + { + var kind = typeof(Tools.Handlers.FileToolHandlers) + .GetCustomAttributes(typeof(McpToolDispatchAttribute), false) + .Cast() + .FirstOrDefault()?.Kind; + + kind.Should().Be(ToolKind.System, "核心文件工具(Read/Write/Edit)属基准核心集,须发完整 schema"); + } +} diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 22110cb7a..7fdbd8a5f 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -61,6 +61,9 @@ public sealed class ReplShell /// 忙时排队输入队列(TASK060 — 基准 queued 语义,回合结束逐条自动发送)。 private readonly List _queuedInputs = []; + /// 排队输入条数上限(守卫 — 狂敲防队列无限膨胀:满后新输入丢弃带反馈)。 + internal const int MaxQueuedInputs = 10; + /// 消息光标导航协调器(交互层 Step1 — 第四交互态,优先级低于权限/模型/搜索模态)。 private readonly MessageCursor _cursorNav = new(); @@ -995,6 +998,12 @@ internal void Submit() if (IsBusy) { + if (_queuedInputs.Count >= MaxQueuedInputs) + { + AddMessage(new ToolResultItem($"排队已满({MaxQueuedInputs} 条上限)— 本条丢弃,请等当前回合结束")); + return; + } + _queuedInputs.Add(text); AddMessage(new ToolResultItem("已排队,当前回合结束后自动发送")); return; diff --git a/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs b/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs index bcb48c423..3a7e1ce76 100644 --- a/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs @@ -102,4 +102,45 @@ public void QueuedInput_EntersHistoryRing() Assert.Contains("排队也入历史", shell.History.Entries); } + + /// 排队上限守卫 — 满 10 条后新输入丢弃(⎿ 反馈),不调引擎不无限膨胀。 + [Fact] + public void QueuedInputs_OverLimit_DroppedWithFeedback() + { + var (shell, engine) = CreateShell(); + MakeBusy(shell, engine); + for (var i = 1; i <= ReplShell.MaxQueuedInputs; i++) + { + TypeAndSubmit(shell, $"第{i}条"); + } + + TypeAndSubmit(shell, "超限条"); + + var notes = shell.View.Messages.Items.OfType().ToList(); + Assert.Contains(notes, t => t.Text.Contains("排队已满")); + Assert.DoesNotContain(notes.Where(t => t.Text.Contains("排队已满")), t => t.Text.Contains("自动发送")); + + EndTurn(shell, engine); + for (var i = 1; i < ReplShell.MaxQueuedInputs; i++) + { + shell.DrainEngine(); + } + Assert.Equal(Enumerable.Range(1, ReplShell.MaxQueuedInputs).Select(i => $"第{i}条"), engine.Submits); + } + + /// 上限内排队 — 正常入队(边界值恰好 10 条不误拦)。 + [Fact] + public void QueuedInputs_AtLimit_AllQueued() + { + var (shell, engine) = CreateShell(); + MakeBusy(shell, engine); + for (var i = 1; i <= ReplShell.MaxQueuedInputs; i++) + { + TypeAndSubmit(shell, $"第{i}条"); + } + + var notes = shell.View.Messages.Items.OfType().ToList(); + Assert.Equal(ReplShell.MaxQueuedInputs, notes.Count(t => t.Text.Contains("已排队"))); + Assert.DoesNotContain(notes, t => t.Text.Contains("排队已满")); + } } From 11d7abba85be8b8a599ee8ff490b23b5c5156654 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:45:04 +0800 Subject: [PATCH 20/30] =?UTF-8?q?feat:=20Host=20=E5=A2=9E=20--selftest-tur?= =?UTF-8?q?n=20=E7=9C=9F=E5=AE=9E=E5=9B=9E=E5=90=88=E8=AE=A1=E6=97=B6?= =?UTF-8?q?=E6=8E=A2=E9=92=88=20|=20=E5=86=B3=E7=AD=96:=20=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=20TUI=20=E5=9B=9E=E5=A4=8D=E5=BB=B6=E8=BF=9F=E4=B8=93?= =?UTF-8?q?=E7=94=A8=EF=BC=88=E8=B5=B0=E7=94=A8=E6=88=B7=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=90=8C=E9=93=BE=20EngineTurnClient=E2=86=92SessionController?= =?UTF-8?q?(mainAgent)=E2=86=92QueryEngine=EF=BC=8C=E9=80=90=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E7=9B=B8=E5=AF=B9=E6=97=B6=E9=97=B4=E6=88=B3=20turn?= =?UTF-8?q?=5Fstarted/=E9=A6=96=E6=80=9D=E8=80=83/=E9=A6=96=20token/turn?= =?UTF-8?q?=5Fcompleted=EF=BC=8C60s=20=E8=B6=85=E6=97=B6=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=AE=9E=E6=B5=8B=20sensenova=20=E9=A6=96?= =?UTF-8?q?=E5=8C=85=203.6~8s=20=E6=B3=A2=E5=8A=A8=E5=9B=9E=E5=90=88=204~8?= =?UTF-8?q?s=20=E5=AE=8C=E6=88=90=E9=93=BE=E8=B7=AF=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E6=88=B7=2040s+=20=E5=A4=8D=E7=8E=B0?= =?UTF-8?q?=E7=B3=BB=E6=97=A7=E4=BA=8C=E8=BF=9B=E5=88=B6=EF=BC=88=E5=B7=A8?= =?UTF-8?q?=E5=9E=8B=E8=AF=B7=E6=B1=82=E4=BF=AE=E5=A4=8D=E5=89=8D=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=EF=BC=89=EF=BC=9B=E5=90=8E=E7=BB=AD=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E5=BB=B6=E8=BF=9F=E9=97=AE=E9=A2=98=E5=8F=AF=E7=94=A8=E6=AD=A4?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E4=B8=80=E9=94=AE=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/apps/NativeTui.Host/Program.cs | 58 ++++++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 0f8a07ca8..4d8844d20 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -7,15 +7,18 @@ namespace NativeTui.Host; /// (Enter 提交 / Esc 忙时打断或闲时退出 / Shift+Tab 权限循环 / Ctrl+C 退出)。 /// public static class Program { - /// 入口 — --selftest 非交互冒烟;无参数进入交互 REPL;未知参数报错诱导。 + /// 入口 — --selftest 非交互冒烟;--selftest-turn 真实回合计时探针(诊断用); + /// 无参数进入交互 REPL;未知参数报错诱导。 /// 顶层 catch ConfigurationException — 配置错误友好呈现(两级 profile 覆盖链 + auth key 状态 + 修复指引, /// 修复:配置错误直接闪退刷未处理异常堆栈),退出码 2。 public static async Task Main(string[] args) { try { if (args is ["--selftest"]) return await RunSelftestAsync().ConfigureAwait(false); + if (args is ["--selftest-turn"]) + return await RunSelftestTurnAsync().ConfigureAwait(false); if (args.Length > 0) { - Console.Error.WriteLine($"未知参数: {string.Join(' ', args)}。用法: NativeTui.Host [--selftest](无参数进入交互模式,需真实终端)"); + Console.Error.WriteLine($"未知参数: {string.Join(' ', args)}。用法: NativeTui.Host [--selftest | --selftest-turn](无参数进入交互模式,需真实终端)"); return 1; } @@ -27,6 +30,57 @@ public static async Task Main(string[] args) { } } + /// 真实回合计时探针(ADR 0080 — 诊断 TUI 链路回复延迟)— 提交一个真实回合 + /// (走用户交互同链:EngineTurnClient → SessionController(mainAgent) → QueryEngine), + /// 逐事件打印相对时间戳(turn_started/首思考/首 token/完成/错误),60s 超时兜底。 + private static async Task RunSelftestTurnAsync() { + using var logLevelScope = EnvVarScope.Set("JCC_LOG_LEVEL", "Error"); + TestEnvironmentDetector.ForceNonInteractive = true; + await using var session = await EngineSession.CreateAsync().ConfigureAwait(false); + _ = session.Client.DrainEvents(); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + session.Client.Submit("只回复两个字母: ok"); + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms 提交回合"); + + string? firstThinking = null; + string? firstToken = null; + string? finalText = null; + while (sw.ElapsedMilliseconds < 60_000) { + await Task.Delay(100).ConfigureAwait(false); + foreach (var evt in session.Client.DrainEvents()) { + switch (evt) { + case BridgeEvent.TurnStarted: + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms turn_started"); + break; + case BridgeEvent.ThinkingStarted: + if (firstThinking is null) { + firstThinking = $"{sw.ElapsedMilliseconds}ms"; + Console.WriteLine($"[probe] t={firstThinking} 首思考"); + } + break; + case BridgeEvent.TokenDelta(var delta): + if (firstToken is null) { + firstToken = $"{sw.ElapsedMilliseconds}ms"; + Console.WriteLine($"[probe] t={firstToken} 首 token"); + } + finalText += delta; + break; + case BridgeEvent.TurnCompleted: + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms turn_completed | 首思考={firstThinking} 首 token={firstToken} | 累计文本={finalText?.Length ?? 0} 字符"); + Console.WriteLine("[probe] 回合文本: " + (finalText is { Length: > 0 } ? finalText[..Math.Min(200, finalText.Length)] : "(空)")); + return 0; + case BridgeEvent.Error(var message): + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms ERROR: {message}"); + return 1; + } + } + } + + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms 超时(60s 无 turn_completed)| 首思考={firstThinking} 首 token={firstToken}"); + return 1; + } + /// 交互模式 — TTY 检查 → UTF-8 → raw mode → 引擎组装(Console.Out 重定向 stderr 防引擎污染渲染)→ REPL 主循环。 private static async Task RunInteractiveAsync() { if (Console.IsInputRedirected || Console.IsOutputRedirected) { From 3bc34c2d6791dd08086015a19843913d2bc9991a Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 21:55:35 +0800 Subject: [PATCH 21/30] =?UTF-8?q?feat:=20TASK062=20P4-P6=20=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E8=90=BD=E5=9C=B0=E2=80=94=E2=80=94E2E=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=90=8D=E5=B8=B8=E9=87=8F=E5=8C=96+TeammatePane=20?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=AE=88=E5=8D=AB+CI=20matrix=20=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5=20|=20=E5=86=B3=E7=AD=96:=20P4=20=E4=B8=A4=20E2E=20?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=20magic=20string=20=E6=94=B9=20AgentToolName?= =?UTF-8?q?EnumConstants.Agent/FileToolNameEnumConstants.FileRead=EF=BC=88?= =?UTF-8?q?=E5=8D=95=E4=B8=80=E6=95=B0=E6=8D=AE=E6=BA=90=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E6=94=B9=E5=90=8D=E5=8D=B3=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E9=94=99=E8=80=8C=E9=9D=9E=20E2E=20=E9=9D=99=E9=BB=98=20not=20?= =?UTF-8?q?found=EF=BC=8C=E6=8E=92=E9=9A=9C=E4=B8=A4=E8=BD=AE=E7=9A=84?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0=E6=B6=88=E9=99=A4=EF=BC=89=EF=BC=9BP5=20Team?= =?UTF-8?q?matePaneMiddleware=20=E5=88=A4=E5=AE=9A=E5=8A=A0=20ExecutorVari?= =?UTF-8?q?ant.Teammate=20=E5=AE=88=E5=8D=AB=EF=BC=88executor=20=E5=89=8D?= =?UTF-8?q?=E5=8F=B0=E5=AD=90=E4=BB=A3=E7=90=86=E4=B8=8D=E5=86=8D=E5=B0=9D?= =?UTF-8?q?=E8=AF=95=20tmux=20pane=E2=80=94=E2=80=94Windows=20=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E5=99=AA=E9=9F=B3=E5=BD=92=E9=9B=B6=E3=80=81E2E=2016s?= =?UTF-8?q?=E2=86=927s=EF=BC=89=EF=BC=9BP6=20ci-e2e=20=E5=A2=9E=20subagent?= =?UTF-8?q?-events=20=E6=9D=A1=E7=9B=AE=EF=BC=88MainAgentPath=20=E8=BF=9B?= =?UTF-8?q?=20CI=20=E7=99=BD=E5=90=8D=E5=8D=95=E2=80=94=E2=80=94=E6=9B=BE?= =?UTF-8?q?=E5=9B=A0=E4=B8=8D=E5=9C=A8=E7=99=BD=E5=90=8D=E5=8D=95=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=E9=95=BF=E6=9C=9F=E6=BD=9C=E4=BC=8F=E7=9A=84=E6=95=99?= =?UTF-8?q?=E8=AE=AD=EF=BC=9BChatService=20=E8=B7=AF=E5=BE=84=E5=BE=85=20P?= =?UTF-8?q?1=20=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=A2=9E=E8=A1=A5=EF=BC=89?= =?UTF-8?q?=EF=BC=9BE2E=20=E5=A4=8D=E9=AA=8C=201/1=20=E7=BB=BF+agents.test?= =?UTF-8?q?s=20632=20=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-e2e.yml | 3 ++- ...345\256\210\345\215\253\345\274\272\345\214\226.md" | 10 +++++----- .../Services/Spawn/Unified/TeammatePaneMiddleware.cs | 8 ++++++-- .../e2e/s_to_z/SubAgentEventMainAgentPathE2ETests.cs | 4 ++-- .../e2e/s_to_z/SubAgentEventStreamE2ETests.cs | 2 +- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index a4d2fd94e..094e6fac6 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -100,7 +100,8 @@ jobs: run: | $tests = @( @{Name="file-tools"; Filter="FullyQualifiedName~FileToolCoverageTests|FullyQualifiedName~SearchToolCoverageTests|FullyQualifiedName~ShellGitToolCoverageTests|FullyQualifiedName~ExtendedToolCoverageTests"}, - @{Name="agent-tools"; Filter="FullyQualifiedName~AgentToolCoverageTests|FullyQualifiedName~BatchToolCoverageTests|FullyQualifiedName~TaskToolCoverageTests|FullyQualifiedName~AstIntegrationTests"} + @{Name="agent-tools"; Filter="FullyQualifiedName~AgentToolCoverageTests|FullyQualifiedName~BatchToolCoverageTests|FullyQualifiedName~TaskToolCoverageTests|FullyQualifiedName~AstIntegrationTests"}, + @{Name="subagent-events"; Filter="FullyQualifiedName~SubAgentEventMainAgentPathE2ETests"} ) foreach ($t in $tests) { $name = $t.Name diff --git "a/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" "b/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" index 636d0e888..72b5c8251 100644 --- "a/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" +++ "b/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" @@ -37,11 +37,11 @@ | 基建实现 | 消费点 | 已实现 | 已验收 | |---------|--------|--------|--------| -| P2 守卫测试(路由器取消收尾) | AgentServiceImpl/Swarm/PlanApproval 三路由器 | ⏳ | ⏳ | -| P3 单一职责提取 AgentMessageRouteLoop | 三路由器重构消费 | ⏳ | ⏳ | -| P4 E2E 工具名常量化 | 两 E2E 脚本 | ⏳ | ⏳ | -| P5 TeammatePane 角色守卫 | TeammatePaneMiddleware | ⏳ | ⏳ | -| P6 CI matrix 增补 | ci-e2e.yml | ⏳ | ⏳ | +| P2 守卫测试(路由器取消收尾) | AgentServiceImpl/Swarm/PlanApproval 三路由器 | ✅(10ba6e250) | ✅ AgentMessageRouteLoopTests 4/4(无消息 Dispose 时限收尾=挂死回归形态/路由派发/停止语义/未注册空流)+ agents.tests 632 | +| P3 单一职责提取 AgentMessageRouteLoop | Swarm/PlanApproval 两路由器重构消费(AgentServiceImpl 保留 BackgroundTaskActor 调度语义) | ✅(10ba6e250) | ✅ ct 传递收敛类内一处构造契约 + analyzer JCC9108 所有权守卫触发验证 + E2E 复验绿 | +| P4 E2E 工具名常量化 | 两 E2E 脚本 | ✅ | ✅ AgentToolNameEnumConstants.Agent + FileToolNameEnumConstants.FileRead 引用 + E2E 复验绿 | +| P5 TeammatePane 角色守卫 | TeammatePaneMiddleware(Variant==Teammate 判定) | ✅ | ✅ E2E 警告噪音归零 + 耗时 16s→7s(免 tmux 进程尝试) | +| P6 CI matrix 增补 | ci-e2e.yml subagent-events 条目(MainAgentPath) | ✅ | ✅ 本地等价验证绿(ChatService 路径待 P1 修复后增补) | ## 4. 遗留 diff --git a/modules/llm/agents/Services/Spawn/Unified/TeammatePaneMiddleware.cs b/modules/llm/agents/Services/Spawn/Unified/TeammatePaneMiddleware.cs index bf0acd9e8..214cd6d11 100644 --- a/modules/llm/agents/Services/Spawn/Unified/TeammatePaneMiddleware.cs +++ b/modules/llm/agents/Services/Spawn/Unified/TeammatePaneMiddleware.cs @@ -30,9 +30,13 @@ public TeammatePaneMiddleware(ISubAgentContextAccessor subAgentContextAccessor, /// 下一个中间件委托 /// 取消令牌 public async Task InvokeAsync(UnifiedSpawnContext context, MiddlewareDelegate next, CancellationToken ct) { - if (!context.IsMainAgent && _layoutManager is not null && context.Agent is not null) { + // 角色守卫(TASK062 P5)— 仅 teammate 变体创建 UI Pane;executor 等普通前台子代理 + // 无 pane 语义(Windows 无 tmux 场景曾对每个 executor spawn 打警告噪音 — E2E 实测) + var current = _subAgentContextAccessor.Current; + var isTeammate = current?.Variant == ExecutorVariant.Teammate; + if (!context.IsMainAgent && isTeammate && _layoutManager is not null && context.Agent is not null) { try { - var agentType = _subAgentContextAccessor.Current?.Variant?.ToValue() ?? _subAgentContextAccessor.Current?.Role.ToValue() ?? "agent"; + var agentType = current?.Variant?.ToValue() ?? current?.Role.ToValue() ?? "agent"; var command = $"# Agent: {context.Task}"; await _layoutManager.CreateTeammatePaneAsync(context.AgentId, agentType, command, context.CancellationToken).ConfigureAwait(false); context.TeammatePaneCreated = true; 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 8436682e9..16f88bb15 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 @@ -131,7 +131,7 @@ private static string BuildMockConfig(string toolCallArguments) { sb.AppendLine(" \"thinking_content\": null,"); sb.AppendLine(" \"tool_calls\": ["); sb.AppendLine(" {"); - sb.AppendLine(" \"tool_name\": \"agent\","); + sb.Append(" \"tool_name\": \"").Append(AgentToolNameEnumConstants.Agent).AppendLine("\","); sb.Append(" \"arguments\": \"").Append(EscapeJsonString(toolCallArguments)).AppendLine("\","); sb.AppendLine(" }".TrimEnd(',')); sb.AppendLine(" ],"); @@ -145,7 +145,7 @@ private static string BuildMockConfig(string toolCallArguments) { sb.AppendLine(" \"thinking_content\": null,"); sb.AppendLine(" \"tool_calls\": ["); sb.AppendLine(" {"); - sb.AppendLine(" \"tool_name\": \"read\","); + sb.Append(" \"tool_name\": \"").Append(FileToolNameEnumConstants.FileRead).AppendLine("\","); sb.AppendLine(" \"arguments\": \"{\\\"file_path\\\":\\\"README.md\\\"}\","); sb.AppendLine(" }".TrimEnd(',')); sb.AppendLine(" ],"); diff --git a/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs index d68cd707f..47c24ea16 100644 --- a/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs +++ b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs @@ -144,7 +144,7 @@ private static string BuildMockConfig(string toolCallArguments) { sb.AppendLine(" \"thinking_content\": null,"); sb.AppendLine(" \"tool_calls\": ["); sb.AppendLine(" {"); - sb.AppendLine(" \"tool_name\": \"agent\","); + sb.Append(" \"tool_name\": \"").Append(AgentToolNameEnumConstants.Agent).AppendLine("\","); sb.Append(" \"arguments\": \"").Append(EscapeJsonString(toolCallArguments)).AppendLine("\","); sb.AppendLine(" }".TrimEnd(',')); sb.AppendLine(" ],"); From 1e2b8f2ffc3acd71f179cac2dba0a005c275ab8d Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 22:30:13 +0800 Subject: [PATCH 22/30] =?UTF-8?q?fix:=20TASK062=20P1=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=AD=90=E4=BB=A3=E7=90=86=20E2E=20=E4=B8=89?= =?UTF-8?q?=E7=BB=BF=EF=BC=88=E8=AF=AF=E8=AF=8A=E4=BF=AE=E6=AD=A3=EF=BC=9A?= =?UTF-8?q?=E9=9D=9E=E4=B8=B2=E8=AF=9D=E8=80=8C=E6=98=AF=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA+=E5=B9=B6=E8=A1=8C=E6=B1=A1=E6=9F=93?= =?UTF-8?q?=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E5=88=86=E6=AD=A5?= =?UTF-8?q?=E6=8F=92=E6=A1=A9=E5=AE=9E=E8=AF=81=E6=8E=A8=E7=BF=BB=20TASK04?= =?UTF-8?q?3=20=E4=B8=B2=E8=AF=9D=E5=81=87=E8=AE=BE=E2=80=94=E2=80=94ChatS?= =?UTF-8?q?ervice=20=E8=B7=AF=E5=BE=84=20agent-handler/fork/=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E4=BB=B6=E5=85=A8=E9=83=A8=E9=9B=B6=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=3D=E5=B7=A5=E5=85=B7=E5=9C=A8=20handler=20=E5=89=8D=E8=A2=AB?= =?UTF-8?q?=20not=20found=20=E7=9F=AD=E8=B7=AF=EF=BC=88kernel.Plugins=20?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=E6=99=9A=E4=BA=8E=E5=90=AF=E5=8A=A8=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E4=BF=AE=E5=A4=8D=E4=B8=89=E4=BB=B6=3D=E2=91=A0?= =?UTF-8?q?=E5=85=88=E4=BE=8B=20E2E=20=E8=A1=A5=20McpPluginBootstrapHandle?= =?UTF-8?q?.Completion=20=E7=A1=AE=E5=AE=9A=E6=80=A7=E7=AD=89=E5=BE=85?= =?UTF-8?q?=EF=BC=88=E9=80=9A=E7=9F=A5=E8=AF=AD=E4=B9=89=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E4=B8=BB=E4=BB=A3=E7=90=86=E8=B7=AF=E5=BE=84=E5=85=88=E4=BE=8B?= =?UTF-8?q?=EF=BC=89=E2=91=A1=E4=B8=A4=E7=B1=BB=E5=8A=A0=20[Collection(Sub?= =?UTF-8?q?AgentEventSerial)]=20=E5=BC=BA=E5=88=B6=E4=B8=B2=E8=A1=8C?= =?UTF-8?q?=EF=BC=88=E5=90=8C=20testhost=20=E5=B9=B6=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E5=BC=95=E6=93=8E=20session=20=E8=BF=9B=E7=A8=8B=E7=BA=A7?= =?UTF-8?q?=E5=B9=B2=E6=89=B0=E8=87=B4=20turn=20=E8=B7=A8=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=B6=88=E8=80=97=E2=80=94=E2=80=94xunit=20paralleliz?= =?UTF-8?q?eTestCollections=20=E5=8F=82=E6=95=B0=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E6=9C=AA=E7=94=9F=E6=95=88=E6=94=B9=E7=94=A8=20Collection?= =?UTF-8?q?=EF=BC=89=E2=91=A2fork=20=E7=94=A8=E4=BE=8B=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20teammate=20=E8=B7=AF=E5=BE=84=E6=BC=94?= =?UTF-8?q?=E8=BF=9B=EF=BC=88AgentName=3Dteammate/=E5=89=8D=E7=BC=80=20tea?= =?UTF-8?q?mmate-=EF=BC=89=EF=BC=9BCI=20=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=20ChatService=20=E4=B8=A4=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=88subagent-events=20=E6=9D=A1=E7=9B=AE=E4=B8=89=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=85=A8=E8=A6=86=E7=9B=96=EF=BC=89=EF=BC=9B3/3=20?= =?UTF-8?q?=E5=85=A8=E7=BB=BF=2014s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-e2e.yml | 2 +- ...6\210\345\215\253\345\274\272\345\214\226.md" | 2 +- .../s_to_z/SubAgentEventMainAgentPathE2ETests.cs | 1 + .../e2e/s_to_z/SubAgentEventStreamE2ETests.cs | 16 +++++++++++++--- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 094e6fac6..f4951d639 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -101,7 +101,7 @@ jobs: $tests = @( @{Name="file-tools"; Filter="FullyQualifiedName~FileToolCoverageTests|FullyQualifiedName~SearchToolCoverageTests|FullyQualifiedName~ShellGitToolCoverageTests|FullyQualifiedName~ExtendedToolCoverageTests"}, @{Name="agent-tools"; Filter="FullyQualifiedName~AgentToolCoverageTests|FullyQualifiedName~BatchToolCoverageTests|FullyQualifiedName~TaskToolCoverageTests|FullyQualifiedName~AstIntegrationTests"}, - @{Name="subagent-events"; Filter="FullyQualifiedName~SubAgentEventMainAgentPathE2ETests"} + @{Name="subagent-events"; Filter="FullyQualifiedName~SubAgentEventStreamE2ETests|FullyQualifiedName~SubAgentEventMainAgentPathE2ETests"} ) foreach ($t in $tests) { $name = $t.Name diff --git "a/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" "b/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" index 72b5c8251..d93cfc425 100644 --- "a/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" +++ "b/docs/proposed/task/TASK062-\345\274\225\346\223\216\344\276\247\351\227\256\351\242\230\347\231\273\350\256\260\344\270\216\345\256\210\345\215\253\345\274\272\345\214\226.md" @@ -8,7 +8,7 @@ | # | 问题 | 性质 | 状态 | 守卫方案 | |---|------|------|------|---------| -| P1 | **ChatService 路径子代理 LLM 流无 AgentId 透传主流**——SubAgentEventStreamE2ETests 红:子代理输出以无标记 Content 串入主流、无 AgentStarted(疑 StreamingFallbackDecorator 全量收集透传——TASK043 域) | 独立缺陷 | ⏳ 待修 | E2E 断言(已有红测试守护)+ 透传点单一职责化 | +| P1 | ~~ChatService 路径子代理 LLM 流无 AgentId 透传主流~~ **误诊修正**:真实根因=①E2E 未等插件就绪(八插件后台化后 kernel.Plugins 挂载延迟→agent 工具 not found 短路→无子代理事件、主 LLM 二轮直拿 turn2 文本——"串话"假象)②跨 class 并行污染(同 testhost 两引擎 session 进程级干扰→turn 被跨测试消耗)。修复=Completion 等待(通知语义)+ [Collection] 串行 + fork 断言对齐 teammate 演进 | 误诊→已修 | ✅ 3/3 全绿(含 CI 白名单增补) | E2E 断言守护 | | P2 | **邮箱 ReceiveAsync 漏传 ct 模式**(已修三处:AgentServiceImpl/SwarmPermissionMessageRouter/PlanApprovalMessageRouter)——"ct 挂在参数上但没传给底层"是静默缺陷,Dispose 挂死才暴露 | 缺陷模式(防再犯) | ✅ 已修 | **路由器取消收尾测试**(TDD 防回归)+ 单一职责提取 | | P3 | **路由循环内嵌大类**——RouteWorkerPermissionResponsesAsync 等三个消息路由循环内嵌在 AgentServiceImpl(900+ 行)等大类里,取消语义/生命周期/分发逻辑耦合,缺陷难以局部暴露 | 结构性风险 | ⏳ 待守卫 | 提取独立路由类(单一职责)+ 取消收尾单元测试 | | P4 | **E2E/测试工具名 magic string**——SubAgentEventStreamE2ETests/MainAgentPath E2E 脚本手写 "Agent"/"agent_spawn" 均错(真值 `agent`,AgentToolName [EnumValue])——两轮排障才定位 | 可维护性 | ⏳ 待守卫 | 脚本改引用 AgentToolNameEnumConstants 常量(单一数据源) | 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 16f88bb15..30c5d39e6 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 @@ -11,6 +11,7 @@ namespace MockServer.E2E.Tests.E2E; /// SubAgentEventChannel → SessionController chunk 循环排空直投 RouteSubagentEvent → /// IEventConsumer 三子代理回调(OnSubagentStarted/OnSubagentToolUse/OnSubagentFinished)。 /// +[Collection("SubAgentEventSerial")] [Trait("Category", "Integration")] public sealed class SubAgentEventMainAgentPathE2ETests { private readonly ITestOutputHelper _output; diff --git a/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs index 47c24ea16..8d2672514 100644 --- a/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs +++ b/test/mock/mock_server.e2e.tests/e2e/s_to_z/SubAgentEventStreamE2ETests.cs @@ -10,6 +10,7 @@ namespace MockServer.E2E.Tests.E2E; /// ① 显式 subagent_type → 前台流式路径(AgentStarted/带身份活动/AgentFinished 全量) /// ② 省略 subagent_type → fork 后台路径(AgentStarted 即时可见,终态经通道/通知回填) /// +[Collection("SubAgentEventSerial")] [Trait("Category", "Integration")] public sealed class SubAgentEventStreamE2ETests { private readonly ITestOutputHelper _output; @@ -61,10 +62,11 @@ public async Task AgentToolFork_WithoutSubagentType_ShouldEmitAgentStartedImmedi var started = events.FirstOrDefault(e => e.Type == ChatStreamEventType.AgentStarted); Assert.True(started is not null, - "fork 路径应即时发射 AgentStarted。" + + "无 subagent_type 路径应即时发射 AgentStarted。" + $"实际事件类型序列: {string.Join(",", events.Select(e => e.Type))}"); - started!.AgentName.Should().Be("fork"); - started.AgentId.Should().StartWith("fork-"); + // 引擎演进:省略 subagent_type 现走 teammate 路径(InProcessTeammateTaskExecutor 优先于 fork) + started!.AgentName.Should().Be("teammate"); + started.AgentId.Should().StartWith("teammate-"); // 主对话的 Agent 工具调用本身不受影响 events.Should().Contain(e => !e.IsSubAgentActivity && e.Type == ChatStreamEventType.ToolCallEnd, @@ -103,6 +105,14 @@ private async Task> RunAgentScenarioAsync(string toolCallA var session = await JoinCode.App.Builder.EngineSessionFactory.CreateGuiSessionAsync() .ConfigureAwait(true); try { + // 确定性等待插件就绪(邮箱/通知语义 — 八插件后台化后 kernel.Plugins 工具挂载晚于启动; + // 不等则 agent 工具 not found 短路、子代理不执行、无子代理事件 — 超时仅挂死保护) + var bootstrapHandle = session.Services.GetService(); + if (bootstrapHandle is not null) + { + await bootstrapHandle.Completion.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(true); + } + var events = new List(); await foreach (var evt in session.ChatService.StreamWithEventsAsync( "帮我检查项目中的README文件", ct).ConfigureAwait(true)) { From 7cab4165ada1350ae16f6ee080a482d9633cf31d Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 22:34:40 +0800 Subject: [PATCH 23/30] =?UTF-8?q?fix:=20TUI=20=E4=B8=89=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=EF=BC=88=E5=9B=9E=E5=A4=8D=E5=8F=8C=E5=8F=91/spinner=E7=BB=AD?= =?UTF-8?q?=E8=BD=AC/=E4=B8=AD=E6=96=87=E8=BE=93=E5=85=A5=E9=94=99?= =?UTF-8?q?=E4=BD=8D=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E2=91=A0?= =?UTF-8?q?=E5=8F=8C=E5=8F=91=E6=A0=B9=E5=9B=A0=3DAgentBase=20=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E9=80=8F=E4=BC=A0=20QueryEngine=20=E7=9A=84=20Complet?= =?UTF-8?q?e=20=E5=90=8E=E5=B0=BE=E9=83=A8=E5=8F=88=E8=A1=A5=E5=8F=91?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=EF=BC=88CLI=20=E6=B5=81=E5=BC=8F=E7=9B=B4?= =?UTF-8?q?=E5=86=99=E6=97=A0=E6=84=9F=E6=8E=A9=E7=9B=96=EF=BC=8CTUI/bridg?= =?UTF-8?q?e=20=E7=B4=AF=E7=A7=AF=E5=BC=8F=E6=B6=88=E8=B4=B9=E8=80=85?= =?UTF-8?q?=E5=8F=8C=E4=B8=8A=E5=B1=8F=EF=BC=89=E2=80=94=E2=80=94sawComple?= =?UTF-8?q?te=20=E6=A0=87=E8=AE=B0=E5=8D=95=E6=AC=A1=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=EF=BC=88=E6=9C=AA=E5=8F=91=E6=97=B6=E5=85=9C=E5=BA=95=E8=A1=A5?= =?UTF-8?q?=E5=8F=91=E4=BF=9D=E5=BC=82=E5=B8=B8=E8=B7=AF=E5=BE=84=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=8E=A2=E9=92=88=E5=AE=9E=E8=AF=81=20assistant=5Fmes?= =?UTF-8?q?sage=202=E2=86=921=EF=BC=9B=E2=91=A1spinner=20=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E7=BB=93=E6=9D=9F=E4=B8=8D=E6=B8=85=20Mode=20?= =?UTF-8?q?=E8=87=B4=20Thinking=20shimmer=20=E7=BB=AD=E8=BD=AC=E2=80=94?= =?UTF-8?q?=E2=80=94EndTurn=20=E5=A4=8D=E4=BD=8D=20Responding=EF=BC=9B?= =?UTF-8?q?=E2=91=A2=E4=B8=AD=E6=96=87=E5=85=89=E6=A0=87=E9=94=99=E4=BD=8D?= =?UTF-8?q?=3DCursorCol=20char=20=E5=81=8F=E7=A7=BB=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=BD=93=E5=B1=8F=E5=B9=95=E5=88=97=EF=BC=88CJK=20=E6=AF=8F?= =?UTF-8?q?=E5=AD=97=202=20=E5=88=97=EF=BC=89=E2=80=94=E2=80=94PromptInput?= =?UTF-8?q?Box.MeasureDisplayWidth=20=E6=B8=B2=E6=9F=93=E5=B1=82=E6=8D=A2?= =?UTF-8?q?=E7=AE=97=EF=BC=88=E5=85=89=E6=A0=87=E5=88=97+ghost=20=E8=B5=B7?= =?UTF-8?q?=E7=82=B9+ReplView=20=E5=B8=A7=E5=B0=BE=E5=85=89=E6=A0=87?= =?UTF-8?q?=E4=B8=89=E5=A4=84=EF=BC=8C=E7=BC=96=E8=BE=91=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E4=B8=8D=E5=8A=A8=E5=8D=95=E4=B8=80=E8=81=8C=E8=B4=A3=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E5=9F=BA=E7=BA=BF=20809+62+agents=20632=20=E5=85=A8?= =?UTF-8?q?=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../llm/agents/Coordinator/Fork/AgentBase.cs | 21 +++++-- native_tui/apps/NativeTui.Host/Program.cs | 14 +++-- .../NativeTui.Core/bridge/ReplBridgeDriver.cs | 7 ++- .../src/NativeTui.Core/repl/PromptInputBox.cs | 29 ++++++--- .../src/NativeTui.Core/repl/ReplView.cs | 5 +- .../repl/WideCharRenderTests.cs | 60 +++++++++++++++++++ 6 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 native_tui/tests/NativeTui.Tests/repl/WideCharRenderTests.cs diff --git a/modules/llm/agents/Coordinator/Fork/AgentBase.cs b/modules/llm/agents/Coordinator/Fork/AgentBase.cs index 3593d92fa..724a28956 100644 --- a/modules/llm/agents/Coordinator/Fork/AgentBase.cs +++ b/modules/llm/agents/Coordinator/Fork/AgentBase.cs @@ -394,6 +394,7 @@ public virtual async IAsyncEnumerable ExecuteStreamAsync([Enum var queryOptions = BuildChatOptions(); var succeeded = true; string? errorMessage = null; + var sawComplete = false; var queryStream = queryOptions is not null ? _queryEngine.QueryAsync(prompt, chatHistory, queryOptions, linkedToken) @@ -408,6 +409,9 @@ public virtual async IAsyncEnumerable ExecuteStreamAsync([Enum } } + if (chunk.Type == AgentStreamChunkType.Complete) + sawComplete = true; + if (chunk.Type == AgentStreamChunkType.Content) { responseBuilder.Append(chunk.Content); } else if (chunk.Type == AgentStreamChunkType.Complete && chunk.CacheSafeParams is not null) { @@ -460,12 +464,17 @@ public virtual async IAsyncEnumerable ExecuteStreamAsync([Enum if (succeeded) Output.Text = finalOutput; else Output.ErrorMessage = errorMessage; - yield return new AgentStreamChunk { - Type = AgentStreamChunkType.Complete, - Content = finalOutput, - ExecutionTimeMs = stopwatch.ElapsedMilliseconds, - AgentId = UniqueId - }; + // 完成信号只发一次(修复双发:QueryEngine 已发 Complete chunk 时不再补发 — + // 双发致 SessionController onDone 两次 → bridge/TUI 累积式消费者 assistant_message 双上屏; + // CLI 流式直写无感掩盖此缺陷。sawComplete 覆盖正常路径;异常/提前退出未发时仍兜底补发) + if (!sawComplete) { + yield return new AgentStreamChunk { + Type = AgentStreamChunkType.Complete, + Content = finalOutput, + ExecutionTimeMs = stopwatch.ElapsedMilliseconds, + AgentId = UniqueId + }; + } } /// diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 4d8844d20..2465df31f 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -45,7 +45,8 @@ private static async Task RunSelftestTurnAsync() { string? firstThinking = null; string? firstToken = null; - string? finalText = null; + var tokenTotal = 0; + var assistantMessages = new List(); while (sw.ElapsedMilliseconds < 60_000) { await Task.Delay(100).ConfigureAwait(false); foreach (var evt in session.Client.DrainEvents()) { @@ -64,11 +65,16 @@ private static async Task RunSelftestTurnAsync() { firstToken = $"{sw.ElapsedMilliseconds}ms"; Console.WriteLine($"[probe] t={firstToken} 首 token"); } - finalText += delta; + tokenTotal += delta; + break; + case BridgeEvent.AssistantMessage(var text, var markdown): + assistantMessages.Add(text); + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms assistant_message #{assistantMessages.Count} | textLen={text.Length} markdown={(markdown is not null ? "有" : "null")} | 首部: {text[..Math.Min(60, text.Length)]}"); break; case BridgeEvent.TurnCompleted: - Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms turn_completed | 首思考={firstThinking} 首 token={firstToken} | 累计文本={finalText?.Length ?? 0} 字符"); - Console.WriteLine("[probe] 回合文本: " + (finalText is { Length: > 0 } ? finalText[..Math.Min(200, finalText.Length)] : "(空)")); + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms turn_completed | 首思考={firstThinking} 首 token={firstToken} | token总长={tokenTotal} | assistant_message数={assistantMessages.Count}"); + foreach (var (msg, idx) in assistantMessages.Select((m, i) => (m, i + 1))) + Console.WriteLine($"[probe] 消息#{idx} 长度={msg.Length} 双倍检测={(msg.Length > 2 && msg[..(msg.Length / 2)] == msg[(msg.Length / 2)..] ? "前半==后半(内容双倍)" : "无")}"); return 0; case BridgeEvent.Error(var message): Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms ERROR: {message}"); diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index 1eba604e3..100753db8 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -175,7 +175,8 @@ public void BeginTurn() _spinner.HasActiveTools = false; } - /// 结束回合:idle + spinner 复位(消息由事件驱动,interrupted 不额外追加);子代理组定格脱离(未完成条目定格为非错误完成)。 + /// 结束回合:idle + spinner 复位(消息由事件驱动,interrupted 不额外追加);子代理组定格脱离(未完成条目定格为非错误完成)。 + /// spinner Mode 复位 Thinking→Responding(修复:回合结束后思考动画续转 — Thinking 态渲染器持续 shimmer)。 public void EndTurn() { IsBusy = false; @@ -185,6 +186,10 @@ public void EndTurn() _spinner.Message = string.Empty; _spinner.ResponseLength = 0; _spinner.HasActiveTools = false; + if (_spinner.Mode == SpinnerMode.Thinking) + { + _spinner.Mode = SpinnerMode.Responding; + } } /// 消息插入 spinner 之前(spinner 恒为滚动区最后一项)。 diff --git a/native_tui/src/NativeTui.Core/repl/PromptInputBox.cs b/native_tui/src/NativeTui.Core/repl/PromptInputBox.cs index 4b2179f84..35668f5cd 100644 --- a/native_tui/src/NativeTui.Core/repl/PromptInputBox.cs +++ b/native_tui/src/NativeTui.Core/repl/PromptInputBox.cs @@ -13,8 +13,9 @@ public sealed class PromptInputBox /// /// 渲染到指定帧区域:row 0 顶部分隔线,row 1 提示符 + 文本 + 光标(+ ghost 幽灵后缀),row 2 底部分隔线。 - /// cursorCol 为文本内偏移(0 = 首字符之前),光标屏幕列 = frame.X + PromptWidth + cursorCol。 - /// ghostSuffix 非空时以 dim 色渲染在文本之后(帧右界截断 — 基准 ghost text dim 提示)。 + /// cursorCol 为文本内偏移(0 = 首字符之前),光标屏幕列 = frame.X + PromptWidth + 显示宽度 + ///(修复:中文宽字符 — char 偏移须换算显示列,原直接当屏幕列致光标落进字符中间)。 + /// ghostSuffix 非空时以 dim 色渲染在文本之后(显示宽度换算起点,帧右界截断 — 基准 ghost text dim 提示)。 /// public void Render(Screen screen, Rect frame, Theme theme, string text, int cursorCol, bool busy = false, string ghostSuffix = "") { @@ -26,20 +27,22 @@ public void Render(Screen screen, Rect frame, Theme theme, string text, int curs var row = frame.Y + 1; screen.WriteText(row, frame.X, $"{Figures.Pointer} ", promptStyle, frame.Right); screen.WriteText(row, frame.X + PromptWidth, text, 0, frame.Right); + var textDisplayWidth = MeasureDisplayWidth(text); if (ghostSuffix.Length > 0) { var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); - screen.WriteText(row, frame.X + PromptWidth + text.Length, ghostSuffix, dim, frame.Right); + screen.WriteText(row, frame.X + PromptWidth + textDisplayWidth, ghostSuffix, dim, frame.Right); } - DrawCursor(screen, frame, row, cursorCol); + DrawCursor(screen, frame, row, text, cursorCol); } - /// 在光标屏幕列写入反色 cell(字符处反色该字符;行尾/空白处为反色空格块)。 - private static void DrawCursor(Screen screen, Rect frame, int row, int cursorCol) + /// 在光标屏幕列写入反色 cell(字符处反色该字符;行尾/空白处为反色空格块)— + /// char 偏移先换算显示宽度(CJK 每字 2 列)。 + private static void DrawCursor(Screen screen, Rect frame, int row, string text, int cursorCol) { var inverse = screen.Styles.GetOrAdd(new TextStyle { Inverse = true }); - var col = frame.X + PromptWidth + cursorCol; + var col = frame.X + PromptWidth + MeasureDisplayWidth(text.AsSpan(0, Math.Min(cursorCol, text.Length))); if (col < 0 || col >= frame.Right || col >= screen.Cols) { return; @@ -47,4 +50,16 @@ private static void DrawCursor(Screen screen, Rect frame, int row, int cursorCol screen[row, col] = screen[row, col] with { StyleId = inverse }; } + + /// 文本显示宽度(逐 rune 累加 RuneWidth — CJK/emoji 2 列,组合符 0 列)。 + internal static int MeasureDisplayWidth(ReadOnlySpan text) + { + var width = 0; + foreach (var rune in text.EnumerateRunes()) + { + width += RuneWidth.Of(rune); + } + + return width; + } } diff --git a/native_tui/src/NativeTui.Core/repl/ReplView.cs b/native_tui/src/NativeTui.Core/repl/ReplView.cs index cd8a74286..c4039b164 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplView.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplView.cs @@ -55,12 +55,13 @@ public int MeasureContentHeight(int cols) => /// /// 输入光标内容坐标(inline 模式帧尾光标位)— 行 = 输入文本行(内容底上方 footer+建议区+2), - /// 列 = PromptWidth + cursorCol(钳制行宽,右缘即 pending wrap 状态)。 + /// 列 = PromptWidth + 显示宽度(修复:中文宽字符 — char 偏移换算显示列,钳制行宽,右缘即 pending wrap 状态)。 /// public CursorPos InputCursorPos(int contentRows, int cols, int cursorCol) { var footerHeight = _footer.MeasureHeight(Footer); - return new CursorPos(Math.Min(cols, PromptInputBox.PromptWidth + cursorCol), contentRows - footerHeight - SuggestionHeight - 2); + var displayCol = PromptInputBox.MeasureDisplayWidth(Input.AsSpan(0, Math.Min(cursorCol, Input.Length))); + return new CursorPos(Math.Min(cols, PromptInputBox.PromptWidth + displayCol), contentRows - footerHeight - SuggestionHeight - 2); } /// 渲染整屏(消息区 → 输入框 → 建议区 → footer)。 diff --git a/native_tui/tests/NativeTui.Tests/repl/WideCharRenderTests.cs b/native_tui/tests/NativeTui.Tests/repl/WideCharRenderTests.cs new file mode 100644 index 000000000..ae2f58916 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/WideCharRenderTests.cs @@ -0,0 +1,60 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 宽字符输入渲染测试(修复:中文输入光标/ghost 错位)— CursorCol 是 UTF-16 char 偏移, +/// 屏幕列须按显示宽度换算(CJK 每字 2 列);PromptInputBox 光标列与 ghost 起点均须宽度换算 +///(原实现 cursorCol/text.Length 直当屏幕列,输入"你好"光标落进字符中间)。 +/// +public sealed class WideCharRenderTests +{ + private const int FrameX = 0; + private const int FrameY = 5; + + /// 显示宽度换算 — 中文 2 列/字,ASCII 1 列/字。 + [Theory] + [InlineData("abc", 3, 3)] + [InlineData("你好", 2, 4)] + [InlineData("a你b", 3, 4)] + public void DisplayWidth_MixedText_AccumulatesRuneWidth(string text, int charCount, int expectedWidth) + { + var width = 0; + foreach (var rune in text.EnumerateRunes()) + width += RuneWidth.Of(rune); + + Assert.Equal(charCount, text.Length); + Assert.Equal(expectedWidth, width); + } + + /// 光标屏幕列 — 中文文本光标偏移按显示宽度定位("你好"光标在末尾 = PromptWidth+4 非 +2)。 + [Fact] + public void PromptInputBox_CursorColumn_UsesDisplayWidth() + { + using var screen = new Screen(cols: 40, rows: 10); + var box = new PromptInputBox(); + + box.Render(screen, new Rect(FrameX, FrameY, 40, 3), Theme.Dark, "你好", cursorCol: 2); + + // "你"占 PromptWidth..+1(Wide+SpacerTail),"好"占 +2..+3,光标在 +4(反色块) + Assert.Equal(CellWidth.Wide, screen[FrameY + 1, PromptInputBox.PromptWidth].Width); + Assert.Equal(CellWidth.SpacerTail, screen[FrameY + 1, PromptInputBox.PromptWidth + 1].Width); + Assert.Equal(CellWidth.Wide, screen[FrameY + 1, PromptInputBox.PromptWidth + 2].Width); + Assert.Equal(CellWidth.SpacerTail, screen[FrameY + 1, PromptInputBox.PromptWidth + 3].Width); + // 光标反色块在 +4(原实现错画在 +2 — 落在"好"字符上) + var inverseStyle = screen.Styles.GetOrAdd(new TextStyle { Inverse = true }); + Assert.Equal(inverseStyle, screen[FrameY + 1, PromptInputBox.PromptWidth + 4].StyleId); + } + + /// ghost 起点 — 中文文本后 ghost 渲染不重叠(起点 = PromptWidth + 显示宽度)。 + [Fact] + public void PromptInputBox_GhostStart_AfterWideText() + { + using var screen = new Screen(cols: 40, rows: 10); + var box = new PromptInputBox(); + + box.Render(screen, new Rect(FrameX, FrameY, 40, 3), Theme.Dark, "你好", cursorCol: 2, ghostSuffix: "x"); + + // ghost 'x' 应在列 PromptWidth+4(宽度换算后),而非 char 数的 +2(会撞"好") + Assert.Equal(new Rune('x'), screen[FrameY + 1, PromptInputBox.PromptWidth + 4].Value); + Assert.Equal(CellWidth.Wide, screen[FrameY + 1, PromptInputBox.PromptWidth + 2].Width); + } +} From ae5a166764509afa0e2ab67947ce85c157438602 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 22:37:51 +0800 Subject: [PATCH 24/30] =?UTF-8?q?docs:=20TASK062=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E6=B6=88=E6=81=AF=E8=AE=BE=E8=AE=A1=E7=A8=BF=EF=BC=88?= =?UTF-8?q?=E6=B6=88=E6=81=AFID/=E6=80=9D=E8=80=83=E6=97=B6=E9=95=BF/CoT?= =?UTF-8?q?=E5=8F=AF=E5=B1=95=E5=BC=80/=E5=9B=9E=E5=A4=8D=EF=BC=89=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E5=9B=9B=E5=AD=97=E6=AE=B5=E5=90=91?= =?UTF-8?q?=E5=90=8E=E5=85=BC=E5=AE=B9=E6=89=A9=E5=B1=95=EF=BC=88Assistant?= =?UTF-8?q?Message=20=E6=9C=AB=E5=B0=BE=E5=8F=AF=E9=80=89=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=90=8C=20SessionInfo.Vendor=20=E5=85=88=E4=BE=8B?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=B8=B2=E6=9F=93=E5=BD=A2=E6=80=81=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=9F=BA=E5=87=86=EF=BC=88=E6=80=9D=E8=80=83dim?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E8=A1=8C+ctrl+o=E5=B1=95=E5=BC=80CoT?= =?UTF-8?q?=EF=BC=89=EF=BC=8CInProcessEventConsumer=E7=B4=AF=E7=A7=AF?= =?UTF-8?q?=E6=80=9D=E8=80=83=E6=96=87=E6=9C=ACOnDone=E7=BB=84=E8=A3=85?= =?UTF-8?q?=EF=BC=9BMessageId=E4=B8=BA=E5=9B=9E=E5=90=88=E5=BA=8F=E5=8F=B7?= =?UTF-8?q?=E9=9D=9E=E6=97=B6=E9=97=B4=E6=88=B3=EF=BC=88=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E4=B8=8D=E6=98=BE=E7=A4=BAID=E6=9C=AC=E8=BA=AB=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E8=A7=86=E8=A7=89=E5=99=AA=E9=9F=B3=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=E5=86=B3=E7=AD=96=E7=82=B9=E5=BE=85=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=A1=AE=E8=AE=A4=EF=BC=9A=E6=80=9D=E8=80=83=E8=A1=8C?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE/ctrl+o=E4=BD=9C=E7=94=A8=E5=9F=9F/=E6=97=A0?= =?UTF-8?q?=E6=80=9D=E8=80=83=E6=A8=A1=E5=9E=8B=E5=BD=A2=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...10\346\201\257\350\256\276\350\256\241.md" | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 "docs/proposed/task/TASK062-\347\273\223\346\236\204\345\214\226\346\266\210\346\201\257\350\256\276\350\256\241.md" diff --git "a/docs/proposed/task/TASK062-\347\273\223\346\236\204\345\214\226\346\266\210\346\201\257\350\256\276\350\256\241.md" "b/docs/proposed/task/TASK062-\347\273\223\346\236\204\345\214\226\346\266\210\346\201\257\350\256\276\350\256\241.md" new file mode 100644 index 000000000..3d12d61c1 --- /dev/null +++ "b/docs/proposed/task/TASK062-\347\273\223\346\236\204\345\214\226\346\266\210\346\201\257\350\256\276\350\256\241.md" @@ -0,0 +1,87 @@ +# TASK062 — 结构化消息(消息 ID / 思考时长 / CoT 可展开 / 回复) + +> 状态:designed(2026-10-21 设计稿待用户确认) +> 前置:三缺陷修复(8378ae808)完结;用户指令:"加入消息格式:消息 id,思考时间,CoT 可视化可点击查看详情,回复" + +## 现状差距(调研) + +| 层 | 现状 | 缺口 | +|----|------|------| +| BridgeEvent.AssistantMessage | Text + Markdown 两字段 | 无 MessageId / ThinkingDurationMs / ThinkingContent | +| InProcessEventConsumer | OnThinking 只发 started/ended 信号(思考内容丢弃) | CoT 内容不达 TUI | +| ThinkingEnded(DurationMs) | 时长已有 | 未挂到消息上(消费者两处状态拼接复杂) | +| AssistantMessageItem | ● + 正文 | 无思考前缀行、无展开交互 | +| 交互 | Ctrl+O 无绑定(diff 预览展开是 TODO 注释) | CoT 展开键位空缺 | + +## 设计方案(对齐基准 Claude Code 形态) + +### 渲染形态(目标视觉) + +``` +❯ 你是谁 + ⎿ 思考 3.2s · ctrl+o 展开推理 ← dim 行(有思考时;无思考不渲染) +● 我是由智谱 AI 训练的 GLM 大模型… ← 回复正文(现状不变) +``` + +展开态(Ctrl+O 切换最近一条消息): + +``` + ⎿ 思考 3.2s ▾ ← 展开标记 + 用户在询问我的身份,我应该介绍 GLM 系列… ← CoT 全文 dim 缩进(2 空格) +● 我是由智谱 AI 训练的 GLM 大模型… +``` + +### 数据链(四字段向后兼容扩展) + +1. **BridgeEvent.AssistantMessage** 末尾加可选参数: + `int MessageId = 0`(回合序号,消费者递增)、`int ThinkingDurationMs = 0`、`string? ThinkingContent = null` + —— 旧调用零破坏(默认值),与 SessionInfo.Vendor/LoggedIn 同先例 +2. **InProcessEventConsumer**:OnThinking 累积思考文本到 `_thinkingContent`;OnDone 组装 + AssistantMessage 时带上 MessageId(interlocked 自增)+ ThinkingDurationMs(首思考墙钟差,已有数据)+ ThinkingContent +3. **AssistantMessageItem**:加三个 init 字段;`MeasureHeight` 有思考 +1 行;渲染思考前缀行 + (`⎿ 思考 {s} · ctrl+o 展开推理` dim);`IsExpanded` 状态 + Toggle(Ctrl+O)控制 CoT 全文渲染(dim 2 空格缩进) +4. **ReplShell**:Ctrl+O 绑定 — 找最后一条带 ThinkingContent 的 AssistantMessageItem/MarkdownMessageItem 调 Toggle;刷新帧 +5. **bridge 子进程协议**(JSON):字段同名透传(JsonSerializer 默认含新字段,旧 TUI 读新引擎忽略未知字段兼容) + +### MessageId 语义 + +- 进程内:`InProcessEventConsumer` 构造时起始值注入(EngineTurnClient 每会话一个 consumer → 1,2,3…递增) +- 用途:Ctrl+O 定位 + transcript 落盘对账(后续扩展);**非时间戳**(终端 UI 不显示 ID 本身——视觉噪音,按基准) + +### 交互键位(对齐基准 Claude Code) + +| 键 | 行为 | +|----|------| +| Ctrl+O | 切换**最近一条**消息的思考块展开/收起(有 CoT 才响应) | + +### 不做(本期边界) + +- 每条消息独立 ID 显示(视觉噪音,基准无此形态——消息按时间序即可定位) +- 思考过程实时逐字流(引擎 OnThinking 有内容,但基准也是完成后折叠呈现——保持) +- 多条消息同时展开(基准单条聚焦语义) + +## 影响面清单 + +| 文件 | 改动 | +|------|------| +| native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs | AssistantMessage +3 可选字段 | +| native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs | 思考累积 + MessageId 自增 + OnDone 组装 | +| native_tui/src/NativeTui.Core/repl/MessageItems.cs | AssistantMessageItem +3 字段 + 思考行渲染 + 展开 | +| native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs | 同上(Markdown 路径同构) | +| native_tui/src/NativeTui.Core/repl/ReplShell.cs | Ctrl+O 键位绑定 | +| native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs | 事件新字段透传到消息项 | +| 引擎侧 BridgeEventConsumer(bridge 协议) | 同字段透传(保持两宿主一致) | + +## 验收表(实现后填) + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| AssistantMessage 3 字段链 | driver → 消息项 | 待做 | 待真机 | +| 思考前缀行渲染 | AssistantMessageItem/Markdown | 待做 | 待真机 | +| Ctrl+O 展开交互 | ReplShell 键位 | 待做 | 待真机 | + +## 决策点(需用户确认) + +1. **思考行位置**:回复上方独立 dim 行(本设计)vs 正文内嵌折叠头(基准 Claude Code 2024 形态)? +2. **Ctrl+O 作用域**:最近一条(本设计,基准语义)vs 光标选中消息(需结合消息光标导航)? +3. **无思考模型**(sensenova 非思考路径):完全无思考行(本设计)vs 显示"思考 0s"? From cf17c7bcab7575ae04f1990d11bd02f183bc5936 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 23:08:15 +0800 Subject: [PATCH 25/30] =?UTF-8?q?feat:=20TASK062=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E6=B6=88=E6=81=AF=EF=BC=88=E6=B6=88=E6=81=AFID/?= =?UTF-8?q?=E6=80=9D=E8=80=83=E6=97=B6=E9=95=BF/CoT=E5=8F=AF=E5=B1=95?= =?UTF-8?q?=E5=BC=80=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20AssistantMessage?= =?UTF-8?q?=20=E6=9C=AB=E5=B0=BE=E4=B8=89=E5=8F=AF=E9=80=89=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=90=91=E5=90=8E=E5=85=BC=E5=AE=B9=EF=BC=88MessageId?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=BA=A7=E9=80=92=E5=A2=9E+ThinkingDurationM?= =?UTF-8?q?s=E5=A4=9A=E6=AE=B5=E7=B4=AF=E8=AE=A1+ThinkingContent=E5=85=A8?= =?UTF-8?q?=E9=87=8F=EF=BC=89=EF=BC=8CInProcessEventConsumer=20OnThinking?= =?UTF-8?q?=E6=AF=8F=E6=AE=B5=E9=87=8D=E5=90=AF=E8=AE=A1=E6=97=B6=EF=BC=88?= =?UTF-8?q?=E5=A4=9A=E6=AE=B51500+1500=E6=B5=8B=E8=AF=95=E9=94=81=E5=AE=9A?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=B8=B2=E6=9F=93=E5=AF=B9=E9=BD=90=E5=9F=BA?= =?UTF-8?q?=E5=87=86=EF=BC=88=E2=8E=BF=E6=80=9D=E8=80=83Ns=C2=B7ctrl+o?= =?UTF-8?q?=E5=B1=95=E5=BC=80dim=E8=A1=8C+=E5=B1=95=E5=BC=80=E6=80=81CoT?= =?UTF-8?q?=E7=BC=A9=E8=BF=9B+200=E8=A1=8C=E5=AE=88=E5=8D=AB=EF=BC=89?= =?UTF-8?q?=EF=BC=8CCtrl+O=E7=BB=91=E5=AE=9AToggleLastThinking=EF=BC=88?= =?UTF-8?q?=E4=B8=A4=E7=B1=BB=E6=B6=88=E6=81=AF=E5=BC=95=E7=94=A8=E7=9B=B8?= =?UTF-8?q?=E7=AD=89=E7=B4=A2=E5=BC=95=E5=8F=96=E6=9B=B4=E9=9D=A0=E5=90=8E?= =?UTF-8?q?=E8=80=85=EF=BC=8C=E6=97=A0CoT=E9=9D=99=E9=BB=98=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E4=B8=89=E5=86=B3=E7=AD=96=E7=82=B9=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=85=A8=E6=8C=89=E6=8E=A8=E8=8D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...10\346\201\257\350\256\276\350\256\241.md" | 22 ++- .../NativeTui.Host/InProcessEventConsumer.cs | 44 +++-- native_tui/apps/NativeTui.Host/Program.cs | 4 +- .../src/NativeTui.Core/bridge/BridgeEvent.cs | 8 +- .../NativeTui.Core/bridge/ReplBridgeDriver.cs | 6 +- .../repl/MarkdownMessageItem.cs | 73 ++++++-- .../src/NativeTui.Core/repl/MessageItems.cs | 67 ++++++- .../NativeTui.Core/repl/MessageRenderer.cs | 28 +++ .../src/NativeTui.Core/repl/ReplShell.cs | 37 ++++ .../InProcessEventConsumerTests.cs | 41 +++++ .../repl/StructuredMessageTests.cs | 165 ++++++++++++++++++ 11 files changed, 458 insertions(+), 37 deletions(-) create mode 100644 native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs diff --git "a/docs/proposed/task/TASK062-\347\273\223\346\236\204\345\214\226\346\266\210\346\201\257\350\256\276\350\256\241.md" "b/docs/proposed/task/TASK062-\347\273\223\346\236\204\345\214\226\346\266\210\346\201\257\350\256\276\350\256\241.md" index 3d12d61c1..43b29a0bb 100644 --- "a/docs/proposed/task/TASK062-\347\273\223\346\236\204\345\214\226\346\266\210\346\201\257\350\256\276\350\256\241.md" +++ "b/docs/proposed/task/TASK062-\347\273\223\346\236\204\345\214\226\346\266\210\346\201\257\350\256\276\350\256\241.md" @@ -1,6 +1,6 @@ # TASK062 — 结构化消息(消息 ID / 思考时长 / CoT 可展开 / 回复) -> 状态:designed(2026-10-21 设计稿待用户确认) +> 状态:implemented(2026-10-21 三决策点用户确认全部按推荐后落地;待真机验收思考模型场景) > 前置:三缺陷修复(8378ae808)完结;用户指令:"加入消息格式:消息 id,思考时间,CoT 可视化可点击查看详情,回复" ## 现状差距(调研) @@ -76,12 +76,26 @@ | 基建实现 | 消费点 | 已实现 | 已验收 | |---------|--------|--------|--------| -| AssistantMessage 3 字段链 | driver → 消息项 | 待做 | 待真机 | -| 思考前缀行渲染 | AssistantMessageItem/Markdown | 待做 | 待真机 | -| Ctrl+O 展开交互 | ReplShell 键位 | 待做 | 待真机 | +| AssistantMessage 3 字段链 | driver → 消息项 | ✅ 8 测试 | 待真机(思考模型) | +| 思考前缀行渲染 | AssistantMessageItem/Markdown | ✅ | 待真机 | +| Ctrl+O 展开交互 | ReplShell 键位 | ✅ 2 测试 | 待真机 | ## 决策点(需用户确认) 1. **思考行位置**:回复上方独立 dim 行(本设计)vs 正文内嵌折叠头(基准 Claude Code 2024 形态)? 2. **Ctrl+O 作用域**:最近一条(本设计,基准语义)vs 光标选中消息(需结合消息光标导航)? 3. **无思考模型**(sensenova 非思考路径):完全无思考行(本设计)vs 显示"思考 0s"? + +## 实现基线(2026-10-21) + +- NativeTui.Tests:809 → **817**(+8:数据链 2 + 渲染 3 + Ctrl+O 2 + 兼容 1) +- Host.Tests:62 → **64**(+2:结构化组装 + 序号跨回合递增) +- 探针实证:id=1 thinkMs=0 cotLen=0(sensenova 非思考路径字段零值正确) +- 多段思考时长累计修复(OnThinking 每段重启计时 — 测试锁定 1500+1500) +- CoT 行数守卫 200 上限(刷屏防护) + + + + + + diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs index 7d99c2dbe..a14bf998b 100644 --- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs +++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs @@ -16,8 +16,11 @@ public sealed class InProcessEventConsumer : IResettableEventConsumer { private readonly ConcurrentQueue _events; private readonly Func _wallClock; private readonly StringBuilder _fullResponse = new(); + private readonly StringBuilder _thinkingContent = new(); private long? _thinkingStartMs; private bool _thinkingSignalled; + private int _thinkingTotalMs; + private int _messageId; /// 构造函数 — 自建队列(独立使用场景);注入墙钟(测试注入确定性时钟)。 public InProcessEventConsumer(Func? wallClock = null) @@ -30,11 +33,14 @@ public InProcessEventConsumer(ConcurrentQueue events, Func? w _wallClock = wallClock ?? (static () => Environment.TickCount64); } - /// 重置回合内状态(新一轮对话前由宿主调用,防跨回合泄漏;队列不清 — 回合边界由生命周期事件标记)。 + /// 重置回合内状态(新一轮对话前由宿主调用,防跨回合泄漏;队列不清 — 回合边界由生命周期事件标记)。 + /// 消息序号不重置(跨回合递增 — 会话级 MessageId)。 public void Reset() { _fullResponse.Clear(); + _thinkingContent.Clear(); _thinkingStartMs = null; _thinkingSignalled = false; + _thinkingTotalMs = 0; } /// 收到文本内容 → token_delta(length);思考结束时结算 thinking_ended。 @@ -46,13 +52,21 @@ public void OnText(string content) { _events.Enqueue(new BridgeEvent.TokenDelta(content.Length)); } - /// 收到思考内容 — 回合内首个思考发 thinking_started 并记录墙钟(回合内单次信号,Reset 后新回合再发)。 + /// 收到思考内容 — 回合内首个思考发 thinking_started(回合内单次信号,Reset 后新回合再发); + /// 多段思考(think→text→think)每段重启计时(时长累计语义);内容全量累积(TASK062 CoT)。 public void OnThinking(string thinking) { - if (thinking.Length == 0 || _thinkingSignalled) - return; - _thinkingSignalled = true; - _thinkingStartMs = _wallClock(); - _events.Enqueue(new BridgeEvent.ThinkingStarted()); + if (thinking.Length > 0) { + if (!_thinkingSignalled) { + _thinkingSignalled = true; + _events.Enqueue(new BridgeEvent.ThinkingStarted()); + } + + if (_thinkingStartMs is null) { + _thinkingStartMs = _wallClock(); + } + + _thinkingContent.Append(thinking); + } } /// 工具调用开始 → tool_call(name, args);思考结束时结算 thinking_ended。 @@ -84,7 +98,15 @@ public void OnDone(TokenUsage? usage, string? modelId) { if (_fullResponse.Length == 0) return; var fullText = _fullResponse.ToString(); - _events.Enqueue(new BridgeEvent.AssistantMessage(fullText, MarkdigProjector.ProjectOptional(fullText))); + // TASK062 结构化组装 — MessageId 会话级递增(Ctrl+O 定位/transcript 对账)+ + // 思考总时长(多段累计)+ CoT 全文(Ctrl+O 展开渲染;空 → null 不输出字段) + var thinking = _thinkingContent.ToString(); + _events.Enqueue(new BridgeEvent.AssistantMessage( + fullText, + MarkdigProjector.ProjectOptional(fullText), + MessageId: ++_messageId, + ThinkingDurationMs: _thinkingTotalMs, + ThinkingContent: thinking.Length > 0 ? thinking : null)); } /// 子代理启动 → SubagentStarted(DSG041 批4 — 显式实现:DIM 默认实现经类类型调用不可见; @@ -114,12 +136,14 @@ public IReadOnlyList DrainEvents() { /// 写入一条事件 — 无界 Channel TryWrite 永远成功(回合线程不阻塞)。 private void WriteEvent(BridgeEvent bridgeEvent) => _events.Enqueue(bridgeEvent); - /// 思考结束结算(进入产出或回合结束时调用,幂等)。 + /// 思考结束结算(进入产出或回合结束时调用,幂等)— 段时长累计入回合思考总时长(TASK062)。 private void EndThinkingIfActive() { if (_thinkingStartMs is not long start) return; _thinkingStartMs = null; - WriteEvent(new BridgeEvent.ThinkingEnded(Math.Max(0, _wallClock() - start))); + var duration = Math.Max(0, _wallClock() - start); + _thinkingTotalMs += (int)Math.Min(int.MaxValue, duration); + WriteEvent(new BridgeEvent.ThinkingEnded(duration)); } /// 工具结果摘要:error 前缀 + 截断。 diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 2465df31f..7b738e523 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -67,9 +67,9 @@ private static async Task RunSelftestTurnAsync() { } tokenTotal += delta; break; - case BridgeEvent.AssistantMessage(var text, var markdown): + case BridgeEvent.AssistantMessage(var text, var markdown, var msgId, var thinkMs, var cot): assistantMessages.Add(text); - Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms assistant_message #{assistantMessages.Count} | textLen={text.Length} markdown={(markdown is not null ? "有" : "null")} | 首部: {text[..Math.Min(60, text.Length)]}"); + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms assistant_message #{assistantMessages.Count} | id={msgId} thinkMs={thinkMs} cotLen={cot?.Length ?? 0} | textLen={text.Length} markdown={(markdown is not null ? "有" : "null")} | 首部: {text[..Math.Min(60, text.Length)]}"); break; case BridgeEvent.TurnCompleted: Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms turn_completed | 首思考={firstThinking} 首 token={firstToken} | token总长={tokenTotal} | assistant_message数={assistantMessages.Count}"); diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index e5dd5dbbc..eeae7fb2a 100644 --- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs +++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs @@ -67,9 +67,15 @@ public sealed record TokenDelta(int Length) : BridgeEvent; /// /// 完整助手消息(追加滚动区)。Markdown 非空时渲染为块模型(M7-4); /// null 不输出字段(WhenWritingNull — 引擎侧旧协议逐字节一致,降级纯文本路径)。 + /// TASK062 结构化:MessageId(回合序号 — Ctrl+O 定位/transcript 对账,UI 不显示数字本身)/ + /// ThinkingDurationMs(思考时长 — 0 = 无思考不渲染思考行)/ ThinkingContent(CoT 全文 — + /// null 不输出字段,Ctrl+O 展开渲染);三者末尾可选参数向后兼容(同 SessionInfo.Vendor 先例)。 /// public sealed record AssistantMessage(string Text, - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] MarkdownDocument? Markdown = null) : BridgeEvent; + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] MarkdownDocument? Markdown = null, + int MessageId = 0, + int ThinkingDurationMs = 0, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ThinkingContent = null) : BridgeEvent; /// 回合完成(idle)。 public sealed record TurnCompleted() : BridgeEvent; diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index 100753db8..1c50bf98c 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -99,10 +99,10 @@ public void Apply(BridgeEvent bridgeEvent) _spinner.HasActiveTools = false; _spinner.ResponseLength += length; break; - case BridgeEvent.AssistantMessage(var text, var markdown): + case BridgeEvent.AssistantMessage(var text, var markdown, var messageId, var thinkingDurationMs, var thinkingContent): AddMessage(markdown is not null - ? new MarkdownMessageItem(markdown, _codeHighlighter) - : new AssistantMessageItem(text)); + ? new MarkdownMessageItem(markdown, _codeHighlighter, thinkingDurationMs, thinkingContent, messageId) + : new AssistantMessageItem(text, thinkingDurationMs, thinkingContent, messageId)); break; case BridgeEvent.TurnCompleted: EndTurn(); diff --git a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs index bc3faf4f6..a97402902 100644 --- a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs +++ b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs @@ -6,33 +6,66 @@ namespace NativeTui.Core.Repl; /// 物理行由 MarkdownLayout 按 width-2 缓存布局(宽度变化才重算;高亮随布局一次性算好缓存); /// 段样式经 MessageRenderer.MapMarkdownStyle 映射(Code → permission 色,对齐基准 codespan); /// 代码行高亮在段写完后按色段覆写前景(M7-5 — 高亮器由宿主注入,null = 降级纯文本)。 +/// TASK062 结构化:思考时长 > 0 时正文上方 dim 思考摘要行 + Ctrl+O 展开 CoT(同 AssistantMessageItem 语义)。 /// -public sealed class MarkdownMessageItem(MarkdownDocument document, ICodeHighlighter? highlighter = null) : IScrollItem, ICursorNavigable +public sealed class MarkdownMessageItem(MarkdownDocument document, ICodeHighlighter? highlighter = null, int thinkingDurationMs = 0, string? thinkingContent = null, int messageId = 0) : IScrollItem, ICursorNavigable { + /// 思考内容行数上限守卫(超限折叠截断提示 — CoT 刷屏防护)。 + private const int MaxThinkingLines = 200; + /// Markdown 正文缩进列(● + 空格,对齐基准 NoSelect minWidth 2)。 private const int BodyIndentCols = 2; /// 块模型文档(暴露供宿主断言与演示构造)。 public MarkdownDocument Document { get; } = document; - /// 缓存的布局宽度(-1 = 未布局)。 - private int _layoutWidth = -1; + /// 思考总时长 ms(0 = 无思考不渲染思考行)。 + public int ThinkingDurationMs => thinkingDurationMs; - /// 缓存的布局结果(行 + 逐行高亮)。 - private MarkdownLayoutResult? _layout; + /// CoT 全文(null = 无内容不可展开)。 + public string? ThinkingContent => thinkingContent; + + /// 消息序号(会话级 — Ctrl+O 定位/transcript 对账)。 + public int MessageId => messageId; - /// 内容高度 = margin 1 + 布局行数(空文档至少 1 行圆点行)。 - public int MeasureHeight(int width) => 1 + Math.Max(1, LayoutFor(width).Lines.Count); + /// 思考块展开态(Ctrl+O 切换)。 + public bool IsThinkingExpanded { get; private set; } - /// 渲染:margin 行留空 → ● text 色(col0)→ 各物理行从 col2 起逐段写 → 代码行覆写高亮前景。 + /// 切换思考块展开态(有 CoT 才有效)。 + public void ToggleThinking() { + if (thinkingContent is { Length: > 0 }) { + IsThinkingExpanded = !IsThinkingExpanded; + } + } + + /// 内容高度 = margin 1 + 布局行数(空文档至少 1 行圆点行)+ 思考摘要行(有思考)+ CoT 行数(展开态)。 + public int MeasureHeight(int width) => + 1 + Math.Max(1, LayoutFor(width).Lines.Count) + + (thinkingDurationMs > 0 ? 1 : 0) + + (IsThinkingExpanded ? CountThinkingLines() : 0); + + /// 渲染:margin 行留空 → 思考摘要行(有思考)→ CoT(展开态)→ ● text 色(col0)→ 各物理行从 col2 起逐段写 → 代码行覆写高亮前景。 public void Render(Screen screen, Rect frame, Theme theme) { + var row = frame.Y + 1; + if (thinkingDurationMs > 0) + { + MessageRenderer.RenderThinkingSummary(screen, frame with { Y = row++, Height = 1 }, theme, thinkingDurationMs, IsThinkingExpanded); + } + + if (IsThinkingExpanded && thinkingContent is { Length: > 0 }) + { + var lines = CountThinkingLines(); + MessageRenderer.RenderThinkingContent(screen, frame with { Y = row, Height = lines }, theme, TruncateThinking()); + row += lines; + } + var text = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Text }); - screen.WriteText(frame.Y + 1, frame.X, Figures.MessageBullet, text, frame.Right); + screen.WriteText(row, frame.X, Figures.MessageBullet, text, frame.Right); var layout = LayoutFor(frame.Width); for (var index = 0; index < layout.Lines.Count; index++) { - var lineFrame = frame with { Y = frame.Y + 1 + index, Height = 1 }; + var lineFrame = frame with { Y = row + index, Height = 1 }; MessageRenderer.RenderMarkdownLine(screen, lineFrame, theme, BodyIndentCols, layout.Lines[index]); if (layout.Highlights[index] is { Spans.Count: > 0 } highlight) { @@ -41,6 +74,20 @@ public void Render(Screen screen, Rect frame, Theme theme) } } + /// CoT 展开行数(含超限截断行)。 + private int CountThinkingLines() => TruncateThinking().Split('\n').Length; + + /// CoT 截断守卫 — 超行数上限时截断并附提示行。 + private string TruncateThinking() { + if (thinkingContent is not { Length: > 0 }) + return string.Empty; + + var lines = thinkingContent.Split('\n'); + return lines.Length > MaxThinkingLines + ? string.Join("\n", lines[..MaxThinkingLines]) + $"\n… 思考内容超 {MaxThinkingLines} 行已截断" + : thinkingContent; + } + /// 按宽度取缓存布局(内容宽 = width - 2 缩进;高亮随布局缓存 — 宽度不变不重算)。 private MarkdownLayoutResult LayoutFor(int width) { @@ -52,4 +99,10 @@ private MarkdownLayoutResult LayoutFor(int width) return _layout; } + + /// 缓存的布局宽度(-1 = 未布局)。 + private int _layoutWidth = -1; + + /// 缓存的布局结果(行 + 逐行高亮)。 + private MarkdownLayoutResult? _layout; } diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs index 50961e8a1..875f2e639 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs @@ -19,18 +19,71 @@ public void Render(Screen screen, Rect frame, Theme theme) => MessageRenderer.RenderUser(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, text); } -/// 助手文本消息项(● text 色 + 正文)。 -public sealed class AssistantMessageItem(string text) : IScrollItem, ICursorNavigable +/// 助手文本消息项(● text 色 + 正文)。TASK062 结构化:思考时长 > 0 时正文上方渲染 +/// dim 思考摘要行(⎿ 思考 {s} · ctrl+o 展开推理);ThinkingContent 非空支持 Ctrl+O 展开 CoT 全文 +///(展开态 ▾ + dim 缩进逐行);MessageId 会话序号(定位/对账,UI 不显示)。 +public sealed class AssistantMessageItem(string text, int thinkingDurationMs = 0, string? thinkingContent = null, int messageId = 0) : IScrollItem, ICursorNavigable { + /// 思考内容行数上限守卫(超限折叠为截断提示 — CoT 刷屏防护)。 + private const int MaxThinkingLines = 200; + /// 正文(宿主反馈消息断言面 — M7-6 模型切换反馈等)。 public string Text => text; - /// 内容高度 = margin 1 + 正文 1。 - public int MeasureHeight(int width) => 2; + /// 思考总时长 ms(0 = 无思考不渲染思考行)。 + public int ThinkingDurationMs => thinkingDurationMs; - /// margin 行留空,正文画在 frame.Y+1。 - public void Render(Screen screen, Rect frame, Theme theme) => - MessageRenderer.RenderAssistant(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, text); + /// CoT 全文(null = 无内容不可展开)。 + public string? ThinkingContent => thinkingContent; + + /// 消息序号(会话级 — Ctrl+O 定位/transcript 对账)。 + public int MessageId => messageId; + + /// 思考块展开态(Ctrl+O 切换 — Toggle)。 + public bool IsThinkingExpanded { get; private set; } + + /// 切换思考块展开态(有 CoT 才有效;高度变化由下次 MeasureHeight 反映)。 + public void ToggleThinking() { + if (thinkingContent is { Length: > 0 }) { + IsThinkingExpanded = !IsThinkingExpanded; + } + } + + /// 内容高度 = margin 1 + 正文 1 + 思考摘要行(有思考)+ CoT 行数(展开态,含截断守卫)。 + public int MeasureHeight(int width) => + 2 + + (thinkingDurationMs > 0 ? 1 : 0) + + (IsThinkingExpanded ? CountThinkingLines() : 0); + + /// margin 行留空 → 思考摘要行(有思考)→ CoT 全文(展开态)→ 正文。 + public void Render(Screen screen, Rect frame, Theme theme) { + var row = frame.Y + 1; + if (thinkingDurationMs > 0) { + MessageRenderer.RenderThinkingSummary(screen, frame with { Y = row++, Height = 1 }, theme, thinkingDurationMs, IsThinkingExpanded); + } + + if (IsThinkingExpanded && thinkingContent is { Length: > 0 }) { + var lines = CountThinkingLines(); + MessageRenderer.RenderThinkingContent(screen, frame with { Y = row, Height = lines }, theme, TruncateThinking()); + row += lines; + } + + MessageRenderer.RenderAssistant(screen, frame with { Y = row, Height = 1 }, theme, text); + } + + /// CoT 展开行数(含超限截断行)。 + private int CountThinkingLines() => TruncateThinking().Split('\n').Length; + + /// CoT 截断守卫 — 超行数上限时截断并附提示行。 + private string TruncateThinking() { + if (thinkingContent is not { Length: > 0 }) + return string.Empty; + + var lines = thinkingContent.Split('\n'); + return lines.Length > MaxThinkingLines + ? string.Join("\n", lines[..MaxThinkingLines]) + $"\n… 思考内容超 {MaxThinkingLines} 行已截断" + : thinkingContent; + } } /// 工具调用消息项(● + bold 工具名 + (参数摘要))。 diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs index f380192dc..7363bc364 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs @@ -55,6 +55,34 @@ public static void RenderToolResult(Screen screen, Rect frame, Theme theme, stri screen.WriteText(frame.Y, frame.X + Figures.ToolResultIndent.Length, text, 0, frame.Right); } + /// 渲染思考摘要行(TASK062 结构化消息):⎿ 前缀 + "思考 {秒} · ctrl+o 展开推理"(收起态)/ + /// "思考 {秒} ▾"(展开态),整行 dim(基准思考块折叠形态;时长 <1s 显示小数)。 + public static void RenderThinkingSummary(Screen screen, Rect frame, Theme theme, int durationMs, bool expanded) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + var seconds = durationMs / 1000.0; + var label = seconds >= 1 + ? $"思考 {seconds:F0}s" + : $"思考 {seconds:F1}s"; + var suffix = expanded ? " ▾" : " · ctrl+o 展开推理"; + screen.WriteText(frame.Y, frame.X, $"{Figures.ToolResultIndent}{label}{suffix}", dim, frame.Right); + } + + /// 渲染思考内容行(展开态 CoT 全文):⎿ 前缀 + 2 空格缩进 dim 正文(逐行 — 换行拆分多行渲染)。 + public static void RenderThinkingContent(Screen screen, Rect frame, Theme theme, string content) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + var indent = $"{Figures.ToolResultIndent} "; + var row = frame.Y; + foreach (var line in content.Split('\n')) { + var text = line.TrimEnd('\r'); + if (text.Length > 0) { + screen.WriteText(row, frame.X, $"{indent}{text}", dim, frame.Right); + } + row++; + } + } + /// 渲染错误消息:● + 空格 + 正文,全部 theme.Error 红色(bridge error 事件)。 public static void RenderError(Screen screen, Rect frame, Theme theme, string text) { diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 7fdbd8a5f..682e10e2e 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -497,6 +497,9 @@ private void HandleCtrlKey(string name) case "r": BeginOrCycleSearch(); break; + case "o": + ToggleLastThinking(); + break; case "n": ScrollIfViewport(3); break; @@ -968,6 +971,40 @@ internal void CopyLastAssistantMessage() PostInfo("已复制到剪贴板"); } + /// Ctrl+O 切换最近一条消息的思考块展开/收起(TASK062 结构化消息 — 基准单条聚焦语义): + /// 找最后一条带 CoT 的消息 Toggle(Assistant/Markdown 两类取滚动区更靠后者); + /// 无 CoT 无操作(静默 — 非思考模型路径);高度变化由下次 MeasureHeight 反映(渲染循环自动重排)。 + internal void ToggleLastThinking() + { + var items = _view.Messages.Items; + var markdownIndex = IndexOfItem(items, items.OfType().LastOrDefault(m => m.ThinkingContent is { Length: > 0 })); + var plainIndex = IndexOfItem(items, items.OfType().LastOrDefault(m => m.ThinkingContent is { Length: > 0 })); + if (markdownIndex < 0 && plainIndex < 0) + { + return; + } + + if (markdownIndex > plainIndex) + { + ((MarkdownMessageItem)items[markdownIndex]).ToggleThinking(); + } + else + { + ((AssistantMessageItem)items[plainIndex]).ToggleThinking(); + } + } + + /// 滚动区项索引(引用相等查找;null = -1)。 + private static int IndexOfItem(IReadOnlyList items, IScrollItem? target) { + if (target is null) + return -1; + for (var i = items.Count - 1; i >= 0; i--) { + if (ReferenceEquals(items[i], target)) + return i; + } + return -1; + } + /// Shift+Tab 循环权限模式(jcc 引擎模式集合)— 发引擎应用请求,footer 以 permission_mode_changed 回执为准。 internal void CyclePermissionMode() { diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs index dc43cb029..10a57f780 100644 --- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs @@ -246,4 +246,45 @@ public void OnDone_PlainTextKeepsMarkdownNull() var message = Assert.IsType(events[^1]); Assert.Null(message.Markdown); } + + /// TASK062 结构化组装 — OnThinking 全量累积 CoT + OnDone 带 MessageId/思考时长/内容; + /// 多段思考(think→text→think)时长累计。 + [Fact] + public void OnDone_AssemblesStructuredFields_ThinkingAccumulated() + { + var clock = 0L; + var consumer = new InProcessEventConsumer(() => clock); + + clock = 1000; + consumer.OnThinking("第一段推理"); + clock = 2500; + consumer.OnText("中间产出"); + consumer.OnThinking("第二段推理"); + clock = 4000; + consumer.OnDone(null, "m"); + + var events = consumer.DrainEvents(); + var message = Assert.IsType(events[^1]); + Assert.Equal(1, message.MessageId); + Assert.Equal(1500 + 1500, message.ThinkingDurationMs); + Assert.Equal("第一段推理第二段推理", message.ThinkingContent); + } + + /// TASK062 — 消息序号跨回合递增(Reset 不清);无思考回合字段为默认值。 + [Fact] + public void MessageId_IncrementsAcrossTurns_NoThinkingDefaults() + { + var consumer = new InProcessEventConsumer(); + + consumer.OnText("第一回合"); + consumer.OnDone(null, null); + consumer.Reset(); + consumer.OnText("第二回合"); + consumer.OnDone(null, null); + + var all = consumer.DrainEvents().OfType().ToList(); + Assert.Equal([1, 2], all.Select(m => m.MessageId)); + Assert.All(all, m => Assert.Equal(0, m.ThinkingDurationMs)); + Assert.All(all, m => Assert.Null(m.ThinkingContent)); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs b/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs new file mode 100644 index 000000000..6a0754bfb --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs @@ -0,0 +1,165 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 结构化消息测试(TASK062 — 消息 ID/思考时长/CoT 可展开)— +/// 数据链(AssistantMessage 三字段 → 消息项构造)、渲染(思考摘要行/展开 CoT)、 +/// 交互(Ctrl+O 切换最近一条;无 CoT 静默)、组装(InProcessEventConsumer 思考累积+序号递增)。 +/// +public sealed class StructuredMessageTests +{ + private const int FrameX = 0; + + // ---- 数据链 ---- + + /// AssistantMessage 事件三字段 — 旧调用(仅 Text/Markdown)默认值零破坏。 + [Fact] + public void AssistantMessageEvent_NewFields_DefaultBackwardCompatible() + { + var legacy = new BridgeEvent.AssistantMessage("hi"); + var rich = new BridgeEvent.AssistantMessage("hi", Markdown: null, MessageId: 3, ThinkingDurationMs: 3200, ThinkingContent: "思考过程"); + + Assert.Equal(0, legacy.MessageId); + Assert.Equal(0, legacy.ThinkingDurationMs); + Assert.Null(legacy.ThinkingContent); + Assert.Equal(3, rich.MessageId); + Assert.Equal(3200, rich.ThinkingDurationMs); + Assert.Equal("思考过程", rich.ThinkingContent); + } + + /// driver 透传 — 事件字段进消息项(Markdown null 路径 AssistantMessageItem)。 + [Fact] + public void Driver_AssistantMessageFields_ProjectToItem() + { + 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.AssistantMessage("回复正文", Markdown: null, MessageId: 2, ThinkingDurationMs: 1500, ThinkingContent: "先分析再回答")); + + var item = view.Messages.Items.OfType().Single(); + Assert.Equal(2, item.MessageId); + Assert.Equal(1500, item.ThinkingDurationMs); + Assert.Equal("先分析再回答", item.ThinkingContent); + } + + // ---- 渲染 ---- + + /// 有思考 — 高度含思考摘要行;渲染含"思考"dim 行与正文。 + [Fact] + public void Render_WithThinking_ShowsSummaryRowBeforeBody() + { + using var screen = new Screen(cols: 40, rows: 12); + var item = new AssistantMessageItem("正文", thinkingDurationMs: 3200, thinkingContent: "CoT", messageId: 1); + + Assert.Equal(3, item.MeasureHeight(40)); + item.Render(screen, new Rect(FrameX, 2, 40, 3), Theme.Dark); + + var row1 = screen.RowText(3); + Assert.Contains("思考", row1); + Assert.Contains("3s", row1); + Assert.Contains("ctrl+o", row1); + var row2 = screen.RowText(4); + Assert.Contains("正文", row2); + } + + /// 无思考 — 高度不变(2 行),无思考行渲染。 + [Fact] + public void Render_WithoutThinking_NoSummaryRow() + { + using var screen = new Screen(cols: 40, rows: 12); + var item = new AssistantMessageItem("正文"); + + Assert.Equal(2, item.MeasureHeight(40)); + item.Render(screen, new Rect(FrameX, 2, 40, 2), Theme.Dark); + + Assert.DoesNotContain("思考", screen.RowText(3)); + Assert.Contains("正文", screen.RowText(3)); + } + + /// 展开态 — CoT 全文渲染 + 高度增长;Toggle 来回收复。 + [Fact] + public void Toggle_ExpandedCoT_RendersAndMeasures() + { + using var screen = new Screen(cols: 60, rows: 16); + var item = new AssistantMessageItem("正文", thinkingDurationMs: 500, thinkingContent: "第一行推理\n第二行推理", messageId: 1); + + item.ToggleThinking(); + Assert.True(item.IsThinkingExpanded); + Assert.Equal(2 + 1 + 2, item.MeasureHeight(60)); + item.Render(screen, new Rect(FrameX, 2, 60, 5), Theme.Dark); + + var combined = screen.RowText(3) + screen.RowText(4) + screen.RowText(5); + Assert.Contains("第一行推理", combined); + Assert.Contains("第二行推理", combined); + Assert.Contains("▾", screen.RowText(3)); + + item.ToggleThinking(); + Assert.False(item.IsThinkingExpanded); + Assert.Equal(3, item.MeasureHeight(60)); + } + + /// 无 CoT — Toggle 无操作(时长有但内容空不可展开)。 + [Fact] + public void Toggle_NoContent_NoOp() + { + var item = new AssistantMessageItem("正文", thinkingDurationMs: 100); + + item.ToggleThinking(); + + Assert.False(item.IsThinkingExpanded); + } + + // ---- 交互(ReplShell Ctrl+O) ---- + + /// Ctrl+O — 切换最近一条带 CoT 消息(含多条时取最后)。 + [Fact] + public void CtrlO_TogglesLastMessageWithCoT() + { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("0", "m", "b", "C:\\"), new StringWriter()); + shell.SeedContent(); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("第一条", MessageId: 1, ThinkingDurationMs: 100, ThinkingContent: "CoT1")); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("第二条", MessageId: 2, ThinkingDurationMs: 200, ThinkingContent: "CoT2")); + shell.DrainEngine(); + + shell.HandleKey(new ConsoleKeyInfo('\x0f', ConsoleKey.O, false, false, false)); + + var items = shell.View.Messages.Items.OfType().ToList(); + Assert.False(items[0].IsThinkingExpanded); + Assert.True(items[1].IsThinkingExpanded); + } + + /// Ctrl+O 无 CoT — 静默无操作(非思考模型路径不弹错误)。 + [Fact] + public void CtrlO_NoCoTAnywhere_SilentNoOp() + { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("0", "m", "b", "C:\\"), new StringWriter()); + shell.SeedContent(); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("无思考回复")); + shell.DrainEngine(); + + shell.HandleKey(new ConsoleKeyInfo('\x0f', ConsoleKey.O, false, false, false)); + + var item = shell.View.Messages.Items.OfType().Single(); + Assert.False(item.IsThinkingExpanded); + } +} + +/// Screen 行文本辅助(测试断言用 — 拼接该行字符;SpacerTail 跳过防宽字符双份)。 +public static class ScreenTestExtensions +{ + public static string RowText(this Screen screen, int row) { + var sb = new System.Text.StringBuilder(); + for (var col = 0; col < screen.Cols; col++) { + var cell = screen[row, col]; + if (cell.Width == CellWidth.SpacerTail) + continue; + sb.Append(cell.Value.ToString()); + } + return sb.ToString().TrimEnd(); + } +} From 9bdb2d40e508e6787dacc409f640d7b4b6049a23 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 23:25:09 +0800 Subject: [PATCH 26/30] =?UTF-8?q?fix:=20=E6=8E=92=E9=98=9F=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=94=B9=E8=BE=93=E5=85=A5=E6=A0=8F=E4=B8=8A=E6=96=B9?= =?UTF-8?q?=20queued=20=E9=9D=A2=E6=9D=BF=EF=BC=88=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E8=AE=BE=E8=AE=A1=EF=BC=89=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20=E7=94=A8=E6=88=B7=E9=AA=8C=E6=94=B6=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E6=BB=9A=E5=8A=A8=E5=8C=BA=E6=8E=92=E9=98=9F=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E7=A0=B4=E5=9D=8F=E6=B6=88=E6=81=AF=E6=B5=81=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=BF=99=E6=97=B6=E6=8F=90=E4=BA=A4=E4=B8=8D=E4=B8=8A?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E5=8C=BA=EF=BC=88=E7=94=A8=E6=88=B7=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=8F=91=E9=80=81=E6=97=B6=E5=88=BB=E6=89=8D=E8=BF=9B?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8C=BA=EF=BC=89=EF=BC=8CReplView=20?= =?UTF-8?q?=E5=A2=9E=20QueuedInputs=20=E9=9D=A2=E6=9D=BF=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=9C=A8=E8=BE=93=E5=85=A5=E6=A1=86=E4=B8=8A=E6=96=B9=EF=BC=88?= =?UTF-8?q?=E2=9D=AF=20=E6=96=87=E6=9C=AC=20dim=20=E9=80=90=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E6=AC=A1=E6=9D=A1=E8=B5=B7=E5=B8=A6=20(queued)=20?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=EF=BC=8C=E9=A6=96=E6=9D=A1=E5=8D=B3=E5=B0=86?= =?UTF-8?q?=E5=8F=91=E9=80=81=E9=AB=98=E4=BA=AE=E8=AF=AD=E4=B9=89=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E9=AB=98=E5=BA=A6=E8=AE=A1=E5=85=A5=20TailHeight/Meas?= =?UTF-8?q?ureContentHeight=20=E5=B8=83=E5=B1=80=E9=93=BE=EF=BC=8CSubmit/P?= =?UTF-8?q?ump=20=E5=8D=B3=E6=97=B6=E5=90=8C=E6=AD=A5=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E4=B8=8D=E7=AD=89=E6=B8=B2=E6=9F=93=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/NativeTui.Core/repl/ReplShell.cs | 24 +++++++---- .../src/NativeTui.Core/repl/ReplView.cs | 40 ++++++++++++++++--- .../NativeTui.Tests/repl/QueuedSubmitTests.cs | 23 ++++++----- 3 files changed, 63 insertions(+), 24 deletions(-) diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 682e10e2e..fc60ed56e 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -1012,9 +1012,10 @@ internal void CyclePermissionMode() _engine.SetPermissionMode(PermissionModeInfo.ToValue(next)); } - /// 提交输入(TASK060 一问一答节奏):空文本忽略;未登录本地反馈(基准 ⎿ Not logged in — - /// 不调引擎不傻等);忙时改排队(基准 queued 语义)— 用户消息上屏 + ⎿ 已排队 dim 行 - /// (ToolResultItem 复用)、入历史环,回合结束自动按序发送(DrainEngine 队列泵);闲时正常直发。 + /// 提交输入(TASK060 一问一答节奏 + TASK062 后续排队面板):空文本忽略;未登录本地反馈 + /// (基准 ⎿ Not logged in — 不调引擎不傻等);忙时改排队(基准 queued 语义)— **不上滚动区** + ///(输入栏上方 queued 面板渲染 — TASK062 用户验收反馈:滚动区排队提示破坏消息流),入历史环, + /// 回合结束自动按序发送(DrainEngine 队列泵 — 发送时才上屏用户消息);闲时正常直发。 internal void Submit() { var text = _input.Text; @@ -1026,9 +1027,9 @@ internal void Submit() _input.Clear(); _history.Add(text); _history.Reset(); - AddMessage(new UserMessageItem(text)); if (!LoginGateOpen) { + AddMessage(new UserMessageItem(text)); AddMessage(new ToolResultItem("未登录 · 请在 auth.json 配置有效 API key 或运行 /login 后重试")); return; } @@ -1038,19 +1039,22 @@ internal void Submit() if (_queuedInputs.Count >= MaxQueuedInputs) { AddMessage(new ToolResultItem($"排队已满({MaxQueuedInputs} 条上限)— 本条丢弃,请等当前回合结束")); + _view.QueuedInputs = _queuedInputs; return; } _queuedInputs.Add(text); - AddMessage(new ToolResultItem("已排队,当前回合结束后自动发送")); + _view.QueuedInputs = _queuedInputs; return; } + AddMessage(new UserMessageItem(text)); _engine.Submit(text); } - /// 排队输入发送泵(DrainEngine 回合结束时调用)— 非忙态且有排队时取队首直发引擎 - ///(用户消息排队时已上屏,此处不重复);逐回合发一条维持一问一答节奏。 + /// 排队输入发送泵(DrainEngine 回合结束时调用)— 非忙态且有排队时取队首: + /// 上屏用户消息(发送时才进滚动区 — 排队期间只在输入栏上方面板)+ 直发引擎; + /// 逐回合发一条维持一问一答节奏。 private void PumpQueuedInputs() { if (IsBusy || _queuedInputs.Count == 0) @@ -1058,8 +1062,11 @@ private void PumpQueuedInputs() return; } - _engine.Submit(_queuedInputs[0]); + var text = _queuedInputs[0]; _queuedInputs.RemoveAt(0); + AddMessage(new UserMessageItem(text)); + _view.QueuedInputs = _queuedInputs; + _engine.Submit(text); } /// 渲染一帧并差分上屏:inline 走相对光标 + append(历史滚入 scrollback)+ DEC 2026 原子上屏;视口走绝对定位 diff。 @@ -1132,6 +1139,7 @@ private void SyncSuggestionState() _view.SuggestionNameColumnWidth = _suggester?.NameColumnWidth ?? 0; _view.SuggestionArgumentMode = _suggester?.IsArgumentMode ?? false; _view.GhostSuffix = _search is { Current.Length: > 0 } ? _search.Current : _suggester?.GhostSuffix ?? string.Empty; + _view.QueuedInputs = _queuedInputs; } /// 同步消息光标选中高亮到滚动区(渲染前状态投影 — ScrollBox 渲染后整块背景覆写)。 diff --git a/native_tui/src/NativeTui.Core/repl/ReplView.cs b/native_tui/src/NativeTui.Core/repl/ReplView.cs index c4039b164..ca7e1b61b 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplView.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplView.cs @@ -40,18 +40,25 @@ public sealed class ReplView /// footer 状态(模式/busy/agents 提示/登录错误/通知)。 public FooterState Footer { get; set; } = new(); + /// 排队待发送输入(TASK062 后续 — 基准 queued 面板:输入栏上方逐行 dim 渲染,不上滚动区; + /// 回合结束自动发送时才上屏为用户消息)。 + public IReadOnlyList QueuedInputs { get; set; } = []; + /// 建议区高度(0 = 未激活)。 private int SuggestionHeight => _suggestionBox.MeasureHeight(Suggestions?.Count ?? 0); + /// 排队面板高度(0 = 无排队)。 + private int QueueHeight => QueuedInputs.Count; + /// - /// 内容总高度(inline 模式建屏用)— 消息全量高度 + 间隔 1 + 输入区 3 + 建议区 + footer 高度; + /// 内容总高度(inline 模式建屏用)— 消息全量高度 + 间隔 1 + 输入区 3 + 排队面板 + 建议区 + footer 高度; /// 屏高取此值时消息区恰好全量展示(无滚动窗口),历史自然滚入终端 scrollback。 /// public int MeasureContentHeight(int cols) => - Messages.MeasureContentHeight(cols) + 4 + SuggestionHeight + _footer.MeasureHeight(Footer); + Messages.MeasureContentHeight(cols) + 4 + QueueHeight + SuggestionHeight + _footer.MeasureHeight(Footer); - /// inline 模式尾部动态区行数(间隔 1 + 输入区 3 + 建议区 + footer 高度)— 退出清理用。 - public int TailHeight => 4 + SuggestionHeight + _footer.MeasureHeight(Footer); + /// inline 模式尾部动态区行数(间隔 1 + 输入区 3 + 排队面板 + 建议区 + footer 高度)— 退出清理用。 + public int TailHeight => 4 + QueueHeight + SuggestionHeight + _footer.MeasureHeight(Footer); /// /// 输入光标内容坐标(inline 模式帧尾光标位)— 行 = 输入文本行(内容底上方 footer+建议区+2), @@ -64,11 +71,12 @@ public CursorPos InputCursorPos(int contentRows, int cols, int cursorCol) return new CursorPos(Math.Min(cols, PromptInputBox.PromptWidth + displayCol), contentRows - footerHeight - SuggestionHeight - 2); } - /// 渲染整屏(消息区 → 输入框 → 建议区 → footer)。 + /// 渲染整屏(消息区 → 排队面板 → 输入框 → 建议区 → footer)。 public void Render(Screen screen, Theme theme) { var footerHeight = _footer.MeasureHeight(Footer); var suggestionHeight = SuggestionHeight; + var queueHeight = QueueHeight; var footerFrame = new Rect(2, screen.Rows - footerHeight, Math.Max(0, screen.Cols - 4), footerHeight); _footer.Render(screen, footerFrame, theme, Footer); @@ -81,8 +89,28 @@ public void Render(Screen screen, Theme theme) var inputFrame = new Rect(0, screen.Rows - footerHeight - suggestionHeight - 3, screen.Cols, 3); _inputBox.Render(screen, inputFrame, theme, Input, CursorCol, Footer.Busy, GhostSuffix); - var messagesHeight = Math.Max(0, screen.Rows - footerHeight - suggestionHeight - 4); + if (queueHeight > 0) + { + RenderQueuePanel(screen, new Rect(0, inputFrame.Y - queueHeight, screen.Cols, queueHeight), theme); + } + + var messagesHeight = Math.Max(0, screen.Rows - footerHeight - suggestionHeight - queueHeight - 4); var messagesFrame = new Rect(0, 0, screen.Cols, messagesHeight); Messages.Render(screen, messagesFrame, theme); } + + /// 渲染排队面板(基准 queued 形态 — 输入栏上方):每条一行 ❯ 前缀 + 文本 + dim " (queued)", + /// 整行 dim(待发态弱化;首条不带 queued 标记 — 即将发送语义,对齐基准下一条高亮)。 + private void RenderQueuePanel(Screen screen, Rect frame, Theme theme) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + var promptStyle = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.PromptBorder, Dim = true }); + for (var i = 0; i < QueuedInputs.Count; i++) + { + var row = frame.Y + i; + var col = screen.WriteText(row, frame.X, $"{Figures.Pointer} ", promptStyle, frame.Right); + var suffix = i == 0 ? string.Empty : " (queued)"; + screen.WriteText(row, col, $"{QueuedInputs[i]}{suffix}", dim, frame.Right); + } + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs b/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs index 3a7e1ce76..b01bf5ee6 100644 --- a/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/QueuedSubmitTests.cs @@ -33,9 +33,9 @@ private static void EndTurn(ReplShell shell, FakeReplEngine engine) { shell.DrainEngine(); } - /// 忙时提交 — 上屏用户输入 + ⎿ 已排队行,不调引擎。 + /// 忙时提交 — 不上滚动区(TASK062 排队面板语义)+ 进输入栏上方 queued 面板 + 不调引擎。 [Fact] - public void BusySubmit_QueuesLocallyWithoutEngineCall() + public void BusySubmit_QueuesInPanelWithoutEngineCall() { var (shell, engine) = CreateShell(); MakeBusy(shell, engine); @@ -43,23 +43,25 @@ public void BusySubmit_QueuesLocallyWithoutEngineCall() TypeAndSubmit(shell, "3333"); Assert.Empty(engine.Submits); - Assert.Contains(shell.View.Messages.Items, m => m is UserMessageItem u && u.Text == "3333"); - Assert.Contains(shell.View.Messages.Items, m => m is ToolResultItem t && t.Text.Contains("已排队")); + Assert.DoesNotContain(shell.View.Messages.Items, m => m is UserMessageItem); + Assert.DoesNotContain(shell.View.Messages.Items, m => m is ToolResultItem t && t.Text.Contains("已排队")); + Assert.Equal(["3333"], shell.View.QueuedInputs); } - /// 回合结束 — 队首自动发送(不重复上屏用户消息)。 + /// 回合结束 — 队首自动发送(此时才上屏用户消息 — 发送时刻进滚动区)。 [Fact] public void TurnCompleted_AutoSendsQueuedInput() { var (shell, engine) = CreateShell(); MakeBusy(shell, engine); TypeAndSubmit(shell, "3333"); - Assert.Single(shell.View.Messages.Items.OfType()); + Assert.Empty(shell.View.Messages.Items.OfType()); EndTurn(shell, engine); Assert.Equal(["3333"], engine.Submits); Assert.Single(shell.View.Messages.Items.OfType()); + Assert.Empty(shell.View.QueuedInputs); } /// 多条排队 — 按序逐回合发送(一问一答节奏)。 @@ -126,9 +128,10 @@ public void QueuedInputs_OverLimit_DroppedWithFeedback() shell.DrainEngine(); } Assert.Equal(Enumerable.Range(1, ReplShell.MaxQueuedInputs).Select(i => $"第{i}条"), engine.Submits); + Assert.Empty(shell.View.QueuedInputs); } - /// 上限内排队 — 正常入队(边界值恰好 10 条不误拦)。 + /// 上限内排队 — 正常入队(边界值恰好 10 条不误拦;全部进 queued 面板不上滚动区)。 [Fact] public void QueuedInputs_AtLimit_AllQueued() { @@ -139,8 +142,8 @@ public void QueuedInputs_AtLimit_AllQueued() TypeAndSubmit(shell, $"第{i}条"); } - var notes = shell.View.Messages.Items.OfType().ToList(); - Assert.Equal(ReplShell.MaxQueuedInputs, notes.Count(t => t.Text.Contains("已排队"))); - Assert.DoesNotContain(notes, t => t.Text.Contains("排队已满")); + Assert.Equal(ReplShell.MaxQueuedInputs, shell.View.QueuedInputs.Count); + Assert.Equal($"第1条", shell.View.QueuedInputs[0]); + Assert.DoesNotContain(shell.View.Messages.Items, m => m is ToolResultItem t && t.Text.Contains("排队已满")); } } From 2d267cd5b99dc825451d4ed586da3fcb799ef74b Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 23:50:59 +0800 Subject: [PATCH 27/30] =?UTF-8?q?feat:=20DSG041=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E7=AC=AC=E2=91=A1=E9=A1=B9=E2=80=94=E2=80=94?= =?UTF-8?q?Host=20selftest=20=E6=89=A9=E5=AD=90=E4=BB=A3=E7=90=86=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E9=93=BE=E6=96=AD=E8=A8=80=EF=BC=88=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20spawn=20=E5=85=A8=E9=93=BE=EF=BC=89=20|=20=E5=86=B3=E7=AD=96?= =?UTF-8?q?:=20SubagentSelftest=20=E5=8F=AF=E6=B5=8B=E7=B1=BB=E6=8F=90?= =?UTF-8?q?=E5=8F=96=EF=BC=88Program=20=E5=85=A5=E5=8F=A3=E8=96=84?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=EF=BC=89=E2=80=94=E2=80=94=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20MockServer=20=E5=81=87=20LLM+=E7=9C=9F=E5=AE=9E=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E7=BB=84=E8=A3=85+=E4=B8=BB=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=AE=8C=E6=95=B4=E5=9B=9E=E5=90=88=EF=BC=8C?= =?UTF-8?q?=E6=96=AD=E8=A8=80=20OnSubagentStarted/ToolUse/Finished=20?= =?UTF-8?q?=E4=B8=89=E5=9B=9E=E8=B0=83=E5=88=B0=E8=BE=BE=EF=BC=88=E9=80=9A?= =?UTF-8?q?=E9=81=93=20Emit=E2=86=92Drain=20=E7=9B=B4=E6=8A=95=E2=86=92?= =?UTF-8?q?=E5=88=86=E6=B5=81=E2=86=92=E6=98=A0=E5=B0=84=E5=85=A8=E9=93=BE?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E6=8F=92=E4=BB=B6=E5=B0=B1=E7=BB=AA=E7=BB=8F?= =?UTF-8?q?=20Completion=20=E9=80=9A=E7=9F=A5=E7=AD=89=E5=BE=85=EF=BC=88E2?= =?UTF-8?q?E=20=E5=90=8C=E6=AC=BE=E6=A0=B9=E5=9B=A0=E9=98=B2=E6=8A=A4?= =?UTF-8?q?=EF=BC=89=EF=BC=9BMockServer=20=E6=9C=AA=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20Skipped=20=E9=9D=9E=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=EF=BC=88CI=20=E7=94=B1=20ci-e2e=20subagent-events=20=E4=B8=89?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=85=9C=E5=BA=95=EF=BC=8C=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=85=A8=E9=93=BE=E9=AA=8C=E8=AF=81=E7=94=A8=EF=BC=89=EF=BC=9B?= =?UTF-8?q?exe=20=E9=80=92=E5=BD=92=E6=9F=A5=E6=89=BE=E5=AF=B9=E9=BD=90=20?= =?UTF-8?q?E2E=20=E5=85=88=E4=BE=8B=EF=BC=88=E9=85=8D=E7=BD=AE/TFM=20?= =?UTF-8?q?=E5=AD=90=E7=9B=AE=E5=BD=95=EF=BC=89=EF=BC=9BInternalsVisibleTo?= =?UTF-8?q?=20=E8=A1=A5=20NativeTui.Host=EF=BC=9B=E5=AE=9E=E6=B5=8B=20self?= =?UTF-8?q?test=20=E5=85=A8=E9=93=BE=E9=80=9A=E8=BF=87=EF=BC=88=E6=97=A0?= =?UTF-8?q?=20SKIP=20=E9=80=80=E5=87=BA=200=20=E4=B8=89=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E5=85=A8=E8=BF=87=EF=BC=89+=20NativeTui.Host?= =?UTF-8?q?.Tests=2064=20+=20NativeTui.Tests=20817=20=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/apps/engine/JoinCode.Engine.csproj | 1 + native_tui/apps/NativeTui.Host/Program.cs | 11 + .../apps/NativeTui.Host/SubagentSelftest.cs | 234 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 native_tui/apps/NativeTui.Host/SubagentSelftest.cs diff --git a/modules/apps/engine/JoinCode.Engine.csproj b/modules/apps/engine/JoinCode.Engine.csproj index 03d5761ca..adf8faf08 100644 --- a/modules/apps/engine/JoinCode.Engine.csproj +++ b/modules/apps/engine/JoinCode.Engine.csproj @@ -33,6 +33,7 @@ + diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 7b738e523..ec05805fd 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -185,6 +185,17 @@ private static async Task RunSelftestAsync() { else AssertModelItems(ModelPickerAssembly.LoadItems(session.Services), failures); + // DSG041 集成验收第②项 — 子代理事件链冒烟(真实 spawn 路径;MockServer 未构建时跳过非失败) + var subagentResult = await SubagentSelftest.RunAsync().ConfigureAwait(false); + switch (subagentResult.Status) { + case SubagentSelftest.Result.Failed: + failures.AddRange(subagentResult.Failures); + break; + case SubagentSelftest.Result.Skipped: + Console.WriteLine("SKIP: " + subagentResult.Failures[0]); + break; + } + if (failures.Count > 0) { foreach (var failure in failures) Console.Error.WriteLine("FAIL: " + failure); diff --git a/native_tui/apps/NativeTui.Host/SubagentSelftest.cs b/native_tui/apps/NativeTui.Host/SubagentSelftest.cs new file mode 100644 index 000000000..82d2f0857 --- /dev/null +++ b/native_tui/apps/NativeTui.Host/SubagentSelftest.cs @@ -0,0 +1,234 @@ +namespace NativeTui.Host; + +/// +/// 子代理事件链 selftest(DSG041 集成验收第②项)— 真实引擎组装下的主代理路径子代理事件冒烟: +/// 启动真实 OpenAI.MockServer 假 LLM → 引擎会话(JCC_ENDPOINT 指向)→ 插件就绪等待(Completion 通知语义) +/// → 主代理提交 agent 工具回合 → 断言 OnSubagentStarted/OnSubagentToolUse/OnSubagentFinished 三回调到达 +/// (引擎通道 Emit → SessionController Drain 直投 → RouteSubagentEvent 分流 → 消费者映射全链)。 +/// MockServer exe 未找到时返回 Skipped(CI 由 ci-e2e subagent-events 三用例兜底,本地全链验证用)。 +/// +internal static class SubagentSelftest +{ + /// 运行结果。 + public enum Result + { + /// 全链通过(三回调全部到达)。 + Passed, + + /// MockServer 未构建 — 跳过(非失败,CI 由 E2E 兜底)。 + Skipped, + + /// 链路断言失败(failures 携带明细)。 + Failed, + } + + /// 运行子代理事件链 selftest。 + public static async Task<(Result Status, IReadOnlyList Failures)> RunAsync() + { + var mockServerPath = ResolveMockServerPath(); + if (mockServerPath is null) + { + return (Result.Skipped, ["MockServer exe 未构建(artifacts/bin/OpenAI.MockServer)— 跳过子代理链冒烟,CI 由 ci-e2e subagent-events 兜底"]); + } + + var failures = new List(); + var configDir = Path.Combine(Path.GetTempPath(), $"jcc_subagent_selftest_{Guid.NewGuid():N}"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mockserver.json"); + await File.WriteAllTextAsync(configPath, BuildMockConfig()).ConfigureAwait(false); + + using var mockServer = StartMockServer(mockServerPath, configPath, out var readyTask); + using var envScope = EnvVarScope.Set("JCC_ENDPOINT", await readyTask.WaitAsync(TimeSpan.FromSeconds(25)).ConfigureAwait(false)) + .Add("OPENAI_API_KEY", "sk-test-subagent-selftest") + .Add("JCC_VENDOR", "openai") + .Add("JCC_MODEL_ID", "gpt-4o") + .Add("JCC_PERMISSION_MODE", "bypass"); + TestEnvironmentDetector.ForceNonInteractive = true; + + try + { + await using var session = await EngineSession.CreateAsync().ConfigureAwait(false); + var chatService = session.Services.GetService() + ?? throw new InvalidOperationException("IChatService 未在引擎 DI 注册"); + var mainAgent = JoinCode.Cli.EngineAgentFactory.CreateMainAgent( + session.Services, new JoinCode.Abstractions.Entity.Session().ObjectId); + if (mainAgent is null) + { + return (Result.Failed, ["主代理创建失败(DI 无 IQueryEngine)"]); + } + + // 插件就绪等待(通知语义 — 八插件后台化后 kernel.Plugins 挂载晚于启动, + // 不等则 agent 工具 not found 短路、子代理不执行 — E2E 同款根因) + var bootstrapHandle = session.Services.GetService(); + if (bootstrapHandle is not null) + { + await bootstrapHandle.Completion.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + } + + var consumer = new RecordingSubagentConsumer(); + var controller = new JoinCode.Adapters.SessionController( + chatService, consumer, new JoinCode.Cli.TurnDiffService(), + session.SessionId, session.Services, mainAgent: mainAgent); + var turn = await controller.StreamResponseAsync("帮我检查项目中的README文件", CancellationToken.None).ConfigureAwait(false); + if (!turn.Succeeded) + { + return (Result.Failed, [$"回合失败: {turn.ErrorMessage ?? "(无)"}"]); + } + + if (consumer.SubagentStarts.Count == 0) + { + failures.Add("OnSubagentStarted 未到达(引擎通道→Drain→分流→映射链断裂)"); + } + + if (consumer.SubagentToolUses.Count == 0) + { + failures.Add("OnSubagentToolUse 未到达(子代理工具活动事件缺失)"); + } + + if (consumer.SubagentFinishes.Count == 0) + { + failures.Add("OnSubagentFinished 未到达(终态事件缺失)"); + } + + return (failures.Count == 0 ? Result.Passed : Result.Failed, failures); + } + finally + { + if (!mockServer.HasExited) + { + try { mockServer.Kill(entireProcessTree: true); } catch { } + } + + try { Directory.Delete(configDir, recursive: true); } catch { } + } + } + + /// 脚本轮次:① 主对话 agent 工具调用 ② 子代理 read 工具 ③ 子代理文本 ④ 主对话收尾。 + private static string BuildMockConfig() + { + const string agentArgs = "{\"name\":\"selftest-sub\",\"subagent_type\":\"executor:search\",\"description\":\"检查README文件\",\"prompt\":\"读取当前目录README并总结\"}"; + return $$""" +{ + "port": 0, + "default_response": "(script exhausted)", + "scripted_turns": [ + { + "thinking_content": null, + "tool_calls": [ + { "tool_name": "agent", "arguments": "{{agentArgs.Replace("\\", "\\\\").Replace("\"", "\\\"")}}" } + ], + "text_response": null, + "follow_up_text": null, + "http_status_code": null + }, + { + "thinking_content": null, + "tool_calls": [ + { "tool_name": "read", "arguments": "{\"file_path\":\"README.md\"}" } + ], + "text_response": null, + "follow_up_text": null, + "http_status_code": null + }, + { + "thinking_content": null, + "tool_calls": null, + "text_response": "README 已确认存在:JoinCode 是一个 AI 工作流引擎。", + "follow_up_text": null, + "http_status_code": null + }, + { + "thinking_content": null, + "tool_calls": null, + "text_response": "子代理已完成 README 检查。", + "follow_up_text": null, + "http_status_code": null + } + ] +} +"""; + } + + /// 启动 MockServer 进程并解析就绪 URL(对齐 E2E StartMockServer 先例)。 + private static System.Diagnostics.Process StartMockServer(string exe, string configPath, out Task readyUrl) + { + 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) || !e.Data.Contains(readyMarker, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var urlPart = e.Data[(e.Data.IndexOf(readyMarker, StringComparison.OrdinalIgnoreCase) + readyMarker.Length)..].Trim(); + readyTcs.TrySetResult(urlPart.TrimEnd('/')); + }; + process.ErrorDataReceived += (_, e) => { }; + if (!process.Start()) + { + throw new InvalidOperationException("无法启动 MockServer 进程"); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + readyUrl = readyTcs.Task; + return process; + } + + /// 从 Host 程序集位置向上找主仓 artifacts/bin/OpenAI.MockServer(跨 native_tui→主仓两棵 artifacts 树; + /// exe 在配置/TFM 子目录 — 对齐 E2E ResolveMockServerPath 递归先例)。 + private static string? ResolveMockServerPath() + { + var exeName = OperatingSystem.IsWindows() ? "JoinCode.OpenAI.MockServer.exe" : "JoinCode.OpenAI.MockServer"; + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + var candidate = Path.Combine(dir, "artifacts", "bin", "OpenAI.MockServer"); + if (Directory.Exists(candidate)) + { + return Directory.EnumerateFiles(candidate, exeName, SearchOption.AllDirectories) + .OrderByDescending(p => p.Contains("Debug")) + .FirstOrDefault(); + } + + dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar)); + } + + return null; + } + + /// 记录子代理三回调的消费面(selftest 断言用 — 对齐 E2E RecordingSubagentConsumer 形态)。 + private sealed class RecordingSubagentConsumer : JoinCode.Abstractions.Interfaces.IEventConsumer + { + 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 void OnText(string content) { } + public void OnThinking(string thinking) { } + public void OnToolStart(string toolName, string? toolCallId, string? arguments) { } + public void OnToolEnd(string toolName, string? resultText, string? toolCallId, bool isError, JoinCode.Abstractions.Models.Diff.StructuredPatchHunk[]? patch) { } + public void OnToolProgress(string toolName, string progressType, string? progressMessage) { } + public void OnLoopDetected(int triggerCount, int loopStartIndex, string? repeatedPattern) { } + 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)); + } +} From 550aa584f0c191d78ad41b9117255d81cd84eaf6 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 23:51:53 +0800 Subject: [PATCH 28/30] =?UTF-8?q?docs:=20DSG041=20=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=A1=A8=E7=BB=88=E6=80=81=E2=80=94=E2=80=94E2E=20=E4=B8=89?= =?UTF-8?q?=E7=BB=BF+selftest=20=E6=96=AD=E8=A8=80=E5=8F=8C=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=EF=BC=88=E5=94=AF=E5=89=A9=E7=94=A8=E6=88=B7=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=BB=88=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/proposed/design/DSG041-subagent-event-source-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/proposed/design/DSG041-subagent-event-source-plan.md b/docs/proposed/design/DSG041-subagent-event-source-plan.md index 310593149..caa3680e2 100644 --- a/docs/proposed/design/DSG041-subagent-event-source-plan.md +++ b/docs/proposed/design/DSG041-subagent-event-source-plan.md @@ -73,8 +73,8 @@ native_tui(TUI 宿主)子代理分组渲染全链已落地(SubagentGroupCo | L1 SubAgentEventChannel 接通主代理路径(**宿主层直投方案**) | AgentStreamExecutionMiddleware/ForkSubAgentManagerActor 发射侧(零改动) | ✅ 批2(6b762c145) | ✅ 单元级:MainAgentPath 2/2(通道事件全序列直投三回调 + 子代理工具事件不走普通回调)+ host.tests 1373 全绿 + Host 编译绿;✅ 集成级:SubAgentEventMainAgentPathE2ETests 1/1 绿(00f2a77ae — MockServer 真实 LLM 全链:agent 工具→子代理 spawn(含工具轮)→Emit→通道→Drain 直投→三回调全达消费者,16s 干净收尾;过程中根治 Host.DisposeAsync 永挂死锁三处 ReceiveAsync 漏传 ct) | | ~~L2 AgentStreamChunkAdapter AgentId 传播~~ | ~~SessionController chunk→ChatStreamEvent 翻译~~ | **✅ 被批2 宿主层直投方案吸收**(通道事件不经 chunk 转换层,AgentBase/adapter 零改动;批3 撤销) | —(无独立交付物) | | L4 引擎侧 BridgeEvent 三 record + BridgeEventConsumer 显式映射 | bridge_serve JSON 协议消费方 | ✅ 批3(6d55a8215) | ✅ BridgeJsonContractTests JSON 形状快照锁定(subagent_started/subagent_tool_use/subagent_finished camelCase)+ BridgeEventConsumerTests 映射断言 + host.tests 1375 + engine/cli/Host 三链编译绿 | -| InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ✅ 批4(3f7f88919) | ✅ InProcessEventConsumerTests 映射断言(三 record 字段逐一对齐)+ NativeTui.Host.Tests 61 全绿 + Host 编译绿;⏳ Host --selftest 扩断言(真实运行收口) | -| TUI 真实链路分组显示 | SubagentGroupCoordinator → 分组渲染(既有零改动) | ✅ 链路闭合(批1-4) | ⏳ 用户真实终端(agents 场景真实引擎 spawn) | +| InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ✅ 批4(3f7f88919) | ✅ InProcessEventConsumerTests 映射断言(三 record 字段逐一对齐)+ NativeTui.Host.Tests 61 全绿 + Host 编译绿;✅ Host --selftest 扩子代理断言(2d267cd5b — SubagentSelftest 真实 MockServer+引擎+主代理路径完整回合,三回调断言实测通过退出 0) | +| TUI 真实链路分组显示 | SubagentGroupCoordinator → 分组渲染(既有零改动) | ✅ 链路闭合(批1-4) | ⏳ 用户真实终端(agents 场景真实引擎 spawn — 唯一剩余项) | ## 8. 后续 @@ -83,8 +83,8 @@ native_tui(TUI 宿主)子代理分组渲染全链已落地(SubagentGroupCo - ~~批3(L2 chunk 传播)~~ **✅ 撤销 — 被批2 宿主层直投方案吸收** - ~~批4(原批3 引擎侧 DTO + BridgeEventConsumer 映射)~~ **✅ 已完成(6d55a8215)** - ~~批5(原批4 InProcessEventConsumer 映射)~~ **✅ 已完成(3f7f88919)— 代码批次全部落地,链路闭合** -- **剩余集成级验收**(收尾项):~~① 批2 E2E~~ **✅ 已完成(00f2a77ae)**——顺带根治 Host.DisposeAsync 永挂死锁(三处邮箱 ReceiveAsync 漏传 ct:AgentServiceImpl/SwarmPermissionMessageRouter/PlanApprovalMessageRouter;dumpasync 取证完整链)② Host --selftest 扩子代理断言 ③ 用户真实终端收口 -- ⚠️ 遗留登记:ChatService 路径 E2E(SubAgentEventStreamE2ETests)仍红——子代理 LLM 流以无 AgentId 的 Content 透传主流(疑 TASK043 StreamingFallbackDecorator 域),独立缺陷非 DSG041 接线问题 +- **剩余集成级验收**:~~① 批2 E2E~~ **✅(00f2a77ae,顺带根治 Host.DisposeAsync 永挂死锁——三处邮箱 ReceiveAsync 漏传 ct)** ~~② Host --selftest 扩子代理断言~~ **✅(2d267cd5b)** ③ 用户真实终端收口(唯一剩余项) +- ✅ ChatService 路径 E2E 亦三绿(b7e4ea782)——误诊修正:非串话缺陷,真实根因=插件就绪等待缺失+xunit 跨 class 并行污染(详见 TASK062 P1);CI 白名单 subagent-events 三用例全覆盖 - 本 DSG 落地后 TASK055 三缺口③关闭 ### 批1 实施记录(hash 已随 PR #439 squash,内容在库) From ab33deb70a3f81018f90ab73173bf2e78d425c4a Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 00:39:06 +0800 Subject: [PATCH 29/30] =?UTF-8?q?feat:=20TASK063=20=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=B6=88=E6=81=AF=E6=89=93=E6=96=AD=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E9=80=81=EF=BC=88steer=20=E5=8D=8F=E8=AE=AE=EF=BC=89=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E4=B8=89=E5=B1=82=E2=80=94=E2=80=94?= =?UTF-8?q?=E2=91=A0=E5=8D=8F=E8=AE=AE=E5=B1=9E=E6=80=A7=20CoordinatorMess?= =?UTF-8?q?age.InterruptAndSend=EF=BC=88=E9=BB=98=E8=AE=A4false=E5=90=91?= =?UTF-8?q?=E5=90=8E=E5=85=BC=E5=AE=B9=EF=BC=8C=E9=82=AE=E7=AE=B1=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=A8=A1=E5=9E=8B=E5=8D=95=E4=B8=80=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=EF=BC=89=EF=BC=9B=E2=91=A1AgentBase.RequestSteer=20?= =?UTF-8?q?=E8=BD=AE=E7=BA=A7=E6=89=93=E6=96=AD=E4=BF=A1=E5=8F=B7=EF=BC=88?= =?UTF-8?q?volatile=20=E7=BD=AE=E4=BD=8D=EF=BC=8CExecuteStreamAsync=20chun?= =?UTF-8?q?k=20=E5=BE=AA=E7=8E=AF=E5=BC=80=E5=A4=B4=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E7=82=B9=E8=B7=B3=E5=87=BA=EF=BC=8CComplete=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E8=A1=A5=E5=8F=91=E6=94=B6=E5=B0=BE=EF=BC=8C=E8=BD=AE?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E5=A4=8D=E4=BD=8D=E2=80=94=E2=80=94=E8=BD=AC?= =?UTF-8?q?=E5=8F=91=E6=B6=88=E6=81=AF=E4=B8=8B=E8=BD=AE=20TryDrain=20?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=EF=BC=89=EF=BC=9B=E2=91=A2=E5=8F=8C=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=EF=BC=88SendMessageToAgentAsync=20=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E8=B7=AF=E5=BE=84=E7=BD=AE=20InterruptAndSend=3Dtrue+?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=AD=E5=AE=9E=E4=BE=8B=20RequestSteer?= =?UTF-8?q?=EF=BC=8CForwardUserInputToAgentAsync=20=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E9=98=9F=E5=88=97=E8=B7=AF=E5=BE=84=E5=90=8C=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E4=B8=80=E8=87=B4=EF=BC=9B=E6=9C=AA=E8=BF=90=E8=A1=8C=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=20null=20=E9=9D=99=E9=BB=98=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=BB=8D=E5=9C=A8=E9=82=AE=E7=AE=B1=E7=AD=89?= =?UTF-8?q?=E4=B8=8B=E8=BD=AE=EF=BC=89=EF=BC=9BIAgent=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E9=9D=A2=20+RequestSteer=EF=BC=88=E5=94=AF=E4=B8=80=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20AgentBase=20=E9=9B=B6=E7=A0=B4=E5=9D=8F=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E5=AE=9E=E7=8E=B0=E6=96=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/agent_interfaces/IAgent.cs | 3 + .../agent/cluster/CoordinatorMessage.cs | 5 + .../Coordinator/misc/SteerInterruptTests.cs | 102 ++++++++++++++++++ .../Coordinator/misc/SteerRoutingTests.cs | 70 ++++++++++++ .../llm/agents/Coordinator/Fork/AgentBase.cs | 21 ++++ .../agents/Services/Core/AgentServiceImpl.cs | 20 +++- 6 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 modules/llm/agents.tests/Agents/Coordinator/misc/SteerInterruptTests.cs create mode 100644 modules/llm/agents.tests/Agents/Coordinator/misc/SteerRoutingTests.cs diff --git a/modules/abstractions/abstractions/abs_agents/agent/agent_interfaces/IAgent.cs b/modules/abstractions/abstractions/abs_agents/agent/agent_interfaces/IAgent.cs index cdfe604a3..a8454654b 100644 --- a/modules/abstractions/abstractions/abs_agents/agent/agent_interfaces/IAgent.cs +++ b/modules/abstractions/abstractions/abs_agents/agent/agent_interfaces/IAgent.cs @@ -40,6 +40,9 @@ public interface IAgent : IAsyncDisposable { void Pause(); /// 恢复执行。 void Resume(); + /// 请求打断当前轮(TASK063 steer — 邮箱 InterruptAndSend 消息路由触发; + /// 当前 LLM 轮尽快收尾,转发消息下轮开头消费)。 + void RequestSteer(); /// 取消执行。 void Cancel(); /// 重置代理状态。 diff --git a/modules/abstractions/abstractions/abs_core/models/models_agent/agent/cluster/CoordinatorMessage.cs b/modules/abstractions/abstractions/abs_core/models/models_agent/agent/cluster/CoordinatorMessage.cs index 3f0a5b84d..ac2988b4e 100644 --- a/modules/abstractions/abstractions/abs_core/models/models_agent/agent/cluster/CoordinatorMessage.cs +++ b/modules/abstractions/abstractions/abs_core/models/models_agent/agent/cluster/CoordinatorMessage.cs @@ -41,6 +41,11 @@ public sealed class CoordinatorMessage { /// 消息可见性 — 控制跨通道路由范围,对标 QQ 系统消息/私信/撤回 — ADR 0111 决策8。 public MessageVisibility Visibility { get; init; } = MessageVisibility.Public; + /// 打断并发送(TASK063 steer 协议)— true 时邮箱层投递消息同时请求目标代理打断当前轮 + ///(对齐用户语义"@子代理消息必须打断并提示":不躺着等下轮 TryDrain);默认 false 保持既有 + ///"排队到下轮"语义向后兼容。消费方:AgentServiceImpl.SendMessageToAgentAsync 路由。 + public bool InterruptAndSend { get; init; } + /// 投影为 MessageEndpoints public MessageEndpoints ToEndpoints() => new(FromAgentId, ToAgentId); } \ No newline at end of file diff --git a/modules/llm/agents.tests/Agents/Coordinator/misc/SteerInterruptTests.cs b/modules/llm/agents.tests/Agents/Coordinator/misc/SteerInterruptTests.cs new file mode 100644 index 000000000..548027cbf --- /dev/null +++ b/modules/llm/agents.tests/Agents/Coordinator/misc/SteerInterruptTests.cs @@ -0,0 +1,102 @@ +namespace Core.Agents.Tests; + +/// +/// 打断并发送(steer)测试 — 用户 @子代理 邮箱转发必须打断当前执行: +/// ①协议属性 CoordinatorMessage.InterruptAndSend(默认 false 向后兼容); +/// ②AgentBase.RequestSteer 轮级打断信号(chunk 循环检查点跳出 — 当前流尽快收尾, +/// 转发消息在下轮 ExecuteStreamAsync 开头 TryDrain 消费); +/// ③IAgent 接口面 RequestSteer(生命周期管理器路由触达)。 +/// +public class SteerInterruptTests +{ + /// 协议属性 — InterruptAndSend 默认 false,可显式置 true。 + [Fact] + public void CoordinatorMessage_InterruptAndSend_Property() + { + var legacy = new CoordinatorMessage { FromAgentId = "a", ToAgentId = "b", MessageType = "user_message", Content = "hi" }; + var steer = new CoordinatorMessage { FromAgentId = "a", ToAgentId = "b", MessageType = "user_message", Content = "hi", InterruptAndSend = true }; + + Assert.False(legacy.InterruptAndSend); + Assert.True(steer.InterruptAndSend); + } + + /// RequestSteer — 信号置位后 ExecuteStreamAsync chunk 循环尽快跳出(早于流自然结束)。 + [Fact] + public async Task RequestSteer_BreaksChunkLoopEarly() + { + var chunks = new List(); + for (var i = 0; i < 100; i++) + chunks.Add(new QueryStreamChunk { Type = AgentStreamChunkType.Content, Content = $"chunk{i}" }); + + var engine = new SteerableQueryEngine(chunks); + await using var agent = new TestSteerAgent("任务", engine); + + var consumed = new List(); + await using var reader = agent.ExecuteStreamAsync(CancellationToken.None).GetAsyncEnumerator(); + await reader.MoveNextAsync(); + agent.RequestSteer(); + + while (await reader.MoveNextAsync()) { + consumed.Add(reader.Current); + // 打断后不应消费全部 100 chunk — 收到 Complete 即止 + if (reader.Current.Type == AgentStreamChunkType.Complete) + break; + } + + consumed.Count.Should().BeLessThan(99, "steer 信号应尽快打断 chunk 流"); + consumed.Last().Type.Should().Be(AgentStreamChunkType.Complete); + } + + /// 无信号 — 全量消费不打断(回归保护)。 + [Fact] + public async Task NoSteer_ConsumesAllChunks() + { + var chunks = new List(); + for (var i = 0; i < 5; i++) + chunks.Add(new QueryStreamChunk { Type = AgentStreamChunkType.Content, Content = $"c{i}" }); + + var engine = new SteerableQueryEngine(chunks); + await using var agent = new TestSteerAgent("任务", engine); + + var count = 0; + await foreach (var chunk in agent.ExecuteStreamAsync(CancellationToken.None)) { + count++; + if (chunk.Type == AgentStreamChunkType.Complete) + break; + } + + // 5 content + 1 complete(AgentBase 收尾补发 — QueryEngine 无 Complete chunk 时 sawComplete=false 兜底) + count.Should().Be(6); + } + + /// 可控 QueryEngine 假体 — 逐 chunk 慢速产出(每 chunk yield 后 Task.Yield 让测试线程置信号)。 + private sealed class SteerableQueryEngine(List chunks) : IQueryEngine + { + public Task ExecuteQueryAsync(string query, CancellationToken cancellationToken = default) + => Task.FromResult(string.Empty); + + public async IAsyncEnumerable QueryAsync( + string userInput, MessageList chatHistory, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + foreach (var chunk in chunks) { + await Task.Yield(); + yield return chunk; + } + } + + public IAsyncEnumerable QueryAsync( + string userInput, MessageList chatHistory, QueryOptions? options, + CancellationToken cancellationToken = default) + => QueryAsync(userInput, chatHistory, cancellationToken); + + public JoinCode.Abstractions.LLM.IQueryService GetChatCompletionService() + => throw new NotSupportedException("测试假体不提供查询服务"); + + public JoinCode.Abstractions.LLM.IChatClient GetKernel() + => throw new NotSupportedException("测试假体不提供 kernel"); + } + + /// 最小 TestAgent(AgentBase 直构造 — 无 spawn 管道依赖)。 + private sealed class TestSteerAgent(string task, IQueryEngine queryEngine) + : AgentBase(task, options: null, queryEngine, logger: null, clock: null, name: "steer-test"); +} diff --git a/modules/llm/agents.tests/Agents/Coordinator/misc/SteerRoutingTests.cs b/modules/llm/agents.tests/Agents/Coordinator/misc/SteerRoutingTests.cs new file mode 100644 index 000000000..b81726c00 --- /dev/null +++ b/modules/llm/agents.tests/Agents/Coordinator/misc/SteerRoutingTests.cs @@ -0,0 +1,70 @@ +namespace JoinCode.Agents.Tests.Coordinator.Misc; + +/// +/// steer 打断路由测试(TASK063 — @子代理邮箱/转发双链打断触达)— +/// SendMessageToAgentAsync / ForwardUserInputToAgentAsync 发送成功后必须调目标代理 RequestSteer +///(运行中实例;未注册实例 GetAgentAsync null 静默跳过不抛);消息协议属性 InterruptAndSend 置位。 +/// +public sealed class SteerRoutingTests +{ + private static (AgentServiceImpl Service, Mock Lifecycle, Mock Broker, Mock Forward) CreateService(Mock? agent = null) { + var lifecycle = new Mock(); + lifecycle.Setup(m => m.GetAgentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(agent?.Object); + + var broker = new Mock(); + broker.Setup(b => b.SendAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var forward = new Mock(); + forward.Setup(f => f.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var service = new AgentServiceImpl( + lifecycle.Object, + new Mock().Object, + new Mock().Object, + new Infrastructure.Pipeline.MiddlewarePipeline([]), + new AgentServiceDependencies { MessageBroker = broker.Object, InputForwardQueue = forward.Object }); + return (service, lifecycle, broker, forward); + } + + /// 邮箱路径 — 发送成功后 RequestSteer 触达目标代理 + 消息带 InterruptAndSend=true。 + [Fact] + public async Task SendMessage_RequestsSteerOnTargetAgent() + { + var agent = new Mock(); + var (service, _, broker, _) = CreateService(agent); + + var sent = await service.SendMessageToAgentAsync("agent-1", "打断去改需求"); + + sent.Should().BeTrue(); + agent.Verify(a => a.RequestSteer(), Times.Once, "邮箱发送必须同时请求打断当前轮"); + broker.Verify(b => b.SendAsync("agent-1", It.Is(m => m.InterruptAndSend), It.IsAny()), Times.Once); + } + + /// 转发队列路径 — 转发后 RequestSteer 触达(双链语义一致)。 + [Fact] + public async Task ForwardUserInput_RequestsSteerOnTargetAgent() + { + var agent = new Mock(); + var (service, _, _, _) = CreateService(agent); + + var sent = await service.ForwardUserInputToAgentAsync("agent-1", "追加输入"); + + sent.Should().BeTrue(); + agent.Verify(a => a.RequestSteer(), Times.Once); + } + + /// 目标未运行 — GetAgentAsync null 静默跳过(消息仍在邮箱等下轮)。 + [Fact] + public async Task SendMessage_AgentNotRunning_NoThrowStillDelivers() + { + var (service, _, broker, _) = CreateService(agent: null); + + var sent = await service.SendMessageToAgentAsync("agent-x", "消息"); + + sent.Should().BeTrue(); + broker.Verify(b => b.SendAsync("agent-x", It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/modules/llm/agents/Coordinator/Fork/AgentBase.cs b/modules/llm/agents/Coordinator/Fork/AgentBase.cs index 724a28956..6438c21d2 100644 --- a/modules/llm/agents/Coordinator/Fork/AgentBase.cs +++ b/modules/llm/agents/Coordinator/Fork/AgentBase.cs @@ -103,6 +103,11 @@ public class AgentBase : Entity, IAgent { /// public ConcurrentQueue ContractChangeNotifications { get; set; } = new(); + /// steer 打断信号(TASK063 — CoordinatorMessage.InterruptAndSend 路由触发): + /// volatile 标记,ExecuteStreamAsync chunk 循环每次迭代检查 — 置位后跳出当前流尽快收尾 + ///(收尾补发 Complete 兜底,转发消息在下轮开头 TryDrain 消费);轮结束自动复位。 + private volatile bool _steerRequested; + /// /// 延迟邮件服务 — 每轮 LLM 调用前消费到期延迟邮件, 注入 ChatHistory /// null 表示未接入延迟邮件(默认); 由 ForkSpawnMiddleware 注入 @@ -344,6 +349,7 @@ public virtual async IAsyncEnumerable ExecuteStreamAsync([Enum StartedAt = _clock.GetUtcNow(); Status = TaskExecutionStatus.Running; _executionCount++; + _steerRequested = false; if (_executionCount > Options.MaxIterations) { _logger?.LogWarning("[Agent {AgentId}] 已达最大迭代次数 {MaxIterations},停止执行", UniqueId, Options.MaxIterations); @@ -401,6 +407,11 @@ public virtual async IAsyncEnumerable ExecuteStreamAsync([Enum : _queryEngine.QueryAsync(prompt, chatHistory, linkedToken); await foreach (var chunk in queryStream.ConfigureAwait(false)) { + // TASK063 steer 检查点 — 打断信号置位即跳出当前流(已产出的 chunk 透传收尾,Complete 兜底补发) + if (_steerRequested) { + break; + } + if (Status == TaskExecutionStatus.Paused) { try { using (await _pauseLock.TryLockAsync(linkedToken).ConfigureAwait(false) ?? throw new System.TimeoutException($"锁 '{_pauseLock.Name}' 等待超时")) { } @@ -487,6 +498,16 @@ public virtual void Pause() { } } + /// + /// 请求打断当前轮(TASK063 steer)— volatile 置位,ExecuteStreamAsync chunk 循环检查点 + /// 尽快跳出(对齐用户语义"@子代理消息必须打断":不打断则消息躺着等下轮 TryDrain)。 + /// 信号轮结束自动复位;调用方(AgentServiceImpl 邮箱路由)负责消息已入转发队列。 + /// + public void RequestSteer() { + _steerRequested = true; + _logger?.LogInformation("[Agent {AgentId}] 收到 steer 打断信号,当前轮将尽快收尾", UniqueId); + } + /// /// 恢复LLM循环 — 子类可重写以扩展恢复逻辑 /// diff --git a/modules/llm/agents/Services/Core/AgentServiceImpl.cs b/modules/llm/agents/Services/Core/AgentServiceImpl.cs index 7e0c5f2e3..6f90b9e86 100644 --- a/modules/llm/agents/Services/Core/AgentServiceImpl.cs +++ b/modules/llm/agents/Services/Core/AgentServiceImpl.cs @@ -567,13 +567,20 @@ public async Task SendMessageToAgentAsync(string agentId, string message, FromAgentId = "parent", ToAgentId = agentId, MessageType = "user_message", - Content = message + Content = message, + // TASK063 steer — @子代理邮箱转发打断并发送:消息入邮箱同时请求目标代理打断当前轮 + //(用户语义:转发必须打断并提示,不躺着等下轮消费) + InterruptAndSend = true }; var sent = await _messageBroker.SendAsync(agentId, agentMessage, cancellationToken).ConfigureAwait(false); if (sent) { - _logger?.LogInformation("[AgentServiceImpl] 消息已发送给代理 {AgentId}", agentId); + // 打断路由 — 运行中的代理实例直接 RequestSteer(chunk 循环检查点尽快收尾; + // 未运行实例无轮可打断静默跳过 — 消息仍在邮箱等下轮消费) + var target = await _lifecycleManager.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); + target?.RequestSteer(); + _logger?.LogInformation("[AgentServiceImpl] 消息已发送给代理 {AgentId}(steer 打断已请求)", agentId); await AppendTranscriptEntryAsync(agentId, "user", $"[MESSAGE] {message}", cancellationToken).ConfigureAwait(false); } @@ -582,7 +589,8 @@ public async Task SendMessageToAgentAsync(string agentId, string message, /// /// 将用户输入转发给运行中的子代理 — 用户在子代理运行期间追加的输入 - /// 消息入 IAgentInputForwardQueue,由子代理每轮 LLM 调用前主动 TryDrain 消费 + /// 消息入 IAgentInputForwardQueue,由子代理每轮 LLM 调用前主动 TryDrain 消费; + /// TASK063 steer — 转发同时请求打断当前轮(对齐邮箱路径语义,两条转发链一致) /// public async Task ForwardUserInputToAgentAsync(string agentId, string userInput, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(agentId); @@ -595,7 +603,11 @@ public async Task ForwardUserInputToAgentAsync(string agentId, string user await _inputForwardQueue.EnqueueAsync(agentId, userInput, cancellationToken).ConfigureAwait(false); - _logger?.LogInformation("[AgentServiceImpl] 用户输入已转发给子代理 {AgentId}", agentId); + // steer 打断路由 — 运行中实例打断当前轮(转发消息在检查点收尾后的下轮 TryDrain 消费) + var target = await _lifecycleManager.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); + target?.RequestSteer(); + + _logger?.LogInformation("[AgentServiceImpl] 用户输入已转发给子代理 {AgentId}(steer 打断已请求)", agentId); await AppendTranscriptEntryAsync(agentId, "user", $"[USER_FORWARD] {userInput}", cancellationToken).ConfigureAwait(false); return true; From dfc7d744d6a70e86fe190ca46b591da912140df7 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 00:46:51 +0800 Subject: [PATCH 30/30] =?UTF-8?q?fix:=20MeshTransport=20=E5=8D=95=E5=90=91?= =?UTF-8?q?=E7=82=B9=E5=AF=B9=E7=82=B9=20CI=20Linux=20=E7=A1=AE=E5=AE=9A?= =?UTF-8?q?=E6=80=A7=E5=A4=B1=E8=B4=A5=E6=A0=B9=E6=B2=BB=EF=BC=88=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E4=B8=A2=E6=B6=88=E6=81=AF=E5=AE=88=E5=8D=AB+?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E9=87=8D=E8=AF=95+=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=8F=8D=E6=A8=A1=E5=BC=8F=E6=B6=88=E9=99=A4=EF=BC=89=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E4=B8=89=E5=B1=82=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E2=80=94=E2=80=94=E2=91=A0=E4=BA=A7=E5=93=81=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=EF=BC=9ASendAsync=20=E8=BF=9E=E6=8E=A5=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8E=9F=E9=9D=99=E9=BB=98=20return=20=E4=B8=A2=E6=B6=88?= =?UTF-8?q?=E6=81=AF=EF=BC=88=E6=97=A0=E5=BC=82=E5=B8=B8=E6=97=A0=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=97=A0=E8=BF=94=E5=9B=9E=E5=80=BC=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E8=A1=A5=20TransportDiagnostics+logger=20=E5=8F=8C=E9=80=9A?= =?UTF-8?q?=E9=81=93=E5=91=8A=E8=AD=A6=E4=BD=BF=E4=B8=A2=E5=B8=A7=E7=82=B9?= =?UTF-8?q?=E5=9C=A8=20CI=20=E8=BE=93=E5=87=BA=E7=9B=B4=E6=8E=A5=E5=8F=AF?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=EF=BC=9B=E2=91=A1=E8=BF=9E=E6=8E=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=9C=89=E9=99=90=E9=87=8D=E8=AF=95=EF=BC=883=20?= =?UTF-8?q?=E6=AC=A1=E7=BA=BF=E6=80=A7=E9=80=80=E9=81=BF=20100/200/300ms?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E6=8E=A5=E6=94=B6=E6=96=B9=20accept=20?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=E6=98=AF=E5=8D=95=E5=AE=9E=E4=BE=8B=E4=B8=B2?= =?UTF-8?q?=E8=A1=8C=E6=A8=A1=E5=9E=8B=EF=BC=88accept=20=E5=90=8E=E6=89=8D?= =?UTF-8?q?=E5=BB=BA=E4=B8=8B=E4=B8=80=E5=AE=9E=E4=BE=8B=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E6=92=9E=E8=AF=A5=E7=AA=97=E5=8F=A3=E5=8D=B3?= =?UTF-8?q?=20ECONNREFUSED=E2=86=92null=E2=86=92=E4=B8=A2=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E2=80=94=E2=80=94=E9=80=80=E9=81=BF=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E7=A9=BF=E9=80=8F=E7=AA=97=E5=8F=A3=EF=BC=8C=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E9=A6=96=E8=BF=9E=E9=9B=B6=E9=A2=9D=E5=A4=96=E5=BC=80=E9=94=80?= =?UTF-8?q?=EF=BC=88=E9=87=8D=E8=AF=95=E4=BB=85=E5=A4=B1=E8=B4=A5=E5=88=86?= =?UTF-8?q?=E6=94=AF=E8=A7=A6=E5=8F=91=EF=BC=89=EF=BC=9B=E2=91=A2=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=B6=88=E9=99=A4=20Task.Delay(100)=20=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=80=A6=E5=90=88=E5=8F=8D=E6=A8=A1=E5=BC=8F=EF=BC=88?= =?UTF-8?q?Windows=20=E7=A2=B0=E5=B7=A7=E5=A4=9F/Linux=20=E6=85=A2?= =?UTF-8?q?=E6=9C=BA=E6=97=A0=E6=AD=A3=E7=A1=AE=E6=80=A7=E4=BF=9D=E8=AF=81?= =?UTF-8?q?=EF=BC=89=E6=94=B9=E4=B8=BA=E7=AB=8B=E5=8D=B3=E5=8F=91=E9=80=81?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E9=BD=90=E5=90=8C=E6=96=87=E4=BB=B6=E5=8F=8C?= =?UTF-8?q?=E5=90=91=E6=B5=8B=E8=AF=95=20CI=20=E7=BB=BF=E5=AE=9E=E8=AF=81?= =?UTF-8?q?=E5=BD=A2=E6=80=81=E2=80=94=E2=80=94=E5=90=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=97=A0=20Delay=20=E4=BA=A6=E9=80=9A?= =?UTF-8?q?=EF=BC=89+=E6=B5=8B=E8=AF=95=E7=AA=97=E5=8F=A3=E5=86=85?= =?UTF-8?q?=E5=BC=80=20TransportDiagnostics.Enabled=EF=BC=88finally=20?= =?UTF-8?q?=E5=A4=8D=E4=BD=8D=EF=BC=89=E4=BD=BF=20CI=20=E5=86=8D=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=20PIPE-ACCEPT/MESH/MESH-WRITE=20=E5=85=A8?= =?UTF-8?q?=E9=93=BE=E6=97=A5=E5=BF=97=E7=9B=B4=E8=BE=BE=E6=96=AD=E7=82=B9?= =?UTF-8?q?=EF=BC=9B=E6=9C=AC=E5=9C=B0=E5=AE=9E=E8=AF=81=EF=BC=9ATransport?= =?UTF-8?q?E2ETest=206/6+=E5=8D=95=E5=90=91=E6=B5=8B=E8=AF=95=205=20?= =?UTF-8?q?=E8=BD=AE=E8=BF=9E=E8=B7=91=E7=A8=B3=E5=AE=9A=EF=BC=8842-56ms?= =?UTF-8?q?=EF=BC=89+async=5Fprimitives=20=E5=85=A8=E9=87=8F=20600/600=20?= =?UTF-8?q?=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/TransportE2ETest.cs | 52 +++++++++++-------- .../async_lock/transport/MeshTransport.cs | 37 ++++++++++++- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs b/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs index 4cdf02b13..4be895b80 100644 --- a/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs +++ b/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs @@ -62,28 +62,36 @@ public async Task BusTransport_TwoInstances_HostElectionWorks() { [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] [Fact] public async Task MeshTransport_TwoPeers_PointToPointMessage() { - var baseName = $"e2e-mesh-{Guid.NewGuid():N}"; - var pid1 = "300001"; - var pid2 = "300002"; - - await using var peer1 = new MeshTransport(basePipeName: baseName, processId: pid1); - await using var peer2 = new MeshTransport(basePipeName: baseName, processId: pid2); - - await peer1.StartAsync(); - await peer2.StartAsync(); - - var connected = await peer1.AddPeerAsync(pid2, CancellationToken.None); - connected.Should().BeTrue("应能连接到 peer2"); - - await Task.Delay(100); - - var data = new ReadOnlyMemory(Encoding.UTF8.GetBytes("mesh-p2p-msg")); - await peer1.SendAsync(pid2, data); - - var received = await ReceiveWithTimeoutAsync(peer2.ReceiveAsync(), TimeSpan.FromSeconds(5)); - received.Should().NotBeNull(); - Encoding.UTF8.GetString(received!.Data.Span).Should().Be("mesh-p2p-msg"); - received.SourceProcessId.Should().Be(pid1); + // 诊断日志开启(finally 复位):CI 失败时 stderr 输出 PIPE-ACCEPT/MESH/MESH-WRITE 全链, + // 丢帧断点直接可定位(TransportDiagnostics.Enabled 是进程级静态,仅本测试窗口内开启) + TransportDiagnostics.Enabled = true; + try { + var baseName = $"e2e-mesh-{Guid.NewGuid():N}"; + var pid1 = "300001"; + var pid2 = "300002"; + + await using var peer1 = new MeshTransport(basePipeName: baseName, processId: pid1); + await using var peer2 = new MeshTransport(basePipeName: baseName, processId: pid2); + + await peer1.StartAsync(); + await peer2.StartAsync(); + + var connected = await peer1.AddPeerAsync(pid2, CancellationToken.None); + connected.Should().BeTrue("应能连接到 peer2"); + + // 确定性发送:立即发送(对齐同文件双向测试的 CI 绿形态 — AddPeer 后无需延时等待; + // 数据经 peer1 连接缓存直写,peer2 侧读循环由管道内核缓冲保底,原先的 Task.Delay(100) + // 是时间耦合反模式:Windows 上碰巧够、Linux CI 慢机上无正确性保证) + var data = new ReadOnlyMemory(Encoding.UTF8.GetBytes("mesh-p2p-msg")); + await peer1.SendAsync(pid2, data); + + var received = await ReceiveWithTimeoutAsync(peer2.ReceiveAsync(), TimeSpan.FromSeconds(5)); + received.Should().NotBeNull(); + Encoding.UTF8.GetString(received!.Data.Span).Should().Be("mesh-p2p-msg"); + received.SourceProcessId.Should().Be(pid1); + } finally { + TransportDiagnostics.Enabled = false; + } } [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] diff --git a/modules/async_primitives/async_lock/transport/MeshTransport.cs b/modules/async_primitives/async_lock/transport/MeshTransport.cs index f2c4d888d..9e0c462ca 100644 --- a/modules/async_primitives/async_lock/transport/MeshTransport.cs +++ b/modules/async_primitives/async_lock/transport/MeshTransport.cs @@ -84,7 +84,13 @@ public async ValueTask SendAsync(string targetProcessId, ReadOnlyMemory da } var conn = await GetOrCreatePeerConnectionAsync(targetProcessId, ct).ConfigureAwait(false); - if (conn is null) return; + if (conn is null) { + // 守卫:连接失败静默丢消息是传输层健壮性缺陷(CI Linux 实测单向点对点丢帧的嫌疑路径)—— + // 诊断日志让丢消息点在 CI 输出直接可定位(TransportDiagnostics.Enabled 开启时) + TransportDiagnostics.Log("MESH", () => $"send dropped: connect failed, target={targetProcessId}, len={data.Length}"); + _logger?.LogWarning("MeshTransport: SendAsync 连接目标 {Peer} 失败,消息被丢弃(len={Len})", targetProcessId, data.Length); + return; + } var envelope = BinaryProtocol.Encode(MessageType.Data, ProcessId, targetProcessId, data); await conn.WriteAsync(envelope, ct).ConfigureAwait(false); @@ -123,6 +129,9 @@ public async ValueTask AddPeerAsync(string peerPid, CancellationToken ct = /// /// 获取或创建到目标进程的连接 — 连接缓存复用。 + /// 连接失败时有限重试(3 次退避):接收方 accept 循环是"单实例 accept 后才创建下一实例"的 + /// 串行模型(PipeAcceptLoop),重连恰好落在该窗口会 ECONNREFUSED — 退避重试穿透窗口, + /// 正常路径(首连即成)零额外开销(重试仅在失败分支触发)。 /// private async ValueTask GetOrCreatePeerConnectionAsync(string peerPid, CancellationToken ct) { if (Volatile.Read(ref _peerConnections).TryGetValue(peerPid, out var existing) && existing.IsConnected) { @@ -130,6 +139,30 @@ public async ValueTask AddPeerAsync(string peerPid, CancellationToken ct = } var pipeName = GetPeerPipeName(peerPid); + for (var attempt = 1; ; attempt++) { + var conn = await TryConnectPeerAsync(peerPid, pipeName, ct).ConfigureAwait(false); + if (conn is not null) { + return conn; + } + + if (attempt >= ConnectRetryLimit || ct.IsCancellationRequested) { + return null; + } + + // 退避重试 — 穿透对端 accept 循环的实例创建间隙(仅失败分支) + TransportDiagnostics.Log("MESH", () => $"connect retry {attempt}/{ConnectRetryLimit}: peer={peerPid}"); + await Task.Delay(ConnectRetryBackoffMs * attempt, ct).ConfigureAwait(false); + } + } + + /// 连接重试上限。 + private const int ConnectRetryLimit = 3; + + /// 连接重试退避基数(毫秒,按尝试次数线性放大)。 + private const int ConnectRetryBackoffMs = 100; + + /// 单次连接尝试 — 建管道 + 写握手 + 注册缓存;失败返回 null(重试由调用方决定)。 + private async ValueTask TryConnectPeerAsync(string peerPid, string pipeName, CancellationToken ct) { var client = NamedPipeFactory.CreateClient(pipeName); try { @@ -137,6 +170,7 @@ public async ValueTask AddPeerAsync(string peerPid, CancellationToken ct = connectCts.CancelAfter(TimeSpan.FromSeconds(5)); await client.ConnectAsync(connectCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { return null; } catch (Exception ex) when (ex is IOException or TimeoutException) { + TransportDiagnostics.Log("MESH", () => $"connect failed: peer={peerPid}, pipe={pipeName}, {ex.GetType().Name}: {ex.Message}"); _logger?.LogDebug("MeshTransport: failed to connect to peer {Peer} on {Pipe}", peerPid, pipeName); await client.DisposeAsync().ConfigureAwait(false); return null; @@ -151,6 +185,7 @@ public async ValueTask AddPeerAsync(string peerPid, CancellationToken ct = var conn = new MeshPeerConnection(peerPid, client, _logger); ImmutableInterlocked.Update(ref _peerConnections, d => d.SetItem(peerPid, conn)); + TransportDiagnostics.Log("MESH", () => $"connected to peer {peerPid} on {pipeName} (attempt ok)"); _logger?.LogDebug("MeshTransport: connected to peer {Peer} on {Pipe}", peerPid, pipeName); return conn; }