Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions modules/composition/composition.tests/commands/BtwCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
namespace Composition.Tests.Commands;

/// <summary>
/// /btw 隔离 fork 测试(对齐 TS runSideQuestion→runForkedAgent 语义)—
/// 主历史副本做前缀 + 独立 AgentBase 单轮执行(MaxIterations=1)+ 不写回主上下文
///(ContextManager 不注入天然隔离);修复背景:原 ChatService 共享管道受主对话会话态
/// 影响偶发空响应。IQueryEngine 假体直证 fork 请求消息形态。
/// </summary>
public sealed class BtwCommandTests {
/// <summary>假 IQueryEngine — 捕获收到的 chatHistory,返回固定单 content chunk。</summary>
private sealed class CaptureQueryEngine : IQueryEngine {
public MessageList? CapturedHistory { get; private set; }
public string CapturedPrompt { get; private set; } = string.Empty;

public Task<string> ExecuteQueryAsync(string query, CancellationToken cancellationToken = default)
=> Task.FromResult("侧边回答");

public async IAsyncEnumerable<QueryStreamChunk> 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<QueryStreamChunk> 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<IChatService>(),
CodeService = Mock.Of<ICodeService>(),
PlanService = Mock.Of<IPlanService>(),
FileSystem = TestFileSystem.Current,
ServiceProvider = services.BuildServiceProvider(),
};
return new ChatCommandContext {
Arguments = arguments,
CancellationToken = CancellationToken.None,
Services = new CommandServiceProvider(commandServices),
};
}

/// <summary>fork 调用 — 返回 QueryEngine 内容 chunk 聚合文本。</summary>
[Fact]
public async Task RunSideQuestion_AggregatesContentChunks() {
var engine = new CaptureQueryEngine();
var context = CreateContext(engine);

var response = await BtwCommand.RunSideQuestionAsync(context, "你好");

response.Should().Be("这是侧边回答。");
}

/// <summary>fork 消息形态 — 历史含 system-reminder 包裹的问题(对齐 TS wrappedQuestion)。</summary>
[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("没有任何可用工具");
}

/// <summary>空参 — 用法提示不调引擎(输出捕获经 Console.SetOut,不经 TerminalHelper 门面 —
/// 测试项目无 cli 域引用,SetOut 与命令侧 TerminalHelper.WriteLine 底层同为 Console.Out)。</summary>
[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");
}
}
1 change: 1 addition & 0 deletions modules/composition/slash/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
global using System.Diagnostics.CodeAnalysis;
global using Core.Agents.Coordinator;
84 changes: 70 additions & 14 deletions modules/composition/slash/system/social/BtwCommand.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
namespace JoinCode.ChatCommands;

/// <summary>
/// /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)。
/// </summary>
[ChatCommand(Name = ChatCommandNameEnumConstants.Btw, Description = "快速向 AI 提一个侧边问题", Usage = "/btw <question>", Category = ChatCommandCategory.Social, ArgumentHint = "<question>")]
[ChatCommandArg("question", Type = "string", Description = "侧边问题内容", Required = true)]
public sealed class BtwCommand : ChatCommandBase {
/// <summary>侧边问题 system-reminder 模板(对齐 TS runSideQuestion wrappedQuestion:隔离代理身份 + 无工具约束 + 单次回复)。</summary>
private const string SideQuestionReminder = """
此为用户的侧边问题,请直接在单次回复中回答。

重要背景:
- 你是为回答这一个问题而生成的独立轻量代理
- 主对话代理未被中断,仍在后台独立运行
- 你共享对话上下文,但是完全独立的实例
- 不要提及被打断或"之前正在做什么"——此表述不正确

关键约束:
- 你没有任何可用工具——不能读文件、执行命令、搜索或采取任何行动
- 这是一次性回复——没有后续轮次
- 只能基于对话上下文中已有的信息作答
- 绝不说"让我试试"、"我将检查"或承诺采取任何行动
- 不知道答案就直说——不要提出去查证或调查

请直接用你已有的信息回答问题。
""";

/// <summary>
/// 执行 /btw 命令 — 将侧边问题发送给 AI,不影响主对话上下文
/// 执行 /btw 命令 — 隔离 fork 代理回答侧边问题,不影响主对话上下文(对齐 TS runSideQuestion)
/// </summary>
/// <param name="context">命令执行上下文,包含问题文本与取消令牌</param>
/// <returns>命令执行结果(始终为 Continue,表示不中断主对话流)</returns>
Expand All @@ -27,21 +49,55 @@ public override async Task<ChatCommandResult> 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);
}

return ChatCommandResult.Continue();
}
}

/// <summary>隔离 fork 侧边问题(对齐 TS runSideQuestion→runForkedAgent):
/// 主历史副本做前缀(共享上下文)+ system-reminder 包裹问题 + 独立 AgentBase 单轮执行
///(MaxIterations=1 对齐 maxTurns:1;ContextManager 不注入 = 不写回主上下文天然隔离)。</summary>
internal static async Task<string> RunSideQuestionAsync(ChatCommandContext context, string question) {
var provider = context.GetCommandServices().ServiceProvider;
var queryEngine = provider?.GetService<IQueryEngine>()
?? throw new InvalidOperationException("IQueryEngine 未注册 — 无法执行侧边问题 fork 调用");

// fork 前缀 — 主上下文消息副本(隔离:追加问题不写回主历史)
var forkHistory = new MessageList();
var contextManager = provider?.GetService<IChatContextManager>();
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<ILogger<AgentBase>>(),
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();
}
}
18 changes: 17 additions & 1 deletion native_tui/apps/NativeTui.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ private static async Task<int> 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;
Expand Down Expand Up @@ -87,6 +87,22 @@ private static async Task<int> RunSelftestTurnAsync() {
return 1;
}

/// <summary>等待当前回合终态(turn_completed/interrupted/error — 排空事件循环)。</summary>
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) {
}
}

/// <summary>交互模式 — TTY 检查 → UTF-8 → raw mode → 引擎组装(Console.Out 重定向 stderr 防引擎污染渲染)→ REPL 主循环。</summary>
private static async Task<int> RunInteractiveAsync() {
if (Console.IsInputRedirected || Console.IsOutputRedirected) {
Expand Down
62 changes: 47 additions & 15 deletions test/unit/host.tests/chat_commands/a_to_f/BtwCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,47 +78,47 @@ 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<IQueryEngine>(engine);
var cmd = new BtwCommand();
var chatService = new Mock<IChatService>();
chatService.Setup(c => c.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync("Test answer");

var context = new ChatCommandContext {
Arguments = "test question",
CancellationToken = CancellationToken.None,
Services = new CommandServiceProvider(new CommandServices {
ChatService = chatService.Object,
ChatService = Mock.Of<IChatService>(),
CodeService = Mock.Of<ICodeService>(),
PlanService = Mock.Of<IPlanService>(),
FileSystem = TestFileSystem.Current,
ServiceProvider = services.BuildServiceProvider(),
}),
};

var result = await cmd.ExecuteAsync(context).ConfigureAwait(true);

result.ShouldContinue.Should().BeTrue();
result.IsHandled.Should().BeTrue();
chatService.Verify(c => c.SendMessageAsync(
It.Is<string>(s => s.Contains("test question")),
It.IsAny<CancellationToken>()), Times.Once);
engine.QueryCallCount.Should().Be(1, "fork 路径走 IQueryEngine(对齐基准 runForkedAgent),不再走 ChatService.SendMessageAsync");
engine.CapturedPrompt.Should().Contain("test question");
}

/// <summary>fork 异常路径 — QueryEngine 抛异常时 HandleError 吞掉,命令仍 Continue 不中断主对话流。</summary>
[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<IChatService>();
chatService.Setup(c => c.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("chat error"));

var engine = new CaptureQueryEngine(throwOnQuery: true);
var services = new ServiceCollection();
services.AddSingleton<IQueryEngine>(engine);
var context = new ChatCommandContext {
Arguments = "test question",
CancellationToken = CancellationToken.None,
Services = new CommandServiceProvider(new CommandServices {
ChatService = chatService.Object,
ChatService = Mock.Of<IChatService>(),
CodeService = Mock.Of<ICodeService>(),
PlanService = Mock.Of<IPlanService>(),
FileSystem = TestFileSystem.Current,
ServiceProvider = services.BuildServiceProvider(),
}),
};

Expand All @@ -127,4 +127,36 @@ public async Task Execute_WhenSendMessageThrows_Should_Return_Continue() {
result.ShouldContinue.Should().BeTrue();
result.IsHandled.Should().BeTrue();
}

/// <summary>捕获型 QueryEngine 假体 — fork 调用计数与 prompt 捕获(IQueryEngine 最小面)。</summary>
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<string> ExecuteQueryAsync(string query, CancellationToken cancellationToken = default)
=> Task.FromResult(fixedResponse ?? string.Empty);

public async IAsyncEnumerable<QueryStreamChunk> 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<QueryStreamChunk> 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");
}
}