diff --git a/AGENTS.md b/AGENTS.md index 8599bc73ce..e4130a0af8 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/docs/proposed/design/DSG041-subagent-event-source-plan.md b/docs/proposed/design/DSG041-subagent-event-source-plan.md new file mode 100644 index 0000000000..8e5e4529b1 --- /dev/null +++ b/docs/proposed/design/DSG041-subagent-event-source-plan.md @@ -0,0 +1,106 @@ +# 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. 四列验收表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| L3 IEventConsumer 三方法(DIM 默认空)+ SessionController RouteSubagentEvent 前置分流 | BridgeEventConsumer / InProcessEventConsumer(批4/批5 接映射) | ✅ 批1(b64c5d16d) | ✅ SessionControllerSubagentEventTests 5/5(Started/Finished/工具分流/普通零回归/混合流)+ host.tests 1371 + abs.tests 694 + engine/cli/Host 三链编译绿 | +| L1 SubAgentEventChannel 接通 QueryEngine 主代理路径 | AgentStreamExecutionMiddleware/ForkSubAgentManagerActor 发射侧(零改动) | ⏳ 批2 | ⏳ E2E 复制先例 + agents 单测 | +| L2 AgentStreamChunkAdapter AgentId 传播 + 类型映射 | SessionController chunk→ChatStreamEvent 翻译 | ⏳ 批3 | ⏳ adapters 单测 | +| L4 引擎侧 BridgeEvent 三 record | bridge_serve JSON 协议消费方 | ⏳ 批4 | ⏳ BridgeJsonContractTests | +| InProcessEventConsumer 映射 | EngineTurnClient → ReplBridgeDriver(既有路由零改动) | ⏳ 批5 | ⏳ Host --selftest 扩断言(ADR 0080 真实运行) | +| TUI 真实链路分组显示 | SubagentGroupCoordinator → 分组渲染(既有零改动) | ⏳ 批5 | ⏳ 用户真实终端(agents 场景真实引擎 spawn) | + +## 8. 后续 + +- ~~批1(L3 协议扩展)可立即开工(无前置依赖)~~ **✅ 已完成(b64c5d16d,2026-10-21)** +- 批2(L1 通道接通):挂点二选一决策见风险1(倾向 AgentBase 循环内排空先行) +- 本 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 走主路径(消费面仅三回调) + +## 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 绿 + 用户真机确认分组渲染正确 | + + + + + + 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 43652eab2b..cbc61f8777 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)、光标态滚动跟随选中项(超屏时) + + + + + + + + + + + + + + + + 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 86b5dc4720..f32c96ac6d 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` 全量重建供复验 + + + + + + + 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 0000000000..6a15103c9f --- /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,54 @@ +# TASK058 — TUI 供应商选择器(/vendor 模态) + +> 状态:implemented(2026-10-21 全链落地 + 双 selftest 通过;剩余用户真机验收) +> 前置: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 双入口接线 + +## 验收表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| 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) + + + + + + diff --git a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs index 6dced7f822..62583a75ef 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 05ce251f42..d5160b8248 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/modules/apps/engine/app/builder/EngineSessionFactory.cs b/modules/apps/engine/app/builder/EngineSessionFactory.cs index 01dd5e6a88..a92b2d1bf9 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 @@ -64,9 +71,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 +95,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/engine/app/modules/McpInitModule.cs b/modules/apps/engine/app/modules/McpInitModule.cs index 0fb0b6c83c..e42f095344 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 0000000000..9a3828bb6a --- /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/modules/apps/gui/hosting/JccChatSession.cs b/modules/apps/gui/hosting/JccChatSession.cs index e81504e9df..9c913e0537 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 1c545a8576..8d1d928065 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/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 0000000000..ab4bb06cd0 --- /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 3bd35ab966..c9fbeb6257 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 5f2e364539..8585d1f59c 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 +} diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs index 5628fcd51a..4bc82d7144 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 34e43c4185..9d58c0bcd8 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,11 +34,21 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien /// 当前会话标识 — 历史种子排除当前会话防双份。 public string SessionId => _engine.SessionId; - /// 组装引擎会话 — 建会话(TuiInteractionModule 注册权限确认 handler)→ 解析权限/模型 → 构造回合客户端 → 种子 session_info;失败时释放已建引擎 Host。 + /// 重发 session_info 事件 — 模型/供应商切换等运行时状态变化后刷新 banner(ReplBridgeDriver.ApplySessionInfo 差分上屏)。 + public void RefreshSessionInfo() => _client.Enqueue(BuildSessionInfoEvent(_checker, _fastMode, _engine.Config)); + + /// 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); @@ -52,8 +66,8 @@ public static async Task CreateAsync(CancellationToken cancellati }, BuildPermissionModeApplier(checker)); - var session = new EngineSession(engine, client, permissionBridge); - client.Enqueue(BuildSessionInfoEvent(checker, fastMode)); + var session = new EngineSession(engine, client, permissionBridge, checker, fastMode); + client.Enqueue(BuildSessionInfoEvent(checker, fastMode, engine.Config)); return session; } catch { await engine.Host.DisposeAsync().ConfigureAwait(false); @@ -70,17 +84,19 @@ public static async Task CreateAsync(CancellationToken cancellati return checker; } - /// 组装会话握手事件 — 版本(引擎程序集)/当前模型(FastMode 主模型,未设置为 null)/ - /// 权限模式(安全闸后的实际值)/bypass 可用性(!DisableBypassGate)/cwd。 - private static BridgeEvent.SessionInfo BuildSessionInfoEvent(PermissionChecker? checker, IFastModeService? fastMode) { + /// 组装会话握手事件(internal — 测试断言 SetPrimaryModel 后 Model 字段更新)。版本(引擎程序集)/当前模型(FastMode 主模型,未设置为 null)/ + /// 权限模式(安全闸后的实际值)/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 82e60d8777..29ca5b8a2e 100644 --- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs +++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs @@ -1,10 +1,13 @@ 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.Exceptions; global using JoinCode.Abstractions.Configuration.Providers; global using JoinCode.Abstractions.Interfaces; global using JoinCode.Abstractions.LLM.Chat; @@ -17,6 +20,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/ModelPickerAssembly.cs b/native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs index 8b94eec14d..a909e070bf 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 46328e885a..0d2a9136ba 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; @@ -50,11 +60,26 @@ 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 后端获取,切换链自研) - var pickerEngine = new ModelPickerEngine( - session.Client, - () => ModelPickerAssembly.LoadItems(session.Services), - id => ModelPickerAssembly.ApplyModel(session.Services, id)); + // M7-6 模型选择器 — 装饰引擎拦截 /model(清单从引擎 IModelCatalog 后端获取,切换链自研); + // 切换成功后 RefreshSessionInfo 重发握手事件刷新 banner(当前模型显示同步) + // 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); pickerEngine.Attach(shell); await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false); diff --git a/native_tui/apps/NativeTui.Host/VendorPickerAssembly.cs b/native_tui/apps/NativeTui.Host/VendorPickerAssembly.cs new file mode 100644 index 0000000000..dd9e83ccb4 --- /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/apps/NativeTui.Host/tui_assembly/SilentLoggerProvider.cs b/native_tui/apps/NativeTui.Host/tui_assembly/SilentLoggerProvider.cs new file mode 100644 index 0000000000..7070036f8e --- /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/apps/NativeTui.Host/tui_assembly/StartupErrorPrinter.cs b/native_tui/apps/NativeTui.Host/tui_assembly/StartupErrorPrinter.cs new file mode 100644 index 0000000000..a6d2367ee7 --- /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/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index 9a0837df20..80ad0a28cd 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 3c130749c8..2de748ec81 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/components/ScrollBox.cs b/native_tui/src/NativeTui.Core/components/ScrollBox.cs index abb73960aa..f3f570009d 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/BannerItem.cs b/native_tui/src/NativeTui.Core/repl/BannerItem.cs index b372ced917..47ec3a1967 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/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 0d7fd358f0..6de7ac65c4 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/MarkdownMessageItem.cs b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs index 9dab7a3531..bc3faf4f69 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 0000000000..fd8723ece1 --- /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 285426f1f8..2536301b24 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/ModelPickerDialog.cs b/native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs index dd383e1ab0..0fda4c3354 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 16b70d415b..2ff839f2b4 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; @@ -214,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 取消)。 @@ -287,7 +294,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 +318,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 +669,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 +939,7 @@ internal void Submit() internal void RenderAndFlush() { SyncSuggestionState(); + SyncCursorHighlight(); EnsureScreens(); _back.Clear(); AdvanceAnimClock(); @@ -885,6 +1006,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 309d1dd089..f40684538b 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 = []; /// 条目清单(渲染与断言投影)。 @@ -48,11 +61,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; + + /// 可见条数 = 展开全量,折叠 min(10, n)。 + public int VisibleCount => _isExpanded ? _agents.Count : Math.Min(MaxVisible, _agents.Count); - /// 折叠条数 = max(0, n - 10)("and N more…" 行依据)。 - public int HiddenCount => Math.Max(0, _agents.Count - VisibleCount); + /// 折叠条数 = 展开恒 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; } @@ -76,19 +104,34 @@ public sealed class SubagentGroupCoordinator /// 追加子代理条目(未完成态)+ 窗口滚动到该条目(活动跟随)。 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, + }; }); } @@ -123,9 +166,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 978a5a4906..1e86308f85 100644 --- a/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs +++ b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs @@ -9,9 +9,11 @@ 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 +public sealed class SubagentGroupItem(SubagentGroupCoordinator coordinator) : IScrollItem, IAnimatable, ICursorNavigable { /// 协调器(分组状态的唯一来源 — Driver 事件路由更新)。 public SubagentGroupCoordinator Coordinator { get; } = coordinator; @@ -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.Host.Tests/EngineSessionTests.cs b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs index 8afa592388..2ac24fb803 100644 --- a/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/EngineSessionTests.cs @@ -40,4 +40,43 @@ 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); + } + + /// 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); + } + + /// 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 e5770fb436..0cb11c15b2 100644 --- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs +++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs @@ -1,15 +1,22 @@ 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.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; 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; +global using Microsoft.Extensions.Logging; global using NativeTui.Core.Bridge; global using NativeTui.Core.Markdown; global using NativeTui.Core.Repl; 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 0000000000..75353bb8ba --- /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); + } +} 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 0000000000..9316e9e8a3 --- /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/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs index aa6d590f48..d8307de64c 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() { 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 0000000000..c32ed8b852 --- /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; + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs b/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs index 7d9e2925d1..2cf14fba96 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 { diff --git a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs index ebd712d9a4..78c0935e94 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() diff --git a/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs b/test/unit/host.tests/adapters/SessionControllerSubagentEventTests.cs new file mode 100644 index 0000000000..8123f8a6ea --- /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)); + } +} diff --git a/test/unit/host.tests/engine/AsyncHostWrapperShutdownOrderTests.cs b/test/unit/host.tests/engine/AsyncHostWrapperShutdownOrderTests.cs new file mode 100644 index 0000000000..44979e9dfa --- /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; + } + } +} diff --git a/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs b/test/unit/host.tests/engine/McpPluginBootstrapHandleTests.cs new file mode 100644 index 0000000000..bbdd6e555b --- /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 0000000000..47c580f58e --- /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); + } +}