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