From ebf5c5df69ac7716f20faa13b8f2088169dd06ae Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 03:05:11 +0800 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20=E6=8E=A2=E9=92=88=E4=BF=9D?= =?UTF-8?q?=E7=95=99=20WaitTurnCompleteAsync=20helper=EF=BC=88=E5=A4=9A?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=9C=BA=E6=99=AF=E8=AF=8A=E6=96=AD=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=EF=BC=89+=20=E6=81=A2=E5=A4=8D=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=8D=95=E5=8F=91=E5=BD=A2=E6=80=81=20|=20=E5=86=B3=E7=AD=96:?= =?UTF-8?q?=20/btw=20=E7=A9=BA=E5=93=8D=E5=BA=94=E5=A4=8D=E7=8E=B0?= =?UTF-8?q?=E8=B0=83=E6=9F=A5=E5=AE=9E=E8=AF=81=E2=80=94=E2=80=94CLI=20?= =?UTF-8?q?=E5=90=8C=E5=91=BD=E4=BB=A4=E9=93=BE=E6=AD=A3=E5=B8=B8(209?= =?UTF-8?q?=E5=AD=97=E7=AC=A6)+TUI=20=E6=8E=A2=E9=92=88=E5=85=88=E4=B8=BB?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=86=8D=20/btw=20=E4=B9=9F=E6=AD=A3?= =?UTF-8?q?=E5=B8=B8(513=E5=AD=97=E7=AC=A6)=EF=BC=8C=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E6=97=A0=E7=A1=AE=E5=AE=9A=E6=80=A7=20bug=EF=BC=9B=E7=96=91?= =?UTF-8?q?=E4=BC=BC=E5=81=B6=E5=8F=91=E6=B5=81=E5=BC=8F=E5=8F=96=E6=B6=88?= =?UTF-8?q?(TASK043=20=E7=99=BD=E5=90=8D=E5=8D=95=E5=B7=B2=E7=9F=A5)?= =?UTF-8?q?=E6=88=96=E4=B8=BB=E5=AF=B9=E8=AF=9D=E5=9B=9E=E5=90=88=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E6=80=81=E6=B1=A1=E6=9F=93=EF=BC=8C=E6=9A=82=E4=B8=8D?= =?UTF-8?q?=E6=94=B9=E5=BC=95=E6=93=8E=EF=BC=8C=E5=9F=BA=E5=87=86=E5=B7=AE?= =?UTF-8?q?=E5=BC=82(fork=20=E9=9A=94=E7=A6=BB=E4=BB=A3=E7=90=86=20vs=20?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E7=AE=A1=E9=81=93)=E5=B7=B2=E5=90=91?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=B1=87=E6=8A=A5=E5=BE=85=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/apps/NativeTui.Host/Program.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index ec05805fd..1d54608ff 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -41,7 +41,7 @@ private static async Task RunSelftestTurnAsync() { var sw = System.Diagnostics.Stopwatch.StartNew(); session.Client.Submit("只回复两个字母: ok"); - Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms 提交回合"); + Console.WriteLine($"[probe] t={sw.ElapsedMilliseconds}ms 提交 /btw"); string? firstThinking = null; string? firstToken = null; @@ -87,6 +87,22 @@ private static async Task RunSelftestTurnAsync() { return 1; } + /// 等待当前回合终态(turn_completed/interrupted/error — 排空事件循环)。 + private static async Task WaitTurnCompleteAsync(EngineSession session, TimeSpan timeout) { + using var cts = new CancellationTokenSource(timeout); + try { + while (!cts.Token.IsCancellationRequested) { + await Task.Delay(100, cts.Token).ConfigureAwait(false); + foreach (var evt in session.Client.DrainEvents()) { + if (evt is BridgeEvent.TurnCompleted or BridgeEvent.TurnInterrupted or BridgeEvent.Error) { + return; + } + } + } + } catch (OperationCanceledException) { + } + } + /// 交互模式 — TTY 检查 → UTF-8 → raw mode → 引擎组装(Console.Out 重定向 stderr 防引擎污染渲染)→ REPL 主循环。 private static async Task RunInteractiveAsync() { if (Console.IsInputRedirected || Console.IsOutputRedirected) { From 95a6fb91500bda7b09142c58062685940cae4d49 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 03:33:21 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20/btw=20=E6=94=B9=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=20fork=20=E4=BB=A3=E7=90=86=EF=BC=88=E7=85=A7=E6=8A=84?= =?UTF-8?q?=E5=9F=BA=E5=87=86=20runSideQuestion=EF=BC=89=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20=E5=AF=B9=E9=BD=90=20TS=20forkedAgent=20=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E2=80=94=E2=80=94AgentBase+InitialMessageList(?= =?UTF-8?q?=E4=B8=BB=E5=8E=86=E5=8F=B2=E5=89=AF=E6=9C=AC+system-reminder?= =?UTF-8?q?=E5=8C=85=E8=A3=B9=E9=97=AE=E9=A2=98)+MaxIterations=3D1(?= =?UTF-8?q?=E5=AF=B9=E9=BD=90maxTurns:1)+ContextManager=E4=B8=8D=E6=B3=A8?= =?UTF-8?q?=E5=85=A5(=E5=A4=A9=E7=84=B6=E9=9A=94=E7=A6=BB=E4=B8=8D?= =?UTF-8?q?=E5=86=99=E5=9B=9E=E4=B8=BB=E4=B8=8A=E4=B8=8B=E6=96=87)?= =?UTF-8?q?=EF=BC=8C=E6=9B=BF=E4=BB=A3=E5=8E=9F=20ChatService=20=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E7=AE=A1=E9=81=93=E8=B7=AF=E5=BE=84(=E5=8F=97?= =?UTF-8?q?=E4=B8=BB=E5=AF=B9=E8=AF=9D=E4=BC=9A=E8=AF=9D=E6=80=81=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E5=81=B6=E5=8F=91=E7=A9=BA=E5=93=8D=E5=BA=94=E7=9A=84?= =?UTF-8?q?=E6=9E=B6=E6=9E=84gap)=EF=BC=9Bsystem-reminder=20=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=85=A7=E6=8A=84=E5=9F=BA=E5=87=86(=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E4=BB=A3=E7=90=86=E8=BA=AB=E4=BB=BD/=E6=97=A0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BA=A6=E6=9D=9F/=E5=8D=95=E6=AC=A1?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D)=EF=BC=9BCLI=20=E7=9C=9F=E6=9C=BA=E5=AE=9E?= =?UTF-8?q?=E8=AF=81=202510=20=E5=AD=97=E7=AC=A6=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=9B=9E=E7=AD=94+composition=20278=20=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commands/BtwCommandTests.cs | 105 ++++++++++++++++++ modules/composition/slash/GlobalUsings.cs | 1 + .../slash/system/social/BtwCommand.cs | 84 +++++++++++--- 3 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 modules/composition/composition.tests/commands/BtwCommandTests.cs diff --git a/modules/composition/composition.tests/commands/BtwCommandTests.cs b/modules/composition/composition.tests/commands/BtwCommandTests.cs new file mode 100644 index 000000000..cbf31f293 --- /dev/null +++ b/modules/composition/composition.tests/commands/BtwCommandTests.cs @@ -0,0 +1,105 @@ +namespace Composition.Tests.Commands; + +/// +/// /btw 隔离 fork 测试(对齐 TS runSideQuestion→runForkedAgent 语义)— +/// 主历史副本做前缀 + 独立 AgentBase 单轮执行(MaxIterations=1)+ 不写回主上下文 +///(ContextManager 不注入天然隔离);修复背景:原 ChatService 共享管道受主对话会话态 +/// 影响偶发空响应。IQueryEngine 假体直证 fork 请求消息形态。 +/// +public sealed class BtwCommandTests { + /// 假 IQueryEngine — 捕获收到的 chatHistory,返回固定单 content chunk。 + private sealed class CaptureQueryEngine : IQueryEngine { + public MessageList? CapturedHistory { get; private set; } + public string CapturedPrompt { get; private set; } = string.Empty; + + public Task ExecuteQueryAsync(string query, CancellationToken cancellationToken = default) + => Task.FromResult("侧边回答"); + + public async IAsyncEnumerable QueryAsync( + string userInput, MessageList chatHistory, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + CapturedPrompt = userInput; + CapturedHistory = chatHistory; + await Task.Yield(); + yield return new QueryStreamChunk { Type = AgentStreamChunkType.Content, Content = "这是侧边回答。" }; + } + + public IAsyncEnumerable QueryAsync( + string userInput, MessageList chatHistory, QueryOptions? options, CancellationToken cancellationToken = default) + => QueryAsync(userInput, chatHistory, cancellationToken); + + public JoinCode.Abstractions.LLM.IQueryService GetChatCompletionService() + => throw new NotSupportedException("测试假体不提供查询服务"); + + public JoinCode.Abstractions.LLM.IChatClient GetKernel() + => throw new NotSupportedException("测试假体不提供 kernel"); + } + + private static ChatCommandContext CreateContext(IQueryEngine queryEngine, string arguments = "你好") { + var services = new ServiceCollection(); + services.AddSingleton(queryEngine); + var commandServices = new CommandServices { + ChatService = Mock.Of(), + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = services.BuildServiceProvider(), + }; + return new ChatCommandContext { + Arguments = arguments, + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + } + + /// fork 调用 — 返回 QueryEngine 内容 chunk 聚合文本。 + [Fact] + public async Task RunSideQuestion_AggregatesContentChunks() { + var engine = new CaptureQueryEngine(); + var context = CreateContext(engine); + + var response = await BtwCommand.RunSideQuestionAsync(context, "你好"); + + response.Should().Be("这是侧边回答。"); + } + + /// fork 消息形态 — 历史含 system-reminder 包裹的问题(对齐 TS wrappedQuestion)。 + [Fact] + public async Task RunSideQuestion_WrapsQuestionWithReminder() { + var engine = new CaptureQueryEngine(); + var context = CreateContext(engine); + + await BtwCommand.RunSideQuestionAsync(context, "什么是 SOLID?"); + + engine.CapturedHistory.Should().NotBeNull(); + Assert.NotNull(engine.CapturedHistory); + var history = engine.CapturedHistory; + history.Count.Should().Be(1); + var last = history[history.Count - 1]; + last.Role.Should().Be(MessageRole.User); + last.Content.Should().Contain("侧边问题"); + last.Content.Should().Contain("什么是 SOLID?"); + last.Content.Should().Contain("没有任何可用工具"); + } + + /// 空参 — 用法提示不调引擎(输出捕获经 Console.SetOut,不经 TerminalHelper 门面 — + /// 测试项目无 cli 域引用,SetOut 与命令侧 TerminalHelper.WriteLine 底层同为 Console.Out)。 + [Fact] + public async Task Execute_EmptyQuestion_ShowsUsageWithoutEngineCall() { + var engine = new CaptureQueryEngine(); + var context = CreateContext(engine, arguments: ""); + var output = new System.Text.StringBuilder(); + var originalOut = System.Console.Out; + await using var writer = new StringWriter(output); + System.Console.SetOut(writer); + try { + var cmd = new BtwCommand(); + await cmd.ExecuteAsync(context); + } finally { + System.Console.SetOut(originalOut); + } + + engine.CapturedHistory.Should().BeNull("空参不应触发 fork 调用"); + output.ToString().Should().Contain("用法: /btw"); + } +} diff --git a/modules/composition/slash/GlobalUsings.cs b/modules/composition/slash/GlobalUsings.cs index b66d60208..b5561d485 100644 --- a/modules/composition/slash/GlobalUsings.cs +++ b/modules/composition/slash/GlobalUsings.cs @@ -1 +1,2 @@ global using System.Diagnostics.CodeAnalysis; +global using Core.Agents.Coordinator; diff --git a/modules/composition/slash/system/social/BtwCommand.cs b/modules/composition/slash/system/social/BtwCommand.cs index 0349b6a2d..b8eb13fed 100644 --- a/modules/composition/slash/system/social/BtwCommand.cs +++ b/modules/composition/slash/system/social/BtwCommand.cs @@ -1,16 +1,38 @@ namespace JoinCode.ChatCommands; /// -/// /btw 命令 — 对齐 TS btw.ts -/// TS 使用 sideQuestion 模式发送独立请求,C# 使用 ChatService.SendMessageAsync -/// 对齐内容:侧边提问不影响主对话上下文 -/// 架构差异:TS 有 React 侧边栏渲染,C# 为终端文本输出 +/// /btw 命令 — 对齐 TS btw.ts + sideQuestion.ts + forkedAgent.ts: +/// TS 用 runForkedAgent(隔离轻量 fork 代理:父上下文消息做前缀共享缓存、独立实例、 +/// canUseTool 全拒、maxTurns=1、不写回父上下文);C# 照抄同语义 — AgentBase + +/// Options.InitialMessageList(主历史副本 + 问题)+ MaxIterations=1 + ContextManager 不注入 +///(天然隔离:不碰主上下文状态、不写回历史)。修复背景:原实现走 ChatService 共享管道, +/// 受主对话会话态影响偶发空响应(CLI/探针实证链路本身健康,共享态是架构 gap)。 /// [ChatCommand(Name = ChatCommandNameEnumConstants.Btw, Description = "快速向 AI 提一个侧边问题", Usage = "/btw ", Category = ChatCommandCategory.Social, ArgumentHint = "")] [ChatCommandArg("question", Type = "string", Description = "侧边问题内容", Required = true)] public sealed class BtwCommand : ChatCommandBase { + /// 侧边问题 system-reminder 模板(对齐 TS runSideQuestion wrappedQuestion:隔离代理身份 + 无工具约束 + 单次回复)。 + private const string SideQuestionReminder = """ +此为用户的侧边问题,请直接在单次回复中回答。 + +重要背景: +- 你是为回答这一个问题而生成的独立轻量代理 +- 主对话代理未被中断,仍在后台独立运行 +- 你共享对话上下文,但是完全独立的实例 +- 不要提及被打断或"之前正在做什么"——此表述不正确 + +关键约束: +- 你没有任何可用工具——不能读文件、执行命令、搜索或采取任何行动 +- 这是一次性回复——没有后续轮次 +- 只能基于对话上下文中已有的信息作答 +- 绝不说"让我试试"、"我将检查"或承诺采取任何行动 +- 不知道答案就直说——不要提出去查证或调查 + +请直接用你已有的信息回答问题。 +"""; + /// - /// 执行 /btw 命令 — 将侧边问题发送给 AI,不影响主对话上下文 + /// 执行 /btw 命令 — 隔离 fork 代理回答侧边问题,不影响主对话上下文(对齐 TS runSideQuestion) /// /// 命令执行上下文,包含问题文本与取消令牌 /// 命令执行结果(始终为 Continue,表示不中断主对话流) @@ -27,16 +49,10 @@ public override async Task ExecuteAsync(ChatCommandContext co return ChatCommandResult.Continue(); } - var prompt = $""" -This is a quick side question that should not disrupt the main conversation context. Answer concisely. - -Question: {question} -"""; - try { TerminalHelper.WriteLine($"{TerminalColors.Muted}── 侧边问题 ──{AnsiStyleEnumConstants.Reset}"); - var result = await context.GetCommandServices().ChatService.SendMessageAsync(prompt, context.CancellationToken).ConfigureAwait(false); - TerminalHelper.WriteLine(result); + var response = await RunSideQuestionAsync(context, question).ConfigureAwait(false); + TerminalHelper.WriteLine(response); TerminalHelper.WriteLine($"{TerminalColors.Muted}── 侧边回答结束 ──{AnsiStyleEnumConstants.Reset}"); } catch (Exception ex) { ChatCommandBase.HandleError("侧边提问", ex); @@ -44,4 +60,44 @@ This is a quick side question that should not disrupt the main conversation cont return ChatCommandResult.Continue(); } -} \ No newline at end of file + + /// 隔离 fork 侧边问题(对齐 TS runSideQuestion→runForkedAgent): + /// 主历史副本做前缀(共享上下文)+ system-reminder 包裹问题 + 独立 AgentBase 单轮执行 + ///(MaxIterations=1 对齐 maxTurns:1;ContextManager 不注入 = 不写回主上下文天然隔离)。 + internal static async Task RunSideQuestionAsync(ChatCommandContext context, string question) { + var provider = context.GetCommandServices().ServiceProvider; + var queryEngine = provider?.GetService() + ?? throw new InvalidOperationException("IQueryEngine 未注册 — 无法执行侧边问题 fork 调用"); + + // fork 前缀 — 主上下文消息副本(隔离:追加问题不写回主历史) + var forkHistory = new MessageList(); + var contextManager = provider?.GetService(); + if (contextManager is not null) { + var parent = await contextManager.GetMessageListAsync(context.CancellationToken).ConfigureAwait(false); + foreach (var message in parent) { + forkHistory.Add(message); + } + } + + forkHistory.AddUserMessage($"{SideQuestionReminder}\n\n{question}"); + + await using var agent = new AgentBase( + task: question, + options: new SubAgentOptions { + InitialMessageList = forkHistory, + MaxIterations = 1, + }, + queryEngine: queryEngine, + logger: provider?.GetService>(), + name: "side-question"); + + var response = new StringBuilder(); + await foreach (var chunk in agent.ExecuteStreamAsync(context.CancellationToken).ConfigureAwait(false)) { + if (chunk.Type == AgentStreamChunkType.Content && chunk.Content is { Length: > 0 }) { + response.Append(chunk.Content); + } + } + + return response.ToString(); + } +} From a08138e0cd14efd362d2fdaba0b8b22f9a927bba Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 04:23:07 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test:=20BtwCommandTests=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=88=B0=20fork=20=E6=96=B0=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=EF=BC=88CI=20#447=20=E7=BA=A2=EF=BC=89=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20=E6=97=A7=E7=94=A8=E4=BE=8B=E6=96=AD=E8=A8=80=20Ch?= =?UTF-8?q?atService.SendMessageAsync=20=E8=A2=AB=E8=B0=83=EF=BC=88?= =?UTF-8?q?=E6=97=A7=E5=85=B1=E4=BA=AB=E7=AE=A1=E9=81=93=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=EF=BC=89=EF=BC=8Cfork=20=E6=94=B9=E9=80=A0=E5=90=8E=E8=B5=B0?= =?UTF-8?q?=20IQueryEngine=EF=BC=88=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=20?= =?UTF-8?q?runForkedAgent=EF=BC=89=E4=B8=8D=E5=86=8D=E8=B0=83=20ChatServic?= =?UTF-8?q?e=E2=80=94=E2=80=94=E6=94=B9=E6=96=AD=E8=A8=80=20fork=20?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E8=AE=A1=E6=95=B0+prompt=20=E6=8D=95?= =?UTF-8?q?=E8=8E=B7=EF=BC=88CaptureQueryEngine=20=E5=81=87=E4=BD=93?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=BC=82=E5=B8=B8=E8=B7=AF=E5=BE=84=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=94=B9=20QueryEngine=20=E6=8A=9B=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E4=BB=8D=20Continue=EF=BC=9B=E9=9D=9E=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=98=AF=E9=A2=84=E6=9C=9F=E8=A1=8C=E4=B8=BA=E5=8F=98=E5=8C=96?= =?UTF-8?q?=EF=BC=88CI=20=E6=97=A5=E5=BF=97=E5=AE=9E=E8=AF=81=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E5=A4=B1=E8=B4=A5=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat_commands/a_to_f/BtwCommandTests.cs | 62 ++++++++++++++----- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/test/unit/host.tests/chat_commands/a_to_f/BtwCommandTests.cs b/test/unit/host.tests/chat_commands/a_to_f/BtwCommandTests.cs index d9eac9702..e9b4d92bc 100644 --- a/test/unit/host.tests/chat_commands/a_to_f/BtwCommandTests.cs +++ b/test/unit/host.tests/chat_commands/a_to_f/BtwCommandTests.cs @@ -78,20 +78,20 @@ public async Task Execute_WithWhitespaceArgs_Should_Show_Usage() { } [Fact] - public async Task Execute_WithQuestion_Should_SendMessage() { + public async Task Execute_WithQuestion_Should_InvokeForkQueryEngine() { + var engine = new CaptureQueryEngine("侧边回答文本"); + var services = new ServiceCollection(); + services.AddSingleton(engine); var cmd = new BtwCommand(); - var chatService = new Mock(); - chatService.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync("Test answer"); - var context = new ChatCommandContext { Arguments = "test question", CancellationToken = CancellationToken.None, Services = new CommandServiceProvider(new CommandServices { - ChatService = chatService.Object, + ChatService = Mock.Of(), CodeService = Mock.Of(), PlanService = Mock.Of(), FileSystem = TestFileSystem.Current, + ServiceProvider = services.BuildServiceProvider(), }), }; @@ -99,26 +99,26 @@ public async Task Execute_WithQuestion_Should_SendMessage() { result.ShouldContinue.Should().BeTrue(); result.IsHandled.Should().BeTrue(); - chatService.Verify(c => c.SendMessageAsync( - It.Is(s => s.Contains("test question")), - It.IsAny()), Times.Once); + engine.QueryCallCount.Should().Be(1, "fork 路径走 IQueryEngine(对齐基准 runForkedAgent),不再走 ChatService.SendMessageAsync"); + engine.CapturedPrompt.Should().Contain("test question"); } + /// fork 异常路径 — QueryEngine 抛异常时 HandleError 吞掉,命令仍 Continue 不中断主对话流。 [Fact] - public async Task Execute_WhenSendMessageThrows_Should_Return_Continue() { + public async Task Execute_WhenQueryEngineThrows_Should_Return_Continue() { var cmd = new BtwCommand(); - var chatService = new Mock(); - chatService.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("chat error")); - + var engine = new CaptureQueryEngine(throwOnQuery: true); + var services = new ServiceCollection(); + services.AddSingleton(engine); var context = new ChatCommandContext { Arguments = "test question", CancellationToken = CancellationToken.None, Services = new CommandServiceProvider(new CommandServices { - ChatService = chatService.Object, + ChatService = Mock.Of(), CodeService = Mock.Of(), PlanService = Mock.Of(), FileSystem = TestFileSystem.Current, + ServiceProvider = services.BuildServiceProvider(), }), }; @@ -127,4 +127,36 @@ public async Task Execute_WhenSendMessageThrows_Should_Return_Continue() { result.ShouldContinue.Should().BeTrue(); result.IsHandled.Should().BeTrue(); } + + /// 捕获型 QueryEngine 假体 — fork 调用计数与 prompt 捕获(IQueryEngine 最小面)。 + private sealed class CaptureQueryEngine(string? fixedResponse = null, bool throwOnQuery = false) : IQueryEngine { + public int QueryCallCount { get; private set; } + public string CapturedPrompt { get; private set; } = string.Empty; + + public Task ExecuteQueryAsync(string query, CancellationToken cancellationToken = default) + => Task.FromResult(fixedResponse ?? string.Empty); + + public async IAsyncEnumerable QueryAsync( + string userInput, MessageList chatHistory, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + QueryCallCount++; + CapturedPrompt = userInput; + if (throwOnQuery) { + throw new InvalidOperationException("fork query error"); + } + + await Task.Yield(); + yield return new QueryStreamChunk { Type = AgentStreamChunkType.Content, Content = fixedResponse ?? string.Empty }; + } + + public IAsyncEnumerable QueryAsync( + string userInput, MessageList chatHistory, QueryOptions? options, CancellationToken cancellationToken = default) + => QueryAsync(userInput, chatHistory, cancellationToken); + + public JoinCode.Abstractions.LLM.IQueryService GetChatCompletionService() + => throw new NotSupportedException("测试假体不提供查询服务"); + + public JoinCode.Abstractions.LLM.IChatClient GetKernel() + => throw new NotSupportedException("测试假体不提供 kernel"); + } } \ No newline at end of file