From d3e2cc1d84f2d3b6a76f6f84c8d87c0323a43969 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 04:15:04 +0800
Subject: [PATCH 01/17] =?UTF-8?q?fix:=20/model=20=E5=88=87=E6=8D=A2?=
=?UTF-8?q?=E5=90=8E=20banner=20=E5=BD=93=E5=89=8D=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E5=90=8C=E6=AD=A5=20|=20=E5=86=B3=E7=AD=96:=20EngineSession=20?=
=?UTF-8?q?=E6=8C=81=E6=9C=89=20checker/fastMode=20=E5=AD=97=E6=AE=B5?=
=?UTF-8?q?=E5=B9=B6=E6=9A=B4=E9=9C=B2=20RefreshSessionInfo=20=E9=87=8D?=
=?UTF-8?q?=E5=8F=91=20session=5Finfo=EF=BC=88applyModel=20=E5=A7=94?=
=?UTF-8?q?=E6=89=98=E7=BB=84=E5=90=88=20ApplyModel+=E5=88=B7=E6=96=B0?=
=?UTF-8?q?=EF=BC=89=EF=BC=8C=E8=80=8C=E9=9D=9E=E8=AE=A9=20ModelPickerEngi?=
=?UTF-8?q?ne=20=E4=BE=9D=E8=B5=96=E5=AE=BF=E4=B8=BB=E5=88=B7=E6=96=B0?=
=?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=9B=E6=B5=8B=E8=AF=95=E7=94=A8=E7=9C=9F?=
=?UTF-8?q?=E5=AE=9E=20FastModeService=20=E9=AA=8C=E8=AF=81=20FIFO=20?=
=?UTF-8?q?=E7=A1=AE=E5=AE=9A=E6=80=A7=EF=BC=88SetPrimaryModelCmd=20?=
=?UTF-8?q?=E5=85=88=E4=BA=8E=E6=9F=A5=E8=AF=A2=E5=91=BD=E4=BB=A4=E5=85=A5?=
=?UTF-8?q?=E9=98=9F=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
native_tui/apps/NativeTui.Host/EngineSession.cs | 15 +++++++++++----
native_tui/apps/NativeTui.Host/Program.cs | 8 ++++++--
.../NativeTui.Host.Tests/EngineSessionTests.cs | 16 ++++++++++++++++
.../tests/NativeTui.Host.Tests/GlobalUsings.cs | 2 ++
4 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs
index 34e43c418..c2b4e7bda 100644
--- a/native_tui/apps/NativeTui.Host/EngineSession.cs
+++ b/native_tui/apps/NativeTui.Host/EngineSession.cs
@@ -11,11 +11,15 @@ public sealed class EngineSession : IAsyncDisposable {
private readonly EngineSessionFactory.Result _engine;
private readonly EngineTurnClient _client;
private readonly TuiPermissionBridge _permissionBridge;
+ private readonly PermissionChecker? _checker;
+ private readonly IFastModeService? _fastMode;
- private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge) {
+ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge, PermissionChecker? checker, IFastModeService? fastMode) {
_engine = engine;
_client = client;
_permissionBridge = permissionBridge;
+ _checker = checker;
+ _fastMode = fastMode;
}
/// 回合客户端 — 主循环消费面(Submit/Interrupt/SetPermissionMode/DrainEvents)。
@@ -30,6 +34,9 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien
/// 当前会话标识 — 历史种子排除当前会话防双份。
public string SessionId => _engine.SessionId;
+ /// 重发 session_info 事件 — 模型切换等运行时状态变化后刷新 banner(ReplBridgeDriver.ApplySessionInfo 差分上屏)。
+ public void RefreshSessionInfo() => _client.Enqueue(BuildSessionInfoEvent(_checker, _fastMode));
+
/// 组装引擎会话 — 建会话(TuiInteractionModule 注册权限确认 handler)→ 解析权限/模型 → 构造回合客户端 → 种子 session_info;失败时释放已建引擎 Host。
public static async Task CreateAsync(CancellationToken cancellationToken = default) {
var permissionBridge = new TuiPermissionBridge();
@@ -52,7 +59,7 @@ public static async Task CreateAsync(CancellationToken cancellati
},
BuildPermissionModeApplier(checker));
- var session = new EngineSession(engine, client, permissionBridge);
+ var session = new EngineSession(engine, client, permissionBridge, checker, fastMode);
client.Enqueue(BuildSessionInfoEvent(checker, fastMode));
return session;
} catch {
@@ -70,9 +77,9 @@ public static async Task CreateAsync(CancellationToken cancellati
return checker;
}
- /// 组装会话握手事件 — 版本(引擎程序集)/当前模型(FastMode 主模型,未设置为 null)/
+ /// 组装会话握手事件(internal — 测试断言 SetPrimaryModel 后 Model 字段更新)。版本(引擎程序集)/当前模型(FastMode 主模型,未设置为 null)/
/// 权限模式(安全闸后的实际值)/bypass 可用性(!DisableBypassGate)/cwd。
- private static BridgeEvent.SessionInfo BuildSessionInfoEvent(PermissionChecker? checker, IFastModeService? fastMode) {
+ internal static BridgeEvent.SessionInfo BuildSessionInfoEvent(PermissionChecker? checker, IFastModeService? fastMode) {
var version = typeof(EngineSessionFactory).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0";
var mode = checker?.CurrentMode ?? EnginePermissionMode.Auto;
return new BridgeEvent.SessionInfo(
diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs
index 46328e885..e6bd58913 100644
--- a/native_tui/apps/NativeTui.Host/Program.cs
+++ b/native_tui/apps/NativeTui.Host/Program.cs
@@ -50,11 +50,15 @@ private static async Task RunInteractiveAsync() {
// banner 占位值 — 首拍 session_info 种子事件覆盖为引擎真实数据(EngineSession.Enqueue)
var banner = new BannerItem("0.0.0", "当前模型未设置", "API 按量计费", Environment.CurrentDirectory);
var commands = SlashCommandSource.Load(session.Services);
- // M7-6 模型选择器 — 装饰引擎拦截 /model(清单从引擎 IModelCatalog 后端获取,切换链自研)
+ // M7-6 模型选择器 — 装饰引擎拦截 /model(清单从引擎 IModelCatalog 后端获取,切换链自研);
+ // 切换成功后 RefreshSessionInfo 重发握手事件刷新 banner(当前模型显示同步)
var pickerEngine = new ModelPickerEngine(
session.Client,
() => ModelPickerAssembly.LoadItems(session.Services),
- id => ModelPickerAssembly.ApplyModel(session.Services, id));
+ id => {
+ ModelPickerAssembly.ApplyModel(session.Services, id);
+ session.RefreshSessionInfo();
+ });
var shell = new ReplShell(pickerEngine, banner, tuiOutput, commands: commands);
pickerEngine.Attach(shell);
await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false);
diff --git a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
index 8afa59238..48339a2cb 100644
--- a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
@@ -40,4 +40,20 @@ public void TranslateTurnResult_UnknownError_FallsBackToDefaultMessage() {
var invalid = Assert.IsType(ex);
Assert.Contains("未知错误", invalid.Message);
}
+
+ /// session_info 事件构造(修复验收缺陷:/model 切换后 banner 不同步):
+ /// SetPrimaryModel 后 BuildSessionInfoEvent 反映新模型 — ApplyModel 委托刷新 banner 的数据基础。
+ /// FIFO 保证:SetPrimaryModelCmd 先入队,GetPrimaryModelQuery 后入队,QuerySync 返回时状态已更新(确定性无时序)。
+ /// ⚠️ Dispose 前完成全部查询 — TrySend 在释放后静默返回 false,QuerySync 将永久阻塞。
+ [Fact]
+ public async Task BuildSessionInfoEvent_AfterModelSwitch_ReflectsNewModel() {
+ await using var fastMode = new FastModeService(
+ config: new WorkflowConfig { Provider = new ProviderConfig { ModelId = "initial-model" } });
+ var before = EngineSession.BuildSessionInfoEvent(checker: null, fastMode);
+ Assert.Equal("initial-model", before.Model);
+
+ fastMode.SetPrimaryModel("switched-model");
+ var after = EngineSession.BuildSessionInfoEvent(checker: null, fastMode);
+ Assert.Equal("switched-model", after.Model);
+ }
}
diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
index e5770fb43..e707ac6f4 100644
--- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
@@ -1,7 +1,9 @@
global using System.Text;
global using System.Collections.Concurrent;
+global using Core.Configuration;
global using JoinCode;
global using JoinCode.Abstractions.Configuration;
+global using JoinCode.Abstractions.Configuration.Providers;
global using JoinCode.Abstractions.Interfaces;
global using JoinCode.Abstractions.LLM.Chat;
global using JoinCode.Abstractions.Models.Diff;
From 09071071fd61655e0e44522ef316dd42486b1f01 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 04:18:46 +0800
Subject: [PATCH 02/17] =?UTF-8?q?docs:=20TASK057=20=E8=A1=A5=E9=AA=8C?=
=?UTF-8?q?=E6=94=B6=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D=E7=AB=A0=E8=8A=82?=
=?UTF-8?q?=EF=BC=88=E5=8F=8C=E6=BA=90=E6=B6=88=E9=99=A4+banner=E5=90=8C?=
=?UTF-8?q?=E6=AD=A5+QuerySync=E8=B8=A9=E5=9D=91=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...217\347\256\200\345\214\226\351\241\271.md" | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git "a/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" "b/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md"
index 86b5dc472..f32c96ac6 100644
--- "a/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md"
+++ "b/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md"
@@ -34,3 +34,21 @@
+
+## 验收缺陷修复(2026-10-21 用户真机验收反馈)
+
+| # | 缺陷 | 根因 | 修复 | 提交 |
+|---|------|------|------|------|
+| 1 | `/theme d` 无效果(值域缺失) | TUI 硬编码 theme=3值 vs 引擎 `[ChatCommandArg]` 真值 7 值 — 参数值域双源 | `SlashCommandMetadata.Arguments` 字段 + 生成器从 `[ChatCommandArg].Enum` 提取注入,TUI 删硬编码改消费元数据 | 0e19963c |
+| 2 | `/model` 切换后 banner 模型名不更新 | session_info 只在启动发一次,`ApplyModel` 后无人重发 | `EngineSession` 持有 checker/fastMode 字段 + `RefreshSessionInfo()` 重发;Program 的 applyModel 委托组合 ApplyModel+刷新 | 041013bd3 |
+
+- Bug2 测试:`EngineSessionTests.BuildSessionInfoEvent_AfterModelSwitch_ReflectsNewModel`(真实 FastModeService,FIFO 确定性)
+- 踩坑:QuerySync 在 Actor Dispose 后 TrySend 静默返回 false → 永久阻塞(测试必须 Dispose 前完成查询,用 `await using var`)
+- 基线更新:NativeTui.Host.Tests 45 → **47**;Host exe 已 `--no-incremental` 全量重建供复验
+
+
+
+
+
+
+
From 895a6761cb6b8a4b047ef1039e7ecbb417914250 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 04:35:53 +0800
Subject: [PATCH 03/17] =?UTF-8?q?feat:=20banner=20=E4=BE=9B=E5=BA=94?=
=?UTF-8?q?=E5=95=86=E6=98=BE=E7=A4=BA=E9=93=BE=EF=BC=88SessionInfo.Vendor?=
=?UTF-8?q?=20=E5=AD=97=E6=AE=B5+BannerItem=20vendor=20=E5=89=8D=E7=BC=80?=
=?UTF-8?q?=E6=B8=B2=E6=9F=93=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20Session?=
=?UTF-8?q?Info=20=E6=9C=AB=E5=B0=BE=E5=8F=AF=E9=80=89=E5=8F=82=E6=95=B0?=
=?UTF-8?q?=E9=9B=B6=E7=A0=B4=E5=9D=8F=E6=97=A7=E8=B0=83=E7=94=A8=EF=BC=8C?=
=?UTF-8?q?Vendor=20=E7=A9=BA=E9=80=80=E5=8C=96=E6=97=A0=E5=89=8D=E7=BC=80?=
=?UTF-8?q?=EF=BC=88=E6=97=A7=E5=BC=95=E6=93=8E=E5=85=BC=E5=AE=B9=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/NativeTui.Core/bridge/BridgeEvent.cs | 7 ++++---
.../NativeTui.Core/bridge/ReplBridgeDriver.cs | 9 +++++----
.../src/NativeTui.Core/repl/BannerItem.cs | 8 ++++++--
.../bridge/ReplBridgeDriverTests.cs | 19 +++++++++++++++++++
4 files changed, 34 insertions(+), 9 deletions(-)
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
index 9a0837df2..80ad0a28c 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
@@ -26,10 +26,11 @@ namespace NativeTui.Core.Bridge;
public abstract record BridgeEvent
{
///
- /// 会话握手(引擎启动后首条事件)— 版本/当前模型/权限模式/bypass 可用性/cwd。
- /// Model 为 null 表示引擎未设置模型(banner 显示"当前模型未设置")。
+ /// 会话握手(引擎启动后首条事件)— 版本/当前模型/权限模式/bypass 可用性/cwd/供应商。
+ /// Model 为 null 表示引擎未设置模型(banner 显示"当前模型未设置");
+ /// Vendor 为 null 表示引擎未上报(banner 退化为不带供应商前缀,旧引擎兼容)。
///
- public sealed record SessionInfo(string Version, string? Model, string PermissionMode, bool BypassAvailable, string Cwd) : BridgeEvent;
+ public sealed record SessionInfo(string Version, string? Model, string PermissionMode, bool BypassAvailable, string Cwd, string? Vendor = null) : 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 3c130749c..2de748ec8 100644
--- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
+++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
@@ -52,8 +52,8 @@ public void Apply(BridgeEvent bridgeEvent)
{
switch (bridgeEvent)
{
- case BridgeEvent.SessionInfo(var version, var model, var permissionMode, var bypassAvailable, var cwd):
- ApplySessionInfo(version, model, permissionMode, bypassAvailable, cwd);
+ case BridgeEvent.SessionInfo(var version, var model, var permissionMode, var bypassAvailable, var cwd, var vendor):
+ ApplySessionInfo(version, model, permissionMode, bypassAvailable, cwd, vendor);
break;
case BridgeEvent.PermissionModeChanged(var mode):
if (PermissionModeInfo.TryFromValue(mode, out var parsed))
@@ -141,11 +141,12 @@ private void ApplySubagentStarted(string subagentId, string agentType, string de
_spinner.HasActiveTools = true;
}
- /// 应用会话握手:banner 换引擎真实数据(模型 null → "当前模型未设置")+ footer 初始模式 + bypass 可用性。
- private void ApplySessionInfo(string version, string? model, string permissionMode, bool bypassAvailable, string cwd)
+ /// 应用会话握手:banner 换引擎真实数据(模型 null → "当前模型未设置";供应商 null → 空,banner 退化无前缀)+ footer 初始模式 + bypass 可用性。
+ private void ApplySessionInfo(string version, string? model, string permissionMode, bool bypassAvailable, string cwd, string? vendor)
{
_banner.Version = version;
_banner.Model = string.IsNullOrWhiteSpace(model) ? "当前模型未设置" : model;
+ _banner.Vendor = vendor ?? string.Empty;
_banner.Cwd = cwd;
BypassAvailable = bypassAvailable;
if (PermissionModeInfo.TryFromValue(permissionMode, out var parsed))
diff --git a/native_tui/src/NativeTui.Core/repl/BannerItem.cs b/native_tui/src/NativeTui.Core/repl/BannerItem.cs
index b372ced91..47ec3a196 100644
--- a/native_tui/src/NativeTui.Core/repl/BannerItem.cs
+++ b/native_tui/src/NativeTui.Core/repl/BannerItem.cs
@@ -25,6 +25,9 @@ public sealed class BannerItem(string version, string model, string billing, str
/// 模型名(引擎未设置时为"当前模型未设置")。
public string Model { get; set; } = model;
+ /// 供应商(引擎上报时 banner 第二行显示 "{vendor}/{model}";空退化无前缀)。
+ public string Vendor { get; set; } = string.Empty;
+
/// 计费方式(无真实登录态可查,模拟配置)。
public string Billing { get; set; } = billing;
@@ -55,11 +58,12 @@ public void Render(Screen screen, Rect frame, Theme theme)
screen.WriteText(row + 1, frame.X + 7, "▛▘", body, right);
screen.WriteText(row + 2, frame.X, " ▘▘ ▝▝ ", body, right);
- // 信息列:bold 名 + 默认空格 + dim 版本 / dim 模型·计费 / dim cwd
+ // 信息列:bold 名 + 默认空格 + dim 版本 / dim 供应商/模型·计费 / dim cwd
var col = screen.WriteText(row, right, Title, bold, infoRight);
col = screen.WriteText(row, col, " ", 0, infoRight);
screen.WriteText(row, col, $"v{Version}", dim, infoRight);
- screen.WriteText(row + 1, right, $"{Model} · {Billing}", dim, infoRight);
+ var vendorPrefix = Vendor.Length > 0 ? $"{Vendor}/" : string.Empty;
+ screen.WriteText(row + 1, right, $"{vendorPrefix}{Model} · {Billing}", dim, infoRight);
screen.WriteText(row + 2, right, Cwd, dim, infoRight);
}
}
diff --git a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
index aa6d590f4..d8307de64 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
@@ -43,6 +43,25 @@ public void Apply_SessionInfoWithoutModel_ShowsNotSetPlaceholder()
Assert.True(_driver.BypassAvailable);
}
+ [Fact]
+ public void Apply_SessionInfoWithVendor_UpdatesBannerVendor()
+ {
+ _driver.Apply(new BridgeEvent.SessionInfo("2.1.303", "glm-4.7", "auto", BypassAvailable: true, @"D:\wd", Vendor: "zhipu"));
+
+ Assert.Equal("zhipu", _banner.Vendor);
+ Assert.Equal("glm-4.7", _banner.Model);
+ }
+
+ [Fact]
+ public void Apply_SessionInfoWithoutVendor_ClearsBannerVendor()
+ {
+ _banner.Vendor = "zhipu";
+
+ _driver.Apply(new BridgeEvent.SessionInfo("2.1.303", "glm-4.7", "auto", BypassAvailable: true, @"D:\wd"));
+
+ Assert.Equal(string.Empty, _banner.Vendor);
+ }
+
[Fact]
public void Apply_SessionInfoWithUnknownMode_KeepsFooterMode()
{
From 6e42ace9647e0f7b9f7f5821e1b07f3fad082454 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 04:26:05 +0800
Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=E5=AD=90=E4=BB=A3=E7=90=86?=
=?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=B1=82=20Step1=20=E6=B6=88=E6=81=AF?=
=?UTF-8?q?=E5=85=89=E6=A0=87=E5=AF=BC=E8=88=AA=EF=BC=88MessageCursor=20?=
=?UTF-8?q?=E5=8D=8F=E8=B0=83=E5=99=A8=20+=20=E7=AC=AC=E5=9B=9B=E4=BA=A4?=
=?UTF-8?q?=E4=BA=92=E6=80=81=20+=20=E9=80=89=E4=B8=AD=E9=AB=98=E4=BA=AE?=
=?UTF-8?q?=20+=20=E5=88=86=E7=BB=84=E5=B1=95=E5=BC=80=EF=BC=89=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E4=BA=A4=E4=BA=92=E7=BB=93=E6=9E=84?=
=?UTF-8?q?=E7=85=A7=E6=8A=84=20ModelPicker/=E6=9D=83=E9=99=90=E5=AF=B9?=
=?UTF-8?q?=E8=AF=9D=E6=A1=86=E6=A8=A1=E6=80=81=E5=85=88=E4=BE=8B=EF=BC=88?=
=?UTF-8?q?ReplShell=20=E7=AC=AC=E5=9B=9B=E4=BA=A4=E4=BA=92=E6=80=81?=
=?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7=E5=B1=85=E6=90=9C=E7=B4=A2=E4=B9=8B?=
=?UTF-8?q?=E5=90=8E=EF=BC=89=EF=BC=8C=E5=8F=AF=E5=AF=BC=E8=88=AA=E5=88=A4?=
=?UTF-8?q?=E5=AE=9A=E7=94=A8=20ICursorNavigable=20=E7=A9=BA=E6=A0=87?=
=?UTF-8?q?=E8=AE=B0=E6=8E=A5=E5=8F=A3=EF=BC=88banner/spinner/=E5=AF=B9?=
=?UTF-8?q?=E8=AF=9D=E6=A1=86=E6=8E=92=E9=99=A4=EF=BC=89=EF=BC=8C=E9=AB=98?=
=?UTF-8?q?=E4=BA=AE=E8=B5=B0=20ScrollBox=20=E6=B8=B2=E6=9F=93=E5=90=8E?=
=?UTF-8?q?=E6=95=B4=E5=9D=97=E8=83=8C=E6=99=AF=E8=A6=86=E5=86=99=20Messag?=
=?UTF-8?q?eActionsBackground=EF=BC=88=E5=89=8D=E6=99=AF=E4=BF=9D=E7=95=99?=
=?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=E6=B6=88=E6=81=AF?=
=?UTF-8?q?=E9=80=89=E4=B8=AD=E8=AF=AD=E4=B9=89=EF=BC=89=EF=BC=8C=E5=88=86?=
=?UTF-8?q?=E7=BB=84=E5=B1=95=E5=BC=80=E8=90=BD=20Coordinator.IsExpanded?=
=?UTF-8?q?=EF=BC=88=E5=B1=95=E5=BC=80=E5=85=A8=E9=87=8F=E6=B8=B2=E6=9F=93?=
=?UTF-8?q?=E7=AA=81=E7=A0=B4=20min(10,n)=20=E7=AA=97=E5=8F=A3+=E7=AA=97?=
=?UTF-8?q?=E5=8F=A3=E5=BD=92=E9=9B=B6=EF=BC=8C=E5=AF=B9=E9=BD=90=E5=9F=BA?=
=?UTF-8?q?=E5=87=86=20Enter=20=E5=B1=95=E5=BC=80=20grouped=5Ftool=5Fuse?=
=?UTF-8?q?=E2=86=92verbose=20=E6=8B=86=E6=9D=A1=E8=AF=AD=E4=B9=89?=
=?UTF-8?q?=EF=BC=89=EF=BC=9B=E6=B5=8B=E8=AF=95=2024=20=E4=B8=AA=E5=85=A8?=
=?UTF-8?q?=E7=BB=BF+=E5=85=A8=E9=87=8F=20775=20=E9=80=9A=E8=BF=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../NativeTui.Core/components/ScrollBox.cs | 25 +
.../repl/MarkdownMessageItem.cs | 2 +-
.../src/NativeTui.Core/repl/MessageCursor.cs | 155 ++++++
.../src/NativeTui.Core/repl/MessageItems.cs | 12 +-
.../src/NativeTui.Core/repl/ReplShell.cs | 132 ++++-
.../src/NativeTui.Core/repl/SubagentGroup.cs | 30 +-
.../NativeTui.Core/repl/SubagentGroupItem.cs | 2 +-
.../repl/MessageCursorTests.cs | 527 ++++++++++++++++++
8 files changed, 871 insertions(+), 14 deletions(-)
create mode 100644 native_tui/src/NativeTui.Core/repl/MessageCursor.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/MessageCursorTests.cs
diff --git a/native_tui/src/NativeTui.Core/components/ScrollBox.cs b/native_tui/src/NativeTui.Core/components/ScrollBox.cs
index abb73960a..f3f570009 100644
--- a/native_tui/src/NativeTui.Core/components/ScrollBox.cs
+++ b/native_tui/src/NativeTui.Core/components/ScrollBox.cs
@@ -35,6 +35,10 @@ public ScrollBox(bool stickyScroll = false)
/// 当前滚动位置(上次渲染结算后的值)。
public int ScrollTop { get; private set; }
+ /// 选中高亮项(消息光标导航 — 交互层 Step1)— 渲染后整块背景覆写
+ /// MessageActionsBackground(前景字符保留);null = 无高亮。基准消息选中背景语义。
+ public IScrollItem? HighlightItem { get; set; }
+
/// 追加内容项(钉底/跟随在下次 Render 时结算)。
public void Add(IScrollItem item) => _items.Add(item);
@@ -95,6 +99,10 @@ public void Render(Screen screen, Rect frame, Theme theme)
{
var itemFrame = new Rect(frame.X, frame.Y + itemTop - ScrollTop, frame.Width, heights[i]);
_items[i].Render(screen, itemFrame, theme);
+ if (ReferenceEquals(_items[i], HighlightItem))
+ {
+ ApplyHighlightBackground(screen, itemFrame, theme);
+ }
}
itemTop = itemBottom;
@@ -103,4 +111,21 @@ public void Render(Screen screen, Rect frame, Theme theme)
_lastMaxScroll = maxScroll;
_lastScrollTop = ScrollTop;
}
+
+ /// 整块背景覆写 — 选中项 frame 区域全行换 MessageActionsBackground(字符与前景保留,
+ /// 越界写入由 Screen 索引器忽略裁剪;含 margin 行 — 基准 React 包裹整条消息语义)。
+ private static void ApplyHighlightBackground(Screen screen, Rect frame, Theme theme)
+ {
+ for (var row = frame.Y; row < frame.Y + frame.Height; row++)
+ {
+ for (var col = frame.X; col < frame.X + frame.Width; col++)
+ {
+ var cell = screen[row, col];
+ screen[row, col] = cell with
+ {
+ StyleId = screen.Styles.GetOrAdd(screen.Styles.Get(cell.StyleId) with { Background = theme.MessageActionsBackground }),
+ };
+ }
+ }
+ }
}
diff --git a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
index 9dab7a353..bc3faf4f6 100644
--- a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
+++ b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
@@ -7,7 +7,7 @@ namespace NativeTui.Core.Repl;
/// 段样式经 MessageRenderer.MapMarkdownStyle 映射(Code → permission 色,对齐基准 codespan);
/// 代码行高亮在段写完后按色段覆写前景(M7-5 — 高亮器由宿主注入,null = 降级纯文本)。
///
-public sealed class MarkdownMessageItem(MarkdownDocument document, ICodeHighlighter? highlighter = null) : IScrollItem
+public sealed class MarkdownMessageItem(MarkdownDocument document, ICodeHighlighter? highlighter = null) : IScrollItem, ICursorNavigable
{
/// Markdown 正文缩进列(● + 空格,对齐基准 NoSelect minWidth 2)。
private const int BodyIndentCols = 2;
diff --git a/native_tui/src/NativeTui.Core/repl/MessageCursor.cs b/native_tui/src/NativeTui.Core/repl/MessageCursor.cs
new file mode 100644
index 000000000..fd8723ece
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/repl/MessageCursor.cs
@@ -0,0 +1,155 @@
+namespace NativeTui.Core.Repl;
+
+///
+/// 可被消息光标导航的滚动项标记(交互层 Step1)— 消息类消息实现(用户/助手/工具调用/工具结果/
+/// diff 结果/错误/Markdown/子代理分组);banner、spinner、权限/模型对话框等装饰项不参与导航。
+///
+public interface ICursorNavigable
+{
+}
+
+///
+/// 消息光标导航协调器(交互层 Step1)— 对齐基准消息导航系统(MESSAGE_ACTIONS feature flag 上下文):
+/// Shift+↑ 进入(Begin 定位最后一条用户消息,基准 enterCursor scan;无用户消息回退最后一条可导航)、
+/// ↑/↓/k/j 相邻可导航项逐条移动(跳过非导航装饰项,clamp 不环绕)、Shift+↑/↓ 跳相邻用户消息、
+/// Ctrl+↑/↓ 首末可导航项、Esc 退出。选中索引为滚动区全局索引(高亮覆写与分组展开直接定位项实例);
+/// 消息动态增删安全 — 每次操作传入当前项列表(协调器不持有列表引用)。
+/// 状态机同 ModelPickerCoordinator 先例(Begin 建立 / Exit 清空幂等)。
+/// 消费方:ReplShell 第四交互态模态路由(HandleCursorNavKey)+ ScrollBox 高亮投影。
+///
+public sealed class MessageCursor
+{
+ /// 光标导航激活态(ReplShell 第四交互态路由依据)。
+ public bool IsCursorActive { get; private set; }
+
+ /// 选中项在滚动区的全局索引(-1 = 无选中)。
+ public int SelectedIndex { get; private set; } = -1;
+
+ /// Begin 进入 — 定位最后一条用户消息(基准 enterCursor:向回扫描最后一条 user 角色);
+ /// 无用户消息回退最后一条可导航消息;无可导航消息不激活(banner+spinner 空会话)。
+ public void Begin(IReadOnlyList items)
+ {
+ var lastUser = -1;
+ var lastNavigable = -1;
+ for (var i = 0; i < items.Count; i++)
+ {
+ if (items[i] is not ICursorNavigable)
+ {
+ continue;
+ }
+
+ lastNavigable = i;
+ if (items[i] is UserMessageItem)
+ {
+ lastUser = i;
+ }
+ }
+
+ var target = lastUser >= 0 ? lastUser : lastNavigable;
+ IsCursorActive = target >= 0;
+ SelectedIndex = target;
+ }
+
+ /// 相邻可导航项移动 — 向 delta 方向扫描最近可导航项(跳过 banner/spinner 装饰项);
+ /// 方向上无可导航项保持不动(clamp 不环绕,对齐基准列表语义)。
+ public void Move(IReadOnlyList items, int delta)
+ {
+ if (!IsCursorActive)
+ {
+ return;
+ }
+
+ var step = Math.Sign(delta);
+ if (step == 0)
+ {
+ return;
+ }
+
+ var target = SelectedIndex;
+ for (var i = SelectedIndex + step; i >= 0 && i < items.Count; i += step)
+ {
+ if (items[i] is ICursorNavigable)
+ {
+ target = i;
+ break;
+ }
+ }
+
+ SelectedIndex = target;
+ }
+
+ /// 跳相邻用户消息(Shift+↑/↓ — 基准消息光标跳转 user 角色消息);
+ /// 指定方向无用户消息保持不动。
+ public void MoveToUserMessage(IReadOnlyList items, int direction)
+ {
+ if (!IsCursorActive)
+ {
+ return;
+ }
+
+ var step = Math.Sign(direction);
+ if (step == 0)
+ {
+ return;
+ }
+
+ for (var i = SelectedIndex + step; i >= 0 && i < items.Count; i += step)
+ {
+ if (items[i] is UserMessageItem)
+ {
+ SelectedIndex = i;
+ return;
+ }
+ }
+ }
+
+ /// 跳首/末可导航消息(Ctrl+↑/↓);无可导航项保持不动(动态删空防御)。
+ public void MoveToEdge(IReadOnlyList items, bool first)
+ {
+ if (!IsCursorActive)
+ {
+ return;
+ }
+
+ var target = first ? FirstNavigable(items) : LastNavigable(items);
+ if (target >= 0)
+ {
+ SelectedIndex = target;
+ }
+ }
+
+ /// Exit 退出 — 清空选中(幂等 — 二次调用 no-op)。
+ public void Exit()
+ {
+ IsCursorActive = false;
+ SelectedIndex = -1;
+ }
+
+ /// 首个可导航项索引(-1 = 无)。
+ private static int FirstNavigable(IReadOnlyList items)
+ {
+ for (var i = 0; i < items.Count; i++)
+ {
+ if (items[i] is ICursorNavigable)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ /// 末个可导航项索引(-1 = 无)。
+ private static int LastNavigable(IReadOnlyList items)
+ {
+ for (var i = items.Count - 1; i >= 0; i--)
+ {
+ if (items[i] is ICursorNavigable)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+}
diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs
index 285426f1f..2536301b2 100644
--- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs
+++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs
@@ -6,7 +6,7 @@ namespace NativeTui.Core.Repl;
///
/// 用户消息项(整行灰底 + ❯ subtle + 正文)。
-public sealed class UserMessageItem(string text) : IScrollItem
+public sealed class UserMessageItem(string text) : IScrollItem, ICursorNavigable
{
/// 内容高度 = margin 1 + 正文 1。
public int MeasureHeight(int width) => 2;
@@ -17,7 +17,7 @@ public void Render(Screen screen, Rect frame, Theme theme) =>
}
/// 助手文本消息项(● text 色 + 正文)。
-public sealed class AssistantMessageItem(string text) : IScrollItem
+public sealed class AssistantMessageItem(string text) : IScrollItem, ICursorNavigable
{
/// 正文(宿主反馈消息断言面 — M7-6 模型切换反馈等)。
public string Text => text;
@@ -31,7 +31,7 @@ public void Render(Screen screen, Rect frame, Theme theme) =>
}
/// 工具调用消息项(● + bold 工具名 + (参数摘要))。
-public sealed class ToolCallItem(string toolName, string args) : IScrollItem
+public sealed class ToolCallItem(string toolName, string args) : IScrollItem, ICursorNavigable
{
/// 内容高度 = margin 1 + 正文 1。
public int MeasureHeight(int width) => 2;
@@ -42,7 +42,7 @@ public void Render(Screen screen, Rect frame, Theme theme) =>
}
/// 工具结果消息项(" ⎿ " dim 前缀 + 正文)。
-public sealed class ToolResultItem(string text) : IScrollItem
+public sealed class ToolResultItem(string text) : IScrollItem, ICursorNavigable
{
/// 内容高度 = margin 1 + 正文 1。
public int MeasureHeight(int width) => 2;
@@ -59,7 +59,7 @@ public void Render(Screen screen, Rect frame, Theme theme) =>
/// 仅超 10 行预览上限折叠为 "… +N lines (Ctrl+O)" dim(上限对齐基准 Write MAX_LINES_TO_RENDER=10 防刷屏先例,
/// Ctrl+O 展开交互为 M7 后续项)。
///
-public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollItem
+public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollItem, ICursorNavigable
{
/// patch 预览行数上限(summary 之外;超限折叠 — 对齐基准 Write MAX_LINES_TO_RENDER=10 先例)。
private const int PreviewPatchRows = 10;
@@ -121,7 +121,7 @@ public void Render(Screen screen, Rect frame, Theme theme)
}
/// 错误消息项(● theme.Error 红色 + 正文,bridge error 事件载体)。
-public sealed class ErrorMessageItem(string text) : IScrollItem
+public sealed class ErrorMessageItem(string text) : IScrollItem, ICursorNavigable
{
/// 错误正文。
public string Text { get; } = text;
diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index 16b70d415..99f4af321 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;
+ /// 消息光标导航协调器(交互层 Step1 — 第四交互态,优先级低于权限/模型/搜索模态)。
+ private readonly MessageCursor _cursorNav = new();
+
/// 进入搜索前的输入草稿(Esc 退出恢复)。
private string _preSearchDraft = string.Empty;
@@ -131,6 +134,9 @@ public ReplShell(
/// 历史反向搜索激活态(Ctrl+R — 测试断言面)。
internal bool IsSearching => _search is not null;
+ /// 消息光标导航协调器(交互层 Step1 — 测试断言面)。
+ internal MessageCursor CursorNav => _cursorNav;
+
/// 输入历史环(测试断言面)。
internal HistoryRing History => _history;
@@ -287,7 +293,8 @@ internal void HandleKey(ConsoleKeyInfo keyInfo)
}
/// 按键分派 — 权限对话框模态优先(数字/Esc/上下/Enter 路由对话框)→ 模型选择器模态
- /// (M7-6:Esc/上下/Enter 路由选择器)→ 补全激活专属键
+ /// (M7-6:Esc/上下/Enter 路由选择器)→ 搜索模态(Ctrl+R 反向搜索)→ 消息光标导航模态
+ /// (交互层 Step1:光标激活时 ↑↓/kj/Shift 跳转/Enter 展开/Esc 两级退出)→ 补全激活专属键
/// (Up/Down 导航/Tab 接受/Enter 执行/Esc 关闭,基准 Autocomplete 上下文优先于 Chat),
/// 其余 Emacs 编辑(Ctrl/Meta 词操作 + kill ring),控制键走编辑/提交/权限,字符插入。
private void HandleKeyPress(KeyPress key)
@@ -310,11 +317,23 @@ private void HandleKeyPress(KeyPress key)
return;
}
+ if (_cursorNav.IsCursorActive)
+ {
+ HandleCursorNavKey(key);
+ return;
+ }
+
if (HandleSuggestionKey(key))
{
return;
}
+ if (key is { Kind: KeyKind.UpArrow, Shift: true, Ctrl: false, Meta: false })
+ {
+ BeginCursorNavigation();
+ return;
+ }
+
if (key.Kind == KeyKind.Escape)
{
HandleEscape();
@@ -649,6 +668,106 @@ private void HandleSearchKey(KeyPress key)
}
}
+ ///
+ /// 消息光标导航模态按键(交互层 Step1,对齐基准 MESSAGE_ACTIONS 上下文键绑定):
+ /// ↑/↓/k/j 相邻可导航消息逐条移动、Shift+↑/↓ 跳相邻用户消息、Ctrl+↑/↓ 首末可导航消息、
+ /// Enter 切换选中分组展开态(基准展开 grouped_tool_use → verbose 逐代理详细渲染)、
+ /// Esc 两级退出(任一分组展开先全部收起、再退出导航);
+ /// 其余按键全部吞掉(模态 — 基准全屏模式 PromptInput 卸载语义,不进输入缓冲)。
+ ///
+ private void HandleCursorNavKey(KeyPress key)
+ {
+ if (key.Kind == KeyKind.Return)
+ {
+ ToggleSelectedGroupExpansion();
+ return;
+ }
+
+ if (key.Kind == KeyKind.Escape)
+ {
+ EscapeCursorNavigation();
+ return;
+ }
+
+ var items = _view.Messages.Items;
+ if (key is { Kind: KeyKind.UpArrow, Shift: true, Ctrl: false, Meta: false })
+ {
+ _cursorNav.MoveToUserMessage(items, -1);
+ return;
+ }
+
+ if (key is { Kind: KeyKind.DownArrow, Shift: true, Ctrl: false, Meta: false })
+ {
+ _cursorNav.MoveToUserMessage(items, 1);
+ return;
+ }
+
+ if (key is { Kind: KeyKind.UpArrow, Ctrl: true, Shift: false, Meta: false })
+ {
+ _cursorNav.MoveToEdge(items, first: true);
+ return;
+ }
+
+ if (key is { Kind: KeyKind.DownArrow, Ctrl: true, Shift: false, Meta: false })
+ {
+ _cursorNav.MoveToEdge(items, first: false);
+ return;
+ }
+
+ switch (key.Kind)
+ {
+ case KeyKind.UpArrow:
+ _cursorNav.Move(items, -1);
+ return;
+ case KeyKind.DownArrow:
+ _cursorNav.Move(items, 1);
+ return;
+ case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "k":
+ _cursorNav.Move(items, -1);
+ return;
+ case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "j":
+ _cursorNav.Move(items, 1);
+ return;
+ default:
+ // 模态:其余键吞掉(不进输入缓冲 — 基准光标态 PromptInput 卸载)
+ return;
+ }
+ }
+
+ /// 进入消息光标导航(Shift+↑ — 定位最后一条用户消息;无可导航消息 no-op)。
+ private void BeginCursorNavigation() => _cursorNav.Begin(_view.Messages.Items);
+
+ /// Enter 切换选中分组展开态(选中非分组消息 no-op — 基准仅 grouped_tool_use 可展开)。
+ private void ToggleSelectedGroupExpansion()
+ {
+ if (_cursorNav.SelectedIndex is >= 0 and var index
+ && index < _view.Messages.Count
+ && _view.Messages.Items[index] is SubagentGroupItem group)
+ {
+ group.Coordinator.IsExpanded = !group.Coordinator.IsExpanded;
+ }
+ }
+
+ /// Esc 两级退出 — 任一分组展开先全部收起(导航保持);否则退出光标导航。
+ private void EscapeCursorNavigation()
+ {
+ var expandedGroups = _view.Messages.Items
+ .OfType()
+ .Where(static group => group.Coordinator.IsExpanded)
+ .ToList();
+ if (expandedGroups.Count > 0)
+ {
+ foreach (var group in expandedGroups)
+ {
+ group.Coordinator.IsExpanded = false;
+ }
+
+ return;
+ }
+
+ _cursorNav.Exit();
+ }
+
/// 补全激活时的专属键拦截(基准 Autocomplete 上下文):
/// 下拉激活:Up/Down 循环导航、Tab 接受不执行、Enter 接受并提交、Esc 仅关建议(优先于打断/清输入/退出);
/// ghost 激活(下拉互斥为空):RightArrow(光标在末尾)接受幽灵补全、Esc 仅关 ghost;
@@ -819,6 +938,7 @@ internal void Submit()
internal void RenderAndFlush()
{
SyncSuggestionState();
+ SyncCursorHighlight();
EnsureScreens();
_back.Clear();
AdvanceAnimClock();
@@ -885,6 +1005,16 @@ private void SyncSuggestionState()
_view.GhostSuffix = _search is { Current.Length: > 0 } ? _search.Current : _suggester?.GhostSuffix ?? string.Empty;
}
+ /// 同步消息光标选中高亮到滚动区(渲染前状态投影 — ScrollBox 渲染后整块背景覆写)。
+ private void SyncCursorHighlight()
+ {
+ _view.Messages.HighlightItem = _cursorNav.IsCursorActive
+ && _cursorNav.SelectedIndex is >= 0 and var index
+ && index < _view.Messages.Count
+ ? _view.Messages.Items[index]
+ : null;
+ }
+
///
/// 尺寸计算(含首帧)成对重建双缓冲 — 尺寸取自注入源(resize 事件化,不读 Console 静态环境);
/// inline 屏高 = 内容总高(消息全量展示,可超视口 — 终端 rows 非 resize 敏感维度),
diff --git a/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs
index 309d1dd08..ad8645817 100644
--- a/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs
+++ b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs
@@ -48,11 +48,26 @@ public sealed class SubagentGroupCoordinator
/// 条目总数。
public int Count => _agents.Count;
- /// 可见条数 = min(10, n)。
- public int VisibleCount => Math.Min(MaxVisible, _agents.Count);
+ /// 展开态(消息光标 Enter 切换 — 交互层 Step1)— true = 全量渲染突破 min(10,n) 窗口
+ /// (对齐基准 Enter 展开 grouped_tool_use → verbose 不分组 → 逐代理独立详细渲染);
+ /// 收起时窗口归零回到组头视角(后续事件活动跟随自动拉回)。
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set
+ {
+ _isExpanded = value;
+ WindowStart = 0;
+ }
+ }
+
+ private bool _isExpanded;
- /// 折叠条数 = max(0, n - 10)("and N more…" 行依据)。
- public int HiddenCount => Math.Max(0, _agents.Count - VisibleCount);
+ /// 可见条数 = 展开全量,折叠 min(10, n)。
+ public int VisibleCount => _isExpanded ? _agents.Count : Math.Min(MaxVisible, _agents.Count);
+
+ /// 折叠条数 = 展开恒 0,折叠 max(0, n - 10)("and N more…" 行依据)。
+ public int HiddenCount => _isExpanded ? 0 : Math.Max(0, _agents.Count - VisibleCount);
/// 滚动窗口起点(0 起,活动跟随保证最新活动条目可见)。
public int WindowStart { get; private set; }
@@ -123,9 +138,14 @@ private void UpdateEntry(string id, Func update)
EnsureVisible(index);
}
- /// 窗口跟随 — 活动条目滚入可见窗口(上边界推进)。
+ /// 窗口跟随 — 活动条目滚入可见窗口(上边界推进;展开态全量可见 no-op)。
private void EnsureVisible(int index)
{
+ if (_isExpanded)
+ {
+ return;
+ }
+
if (index < WindowStart)
{
WindowStart = index;
diff --git a/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs
index 978a5a490..9b5287cc3 100644
--- a/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs
+++ b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs
@@ -11,7 +11,7 @@ namespace NativeTui.Core.Repl;
/// Driver 每事件更新即生效 — 无需失效通知,ScrollBox 每帧全量重渲)。
/// IAnimatable — ReplShell 每帧广播动画时钟,组头 ● 600ms 方波闪烁(基准 ToolUseLoader + useBlink)。
///
-public sealed class SubagentGroupItem(SubagentGroupCoordinator coordinator) : IScrollItem, IAnimatable
+public sealed class SubagentGroupItem(SubagentGroupCoordinator coordinator) : IScrollItem, IAnimatable, ICursorNavigable
{
/// 协调器(分组状态的唯一来源 — Driver 事件路由更新)。
public SubagentGroupCoordinator Coordinator { get; } = coordinator;
diff --git a/native_tui/tests/NativeTui.Tests/repl/MessageCursorTests.cs b/native_tui/tests/NativeTui.Tests/repl/MessageCursorTests.cs
new file mode 100644
index 000000000..c32ed8b85
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/MessageCursorTests.cs
@@ -0,0 +1,527 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// 消息光标导航测试(交互层 Step1)— 对齐基准消息导航系统(MESSAGE_ACTIONS:Shift+↑ 进入 enterCursor
+/// 定位最后一条用户消息、↑/↓/k/j 逐条、Shift+↑/↓ 跳用户消息、Ctrl+↑/↓ 首末、Enter 展开 grouped_tool_use、
+/// Esc 两级退出先收起展开再退出):
+/// MessageCursor 协调器纯状态机(Begin 定位/相邻可导航移动/用户消息跳转/首末/退出)+
+/// SubagentGroupCoordinator 展开态(全量窗口替代 min(10,n) 折叠)+ ReplShell 第四交互态键路由
+/// (Shift+↑ 进入、光标态模态吞键)+ ScrollBox 选中高亮(整块背景覆写 MessageActionsBackground)。
+///
+public sealed class MessageCursorTests
+{
+ /// 构造测试 shell(StringWriter 收渲染输出,固定尺寸源,seed 消息可缺省)。
+ private static (ReplShell Shell, FakeReplEngine Engine) CreateShell(IReadOnlyList? seed = null)
+ {
+ var engine = new FakeReplEngine();
+ var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp");
+ var shell = new ReplShell(engine, banner, new StringWriter(), seedMessages: seed, sizeSource: new FakeTerminalSizeSource(80, 24));
+ return (shell, engine);
+ }
+
+ /// 构造混合消息项序列(banner[0] / q1[1] / a1[2] / tool[3] / q2[4] / group[5] / spinner[6] — 可导航 = 1..5)。
+ private static IScrollItem[] CreateItems() =>
+ [
+ new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp"),
+ new UserMessageItem("q1"),
+ new AssistantMessageItem("a1"),
+ new ToolCallItem("Read", "f.cs"),
+ new UserMessageItem("q2"),
+ new SubagentGroupItem(CreateGroup()),
+ new SpinnerItem { Message = string.Empty },
+ ];
+
+ /// 构造三代理组(供可导航混合序列的分组项)。
+ private static SubagentGroupCoordinator CreateGroup()
+ {
+ var coordinator = new SubagentGroupCoordinator();
+ coordinator.AddAgent("a1", "explore", "搜索");
+ coordinator.AddAgent("a2", "reviewer", "审查");
+ coordinator.AddAgent("a3", "builder", "编译");
+ return coordinator;
+ }
+
+ /// Shift+↑ 键(进入消息光标导航)。
+ private static ConsoleKeyInfo ShiftUp() => new('\0', ConsoleKey.UpArrow, shift: true, alt: false, control: false);
+
+ /// Shift+↓ 键(跳用户消息向下)。
+ private static ConsoleKeyInfo ShiftDown() => new('\0', ConsoleKey.DownArrow, shift: true, alt: false, control: false);
+
+ /// 普通 ↑ 键。
+ private static ConsoleKeyInfo Up() => new('\0', ConsoleKey.UpArrow, shift: false, alt: false, control: false);
+
+ /// 普通 ↓ 键。
+ private static ConsoleKeyInfo Down() => new('\0', ConsoleKey.DownArrow, shift: false, alt: false, control: false);
+
+ /// Ctrl+↑ 键(首条可导航消息)。
+ private static ConsoleKeyInfo CtrlUp() => new('\0', ConsoleKey.UpArrow, shift: false, alt: false, control: true);
+
+ /// Ctrl+↓ 键(末条可导航消息)。
+ private static ConsoleKeyInfo CtrlDown() => new('\0', ConsoleKey.DownArrow, shift: false, alt: false, control: true);
+
+ /// Enter 键。
+ private static ConsoleKeyInfo Enter() => new('\r', ConsoleKey.Enter, shift: false, alt: false, control: false);
+
+ /// Esc 键。
+ private static ConsoleKeyInfo Esc() => new('\0', ConsoleKey.Escape, shift: false, alt: false, control: false);
+
+ /// 可打印字符键。
+ private static ConsoleKeyInfo Char(char c) => new(c, ConsoleKey.None, shift: false, alt: false, control: false);
+
+ // ---- MessageCursor 协调器(纯状态机) ----
+
+ /// Begin — 定位最后一条用户消息(基准 enterCursor scan 语义)。
+ [Fact]
+ public void Begin_LocatesLastUserMessage()
+ {
+ var cursor = new MessageCursor();
+ cursor.Begin(CreateItems());
+
+ Assert.True(cursor.IsCursorActive);
+ Assert.Equal(4, cursor.SelectedIndex);
+ }
+
+ /// Begin — 无用户消息时回退定位最后一条可导航消息。
+ [Fact]
+ public void Begin_NoUserMessage_FallsBackToLastNavigable()
+ {
+ var cursor = new MessageCursor();
+ var items = new IScrollItem[]
+ {
+ new BannerItem("1.0.0", "m", "API", "/tmp"),
+ new AssistantMessageItem("a1"),
+ new ToolResultItem("结果"),
+ new SpinnerItem { Message = string.Empty },
+ };
+ cursor.Begin(items);
+
+ Assert.True(cursor.IsCursorActive);
+ Assert.Equal(2, cursor.SelectedIndex);
+ }
+
+ /// Begin — 无可导航消息(仅 banner/spinner)不激活。
+ [Fact]
+ public void Begin_NoNavigableMessages_StaysInactive()
+ {
+ var cursor = new MessageCursor();
+ var items = new IScrollItem[]
+ {
+ new BannerItem("1.0.0", "m", "API", "/tmp"),
+ new SpinnerItem { Message = string.Empty },
+ };
+ cursor.Begin(items);
+
+ Assert.False(cursor.IsCursorActive);
+ Assert.Equal(-1, cursor.SelectedIndex);
+ }
+
+ /// Move — 相邻可导航消息步进(跳过 banner/spinner 非导航项)+ 边界 clamp 不环绕。
+ [Fact]
+ public void Move_StepsBetweenNavigableItems_ClampsAtBounds()
+ {
+ var items = CreateItems();
+ var cursor = new MessageCursor();
+ cursor.Begin(items);
+ Assert.Equal(4, cursor.SelectedIndex);
+
+ cursor.Move(items, -1);
+ Assert.Equal(3, cursor.SelectedIndex);
+
+ cursor.Move(items, -1);
+ Assert.Equal(2, cursor.SelectedIndex);
+
+ cursor.Move(items, 1);
+ Assert.Equal(3, cursor.SelectedIndex);
+
+ cursor.Move(items, 1);
+ cursor.Move(items, 1);
+ Assert.Equal(5, cursor.SelectedIndex);
+
+ cursor.Move(items, 1);
+ Assert.Equal(5, cursor.SelectedIndex);
+
+ var top = new MessageCursor();
+ top.Begin(items);
+ top.Move(items, 1);
+ top.Move(items, -1);
+ top.Move(items, -1);
+ Assert.Equal(3, top.SelectedIndex);
+
+ top.Move(items, -1);
+ top.Move(items, -1);
+ top.Move(items, -1);
+ Assert.Equal(1, top.SelectedIndex);
+ }
+
+ /// MoveToUserMessage — 跳到相邻用户消息(向前/向后);无后续用户消息保持不动。
+ [Fact]
+ public void MoveToUserMessage_JumpsBetweenUserMessages()
+ {
+ var items = CreateItems();
+ var cursor = new MessageCursor();
+ cursor.Begin(items);
+ Assert.Equal(4, cursor.SelectedIndex);
+
+ cursor.MoveToUserMessage(items, -1);
+ Assert.Equal(1, cursor.SelectedIndex);
+
+ cursor.MoveToUserMessage(items, -1);
+ Assert.Equal(1, cursor.SelectedIndex);
+
+ cursor.MoveToUserMessage(items, 1);
+ Assert.Equal(4, cursor.SelectedIndex);
+
+ cursor.MoveToUserMessage(items, 1);
+ Assert.Equal(4, cursor.SelectedIndex);
+ }
+
+ /// MoveToUserMessage — 起点不在用户消息上时,向指定方向找最近用户消息。
+ [Fact]
+ public void MoveToUserMessage_FromNonUserMessage_FindsNearestInDirection()
+ {
+ var items = CreateItems();
+ var cursor = new MessageCursor();
+ cursor.Begin(items);
+ cursor.Move(items, -1);
+ cursor.Move(items, -1);
+ Assert.Equal(2, cursor.SelectedIndex);
+
+ cursor.MoveToUserMessage(items, 1);
+ Assert.Equal(4, cursor.SelectedIndex);
+ }
+
+ /// MoveToEdge — 首/末可导航消息(Ctrl+↑/↓)。
+ [Fact]
+ public void MoveToEdge_FirstAndLastNavigable()
+ {
+ var items = CreateItems();
+ var cursor = new MessageCursor();
+ cursor.Begin(items);
+
+ cursor.MoveToEdge(items, first: true);
+ Assert.Equal(1, cursor.SelectedIndex);
+
+ cursor.MoveToEdge(items, first: false);
+ Assert.Equal(5, cursor.SelectedIndex);
+ }
+
+ /// Exit — 清空选中(SelectedIndex 归 -1,幂等)。
+ [Fact]
+ public void Exit_ClearsState()
+ {
+ var cursor = new MessageCursor();
+ cursor.Begin(CreateItems());
+ cursor.Exit();
+
+ Assert.False(cursor.IsCursorActive);
+ Assert.Equal(-1, cursor.SelectedIndex);
+
+ cursor.Exit();
+ Assert.False(cursor.IsCursorActive);
+ }
+
+ // ---- SubagentGroupCoordinator 展开态 ----
+
+ /// 展开 — VisibleCount 全量、HiddenCount 归零(突破 min(10,n) 窗口)。
+ [Fact]
+ public void IsExpanded_FullWindowWithoutHidden()
+ {
+ var coordinator = new SubagentGroupCoordinator();
+ for (var i = 0; i < 13; i++)
+ {
+ coordinator.AddAgent($"a{i}", "explore", $"任务{i}");
+ }
+
+ Assert.False(coordinator.IsExpanded);
+ Assert.Equal(10, coordinator.VisibleCount);
+ Assert.Equal(3, coordinator.HiddenCount);
+
+ coordinator.IsExpanded = true;
+ Assert.Equal(13, coordinator.VisibleCount);
+ Assert.Equal(0, coordinator.HiddenCount);
+
+ coordinator.IsExpanded = false;
+ Assert.Equal(10, coordinator.VisibleCount);
+ Assert.Equal(3, coordinator.HiddenCount);
+ }
+
+ /// 展开态渲染高度 — margin 1 + 组头 1 + 全量 N×2(无溢出行)。
+ [Fact]
+ public void MeasureHeight_ExpandedRendersAllAgents()
+ {
+ var coordinator = new SubagentGroupCoordinator();
+ for (var i = 0; i < 13; i++)
+ {
+ coordinator.AddAgent($"a{i}", "explore", $"任务{i}");
+ }
+
+ var item = new SubagentGroupItem(coordinator);
+ Assert.Equal(23, item.MeasureHeight(60));
+
+ coordinator.IsExpanded = true;
+ Assert.Equal(28, item.MeasureHeight(60));
+ }
+
+ /// 展开态渲染 — 第 13 个代理(折叠时溢出不可见)完整可见 + 无 "and N more…" 溢出行
+ /// (混合类型规避全同型 hideType 形态,锁定 "└─ type (desc)" 完整行)。
+ [Fact]
+ public void Render_ExpandedShowsAllAgentsWithoutOverflow()
+ {
+ var coordinator = new SubagentGroupCoordinator();
+ for (var i = 0; i < 13; i++)
+ {
+ coordinator.AddAgent($"a{i}", i % 2 == 0 ? "explore" : "reviewer", $"任务{i}");
+ }
+
+ coordinator.IsExpanded = true;
+ using var screen = new Screen(cols: 60, rows: 32);
+ new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 32), Theme.Dark);
+
+ Assert.StartsWith("├─ reviewer (任务11)", RowText.Of(screen, 24, 0, 60));
+ Assert.StartsWith("└─ explore (任务12)", RowText.Of(screen, 26, 0, 60));
+ Assert.DoesNotContain("and 3 more", RowText.Of(screen, 27, 0, 60));
+ }
+
+ // ---- ReplShell 第四交互态键路由 ----
+
+ /// Shift+↑ 进入光标导航 — 定位最后一条用户消息(items: banner/q1/a1/q2/spinner → q2 索引 3)。
+ [Fact]
+ public void ShiftUpArrow_EntersCursorNav_AtLastUserMessage()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+
+ shell.HandleKey(ShiftUp());
+
+ Assert.True(shell.CursorNav.IsCursorActive);
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+ }
+
+ /// 光标态 ↑/↓ — 相邻可导航消息移动(跳过 spinner 尾项)+ 首末 clamp 不环绕。
+ [Fact]
+ public void CursorNav_PlainArrowsMoveSelection()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Down());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Up());
+ Assert.Equal(2, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Down());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Up());
+ shell.HandleKey(Up());
+ Assert.Equal(1, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Up());
+ Assert.Equal(1, shell.CursorNav.SelectedIndex);
+ }
+
+ /// 光标态 k/j — vim 键逐条移动(基准 MessageActions 上下文绑定)。
+ [Fact]
+ public void CursorNav_KAndJMoveSelection()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Char('k'));
+ Assert.Equal(2, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Char('j'));
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+ }
+
+ /// 光标态 Shift+↑/↓ — 跳用户消息。
+ [Fact]
+ public void CursorNav_ShiftArrowsJumpUserMessages()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(ShiftUp());
+ Assert.Equal(1, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(ShiftDown());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+ }
+
+ /// 光标态 Ctrl+↑/↓ — 首末可导航消息。
+ [Fact]
+ public void CursorNav_CtrlArrowsJumpToEdges()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+
+ shell.HandleKey(CtrlUp());
+ Assert.Equal(1, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(CtrlDown());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+ }
+
+ /// 光标态 Enter — 选中分组项时切换展开/收起(对齐基准 Enter 展开 grouped_tool_use)。
+ [Fact]
+ public void CursorNav_EnterTogglesGroupExpansion()
+ {
+ var group = CreateGroup();
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1"), new SubagentGroupItem(group), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Up());
+ Assert.Equal(2, shell.CursorNav.SelectedIndex);
+ Assert.False(group.IsExpanded);
+
+ shell.HandleKey(Enter());
+ Assert.True(group.IsExpanded);
+
+ shell.HandleKey(Enter());
+ Assert.False(group.IsExpanded);
+ }
+
+ /// 光标态 Enter — 选中非分组消息 no-op(不退出导航)。
+ [Fact]
+ public void CursorNav_EnterOnPlainMessage_NoOp()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ Assert.Equal(1, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Enter());
+
+ Assert.True(shell.CursorNav.IsCursorActive);
+ Assert.Equal(1, shell.CursorNav.SelectedIndex);
+ }
+
+ /// 光标态 Esc 两级退出 — 有展开组先收起(导航保持);再 Esc 才退出。
+ [Fact]
+ public void CursorNav_EscapeCollapsesExpandedGroup_ThenSecondEscapeExits()
+ {
+ var group = CreateGroup();
+ var (shell, _) = CreateShell(seed: [new SubagentGroupItem(group), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+ shell.HandleKey(Enter());
+ Assert.True(group.IsExpanded);
+
+ shell.HandleKey(Esc());
+ Assert.False(group.IsExpanded);
+ Assert.True(shell.CursorNav.IsCursorActive);
+
+ shell.HandleKey(Esc());
+ Assert.False(shell.CursorNav.IsCursorActive);
+ }
+
+ /// 光标态 Esc — 无展开组直接退出导航。
+ [Fact]
+ public void CursorNav_EscapeWithoutExpansion_ExitsImmediately()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ Assert.True(shell.CursorNav.IsCursorActive);
+
+ shell.HandleKey(Esc());
+ Assert.False(shell.CursorNav.IsCursorActive);
+ }
+
+ /// 光标态模态 — 字符键不进输入缓冲(基准全屏模式 PromptInput 卸载语义)。
+ [Fact]
+ public void CursorNav_SwallowsCharacterKeys_InputUntouched()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+
+ shell.HandleKey(Char('a'));
+ shell.HandleKey(Char('b'));
+
+ Assert.Equal(string.Empty, shell.Input.Text);
+ }
+
+ /// 未进入光标态 — 普通 ↑ 走历史导航(不触发导航),Shift+↑ 不吞字符。
+ [Fact]
+ public void PlainUpArrow_WithoutCursorNav_StillNavigatesHistory()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1")]);
+ shell.SeedContent();
+ shell.Input.Replace("draft", cursorToStart: false);
+
+ shell.HandleKey(Up());
+
+ Assert.False(shell.CursorNav.IsCursorActive);
+ }
+
+ // ---- 选中高亮渲染 ----
+
+ /// 渲染高亮 — 选中项整块背景覆写 MessageActionsBackground(前景保留:正文样式前景 Text)。
+ /// 双缓冲交换语义:渲染两次后 BackBuffer 携带上帧内容(同内容)。
+ [Fact]
+ public void RenderAndFlush_HighlightsSelectedItemBackground()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.RenderAndFlush();
+ shell.RenderAndFlush();
+
+ var back = shell.BackBuffer!;
+ Assert.True(TryFindText(back, "q2", out var q2Row, out var q2Col));
+ var highlighted = back.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text, Background = Theme.Dark.MessageActionsBackground });
+ Assert.Equal(highlighted, back[q2Row, q2Col].StyleId);
+
+ Assert.True(TryFindText(back, "q1", out var q1Row, out var q1Col));
+ var normal = back.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text, Background = Theme.Dark.UserMessageBackground });
+ Assert.Equal(normal, back[q1Row, q1Col].StyleId);
+ }
+
+ /// 无光标态 — 不高亮(渲染不变)。双缓冲交换语义:渲染两次后 BackBuffer 携带上帧内容。
+ [Fact]
+ public void RenderAndFlush_WithoutCursorNav_NoHighlight()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("q1")]);
+ shell.SeedContent();
+ shell.RenderAndFlush();
+ shell.RenderAndFlush();
+
+ var back = shell.BackBuffer!;
+ Assert.True(TryFindText(back, "q1", out var row, out var col));
+ var normal = back.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text, Background = Theme.Dark.UserMessageBackground });
+ Assert.Equal(normal, back[row, col].StyleId);
+ }
+
+ /// 定位两字符文本的 cell 坐标(正文 'q' 右邻 '2' 形态)。
+ private static bool TryFindText(Screen screen, string text, out int row, out int col)
+ {
+ for (var r = 0; r < screen.Rows; r++)
+ {
+ for (var c = 0; c < screen.Cols - 1; c++)
+ {
+ if (screen[r, c].Value == new Rune(text[0]) && screen[r, c + 1].Value == new Rune(text[1]))
+ {
+ row = r;
+ col = c;
+ return true;
+ }
+ }
+ }
+
+ row = -1;
+ col = -1;
+ return false;
+ }
+}
From e30d242c792d316b4979a8de6b2511251aa30526 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 04:32:41 +0800
Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=E5=AD=90=E4=BB=A3=E7=90=86?=
=?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=B1=82=20Step2=20=E6=AF=8F=E4=BB=A3?=
=?UTF-8?q?=E7=90=86=E6=B4=BB=E5=8A=A8=E6=97=A5=E5=BF=97=EF=BC=88=E6=9C=80?=
=?UTF-8?q?=E8=BF=913=E6=9D=A1=E7=8E=AF=E5=BD=A2=E7=BC=93=E5=86=B2+hidden?=
=?UTF-8?q?=E4=B8=A2=E5=BC=83=E8=AE=A1=E6=95=B0=EF=BC=8C=E5=B1=95=E5=BC=80?=
=?UTF-8?q?=E6=80=81=E9=80=90=E8=A1=8C=E6=B8=B2=E6=9F=93=EF=BC=89=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E6=B4=BB=E5=8A=A8=E6=97=A5=E5=BF=97?=
=?UTF-8?q?=E8=90=BD=20SubagentEntry=20=E4=B8=8D=E5=8F=AF=E5=8F=98=20recor?=
=?UTF-8?q?d=20=E8=BF=BD=E5=8A=A0=E5=AD=97=E6=AE=B5=EF=BC=88RecentActiviti?=
=?UTF-8?q?es+HiddenActivityCount=EF=BC=8CCoordinator=20RecordToolUse=20?=
=?UTF-8?q?=E7=8E=AF=E5=BD=A2=E8=BF=BD=E5=8A=A0=E4=B8=A2=E6=9C=80=E6=97=A7?=
=?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=B8=B2=E6=9F=93=E5=B1=95=E5=BC=80=E6=80=81?=
=?UTF-8?q?=E6=AF=8F=E6=9D=A1=E6=B4=BB=E5=8A=A8=E7=8B=AC=E7=AB=8B=E8=A1=8C?=
=?UTF-8?q?=E5=A4=8D=E7=94=A8=20RenderSubagentActivityRow=EF=BC=88?=
=?UTF-8?q?=E9=9B=B6=E6=B4=BB=E5=8A=A8=20Initializing/Done=20=E5=85=9C?=
=?UTF-8?q?=E5=BA=95=E5=8D=95=E8=A1=8C=EF=BC=89=EF=BC=8C=E5=AE=B9=E9=87=8F?=
=?UTF-8?q?3=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=E5=8D=95=E4=BB=A3?=
=?UTF-8?q?=E7=90=86=E6=8A=98=E5=8F=A0=E5=B0=BE=E9=83=A8=E7=AA=97=E5=8F=A3?=
=?UTF-8?q?=E8=AF=AD=E4=B9=89=EF=BC=9B=E5=85=A8=E9=87=8F=20778=20=E9=80=9A?=
=?UTF-8?q?=E8=BF=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/NativeTui.Core/repl/SubagentGroup.cs | 44 ++++++++++---
.../NativeTui.Core/repl/SubagentGroupItem.cs | 49 ++++++++++++--
.../repl/SubagentGroupTests.cs | 66 +++++++++++++++++++
3 files changed, 147 insertions(+), 12 deletions(-)
diff --git a/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs
index ad8645817..f40684538 100644
--- a/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs
+++ b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs
@@ -1,8 +1,14 @@
namespace NativeTui.Core.Repl;
+/// 子代理工具活动记录(交互层 Step2)— 活动日志环形缓冲的单元(展开态逐行渲染)。
+/// 工具名(活动行前缀)。
+/// 活动摘要(活动行正文)。
+public sealed record SubagentActivity(string ToolName, string Summary);
+
///
/// 子代理条目(M7-7)— 一个并行派发子代理的渲染状态投影(不可变 record,Coordinator 替换式更新):
/// 身份(Id/AgentType/Description)+ 进度累计(ToolUseCount/TokenCount/LastToolName/LastToolSummary)+
+/// 活动日志(RecentActivities 最近 3 条环形 + HiddenActivityCount 丢弃计数 — 展开态逐行渲染,交互层 Step2)+
/// 终态(IsDone/IsError)。对齐基准 AgentProgressLine 行数据:状态行 "Type (desc) · N tool uses · M tokens"
/// + 活动行 "lastToolName: summary | Initializing… | Done"。
///
@@ -15,6 +21,8 @@ namespace NativeTui.Core.Repl;
/// 最后工具摘要(活动行正文)。
/// 是否定格(FinishAgent 或回合收尾 FinalizePending)。
/// 是否失败(组头 ● Error 红聚合)。
+/// 最近工具活动日志(环形保留最近 3 条 — 展开态逐行渲染)。
+/// 环形丢弃的更早活动数(展开态 "+N more tool uses" 行依据)。
public sealed record SubagentEntry(
string Id,
string AgentType,
@@ -24,7 +32,9 @@ public sealed record SubagentEntry(
string LastToolName,
string LastToolSummary,
bool IsDone,
- bool IsError);
+ bool IsError,
+ IReadOnlyList RecentActivities,
+ int HiddenActivityCount);
///
/// 子代理分组协调器(M7-7)— 对齐基准 AgentTool 分组渲染语义(groupToolUses + AgentProgressLine):
@@ -40,6 +50,9 @@ public sealed class SubagentGroupCoordinator
/// 最大可见条数(对齐模型选择器 visibleOptionCount=Math.min(10, n) 先例;基准组内无截断靠虚拟化,本项目窗口为对等机制)。
public const int MaxVisible = 10;
+ /// 活动日志环形容量(交互层 Step2 — 基准单代理折叠尾部窗口 3 条语义:最近 3 条 + "+N more")。
+ public const int MaxRecentActivities = 3;
+
private readonly List _agents = [];
/// 条目清单(渲染与断言投影)。
@@ -91,19 +104,34 @@ public bool IsExpanded
/// 追加子代理条目(未完成态)+ 窗口滚动到该条目(活动跟随)。
public void AddAgent(string id, string agentType, string description)
{
- _agents.Add(new SubagentEntry(id, agentType, description, ToolUseCount: 0, TokenCount: 0, LastToolName: string.Empty, LastToolSummary: string.Empty, IsDone: false, IsError: false));
+ _agents.Add(new SubagentEntry(id, agentType, description, ToolUseCount: 0, TokenCount: 0,
+ LastToolName: string.Empty, LastToolSummary: string.Empty, IsDone: false, IsError: false,
+ RecentActivities: [], HiddenActivityCount: 0));
EnsureVisible(_agents.Count - 1);
}
- /// 记录子代理工具活动 — 计数/令牌累计 + 最后活动更新 + 窗口跟随。
+ /// 记录子代理工具活动 — 计数/令牌累计 + 最后活动更新 + 活动日志环形追加(超容量丢最旧并计数)+ 窗口跟随。
public void RecordToolUse(string id, string toolName, string summary, int tokenDelta)
{
- UpdateEntry(id, entry => entry with
+ UpdateEntry(id, entry =>
{
- ToolUseCount = entry.ToolUseCount + 1,
- TokenCount = entry.TokenCount + tokenDelta,
- LastToolName = toolName,
- LastToolSummary = summary,
+ var activities = entry.RecentActivities.Append(new SubagentActivity(toolName, summary)).ToList();
+ var hidden = entry.HiddenActivityCount;
+ while (activities.Count > MaxRecentActivities)
+ {
+ activities.RemoveAt(0);
+ hidden++;
+ }
+
+ return entry with
+ {
+ ToolUseCount = entry.ToolUseCount + 1,
+ TokenCount = entry.TokenCount + tokenDelta,
+ LastToolName = toolName,
+ LastToolSummary = summary,
+ RecentActivities = activities,
+ HiddenActivityCount = hidden,
+ };
});
}
diff --git a/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs
index 9b5287cc3..1e86308f8 100644
--- a/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs
+++ b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs
@@ -9,6 +9,8 @@ namespace NativeTui.Core.Repl;
/// 运行中条目整行 dim(基准 AgentProgressLine isResolved 前提色);树形收口 └─ 按"组最后一个条目"判定
/// (窗口截断时可见条目均不收口,树延续到溢出行之后语义)。状态源 = SubagentGroupCoordinator(投影,
/// Driver 每事件更新即生效 — 无需失效通知,ScrollBox 每帧全量重渲)。
+/// 展开态(交互层 Step1 Enter 切换)— 全量代理突破 min(10,n) 窗口(对齐基准 verbose 不分组拆条语义);
+/// 每代理活动日志逐行渲染(交互层 Step2 — 最近 3 条环形 + "+N more tool uses" 丢弃计数行)。
/// IAnimatable — ReplShell 每帧广播动画时钟,组头 ● 600ms 方波闪烁(基准 ToolUseLoader + useBlink)。
///
public sealed class SubagentGroupItem(SubagentGroupCoordinator coordinator) : IScrollItem, IAnimatable, ICursorNavigable
@@ -19,17 +21,29 @@ public sealed class SubagentGroupItem(SubagentGroupCoordinator coordinator) : IS
/// 动画时钟(ReplShell 每帧广播;600ms 方波相位计算)。
public long AnimTimeMs { get; set; }
- /// 内容高度 = margin 1 + 组头 1 + 可见条目 ×2 + 溢出 0/1。
- public int MeasureHeight(int width) =>
- 2 + Coordinator.VisibleCount * 2 + (Coordinator.HiddenCount > 0 ? 1 : 0);
+ /// 内容高度 — 折叠态:margin 1 + 组头 1 + 可见条目 ×2 + 溢出 0/1;
+ /// 展开态:margin 1 + 组头 1 + 每条目(状态 1 + 活动行数(零活动 Initializing… 兜底 1)+ hidden 行 0/1)。
+ public int MeasureHeight(int width) => 2 + (Coordinator.IsExpanded
+ ? Coordinator.Agents.Sum(ExpandedEntryRows)
+ : Coordinator.VisibleCount * 2 + (Coordinator.HiddenCount > 0 ? 1 : 0));
- /// 渲染:margin 留空 → 组头(● 闪烁三态 + 文案)→ 可见条目两行式 → 溢出行(如有)。
+ /// 渲染:margin 留空 → 组头(● 闪烁三态 + 文案)→ 展开态逐条目活动日志 / 折叠态可见条目两行式 → 溢出行(如有)。
public void Render(Screen screen, Rect frame, Theme theme)
{
var row = frame.Y + 1;
MessageRenderer.RenderSubagentHeader(screen, RowFrame(frame, row++), theme, Coordinator.HeaderText, Coordinator.AllComplete, Coordinator.AnyError, AnimTimeMs);
var hideType = Coordinator.AllSameType;
+ if (Coordinator.IsExpanded)
+ {
+ for (var i = 0; i < Coordinator.Count; i++)
+ {
+ RenderExpandedEntry(screen, frame, theme, ref row, Coordinator.Agents[i], isLastOfGroup: i == Coordinator.Count - 1, hideType);
+ }
+
+ return;
+ }
+
for (var i = 0; i < Coordinator.VisibleCount; i++)
{
var index = Coordinator.WindowStart + i;
@@ -46,6 +60,33 @@ public void Render(Screen screen, Rect frame, Theme theme)
}
}
+ /// 展开态单条目渲染 — 状态行 + 逐活动行("tool: summary")+ hidden 丢弃计数行;
+ /// 零活动兜底单行(Initializing… / Done — 与折叠态活动行同形态)。
+ private static void RenderExpandedEntry(Screen screen, Rect frame, Theme theme, ref int row, SubagentEntry entry, bool isLastOfGroup, bool hideType)
+ {
+ var isRunning = !entry.IsDone;
+ MessageRenderer.RenderSubagentStatusRow(screen, RowFrame(frame, row++), theme, isLastOfGroup, entry, isRunning, hideType);
+ if (entry.RecentActivities.Count == 0)
+ {
+ MessageRenderer.RenderSubagentActivityRow(screen, RowFrame(frame, row++), theme, isLastOfGroup, ActivityText(entry), isRunning);
+ return;
+ }
+
+ foreach (var activity in entry.RecentActivities)
+ {
+ MessageRenderer.RenderSubagentActivityRow(screen, RowFrame(frame, row++), theme, isLastOfGroup, $"{activity.ToolName}: {activity.Summary}", isRunning);
+ }
+
+ if (entry.HiddenActivityCount > 0)
+ {
+ MessageRenderer.RenderSubagentActivityRow(screen, RowFrame(frame, row++), theme, isLastOfGroup, $"+{entry.HiddenActivityCount} more tool uses", isRunning);
+ }
+ }
+
+ /// 展开态单条目行数 — 状态 1 + max(1, 活动数)(零活动兜底)+ hidden 行 0/1。
+ private static int ExpandedEntryRows(SubagentEntry entry) =>
+ 1 + Math.Max(1, entry.RecentActivities.Count) + (entry.HiddenActivityCount > 0 ? 1 : 0);
+
/// 活动行文本 — 完成 Done / 无活动 Initializing… / 最后工具 "名: 摘要"。
private static string ActivityText(SubagentEntry entry) =>
entry.IsDone ? "Done"
diff --git a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs
index ebd712d9a..78c0935e9 100644
--- a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs
+++ b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs
@@ -320,6 +320,72 @@ public void MeasureHeight_FollowsWindowState()
Assert.Equal(23, new SubagentGroupItem(coordinator).MeasureHeight(60));
}
+ // ---- 每代理活动日志(交互层 Step2 — 展开态显示) ----
+
+ /// RecordToolUse — 活动日志环形缓冲:保留最近 3 条 + 丢弃计数(基准单代理尾部窗口 3 条语义)。
+ [Fact]
+ public void RecordToolUse_AppendsRecentActivities_RingKeepsLastThree()
+ {
+ var coordinator = new SubagentGroupCoordinator();
+ coordinator.AddAgent("a1", "explore", "搜索");
+ coordinator.RecordToolUse("a1", "Read", "f1", 100);
+ coordinator.RecordToolUse("a1", "Grep", "f2", 100);
+ coordinator.RecordToolUse("a1", "Read", "f3", 100);
+ coordinator.RecordToolUse("a1", "Bash", "f4", 100);
+
+ var agent = coordinator.Agents[0];
+ Assert.Equal(4, agent.ToolUseCount);
+ Assert.Equal(3, agent.RecentActivities.Count);
+ Assert.Equal("Grep", agent.RecentActivities[0].ToolName);
+ Assert.Equal("f2", agent.RecentActivities[0].Summary);
+ Assert.Equal("Read", agent.RecentActivities[1].ToolName);
+ Assert.Equal("Bash", agent.RecentActivities[2].ToolName);
+ Assert.Equal(1, agent.HiddenActivityCount);
+ }
+
+ /// 展开态渲染 — 每条活动独立行 + "+N more tool uses" 丢弃计数行(末代理 " ⎿" 收口前缀;
+ /// 单代理组全同型 hideType:状态行描述直起无类型徽章)。
+ [Fact]
+ public void Render_ExpandedShowsActivityLogAndHiddenCount()
+ {
+ var coordinator = new SubagentGroupCoordinator();
+ coordinator.AddAgent("a1", "explore", "搜索");
+ coordinator.RecordToolUse("a1", "Read", "f1", 100);
+ coordinator.RecordToolUse("a1", "Grep", "f2", 100);
+ coordinator.RecordToolUse("a1", "Read", "f3", 100);
+ coordinator.RecordToolUse("a1", "Bash", "f4", 100);
+
+ coordinator.IsExpanded = true;
+ using var screen = new Screen(cols: 60, rows: 10);
+ new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 10), Theme.Dark);
+
+ Assert.StartsWith("└─ 搜索 · 4 tool uses · 400 tokens", RowText.Of(screen, 2, 0, 60));
+ Assert.StartsWith(" ⎿ Grep: f2", RowText.Of(screen, 3, 0, 60));
+ Assert.StartsWith(" ⎿ Read: f3", RowText.Of(screen, 4, 0, 60));
+ Assert.StartsWith(" ⎿ Bash: f4", RowText.Of(screen, 5, 0, 60));
+ Assert.StartsWith(" ⎿ +1 more tool uses", RowText.Of(screen, 6, 0, 60));
+ }
+
+ /// 展开态高度 — 每代理状态行 + 活动行数(零活动 Initializing… 兜底 1 行)+ hidden 行 0/1。
+ [Fact]
+ public void MeasureHeight_ExpandedCountsActivityRows()
+ {
+ var coordinator = new SubagentGroupCoordinator();
+ coordinator.AddAgent("a1", "explore", "搜索");
+ coordinator.RecordToolUse("a1", "Read", "f1", 100);
+ coordinator.RecordToolUse("a1", "Grep", "f2", 100);
+ coordinator.RecordToolUse("a1", "Read", "f3", 100);
+ coordinator.RecordToolUse("a1", "Bash", "f4", 100);
+
+ coordinator.IsExpanded = true;
+ Assert.Equal(7, new SubagentGroupItem(coordinator).MeasureHeight(60));
+
+ var fresh = new SubagentGroupCoordinator();
+ fresh.AddAgent("a1", "explore", "搜索");
+ fresh.IsExpanded = true;
+ Assert.Equal(4, new SubagentGroupItem(fresh).MeasureHeight(60));
+ }
+
/// 运行中行 dim / 完成行不 dim(基准 AgentProgressLine 运行中整行 dim)。
[Fact]
public void Render_RunningRowsDimDoneRowsNot()
From e16d2903abafc0bc2069b02f620c07955df1fd7d Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 04:34:20 +0800
Subject: [PATCH 06/17] =?UTF-8?q?docs:=20TASK055=20=E5=AD=90=E4=BB=A3?=
=?UTF-8?q?=E7=90=86=E4=BA=A4=E4=BA=92=E5=B1=82=20Step1+2=20=E8=90=BD?=
=?UTF-8?q?=E5=9C=B0=E8=AE=B0=E5=BD=95=EF=BC=88=E6=B6=88=E6=81=AF=E5=85=89?=
=?UTF-8?q?=E6=A0=87=E5=AF=BC=E8=88=AA+=E6=B4=BB=E5=8A=A8=E6=97=A5?=
=?UTF-8?q?=E5=BF=97=EF=BC=8C=E5=9B=9B=E5=88=97=E9=AA=8C=E6=94=B6=E8=A1=A8?=
=?UTF-8?q?+=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...\247\345\257\271\351\275\220ClaudeCode.md" | 57 +++++++++++++++++++
1 file changed, 57 insertions(+)
diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md"
index 43652eab2..cbc61f877 100644
--- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md"
+++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md"
@@ -1024,3 +1024,60 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g
+## 子代理交互层落地(2026-10-21,用户验收三缺口之①:点击进入查看内容 — Step1+2)
+
+> 基准机制调研闭环:查看子代理内容的基准等价物 = **消息导航系统**(MESSAGE_ACTIONS feature flag + 全屏模式)——
+> Shift+↑ 进入(enterCursor 定位最后一条用户消息)、↑/↓/k/j 逐条、Shift+↑/↓ 跳用户消息、Ctrl+↑/↓ 首末、
+> Enter 展开 grouped_tool_use(verbose 不分组 → 300 代理拆 300 条独立详细消息)、Esc 两级退出(先收起展开再退出);
+> 无逐代理专项选择,靠消息级导航;ctrl+o 是全局 transcript 切换(不拆分组);选中高亮 = messageActionsBackground。
+
+### 实施记录(2 commit 链)
+
+| 阶段 | commit | 内容 |
+|------|--------|------|
+| Step1 消息光标导航 | 368de96b9 | MessageCursor 协调器(纯状态机:Begin 定位最后用户消息/相邻可导航移动/用户消息跳转/首末/退出,每次传 items 引用不持有 — 消息动态增删安全)+ ICursorNavigable 空标记接口(8 消息类实现;banner/spinner/对话框排除)+ ReplShell 第四交互态(HandleCursorNavKey 模态 — 优先级 permission>modelPicker>search>cursor>suggestion;Shift+↑ 进入;光标态吞字符键 = 基准全屏 PromptInput 卸载语义)+ ScrollBox.HighlightItem 渲染后整块背景覆写(前景保留,MessageActionsBackground)+ SubagentGroupCoordinator.IsExpanded(展开全量渲染突破 min(10,n) + 窗口归零);全量 775/775 绿(含 24 新测试) |
+| Step2 每代理活动日志 | 41750dd0d | SubagentActivity record + SubagentEntry 追加 RecentActivities(环形保留最近 3 条)+ HiddenActivityCount(丢弃计数)+ Coordinator.RecordToolUse 环形追加(超容量丢最旧)+ SubagentGroupItem 展开态逐活动行渲染("tool: summary" 复用 RenderSubagentActivityRow + "+N more tool uses" 丢弃行;零活动 Initializing…/Done 兜底单行)+ MeasureHeight 展开态逐条目计数;全量 778/778 绿(含 3 新测试) |
+
+### 与基准的对齐与偏离(决策记录)
+
+| 项 | 基准 | 本项目 | 依据 |
+|----|------|--------|------|
+| 进入键 | Shift+↑(idle 绑定) | 同基准(Shift+UpArrow 无修饰) | KeyKind.UpArrow+Shift 空闲可用(普通↑历史导航不冲突) |
+| 光标态输入框 | PromptInput 卸载(全屏模式) | **模态吞键**(字符不进输入缓冲) | 权限/模型对话框模态先例 — ReplShell 第四交互态 |
+| Enter 展开 | grouped_tool_use 拆 N 条独立消息(verbose 渲染) | **单 Item 展开态**(全量代理 + 逐活动日志) | 本项目分组是单 Item 非 N 条消息;展开语义等价(折叠窗口↔全量+活动详情) |
+| 选中高亮 | 消息包裹 messageActionsBackground | **ScrollBox 渲染后整块覆写**(前景保留) | 零侵入 8 消息类 — 高亮独立于各消息渲染逻辑 |
+| 活动日志 | 单代理折叠尾部窗口 3 条 + "+N more (ctrl+o)" | **每代理环形 3 条 + "+N more tool uses"(展开态)** | 分组展开态的逐代理对等机制 |
+| Esc 两级 | 先收起 expanded 再退出 | 同基准 | — |
+
+### 四列验收表(AGENTS.md 强制)
+
+| 基建实现 | 消费点 | 已实现 | 已验收 |
+|---------|--------|--------|--------|
+| Core MessageCursor.cs(协调器 + ICursorNavigable 标记) | ReplShell.HandleCursorNavKey 模态路由 + ScrollBox 高亮投影 | ✅ | ✅ MessageCursorTests 协调器 8/8(定位/移动/跳转/首末/退出) |
+| ReplShell 第四交互态(HandleKeyPress 分派链 + Shift+↑ 进入 + Esc 两级退出) | 键盘交互(真实终端按键) | ✅ | ✅ MessageCursorTests 键路由 12/12(进入/移动/kj/Shift跳转/Ctrl首末/Enter展开/Esc两级/吞键/无冲突) |
+| ScrollBox.HighlightItem + ApplyHighlightBackground(整块背景覆写) | SyncCursorHighlight 渲染前投影 → 每帧选中高亮 | ✅ | ✅ 渲染断言 2/2(选中背景 MessageActionsBackground + 无光标无高亮;双缓冲交换语义渲染两次断言) |
+| SubagentGroupCoordinator.IsExpanded(展开全量 + 窗口归零) | MessageCursor Enter 切换 + Esc 收起 | ✅ | ✅ 展开 4/4(全量窗口/高度/渲染无溢出/Enter 切换) |
+| SubagentEntry 活动日志(RecentActivities 环形 3 + HiddenActivityCount) | SubagentGroupItem 展开态逐活动行渲染 | ✅ | ✅ 活动日志 3/3(环形保留/展开渲染+hidden 行/高度计数) |
+| 全链真实终端交互验收 | Demo agents 场景 + Shift+↑ 导航 | ✅ | ⏳ 待用户真实终端(agents 12/300 → Shift+↑ → ↑↓ 导航 → Enter 展开 → Esc 两级);Demo --selftest 绿 |
+
+### 遗留项
+
+1. 引擎侧 subagent 事件源改造(跨模块独立 spec — InProcessEventConsumer 无子代理事件,Host 真实引擎接线前置)
+2. 用户真实终端交互验收(Shift+↑ 进入/导航/Enter 展开/活动日志/Esc 两级)
+3. P2 细节:k/j 之外 vim 键扩展(gg/G)、光标态滚动跟随选中项(超屏时)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From c9b8407a0777e60f3bf391de348c10457fa78a1e Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 04:53:14 +0800
Subject: [PATCH 07/17] =?UTF-8?q?feat:=20/vendor=20=E4=BE=9B=E5=BA=94?=
=?UTF-8?q?=E5=95=86=E6=A8=A1=E6=80=81=E9=80=89=E6=8B=A9=E5=99=A8=EF=BC=88?=
=?UTF-8?q?CommandPickerEngine=20=E6=B3=9B=E5=8C=96=E5=A4=8D=E7=94=A8?=
=?UTF-8?q?=E6=95=B4=E6=9D=A1=E6=A8=A1=E6=80=81=E9=93=BE=EF=BC=89=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20ModelPickerEngine=20=E6=B3=9B=E5=8C=96?=
=?UTF-8?q?=E4=B8=BA=20command/noun=20=E5=8F=82=E6=95=B0=E5=8C=96=E7=9A=84?=
=?UTF-8?q?=20CommandPickerEngine=EF=BC=88Attach=20=E9=80=92=E5=BD=92?=
=?UTF-8?q?=E4=BC=A0=E6=92=AD=E5=86=85=E5=B1=82=E5=90=8C=E6=97=8F=E7=BB=91?=
=?UTF-8?q?=E5=AE=9A=EF=BC=89=EF=BC=8Cvendor=20=E5=A4=8D=E7=94=A8=20Coordi?=
=?UTF-8?q?nator/Dialog/=E6=A8=A1=E6=80=81=E8=B7=AF=E7=94=B1=E9=9B=B6?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=8A=B6=E6=80=81=E6=9C=BA=EF=BC=9B=E5=88=87?=
=?UTF-8?q?=E6=8D=A2=E9=93=BE=E5=AF=B9=E9=BD=90=20GUI=20SetVendorAsync?=
=?UTF-8?q?=EF=BC=88ProviderConfig.Vendor/ModelId=20=E5=8E=9F=E5=9C=B0?=
=?UTF-8?q?=E6=94=B9=E4=BF=9D=20DI=20=E5=8D=95=E4=BE=8B=E5=BC=95=E7=94=A8?=
=?UTF-8?q?=E4=B8=8D=E5=8F=98=EF=BC=89+=20ModelPickerAssembly.CurrentProvi?=
=?UTF-8?q?der=20=E6=94=B9=E8=AF=BB=20WorkflowConfig=20=E5=8D=95=E4=BE=8B?=
=?UTF-8?q?=E4=BD=BF=20/model=20=E6=B8=85=E5=8D=95=E8=B7=9F=E9=9A=8F?=
=?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...06\351\200\211\346\213\251\345\231\250.md" | 33 +++++++
native_tui/apps/NativeTui.Demo/Program.cs | 20 ++++-
.../apps/NativeTui.Host/EngineSession.cs | 14 +--
.../apps/NativeTui.Host/GlobalUsings.cs | 2 +
.../NativeTui.Host/ModelPickerAssembly.cs | 11 +--
native_tui/apps/NativeTui.Host/Program.cs | 21 +++--
.../NativeTui.Host/VendorPickerAssembly.cs | 61 +++++++++++++
...PickerEngine.cs => CommandPickerEngine.cs} | 43 +++++----
.../NativeTui.Core/repl/ModelPickerDialog.cs | 13 +--
.../src/NativeTui.Core/repl/ReplShell.cs | 5 +-
.../EngineSessionTests.cs | 14 +++
.../NativeTui.Host.Tests/GlobalUsings.cs | 3 +
.../VendorPickerAssemblyTests.cs | 89 +++++++++++++++++++
.../NativeTui.Tests/repl/ModelPickerTests.cs | 64 +++++++++++--
14 files changed, 340 insertions(+), 53 deletions(-)
create mode 100644 "docs/proposed/task/TASK058-TUI\344\276\233\345\272\224\345\225\206\351\200\211\346\213\251\345\231\250.md"
create mode 100644 native_tui/apps/NativeTui.Host/VendorPickerAssembly.cs
rename native_tui/src/NativeTui.Core/repl/{ModelPickerEngine.cs => CommandPickerEngine.cs} (51%)
create mode 100644 native_tui/tests/NativeTui.Host.Tests/VendorPickerAssemblyTests.cs
diff --git "a/docs/proposed/task/TASK058-TUI\344\276\233\345\272\224\345\225\206\351\200\211\346\213\251\345\231\250.md" "b/docs/proposed/task/TASK058-TUI\344\276\233\345\272\224\345\225\206\351\200\211\346\213\251\345\231\250.md"
new file mode 100644
index 000000000..946ed082d
--- /dev/null
+++ "b/docs/proposed/task/TASK058-TUI\344\276\233\345\272\224\345\225\206\351\200\211\346\213\251\345\231\250.md"
@@ -0,0 +1,33 @@
+# TASK058 — TUI 供应商选择器(/vendor 模态)
+
+> 状态:in_progress(2026-10-21 开工)
+> 前置:TASK057 完结;Bug1/Bug2 验收通过后用户新需求:"怎么切换供应商,你好像没有做"
+> 用户决策:模态选择器方案(对齐 /model 交互;引擎切换逻辑现成,只做 TUI 交互层)
+
+## 调研结论
+
+| 资产 | 现状 |
+|------|------|
+| 引擎 `/vendor` 命令 | ✅ 完整(VendorCommand:10 供应商校验 + 默认模型跟随 + profile 持久化;`/vendor 空格` 参数补全已随 Bug1 元数据管道自动可用) |
+| GUI 切换链 | ✅ JccChatSession.SetVendorAsync — `config.Provider.Vendor = vendor` 原地改(ProviderConfig 的 Vendor/ModelId 是 `{ get; set; }` 可变属性,DI 单例引用不变全消费方可见) |
+| TUI 模态先例 | ✅ ModelPicker 三件套(Engine 装饰器 + Coordinator 状态机 + Dialog 渲染),vendor 交互完全同构 |
+| WorkflowConfig 获取 | ✅ EngineSessionFactory.Result.Config 直接暴露 + DI 单例 AddSingleton(config) |
+| banner | ❌ SessionInfo 事件无 Vendor 字段(需加,末尾可选参数零破坏) |
+
+## 方案(复用优先,禁止重复造轮子)
+
+1. **Core**:`BridgeEvent.SessionInfo` 加 `Vendor = null` 可选参数;`BannerItem` 加 Vendor 属性渲染 `{vendor}/{model} · billing`(空退化);`ReplBridgeDriver` 透传
+2. **Core**:`ModelPickerDialog` 构造参数化 Title/Subtitle/Shortcuts(默认现文案)——vendor 复用整条对话框渲染链
+3. **Core**:新增 `VendorPickerEngine`(对齐 ModelPickerEngine:拦截 `/vendor` 无参弹选择器;带参透传引擎直达命令)
+4. **Host**:`VendorPickerAssembly` — LoadItems(VendorKind 枚举 → ModelPickerItem,DisplayName 用 IModelCatalog.GetProviderDisplayName,Description 显默认模型,IsCurrent 标记)+ ApplyVendor(config.Provider 原地改 + SetPrimaryModel + profile 持久化,对齐 GUI SetVendorAsync 语义)
+5. **Host**:`EngineSession.BuildSessionInfoEvent` 加 Vendor(`_engine.Config.Provider.Vendor`);`ModelPickerAssembly.CurrentProvider` 改读 WorkflowConfig 单例(vendor 切换后 /model 清单跟随,环境变量兜底保留)
+6. **Program**:VendorPickerEngine 包 ModelPickerEngine 外层(装饰链)+ applyVendor 委托组合 ApplyVendor+RefreshSessionInfo;Demo 双入口接线
+
+## 验收表
+
+| 基建实现 | 消费点 | 已实现 | 已验收 |
+|---------|--------|--------|--------|
+| VendorPickerEngine(Core) | Program 装饰链 + Demo | ✅ | ✅ ADR 0080 真机(/vendor 弹框 10 项 + 切换 + banner vendor/model 同步) |
+| BannerItem.Vendor 渲染 | ReplBridgeDriver.ApplySessionInfo | ✅ | ✅ |
+| VendorPickerAssembly(Host) | Program applyVendor 委托 | ✅ | ✅ |
+| SessionInfo.Vendor 字段 | EngineSession/Demo 种子事件 | ✅ | ✅ |
diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs
index 5628fcd51..4bc82d714 100644
--- a/native_tui/apps/NativeTui.Demo/Program.cs
+++ b/native_tui/apps/NativeTui.Demo/Program.cs
@@ -69,7 +69,13 @@ private static int RunInteractive(string? model = null, bool viewport = false)
var info = SessionInfo.Create(version, model);
var banner = new BannerItem(info.Version, info.Model, info.Billing, info.WorkingDirectory);
// M7-6 模型选择器演示 — 装饰模拟引擎拦截 /model 打开 Picker(模拟清单,真实引擎见 NativeTui.Host)
- var pickerEngine = new ModelPickerEngine(new SimulatedEngine(), DemoModelItems, _ => { });
+ // TASK058 供应商选择器演示 — 外层装饰拦截 /vendor(模拟清单)
+ var pickerEngine = new CommandPickerEngine(
+ new CommandPickerEngine(new SimulatedEngine(), "/model", "模型", DemoModelItems, _ => { }),
+ "/vendor",
+ "供应商",
+ DemoVendorItems,
+ _ => { });
var shell = new ReplShell(pickerEngine, banner, Console.Out, inline: !viewport, seedMessages: SeedMessages(), commands: DemoCommands(), codeHighlighter: new TextMateCodeHighlighter(dark: true));
pickerEngine.Attach(shell);
shell.Run();
@@ -78,7 +84,7 @@ private static int RunInteractive(string? model = null, bool viewport = false)
return 0;
}
- /// Demo 演示命令清单(斜杠补全数据源)— 与 SimulatedEngine.ParseCommand 路由同源 + /model(ModelPickerEngine 装饰器拦截)。
+ /// Demo 演示命令清单(斜杠补全数据源)— 与 SimulatedEngine.ParseCommand 路由同源 + /model、/vendor(CommandPickerEngine 装饰器拦截)。
private static IReadOnlyList DemoCommands() =>
[
new CommandSuggestion("md", "Markdown 全形态渲染", [], IsHidden: false),
@@ -87,6 +93,7 @@ private static IReadOnlyList DemoCommands() =>
new CommandSuggestion("all", "完整回合(补丁 + Markdown)", [], IsHidden: false),
new CommandSuggestion("agents", "并行子代理分组(可带数量 agents 300)", [], IsHidden: false),
new CommandSuggestion("model", "切换模型(M7-6 选择器)", [], IsHidden: false),
+ new CommandSuggestion("vendor", "切换供应商(TASK058 选择器)", [], IsHidden: false),
];
/// Demo 模拟模型清单(M7-6 选择器演示 — 真实引擎从 IModelCatalog 后端获取,见 NativeTui.Host ModelPickerAssembly)。
@@ -97,6 +104,15 @@ private static IReadOnlyList DemoModelItems() =>
new ModelPickerItem("gpt-nano", "GPT Nano", "最快响应 · 轻量便宜", IsCurrent: false),
];
+ /// Demo 模拟供应商清单(TASK058 选择器演示 — 真实引擎从 VendorKind 枚举投影,见 NativeTui.Host VendorPickerAssembly)。
+ private static IReadOnlyList DemoVendorItems() =>
+ [
+ new ModelPickerItem("zhipu", "智谱 AI", "GLM 系列 · 默认 glm-4.7", IsCurrent: true),
+ new ModelPickerItem("deepseek", "DeepSeek", "深度求索 · 默认 deepseek-chat", IsCurrent: false),
+ new ModelPickerItem("openai", "OpenAI", "GPT 系列 · 默认 gpt-5.3", IsCurrent: false),
+ new ModelPickerItem("anthropic", "Anthropic", "Claude 系列 · 默认 claude-sonnet-4", IsCurrent: false),
+ ];
+
/// 模拟模式种子消息(示例对话 — banner 之后、spinner 之前插入滚动区;assistant 回复为 Markdown 行内样式演示,M7-4 启动即见渲染;末条为演示命令提示)。
private static IReadOnlyList SeedMessages() =>
[
diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs
index c2b4e7bda..31051115d 100644
--- a/native_tui/apps/NativeTui.Host/EngineSession.cs
+++ b/native_tui/apps/NativeTui.Host/EngineSession.cs
@@ -34,8 +34,8 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien
/// 当前会话标识 — 历史种子排除当前会话防双份。
public string SessionId => _engine.SessionId;
- /// 重发 session_info 事件 — 模型切换等运行时状态变化后刷新 banner(ReplBridgeDriver.ApplySessionInfo 差分上屏)。
- public void RefreshSessionInfo() => _client.Enqueue(BuildSessionInfoEvent(_checker, _fastMode));
+ /// 重发 session_info 事件 — 模型/供应商切换等运行时状态变化后刷新 banner(ReplBridgeDriver.ApplySessionInfo 差分上屏)。
+ public void RefreshSessionInfo() => _client.Enqueue(BuildSessionInfoEvent(_checker, _fastMode, _engine.Config));
/// 组装引擎会话 — 建会话(TuiInteractionModule 注册权限确认 handler)→ 解析权限/模型 → 构造回合客户端 → 种子 session_info;失败时释放已建引擎 Host。
public static async Task CreateAsync(CancellationToken cancellationToken = default) {
@@ -60,7 +60,7 @@ public static async Task CreateAsync(CancellationToken cancellati
BuildPermissionModeApplier(checker));
var session = new EngineSession(engine, client, permissionBridge, checker, fastMode);
- client.Enqueue(BuildSessionInfoEvent(checker, fastMode));
+ client.Enqueue(BuildSessionInfoEvent(checker, fastMode, engine.Config));
return session;
} catch {
await engine.Host.DisposeAsync().ConfigureAwait(false);
@@ -78,16 +78,18 @@ public static async Task CreateAsync(CancellationToken cancellati
}
/// 组装会话握手事件(internal — 测试断言 SetPrimaryModel 后 Model 字段更新)。版本(引擎程序集)/当前模型(FastMode 主模型,未设置为 null)/
- /// 权限模式(安全闸后的实际值)/bypass 可用性(!DisableBypassGate)/cwd。
- internal static BridgeEvent.SessionInfo BuildSessionInfoEvent(PermissionChecker? checker, IFastModeService? fastMode) {
+ /// 权限模式(安全闸后的实际值)/bypass 可用性(!DisableBypassGate)/cwd/供应商(WorkflowConfig.Provider.Vendor,TASK058 banner 显示)。
+ 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;
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);
+ Cwd: Environment.CurrentDirectory,
+ Vendor: string.IsNullOrWhiteSpace(vendor) ? null : vendor);
}
/// 构造权限模式应用器 — 解析字符串(FromValue)→ 应用(CurrentMode setter 含 bypass 安全闸)→
diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
index 82e60d877..f246c6adb 100644
--- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs
+++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
@@ -1,9 +1,11 @@
global using System.Collections.Concurrent;
global using System.Diagnostics;
global using System.Text;
+global using Core.Configuration;
global using Core.Permission;
global using Core.Utils;
global using JoinCode.Abstractions.Entity;
+global using JoinCode.Abstractions.Configuration;
global using JoinCode.Abstractions.Configuration.AppData;
global using JoinCode.Abstractions.Configuration.Providers;
global using JoinCode.Abstractions.Interfaces;
diff --git a/native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs b/native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs
index 8b94eec14..a909e070b 100644
--- a/native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs
+++ b/native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs
@@ -20,7 +20,7 @@ internal static IReadOnlyList LoadItems(IServiceProvider servic
return [new ModelPickerItem(fallback, fallback, string.Empty, IsCurrent: true)];
}
- var provider = CurrentProvider();
+ var provider = CurrentProvider(services);
var models = catalog.EnsureCurrentModelInList(catalog.GetModelsForProvider(provider), current);
return models
.Select(m => new ModelPickerItem(m.Id, m.DisplayName, m.Description, m.Id.Equals(current, StringComparison.OrdinalIgnoreCase)))
@@ -37,14 +37,15 @@ internal static void ApplyModel(IServiceProvider services, string modelId)
if (fastMode is { IsFastModeActive: true })
{
var catalog = services.GetService();
- if (catalog is not null && !catalog.SupportsFastMode(modelId, CurrentProvider()))
+ if (catalog is not null && !catalog.SupportsFastMode(modelId, CurrentProvider(services)))
{
fastMode.Deactivate();
}
}
}
- /// 当前提供商 — 环境变量兜底链(对齐 ModelCommand.GetCurrentProvider 同款取值顺序,Host 无 ChatCommandContext)。
- private static string CurrentProvider() =>
- Environment.GetEnvironmentVariable(JccEnvVar.Vendor.ToValue()) ?? VendorKind.OpenAi.ToValue();
+ /// 当前提供商 — WorkflowConfig DI 单例优先(/vendor 切换后清单实时跟随,TASK058),
+ /// 环境变量兜底(对齐 ModelCommand.GetCurrentProvider 同款取值顺序,Host 无 ChatCommandContext)。
+ private static string CurrentProvider(IServiceProvider services) =>
+ VendorPickerAssembly.CurrentVendor(services);
}
diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs
index e6bd58913..b2fa2daf1 100644
--- a/native_tui/apps/NativeTui.Host/Program.cs
+++ b/native_tui/apps/NativeTui.Host/Program.cs
@@ -52,11 +52,22 @@ private static async Task RunInteractiveAsync() {
var commands = SlashCommandSource.Load(session.Services);
// M7-6 模型选择器 — 装饰引擎拦截 /model(清单从引擎 IModelCatalog 后端获取,切换链自研);
// 切换成功后 RefreshSessionInfo 重发握手事件刷新 banner(当前模型显示同步)
- var pickerEngine = new ModelPickerEngine(
- session.Client,
- () => ModelPickerAssembly.LoadItems(session.Services),
- id => {
- ModelPickerAssembly.ApplyModel(session.Services, id);
+ // TASK058 供应商选择器 — 外层再装饰拦截 /vendor(清单 VendorKind 枚举投影,切换链对齐 GUI SetVendorAsync)
+ var pickerEngine = new CommandPickerEngine(
+ new CommandPickerEngine(
+ session.Client,
+ "/model",
+ "模型",
+ () => ModelPickerAssembly.LoadItems(session.Services),
+ id => {
+ ModelPickerAssembly.ApplyModel(session.Services, id);
+ session.RefreshSessionInfo();
+ }),
+ "/vendor",
+ "供应商",
+ () => VendorPickerAssembly.LoadItems(session.Services),
+ vendor => {
+ VendorPickerAssembly.ApplyVendor(session.Services, vendor);
session.RefreshSessionInfo();
});
var shell = new ReplShell(pickerEngine, banner, tuiOutput, commands: commands);
diff --git a/native_tui/apps/NativeTui.Host/VendorPickerAssembly.cs b/native_tui/apps/NativeTui.Host/VendorPickerAssembly.cs
new file mode 100644
index 000000000..dd9e83ccb
--- /dev/null
+++ b/native_tui/apps/NativeTui.Host/VendorPickerAssembly.cs
@@ -0,0 +1,61 @@
+namespace NativeTui.Host;
+
+///
+/// 供应商选择器装配(TASK058)— 清单从 VendorKind 枚举 + IModelCatalog 投影
+/// (DisplayName 用目录显示名,Description 显默认模型,IsCurrent 标记 WorkflowConfig 当前供应商);
+/// 切换链对齐 GUI JccChatSession.SetVendorAsync 语义:WorkflowConfig.Provider 原地改
+/// (Vendor/ModelId 可变属性,DI 单例引用不变全消费方可见)+ IFastModeService 主模型跟随 +
+/// IConfigurationService 持久化 profile(键语义对齐 VendorCommand/SettingsMapper)+ fast mode 自关。
+///
+internal static class VendorPickerAssembly
+{
+ /// 从引擎服务投影供应商清单 — 枚举为唯一数据源(VendorKind,规则7),目录缺失时显示名退化为枚举值。
+ internal static IReadOnlyList LoadItems(IServiceProvider services)
+ {
+ var current = CurrentVendor(services);
+ var catalog = services.GetService();
+ return Enum.GetValues()
+ .Select(kind => kind.ToValue())
+ .Select(vendor => {
+ var displayName = catalog?.GetProviderDisplayName(vendor) ?? vendor;
+ var defaultModel = catalog?.GetDefaultModelForProvider(vendor) ?? string.Empty;
+ var description = defaultModel.Length > 0 ? $"默认模型: {defaultModel}" : string.Empty;
+ return new ModelPickerItem(vendor, displayName, description, vendor.Equals(current, StringComparison.OrdinalIgnoreCase));
+ })
+ .ToArray();
+ }
+
+ /// 切换链 — config.Provider 原地改 + 主模型跟随 + profile 持久化 + fast mode 自关(新默认模型不支持时)。
+ internal static void ApplyVendor(IServiceProvider services, string vendor)
+ {
+ var config = services.GetService();
+ var catalog = services.GetService();
+ var defaultModelId = catalog?.GetDefaultModelForProvider(vendor) ?? string.Empty;
+
+ if (config?.Provider is { } provider) {
+ provider.Vendor = vendor;
+ if (defaultModelId.Length > 0) {
+ provider.ModelId = defaultModelId;
+ }
+ }
+
+ var fastMode = services.GetService();
+ if (defaultModelId.Length > 0) {
+ fastMode?.SetPrimaryModel(defaultModelId);
+ }
+
+ services.GetService()?.SetAsync("profile", vendor).GetAwaiter().GetResult();
+
+ if (fastMode is { IsFastModeActive: true } && defaultModelId.Length > 0) {
+ if (catalog is not null && !catalog.SupportsFastMode(defaultModelId, vendor)) {
+ fastMode.Deactivate();
+ }
+ }
+ }
+
+ /// 当前供应商 — WorkflowConfig DI 单例优先(vendor 切换后实时),环境变量兜底(对齐 ModelPickerAssembly.CurrentProvider 取值顺序)。
+ internal static string CurrentVendor(IServiceProvider services) =>
+ services.GetService()?.Provider?.Vendor
+ ?? Environment.GetEnvironmentVariable(JccEnvVar.Vendor.ToValue())
+ ?? VendorKind.OpenAi.ToValue();
+}
diff --git a/native_tui/src/NativeTui.Core/repl/ModelPickerEngine.cs b/native_tui/src/NativeTui.Core/repl/CommandPickerEngine.cs
similarity index 51%
rename from native_tui/src/NativeTui.Core/repl/ModelPickerEngine.cs
rename to native_tui/src/NativeTui.Core/repl/CommandPickerEngine.cs
index 0d7fd358f..6de7ac65c 100644
--- a/native_tui/src/NativeTui.Core/repl/ModelPickerEngine.cs
+++ b/native_tui/src/NativeTui.Core/repl/CommandPickerEngine.cs
@@ -1,33 +1,44 @@
namespace NativeTui.Core.Repl;
///
-/// 模型选择引擎装饰器(M7-6,全部自研)— Submit 拦截 "/model"(精确匹配无参形态)打开
-/// ReplShell 模型选择器,其余输入透传内层引擎;选中后回调宿主 applyModel 执行切换
-/// (宿主直调引擎服务 — IFastModeService/IConfigurationService,不经 slash 命令系统)+ PostInfo 反馈。
+/// 命令选择器装饰引擎(M7-6 ModelPickerEngine 泛化,全部自研)— Submit 拦截指定命令
+/// (精确匹配无参形态,如 "/model" / "/vendor")打开 ReplShell 选择器模态,其余输入透传内层引擎;
+/// 选中后回调宿主 applyItem 执行切换(宿主直调引擎服务,不经 slash 命令系统)+ PostInfo 反馈。
/// 组装:宿主先构造装饰器 → new ReplShell(装饰器) → 装饰器.Attach(shell)(延迟绑定,
-/// 对齐 Host controller ??= 先例避免构造循环依赖)。
+/// 对齐 Host controller ??= 先例避免构造循环依赖)。可叠层装饰(vendor 外层包 model 内层)。
///
-public sealed class ModelPickerEngine(
+public sealed class CommandPickerEngine(
IReplEngine inner,
+ string command,
+ string switchedNoun,
Func> listProvider,
- Action applyModel) : IReplEngine
+ Action applyItem) : IReplEngine
{
- /// 拦截的命令输入(基准 /model 无参形态打开选择器;带参形态透传引擎按未知输入处理)。
- private const string ModelCommand = "/model";
-
/// 外壳(延迟绑定 — Attach 注入)。
private ReplShell? _shell;
- /// 绑定外壳(new ReplShell(装饰器) 之后调用一次)。
- public void Attach(ReplShell shell) => _shell = shell;
+ /// 选择器标题("Select {noun}" — Dialog 标题)。
+ private string Title => $"Select {switchedNoun}";
+
+ /// 选择器副标题(Dialog 副标题 — 会话级生效语义)。
+ private string Subtitle => $"Switch between {switchedNoun}s. Applies to this session and future sessions.";
+
+ /// 绑定外壳(new ReplShell(装饰器) 之后调用一次)— 叠层装饰时递归传播内层同族
+ ///(组装方只 Attach 最外层,内层拦截依赖 shell 引用;同文件同族互知,类型耦合可接受)。
+ public void Attach(ReplShell shell) {
+ _shell = shell;
+ if (inner is CommandPickerEngine nested) {
+ nested.Attach(shell);
+ }
+ }
- /// 提交 — "/model" 拦截打开选择器(清单经 listProvider 从引擎投影);其余透传。
+ /// 提交 — 目标命令拦截打开选择器(清单经 listProvider 从引擎投影);其余透传。
public void Submit(string text)
{
- if (_shell is not null && text.Trim() == ModelCommand)
+ if (_shell is not null && text.Trim() == command)
{
var items = listProvider();
- _shell.BeginModelPicker(items, id => OnSelected(id, items));
+ _shell.BeginModelPicker(items, id => OnSelected(id, items), Title, Subtitle);
return;
}
@@ -55,8 +66,8 @@ private void OnSelected(string? id, IReadOnlyList items)
return;
}
- applyModel(id);
+ applyItem(id);
var displayName = items.FirstOrDefault(m => m.Id == id)?.DisplayName ?? id;
- _shell?.PostInfo($"已切换模型: {displayName}");
+ _shell?.PostInfo($"已切换{switchedNoun}: {displayName}");
}
}
diff --git a/native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs b/native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs
index dd383e1ab..0fda4c335 100644
--- a/native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs
+++ b/native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs
@@ -9,15 +9,10 @@ namespace NativeTui.Core.Repl;
/// 当前项尾 ✓ success;描述行:5 空格缩进 dim,空描述折叠)+ 溢出行(1 缩进 dim "and N more…")
/// + 空行 + 快捷键行(1 缩进 dim "Enter confirm · Esc exit")。
/// 状态源 = ModelPickerCoordinator(焦点/窗口/挂起),Begin 时插入 Messages,确认/取消时移除。
+/// title/subtitle 构造参数化(默认模型文案)— vendor 选择器复用同条渲染链。
///
-public sealed class ModelPickerDialog(ModelPickerCoordinator coordinator) : IScrollItem
+public sealed class ModelPickerDialog(ModelPickerCoordinator coordinator, string title = "Select model", string subtitle = "Switch between models. Applies to this session and future sessions.") : IScrollItem
{
- /// 固定标题(基准 ModelPicker title="Select model")。
- private const string Title = "Select model";
-
- /// 固定副标题(基准 "Switch between Claude models. Applies to this session and future sessions.",本项目语境改模)。
- private const string Subtitle = "Switch between models. Applies to this session and future sessions.";
-
/// 固定快捷键行(基准 Byline "Enter confirm · Esc exit")。
private const string Shortcuts = "Enter confirm · Esc exit";
@@ -43,8 +38,8 @@ public void Render(Screen screen, Rect frame, Theme theme)
var row = frame.Y + 1;
MessageRenderer.RenderPermissionBorder(screen, RowFrame(frame, row++), theme);
- MessageRenderer.RenderModelTitle(screen, RowFrame(frame, row++), theme, Title);
- MessageRenderer.RenderModelSubtitle(screen, RowFrame(frame, row++), theme, Subtitle);
+ MessageRenderer.RenderModelTitle(screen, RowFrame(frame, row++), theme, title);
+ MessageRenderer.RenderModelSubtitle(screen, RowFrame(frame, row++), theme, subtitle);
row++;
for (var i = 0; i < Coordinator.VisibleCount; i++)
diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index 99f4af321..2ff839f2b 100644
--- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs
+++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
@@ -220,12 +220,13 @@ private void CompletePermission(PermissionChoice choice)
///
/// 挂起模型选择器(M7-6 — 宿主装饰器拦截 /model 后调此打开,全链自研不依赖 slash 命令系统):
/// 清单由宿主从引擎 IModelCatalog 投影(后端获取);完成回调参数 = 选中模型 id(Esc 取消为 null)。
+ /// title/subtitle 可选参数化(默认模型文案)— vendor 选择器复用同条模态链。
///
- public void BeginModelPicker(IReadOnlyList items, Action? onCompleted = null)
+ public void BeginModelPicker(IReadOnlyList items, Action? onCompleted = null, string title = "Select model", string subtitle = "Switch between models. Applies to this session and future sessions.")
{
_modelPicker.Begin(items);
_modelPickerCallback = onCompleted;
- AddMessage(new ModelPickerDialog(_modelPicker));
+ AddMessage(new ModelPickerDialog(_modelPicker, title, subtitle));
}
/// 完成模型选择并移除对话框项(id = null 表示 Esc 取消)。
diff --git a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
index 48339a2cb..a721248b8 100644
--- a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
@@ -56,4 +56,18 @@ public async Task BuildSessionInfoEvent_AfterModelSwitch_ReflectsNewModel() {
var after = EngineSession.BuildSessionInfoEvent(checker: null, fastMode);
Assert.Equal("switched-model", after.Model);
}
+
+ /// session_info 事件供应商字段(TASK058)— WorkflowConfig.Provider.Vendor 透传;无 config 时 Vendor 为 null(旧引擎兼容退化)。
+ [Fact]
+ public async Task BuildSessionInfoEvent_WithConfig_CarriesVendor()
+ {
+ await using var fastMode = new FastModeService();
+ var config = new WorkflowConfig { Provider = new ProviderConfig { Vendor = "zhipu" } };
+
+ var withConfig = EngineSession.BuildSessionInfoEvent(checker: null, fastMode, config);
+ Assert.Equal("zhipu", withConfig.Vendor);
+
+ var withoutConfig = EngineSession.BuildSessionInfoEvent(checker: null, fastMode, config: null);
+ Assert.Null(withoutConfig.Vendor);
+ }
}
diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
index e707ac6f4..8ad66817a 100644
--- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
@@ -3,12 +3,15 @@
global using Core.Configuration;
global using JoinCode;
global using JoinCode.Abstractions.Configuration;
+global using JoinCode.Abstractions.Configuration.AppData;
global using JoinCode.Abstractions.Configuration.Providers;
+global using JoinCode.Abstractions.Configuration.Settings;
global using JoinCode.Abstractions.Interfaces;
global using JoinCode.Abstractions.LLM.Chat;
global using JoinCode.Abstractions.Models.Diff;
global using JoinCode.Abstractions.Security.Permission;
global using JoinCode.Abstractions.Security.Shell;
+global using JoinCode.Abstractions.Utils;
global using JoinCode.Adapters;
global using JoinCode.App.Builder;
global using Microsoft.Extensions.DependencyInjection;
diff --git a/native_tui/tests/NativeTui.Host.Tests/VendorPickerAssemblyTests.cs b/native_tui/tests/NativeTui.Host.Tests/VendorPickerAssemblyTests.cs
new file mode 100644
index 000000000..9316e9e8a
--- /dev/null
+++ b/native_tui/tests/NativeTui.Host.Tests/VendorPickerAssemblyTests.cs
@@ -0,0 +1,89 @@
+namespace NativeTui.Host.Tests;
+
+///
+/// 供应商选择器装配测试(TASK058)— 清单投影(VendorKind 枚举唯一数据源 + 显示名/默认模型来自
+/// IModelCatalog + 当前供应商标记)+ 切换链(WorkflowConfig.Provider 原地改 + SetPrimaryModel 跟随 +
+/// profile 持久化,对齐 GUI SetVendorAsync 语义)。真实引擎 catalog 由 --selftest 覆盖(ADR 0080)。
+///
+public sealed class VendorPickerAssemblyTests
+{
+ /// 清单投影 — VendorKind 枚举全量 + 显示名走目录 + 当前供应商标记(DI 无 config 时环境兜底)。
+ [Fact]
+ public void LoadItems_ProjectsVendorKindWithCurrentMarker()
+ {
+ using var environment = EnvVarScope.Set(JccEnvVar.Vendor.ToValue(), "zhipu");
+ var config = new WorkflowConfig { Provider = new ProviderConfig { Vendor = "zhipu" } };
+ var services = new ServiceCollection().AddSingleton(config).BuildServiceProvider();
+
+ var items = VendorPickerAssembly.LoadItems(services);
+
+ Assert.Equal(Enum.GetValues().Length, items.Count);
+ var zhipu = items.Single(i => i.Id == "zhipu");
+ Assert.True(zhipu.IsCurrent);
+ Assert.Equal("zhipu", zhipu.DisplayName);
+ Assert.False(items.Single(i => i.Id == "openai").IsCurrent);
+ }
+
+ /// 切换链 — config.Provider 原地改(Vendor + 默认模型跟随)+ FastMode 主模型同步。
+ [Fact]
+ public async Task ApplyVendor_MutatesProviderInPlaceAndFollowsDefaultModel()
+ {
+ var config = new WorkflowConfig { Provider = new ProviderConfig { Vendor = "zhipu", ModelId = "glm-4.7" } };
+ await using var fastMode = new FastModeService(config);
+ var services = new ServiceCollection()
+ .AddSingleton(config)
+ .AddSingleton(fastMode)
+ .BuildServiceProvider();
+
+ VendorPickerAssembly.ApplyVendor(services, "deepseek");
+
+ Assert.Equal("deepseek", config.Provider.Vendor);
+ Assert.Equal(fastMode.PrimaryModelId, config.Provider.ModelId);
+ }
+
+ /// 切换链 — profile 持久化(IConfigurationService.SetAsync 收到供应商值)。
+ [Fact]
+ public void ApplyVendor_PersistsProfile()
+ {
+ var config = new WorkflowConfig { Provider = new ProviderConfig { Vendor = "zhipu" } };
+ var configService = new FakeConfigurationService();
+ var services = new ServiceCollection()
+ .AddSingleton(config)
+ .AddSingleton(configService)
+ .BuildServiceProvider();
+
+ VendorPickerAssembly.ApplyVendor(services, "deepseek");
+
+ Assert.Equal(("profile", "deepseek"), configService.LastSet);
+ }
+
+ /// 当前供应商解析 — WorkflowConfig DI 单例优先于环境变量(vendor 切换后实时)。
+ [Fact]
+ public void CurrentVendor_PrefersWorkflowConfigOverEnvironment()
+ {
+ using var environment = EnvVarScope.Set(JccEnvVar.Vendor.ToValue(), "openai");
+ var config = new WorkflowConfig { Provider = new ProviderConfig { Vendor = "zhipu" } };
+ var services = new ServiceCollection().AddSingleton(config).BuildServiceProvider();
+
+ Assert.Equal("zhipu", VendorPickerAssembly.CurrentVendor(services));
+ }
+
+ /// 记录型配置服务假体(仅捕获 SetAsync 单参重载 — VendorPickerAssembly 消费面;其余成员 throw 超出测试范围)。
+ private sealed class FakeConfigurationService : IConfigurationService
+ {
+ public (string Key, string Value)? LastSet { get; private set; }
+
+ public event EventHandler? SettingChanged { add { } remove { } }
+
+ public Task GetAsync(string key, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task GetAsync(string key, SettingSource source, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) {
+ LastSet = (key, value);
+ return Task.FromResult(true);
+ }
+ public Task SetAsync(string key, string value, SettingSource source, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task SetAsync(string key, string value, SettingSource source, string? appStateKey, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetAllAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs b/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs
index 7d9e2925d..2cf14fba9 100644
--- a/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs
+++ b/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs
@@ -370,7 +370,7 @@ public async Task Shell_PermissionTakesPrecedenceOverModelPicker()
Assert.True(shell.ModelPicker.IsPending);
}
- // ---- ModelPickerEngine 装饰器 ----
+ // ---- CommandPickerEngine 装饰器(ModelPickerEngine 泛化 — command/noun 参数化,/model 与 /vendor 共用) ----
/// Submit "/model" — 拦截打开选择器,不透传内层引擎。
[Fact]
@@ -378,7 +378,7 @@ public void Engine_SubmitModelCommand_OpensPickerNotForwarded()
{
var inner = new FakeModelEngine();
var (shell, _) = CreateShell();
- var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, _ => { });
+ var pickerEngine = new CommandPickerEngine(inner, "/model", "模型", () => SampleItems, _ => { });
pickerEngine.Attach(shell);
pickerEngine.Submit("/model");
@@ -393,7 +393,7 @@ public void Engine_SubmitOtherText_ForwardedToInner()
{
var inner = new FakeModelEngine();
var (shell, _) = CreateShell();
- var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, _ => { });
+ var pickerEngine = new CommandPickerEngine(inner, "/model", "模型", () => SampleItems, _ => { });
pickerEngine.Attach(shell);
pickerEngine.Submit("/model mini");
@@ -402,14 +402,14 @@ public void Engine_SubmitOtherText_ForwardedToInner()
Assert.False(shell.ModelPicker.IsPending);
}
- /// 确认选择 — applyModel 收到 id + PostInfo 反馈消息入滚动区。
+ /// 确认选择 — applyItem 收到 id + PostInfo 反馈消息入滚动区。
[Fact]
public void Engine_ConfirmSelection_AppliesModelAndPostsFeedback()
{
var inner = new FakeModelEngine();
var applied = new List();
var (shell, _) = CreateShell();
- var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, applied.Add);
+ var pickerEngine = new CommandPickerEngine(inner, "/model", "模型", () => SampleItems, applied.Add);
pickerEngine.Attach(shell);
pickerEngine.Submit("/model");
@@ -419,14 +419,14 @@ public void Engine_ConfirmSelection_AppliesModelAndPostsFeedback()
Assert.Contains(shell.View.Messages.Items, m => m is AssistantMessageItem { } a && a.Text.Contains("GPT Mini"));
}
- /// Esc 取消 — applyModel 不调用 + 无反馈消息。
+ /// Esc 取消 — applyItem 不调用 + 无反馈消息。
[Fact]
public void Engine_EscapeCancel_NoApplyNoFeedback()
{
var inner = new FakeModelEngine();
var applied = new List();
var (shell, _) = CreateShell();
- var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, applied.Add);
+ var pickerEngine = new CommandPickerEngine(inner, "/model", "模型", () => SampleItems, applied.Add);
pickerEngine.Attach(shell);
pickerEngine.Submit("/model");
@@ -442,7 +442,7 @@ public void Engine_PassThroughMembers()
{
var inner = new FakeModelEngine();
inner.Events.Enqueue(new BridgeEvent.TurnStarted());
- var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, _ => { });
+ var pickerEngine = new CommandPickerEngine(inner, "/model", "模型", () => SampleItems, _ => { });
pickerEngine.Interrupt();
pickerEngine.SetPermissionMode("plan");
@@ -454,6 +454,54 @@ public void Engine_PassThroughMembers()
Assert.True(pickerEngine.IsAlive);
}
+ /// vendor 装饰器(TASK058)— Submit "/vendor" 拦截打开选择器 + 反馈文案带供应商名词;"/model" 透传内层。
+ [Fact]
+ public void Engine_VendorCommand_OpensPickerAndModelForwards()
+ {
+ var inner = new FakeModelEngine();
+ var applied = new List();
+ var (shell, _) = CreateShell();
+ var vendorEngine = new CommandPickerEngine(inner, "/vendor", "供应商", () => SampleItems, applied.Add);
+ vendorEngine.Attach(shell);
+
+ vendorEngine.Submit("/model");
+ Assert.Equal(["/model"], inner.Submits);
+ Assert.False(shell.ModelPicker.IsPending);
+
+ vendorEngine.Submit("/vendor");
+ Assert.True(shell.ModelPicker.IsPending);
+ shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false));
+
+ Assert.Equal(["gpt-mini"], applied);
+ Assert.Contains(shell.View.Messages.Items, m => m is AssistantMessageItem { } a && a.Text.Contains("已切换供应商"));
+ }
+
+ /// 叠层装饰(TASK058 Host 组装形态)— vendor 外层 + model 内层,两命令各自拦截互不干扰。
+ [Fact]
+ public void Engine_StackedVendorsOuterModelInner_BothIntercept()
+ {
+ var inner = new FakeModelEngine();
+ var modelApplied = new List();
+ var vendorApplied = new List();
+ var (shell, _) = CreateShell();
+ var modelEngine = new CommandPickerEngine(inner, "/model", "模型", () => SampleItems, modelApplied.Add);
+ var vendorEngine = new CommandPickerEngine(modelEngine, "/vendor", "供应商", () => SampleItems, vendorApplied.Add);
+ vendorEngine.Attach(shell);
+
+ vendorEngine.Submit("/model");
+ Assert.True(shell.ModelPicker.IsPending);
+ shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false));
+ Assert.Empty(modelApplied);
+
+ vendorEngine.Submit("/vendor");
+ Assert.True(shell.ModelPicker.IsPending);
+ shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false));
+
+ Assert.Empty(modelApplied);
+ Assert.Equal(["gpt-mini"], vendorApplied);
+ Assert.Empty(inner.Submits);
+ }
+
/// 记录型假引擎(PermissionDialogTests.FakePermissionEngine 同构)。
private sealed class FakeModelEngine : IReplEngine
{
From d5d0b36d3888f7ebe6e992f798f8b9bcfaadc5cf Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 04:58:23 +0800
Subject: [PATCH 08/17] =?UTF-8?q?docs:=20TASK058=20=E4=BE=9B=E5=BA=94?=
=?UTF-8?q?=E5=95=86=E9=80=89=E6=8B=A9=E5=99=A8=E8=90=BD=E5=9C=B0=E8=AE=B0?=
=?UTF-8?q?=E5=BD=95=EF=BC=88=E5=9F=BA=E7=BA=BF=20782+52=20+=20selftest=20?=
=?UTF-8?q?=E5=8F=8C=E9=80=9A=E8=BF=87=20+=20=E8=B8=A9=E5=9D=91=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...06\351\200\211\346\213\251\345\231\250.md" | 31 ++++++++++++++++---
1 file changed, 26 insertions(+), 5 deletions(-)
diff --git "a/docs/proposed/task/TASK058-TUI\344\276\233\345\272\224\345\225\206\351\200\211\346\213\251\345\231\250.md" "b/docs/proposed/task/TASK058-TUI\344\276\233\345\272\224\345\225\206\351\200\211\346\213\251\345\231\250.md"
index 946ed082d..6a15103c9 100644
--- "a/docs/proposed/task/TASK058-TUI\344\276\233\345\272\224\345\225\206\351\200\211\346\213\251\345\231\250.md"
+++ "b/docs/proposed/task/TASK058-TUI\344\276\233\345\272\224\345\225\206\351\200\211\346\213\251\345\231\250.md"
@@ -1,6 +1,6 @@
# TASK058 — TUI 供应商选择器(/vendor 模态)
-> 状态:in_progress(2026-10-21 开工)
+> 状态:implemented(2026-10-21 全链落地 + 双 selftest 通过;剩余用户真机验收)
> 前置:TASK057 完结;Bug1/Bug2 验收通过后用户新需求:"怎么切换供应商,你好像没有做"
> 用户决策:模态选择器方案(对齐 /model 交互;引擎切换逻辑现成,只做 TUI 交互层)
@@ -27,7 +27,28 @@
| 基建实现 | 消费点 | 已实现 | 已验收 |
|---------|--------|--------|--------|
-| VendorPickerEngine(Core) | Program 装饰链 + Demo | ✅ | ✅ ADR 0080 真机(/vendor 弹框 10 项 + 切换 + banner vendor/model 同步) |
-| BannerItem.Vendor 渲染 | ReplBridgeDriver.ApplySessionInfo | ✅ | ✅ |
-| VendorPickerAssembly(Host) | Program applyVendor 委托 | ✅ | ✅ |
-| SessionInfo.Vendor 字段 | EngineSession/Demo 种子事件 | ✅ | ✅ |
+| CommandPickerEngine(Core,ModelPickerEngine 泛化) | Program 装饰链(vendor 外层包 model)+ Demo | ✅ | ✅ ADR 0080 真机(待用户:/vendor 弹框 10 项 + 切换 + banner vendor/model 同步) |
+| BannerItem.Vendor 渲染(SessionInfo.Vendor 字段链) | ReplBridgeDriver.ApplySessionInfo | ✅ | ✅ 单元 782+52 全绿 |
+| VendorPickerAssembly(Host) | Program applyVendor 委托 | ✅ | ✅ 单元 4 测试 + selftest |
+| ModelPickerAssembly.CurrentProvider 读 config | /model 清单跟随供应商 | ✅ | ✅ selftest |
+
+## 最终基线
+
+- NativeTui.Tests:753 → **782**(+29:vendor bridge 2 + CommandPickerEngine vendor/叠层 2 + banner/demo 等)
+- NativeTui.Host.Tests:47 → **52**(+5:VendorPickerAssembly 4 + EngineSession vendor 1)
+- Demo --selftest + --bridge-selftest 双通过;Host --selftest 通过(假 key 冒烟 — 真实引擎组装链)
+- Host exe 已 `--no-incremental` 全量重建
+- 提交:4e496d776(banner vendor 数据链)+ 439fda3(选择器全链)
+- 归档:ModelPickerEngine.cs → `.xxx/ModelPickerEngine.cs.20261021.del`(泛化为 CommandPickerEngine 替代)
+
+## 踩坑记录
+
+- AI shell 无 OPENAI_API_KEY 且 auth.json openai 键为空串 → Host selftest LoadConfigAsync 抛 ConfigurationException;用户真实环境不受影响(假 key 冒烟通过)
+- 构造参数默认值不能引用类内 const(CS0103)— ModelPickerDialog 参数化时用字面量默认值
+- 叠层装饰 Attach 只绑外层 → 内层拦截失效;Attach 递归传播内层同族(CommandPickerEngine.Attach)
+
+
+
+
+
+
From 275a0e42d434e7a13c7ed74a479798970327a697 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 05:30:28 +0800
Subject: [PATCH 09/17] =?UTF-8?q?fix:=20TUI=20=E6=A8=AA=E5=90=91=E7=BA=BF?=
=?UTF-8?q?=E6=AE=8B=E5=BD=B1=E6=A0=B9=E5=9B=A0=E4=BF=AE=E5=A4=8D=EF=BC=88?=
=?UTF-8?q?=E5=BC=95=E6=93=8E=E6=97=A5=E5=BF=97=E9=9D=99=E9=BB=98=EF=BC=89?=
=?UTF-8?q?=20|=20=E5=86=B3=E7=AD=96:=20=E6=A0=B9=E5=9B=A0=E6=98=AF=20Crea?=
=?UTF-8?q?teGuiSessionAsync=20=E6=97=A0=20loggerProvider=20=E5=9B=9E?=
=?UTF-8?q?=E9=80=80=20AddConsole=EF=BC=8CError=20=E6=97=A5=E5=BF=97?=
=?UTF-8?q?=E7=9B=B4=E5=86=99=20stderr=20=E4=B8=8E=20inline=20=E6=B8=B2?=
=?UTF-8?q?=E6=9F=93=E4=BA=A4=E9=94=99=E7=A0=B4=E5=9D=8F=E7=9B=B8=E5=AF=B9?=
=?UTF-8?q?=E5=85=89=E6=A0=87=E8=BF=BD=E8=B8=AA=EF=BC=8C=E6=97=A7=E5=B8=A7?=
=?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E7=BA=BF=E5=AF=B9/footer=20?=
=?UTF-8?q?=E8=A2=AB=E9=94=99=E4=BD=8D=E5=9B=BA=E5=8C=96=E5=A4=9A=E6=9D=A1?=
=?UTF-8?q?=E6=AE=8B=E5=BD=B1=EF=BC=9BTUI=20=E6=B3=A8=E5=85=A5=E8=87=AA?=
=?UTF-8?q?=E7=A0=94=20SilentLoggerProvider=20=E5=85=A8=E9=9D=99=E9=BB=98?=
=?UTF-8?q?=EF=BC=88=E9=94=99=E8=AF=AF=E5=8F=8D=E9=A6=88=E5=B7=B2=E6=9C=89?=
=?UTF-8?q?=20Error=20=E4=BA=8B=E4=BB=B6=E9=93=BE=20=E2=97=8F=20=E5=9B=9E?=
=?UTF-8?q?=E5=90=88=E6=89=A7=E8=A1=8C=E5=A4=B1=E8=B4=A5=EF=BC=8C=E4=B8=8D?=
=?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=BC=95=E6=93=8E=E6=97=A5=E5=BF=97=EF=BC=89?=
=?UTF-8?q?=EF=BC=8C=E8=B7=A8=20modules=20=E5=8C=85=E7=89=88=E6=9C=AC?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=8B=20NullLoggerProvider=20=E8=A7=A3?=
=?UTF-8?q?=E6=9E=90=E4=B8=8D=E5=8F=AF=E9=9D=A0=E6=95=85=E8=87=AA=E7=A0=94?=
=?UTF-8?q?=EF=BC=9B=E5=8F=A6=20Tavily=20SearchDepth=20=E5=A4=96=E9=83=A8?=
=?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=80=BC=E6=92=9E=20ApiLoggingLevel=20?=
=?UTF-8?q?=E6=9E=9A=E4=B8=BE=E5=90=8D=E7=B3=BB=E5=88=86=E6=9E=90=E5=99=A8?=
=?UTF-8?q?=E9=A2=84=E5=AD=98=E8=AF=AF=E6=8A=A5=EF=BC=88=E5=A2=9E=E9=87=8F?=
=?UTF-8?q?=E7=BC=96=E8=AF=91=E6=9C=AA=E6=9A=B4=E9=9C=B2=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?SuppressMessage=20=E8=B1=81=E5=85=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../app/builder/EngineSessionFactory.cs | 5 ++-
modules/apps/gui/hosting/JccChatSession.cs | 2 +-
.../providers/tavily/TavilySearchProvider.cs | 3 +-
.../apps/NativeTui.Host/EngineSession.cs | 9 ++++-
.../apps/NativeTui.Host/GlobalUsings.cs | 1 +
.../tui_assembly/SilentLoggerProvider.cs | 37 +++++++++++++++++++
.../EngineSessionTests.cs | 9 +++++
.../NativeTui.Host.Tests/GlobalUsings.cs | 1 +
8 files changed, 63 insertions(+), 4 deletions(-)
create mode 100644 native_tui/apps/NativeTui.Host/tui_assembly/SilentLoggerProvider.cs
diff --git a/modules/apps/engine/app/builder/EngineSessionFactory.cs b/modules/apps/engine/app/builder/EngineSessionFactory.cs
index 01dd5e6a8..f62fefc15 100644
--- a/modules/apps/engine/app/builder/EngineSessionFactory.cs
+++ b/modules/apps/engine/app/builder/EngineSessionFactory.cs
@@ -64,9 +64,12 @@ public sealed class Result {
/// 创建 GUI/TUI 引擎会话 — 不含 PipeModule/CliModule(宿主不需要命名管道和终端交互),
/// 含 HousekeepingModule(后台清理和实体回收)。
/// extraModules: 宿主可传入额外模块(如 GuiInteractionModule/TuiInteractionModule/CliRegistrationModule)。
+ /// loggerProvider: 宿主日志 provider(ADR 0100 表现层注入);null 时引擎回退默认 console —
+ /// TUI 宿主必须传静默 provider(Console 日志直写 stderr 会与 inline 渲染交错,光标追踪错乱致残影)。
///
public static Task CreateGuiSessionAsync(
IEnumerable? extraModules = null,
+ ILoggerProvider? loggerProvider = null,
CancellationToken cancellationToken = default) {
var fs = IO.FileSystem.FileSystemFactory.Create();
var options = new CommandLineOptions {
@@ -85,7 +88,7 @@ public static Task CreateGuiSessionAsync(
if (extraModules is not null)
modules.AddRange(extraModules);
- return CreateSessionAsync(options, fs, modules, loggerProvider: null, cancellationToken, clearPipeEndpoint: true);
+ return CreateSessionAsync(options, fs, modules, loggerProvider, cancellationToken, clearPipeEndpoint: true);
}
///
diff --git a/modules/apps/gui/hosting/JccChatSession.cs b/modules/apps/gui/hosting/JccChatSession.cs
index e81504e9d..9c913e053 100644
--- a/modules/apps/gui/hosting/JccChatSession.cs
+++ b/modules/apps/gui/hosting/JccChatSession.cs
@@ -270,7 +270,7 @@ public static async Task CreateAsync(
CancellationToken cancellationToken = default) {
var swTotal = System.Diagnostics.Stopwatch.StartNew();
var result = await EngineSessionFactory.CreateGuiSessionAsync(
- [new GuiInteractionModule()], cancellationToken);
+ [new GuiInteractionModule()], cancellationToken: cancellationToken);
App.LogDiag($"[JccChatSession] EngineSessionFactory.CreateGuiSessionAsync: {swTotal.ElapsedMilliseconds}ms");
var executionSettings = result.Services.GetService();
diff --git a/modules/cognition/hands/web/search_providers/providers/tavily/TavilySearchProvider.cs b/modules/cognition/hands/web/search_providers/providers/tavily/TavilySearchProvider.cs
index 1c545a857..8d1d92806 100644
--- a/modules/cognition/hands/web/search_providers/providers/tavily/TavilySearchProvider.cs
+++ b/modules/cognition/hands/web/search_providers/providers/tavily/TavilySearchProvider.cs
@@ -102,8 +102,9 @@ internal sealed record TavilyRequestDto {
[JsonPropertyName("max_results")]
public int MaxResults { get; init; } = 10;
- /// 搜索深度:basic 或 advanced。
+ /// 搜索深度:basic 或 advanced(Tavily API 协议值)。
[JsonPropertyName("search_depth")]
+ [SuppressMessage("AotSafety", "JCC10007", Justification = "外部协议: Tavily API search_depth 协议值 basic/advanced,与 ApiLoggingLevel 枚举值撞名纯巧合,非同源字符串")]
public string SearchDepth { get; init; } = "basic";
/// 是否包含 AI 摘要答案。
diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs
index 31051115d..9d58c0bcd 100644
--- a/native_tui/apps/NativeTui.Host/EngineSession.cs
+++ b/native_tui/apps/NativeTui.Host/EngineSession.cs
@@ -37,11 +37,18 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien
/// 重发 session_info 事件 — 模型/供应商切换等运行时状态变化后刷新 banner(ReplBridgeDriver.ApplySessionInfo 差分上屏)。
public void RefreshSessionInfo() => _client.Enqueue(BuildSessionInfoEvent(_checker, _fastMode, _engine.Config));
- /// 组装引擎会话 — 建会话(TuiInteractionModule 注册权限确认 handler)→ 解析权限/模型 → 构造回合客户端 → 种子 session_info;失败时释放已建引擎 Host。
+ /// TUI 静默日志 provider(internal — 测试断言非 Console:Console 直写 stderr 与 inline 渲染交错致光标错乱/横向线残影)。
+ /// 错误反馈走 EngineTurnClient Error 事件链(● 回合执行失败),不依赖引擎日志。
+ internal static ILoggerProvider LoggerProvider => SilentLoggerProvider.Instance;
+
+ /// 组装引擎会话 — 建会话(TuiInteractionModule 注册权限确认 handler;静默 logger —
+ /// Console 日志直写 stderr 会与 inline 渲染交错破坏光标追踪,错误反馈走 EngineTurnClient Error 事件链)
+ /// → 解析权限/模型 → 构造回合客户端 → 种子 session_info;失败时释放已建引擎 Host。
public static async Task CreateAsync(CancellationToken cancellationToken = default) {
var permissionBridge = new TuiPermissionBridge();
var engine = await EngineSessionFactory.CreateGuiSessionAsync(
extraModules: [new TuiInteractionModule(permissionBridge)],
+ loggerProvider: LoggerProvider,
cancellationToken: cancellationToken).ConfigureAwait(false);
try {
var checker = await ResolvePermissionCheckerAsync(engine.Services).ConfigureAwait(false);
diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
index f246c6adb..06ab22d7e 100644
--- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs
+++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
@@ -19,6 +19,7 @@
global using JoinCode.ChatCommands;
global using JoinCode.Cli;
global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Logging;
global using NativeTui.Core;
global using NativeTui.Core.Ansi;
global using NativeTui.Core.Bridge;
diff --git a/native_tui/apps/NativeTui.Host/tui_assembly/SilentLoggerProvider.cs b/native_tui/apps/NativeTui.Host/tui_assembly/SilentLoggerProvider.cs
new file mode 100644
index 000000000..7070036f8
--- /dev/null
+++ b/native_tui/apps/NativeTui.Host/tui_assembly/SilentLoggerProvider.cs
@@ -0,0 +1,37 @@
+namespace NativeTui.Host;
+
+///
+/// TUI 静默日志 provider(修复:引擎 Console 日志直写 stderr 与 inline 渲染交错 —
+/// 相对光标追踪错乱,旧帧输入框线对/footer 被错位固化成多条横向残影)。
+/// 错误反馈走 EngineTurnClient Error 事件链(● 回合执行失败),引擎日志全静默。
+/// 自研实现 — 跨 modules 包版本统一下 NullLoggerProvider 解析不可靠(ADR 0140 Host 层自持组装件)。
+///
+internal sealed class SilentLoggerProvider : ILoggerProvider
+{
+ /// 共享单例(无状态)。
+ internal static readonly SilentLoggerProvider Instance = new();
+
+ /// 静默 logger 单例。
+ private static readonly ILogger SilentLogger = new NullLogger();
+
+ private SilentLoggerProvider() { }
+
+ /// 创建静默 logger。
+ public ILogger CreateLogger(string categoryName) => SilentLogger;
+
+ /// 无资源。
+ public void Dispose() { }
+
+ /// 全吞 logger — 全级别禁用、零输出。
+ private sealed class NullLogger : ILogger
+ {
+ /// 无 scope。
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ /// 全级别禁用。
+ public bool IsEnabled(LogLevel logLevel) => false;
+
+ /// 零输出。
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { }
+ }
+}
diff --git a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
index a721248b8..2ac24fb80 100644
--- a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs
@@ -70,4 +70,13 @@ public async Task BuildSessionInfoEvent_WithConfig_CarriesVendor()
var withoutConfig = EngineSession.BuildSessionInfoEvent(checker: null, fastMode, config: null);
Assert.Null(withoutConfig.Vendor);
}
+
+ /// TUI 静默日志(修复:引擎 Console 日志直写 stderr 与 inline 渲染交错致光标错乱/横向线残影)—
+ /// provider 必须是 SilentLoggerProvider(禁止 Console 系 provider)。
+ [Fact]
+ public void LoggerProvider_IsSilent_NotConsoleProvider()
+ {
+ Assert.Same(SilentLoggerProvider.Instance, EngineSession.LoggerProvider);
+ Assert.DoesNotContain("Console", EngineSession.LoggerProvider.GetType().Name, StringComparison.Ordinal);
+ }
}
diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
index 8ad66817a..667bf184d 100644
--- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
@@ -15,6 +15,7 @@
global using JoinCode.Adapters;
global using JoinCode.App.Builder;
global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Logging;
global using NativeTui.Core.Bridge;
global using NativeTui.Core.Markdown;
global using NativeTui.Core.Repl;
From c2e4d13c5199229d124635c0fc59ba2e4d173454 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 05:27:50 +0800
Subject: [PATCH 10/17] =?UTF-8?q?feat:=20=E5=BC=95=E6=93=8E=E4=BE=A7?=
=?UTF-8?q?=E5=85=AB=E6=8F=92=E4=BB=B6=E5=8A=A0=E8=BD=BD=E5=90=8E=E5=8F=B0?=
=?UTF-8?q?=E5=8C=96=20McpPluginBootstrap=EF=BC=88=E5=90=AF=E5=8A=A8?=
=?UTF-8?q?=E5=BC=80=E9=94=80=E4=BC=98=E5=8C=96=20A=20=E9=A1=B9=EF=BC=89+?=
=?UTF-8?q?=20AGENTS.md=20=E4=B8=B4=E6=97=B6=E6=97=A5=E5=BF=97=E5=BD=92?=
=?UTF-8?q?=E7=BD=AE=E8=A7=84=E7=9F=A9=20|=20=E5=86=B3=E7=AD=96:=20McpInit?=
=?UTF-8?q?Module.ConfigureAsync=20=E4=B8=8D=E5=86=8D=E9=98=BB=E5=A1=9E?=
=?UTF-8?q?=E7=AD=89=E5=85=AB=E6=8F=92=E4=BB=B6=20WhenAll=20=E5=A2=99?=
=?UTF-8?q?=E9=92=9F=EF=BC=88=E5=AE=9E=E6=B5=8B=20mcpInit=20=E6=8F=92?=
=?UTF-8?q?=E4=BB=B6=20MCP=20stdio=20=E6=8F=A1=E6=89=8B=201.6s=20=E4=B8=BA?=
=?UTF-8?q?=E5=85=B3=E9=94=AE=E8=B7=AF=E5=BE=84=EF=BC=89=EF=BC=8C=E5=90=8E?=
=?UTF-8?q?=E5=8F=B0=E5=8C=96=E7=BB=8F=20McpPluginBootstrapHandle=20DI=20?=
=?UTF-8?q?=E5=8D=95=E4=BE=8B=E7=BB=91=E5=AE=9A=E5=AE=BF=E4=B8=BB=20Dispos?=
=?UTF-8?q?e=EF=BC=88=E9=80=80=E5=87=BA=E7=AB=9E=E6=80=81=E9=98=B2?=
=?UTF-8?q?=E6=8A=A4=E2=80=94=E2=80=94=E5=85=88=E5=8F=96=E6=B6=88=E5=8A=A0?=
=?UTF-8?q?=E9=80=9F=E6=96=AD=E9=93=BE=E5=86=8D=E6=97=A0=E8=B6=85=E6=97=B6?=
=?UTF-8?q?=E7=AD=89=E5=BE=85=EF=BC=8C=E5=AF=B9=E9=BD=90=E8=B5=84=E6=BA=90?=
=?UTF-8?q?=E8=A7=84=E8=8C=83=E7=A6=81=E8=B6=85=E6=97=B6=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?=E9=92=A9=E5=AD=90=E5=89=8D=E5=8F=96=E6=B6=88=E7=9F=AD=E8=B7=AF?=
=?UTF-8?q?=E9=98=B2=20LoadXxxSafeAsync=20=E5=90=9E=E5=8F=96=E6=B6=88?=
=?UTF-8?q?=E8=87=B4=20WhenAll=20=E8=AF=AF=E5=88=A4=E5=85=A8=E6=88=90?=
=?UTF-8?q?=E5=8A=9F=E5=90=8E=E7=A2=B0=E5=B7=B2=E9=87=8A=E6=94=BE=20IServi?=
=?UTF-8?q?ceProvider=EF=BC=8C=E5=85=AB=E4=B8=AA=E9=87=8D=E5=A4=8D=20SafeA?=
=?UTF-8?q?sync=20=E6=94=B6=E6=95=9B=E4=B8=BA=E6=B3=9B=E5=9E=8B=20LoadPlug?=
=?UTF-8?q?inSafeAsync=EF=BC=88=E5=8F=96=E6=B6=88=E9=9D=99=E9=BB=98+?=
=?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=AE=B0=E6=97=A5=E5=BF=97=EF=BC=89=EF=BC=9B?=
=?UTF-8?q?=E5=AE=9E=E6=B5=8B=20CreateAsync=202435ms=E2=86=921221ms=20?=
=?UTF-8?q?=E7=9C=81=2050%=EF=BC=88=E7=83=AD=E7=A8=B3=E6=80=81=E6=80=BB?=
=?UTF-8?q?=E5=90=AF=E5=8A=A8=20~2.5s=E2=86=92~1.6s=EF=BC=89=EF=BC=9B?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=2011/11=20=E7=BB=BF=20+=20host.tests=201362?=
=?UTF-8?q?=20+=20NativeTui.Tests=20782=20+=20Host.Tests=2052=20=E5=85=A8?=
=?UTF-8?q?=E7=BB=BF=EF=BC=9BAGENTS.md=20=E5=A2=9E=E8=A1=A5=E4=B8=B4?=
=?UTF-8?q?=E6=97=B6=E6=97=A5=E5=BF=97=E5=BD=92=E7=BD=AE(.xxx/)+=E6=9C=AC?=
=?UTF-8?q?=E5=9C=B0=20Debug=20=E4=B8=8D=E5=81=9A=20AOT=20=E4=B8=A4?=
=?UTF-8?q?=E6=9D=A1=E8=A7=84=E7=9F=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 6 +
.../apps/engine/app/modules/McpInitModule.cs | 152 ++++++------------
.../engine/app/modules/McpPluginBootstrap.cs | 113 +++++++++++++
.../engine/McpPluginBootstrapHandleTests.cs | 86 ++++++++++
.../engine/McpPluginBootstrapTests.cs | 129 +++++++++++++++
5 files changed, 380 insertions(+), 106 deletions(-)
create mode 100644 modules/apps/engine/app/modules/McpPluginBootstrap.cs
create mode 100644 test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs
create mode 100644 test/unit/host.tests/engine/McpPluginBootstrapTests.cs
diff --git a/AGENTS.md b/AGENTS.md
index 8599bc73c..e4130a0af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -666,6 +666,12 @@ nuget包: 拒绝全部微软的AI包,因为大部分不支持NativeAOT。
要求分类,通常一个文件夹内直接暴露的文件少于40个,可以多层文件夹。
强迫症就是每个文件夹内文件和文件夹不应该同时存在,而是纯文件夹或者内纯文件,不得混淆。
+### 临时日志归置(强制)
+
+**AI 产生的编译日志/调试日志/探针输出**(`dotnet build` 重定向、测试输出捕获、计时探针、selftest 输出等)必须统一写入项目根 `.xxx/` 目录(如 `.xxx/logs/`),**禁止散落在仓库根目录**(污染 git status、混入误提交)。一次性分析产物用完即删;需跨步骤比对的留在 `.xxx/` 下命名(如 `.xxx/logs/build_red.txt`)。
+
+**本地测试构建走 Debug 配置**,不做 AOT 发布(`PublishAot`/Release 全量编译耗时长,测试迭代优先速度);AOT 验证留给 CI/Release。
+
## 编译
### 16 功能域架构(强制编译顺序)
diff --git a/modules/apps/engine/app/modules/McpInitModule.cs b/modules/apps/engine/app/modules/McpInitModule.cs
index 0fb0b6c83..e42f09534 100644
--- a/modules/apps/engine/app/modules/McpInitModule.cs
+++ b/modules/apps/engine/app/modules/McpInitModule.cs
@@ -1,7 +1,9 @@
namespace JoinCode.App.Modules;
///
-/// MCP 初始化模块 — 启动后初始化 MCP 服务和桥接
+/// MCP 初始化模块 — 启动后初始化 MCP 服务和桥接:挂工具/资源/提示词变更事件(增量刷新)+
+/// 接线技能桥;八插件加载经 后台化(启动开销优化 — TASK037 后续:
+/// 不阻塞启动等加载墙钟,工具就绪后自动补齐 kernel.Plugins)。
///
[AppModule(Order = 90)]
public sealed class McpInitModule : IAppModule {
@@ -11,11 +13,12 @@ public sealed class McpInitModule : IAppModule {
public int Order => 90;
///
- /// 注册模块所需的服务到 DI 容器
+ /// 注册模块所需的服务到 DI 容器 — 后台加载生命周期句柄(Host DisposeAsync 释放时等待链收尾)
///
/// 服务集合
/// 模块配置上下文
public void ConfigureServices(IServiceCollection services, AppModuleContext context) {
+ services.AddSingleton();
}
///
@@ -48,125 +51,62 @@ public async Task ConfigureAsync(IServiceProvider services, CancellationToken ct
services.WirePluginSkillBridge();
- // 并行:所有插件加载(八者逻辑独立,IToolRegistry 线程安全)
- var dreamTask = LoadDreamPluginSafeAsync(services, logger, ct);
- var fixHooksTask = LoadFixHooksPluginSafeAsync(services, logger, ct);
- var sandboxTask = LoadSandboxProvidersPluginSafeAsync(services, logger, ct);
- var telemetryTask = LoadTelemetryPluginSafeAsync(services, logger, ct);
- var agentRolesTask = LoadAgentRolesPluginSafeAsync(services, logger, ct);
- var csharpLangTask = LoadCSharpLanguagePluginSafeAsync(services, logger, ct);
- var llmTask = LoadLlmProvidersPluginSafeAsync(services, logger, ct);
- var mcpInitTask = LoadMcpInitPluginSafeAsync(services, logger, ct);
- await Task.WhenAll(dreamTask, fixHooksTask, sandboxTask, telemetryTask, agentRolesTask, csharpLangTask, llmTask, mcpInitTask).ConfigureAwait(false);
-
- // 所有工具注册完成后,同步工具列表 + 刷新 kernel.Plugins
- try {
- var toolsBridge = services.GetRequiredService();
- await toolsBridge.OnToolsListChangedAsync(ct).ConfigureAwait(false);
- } catch (Exception ex) {
- logger?.LogError(ex, "[MCP] OnToolsListChangedAsync failed");
- }
-
- // 把 IToolRegistry 中的工具挂载到 kernel.Plugins — 修复断裂的工具管线
- // McpToolBridge.CreatePluginAsync 从 IToolRegistry 提取所有工具(含 SlashToMcpAdapter 注册的斜杠命令)
- // 挂载到 IChatClient.Plugins 后,AnthropicQueryService/OpenAIQueryService 能通过 BuildXxxToolsFromKernel 构建工具列表发送给 LLM
- await RefreshKernelPluginsAsync(services, logger).ConfigureAwait(false);
- }
-
- ///
- /// 安全加载 DreamPlugin — 失败仅记日志,不阻断启动
- ///
- private static async Task LoadDreamPluginSafeAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
- try {
- var pluginManager = services.GetRequiredService();
- await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
- } catch (Exception ex) {
- logger?.LogError(ex, "[MCP] LoadDreamPlugin failed");
- }
- }
-
- ///
- /// 安全加载 FixHooksPlugin — 失败仅记日志,不阻断启动
- ///
- private static async Task LoadFixHooksPluginSafeAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
- try {
- var pluginManager = services.GetRequiredService();
- await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
- } catch (Exception ex) {
- logger?.LogError(ex, "[MCP] LoadFixHooksPlugin failed");
- }
- }
-
- ///
- /// 安全加载 SandboxProvidersPlugin — 失败仅记日志,不阻断启动
- ///
- private static async Task LoadSandboxProvidersPluginSafeAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
- try {
- var pluginManager = services.GetRequiredService();
- await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
- } catch (Exception ex) {
- logger?.LogError(ex, "[MCP] LoadSandboxProvidersPlugin failed");
- }
- }
-
- ///
- /// 安全加载 TelemetryPlugin — 失败仅记日志,不阻断启动
- ///
- private static async Task LoadTelemetryPluginSafeAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
- try {
- var pluginManager = services.GetRequiredService();
- await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
- } catch (Exception ex) {
- logger?.LogError(ex, "[MCP] LoadTelemetryPlugin failed");
- }
+ // 启动开销优化(TASK037 后续)— 八插件加载后台化:启动不阻塞等待加载墙钟(实测 ~1.6s,
+ // mcpInit 插件 MCP 客户端握手为关键路径);工具就绪后就绪钩子自动补齐
+ // (toolsBridge 同步 + kernel.Plugins 刷新 — 与 ToolsListChanged 事件增量刷新双保险);
+ // 生命周期经 Handle 单例绑定宿主 Dispose(退出竞态防护 — 先取消加速断链再无超时等待收尾)
+ services.GetRequiredService()
+ .Run(ct => McpPluginBootstrap.StartAsync(
+ ct => StartPluginLoads(services, logger, ct),
+ ct => OnPluginsReadyAsync(services, logger, ct),
+ ct));
}
- ///
- /// 安全加载 AgentRolesPlugin — 失败仅记日志,不阻断启动
- ///
- private static async Task LoadAgentRolesPluginSafeAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
- try {
- var pluginManager = services.GetRequiredService();
- await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
- } catch (Exception ex) {
- logger?.LogError(ex, "[MCP] LoadAgentRolesPlugin failed");
- }
- }
+ /// 八插件并行加载清单(逻辑独立,IToolRegistry 线程安全 — WhenAll 墙钟语义)。
+ private static Task[] StartPluginLoads(IServiceProvider services, ILogger? logger, CancellationToken ct) =>
+ [
+ LoadPluginSafeAsync("DreamPlugin", services, logger, ct),
+ LoadPluginSafeAsync("FixHooksPlugin", services, logger, ct),
+ LoadPluginSafeAsync("SandboxProvidersPlugin", services, logger, ct),
+ LoadPluginSafeAsync("TelemetryPlugin", services, logger, ct),
+ LoadPluginSafeAsync("AgentRolesPlugin", services, logger, ct),
+ LoadPluginSafeAsync("CSharpLanguagePlugin", services, logger, ct),
+ LoadPluginSafeAsync("LlmProvidersPlugin", services, logger, ct),
+ LoadPluginSafeAsync("McpInitPlugin", services, logger, ct),
+ ];
///
- /// 安全加载 CSharpLanguagePlugin — 失败仅记日志,不阻断启动
+ /// 安全加载工作流插件(八插件公共路径 — 原八个 SafeAsync 方法收敛):
+ /// 失败仅记日志不阻断启动;宿主关闭取消静默(Handle Dispose 触发的取消非失败 —
+ /// 否则取消噪音淹没真实加载错误日志)。
///
- private static async Task LoadCSharpLanguagePluginSafeAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
+ private static async Task LoadPluginSafeAsync(string name, IServiceProvider services, ILogger? logger, CancellationToken ct)
+ where TPlugin : class, IWorkflowPlugin, new() {
try {
var pluginManager = services.GetRequiredService();
- await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
+ await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
+ } catch (OperationCanceledException) when (ct.IsCancellationRequested) {
+ // 宿主关闭取消 — 静默收尾(非失败)
} catch (Exception ex) {
- logger?.LogError(ex, "[MCP] LoadCSharpLanguagePlugin failed");
+ logger?.LogError(ex, "[MCP] Load{name} failed", name);
}
}
- ///
- /// 安全加载 LlmProvidersPlugin — 失败仅记日志,不阻断启动
- ///
- private static async Task LoadLlmProvidersPluginSafeAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
+ /// 插件就绪钩子 — 全部加载成功后同步工具列表 + 刷新 kernel.Plugins
+ /// (原 ConfigureAsync 尾部逻辑原样迁移;异常由 Bootstrap SafeAsync 兜底吞掉)。
+ private static async Task OnPluginsReadyAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
+ // 所有工具注册完成后,同步工具列表 + 刷新 kernel.Plugins
try {
- var pluginManager = services.GetRequiredService();
- await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
+ var toolsBridge = services.GetRequiredService();
+ await toolsBridge.OnToolsListChangedAsync(ct).ConfigureAwait(false);
} catch (Exception ex) {
- logger?.LogError(ex, "[MCP] LoadLlmProvidersPlugin failed");
+ logger?.LogError(ex, "[MCP] OnToolsListChangedAsync failed");
}
- }
- ///
- /// 安全加载 McpInitPlugin — 失败仅记日志,不阻断启动
- ///
- private static async Task LoadMcpInitPluginSafeAsync(IServiceProvider services, ILogger? logger, CancellationToken ct) {
- try {
- var pluginManager = services.GetRequiredService();
- await pluginManager.LoadWorkflowPluginAsync(ct).ConfigureAwait(false);
- } catch (Exception ex) {
- logger?.LogError(ex, "[MCP] LoadMcpInitPlugin failed");
- }
+ // 把 IToolRegistry 中的工具挂载到 kernel.Plugins — 修复断裂的工具管线
+ // McpToolBridge.CreatePluginAsync 从 IToolRegistry 提取所有工具(含 SlashToMcpAdapter 注册的斜杠命令)
+ // 挂载到 IChatClient.Plugins 后,AnthropicQueryService/OpenAIQueryService 能通过 BuildXxxToolsFromKernel 构建工具列表发送给 LLM
+ await RefreshKernelPluginsAsync(services, logger).ConfigureAwait(false);
}
///
diff --git a/modules/apps/engine/app/modules/McpPluginBootstrap.cs b/modules/apps/engine/app/modules/McpPluginBootstrap.cs
new file mode 100644
index 000000000..9a3828bb6
--- /dev/null
+++ b/modules/apps/engine/app/modules/McpPluginBootstrap.cs
@@ -0,0 +1,113 @@
+namespace JoinCode.App.Modules;
+
+///
+/// MCP 插件后台加载协调器(启动开销优化 — TASK037 后续):
+/// 基准问题:McpInitModule.ConfigureAsync 阻塞等待八插件并行加载墙钟(实测 ~1.6s — mcpInit 插件
+/// MCP 客户端 stdio 子进程启动+握手为关键路径),拖慢 CLI/GUI/TUI 全宿主启动。
+/// 协调语义:StartAsync 立即返回(调用方 fire-and-forget — 启动不等插件),
+/// 后台链 WhenAll 全成功后调用就绪钩子(toolsBridge 同步 + kernel.Plugins 刷新 — 工具秒级自动补齐,
+/// 与 RemoteClientManager.ToolsListChanged 事件增量刷新双保险);
+/// 加载链/钩子异常全部吞掉(SafeAsync — 返回 Task 恒成功完成,异常不逃逸 ThreadPool;
+/// 单插件失败仅丢失该插件工具,不阻断其余就绪补齐 — 对齐 LoadXxxSafeAsync 语义)。
+/// 消费方:McpInitModule.ConfigureAsync(委托注入加载清单与就绪钩子 — 零 DI 耦合可测)。
+///
+internal static class McpPluginBootstrap
+{
+ /// 启动后台加载链 — 立即返回后台 Task(调用方 fire-and-forget;全异常吞 — 恒成功完成语义)。
+ /// pluginLoads 返回并行加载任务清单(WhenAll 墙钟语义保持);onAllLoaded 为全成功后的就绪钩子。
+ public static Task StartAsync(
+ Func pluginLoads,
+ Func onAllLoaded,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(pluginLoads);
+ ArgumentNullException.ThrowIfNull(onAllLoaded);
+ return RunAsync(pluginLoads, onAllLoaded, cancellationToken);
+ }
+
+ /// 后台链主体 — WhenAll → 全成功钩子;任何阶段异常吞掉(返回 Task 恒成功完成)。
+ private static async Task RunAsync(
+ Func pluginLoads,
+ Func onAllLoaded,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var loads = pluginLoads(cancellationToken);
+ await Task.WhenAll(loads).ConfigureAwait(false);
+ if (cancellationToken.IsCancellationRequested)
+ {
+ // 宿主已请求关闭 — 跳过就绪钩子(Dispose 竞态防护:LoadXxxSafeAsync 会把取消吞成"完成",
+ // WhenAll 误判全成功;钩子前取消短路防碰已释放 IServiceProvider)
+ return;
+ }
+
+ await onAllLoaded(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // 宿主关闭取消 — 静默收尾(后台链随进程结束)
+ }
+ catch (Exception ex)
+ {
+ // SafeAsync — 单插件失败/钩子失败吞掉不逃逸(fire-and-forget 未观察异常崩溃风险);
+ // 诊断走 stderr 旁路(协调器零依赖不持 logger — 对齐 AsyncStderrWriter 诊断先例)
+ Console.Error.WriteLine($"[McpPluginBootstrap] 后台加载链终止: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+}
+
+///
+/// MCP 插件后台加载生命周期句柄(DI 单例)— 后台链与宿主 Dispose 的退出竞态防护:
+/// Run 记录链 Task + 持取消源(单链语义 — 二次 Run no-op);DisposeAsync 先取消(加速断链 —
+/// 链内 OCE 静默收尾)再无超时等待链完成(对齐 AGENTS 资源规范:释放函数禁止超时,直接 await)。
+/// 场景:真实交互会话链早已完成(Dispose 瞬回零等待);启动即退出(selftest/快速退出)等链收尾
+/// (秒级 — 消除 Dispose 后访问已释放 IServiceProvider 的 ObjectDisposedException 竞态噪音)。
+/// 注册消费:McpInitModule.ConfigureServices 注册 → ConfigureAsync 经 Run 启动 →
+/// Host DisposeAsync 释放容器单例时等待收尾。
+///
+internal sealed class McpPluginBootstrapHandle : IAsyncDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+ private Task _background = Task.CompletedTask;
+ private bool _started;
+ private bool _disposed;
+
+ /// 启动后台链(单链语义 — 首次调用生效,后到 no-op;token 归句柄持有供 Dispose 取消)。
+ public void Run(Func background)
+ {
+ ArgumentNullException.ThrowIfNull(background);
+ if (_started || _disposed)
+ {
+ return;
+ }
+
+ _started = true;
+ _background = background(_cts.Token);
+ }
+
+ /// 收尾 — 取消(尽力加速断链)→ 无超时等待链完成(链恒成功语义由 Bootstrap 保证,兜底吞);幂等。
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _cts.Cancel();
+ try
+ {
+ await _background.ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // 防御兜底 — 链异常应已在 Bootstrap 内吞;此处防 Run 装入未包装的原始 Task
+ Console.Error.WriteLine($"[McpPluginBootstrapHandle] 后台链终止: {ex.GetType().Name}: {ex.Message}");
+ }
+ finally
+ {
+ _cts.Dispose();
+ }
+ }
+}
diff --git a/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs b/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs
new file mode 100644
index 000000000..bbdd6e555
--- /dev/null
+++ b/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs
@@ -0,0 +1,86 @@
+namespace JoinCode.Host.Tests.Engine;
+
+///
+/// MCP 插件后台加载生命周期句柄测试(启动开销优化 — TASK037 后续):
+/// 后台链与宿主 Dispose 的退出竞态防护 — Run 记录链 Task + 持取消源;
+/// DisposeAsync 先取消(加速断链 — OCE 静默收尾)再无超时等待链完成
+/// (对齐 AGENTS 资源规范:释放函数禁止超时,直接 await)。
+/// 场景:真实交互会话链早完成(Dispose 瞬回);启动即退出(selftest)等链收尾(日志干净无 ObjectDisposed 竞态)。
+///
+public sealed class McpPluginBootstrapHandleTests
+{
+ /// DisposeAsync — 等待后台链完成(链未完成时阻塞,完成后返回)。
+ [Fact]
+ public async Task ShutdownWaitsForBackgroundChain()
+ {
+ await using var handle = new McpPluginBootstrapHandle();
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ handle.Run(ct => gate.Task);
+
+ var disposeTask = handle.DisposeAsync().AsTask();
+ await Task.Delay(50);
+ Assert.False(disposeTask.IsCompleted);
+
+ gate.SetResult();
+ await disposeTask.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ /// DisposeAsync — 先取消再等(门闩监听取消:Dispose 触发 ct 取消 → 链收尾 → Dispose 返回)。
+ [Fact]
+ public async Task ShutdownCancelsTokenThenWaits()
+ {
+ await using var handle = new McpPluginBootstrapHandle();
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ handle.Run(async ct =>
+ {
+ await using var registration = ct.Register(() => cancelled.TrySetResult());
+ await cancelled.Task;
+ });
+
+ var disposeTask = handle.DisposeAsync().AsTask();
+ await disposeTask.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ /// DisposeAsync — 后台链异常已被链内吞(Run 的链恒成功语义);Dispose 不抛。
+ [Fact]
+ public async Task ShutdownSwallowsBackgroundFault()
+ {
+ await using var handle = new McpPluginBootstrapHandle();
+ handle.Run(_ => Task.FromException(new InvalidOperationException("链失败")));
+
+ var disposeTask = handle.DisposeAsync().AsTask();
+ await disposeTask.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ /// 幂等/顺序 — 未 Run 直接 Dispose no-op;二次 Dispose no-op。
+ [Fact]
+ public async Task ShutdownIdempotentAndNoRunNoop()
+ {
+ await using var handle = new McpPluginBootstrapHandle();
+ await handle.DisposeAsync();
+ await handle.DisposeAsync();
+
+ handle.Run(_ => Task.CompletedTask);
+ await handle.DisposeAsync();
+ await handle.DisposeAsync();
+ }
+
+ /// Run 多次 — 仅首次生效(单链语义,后到 no-op)。
+ [Fact]
+ public async Task RunSecondCallIgnored()
+ {
+ await using var handle = new McpPluginBootstrapHandle();
+ var firstGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondObserved = false;
+ handle.Run(_ => firstGate.Task);
+ handle.Run(_ =>
+ {
+ secondObserved = true;
+ return Task.CompletedTask;
+ });
+
+ firstGate.SetResult();
+ await handle.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.False(secondObserved);
+ }
+}
diff --git a/test/unit/host.tests/engine/McpPluginBootstrapTests.cs b/test/unit/host.tests/engine/McpPluginBootstrapTests.cs
new file mode 100644
index 000000000..47c580f58
--- /dev/null
+++ b/test/unit/host.tests/engine/McpPluginBootstrapTests.cs
@@ -0,0 +1,129 @@
+namespace JoinCode.Host.Tests.Engine;
+
+///
+/// MCP 插件后台加载协调器测试(启动开销优化 — TASK037 后续):
+/// StartAsync 立即返回(启动不等八插件加载墙钟 — 实测 McpInitModule 并行 WhenAll 1.6s 阻塞启动)、
+/// 加载链完成后调用就绪钩子(toolsBridge 同步 + kernel.Plugins 刷新 — 工具秒级自动补齐)、
+/// 加载链/钩子异常全部吞掉(SafeAsync — 后台 Task 不 faulted 逃逸 ThreadPool)。
+///
+public sealed class McpPluginBootstrapTests
+{
+ /// StartAsync — 立即返回:插件加载未完成(TCS 门闩未放行时后台链挂起、钩子未调);
+ /// 门闩放行后后台链完成、钩子被调。
+ [Fact]
+ public async Task StartAsync_ReturnsBeforePluginsComplete_ThenInvokesHookWhenDone()
+ {
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var hookInvoked = false;
+ var background = McpPluginBootstrap.StartAsync(
+ pluginLoads: _ => [gate.Task],
+ onAllLoaded: async _ =>
+ {
+ hookInvoked = true;
+ await Task.Yield();
+ });
+
+ await Task.Delay(50);
+ Assert.False(background.IsCompleted);
+ Assert.False(hookInvoked);
+
+ gate.SetResult();
+ await background.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.True(hookInvoked);
+ }
+
+ /// 并行加载语义 — 全部加载完成后钩子才调用(WhenAll 墙钟语义保持)。
+ [Fact]
+ public async Task StartAsync_WaitsAllLoads_BeforeHook()
+ {
+ var gateA = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var gateB = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var observed = 0;
+ var background = McpPluginBootstrap.StartAsync(
+ pluginLoads: _ => [gateA.Task, gateB.Task],
+ onAllLoaded: _ =>
+ {
+ Interlocked.Increment(ref observed);
+ return Task.CompletedTask;
+ });
+
+ gateA.SetResult();
+ await Task.Delay(50);
+ Assert.False(background.IsCompleted);
+ Assert.Equal(0, observed);
+
+ gateB.SetResult();
+ await background.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal(1, observed);
+ }
+
+ /// 加载链异常 — 吞掉不逃逸(后台 Task 成功完成语义,钩子不调)。
+ [Fact]
+ public async Task StartAsync_PluginLoadFault_SwallowsWithoutHook()
+ {
+ var hookInvoked = false;
+ var background = McpPluginBootstrap.StartAsync(
+ pluginLoads: _ => [Task.FromException(new InvalidOperationException("插件加载失败"))],
+ onAllLoaded: _ =>
+ {
+ hookInvoked = true;
+ return Task.CompletedTask;
+ });
+
+ await background.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal(TaskStatus.RanToCompletion, background.Status);
+ Assert.False(hookInvoked);
+ }
+
+ /// 钩子异常 — 同样吞掉不逃逸(后台 Task 成功完成语义)。
+ [Fact]
+ public async Task StartAsync_HookFault_Swallows()
+ {
+ var background = McpPluginBootstrap.StartAsync(
+ pluginLoads: _ => [Task.CompletedTask],
+ onAllLoaded: _ => Task.FromException(new InvalidOperationException("刷新失败")));
+
+ await background.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal(TaskStatus.RanToCompletion, background.Status);
+ }
+
+ /// 混合结果 — 部分成功部分失败(SafeAsync 各自吞):钩子不调(WhenAll 非全成功)。
+ [Fact]
+ public async Task StartAsync_MixedLoadResults_SwallowsWithoutHook()
+ {
+ var hookInvoked = false;
+ var background = McpPluginBootstrap.StartAsync(
+ pluginLoads: _ => [Task.CompletedTask, Task.FromException(new InvalidOperationException("失败"))],
+ onAllLoaded: _ =>
+ {
+ hookInvoked = true;
+ return Task.CompletedTask;
+ });
+
+ await background.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal(TaskStatus.RanToCompletion, background.Status);
+ Assert.False(hookInvoked);
+ }
+
+ /// 宿主已取消 — 全部加载完成也不调就绪钩子(Dispose 竞态防护:
+ /// LoadXxxSafeAsync 吞掉取消视为完成时,钩子前取消短路防碰已释放 IServiceProvider)。
+ [Fact]
+ public async Task StartAsync_CancelledToken_SkipsReadyHook()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var hookInvoked = false;
+ var background = McpPluginBootstrap.StartAsync(
+ pluginLoads: _ => [Task.CompletedTask],
+ onAllLoaded: _ =>
+ {
+ hookInvoked = true;
+ return Task.CompletedTask;
+ },
+ cts.Token);
+
+ await background.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal(TaskStatus.RanToCompletion, background.Status);
+ Assert.False(hookInvoked);
+ }
+}
From cf385b2deeecc8581793575dcbfc00f2191dc110 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 05:40:50 +0800
Subject: [PATCH 11/17] =?UTF-8?q?fix:=20AsyncHostWrapper=20=E9=87=8A?=
=?UTF-8?q?=E6=94=BE=E9=A1=BA=E5=BA=8F=E4=BF=9D=E8=AF=81=E2=80=94=E2=80=94?=
=?UTF-8?q?=E5=85=88=E6=94=B6=E5=B0=BE=E5=90=8E=E5=8F=B0=E6=8F=92=E4=BB=B6?=
=?UTF-8?q?=E9=93=BE=E5=86=8D=E9=87=8A=E6=94=BE=E5=AE=B9=E5=99=A8=EF=BC=88?=
=?UTF-8?q?=E9=80=80=E5=87=BA=E7=AB=9E=E6=80=81=E6=A0=B9=E6=B2=BB=EF=BC=89?=
=?UTF-8?q?=20|=20=E5=86=B3=E7=AD=96:=20=E7=AB=9E=E6=80=81=E6=A0=B9?=
=?UTF-8?q?=E5=9B=A0=3D=E5=AE=B9=E5=99=A8=E6=9C=AC=E4=BD=93=E5=85=88?=
=?UTF-8?q?=E6=A0=87=E8=AE=B0=20disposed=20=E5=86=8D=E9=87=8A=E6=94=BE=20H?=
=?UTF-8?q?andle=20=E5=8D=95=E4=BE=8B=EF=BC=8C=E6=8F=92=E4=BB=B6=20Initial?=
=?UTF-8?q?izeAsync=20=E5=9C=A8=E7=AA=97=E5=8F=A3=E5=86=85=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E8=A7=A3=E6=9E=90=E6=92=9E=20ObjectDisposedException?=
=?UTF-8?q?=EF=BC=9B=E4=BF=AE=E6=B3=95=E8=90=BD=20AsyncHostWrapper.Dispose?=
=?UTF-8?q?Async=EF=BC=88engine=20=E5=BA=93=E5=86=85=E6=94=B6=E5=8F=A3?=
=?UTF-8?q?=EF=BC=8CCLI/GUI/TUI-Host=20=E4=B8=89=E5=AE=BF=E4=B8=BB?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=EF=BC=89=EF=BC=8C=E5=AE=B9=E5=99=A8=E5=AD=98?=
=?UTF-8?q?=E6=B4=BB=E6=9C=9F=E5=86=85=E5=8F=96=E6=B6=88+=E6=97=A0?=
=?UTF-8?q?=E8=B6=85=E6=97=B6=E7=AD=89=E5=BE=85=E5=90=8E=E5=8F=B0=E9=93=BE?=
=?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=9B=E5=90=8C=E6=AD=A5=20Dispose=20?=
=?UTF-8?q?=E4=BF=9D=E6=8C=81=E7=BA=AF=E9=80=8F=E4=BC=A0=EF=BC=88analyzer?=
=?UTF-8?q?=20=E7=A6=81=20fire-and-forget=E3=80=81=E5=AE=B9=E5=99=A8?=
=?UTF-8?q?=E8=87=AA=E8=BA=AB=E9=87=8A=E6=94=BE=E5=8D=95=E4=BE=8B=E5=85=9C?=
=?UTF-8?q?=E5=BA=95=EF=BC=8C=E4=B8=BB=E8=B7=AF=E5=BE=84=20await=20using?=
=?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=AE=9E=E6=B5=8B=20selftest=20stderr=20?=
=?UTF-8?q?=E9=9B=B6=20fail=20=E9=9B=B6=20ObjectDisposed=20=E9=80=80?=
=?UTF-8?q?=E5=87=BA=E7=A0=81=200=EF=BC=9Bwrapper=204=20=E6=96=B0=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=20+=20host.tests=20=E5=85=A8=E9=87=8F=201366=20?=
=?UTF-8?q?=E7=BB=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../app/builder/EngineSessionFactory.cs | 11 +-
.../AsyncHostWrapperShutdownOrderTests.cs | 137 ++++++++++++++++++
2 files changed, 146 insertions(+), 2 deletions(-)
create mode 100644 test/unit/host.tests/engine/AsyncHostWrapperShutdownOrderTests.cs
diff --git a/modules/apps/engine/app/builder/EngineSessionFactory.cs b/modules/apps/engine/app/builder/EngineSessionFactory.cs
index f62fefc15..a92b2d1bf 100644
--- a/modules/apps/engine/app/builder/EngineSessionFactory.cs
+++ b/modules/apps/engine/app/builder/EngineSessionFactory.cs
@@ -11,6 +11,9 @@ public interface IAsyncHost : IHost, IAsyncDisposable;
///
/// 异步 Host 包装器 — 将 包装为 。
/// 运行时 Host 实例已实现 IAsyncDisposable,此包装器仅让编译时类型暴露 DisposeAsync。
+/// 释放顺序保证:DisposeAsync 先收尾
+/// (取消 + 等后台插件链完成)再释放内层 Host — 容器本体 dispose 先于后台链收尾时,插件
+/// InitializeAsync 内服务解析撞 ObjectDisposedException(退出竞态,全宿主统一在此收口)。
///
internal sealed class AsyncHostWrapper : IAsyncHost {
private readonly IHost _inner;
@@ -22,10 +25,14 @@ internal sealed class AsyncHostWrapper : IAsyncHost {
public Task StartAsync(CancellationToken cancellationToken = default) => _inner.StartAsync(cancellationToken);
/// 停止 Host — 透传到内部 Host
public Task StopAsync(CancellationToken cancellationToken = default) => _inner.StopAsync(cancellationToken);
- /// 同步释放 — 透传到内部 Host
+ /// 同步释放 — 透传到内部 Host(后台链收尾由容器自身释放 Handle 单例兜底;
+ /// 全量保序走 DisposeAsync 主路径,同步 Dispose 是死锁高危遗留调用面不动原语义)
public void Dispose() => _inner.Dispose();
- /// 异步释放 — 优先用内部 Host 的 DisposeAsync,回退到同步 Dispose
+ /// 异步释放 — 先收尾后台插件链(取消 + 无超时等待链完成,容器存活期内断链),
+ /// 再释放内部 Host(优先 DisposeAsync,回退同步 Dispose)
public async ValueTask DisposeAsync() {
+ if (_inner.Services.GetService() is { } bootstrap)
+ await bootstrap.DisposeAsync().ConfigureAwait(false);
if (_inner is IAsyncDisposable ad)
await ad.DisposeAsync().ConfigureAwait(false);
else
diff --git a/test/unit/host.tests/engine/AsyncHostWrapperShutdownOrderTests.cs b/test/unit/host.tests/engine/AsyncHostWrapperShutdownOrderTests.cs
new file mode 100644
index 000000000..44979e9df
--- /dev/null
+++ b/test/unit/host.tests/engine/AsyncHostWrapperShutdownOrderTests.cs
@@ -0,0 +1,137 @@
+namespace JoinCode.Host.Tests.Engine;
+
+///
+/// AsyncHostWrapper 释放顺序测试(引擎侧后台插件链退出竞态修复):
+/// DisposeAsync 必须先收尾 McpPluginBootstrapHandle(取消 + 等后台链完成)再释放内层 Host——
+/// 容器本体 dispose 先于后台链收尾时,插件 InitializeAsync 内 GetRequiredService 撞
+/// ObjectDisposedException(Host --selftest 实测 2 条 fail 噪音根因)。
+/// 顺序保证使所有宿主(CLI/GUI/TUI-Host)统一在容器存活期内完成后台链取消。
+/// 判定方式:fake Host 释放时刻检查后台链门闩已放行(Handle 收尾完成 → 取消已传播且链已断)。
+///
+public sealed class AsyncHostWrapperShutdownOrderTests
+{
+ /// 构造包装宿主:fake IHost(释放时刻探测门闩)+ DI 注册后台句柄(Run 挂门闩后台链)。
+ private static (AsyncHostWrapper Wrapper, ProbingHost Host, McpPluginBootstrapHandle Handle, TaskCompletionSource Gate) Create()
+ {
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ var provider = services.BuildServiceProvider();
+ var host = new ProbingHost(provider, gate);
+ var wrapper = new AsyncHostWrapper(host);
+ var handle = provider.GetRequiredService();
+ handle.Run(async ct =>
+ {
+ await using var registration = ct.Register(() => gate.TrySetResult());
+ await gate.Task;
+ });
+ return (wrapper, host, handle, gate);
+ }
+
+ /// 释放顺序 — Handle 收尾(门闩放行)先于内层 Host 释放(host 释放时刻门闩已完成)。
+ [Fact]
+ public async Task ShutdownAsync_CollapsesBootstrapHandle_BeforeHostDisposal()
+ {
+ var (wrapper, host, _, gate) = Create();
+ Assert.False(gate.Task.IsCompleted);
+
+ await wrapper.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.True(host.Disposed);
+ Assert.True(host.GateCompletedAtDisposal, "Host 释放时刻后台链尚未收尾 — Handle 收尾必须先于容器释放");
+ }
+
+ /// 取消传播 — DisposeAsync 触发 Handle 取消令牌(后台链据此断链收尾)。
+ [Fact]
+ public async Task ShutdownAsync_CancelsHandleToken()
+ {
+ var (wrapper, _, _, gate) = Create();
+
+ await wrapper.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.True(gate.Task.IsCompleted);
+ }
+
+ /// 无 Handle 注册(模块链不含 McpInitModule 的极端组装)— DisposeAsync no-op 直达宿主释放不抛。
+ [Fact]
+ public async Task ShutdownAsync_WithoutRegisteredHandle_DisposesHost()
+ {
+ var provider = new ServiceCollection().BuildServiceProvider();
+ var host = new ProbingHost(provider, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
+ var wrapper = new AsyncHostWrapper(host);
+
+ await wrapper.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.True(host.Disposed);
+ }
+
+ /// 内层 Host 非 IAsyncDisposable — 回退同步 Dispose 路径同样先收尾 Handle。
+ [Fact]
+ public async Task ShutdownAsync_SyncOnlyHost_StillCollapsesHandleFirst()
+ {
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ var provider = services.BuildServiceProvider();
+ var host = new SyncOnlyHost(provider, gate);
+ var wrapper = new AsyncHostWrapper(host);
+ var handle = provider.GetRequiredService();
+ handle.Run(async ct =>
+ {
+ await using var registration = ct.Register(() => gate.TrySetResult());
+ await gate.Task;
+ });
+
+ await wrapper.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.True(host.Disposed);
+ Assert.True(host.GateCompletedAtDisposal, "同步回退路径同样必须先收尾 Handle 再释放容器");
+ }
+
+ /// 释放时刻探测门闩的 fake IHost(IAsyncDisposable)。
+ private sealed class ProbingHost(IServiceProvider services, TaskCompletionSource gate) : IHost, IAsyncDisposable
+ {
+ public bool Disposed { get; private set; }
+
+ /// 释放时刻后台链门闩是否已完成(顺序判定面)。
+ public bool GateCompletedAtDisposal { get; private set; }
+
+ public IServiceProvider Services => services;
+
+ public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ GateCompletedAtDisposal = gate.Task.IsCompleted;
+ Disposed = true;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ Dispose();
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ /// 仅同步 Dispose 的 fake IHost(回退路径)。
+ private sealed class SyncOnlyHost(IServiceProvider services, TaskCompletionSource gate) : IHost
+ {
+ public bool Disposed { get; private set; }
+
+ public bool GateCompletedAtDisposal { get; private set; }
+
+ public IServiceProvider Services => services;
+
+ public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ GateCompletedAtDisposal = gate.Task.IsCompleted;
+ Disposed = true;
+ }
+ }
+}
From f78e2674e4d13e25df77e461b408238bd59b409b Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 06:03:40 +0800
Subject: [PATCH 12/17] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=E9=94=99?=
=?UTF-8?q?=E8=AF=AF=E5=8F=8B=E5=A5=BD=E5=91=88=E7=8E=B0=EF=BC=88=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D=E9=97=AA=E9=80=80=E5=88=B7=E5=A0=86=E6=A0=88=EF=BC=89?=
=?UTF-8?q?=20|=20=E5=86=B3=E7=AD=96:=20Main=20=E9=A1=B6=E5=B1=82=20catch?=
=?UTF-8?q?=20ConfigurationException=20=E7=BB=9F=E4=B8=80=E8=B5=B0=20Start?=
=?UTF-8?q?upErrorPrinter=20=E7=BB=93=E6=9E=84=E5=8C=96=E9=9D=A2=E6=9D=BF?=
=?UTF-8?q?=EF=BC=88=E4=B8=A4=E7=BA=A7=20profile=20=E8=A6=86=E7=9B=96?=
=?UTF-8?q?=E9=93=BE=E7=9C=9F=E5=AE=9E=E6=8E=A2=E6=B5=8B=20+=20auth=20key?=
=?UTF-8?q?=20=E7=8A=B6=E6=80=81=20+=20=E7=8E=AF=E5=A2=83=E5=8F=98?=
=?UTF-8?q?=E9=87=8F=20+=20=E4=B8=89=E6=9D=A1=E4=BF=AE=E5=A4=8D=E8=B7=AF?=
=?UTF-8?q?=E5=BE=84=EF=BC=8C=E9=80=80=E5=87=BA=E7=A0=81=202=20=E5=8C=BA?=
=?UTF-8?q?=E5=88=86=E9=85=8D=E7=BD=AE=E9=94=99=E8=AF=AF=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?Probe=20=E7=BA=AF=E5=87=BD=E6=95=B0=E6=B3=A8=E5=85=A5=20JSON=20?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=E6=B5=8B=E8=AF=95=E5=8F=8B=E5=A5=BD=20+=20Pr?=
=?UTF-8?q?obeLive=20=E7=9C=9F=E5=AE=9E=E8=AF=BB=E5=8F=96=EF=BC=9B?=
=?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=86=85=E5=B1=82=20catch=20=E6=94=BE?=
=?UTF-8?q?=E8=A1=8C=20ConfigurationException=20=E5=86=92=E6=B3=A1?=
=?UTF-8?q?=EF=BC=88raw=20mode=20=E5=85=88=E7=94=B1=20using=20=E6=81=A2?=
=?UTF-8?q?=E5=A4=8D=E5=86=8D=E6=89=93=E5=8D=B0=EF=BC=8C=E7=BB=88=E7=AB=AF?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E5=B9=B2=E5=87=80=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/NativeTui.Host/GlobalUsings.cs | 1 +
native_tui/apps/NativeTui.Host/Program.cs | 26 ++-
.../tui_assembly/StartupErrorPrinter.cs | 182 ++++++++++++++++++
.../NativeTui.Host.Tests/GlobalUsings.cs | 1 +
.../StartupErrorPrinterTests.cs | 104 ++++++++++
5 files changed, 306 insertions(+), 8 deletions(-)
create mode 100644 native_tui/apps/NativeTui.Host/tui_assembly/StartupErrorPrinter.cs
create mode 100644 native_tui/tests/NativeTui.Host.Tests/StartupErrorPrinterTests.cs
diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
index 06ab22d7e..29ca5b8a2 100644
--- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs
+++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
@@ -7,6 +7,7 @@
global using JoinCode.Abstractions.Entity;
global using JoinCode.Abstractions.Configuration;
global using JoinCode.Abstractions.Configuration.AppData;
+global using JoinCode.Abstractions.Exceptions;
global using JoinCode.Abstractions.Configuration.Providers;
global using JoinCode.Abstractions.Interfaces;
global using JoinCode.Abstractions.LLM.Chat;
diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs
index b2fa2daf1..0d2a9136b 100644
--- a/native_tui/apps/NativeTui.Host/Program.cs
+++ b/native_tui/apps/NativeTui.Host/Program.cs
@@ -7,16 +7,24 @@ namespace NativeTui.Host;
/// (Enter 提交 / Esc 忙时打断或闲时退出 / Shift+Tab 权限循环 / Ctrl+C 退出)。
///
public static class Program {
- /// 入口 — --selftest 非交互冒烟;无参数进入交互 REPL;未知参数报错诱导。
+ /// 入口 — --selftest 非交互冒烟;无参数进入交互 REPL;未知参数报错诱导。
+ /// 顶层 catch ConfigurationException — 配置错误友好呈现(两级 profile 覆盖链 + auth key 状态 + 修复指引,
+ /// 修复:配置错误直接闪退刷未处理异常堆栈),退出码 2。
public static async Task Main(string[] args) {
- if (args is ["--selftest"])
- return await RunSelftestAsync().ConfigureAwait(false);
- if (args.Length > 0) {
- Console.Error.WriteLine($"未知参数: {string.Join(' ', args)}。用法: NativeTui.Host [--selftest](无参数进入交互模式,需真实终端)");
- return 1;
- }
+ try {
+ if (args is ["--selftest"])
+ return await RunSelftestAsync().ConfigureAwait(false);
+ if (args.Length > 0) {
+ Console.Error.WriteLine($"未知参数: {string.Join(' ', args)}。用法: NativeTui.Host [--selftest](无参数进入交互模式,需真实终端)");
+ return 1;
+ }
- return await RunInteractiveAsync().ConfigureAwait(false);
+ return await RunInteractiveAsync().ConfigureAwait(false);
+ } catch (ConfigurationException ex) {
+ var vendor = StartupErrorPrinter.ExtractVendor(ex.Message) ?? "未知";
+ Console.Error.WriteLine(StartupErrorPrinter.Format(ex, StartupErrorPrinter.ProbeLive(vendor)));
+ return 2;
+ }
}
/// 交互模式 — TTY 检查 → UTF-8 → raw mode → 引擎组装(Console.Out 重定向 stderr 防引擎污染渲染)→ REPL 主循环。
@@ -41,6 +49,8 @@ private static async Task RunInteractiveAsync() {
EngineSession session;
try {
session = await EngineSession.CreateAsync().ConfigureAwait(false);
+ } catch (ConfigurationException) {
+ throw; // 冒泡 Main 统一友好呈现(raw mode 由 using 恢复后再打印)
} catch (Exception ex) {
Console.Error.WriteLine($"引擎启动失败: {ex.Message}。请检查 jcc 引擎配置(settings.json / providers)后重试。");
return 1;
diff --git a/native_tui/apps/NativeTui.Host/tui_assembly/StartupErrorPrinter.cs b/native_tui/apps/NativeTui.Host/tui_assembly/StartupErrorPrinter.cs
new file mode 100644
index 000000000..a6d2367ee
--- /dev/null
+++ b/native_tui/apps/NativeTui.Host/tui_assembly/StartupErrorPrinter.cs
@@ -0,0 +1,182 @@
+namespace NativeTui.Host;
+
+///
+/// 启动配置错误友好呈现(修复:配置错误直接闪退刷未处理异常堆栈)—
+/// ConfigurationException → 结构化指引文本:标题+原因 → 检查点(两级 profile 覆盖链 +
+/// auth key 状态 + 环境变量)→ 修复建议。错误诱导四要素(AGENTS.md 规矩9):
+/// 为什么失败 / 什么参数触发 / 当前状态在哪 / 接下来怎么做。
+/// Probe 纯函数(注入 JSON 文本,测试友好)+ ProbeLive 真实读取(Main 消费)。
+///
+internal static class StartupErrorPrinter
+{
+ /// 探测结果 — 两级 profile 指针 + auth key 长度 + 环境变量名/值。
+ /// 用户级 ~/.jcc/settings.json 的 current.profile(缺/解析失败 null)。
+ /// 项目级 {cwd}/.jcc/settings.json 的 current.profile(无文件 null — 覆盖链中断点)。
+ /// 探测的供应商标识。
+ /// auth.json 对应键长度(0 = 无键或空串)。
+ /// 供应商专属环境变量名(ProviderEnvVar 映射)。
+ /// 环境变量值(null = 未设置)。
+ internal sealed record ProbeResult(
+ string? UserProfile,
+ string? ProjectProfile,
+ string AuthVendor,
+ int AuthKeyLength,
+ string? EnvVarName,
+ string? EnvVarValue);
+
+ /// 错误标题(框线首行)。
+ private const string Title = "jcc 启动失败 — 配置错误";
+
+ /// auth.json 绝对路径(修复指引展示)。
+ private static string AuthPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".jcc", "auth.json");
+
+ /// 用户级 settings.json 绝对路径。
+ private static string UserSettingsPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".jcc", "settings.json");
+
+ /// 项目级 settings.json 路径(cwd 下)。
+ private static string ProjectSettingsPath => Path.Combine(
+ Environment.CurrentDirectory, ".jcc", "settings.json");
+
+ /// 组装友好文本 — 框线面板:标题 / 原因 / 检查点(覆盖链 + key 状态)/ 修复建议。
+ internal static string Format(ConfigurationException exception, ProbeResult probe)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine($"╭─ {Title}");
+ builder.AppendLine($"│ {exception.Message}");
+ builder.AppendLine("│");
+ builder.AppendLine("│ 检查点(profile 覆盖链: 项目级 > 用户级 > 环境变量):");
+ AppendProfileLine(builder, "项目级", probe.ProjectProfile, ProjectSettingsPath);
+ AppendProfileLine(builder, "用户级", probe.UserProfile, UserSettingsPath);
+ builder.AppendLine($"│ API Key: {AuthPath} 的 '{probe.AuthVendor}' 键 " +
+ (probe.AuthKeyLength > 0 ? $"已设置({probe.AuthKeyLength} 字符)" : "缺失或为空"));
+ if (probe.EnvVarName is { Length: > 0 } envName)
+ {
+ builder.AppendLine($"│ 环境变量 {envName}: " + (probe.EnvVarValue is { Length: > 0 } ? "已设置" : "未设置"));
+ }
+
+ builder.AppendLine("│");
+ builder.AppendLine("│ 修复建议:");
+ if (probe.AuthKeyLength == 0)
+ {
+ builder.AppendLine($"│ A. 补充 key: 在 {AuthPath} 添加 \"{probe.AuthVendor}\": \"sk-...\"");
+ if (probe.EnvVarName is { Length: > 0 } envName2)
+ {
+ builder.AppendLine($"│ B. 临时: 设置环境变量 {envName2}");
+ }
+ }
+
+ builder.AppendLine("│ C. 切换到已有 key 的供应商: 编辑 settings.json 的 current.profile");
+ builder.AppendLine("│ (指向 auth.json 中已有非空键的供应商,或用 /vendor 切换后重启)");
+ builder.Append("╰─");
+ return builder.ToString();
+ }
+
+ /// 检查点行 — profile 值 + 来源文件(null = 文件缺失或解析失败,覆盖链断点)。
+ private static void AppendProfileLine(StringBuilder builder, string label, string? profile, string path) =>
+ builder.AppendLine($"│ {label} current.profile = {profile ?? "(未设置/文件不存在)"} ← {path}");
+
+ /// 从异常消息提取供应商名("Provider 'xxx' …" 模式;无匹配返回 null → 探测降级)。
+ internal static string? ExtractVendor(string message) =>
+ System.Text.RegularExpressions.Regex.Match(message, "Provider '([^']+)'") is { Success: true } m ? m.Groups[1].Value : null;
+
+ /// 探测(纯函数 — 注入两级 settings JSON / auth JSON / env 值,测试友好;不碰文件系统)。
+ /// 目标供应商(auth 键与 env 变量名探测对象)。
+ /// 用户级 settings.json 文本(null = 缺失)。
+ /// 项目级 settings.json 文本(null = 缺失)。
+ /// auth.json 文本(null = 缺失)。
+ /// 供应商环境变量值(null = 未设置)。
+ internal static ProbeResult Probe(string vendor, string? userSettingsJson, string? projectSettingsJson, string? authJson, string? envValue) =>
+ new(
+ UserProfile: ReadProfile(userSettingsJson),
+ ProjectProfile: ReadProfile(projectSettingsJson),
+ AuthVendor: vendor,
+ AuthKeyLength: ReadAuthKeyLength(authJson, vendor),
+ EnvVarName: EnvVarNameFor(vendor),
+ EnvVarValue: envValue is { Length: > 0 } ? envValue : null);
+
+ /// 探测(真实读取 — Main 消费;文件缺失/解析失败静默降级 null/0,探测永不抛)。
+ internal static ProbeResult ProbeLive(string vendor) =>
+ Probe(
+ vendor,
+ TryReadFile(UserSettingsPath),
+ TryReadFile(ProjectSettingsPath),
+ TryReadFile(AuthPath),
+ ProbeLiveEnv(vendor));
+
+ /// 读环境变量(供应商专属名)。
+ private static string? ProbeLiveEnv(string vendor) =>
+ EnvVarNameFor(vendor) is { Length: > 0 } name ? Environment.GetEnvironmentVariable(name) : null;
+
+ /// JSON 文本 → current.profile(缺失/解析失败 null — RelaxedJson 宽容 + System.Text.Json 回退)。
+ private static string? ReadProfile(string? settingsJson)
+ {
+ if (settingsJson is null or { Length: 0 })
+ {
+ return null;
+ }
+
+ try
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(settingsJson);
+ return doc.RootElement.TryGetProperty("current", out var current)
+ && current.TryGetProperty("profile", out var profile)
+ && profile.ValueKind == System.Text.Json.JsonValueKind.String
+ ? profile.GetString()
+ : null;
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return null;
+ }
+ }
+
+ /// auth.json → 供应商标键长度(缺失/无键/非字符串 0)。
+ private static int ReadAuthKeyLength(string? authJson, string vendor)
+ {
+ if (authJson is null or { Length: 0 })
+ {
+ return 0;
+ }
+
+ try
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(authJson);
+ return doc.RootElement.TryGetProperty(vendor, out var value)
+ && value.ValueKind == System.Text.Json.JsonValueKind.String
+ ? value.GetString() is { Length: > 0 } key ? key.Length : 0
+ : 0;
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return 0;
+ }
+ }
+
+ /// 供应商 → 环境变量名(ProviderEnvVar [EnumValue] 唯一数据源;未知供应商 null)。
+ private static string? EnvVarNameFor(string vendor) => vendor.ToLowerInvariant() switch {
+ "openai" => ProviderEnvVar.OpenAiApiKey.ToValue(),
+ "azure" => ProviderEnvVar.AzureOpenAiApiKey.ToValue(),
+ "anthropic" => ProviderEnvVar.AnthropicApiKey.ToValue(),
+ "agnes" => ProviderEnvVar.AgnesApiKey.ToValue(),
+ "deepseek" => ProviderEnvVar.DeepSeekApiKey.ToValue(),
+ "sensenova" => ProviderEnvVar.SenseNovaApiKey.ToValue(),
+ "zhipu" => ProviderEnvVar.ZhipuApiKey.ToValue(),
+ "jev" => ProviderEnvVar.JevApiKey.ToValue(),
+ _ => null
+ };
+
+ /// 读文件(缺失/异常 null — 探测容错)。
+ private static string? TryReadFile(string path)
+ {
+ try
+ {
+ return File.Exists(path) ? File.ReadAllText(path) : null;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
index 667bf184d..0cb11c15b 100644
--- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
@@ -6,6 +6,7 @@
global using JoinCode.Abstractions.Configuration.AppData;
global using JoinCode.Abstractions.Configuration.Providers;
global using JoinCode.Abstractions.Configuration.Settings;
+global using JoinCode.Abstractions.Exceptions;
global using JoinCode.Abstractions.Interfaces;
global using JoinCode.Abstractions.LLM.Chat;
global using JoinCode.Abstractions.Models.Diff;
diff --git a/native_tui/tests/NativeTui.Host.Tests/StartupErrorPrinterTests.cs b/native_tui/tests/NativeTui.Host.Tests/StartupErrorPrinterTests.cs
new file mode 100644
index 000000000..75353bb8b
--- /dev/null
+++ b/native_tui/tests/NativeTui.Host.Tests/StartupErrorPrinterTests.cs
@@ -0,0 +1,104 @@
+namespace NativeTui.Host.Tests;
+
+///
+/// 启动配置错误友好呈现测试(修复:配置错误直接闪退刷未处理异常堆栈)—
+/// StartupErrorPrinter 把 ConfigurationException 翻译成结构化指引:
+/// 标题+原因 → 检查点(两级 profile 覆盖链 + auth key 状态)→ 修复建议(改指针/补 key/环境变量)。
+/// 错误诱导四要素(AGENTS.md 规矩9):为什么失败/什么参数触发/如何修复/下一步怎么做。
+///
+public sealed class StartupErrorPrinterTests
+{
+ /// 探测数据假体 — 固定 profile 覆盖链与 key 状态(项目级 openai 覆盖用户级,key 缺失)。
+ private static StartupErrorPrinter.ProbeResult FakeProbe(string? user = "zhipu", string? project = "openai", int authLength = 0) =>
+ new(UserProfile: user, ProjectProfile: project, AuthVendor: "openai", AuthKeyLength: authLength, EnvVarName: "OPENAI_API_KEY", EnvVarValue: null);
+
+ /// 标题+核心原因 — 含供应商名与错误消息。
+ [Fact]
+ public void Format_ContainsTitleAndReason()
+ {
+ var ex = new ConfigurationException("Provider 'openai' 配置无效: 缺少 API Key");
+
+ var text = StartupErrorPrinter.Format(ex, FakeProbe());
+
+ Assert.Contains("jcc 启动失败", text);
+ Assert.Contains("openai", text);
+ Assert.Contains("缺少 API Key", text);
+ }
+
+ /// 检查点 — 两级 profile 覆盖链可见(项目级覆盖用户级是闪退根因,必须暴露)。
+ [Fact]
+ public void Format_ShowsProfileOverrideChain()
+ {
+ var ex = new ConfigurationException("Provider 'openai' 配置无效: 缺少 API Key");
+
+ var text = StartupErrorPrinter.Format(ex, FakeProbe(user: "zhipu", project: "openai"));
+
+ Assert.Contains("zhipu", text);
+ Assert.Contains("openai", text);
+ Assert.Contains("项目级", text);
+ Assert.Contains("用户级", text);
+ }
+
+ /// 修复建议 — 三条路径(改指针/补 key/环境变量)+ auth.json 路径。
+ [Fact]
+ public void Format_ContainsFixGuidanceAndAuthPath()
+ {
+ var ex = new ConfigurationException("Provider 'openai' 配置无效: 缺少 API Key");
+
+ var text = StartupErrorPrinter.Format(ex, FakeProbe());
+
+ Assert.Contains("auth.json", text);
+ Assert.Contains("OPENAI_API_KEY", text);
+ Assert.Contains("current.profile", text);
+ }
+
+ /// key 有效时 — 不误导用户补 key(auth 有值场景提示指向检查)。
+ [Fact]
+ public void Format_WithValidAuthKey_PointsToPointer()
+ {
+ var ex = new ConfigurationException("Provider 'openai' 配置无效: 缺少 API Key");
+
+ var text = StartupErrorPrinter.Format(ex, FakeProbe(authLength: 51));
+
+ Assert.Contains("auth.json", text);
+ }
+
+ /// 探测兜底 — 文件缺失/解析失败不抛异常(probe 自身容错,探测失败返回 null 字段)。
+ [Fact]
+ public void Probe_MissingFiles_ReturnsNullFieldsWithoutThrow()
+ {
+ var probe = StartupErrorPrinter.Probe("openai", userSettingsJson: null, projectSettingsJson: null, authJson: null, envValue: null);
+
+ Assert.Null(probe.UserProfile);
+ Assert.Null(probe.ProjectProfile);
+ Assert.Equal(0, probe.AuthKeyLength);
+ }
+
+ /// 探测解析 — 用户级/项目级 settings.json + auth.json 键长度 + 环境变量名。
+ [Fact]
+ public void Probe_ParsesSettingsAndAuth()
+ {
+ var settings = """{"current":{"profile":"sensenova"}}""";
+ var project = """{"current":{"profile":"zhipu"}}""";
+ var auth = """{"sensenova":"sk-0123456789abcdef0123456789abcdef"}""";
+
+ var probe = StartupErrorPrinter.Probe("sensenova", settings, project, auth, envValue: null);
+
+ Assert.Equal("sensenova", probe.UserProfile);
+ Assert.Equal("zhipu", probe.ProjectProfile);
+ Assert.Equal(35, probe.AuthKeyLength);
+ Assert.Equal("SENSENOVA_API_KEY", probe.EnvVarName);
+ Assert.Null(probe.EnvVarValue);
+ }
+
+ /// 探测容错 — 非法 JSON 不抛(解析失败降级 null)。
+ [Fact]
+ public void Probe_InvalidJson_DegradesToNull()
+ {
+ var probe = StartupErrorPrinter.Probe("openai", userSettingsJson: "{broken", projectSettingsJson: "not-json", authJson: "[", envValue: null);
+
+ Assert.Null(probe.UserProfile);
+ Assert.Null(probe.ProjectProfile);
+ Assert.Equal(0, probe.AuthKeyLength);
+ }
+}
From 2c97cc24b6645a7dc20237b325be506d19e64311 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 06:00:21 +0800
Subject: [PATCH 13/17] =?UTF-8?q?docs:=20DSG041=20=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E4=BE=A7=E5=AD=90=E4=BB=A3=E7=90=86=E4=BA=8B=E4=BB=B6=E6=BA=90?=
=?UTF-8?q?=E6=94=B9=E9=80=A0=E8=AE=A1=E5=88=92=EF=BC=88=E4=B8=89=E7=BC=BA?=
=?UTF-8?q?=E5=8F=A3=E2=91=A2=20spec=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20?=
=?UTF-8?q?=E6=96=B9=E6=A1=88A=E4=B8=BB=E5=B9=B2=3D=E6=B2=BF=E6=97=A2?=
=?UTF-8?q?=E6=9C=89=20SubAgentEventChannel=20=E6=89=93=E9=80=9A=20QueryEn?=
=?UTF-8?q?gine=20=E4=B8=BB=E4=BB=A3=E7=90=86=E8=B7=AF=E5=BE=84=EF=BC=88?=
=?UTF-8?q?=E5=8F=91=E5=B0=84=E4=BE=A7=E9=9B=B6=E6=96=B0=E4=BB=A3=E7=A0=81?=
=?UTF-8?q?=E2=80=94=E2=80=94AgentStreamExecutionMiddleware=20=E7=AD=89?=
=?UTF-8?q?=E4=B8=89=E7=82=B9=E5=B7=B2=E5=9C=A8=20Emit=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?=E6=A0=B9=E5=9B=A0=E4=B8=89=E6=96=AD=E7=82=B9=3D=E4=BD=9C?=
=?UTF-8?q?=E7=94=A8=E5=9F=9F(AsyncLocal=20=E6=97=A0=20EnterScope)+IEventC?=
=?UTF-8?q?onsumer=20=E5=8D=8F=E8=AE=AE(=E6=97=A0=E5=AD=90=E4=BB=A3?=
=?UTF-8?q?=E7=90=86=E5=9B=9E=E8=B0=83)+=E5=BC=95=E6=93=8E=E4=BE=A7=20Brid?=
=?UTF-8?q?geEvent=20DTO(=E6=97=A0=E4=B8=89=E4=BA=8B=E4=BB=B6)=EF=BC=9B?=
=?UTF-8?q?=E5=9B=9B=E5=B1=82=E6=94=B9=E9=80=A0=E6=8C=89=20ADR=200143=20?=
=?UTF-8?q?=E5=85=88=E6=9C=AC=E4=BD=93=E5=90=8E=E6=8A=95=E5=BD=B1=E6=AC=A1?=
=?UTF-8?q?=E5=BA=8F=EF=BC=88L3=E5=8D=8F=E8=AE=AE=E2=86=92L1=E9=80=9A?=
=?UTF-8?q?=E9=81=93=E2=86=92L2=E4=BC=A0=E6=92=AD=E2=86=92L4=E6=8A=95?=
=?UTF-8?q?=E5=BD=B1=EF=BC=89=E4=BA=94=E6=89=B9=E6=AC=A1=E6=B8=90=E8=BF=9B?=
=?UTF-8?q?=E5=AE=9E=E6=96=BD=EF=BC=9B=E6=96=B9=E6=A1=88B=E8=BD=AE?=
=?UTF-8?q?=E8=AF=A2=E5=90=A6=E5=86=B3=EF=BC=88=E5=BB=B6=E8=BF=9F/?=
=?UTF-8?q?=E9=9D=9E=E5=A2=9E=E9=87=8F/=E5=8F=8C=E5=AE=9E=E7=8E=B0?=
=?UTF-8?q?=E4=B8=89=E8=BF=9D=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../DSG041-subagent-event-source-plan.md | 88 +++++++++++++++++++
1 file changed, 88 insertions(+)
create mode 100644 docs/proposed/design/DSG041-subagent-event-source-plan.md
diff --git a/docs/proposed/design/DSG041-subagent-event-source-plan.md b/docs/proposed/design/DSG041-subagent-event-source-plan.md
new file mode 100644
index 000000000..75a2310f7
--- /dev/null
+++ b/docs/proposed/design/DSG041-subagent-event-source-plan.md
@@ -0,0 +1,88 @@
+# DSG041 — 引擎侧子代理事件源改造计划(三缺口③)
+
+> 状态:proposed(2026-10-21)
+> 关联:TASK055 M7-7(子代理分组渲染已落地)、ADR 0143(协议层唯一数据源=IEventConsumer 事件模型)、ADR 0141(bridge 事件投影)
+> 前置调研:2026-10-21 引擎侧执行链全量探索(探索代理报告,全部结论已核到文件:行号)
+
+## 1. 问题
+
+native_tui(TUI 宿主)子代理分组渲染全链已落地(SubagentGroupCoordinator + BridgeEvent 三事件 + ReplBridgeDriver 路由),但**真实引擎链路无子代理事件源**——目前仅 Demo 的 SimulatedEngine 模拟发射。真实链路表现:TUI 跑真实引擎时子代理调用不产生任何分组显示。
+
+## 2. 调研结论(现状事实)
+
+**核心发现:子代理事件源在引擎侧已存在且已发射**——`AgentStreamExecutionMiddleware`(前台)/ `AgentForkMiddleware`(fork/teammate)/ `ForkSubAgentManagerActor`(后台完成)持续向 `SubAgentEventChannel` Emit `AgentStarted` / 带 AgentId 的活动事件 / `AgentFinished`(ChatStreamEvent 层)。ChatService 管道路径(GUI/E2E 已验证:`SubAgentEventStreamE2ETests`)事件正常流动。
+
+**native_tui 主代理路径丢事件的三个断点**:
+
+| # | 断点 | 位置 | 性质 |
+|---|------|------|------|
+| ① | **作用域断点(根因)** | 主代理路径 `AgentBase.ExecuteStreamAsync → QueryEngine.QueryAsync` 不经 ChatService 管道;`ToolExecutionHandler.cs:80` 的 `EnterScope()` 不在链上 | `SubAgentEventChannel.Current`(AsyncLocal)为 null → 发射侧 `channel?.Emit` 全部静默跳过 |
+| ② | **IEventConsumer 协议断点** | `IEventConsumer.cs`(8 方法)无子代理回调;`SessionController.ProcessEvent` 的 Switch 不传 onAgentStarted/onAgentFinished;`AgentStreamChunkAdapter` 不传播 chunk.AgentId | 事件模型(协议本体)缺子代理粒度 |
+| ③ | **桥协议断点** | 引擎侧 `Cli.Bridge.BridgeEvent`(modules/apps/engine/bridge/BridgeProtocol.cs)无 subagent 三事件;`BridgeEventConsumer`/`InProcessEventConsumer` 无映射 | bridge 形态(JSON 投影)发不出来 |
+
+**子代理执行链事实**:子代理是独立 `AgentBase` 实例(非工具内嵌套回合),内部走完整 QueryEngine LLM+工具循环;数据可得性全齐(SubagentId=chunk.AgentId、AgentType/Description=spawnOptions、ToolName/Summary=chunk 字段、TokenGain=Usage/ProgressTracker 差值、IsError=succeeded)。
+
+## 3. 方案 A(主干推荐)— 沿既有 SubAgentEventChannel 机制打通
+
+**原则:发射侧零新代码**(三个发射点已在 Emit),每层挂点均有先例可循。
+
+### 四层改造(自底向上)
+
+| 层 | 改造 | 落点 | 先例 |
+|----|------|------|------|
+| L1 通道接通 | `AgentBase.ExecuteStreamAsync` 持有 SubAgentEventChannel(挂 Options/字段),QueryAsync 循环内逐 chunk 排空通道转 AgentStreamChunk(新增 AgentStreamChunkType.AgentStarted/AgentFinished)随主流 yield;工具执行段进作用域 | `AgentBase.cs:340-469`(循环)+ `QueryEngine.cs`(`QueryMiddlewareContext` 挂通道 + `QueryCoreMiddleware` 工具段排空,或 AgentBase 层排空二选一——实施时按侵入度定) | `QueryLoopMiddleware.WaitForTaskWithDrainAsync`(50ms 轮询排空先例)+ `ToolExecutionHandler.cs:80` EnterScope |
+| L2 chunk 传播 | `AgentStreamChunkAdapter.ToChatStreamEvent` 传播 `chunk.AgentId` + 新 chunk 类型映射 → `ChatStreamEvent.AgentStarted/AgentFinished`(工厂已存在 ChatStreamEvent.cs:166/177) | `AgentStreamChunkAdapter.cs:12-51` | 工厂现成 |
+| L3 协议扩展 | `IEventConsumer` 新增 `OnSubagentStarted(subagentId, agentType, description)` / `OnSubagentToolUse(subagentId, toolName, summary, tokenGain)` / `OnSubagentFinished(subagentId, isError)`;`SessionController.ProcessEvent` Switch 传入三回调 | `IEventConsumer.cs` + `SessionController.cs:157-185` | **ADR 0143 不变式:先改事件模型(本体)再同步投影** |
+| L4 双消费者映射 | 引擎侧 `Cli.Bridge.BridgeEvent` 增 subagent 三 record(字段对齐 native_tui BridgeEvent.cs:83/89/92,JSON 形状进 BridgeJsonContractTests 锁定);`BridgeEventConsumer` + `InProcessEventConsumer` 实现 L3 三方法 | `BridgeProtocol.cs:34-127` + 两消费者 | 两消费者语义对齐先例 |
+
+### 消费端(native_tui,零改动验证面)
+
+`ReplBridgeDriver.cs:110-131` 已路由三事件到 `SubagentGroupCoordinator`——L1-L4 打通后真实链路自动出分组显示,TUI 侧**零代码改动**(验收面)。
+
+## 4. 方案 B(补充位)— IAgentService 轮询聚合
+
+`AgentServiceImpl` 已有 `AgentCompleted` 事件 + `GetAgentProgressAsync`/`GetAgentActivityHistoryAsync` 轮询面。仅在宿主层起轮询器投影终态。
+
+**定位:不作为主方案**——轮询延迟、拿不到精确 Started 时刻、tokenGain 非增量语义,且与方案 A 通道机制构成两套实现(违反唯一数据源规范)。fork 后台路径完成事件已在 `ForkSubAgentManagerActor.cs:467` 发射、通道接通即覆盖,无需 B。
+
+## 5. 实施批次(渐进式,每批编译+测试+commit)
+
+| 批次 | 内容 | 验证 |
+|------|------|------|
+| 批1 | L3 协议扩展:IEventConsumer 三方法(默认空实现——不破坏既有实现方)+ SessionController.ProcessEvent 三回调 | host.tests 新测试(fake consumer 断言回调) |
+| 批2 | L1 通道接通:AgentBase/QueryEngine 段排空 + EnterScope + AgentStreamChunk 新类型 | agents 域单测 + E2E 复制 SubAgentEventStreamE2ETests 先例 |
+| 批3 | L2 chunk 传播:AgentStreamChunkAdapter AgentId + 类型映射 | adapters 单测 |
+| 批4 | L4 引擎侧 DTO + BridgeEventConsumer 映射 | BridgeJsonContractTests 锁形状 |
+| 批5 | InProcessEventConsumer 映射 + native_tui 真实链路验收 | Host --selftest 扩子代理断言(真实 spawn,不调 LLM 可用 MockServer) |
+
+**顺序理由**:L3 先行(事件模型=协议本体,ADR 0143 演进次序铁律"先本体后投影");L1 最重(执行链侵入)单独成批;批5 端到端收口。
+
+## 6. 风险与决策点
+
+1. **L1 挂点二选一**(批2 实施时定):`QueryMiddlewareContext`+QueryCoreMiddleware 排空(对齐 QueryLoopMiddleware 先例,但 QueryEngine 是 cognition 域共享组件侵入面大)vs AgentBase 循环内排空(侵入小但 fork 后台路径需另接)。倾向后者先行 + fork 路径复用通道。
+2. **AsyncLocal 作用域传播**:EnterScope 须覆盖子代理内全部工具执行段(含嵌套 spawn);E2E 先例已验证前台/fork 两条路径。
+3. **tokenGain 增量语义**:ProgressTracker.RecordTokenUsage 原子累加 vs 差值发射——对齐 native_tui 消费语义(TokenDelta 逐次累加)取差值。
+4. **无消费方场景零影响**:通道无作用域静默丢弃的既有语义保留(CLI 纯文本等场景)。
+5. **IEventConsumer 默认实现**:三方法走接口默认实现(DIM),既有 8+ 实现方零改动;注意 DIM 须经接口类型调用才可见(项目已有先例坑)。
+
+## 7. 四列验收表
+
+| 基建实现 | 消费点 | 已实现 | 已验收 |
+|---------|--------|--------|--------|
+| L1 SubAgentEventChannel 接通 QueryEngine 主代理路径 | AgentStreamExecutionMiddleware/ForkSubAgentManagerActor 发射侧(零改动) | ⏳ 批2 | ⏳ E2E 复制先例 + agents 单测 |
+| L3 IEventConsumer 三方法 + SessionController 三回调 | BridgeEventConsumer / InProcessEventConsumer | ⏳ 批1 | ⏳ host.tests fake consumer |
+| L4 引擎侧 BridgeEvent 三 record | bridge_serve JSON 协议消费方 | ⏳ 批4 | ⏳ BridgeJsonContractTests |
+| InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ⏳ 批5 | ⏳ Host --selftest 扩断言(ADR 0080 真实运行) |
+| TUI 真实链路分组显示 | SubagentGroupCoordinator → 分组渲染(既有零改动) | ⏳ 批5 | ⏳ 用户真实终端(agents 场景真实引擎 spawn) |
+
+## 8. 后续
+
+- 批1(L3 协议扩展)可立即开工(无前置依赖)
+- L1 挂点决策(风险1)在批2 实施前向用户确认
+- 本 DSG 落地后 TASK055 三缺口③关闭
+
+
+
+
+
+
From a8be9355a81b676a7484b2e9e6070c06d8527206 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 06:20:18 +0800
Subject: [PATCH 14/17] =?UTF-8?q?fix:=20=E6=A8=A1=E5=9E=8B=E5=88=97?=
=?UTF-8?q?=E8=A1=A8=E6=8C=81=E4=B9=85=E5=8C=96=E6=94=B9=E8=90=BD=20provid?=
=?UTF-8?q?ers=5Fllm/{vendor}/models.json=EF=BC=88=E6=B6=88=E9=99=A4=20set?=
=?UTF-8?q?tings.json=20=E8=86=A8=E8=83=80=EF=BC=89=20|=20=E5=86=B3?=
=?UTF-8?q?=E7=AD=96:=20=E6=A0=B9=E5=9B=A0=E6=98=AF=20SettingsJsonModelWri?=
=?UTF-8?q?ter=20=E8=B5=B0=E6=8B=86=E5=88=86=E5=89=8D=E6=97=A7=E8=B7=AF?=
=?UTF-8?q?=E5=BE=84=E6=8A=8A=E5=90=88=E5=B9=B6=E6=A8=A1=E5=9E=8B=E6=95=B4?=
=?UTF-8?q?=E4=BD=93=E5=86=99=E5=9B=9E=20settings.json=20=E5=86=85?=
=?UTF-8?q?=E8=81=94=20vendor=20=E5=9D=97=EF=BC=8829KB+=2055=20=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=EF=BC=89=EF=BC=8C=E4=B8=8E=20TASK054=20P6=20=E6=8B=86?=
=?UTF-8?q?=E5=88=86=E5=B8=83=E5=B1=80=EF=BC=88VendorModelsFile=20DTO=20?=
=?UTF-8?q?=E5=B7=B2=E5=AD=98=E5=9C=A8=EF=BC=89=E5=88=86=E8=A3=82=E6=88=90?=
=?UTF-8?q?=E4=B8=A4=E5=A5=97=E5=86=99=E5=85=A5=EF=BC=9B=E6=94=B9=E9=80=A0?=
=?UTF-8?q?=20WriteAsync=20=E9=80=90=E4=BE=9B=E5=BA=94=E5=95=86=E5=86=99?=
=?UTF-8?q?=20models.json=20=E5=AD=90=E6=96=87=E4=BB=B6=20+=20baseDir=20?=
=?UTF-8?q?=E6=9E=84=E9=80=A0=E6=B3=A8=E5=85=A5=EF=BC=88=E6=B5=8B=E8=AF=95?=
=?UTF-8?q?=E4=B8=8D=E7=A2=B0=E7=9C=9F=E7=9B=98=EF=BC=89=EF=BC=8Csettings.?=
=?UTF-8?q?json=20=E9=9B=B6=E8=A7=A6=E7=A2=B0=EF=BC=88current=20=E6=8C=87?=
=?UTF-8?q?=E9=92=88=E4=B8=8E=E6=A8=A1=E5=9E=8B=E8=A7=A3=E8=80=A6=EF=BC=89?=
=?UTF-8?q?=EF=BC=8CModelFetchStartupService=20=E7=AD=BE=E5=90=8D=E8=B7=9F?=
=?UTF-8?q?=E9=9A=8F=20+=20=E9=87=8D=E8=AF=95=E6=97=A5=E5=BF=97=E6=8E=AA?=
=?UTF-8?q?=E8=BE=9E=E6=9B=B4=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../SettingsJsonModelWriterTests.cs | 97 +++++++++++++++++++
.../model_fetch/ModelFetchStartupService.cs | 14 ++-
.../model_fetch/SettingsJsonModelWriter.cs | 62 ++++++------
3 files changed, 132 insertions(+), 41 deletions(-)
create mode 100644 modules/guard/guard.config.tests/configuration/model_fetch/SettingsJsonModelWriterTests.cs
diff --git a/modules/guard/guard.config.tests/configuration/model_fetch/SettingsJsonModelWriterTests.cs b/modules/guard/guard.config.tests/configuration/model_fetch/SettingsJsonModelWriterTests.cs
new file mode 100644
index 000000000..ab4bb06cd
--- /dev/null
+++ b/modules/guard/guard.config.tests/configuration/model_fetch/SettingsJsonModelWriterTests.cs
@@ -0,0 +1,97 @@
+namespace Core.Configuration.ModelFetch.Tests;
+
+///
+/// SettingsJsonModelWriter 模型持久化路径测试(修复:模型列表写回 settings.json 内联 vendor 块 —
+/// 违反用户布局"settings.json 只留当前配置",巨量模型列表膨胀到 29KB+)—
+/// 模型持久化必须落 providers_llm/{vendor}/models.json(VendorModelsFile DTO,TASK054 拆分布局),
+/// settings.json 零触碰(模型更新不涉及 current 指针)。
+///
+public class SettingsJsonModelWriterTests {
+ /// 写回落点 — 每个供应商写 providers_llm/{name}/models.json,不写 settings.json。
+ [Fact]
+ public async Task WriteAsync_WritesVendorModelsSubFiles_NotSettingsJson() {
+ var captured = new List();
+ var (writer, _) = CreateWriter(captured);
+ var updates = new Dictionary> {
+ ["openai"] = [new ModelItemConfig { Id = "gpt-5.6-sol" }],
+ ["zhipu"] = [new ModelItemConfig { Id = "glm-5.2" }],
+ };
+
+ await writer.WriteAsync(updates);
+
+ captured.Should().Equal(
+ $"{TestBaseDir}/providers_llm/openai/models.json",
+ $"{TestBaseDir}/providers_llm/zhipu/models.json");
+ captured.Should().NotContain(path => path.EndsWith("settings.json", StringComparison.Ordinal));
+ }
+
+ /// 写入内容 — VendorModelsFile 包裹({"models":[...]})而非裸数组。
+ [Fact]
+ public async Task WriteAsync_ContentIsVendorModelsFileEnvelope() {
+ var captured = new List<(string Path, string Content)>();
+ var (writer, _) = CreateWriter(paths: null, contents: captured);
+ var updates = new Dictionary> {
+ ["zhipu"] = [new ModelItemConfig { Id = "glm-5.2" }],
+ };
+
+ await writer.WriteAsync(updates);
+
+ captured.Should().HaveCount(1);
+ captured[0].Content.Should().Contain("\"models\"");
+ captured[0].Content.Should().Contain("glm-5.2");
+ }
+
+ /// 空更新 — 零写入。
+ [Fact]
+ public async Task WriteAsync_NoUpdates_NoWrite() {
+ var captured = new List();
+ var (writer, _) = CreateWriter(captured);
+
+ await writer.WriteAsync(new Dictionary>());
+
+ captured.Should().BeEmpty();
+ }
+
+ private const string TestBaseDir = "/test-jcc";
+
+ /// 构造写回器 + 路径捕获 mock fs(CreateStream 走 MemoryStream 包装,不落盘)。
+ private static (SettingsJsonModelWriter Writer, Mock Fs) CreateWriter(
+ List? paths = null, List<(string, string)>? contents = null) {
+ var mock = new Mock();
+ mock.Setup(x => x.CreateDirectory(It.IsAny()));
+ mock.Setup(x => x.DirectoryExists(It.IsAny())).Returns(true);
+ mock.Setup(x => x.CreateStream(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((path, _, _, _) => {
+ paths?.Add(path.Replace('\\', '/'));
+ if (contents is not null) {
+ return new CapturingStream(new MemoryStream(), content => contents.Add((path, content)));
+ }
+ return new MemoryStream();
+ });
+ var writer = new SettingsJsonModelWriter(mock.Object, baseDir: TestBaseDir);
+ return (writer, mock);
+ }
+
+ /// 捕获写入内容的流包装(Dispose 时快照 — 双重 Dispose 幂等只捕获一次)。
+ private sealed class CapturingStream(MemoryStream inner, Action onClosed) : Stream {
+ private bool _captured;
+ public override bool CanRead => inner.CanRead;
+ public override bool CanSeek => inner.CanSeek;
+ public override bool CanWrite => inner.CanWrite;
+ public override long Length => inner.Length;
+ public override long Position { get => inner.Position; set => inner.Position = value; }
+ public override void Flush() => inner.Flush();
+ public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
+ public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
+ public override void SetLength(long value) => inner.SetLength(value);
+ public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count);
+ protected override void Dispose(bool disposing) {
+ if (disposing && !_captured) {
+ _captured = true;
+ onClosed(System.Text.Encoding.UTF8.GetString(inner.ToArray()));
+ inner.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/modules/guard/guard/configuration/configuration2/core/model_fetch/ModelFetchStartupService.cs b/modules/guard/guard/configuration/configuration2/core/model_fetch/ModelFetchStartupService.cs
index 3bd35ab96..c9fbeb625 100644
--- a/modules/guard/guard/configuration/configuration2/core/model_fetch/ModelFetchStartupService.cs
+++ b/modules/guard/guard/configuration/configuration2/core/model_fetch/ModelFetchStartupService.cs
@@ -33,7 +33,7 @@ public ModelFetchStartupService(
/// 1. 检查 autoFetchModels 开关
/// 2. 并行拉取各供应商模型 id 列表
/// 3. 智能合并:以远程 id 为准增删,保留本地元数据
- /// 4. 写回 settings.json(触发文件监控自动刷新内存和GUI)
+ /// 4. 写回 providers_llm/{vendor}/models.json(触发文件监控自动刷新内存和GUI)
///
public async Task ExecuteAsync(
SettingsJson settings,
@@ -58,7 +58,7 @@ public async Task ExecuteAsync(
}
if (updates.Count > 0)
- await WriteWithRetryAsync(settings, updates, cancellationToken).ConfigureAwait(false);
+ await WriteWithRetryAsync(updates, cancellationToken).ConfigureAwait(false);
_logger?.LogInformation("[ModelFetchStartupService] 模型列表拉取完成,更新了 {Count} 个供应商", updates.Count);
} catch (Exception ex) {
@@ -67,26 +67,24 @@ public async Task ExecuteAsync(
}
///
- /// 写回 settings.json — 对文件锁异常重试最多3次(间隔递增),记录 HResult 辅助诊断锁源
+ /// 写回 providers_llm/{vendor}/models.json — 对文件锁异常重试最多3次(间隔递增),记录 HResult 辅助诊断锁源
/// 常见 HResult: -2147024891=ACCESS_DENIED(5, 权限/Defender扫描), -2147024864=SHARING_VIOLATION(32, 被独占)
///
private async Task WriteWithRetryAsync(
- SettingsJson settings,
IReadOnlyDictionary> updates,
CancellationToken cancellationToken) {
var delays = new[] { 500, 1000, 1500 };
for (var attempt = 0; ; attempt++) {
try {
- await _writer.WriteAsync(settings, updates, cancellationToken).ConfigureAwait(false);
+ await _writer.WriteAsync(updates, cancellationToken).ConfigureAwait(false);
await RefreshInMemoryConfigAsync(cancellationToken).ConfigureAwait(false);
return;
} catch (UnauthorizedAccessException ex) {
if (attempt >= delays.Length) {
- var path = SettingsLoader.GetUserSettingsPath();
- _logger?.LogWarning(ex, "[ModelFetchStartupService] settings.json 写入仍被拒(HResult={HResult}),跳过本次更新 | 路径: {Path} | 不影响启动", ex.HResult, path);
+ _logger?.LogWarning(ex, "[ModelFetchStartupService] providers_llm models.json 写入仍被拒(HResult={HResult}),跳过本次更新 | 不影响启动", ex.HResult);
return;
}
- _logger?.LogWarning(ex, "[ModelFetchStartupService] settings.json 写入被拒(HResult={HResult}),{Delay}ms 后重试(第{Attempt}次)", ex.HResult, delays[attempt], attempt + 1);
+ _logger?.LogWarning(ex, "[ModelFetchStartupService] providers_llm models.json 写入被拒(HResult={HResult}),{Delay}ms 后重试(第{Attempt}次)", ex.HResult, delays[attempt], attempt + 1);
await Task.Delay(delays[attempt], cancellationToken).ConfigureAwait(false);
}
}
diff --git a/modules/guard/guard/configuration/configuration2/core/model_fetch/SettingsJsonModelWriter.cs b/modules/guard/guard/configuration/configuration2/core/model_fetch/SettingsJsonModelWriter.cs
index 5f2e36453..8585d1f59 100644
--- a/modules/guard/guard/configuration/configuration2/core/model_fetch/SettingsJsonModelWriter.cs
+++ b/modules/guard/guard/configuration/configuration2/core/model_fetch/SettingsJsonModelWriter.cs
@@ -1,63 +1,59 @@
namespace Core.Configuration.ModelFetch;
///
-/// settings.json 模型列表写回器 — 直接操作 SettingsJson 对象,用 ConfigIndentedJsonContext 序列化保证类型一致性
-/// 写入用 FileShare.ReadWrite 避免与 jcc 内部并发的文件读取冲突
-/// 写回前调用 IConfigChangeNotifier.MarkInternalWrite 防抖,避免自身写入触发循环刷新
+/// 供应商模型列表写回器 — 持久化到 providers_llm/{vendor}/models.json(VendorModelsFile 包裹,
+/// TASK054 P6 拆分布局),settings.json 零触碰(模型更新不涉及 current 指针 — 用户布局约定
+/// "settings.json 只留当前配置",模型列表内联会膨胀到 29KB+)。
+/// 写入用 FileShare.ReadWrite 避免与 jcc 内部并发的文件读取冲突;
+/// 写前调用 IConfigChangeNotifier.MarkInternalWrite 防抖,避免自身写入触发循环刷新。
///
public sealed class SettingsJsonModelWriter {
private readonly IFileSystem _fs;
private readonly IConfigChangeNotifier? _changeNotifier;
private readonly ILogger? _logger;
+ private readonly string _baseDir;
///
- /// 构造函数 — 注入文件系统、可选的配置变更通知器和日志器
+ /// 构造函数 — 注入文件系统、可选的配置变更通知器、日志器和根目录
///
- public SettingsJsonModelWriter(IFileSystem fs, IConfigChangeNotifier? changeNotifier = null, ILogger? logger = null) {
+ /// 文件系统抽象
+ /// 配置变更通知器(可选 — MarkInternalWrite 防抖)
+ /// 日志器(可选)
+ /// jcc 配置根目录(默认 AppDataConstants.Paths.JccDirectory — 测试注入临时目录)
+ public SettingsJsonModelWriter(
+ IFileSystem fs,
+ IConfigChangeNotifier? changeNotifier = null,
+ ILogger? logger = null,
+ string? baseDir = null) {
_fs = fs;
_changeNotifier = changeNotifier;
_logger = logger;
+ _baseDir = baseDir ?? AppDataConstants.Paths.JccDirectory;
}
///
- /// 将合并后的模型列表写回 settings.json — 构建新 SettingsJson 对象,保留所有原有字段
+ /// 将合并后的模型列表写回 providers_llm/{vendor}/models.json — 每供应商一个子文件,
+ /// settings.json 不动(current 指针与模型列表解耦)
///
- /// 当前 SettingsJson 对象(从文件加载)
/// 供应商名 → 合并后的模型列表
/// 取消令牌
public async Task WriteAsync(
- SettingsJson settings,
IReadOnlyDictionary> updates,
CancellationToken cancellationToken = default) {
if (updates.Count == 0) return;
- if (settings.Vendor is null) return;
- var newVendor = new Dictionary(settings.Vendor, StringComparer.OrdinalIgnoreCase);
+ var vendorsDir = Path.Combine(_baseDir, "providers_llm");
+ DirectoryHelper.EnsureDirectoryExists(_fs, vendorsDir);
+
foreach (var (profile, models) in updates) {
- if (!newVendor.TryGetValue(profile, out var oldProfile)) continue;
- newVendor[profile] = new ProfileSettings {
- Provider = oldProfile.Provider,
- Protocol = oldProfile.Protocol,
- Model = oldProfile.Model,
- Endpoint = oldProfile.Endpoint,
- ApiKeyEnvVar = oldProfile.ApiKeyEnvVar,
- Models = models,
- ModelsEndpoint = oldProfile.ModelsEndpoint,
- };
+ var modelsPath = Path.Combine(vendorsDir, profile, "models.json");
+ DirectoryHelper.EnsureDirectoryExists(_fs, Path.GetDirectoryName(modelsPath));
+ var modelsJson = RelaxedJsonSerializer.SerializeIndented(new VendorModelsFile { Models = models }, ConfigIndentedJsonContext.Default);
+ _changeNotifier?.MarkInternalWrite(modelsPath);
+ await WriteWithSharedAccessAsync(modelsPath, modelsJson, cancellationToken).ConfigureAwait(false);
}
- var newSettings = new SettingsJson {
- Vendor = newVendor,
- Current = settings.Current,
- AutoFetchModels = settings.AutoFetchModels,
- };
-
- var json = RelaxedJsonSerializer.SerializeIndented(newSettings, ConfigIndentedJsonContext.Default);
- var settingsPath = SettingsLoader.GetUserSettingsPath();
-
- _changeNotifier?.MarkInternalWrite(settingsPath);
- await WriteWithSharedAccessAsync(settingsPath, json, cancellationToken).ConfigureAwait(false);
- _logger?.LogInformation("[SettingsJsonModelWriter] 已更新 {Count} 个供应商的模型列表", updates.Count);
+ _logger?.LogInformation("[SettingsJsonModelWriter] 已更新 {Count} 个供应商的模型列表(providers_llm 子文件)", updates.Count);
}
///
@@ -69,4 +65,4 @@ private async Task WriteWithSharedAccessAsync(string path, string content, Cance
await writer.WriteAsync(content.AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
-}
\ No newline at end of file
+}
From b583e28ea329028e0fa203bb9238832156c78413 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 06:22:54 +0800
Subject: [PATCH 15/17] =?UTF-8?q?feat:=20DSG041=20=E6=89=B91=20=E5=AD=90?=
=?UTF-8?q?=E4=BB=A3=E7=90=86=E4=BA=8B=E4=BB=B6=E5=8D=8F=E8=AE=AE=E6=89=A9?=
=?UTF-8?q?=E5=B1=95=EF=BC=88IEventConsumer=20=E4=B8=89=E5=9B=9E=E8=B0=83?=
=?UTF-8?q?=20+=20SessionController=20=E5=89=8D=E7=BD=AE=E5=88=86=E6=B5=81?=
=?UTF-8?q?=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E5=8D=8F=E8=AE=AE?=
=?UTF-8?q?=E6=9C=AC=E4=BD=93=E5=85=88=E8=A1=8C=EF=BC=88ADR=200143=20?=
=?UTF-8?q?=E5=85=88=E6=9C=AC=E4=BD=93=E5=90=8E=E6=8A=95=E5=BD=B1=E9=93=81?=
=?UTF-8?q?=E5=BE=8B=E2=80=94=E2=80=94L3=20=E6=89=B9=E6=AC=A1=EF=BC=89?=
=?UTF-8?q?=EF=BC=8CIEventConsumer=20=E5=A2=9E=20OnSubagentStarted/OnSubag?=
=?UTF-8?q?entToolUse/OnSubagentFinished=20=E4=B8=89=E6=96=B9=E6=B3=95?=
=?UTF-8?q?=E8=B5=B0=20DIM=20=E9=BB=98=E8=AE=A4=E7=A9=BA=E5=AE=9E=E7=8E=B0?=
=?UTF-8?q?=EF=BC=88=E6=97=A2=E6=9C=89=208+=20=E5=AE=9E=E7=8E=B0=E6=96=B9?=
=?UTF-8?q?=E9=9B=B6=E6=94=B9=E5=8A=A8=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0=E9=A1=BB=E7=BB=8F=E6=8E=A5=E5=8F=A3=E7=B1=BB=E5=9E=8B?=
=?UTF-8?q?=E8=B0=83=E7=94=A8=E7=9A=84=E6=97=A2=E6=9C=89=E5=9D=91=E5=B7=B2?=
=?UTF-8?q?=E8=A7=84=E9=81=BF=EF=BC=89=EF=BC=9BProcessEvent=20=E5=AF=B9=20?=
=?UTF-8?q?IsSubAgentActivity=EF=BC=88AgentId=20=E9=9D=9E=20null=EF=BC=89?=
=?UTF-8?q?=E5=89=8D=E7=BD=AE=E5=88=86=E6=B5=81=20RouteSubagentEvent?=
=?UTF-8?q?=E2=80=94=E2=80=94AgentStarted=E2=86=92=E8=BA=AB=E4=BB=BD?=
=?UTF-8?q?=E4=B8=89=E5=85=83=E7=BB=84=E3=80=81AgentFinished=E2=86=92?=
=?UTF-8?q?=E6=88=90=E8=B4=A5=EF=BC=88AgentSuccess=20=E6=88=90=E5=8A=9F?=
=?UTF-8?q?=E8=AF=AD=E4=B9=89=E5=8F=96=E5=8F=8D=E4=B8=BA=20isError?=
=?UTF-8?q?=EF=BC=89=E3=80=81=E5=B8=A6=20AgentId=20=E5=B7=A5=E5=85=B7?=
=?UTF-8?q?=E4=BA=8B=E4=BB=B6=E2=86=92OnSubagentToolUse=EF=BC=88tokenGain?=
=?UTF-8?q?=20=E6=81=92=200=20=E5=85=9C=E5=BA=95=EF=BC=8C=E6=89=B92=20?=
=?UTF-8?q?=E9=80=9A=E9=81=93=E6=8E=A5=E9=80=9A=E5=90=8E=E5=A1=AB=E7=9C=9F?=
=?UTF-8?q?=E5=AE=9E=E5=80=BC=EF=BC=89=EF=BC=9B=E6=99=AE=E9=80=9A=E4=BA=8B?=
=?UTF-8?q?=E4=BB=B6=E9=9B=B6=E5=9B=9E=E5=BD=92=EF=BC=88AgentId=20null=20?=
=?UTF-8?q?=E8=B5=B0=E6=97=A2=E6=9C=89=20Switch=20=E8=B7=AF=E5=BE=84?=
=?UTF-8?q?=EF=BC=89=EF=BC=9B5=20=E6=96=B0=E6=B5=8B=E8=AF=95=20+=20host.te?=
=?UTF-8?q?sts=201371=20+=20abs.tests=20694=20=E5=85=A8=E7=BB=BF=20+=20eng?=
=?UTF-8?q?ine/cli/Host=20=E4=B8=89=E9=93=BE=E7=BC=96=E8=AF=91=E7=BB=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../presentation/IEventConsumer.cs | 25 +++
.../apps/engine/adapters/SessionController.cs | 40 +++-
.../SessionControllerSubagentEventTests.cs | 176 ++++++++++++++++++
3 files changed, 240 insertions(+), 1 deletion(-)
create mode 100644 test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs
diff --git a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs
index 6dced7f82..62583a75e 100644
--- a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs
+++ b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs
@@ -45,6 +45,31 @@ public interface IEventConsumer {
/// 流式响应完成
///
void OnDone(TokenUsage? usage, string? modelId);
+
+ ///
+ /// 子代理启动(DSG041 批1 — 协议本体先扩,投影层后跟;默认空实现 — 既有实现方零改动,
+ /// 默认实现须经接口类型调用才可见)
+ ///
+ /// 子代理标识(事件路由键,分组渲染 AddAgent 键)
+ /// 代理类型(组头同型拼接 + 八色取色键)
+ /// 任务描述(状态行括号段)
+ void OnSubagentStarted(string subagentId, string agentType, string description) { }
+
+ ///
+ /// 子代理工具活动(DSG041 批1 — Started 与 Finished 间的过程事件;默认空实现)
+ ///
+ /// 子代理标识
+ /// 工具名
+ /// 活动摘要(参数/结果/进度文本)
+ /// 令牌增量(批2 通道接通后填充真实值;批1 恒 0)
+ void OnSubagentToolUse(string subagentId, string toolName, string summary, int tokenGain) { }
+
+ ///
+ /// 子代理完成(DSG041 批1 — 终态定格;默认空实现)
+ ///
+ /// 子代理标识
+ /// 是否失败
+ void OnSubagentFinished(string subagentId, bool isError) { }
}
///
diff --git a/modules/apps/engine/adapters/SessionController.cs b/modules/apps/engine/adapters/SessionController.cs
index 05ce251f4..d5160b824 100644
--- a/modules/apps/engine/adapters/SessionController.cs
+++ b/modules/apps/engine/adapters/SessionController.cs
@@ -150,10 +150,15 @@ public async Task StreamResponseAsync(string input, Cancellat
}
///
- /// 处理单个 ChatStreamEvent — 分发到 IEventConsumer,返回 Done 事件的 modelId(否则 null)
+ /// 处理单个 ChatStreamEvent — 子代理活动事件(AgentId 非 null)前置分流到子代理回调(DSG041 批1),
+ /// 其余分发到 IEventConsumer 既有回调,返回 Done 事件的 modelId(否则 null)
///
private string? ProcessEvent(ChatStreamEvent evt, StringBuilder fullResponse, StringBuilder thinkingContent) {
string? modelId = null;
+ if (RouteSubagentEvent(evt)) {
+ return null;
+ }
+
evt.Switch(
onText: content => {
if (content.Length > 0) fullResponse.Append(content);
@@ -186,6 +191,39 @@ public async Task StreamResponseAsync(string input, Cancellat
return modelId;
}
+ ///
+ /// 子代理活动事件前置分流(DSG041 批1 — AgentId 非 null 即子代理事件,路由到三子代理回调):
+ /// AgentStarted → OnSubagentStarted(身份三元组)、AgentFinished → OnSubagentFinished(成败)、
+ /// 带 AgentId 的工具事件 → OnSubagentToolUse(工具名 + 摘要;tokenGain 批2 通道接通后填充)。
+ /// 返回 true = 已消费(主 Switch 跳过);false = 主对话事件走既有路径。
+ ///
+ private bool RouteSubagentEvent(ChatStreamEvent evt) {
+ if (evt.AgentId is not { } agentId) {
+ return false;
+ }
+
+ switch (evt.Type) {
+ case ChatStreamEventType.AgentStarted:
+ _consumer.OnSubagentStarted(agentId, evt.AgentName ?? string.Empty, evt.AgentDescription ?? string.Empty);
+ return true;
+ case ChatStreamEventType.AgentFinished:
+ _consumer.OnSubagentFinished(agentId, isError: evt.AgentSuccess is not true);
+ return true;
+ case ChatStreamEventType.ToolCallStart:
+ _consumer.OnSubagentToolUse(agentId, evt.ToolName ?? string.Empty, evt.ToolArguments ?? string.Empty, tokenGain: 0);
+ return true;
+ case ChatStreamEventType.ToolCallEnd:
+ _consumer.OnSubagentToolUse(agentId, evt.ToolName ?? string.Empty, evt.ToolResultText ?? string.Empty, tokenGain: 0);
+ return true;
+ case ChatStreamEventType.ToolProgress:
+ _consumer.OnSubagentToolUse(agentId, evt.ToolName ?? string.Empty, evt.ProgressMessage ?? string.Empty, tokenGain: 0);
+ return true;
+ default:
+ // 子代理的其他事件类型(Content/Thinking 等)暂不投影 — 消费面仅三回调
+ return false;
+ }
+ }
+
///
/// 主代理预处理结果 — 包含预处理结果和注入文本
///
diff --git a/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs b/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs
new file mode 100644
index 000000000..8123f8a6e
--- /dev/null
+++ b/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs
@@ -0,0 +1,176 @@
+namespace JoinCode.Host.Tests.Adapters;
+
+///
+/// SessionController 子代理事件路由测试(DSG041 批1 — L3 协议扩展):
+/// IEventConsumer 新增三子代理回调(OnSubagentStarted/OnSubagentToolUse/OnSubagentFinished),
+/// ProcessEvent 对 IsSubAgentActivity 事件(AgentId 非 null)前置分流路由到子代理回调——
+/// 普通工具事件零回归(AgentId null 走既有 onToolStart/onToolEnd 路径)。
+/// 事件源侧(AgentStreamExecutionMiddleware 等已 Emit)与通道接通(批2)无关——本批只测
+/// "事件到达 ProcessEvent 后的正确分流"(经 ChatService 路径喂入,对齐既有 mock 先例)。
+///
+public sealed class SessionControllerSubagentEventTests
+{
+ /// 构造无主代理 SessionController(走 ChatService.StreamWithEventsAsync 路径)。
+ private static SessionController CreateController(IReadOnlyList events, RecordingSubagentConsumer consumer) {
+ var services = new ServiceCollection();
+ var sp = services.BuildServiceProvider();
+ var chatService = CreateMockChatService(events);
+ return new SessionController(chatService, consumer, new TurnDiffService(), "test-session", sp);
+ }
+
+ /// AgentStarted 事件 — 路由 OnSubagentStarted(id/名称/描述全字段)。
+ [Fact]
+ public async Task AgentStartedEvent_RoutesToOnSubagentStarted()
+ {
+ var consumer = new RecordingSubagentConsumer();
+ var controller = CreateController(
+ [
+ ChatStreamEvent.AgentStarted("agent-1", "explore", "搜索渲染链", "researcher"),
+ ChatStreamEvent.Done(),
+ ], consumer);
+
+ var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.True(result.Succeeded);
+ var started = Assert.Single(consumer.SubagentStarts);
+ Assert.Equal("agent-1", started.SubagentId);
+ Assert.Equal("explore", started.AgentType);
+ Assert.Equal("搜索渲染链", started.Description);
+ }
+
+ /// AgentFinished 事件 — 路由 OnSubagentFinished(id/成败)。
+ [Fact]
+ public async Task AgentFinishedEvent_RoutesToOnSubagentFinished()
+ {
+ var consumer = new RecordingSubagentConsumer();
+ var controller = CreateController(
+ [
+ ChatStreamEvent.AgentStarted("agent-1", "explore", "搜索"),
+ new ChatStreamEvent { Type = ChatStreamEventType.AgentFinished, AgentId = "agent-1", AgentSuccess = false },
+ ChatStreamEvent.Done(),
+ ], consumer);
+
+ var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.True(result.Succeeded, $"回合失败: {result.ErrorMessage ?? "(无)"}");
+ var finished = Assert.Single(consumer.SubagentFinishes);
+ Assert.Equal("agent-1", finished.SubagentId);
+ Assert.True(finished.IsError);
+ }
+
+ /// 带 AgentId 的工具事件 — 路由 OnSubagentToolUse 且不走普通 OnToolStart/OnToolEnd(分流)。
+ [Fact]
+ public async Task SubagentToolEvent_RoutesToOnSubagentToolUse_NotPlainToolCallbacks()
+ {
+ var consumer = new RecordingSubagentConsumer();
+ var controller = CreateController(
+ [
+ ChatStreamEvent.AgentStarted("agent-1", "explore", "搜索"),
+ new ChatStreamEvent { Type = ChatStreamEventType.ToolCallStart, AgentId = "agent-1", ToolName = "Read", ToolArguments = "file.cs" },
+ new ChatStreamEvent { Type = ChatStreamEventType.ToolCallEnd, AgentId = "agent-1", ToolName = "Read", ToolResultText = "内容…" },
+ ChatStreamEvent.Done(),
+ ], consumer);
+
+ var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(2, consumer.SubagentToolUses.Count);
+ Assert.All(consumer.SubagentToolUses, use => Assert.Equal("agent-1", use.SubagentId));
+ Assert.Equal("Read", consumer.SubagentToolUses[0].ToolName);
+ Assert.Equal("file.cs", consumer.SubagentToolUses[0].Summary);
+ Assert.Equal("Read", consumer.SubagentToolUses[1].ToolName);
+ Assert.Equal("内容…", consumer.SubagentToolUses[1].Summary);
+ Assert.Empty(consumer.ToolStarts);
+ Assert.Empty(consumer.ToolEnds);
+ }
+
+ /// 普通工具事件(AgentId null)— 走既有 OnToolStart/OnToolEnd 零回归。
+ [Fact]
+ public async Task PlainToolEvent_StillRoutesToPlainToolCallbacks()
+ {
+ var consumer = new RecordingSubagentConsumer();
+ var controller = CreateController(
+ [
+ ChatStreamEvent.ToolStart("Read", "call-1", "file.cs"),
+ ChatStreamEvent.ToolEnd("Read", "内容…", "call-1"),
+ ChatStreamEvent.Done(),
+ ], consumer);
+
+ var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.True(result.Succeeded);
+ Assert.Empty(consumer.SubagentStarts);
+ Assert.Empty(consumer.SubagentToolUses);
+ var start = Assert.Single(consumer.ToolStarts);
+ Assert.Equal("Read", start.toolName);
+ var end = Assert.Single(consumer.ToolEnds);
+ Assert.Equal("Read", end.toolName);
+ }
+
+ /// 混合流 — 子代理事件与主对话事件并存时各自正确分流(回合收尾不受影响)。
+ [Fact]
+ public async Task MixedStream_RoutesEachEventCorrectly()
+ {
+ var consumer = new RecordingSubagentConsumer();
+ var controller = CreateController(
+ [
+ ChatStreamEvent.Text("主回复前段"),
+ ChatStreamEvent.AgentStarted("agent-1", "explore", "搜索"),
+ new ChatStreamEvent { Type = ChatStreamEventType.ToolCallEnd, AgentId = "agent-1", ToolName = "Grep", ToolResultText = "命中" },
+ new ChatStreamEvent { Type = ChatStreamEventType.AgentFinished, AgentId = "agent-1", AgentSuccess = true },
+ ChatStreamEvent.Text("主回复后段"),
+ ChatStreamEvent.Done(null, "test-model"),
+ ], consumer);
+
+ var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal("主回复前段主回复后段", result.Response);
+ Assert.Single(consumer.SubagentStarts);
+ Assert.Single(consumer.SubagentToolUses);
+ var finished = Assert.Single(consumer.SubagentFinishes);
+ Assert.False(finished.IsError);
+ Assert.Equal(2, consumer.TextEvents.Count);
+ var done = Assert.Single(consumer.DoneEvents);
+ Assert.Equal("test-model", done.modelId);
+ }
+
+ private static IChatService CreateMockChatService(IReadOnlyList events) {
+ var mock = new Mock();
+ mock.Setup(s => s.StreamWithEventsAsync(It.IsAny(), It.IsAny()))
+ .Returns((string input, CancellationToken ct) => EmitEventsAsync(events, ct));
+ return mock.Object;
+ }
+
+ private static async IAsyncEnumerable EmitEventsAsync(
+ IReadOnlyList events,
+ [EnumeratorCancellation] CancellationToken ct) {
+ foreach (var evt in events) {
+ ct.ThrowIfCancellationRequested();
+ yield return evt;
+ }
+ }
+
+ /// 记录子代理 + 普通事件的 fake 消费者(对齐 RecordingEventConsumer 先例 + 三子代理回调)。
+ private sealed class RecordingSubagentConsumer : IEventConsumer {
+ public List TextEvents { get; } = [];
+ public List<(string toolName, string? toolCallId, string? arguments)> ToolStarts { get; } = [];
+ public List<(string toolName, string? resultText, string? toolCallId, bool isError)> ToolEnds { get; } = [];
+ public List<(TokenUsage? usage, string? modelId)> DoneEvents { get; } = [];
+ public List<(string SubagentId, string AgentType, string Description)> SubagentStarts { get; } = [];
+ public List<(string SubagentId, string ToolName, string Summary, int TokenGain)> SubagentToolUses { get; } = [];
+ public List<(string SubagentId, bool IsError)> SubagentFinishes { get; } = [];
+
+ public void OnText(string content) => TextEvents.Add(content);
+ public void OnThinking(string thinking) { }
+ public void OnToolStart(string toolName, string? toolCallId, string? arguments) => ToolStarts.Add((toolName, toolCallId, arguments));
+ public void OnToolEnd(string toolName, string? resultText, string? toolCallId, bool isError, StructuredPatchHunk[]? patch) => ToolEnds.Add((toolName, resultText, toolCallId, 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) => DoneEvents.Add((usage, modelId));
+ public void OnSubagentStarted(string subagentId, string agentType, string description) => SubagentStarts.Add((subagentId, agentType, description));
+ public void OnSubagentToolUse(string subagentId, string toolName, string summary, int tokenGain) => SubagentToolUses.Add((subagentId, toolName, summary, tokenGain));
+ public void OnSubagentFinished(string subagentId, bool isError) => SubagentFinishes.Add((subagentId, isError));
+ }
+}
From 18232ab899a06ea48af11395c8ffb88e0eee6459 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 06:42:41 +0800
Subject: [PATCH 16/17] =?UTF-8?q?docs:=20DSG041=20=E6=89=B91=20=E6=94=B6?=
=?UTF-8?q?=E5=8F=A3=E6=9B=B4=E6=96=B0=EF=BC=88=E9=AA=8C=E6=94=B6=E8=A1=A8?=
=?UTF-8?q?=20L3=20=E2=9C=85=20+=20=E5=AE=9E=E6=96=BD=E8=AE=B0=E5=BD=95?=
=?UTF-8?q?=E5=90=AB=20AgentSuccess=20=E8=AF=AD=E4=B9=89=E5=8F=96=E5=8F=8D?=
=?UTF-8?q?=E5=9D=91=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 | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/docs/proposed/design/DSG041-subagent-event-source-plan.md b/docs/proposed/design/DSG041-subagent-event-source-plan.md
index 75a2310f7..6b8e8990e 100644
--- a/docs/proposed/design/DSG041-subagent-event-source-plan.md
+++ b/docs/proposed/design/DSG041-subagent-event-source-plan.md
@@ -69,18 +69,25 @@ 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 单测 |
-| L3 IEventConsumer 三方法 + SessionController 三回调 | BridgeEventConsumer / InProcessEventConsumer | ⏳ 批1 | ⏳ host.tests fake consumer |
+| L2 AgentStreamChunkAdapter AgentId 传播 + 类型映射 | SessionController chunk→ChatStreamEvent 翻译 | ⏳ 批3 | ⏳ adapters 单测 |
| L4 引擎侧 BridgeEvent 三 record | bridge_serve JSON 协议消费方 | ⏳ 批4 | ⏳ BridgeJsonContractTests |
| InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ⏳ 批5 | ⏳ Host --selftest 扩断言(ADR 0080 真实运行) |
| TUI 真实链路分组显示 | SubagentGroupCoordinator → 分组渲染(既有零改动) | ⏳ 批5 | ⏳ 用户真实终端(agents 场景真实引擎 spawn) |
## 8. 后续
-- 批1(L3 协议扩展)可立即开工(无前置依赖)
-- L1 挂点决策(风险1)在批2 实施前向用户确认
+- ~~批1(L3 协议扩展)可立即开工(无前置依赖)~~ **✅ 已完成(b64c5d16d,2026-10-21)**
+- 批2(L1 通道接通):挂点二选一决策见风险1(倾向 AgentBase 循环内排空先行)
- 本 DSG 落地后 TASK055 三缺口③关闭
+### 批1 实施记录(b64c5d16d)
+
+- 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 走主路径(消费面仅三回调)
+
From f2eb1f39ea76e27f212eed5e7db18848822ab87f Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 06:45:08 +0800
Subject: [PATCH 17/17] =?UTF-8?q?docs:=20DSG041=20=E5=90=84=E6=89=B9?=
=?UTF-8?q?=E9=AA=8C=E6=94=B6=E6=96=B9=E5=BC=8F=E5=AE=9A=E4=B9=89=EF=BC=88?=
=?UTF-8?q?=E5=8D=95=E6=B5=8B=E7=BA=A2=E7=BB=BF=E2=86=92E2E=20=E5=A4=8D?=
=?UTF-8?q?=E5=88=B6=E5=85=88=E4=BE=8B=E2=86=92selftest=20=E7=9C=9F?=
=?UTF-8?q?=E5=AE=9E=E8=BF=90=E8=A1=8C=E2=86=92=E7=94=A8=E6=88=B7=E7=9C=9F?=
=?UTF-8?q?=E6=9C=BA=E6=94=B6=E5=8F=A3=EF=BC=8C=E5=B1=82=E7=BA=A7=E9=80=92?=
=?UTF-8?q?=E8=BF=9B=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 | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/proposed/design/DSG041-subagent-event-source-plan.md b/docs/proposed/design/DSG041-subagent-event-source-plan.md
index 6b8e8990e..8e5e4529b 100644
--- a/docs/proposed/design/DSG041-subagent-event-source-plan.md
+++ b/docs/proposed/design/DSG041-subagent-event-source-plan.md
@@ -88,6 +88,17 @@ native_tui(TUI 宿主)子代理分组渲染全链已落地(SubagentGroupCo
- 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 走主路径(消费面仅三回调)
+## 9. 各批验收方式定义(2026-10-21 用户确认"先定义验收方式再逐个进行")
+
+> 原则:TDD 红绿循环(每批先红测试后实现)+ 层级递进验收(单测 → E2E → 真实运行),全链最终以 ADR 0080 真实运行收口。
+
+| 批次 | 单元级验收(红绿循环) | 集成级验收 | 通过判定 |
+|------|----------------------|-----------|---------|
+| 批2 L1 通道接通 | AgentBase/AgentServiceImpl 新单测:通道挂载后子代理事件随主流 yield(EnterScope 前后 Current 值断言 + 排空转 chunk 断言) | **E2E 复制 SubAgentEventStreamE2ETests 先例**(test/mock/mock_server.e2e.tests)——改造为 QueryEngine 主代理路径(AgentBase.ExecuteStreamAsync 直连,不经 ChatService 管道),断言 AgentStarted/带 AgentId 活动/AgentFinished 全序列可观察 | E2E 绿 + 既有全量回归零破坏(agents/llm/brain 域测试) |
+| 批3 L2 chunk 传播 | AgentStreamChunkAdapter 单测:chunk.AgentId → ChatStreamEvent.AgentId、新 chunk 类型(AgentStarted/AgentFinished)→ 对应事件工厂映射 | SessionControllerSubagentEventTests 扩 chunk 路径用例(主代理路径 chunk→事件→回调全链) | adapter 输出事件 AgentId 非空 + 类型映射正确 |
+| 批4 L4 引擎侧 DTO | BridgeEventConsumer 映射单测(三方法→三 record 字段对齐)+ **BridgeJsonContractTests 锁 JSON 形状**(subagent_started/subagent_tool_use/subagent_finished 序列化快照) | bridge_serve 形态冒烟(进程级 stdout JSON 投影) | JSON 形状锁定 + 双端字段一致(与 native_tui BridgeEvent 逐字段核对) |
+| 批5 TUI 映射+真链 | InProcessEventConsumer 映射单测(NativeTui.Host.Tests:三方法→BridgeEvent 三事件投影) | **Host --selftest 扩子代理断言**(MockServer 假 LLM + 真实 spawn 路径,不依赖真实 API Key)→ 用户真实终端收口(agents 场景真实分组显示) | selftest 绿 + 用户真机确认分组渲染正确 |
+