diff --git a/modules/composition/composition.tests/commands/BtwCommandTests.cs b/modules/composition/composition.tests/commands/BtwCommandTests.cs new file mode 100644 index 0000000000..cbf31f293b --- /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 b66d602087..b5561d485c 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 0349b6a2d6..b8eb13fed1 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(); + } +} diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index ec05805fd9..1d54608ff7 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) { 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 d9eac97021..e9b4d92bc3 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