From 4191a5bc2e23290a0cf09777b5f82cd85baa29f3 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Fri, 23 Oct 2026 01:50:47 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20TASK065=20=E8=A1=A5=E6=B5=8B=E7=AC=AC?=
=?UTF-8?q?=E5=9B=9B=E6=B3=A2=E4=B8=89=E4=BB=A3=E7=90=86=E6=89=B9=E6=AC=A1?=
=?UTF-8?q?=EF=BC=88372=E6=B5=8B=E8=AF=95+PTL/type=E5=8F=8Cbug=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D+372=E5=91=BD=E4=BB=A4=E6=B8=85=E9=9B=B6=EF=BC=89=20|?=
=?UTF-8?q?=20=E5=86=B3=E7=AD=96:=20=E4=B8=89=E4=BB=A3=E7=90=86=E5=B9=B6?=
=?UTF-8?q?=E8=A1=8C=E2=80=94=E2=80=94A:=20CompactCommand=20PTL=20?=
=?UTF-8?q?=E9=87=8D=E8=AF=95=E8=80=97=E5=B0=BD=E5=BC=82=E5=B8=B8=E5=86=92?=
=?UTF-8?q?=E6=B3=A1=E4=BF=AE=E5=A4=8D=EF=BC=88catch=20=E4=BD=93=E5=86=85?=
=?UTF-8?q?=20break=20=E8=B5=B0=E6=9C=80=E7=BB=88=E5=A4=B1=E8=B4=A5?=
=?UTF-8?q?=E5=85=9C=E5=BA=95=EF=BC=8C=E5=BF=AB=E7=85=A7=E7=BF=BB=E8=BD=AC?=
=?UTF-8?q?=E4=B8=BA=E9=99=8D=E7=BA=A7=E6=96=AD=E8=A8=80=EF=BC=89+=20Memor?=
=?UTF-8?q?yCommand=20--type=20=E5=B0=8F=E5=86=99=E5=89=A5=E7=A6=BB?=
=?UTF-8?q?=E6=AE=8B=E7=95=99=EF=BC=88StripOptionToken=20OrdinalIgnoreCase?=
=?UTF-8?q?=20=E6=9C=89=E5=90=8D=E5=87=BD=E6=95=B0=EF=BC=89=EF=BC=8C34?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=BA=A2=E8=BD=AC=E7=BB=BF=EF=BC=9BB:=20agen?=
=?UTF-8?q?ts/brain=20=E5=9F=9F=2015=20=E5=91=BD=E4=BB=A4=20186=20?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88insights/falv/goal/ask=20=E5=85=A8?=
=?UTF-8?q?=20Moq=20=E9=9A=94=E7=A6=BB=EF=BC=89+3=20bug=20=E7=99=BB?=
=?UTF-8?q?=E8=AE=B0=EF=BC=88Proactive=20=E6=9C=8D=E5=8A=A1=E7=BC=BA?=
=?UTF-8?q?=E5=A4=B1=E8=AF=AF=E5=AF=BC=E6=8F=90=E7=A4=BA/Goal=20=E9=80=89?=
=?UTF-8?q?=E9=A1=B9=E4=BD=8D=E7=BD=AE=E5=8F=97=E9=99=90/Falv=20=E6=9E=9A?=
=?UTF-8?q?=E4=B8=BE=E5=90=8D=E5=9B=9E=E6=98=BE=EF=BC=89=EF=BC=9BC:=20guar?=
=?UTF-8?q?d/hands/system/transport=2021=20=E5=91=BD=E4=BB=A4=20186=20?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95+2=20bug=20=E7=99=BB=E8=AE=B0=EF=BC=88Vim=20s?=
=?UTF-8?q?tatus=20=E8=AF=AF=E5=88=87=E6=8D=A2/SandboxToggle=20=E6=95=B4?=
=?UTF-8?q?=E4=B8=B2=E5=B0=8F=E5=86=99=E5=8C=96=EF=BC=89+=E7=9B=AE?=
=?UTF-8?q?=E5=BD=95=E6=95=B4=E7=90=86=EF=BC=8834=20=E5=9F=BA=E7=BA=BF?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=A7=BB=20commands=5Fwave123/=20=E7=A0=B4?=
=?UTF-8?q?=20JCC10001=20=E4=B8=8A=E9=99=90=EF=BC=89=EF=BC=9B=E5=9F=BA?=
=?UTF-8?q?=E7=BA=BF=20composition=20608=E2=86=92981=EF=BC=8898=20?=
=?UTF-8?q?=E5=91=BD=E4=BB=A4=E4=BB=85=E5=89=A9=E5=9F=BA=E7=A1=80=E8=AE=BE?=
=?UTF-8?q?=E6=96=BD=E7=B1=BB=E6=97=A0=E6=B5=8B=E8=AF=95=EF=BC=89+?=
=?UTF-8?q?=E9=AB=98=E5=8E=8B=E5=9B=9B=E8=BF=9E=E5=85=A8=E7=BB=BF+cli=20?=
=?UTF-8?q?=E7=BC=96=E8=AF=91=200=20=E9=94=99=E8=AF=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../composition.tests/GlobalUsings.cs | 9 +
.../commands/AddDirCommandTest.cs | 164 ++++++++
.../commands/AdvisorCommandTest.cs | 136 +++++++
.../commands/AskClarifyCommandTest.cs | 160 ++++++++
.../commands/AssistantCommandTest.cs | 136 +++++++
.../commands/BriefCommandTest.cs | 241 +++++++++++
.../commands/BuddyCommandTest.cs | 93 +++++
.../commands/CodeIndexCommandTest.cs | 88 ++++
.../commands/ColorCommandTest.cs | 89 ++++
.../commands/CompactCommandTest.cs | 23 +-
.../commands/DesktopCommandTest.cs | 86 ++++
.../commands/DiagCommandTest.cs | 179 ++++++++
.../commands/ExtraUsageCommandTest.cs | 102 +++++
.../commands/FalvCommandTest.cs | 383 ++++++++++++++++++
.../commands/FeedbackCommandTest.cs | 79 ++++
.../commands/GoalCommandTest.cs | 365 +++++++++++++++++
.../commands/HeapdumpCommandTest.cs | 53 +++
.../commands/HistoryCommandTest.cs | 86 ++++
.../commands/InsightsCommandTest.cs | 245 +++++++++++
.../commands/MemoryCommandTest.cs | 24 +-
.../commands/OauthRefreshCommandTest.cs | 205 ++++++++++
.../commands/PeersCommandTest.cs | 76 ++++
.../commands/ProactiveCommandTest.cs | 151 +++++++
.../commands/RateLimitCommandTest.cs | 124 ++++++
.../commands/ReleaseNotesCommandTest.cs | 113 ++++++
.../commands/ReviewCommandTest.cs | 116 ++++++
.../commands/SamplingCommandTest.cs | 156 +++++++
.../commands/SandboxToggleCommandTest.cs | 266 ++++++++++++
.../commands/SecurityReviewCommandTest.cs | 135 ++++++
.../commands/SkillsCommandTest.cs | 157 +++++++
.../commands/StatuslineCommandTest.cs | 154 +++++++
.../commands/StickersCommandTest.cs | 66 +++
.../commands/SwitchCommandTest.cs | 154 +++++++
.../commands/TagCommandTest.cs | 185 +++++++++
.../commands/ThinkbackCommandTest.cs | 185 +++++++++
.../commands/ToolsCommandTest.cs | 120 ++++++
.../commands/VimCommandTest.cs | 138 +++++++
.../commands/VoiceCommandTest.cs | 156 +++++++
.../commands/WorkflowsCommandTest.cs | 240 +++++++++++
.../AgentsCommandTest.cs | 0
.../BtwCommandTests.cs | 0
.../CommandOutputCollection.cs | 0
.../CommandTestHarness.cs | 0
.../CommitCommandTests.cs | 0
.../ConfigCommandTest.cs | 0
.../ContextCommandTest.cs | 0
.../DebugLogCommandTest.cs | 0
.../DoctorCommandTest.cs | 0
.../EffortCommandTest.cs | 0
.../ExportCommandTest.cs | 0
.../FastCommandTest.cs | 0
.../ForkCommandTest.cs | 0
.../GitHubRepoNameValidatorTests.cs | 0
.../HelpCommandTest.cs | 0
.../HooksCommandTest.cs | 0
.../InstallGitHubAppCommandExeSmokeTests.cs | 0
.../InstallGitHubAppCommandInjectionTests.cs | 0
.../LoginCommandTest.cs | 0
.../LogoutCommandTest.cs | 0
.../McpCommandTest.cs | 0
.../ModelCommandTest.cs | 0
.../PermissionsCommandTest.cs | 0
.../PluginCommandTest.cs | 0
.../PrivacySettingsCommandTest.cs | 0
.../QuitCommandTest.cs | 0
.../ResetConfigCommandTest.cs | 0
.../StatsCommandTest.cs | 0
.../StatusCommandTest.cs | 0
.../ThemeCommandTest.cs | 0
.../TrustCommandTest.cs | 0
.../UpgradeCommandTest.cs | 0
.../VendorCommandTest.cs | 0
.../VersionCommandTest.cs | 0
.../slash/agents/agent/MemoryCommand.cs | 20 +-
.../slash/brain/session/CompactCommand.cs | 6 +-
75 files changed, 5652 insertions(+), 12 deletions(-)
create mode 100644 modules/composition/composition.tests/commands/AddDirCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/AdvisorCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/AskClarifyCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/AssistantCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/BriefCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/BuddyCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/CodeIndexCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/ColorCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/DesktopCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/DiagCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/ExtraUsageCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/FalvCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/FeedbackCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/GoalCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/HeapdumpCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/HistoryCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/InsightsCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/OauthRefreshCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/PeersCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/ProactiveCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/RateLimitCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/ReleaseNotesCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/ReviewCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/SamplingCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/SecurityReviewCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/SkillsCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/StatuslineCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/StickersCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/SwitchCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/TagCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/ThinkbackCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/ToolsCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/VimCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/VoiceCommandTest.cs
create mode 100644 modules/composition/composition.tests/commands/WorkflowsCommandTest.cs
rename modules/composition/composition.tests/{commands => commands_wave123}/AgentsCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/BtwCommandTests.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/CommandOutputCollection.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/CommandTestHarness.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/CommitCommandTests.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/ConfigCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/ContextCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/DebugLogCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/DoctorCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/EffortCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/ExportCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/FastCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/ForkCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/GitHubRepoNameValidatorTests.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/HelpCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/HooksCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/InstallGitHubAppCommandExeSmokeTests.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/InstallGitHubAppCommandInjectionTests.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/LoginCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/LogoutCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/McpCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/ModelCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/PermissionsCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/PluginCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/PrivacySettingsCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/QuitCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/ResetConfigCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/StatsCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/StatusCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/ThemeCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/TrustCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/UpgradeCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/VendorCommandTest.cs (100%)
rename modules/composition/composition.tests/{commands => commands_wave123}/VersionCommandTest.cs (100%)
diff --git a/modules/composition/composition.tests/GlobalUsings.cs b/modules/composition/composition.tests/GlobalUsings.cs
index f65ad08d1d..1ca7d8ef8c 100644
--- a/modules/composition/composition.tests/GlobalUsings.cs
+++ b/modules/composition/composition.tests/GlobalUsings.cs
@@ -109,9 +109,18 @@
global using Tools;
global using Tools.Handlers;
global using Xunit.Abstractions;
+global using JoinCode.Reasoning.Cone;
+global using JoinCode.Reasoning.Engine;
+global using JoinCode.Reasoning.Evidence;
+global using JoinCode.Reasoning.State;
+global using JoinCode.Abstractions.Security.Sandbox;
+global using JoinCode.Abstractions.Models.Voice;
+global using PluginContext = JoinCode.Abstractions.Entity.PluginContext;
+global using Infrastructure.Utils.Resilience;
global using AgentInfo = Core.Agents.Coordinator.AgentInfo;
global using AgentMsg = JoinCode.Abstractions.Models.Agent.CoordinatorMessage;
global using CompressionContentType = JoinCode.Abstractions.Interfaces.Context.ContentType;
+global using BudgetStatus = JoinCode.Reasoning.Engine.BudgetStatus;
global using CostTracker = Core.CostTracking.CostTracker;
global using IMcpClient = JoinCode.Abstractions.Mcp.Client.IMcpClient;
global using IToolRegistry = JoinCode.Abstractions.Tools.IToolRegistry;
diff --git a/modules/composition/composition.tests/commands/AddDirCommandTest.cs b/modules/composition/composition.tests/commands/AddDirCommandTest.cs
new file mode 100644
index 0000000000..894e0957fa
--- /dev/null
+++ b/modules/composition/composition.tests/commands/AddDirCommandTest.cs
@@ -0,0 +1,164 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /add-dir 命令单元测试 — 覆盖空参列目录/目录不存在/成功添加(信任联动+会话提示)/
+/// 重复添加/--remember 持久化(含重复幂等)/工作区服务缺失降级/已信任不重复信任
+/// IWorkspaceService 经 CommandServices 字段注入;ITrustFolderManager 与 IConfigurationService 经 DI 注入;
+/// 目录存在性经 InMemoryFileSystem 验证
+///
+[Collection("command-output")]
+public sealed class AddDirCommandTest {
+ private static (AddDirCommand Command, Mock Workspace, Mock Trust, Mock Config, ChatCommandContext Context, IFileSystem Fs) Arrange(
+ string arguments = "",
+ Mock? workspaceOverride = null,
+ bool withWorkspace = true,
+ Mock? trustOverride = null,
+ Mock? configOverride = null,
+ bool addReturns = true,
+ IReadOnlyList? additionalDirs = null) {
+ var workspace = workspaceOverride ?? new Mock();
+ workspace.Setup(w => w.AddDirectory(It.IsAny())).Returns(addReturns);
+ workspace.Setup(w => w.GetAdditionalDirectories()).Returns(additionalDirs ?? []);
+
+ var trust = trustOverride ?? new Mock();
+ if (trustOverride is null) {
+ trust.Setup(t => t.IsTrusted(It.IsAny())).Returns(false);
+ }
+
+ var config = configOverride ?? new Mock();
+ if (configOverride is null) {
+ config.Setup(c => c.GetAsync("permissions.additionalDirectories", It.IsAny()))
+ .ReturnsAsync((string?)null);
+ }
+
+ var fs = TestFileSystem.Current;
+ var services = CommandTestHarness.CreateServices(
+ fileSystem: fs,
+ workspaceService: withWorkspace ? workspace.Object : null,
+ configureDi: di => {
+ di.AddSingleton(trust.Object);
+ di.AddSingleton(config.Object);
+ });
+ var context = CommandTestHarness.CreateContext(services, arguments: arguments);
+ return (new AddDirCommand(), workspace, trust, config, context, fs);
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_ListsCurrentDirectories() {
+ var (cmd, _, _, _, context, _) = Arrange(arguments: "", additionalDirs: [@"C:\extra\a", @"C:\extra\b"]);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ result.IsHandled.Should().BeTrue();
+ output.Should().Contain("当前额外工作目录:");
+ output.Should().Contain(@"C:\extra\a");
+ output.Should().Contain(@"C:\extra\b");
+ }
+
+ [Fact]
+ public async Task Execute_MissingDirectory_ShowsErrorWithoutAdding() {
+ var (cmd, workspace, _, _, context, _) = Arrange(arguments: @"C:\no-such-dir");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("目录不存在: C:\\no-such-dir");
+ workspace.Verify(w => w.AddDirectory(It.IsAny()), Times.Never, "目录不存在时不应添加");
+ }
+
+ [Fact]
+ public async Task Execute_ExistingDirectory_AddsAndTrustsDirectory() {
+ var dir = Path.Combine(Path.GetTempPath(), "adddir-basic");
+ var (cmd, workspace, trust, _, context, fs) = Arrange(arguments: dir);
+ fs.CreateDirectory(dir);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ var fullPath = Path.GetFullPath(dir);
+ workspace.Verify(w => w.AddDirectory(fullPath), Times.Once, "应添加 GetFullPath 归一后的目录");
+ trust.Verify(t => t.Trust(fullPath), Times.Once, "添加成功时应联动信任该目录");
+ output.Should().Contain($"已添加工作目录: {fullPath} (仅本次会话)");
+ output.Should().Contain("/permissions workspace 管理工作区目录");
+ }
+
+ [Fact]
+ public async Task Execute_AlreadyAddedDirectory_ShowsExistsWithoutRetrust() {
+ var dir = Path.Combine(Path.GetTempPath(), "adddir-dup");
+ var (cmd, workspace, trust, _, context, fs) = Arrange(arguments: dir, addReturns: false);
+ fs.CreateDirectory(dir);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain($"目录已存在: {Path.GetFullPath(dir)}");
+ trust.Verify(t => t.Trust(It.IsAny()), Times.Never, "重复添加不应再次触发信任");
+ }
+
+ [Fact]
+ public async Task Execute_WithRemember_PersistsToLocalSettings() {
+ var dir = Path.Combine(Path.GetTempPath(), "adddir-remember");
+ var (cmd, _, _, config, context, fs) = Arrange(arguments: $"{dir} --remember");
+ fs.CreateDirectory(dir);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ var fullPath = Path.GetFullPath(dir);
+ config.Verify(c => c.SetAsync("permissions.additionalDirectories", fullPath, It.IsAny()),
+ Times.Once, "--remember 应把目录追加持久化到本地设置");
+ output.Should().Contain($"已添加工作目录: {fullPath} (已保存到本地设置)");
+ }
+
+ [Fact]
+ public async Task Execute_RememberAlreadyPersisted_DoesNotDuplicate() {
+ var dir = Path.Combine(Path.GetTempPath(), "adddir-remember-dup");
+ var fullPath = Path.GetFullPath(dir);
+ var config = new Mock();
+ config.Setup(c => c.GetAsync("permissions.additionalDirectories", It.IsAny()))
+ .ReturnsAsync(fullPath);
+ var (cmd, _, _, _, context, fs) = Arrange(arguments: $"{dir} --remember", configOverride: config);
+ fs.CreateDirectory(dir);
+
+ var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ config.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never, "目录已在持久化列表时不应重复写入相同目录");
+ }
+
+ [Fact]
+ public async Task Execute_WithoutWorkspaceService_DoesNotThrow() {
+ var dir = Path.Combine(Path.GetTempPath(), "adddir-nows");
+ var (cmd, workspace, _, _, context, fs) = Arrange(arguments: dir, withWorkspace: false);
+ fs.CreateDirectory(dir);
+
+ var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("工作区服务缺失应优雅降级而非抛异常");
+ workspace.Verify(w => w.AddDirectory(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Execute_TrustedDirectory_DoesNotRetrust() {
+ var dir = Path.Combine(Path.GetTempPath(), "adddir-trusted");
+ var trust = new Mock();
+ trust.Setup(t => t.IsTrusted(It.IsAny())).Returns(true);
+ var (cmd, _, trustMock, _, context, fs) = Arrange(arguments: dir, trustOverride: trust);
+ fs.CreateDirectory(dir);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ trustMock.Verify(t => t.Trust(It.IsAny()), Times.Never, "目录已信任时不应重复调用 Trust");
+ output.Should().Contain("已添加工作目录:", "已信任不影响目录添加主路径");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new AddDirCommand();
+ cmd.Name.Should().Be("add-dir");
+ cmd.Usage.Should().Be("/add-dir [--remember]");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/AdvisorCommandTest.cs b/modules/composition/composition.tests/commands/AdvisorCommandTest.cs
new file mode 100644
index 0000000000..5b53afb387
--- /dev/null
+++ b/modules/composition/composition.tests/commands/AdvisorCommandTest.cs
@@ -0,0 +1,136 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /advisor 命令单元测试 — 覆盖空参状态查询(启用/禁用)/off 与 unset 关闭/
+/// 有效模型设置/未知模型警告仍设置/服务缺失静默/元数据一致性
+/// AdvisorService 用 Moq 桩经 DI 注入;ModelConfigLoader 用 Moq 桩构造注入
+///
+[Collection("command-output")]
+public sealed class AdvisorCommandTest {
+ private static Mock LoaderWithModels(params string[] modelIds) {
+ var loader = new Mock();
+ loader.Setup(l => l.GetAllModelIds()).Returns(modelIds);
+ return loader;
+ }
+
+ private static (AdvisorCommand Command, ChatCommandContext Context, Mock Service) Arrange(
+ string arguments = "",
+ Mock? service = null,
+ Mock? loader = null) {
+ var advisorService = service ?? new Mock();
+ var services = CommandTestHarness.CreateServices(
+ configureDi: di => di.AddSingleton(advisorService.Object));
+ var context = CommandTestHarness.CreateContext(services, arguments: arguments);
+ return (new AdvisorCommand(loader?.Object), context, advisorService);
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_AdvisorEnabled_ShowsModelAndReviewHint() {
+ var service = new Mock();
+ service.Setup(s => s.GetIsAdvisorEnabledAsync()).ReturnsAsync(true);
+ service.Setup(s => s.GetAdvisorModelAsync()).ReturnsAsync("gpt-4o");
+ var (cmd, context, _) = Arrange(service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("顾问模型: gpt-4o");
+ output.Should().Contain("顾问将审查主模型的工作");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_AdvisorDisabled_ShowsEnableHint() {
+ var service = new Mock();
+ service.Setup(s => s.GetIsAdvisorEnabledAsync()).ReturnsAsync(false);
+ var (cmd, context, _) = Arrange(service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("顾问模式:未启用");
+ output.Should().Contain("使用 /advisor 启用顾问", "禁用状态应引导用户启用方式");
+ }
+
+ [Theory]
+ [InlineData("off")]
+ [InlineData("unset")]
+ [InlineData("OFF")]
+ [InlineData("Unset")]
+ public async Task Execute_OffOrUnset_ClearsAdvisorModel(string arguments) {
+ var service = new Mock();
+ var (cmd, context, _) = Arrange(arguments: arguments, service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ service.Verify(s => s.ClearAdvisorModel(), Times.Once, $"{arguments} 应关闭顾问模式(大小写不敏感)");
+ output.Should().Contain("顾问模式:已禁用");
+ }
+
+ [Fact]
+ public async Task Execute_KnownModel_SetsAdvisorModelDirectly() {
+ var service = new Mock();
+ var loader = LoaderWithModels("gpt-4o", "claude-3");
+ var (cmd, context, _) = Arrange(arguments: "claude-3", service: service, loader: loader);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ service.Verify(s => s.SetAdvisorModel("claude-3"), Times.Once, "已知模型应直接设置");
+ output.Should().Contain("顾问模型: claude-3");
+ output.Should().Contain("顾问将审查主模型的工作");
+ output.Should().NotContain("已知支持模型:", "已知模型不应触发支持列表警告");
+ }
+
+ [Fact]
+ public async Task Execute_UnknownModel_WarnsButStillSets() {
+ var service = new Mock();
+ var loader = LoaderWithModels("gpt-4o", "claude-3");
+ var (cmd, context, _) = Arrange(arguments: "llama-x", service: service, loader: loader);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("模型 'llama-x' 可能不支持顾问功能", "未知模型应给出警告");
+ output.Should().Contain("已知支持模型:");
+ output.Should().Contain(" gpt-4o");
+ output.Should().Contain(" claude-3", "警告应列出全部已知模型辅助选择");
+ output.Should().Contain("确定设置?输入 /advisor 确认");
+ service.Verify(s => s.SetAdvisorModel("llama-x"), Times.Once,
+ "未知模型仍执行设置(警告不拦截,交由用户确认)");
+ }
+
+ [Fact]
+ public async Task Execute_UnknownModel_WithoutLoader_StillSetsModel() {
+ var service = new Mock();
+ var (cmd, context, _) = Arrange(arguments: "any-model", service: service, loader: null);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ service.Verify(s => s.SetAdvisorModel("any-model"), Times.Once,
+ "模型列表加载器缺失(空列表)时仍应设置模型");
+ output.Should().Contain("顾问模型: any-model");
+ }
+
+ [Fact]
+ public async Task Execute_WithoutService_ReturnsSilently() {
+ var services = CommandTestHarness.CreateServices();
+ var context = CommandTestHarness.CreateContext(services, arguments: "gpt-4o");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(
+ () => new AdvisorCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("服务缺失应静默返回而非崩溃");
+ output.Should().NotContain("顾问模型:", "无服务时不应渲染顾问状态");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new AdvisorCommand();
+ cmd.Name.Should().Be("advisor");
+ cmd.Usage.Should().Be("/advisor [model|off]");
+ cmd.Description.Should().Contain("顾问模型");
+ cmd.ArgumentHint.Should().Be("[model|off]");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/AskClarifyCommandTest.cs b/modules/composition/composition.tests/commands/AskClarifyCommandTest.cs
new file mode 100644
index 0000000000..2e4ecf86c4
--- /dev/null
+++ b/modules/composition/composition.tests/commands/AskClarifyCommandTest.cs
@@ -0,0 +1,160 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /?? (别名 /ask) 命令单元测试 — 覆盖带需求主路径(系统提示词前缀+响应含完成标记时退出循环)/
+/// ChatService 异常收敛/退出命令识别纯函数/元数据一致性
+/// 命令内部将 TerminalHelper 输出切到真实 stdout(绕过测试捕获),故断言以
+/// ChatService 调用形态(Verify)与结果语义为主,输出文本不作强断言;
+/// ChatService 用 Moq 桩自建 CommandServices 注入,不发真实请求
+///
+[Collection("command-output")]
+public sealed class AskClarifyCommandTest {
+ /// 把事件序列包装为异步事件流 — 模拟 LLM 流式响应
+ private static async IAsyncEnumerable EventStream(
+ IReadOnlyList events,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) {
+ foreach (var evt in events) {
+ ct.ThrowIfCancellationRequested();
+ await Task.Yield();
+ yield return evt;
+ }
+ }
+
+ private static (AskClarifyCommand Command, ChatCommandContext Context, Mock Chat) Arrange(
+ string arguments = "做一个 todo 应用") {
+ var chatService = new Mock();
+ chatService.Setup(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()))
+ .Returns((string message, CancellationToken ct) => EventStream(
+ new[] {
+ ChatStreamEvent.Text("【需求已明确】\n需求清单: 1. 增删改查 2. 本地存储"),
+ }, ct));
+ var commandServices = new CommandServices {
+ ChatService = chatService.Object,
+ CodeService = Mock.Of(),
+ PlanService = Mock.Of(),
+ FileSystem = TestFileSystem.Current,
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ };
+ var context = new ChatCommandContext {
+ Arguments = arguments,
+ SessionId = "test-session",
+ CancellationToken = CancellationToken.None,
+ Services = new CommandServiceProvider(commandServices),
+ };
+ return (new AskClarifyCommand(), context, chatService);
+ }
+
+ [Fact]
+ public async Task Execute_WithRequirement_SendsSystemPromptAndUserNeed() {
+ var (cmd, context, chat) = Arrange("做一个 todo 应用");
+
+ var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ chat.Verify(c => c.StreamWithEventsAsync(
+ It.Is(msg =>
+ msg.StartsWith(AskClarifyPrompts.SystemPrompt, StringComparison.Ordinal)
+ && msg.Contains("---")
+ && msg.Contains("用户需求: 做一个 todo 应用")),
+ It.IsAny()), Times.Once,
+ "首轮消息应为系统提示词 + 分隔线 + 用户需求形态");
+ }
+
+ [Fact]
+ public async Task Execute_ResponseContainsDoneMarker_ExitsLoopAfterSingleRound() {
+ var (cmd, context, chat) = Arrange();
+
+ var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ chat.Verify(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()), Times.Once,
+ "响应含【需求已明确】标记应直接退出澄清循环,只发起一轮请求");
+ }
+
+ [Fact]
+ public async Task Execute_DoneMarkerIsCaseInsensitive_AlsoExits() {
+ var chat = new Mock();
+ chat.Setup(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()))
+ .Returns((string message, CancellationToken ct) => EventStream(
+ new[] { ChatStreamEvent.Text("【需求已明确】") }, ct));
+ var commandServices = new CommandServices {
+ ChatService = chat.Object,
+ CodeService = Mock.Of(),
+ PlanService = Mock.Of(),
+ FileSystem = TestFileSystem.Current,
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ };
+ var context = new ChatCommandContext {
+ Arguments = "需求",
+ SessionId = "test-session",
+ CancellationToken = CancellationToken.None,
+ Services = new CommandServiceProvider(commandServices),
+ };
+
+ var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => new AskClarifyCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ chat.Verify(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()), Times.Once,
+ "完成标记匹配应大小写不敏感并退出循环");
+ }
+
+ [Fact]
+ public async Task Execute_ChatServiceThrows_ConvergesWithoutCrash() {
+ var chat = new Mock();
+ chat.Setup(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()))
+ .Throws(new InvalidOperationException("provider unavailable"));
+ var commandServices = new CommandServices {
+ ChatService = chat.Object,
+ CodeService = Mock.Of(),
+ PlanService = Mock.Of(),
+ FileSystem = TestFileSystem.Current,
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ };
+ var context = new ChatCommandContext {
+ Arguments = "做一个 todo 应用",
+ SessionId = "test-session",
+ CancellationToken = CancellationToken.None,
+ Services = new CommandServiceProvider(commandServices),
+ };
+
+ var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => new AskClarifyCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("ChatService 异常应被命令内部捕获收敛,不应向上抛出");
+ chat.Verify(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Theory]
+ [InlineData("/end")]
+ [InlineData("/done")]
+ [InlineData("/exit")]
+ [InlineData("/quit")]
+ [InlineData(" /END ")]
+ public void IsExitCommand_RecognizesExitCommands(string input) {
+ AskClarifyCommand.IsExitCommand(input).Should().BeTrue(
+ $"'{input}' 应识别为退出命令(大小写不敏感+两端空白容忍)");
+ }
+
+ [Theory]
+ [InlineData("/endd")]
+ [InlineData("hello")]
+ [InlineData("")]
+ public void IsExitCommand_RejectsNonExitInput(string input) {
+ AskClarifyCommand.IsExitCommand(input).Should().BeFalse($"'{input}' 不是退出命令");
+ }
+
+ [Fact]
+ public void ClarifyDoneMarker_ContractValue() {
+ AskClarifyCommand.ClarifyDoneMarker.Should().Be("【需求已明确】",
+ "完成标记是 LLM 与宿主的协议契约,变更需两侧同步");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new AskClarifyCommand();
+ cmd.Name.Should().Be("??");
+ cmd.Aliases.Should().Contain("ask", "ask 为 /?? 的别名");
+ cmd.Usage.Should().Contain("/??").And.Contain("/ask");
+ cmd.Description.Should().Contain("需求澄清");
+ cmd.ArgumentHint.Should().Be("[需求描述]");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/AssistantCommandTest.cs b/modules/composition/composition.tests/commands/AssistantCommandTest.cs
new file mode 100644
index 0000000000..36f1bb089c
--- /dev/null
+++ b/modules/composition/composition.tests/commands/AssistantCommandTest.cs
@@ -0,0 +1,136 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /assistant 命令单元测试 — 覆盖 on/1 启用(环境变量写入+持久化提示)/off/0 停用/
+/// status 状态查询(启用/停用)/未识别参数回退状态/服务缺失静默/元数据一致性
+/// AssistantModeService 用 Moq 桩经 DI 注入;环境变量写入后 finally 恢复避免进程污染
+///
+[Collection("command-output")]
+public sealed class AssistantCommandTest {
+ private const string EnvVarName = "JCC_ASSISTANT_MODE";
+
+ private static (AssistantCommand Command, ChatCommandContext Context) Arrange(
+ string arguments = "",
+ Mock? service = null) {
+ var services = CommandTestHarness.CreateServices(
+ configureDi: di => {
+ if (service is not null) di.AddSingleton(service.Object);
+ });
+ var context = CommandTestHarness.CreateContext(services, arguments: arguments);
+ return (new AssistantCommand(), context);
+ }
+
+ /// 环境变量作用域 — 写入并在退出时恢复原值,避免污染并行测试进程
+ private static IDisposable EnvScope(string value) {
+ var original = Environment.GetEnvironmentVariable(EnvVarName);
+ Environment.SetEnvironmentVariable(EnvVarName, value);
+ return new EnvRestore(EnvVarName, original);
+ }
+
+ private sealed class EnvRestore(string name, string? original) : IDisposable {
+ public void Dispose() => Environment.SetEnvironmentVariable(name, original);
+ }
+
+ [Theory]
+ [InlineData("on")]
+ [InlineData("1")]
+ public async Task Execute_On_SetsEnvVarAndShowsEnabled(string arguments) {
+ using var _ = EnvScope("0");
+ var (cmd, context) = Arrange(arguments: arguments);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ Environment.GetEnvironmentVariable(EnvVarName).Should().Be("1",
+ $"{arguments} 应写入 {EnvVarName}=1");
+ output.Should().Contain("长期助手模式已启用");
+ output.Should().Contain($"环境变量 {EnvVarName}=1 已设置", "启用提示应说明环境变量生效范围");
+ output.Should().Contain("如需持久化,请在系统环境变量中设置", "应提示持久化方式");
+ }
+
+ [Theory]
+ [InlineData("off")]
+ [InlineData("0")]
+ public async Task Execute_Off_ClearsEnvVarAndShowsDisabled(string arguments) {
+ using var _ = EnvScope("1");
+ var (cmd, context) = Arrange(arguments: arguments);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ Environment.GetEnvironmentVariable(EnvVarName).Should().Be("0",
+ $"{arguments} 应写入 {EnvVarName}=0");
+ output.Should().Contain("长期助手模式已停用");
+ }
+
+ [Fact]
+ public async Task Execute_Status_ServiceEnabled_ShowsEnabledState() {
+ using var _ = EnvScope("1");
+ var service = new Mock();
+ service.Setup(s => s.IsAssistantModeEnabled).Returns(true);
+ var (cmd, context) = Arrange(arguments: "status", service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("长期助手模式:");
+ output.Should().Contain("状态:已启用");
+ output.Should().Contain($"环境变量:{EnvVarName}=1", "状态应回显环境变量当前取值");
+ }
+
+ [Fact]
+ public async Task Execute_Status_ServiceDisabled_ShowsDisabledState() {
+ var service = new Mock();
+ service.Setup(s => s.IsAssistantModeEnabled).Returns(false);
+ var (cmd, context) = Arrange(arguments: "status", service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("状态:已停用");
+ output.Should().Contain("环境变量:JCC_ASSISTANT_MODE=(未设置)", "环境变量未设置时应显示占位文本");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_DefaultsToStatus() {
+ var service = new Mock();
+ service.Setup(s => s.IsAssistantModeEnabled).Returns(true);
+ var (cmd, context) = Arrange(arguments: "", service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("长期助手模式:", "空参默认等同 status(NullAction=Status)");
+ }
+
+ [Fact]
+ public async Task Execute_UnrecognizedArg_FallsBackToStatus() {
+ var service = new Mock();
+ service.Setup(s => s.IsAssistantModeEnabled).Returns(false);
+ var (cmd, context) = Arrange(arguments: "bogus", service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("长期助手模式:", "未识别参数应回退显示状态而非报错");
+ }
+
+ [Fact]
+ public async Task Execute_WithoutService_StatusPrintsNothingButContinues() {
+ var (cmd, context) = Arrange(arguments: "status", service: null);
+
+ var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("服务缺失时状态打印跳过,命令不应崩溃");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new AssistantCommand();
+ cmd.Name.Should().Be("assistant");
+ cmd.Usage.Should().Be("/assistant [on|off|status]");
+ cmd.Description.Should().Contain("长期助手模式");
+ cmd.IsHidden.Should().BeTrue("assistant 为隐藏命令");
+ cmd.ArgumentHint.Should().Be("[on|off|status]");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/BriefCommandTest.cs b/modules/composition/composition.tests/commands/BriefCommandTest.cs
new file mode 100644
index 0000000000..ff4d3e0997
--- /dev/null
+++ b/modules/composition/composition.tests/commands/BriefCommandTest.cs
@@ -0,0 +1,241 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /brief 命令单元测试 — 覆盖 on/off/toggle 三分支(BriefModeService 状态迁移+状态打印)/
+/// 权限拦截(无 entitlement 不启用)/服务缺失静默/状态变化注入 SystemReminder/
+/// IsEnabled 环境变量覆盖语义/元数据一致性
+/// BriefModeService 用 Moq 桩(闭包状态模拟 Enable/Disable/Toggle 副作用)经
+/// CommandServices.BriefModeService 注入;EntitlementService 经 DI 注入;
+/// SystemReminderManager 用真实实例(AddReminderAsync 非 virtual 无法 Moq)验证提醒副作用
+///
+[Collection("command-output")]
+public sealed class BriefCommandTest {
+ private const string EnvVarName = "JCC_BRIEF";
+
+ /// 带闭包状态的 BriefModeService 桩 — Enable/Disable/Toggle 真实迁移 IsEnabled
+ private sealed class StatefulBriefService {
+ public bool Enabled;
+ public DateTime? EnabledAt;
+
+ public Mock ToMock() {
+ var mock = new Mock();
+ mock.Setup(s => s.IsEnabled).Returns(() => Enabled);
+ mock.Setup(s => s.EnabledAt).Returns(() => EnabledAt);
+ mock.Setup(s => s.Enable()).Callback(() => { Enabled = true; EnabledAt = DateTime.UtcNow; });
+ mock.Setup(s => s.Disable()).Callback(() => Enabled = false);
+ mock.Setup(s => s.Toggle()).Callback(() => {
+ Enabled = !Enabled;
+ EnabledAt = Enabled ? DateTime.UtcNow : null;
+ }).Returns(() => Enabled);
+ return mock;
+ }
+ }
+
+ private static (BriefCommand Command, ChatCommandContext Context, StatefulBriefService State) Arrange(
+ string arguments = "",
+ IBriefModeService? briefService = null,
+ IEntitlementService? entitlement = null,
+ SystemReminderManager? reminderManager = null) {
+ var state = new StatefulBriefService();
+ var di = new ServiceCollection();
+ if (entitlement is not null) di.AddSingleton(entitlement);
+ if (reminderManager is not null) di.AddSingleton(reminderManager);
+ var commandServices = new CommandServices {
+ ChatService = Mock.Of(),
+ CodeService = Mock.Of(),
+ PlanService = Mock.Of(),
+ FileSystem = TestFileSystem.Current,
+ ServiceProvider = di.BuildServiceProvider(),
+ BriefModeService = briefService ?? state.ToMock().Object,
+ };
+ var context = new ChatCommandContext {
+ Arguments = arguments,
+ SessionId = "test-session",
+ CancellationToken = CancellationToken.None,
+ Services = new CommandServiceProvider(commandServices),
+ };
+ return (new BriefCommand(), context, state);
+ }
+
+ private static IEntitlementService Entitlement(bool briefEntitled) {
+ var mock = new Mock();
+ mock.Setup(e => e.IsBriefEntitled).Returns(briefEntitled);
+ return mock.Object;
+ }
+
+ [Fact]
+ public async Task Execute_On_EnablesBriefModeAndPrintsStatus() {
+ var (cmd, context, state) = Arrange(arguments: "on");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ state.Enabled.Should().BeTrue("on 应调用 Enable 启用简要模式");
+ output.Should().Contain("简要消息模式已启用");
+ output.Should().Contain("LLM 将通过 send_user_message 工具回复用户",
+ "启用状态应说明回复通道切换");
+ }
+
+ [Fact]
+ public async Task Execute_On_EnabledWithTimestamp_ShowsEnabledAt() {
+ var fixedAt = new DateTime(2026, 1, 15, 10, 30, 0);
+ var mock = new Mock();
+ mock.Setup(s => s.IsEnabled).Returns(true);
+ mock.Setup(s => s.EnabledAt).Returns(fixedAt);
+ var (cmd, context, _) = Arrange(arguments: "on", briefService: mock.Object);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("启用时间: 2026-01-15 10:30:00", "已启用且有时间时应渲染启用时间");
+ }
+
+ [Fact]
+ public async Task Execute_On_WithoutEntitlement_BlockedWithMutedHint() {
+ var mock = new Mock();
+ mock.Setup(s => s.IsEnabled).Returns(false);
+ var (cmd, context, _) = Arrange(
+ arguments: "on",
+ briefService: mock.Object,
+ entitlement: Entitlement(briefEntitled: false));
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("简要模式未启用 — 当前账户无权限", "无权限时应拦截并说明原因");
+ mock.Verify(s => s.Enable(), Times.Never, "无权限时不应启用");
+ }
+
+ [Fact]
+ public async Task Execute_Off_DisablesBriefModeAndPrintsStatus() {
+ var (cmd, context, state) = Arrange(arguments: "off");
+ state.Enabled = true;
+ state.EnabledAt = DateTime.UtcNow;
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ state.Enabled.Should().BeFalse("off 应调用 Disable 停用简要模式");
+ output.Should().Contain("简要消息模式已禁用 - LLM 将使用普通文本回复");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_TogglesBriefMode() {
+ var (cmd, context, state) = Arrange(arguments: "");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ state.Enabled.Should().BeTrue("空参走 Toggle 分支(NullAction 默认 Toggle),false→true");
+ output.Should().Contain("简要消息模式已启用");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_DisabledWithoutEntitlement_Blocked() {
+ var mock = new Mock();
+ mock.Setup(s => s.IsEnabled).Returns(false);
+ var (cmd, context, _) = Arrange(
+ arguments: "",
+ briefService: mock.Object,
+ entitlement: Entitlement(briefEntitled: false));
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("简要模式未启用 — 当前账户无权限", "toggle 自关闭态开启同样受权限门控");
+ mock.Verify(s => s.Toggle(), Times.Never, "无权限时 toggle 不应执行");
+ }
+
+ [Fact]
+ public async Task Execute_StateChange_InjectsBriefStateReminder() {
+ await using var reminderManager = new SystemReminderManager();
+ var (cmd, context, _) = Arrange(arguments: "on", reminderManager: reminderManager);
+
+ await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ var reminders = await reminderManager.GetRemindersAsync();
+ reminders.Should().ContainSingle("状态变化应注入一条 brief-mode-state 提醒");
+ var reminder = reminders[0];
+ reminder.Id.Should().Be("brief-mode-state");
+ reminder.Content.Should().Contain("Brief mode is now enabled");
+ reminder.Content.Should().Contain("send_user_message", "提醒应告知 LLM 使用 SendUserMessage 工具");
+ reminder.Priority.Should().Be(10);
+ }
+
+ [Fact]
+ public async Task Execute_Disable_InjectsDisabledReminder() {
+ await using var reminderManager = new SystemReminderManager();
+ var (cmd, context, state) = Arrange(arguments: "off", reminderManager: reminderManager);
+ state.Enabled = true;
+
+ await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ var reminders = await reminderManager.GetRemindersAsync();
+ reminders.Should().ContainSingle("停用状态变化应注入提醒");
+ reminders[0].Content.Should().Contain("Brief mode is now disabled");
+ reminders[0].Content.Should().Contain("no longer available");
+ }
+
+ [Fact]
+ public async Task Execute_NoStateChange_NoReminderInjected() {
+ await using var reminderManager = new SystemReminderManager();
+ var mock = new Mock();
+ mock.Setup(s => s.IsEnabled).Returns(true); // 已启用,on 无状态变化
+ mock.Setup(s => s.Enable()).Callback(() => { });
+ var (cmd, context, _) = Arrange(arguments: "on", briefService: mock.Object, reminderManager: reminderManager);
+
+ await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ var reminders = await reminderManager.GetRemindersAsync();
+ reminders.Should().BeEmpty("状态未变化时不应重复注入提醒");
+ }
+
+ [Fact]
+ public async Task Execute_WithoutBriefService_ReturnsSilently() {
+ var commandServices = new CommandServices {
+ ChatService = Mock.Of(),
+ CodeService = Mock.Of(),
+ PlanService = Mock.Of(),
+ FileSystem = TestFileSystem.Current,
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ };
+ var context = new ChatCommandContext {
+ Arguments = "on",
+ SessionId = "test-session",
+ CancellationToken = CancellationToken.None,
+ Services = new CommandServiceProvider(commandServices),
+ };
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(
+ () => new BriefCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("服务缺失应静默返回而非崩溃");
+ output.Should().NotContain("简要消息模式已启用");
+ }
+
+ [Theory]
+ [InlineData("0", false)]
+ [InlineData("false", false)]
+ [InlineData("FALSE", false)]
+ [InlineData("1", true)]
+ [InlineData("true", true)]
+ [InlineData("", true)]
+ public void IsEnabled_EnvVarOverridesDefaultState(string envValue, bool expected) {
+ var original = Environment.GetEnvironmentVariable(EnvVarName);
+ try {
+ Environment.SetEnvironmentVariable(EnvVarName, envValue.Length == 0 ? null : envValue);
+ new BriefCommand().IsEnabled.Should().Be(expected,
+ $"环境变量 {EnvVarName}='{envValue}' 应得到 IsEnabled={expected}(非 0/false 即启用,未设置默认启用)");
+ } finally {
+ Environment.SetEnvironmentVariable(EnvVarName, original);
+ }
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new BriefCommand();
+ cmd.Name.Should().Be("brief");
+ cmd.Usage.Should().Be("/brief [on|off]");
+ cmd.Description.Should().Contain("简要消息模式");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/BuddyCommandTest.cs b/modules/composition/composition.tests/commands/BuddyCommandTest.cs
new file mode 100644
index 0000000000..65514a74d1
--- /dev/null
+++ b/modules/composition/composition.tests/commands/BuddyCommandTest.cs
@@ -0,0 +1,93 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /buddy 命令单元测试 — 覆盖主路径渲染(ASCII/名称/稀有度/眼睛帽子)/闪光与空帽子形态/
+/// 服务缺失静默降级/元数据一致性
+/// BuddyService 用 Moq 桩经 DI 注入(GetService 解析)
+///
+[Collection("command-output")]
+public sealed class BuddyCommandTest {
+ private static BuddyInfo Buddy(
+ bool shiny = false,
+ string? hat = "wizard") => new() {
+ Name = "Pikachu",
+ Species = "electric-mouse",
+ Rarity = BuddyRarity.Rare,
+ Eye = "happy",
+ Hat = hat ?? string.Empty,
+ Shiny = shiny,
+ AsciiArt = @" /\_/\
+ ( o.o )",
+ };
+
+ private static (BuddyCommand Command, ChatCommandContext Context, Mock Service) Arrange(
+ Mock? service = null,
+ string sessionId = "test-session") {
+ var buddyService = service ?? new Mock();
+ var services = CommandTestHarness.CreateServices(
+ configureDi: di => di.AddSingleton(buddyService.Object));
+ var context = CommandTestHarness.CreateContext(services, sessionId: sessionId);
+ return (new BuddyCommand(), context, buddyService);
+ }
+
+ [Fact]
+ public async Task Execute_WithBuddy_RendersAsciiArtAndDetails() {
+ var service = new Mock();
+ service.Setup(s => s.GetBuddy("test-session")).Returns(Buddy());
+ var (cmd, context, _) = Arrange(service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain(@" /\_/\", "ASCII 艺术图应原样渲染");
+ output.Should().Contain(" Pikachu (electric-mouse)", "名称与物种应成对渲染");
+ output.Should().Contain(" 稀有度: Rare");
+ output.Should().Contain(" 眼睛: happy 帽子: wizard", "眼睛与帽子应在同一行渲染");
+ service.Verify(s => s.GetBuddy("test-session"), Times.Once, "应按会话 ID 查询伙伴");
+ }
+
+ [Fact]
+ public async Task Execute_ShinyBuddy_MarksShinyBadge() {
+ var service = new Mock();
+ service.Setup(s => s.GetBuddy(It.IsAny())).Returns(Buddy(shiny: true));
+ var (cmd, context, _) = Arrange(service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("稀有度: Rare ✨闪亮!", "闪光形态应在稀有度后追加徽记");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyHat_ShowsNoneLabel() {
+ var service = new Mock();
+ service.Setup(s => s.GetBuddy(It.IsAny())).Returns(Buddy(hat: null));
+ var (cmd, context, _) = Arrange(service: service);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("帽子: 无", "空帽子应显示占位'无'而非空白");
+ }
+
+ [Fact]
+ public async Task Execute_WithoutService_ReturnsSilently() {
+ var services = CommandTestHarness.CreateServices();
+ var context = CommandTestHarness.CreateContext(services);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(
+ () => new BuddyCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("服务缺失应静默返回继续会话而非崩溃");
+ output.Should().NotContain("Pikachu", "无服务时不应渲染任何伙伴信息");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new BuddyCommand();
+ cmd.Name.Should().Be("buddy");
+ cmd.Usage.Should().Be("/buddy");
+ cmd.Description.Should().Contain("伙伴精灵");
+ cmd.IsHidden.Should().BeTrue("buddy 为隐藏命令");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/CodeIndexCommandTest.cs b/modules/composition/composition.tests/commands/CodeIndexCommandTest.cs
new file mode 100644
index 0000000000..e27c178372
--- /dev/null
+++ b/modules/composition/composition.tests/commands/CodeIndexCommandTest.cs
@@ -0,0 +1,88 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /code-index 命令单元测试 — 覆盖索引器未注册降级/重建成功(统计渲染)/重建异常降级
+/// ICodeIndexer 经 DI 注入;统计经 IndexStats 桩值断言渲染不变量
+///
+[Collection("command-output")]
+public sealed class CodeIndexCommandTest {
+ private static IndexStats CreateStats() => new() {
+ FileCount = 12,
+ SymbolCount = 340,
+ CallEdgeCount = 56,
+ DependencyEdgeCount = 78,
+ ProjectCount = 3,
+ LastUpdated = DateTimeOffset.UtcNow,
+ };
+
+ private static (CodeIndexCommand Command, Mock Indexer, ChatCommandContext Context) Arrange(
+ bool withIndexer = true,
+ IndexStats? stats = null,
+ Exception? rebuildError = null) {
+ var indexer = new Mock();
+ if (rebuildError is not null) {
+ indexer.Setup(i => i.RebuildIndexAsync(It.IsAny())).ThrowsAsync(rebuildError);
+ } else {
+ indexer.Setup(i => i.RebuildIndexAsync(It.IsAny())).Returns(Task.CompletedTask);
+ }
+ indexer.Setup(i => i.GetStatsAsync(It.IsAny())).ReturnsAsync(stats ?? CreateStats());
+
+ var services = CommandTestHarness.CreateServices(
+ configureDi: di => {
+ if (withIndexer) di.AddSingleton(indexer.Object);
+ });
+ var context = CommandTestHarness.CreateContext(services);
+ return (new CodeIndexCommand(), indexer, context);
+ }
+
+ [Fact]
+ public async Task Execute_WithoutIndexer_ShowsNotRegistered() {
+ var (cmd, indexer, context) = Arrange(withIndexer: false);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ result.IsHandled.Should().BeTrue();
+ output.Should().Contain("代码索引服务未注册,无法构建索引");
+ indexer.Verify(i => i.RebuildIndexAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Execute_WithIndexer_RebuildsAndRendersStats() {
+ var (cmd, indexer, context) = Arrange();
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ indexer.Verify(i => i.RebuildIndexAsync(It.IsAny()), Times.Once,
+ "应显式触发索引重建");
+ output.Should().Contain("正在构建代码索引...");
+ output.Should().Contain("代码索引构建完成");
+ output.Should().Contain("文件数: 12");
+ output.Should().Contain("符号数: 340");
+ output.Should().Contain("调用边: 56");
+ output.Should().Contain("依赖边: 78");
+ output.Should().Contain("项目数: 3");
+ }
+
+ [Fact]
+ public async Task Execute_RebuildThrows_ShowsFailureWithExceptionMessage() {
+ var (cmd, indexer, context) = Arrange(rebuildError: new InvalidOperationException("parse failed"));
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("代码索引构建失败");
+ output.Should().Contain("parse failed");
+ indexer.Verify(i => i.GetStatsAsync(It.IsAny()), Times.Never,
+ "重建失败后不应继续读取统计");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new CodeIndexCommand();
+ cmd.Name.Should().Be("code-index");
+ cmd.Usage.Should().Be("/code-index");
+ cmd.ExposeToMcp.Should().BeTrue();
+ }
+}
diff --git a/modules/composition/composition.tests/commands/ColorCommandTest.cs b/modules/composition/composition.tests/commands/ColorCommandTest.cs
new file mode 100644
index 0000000000..4cc36ff550
--- /dev/null
+++ b/modules/composition/composition.tests/commands/ColorCommandTest.cs
@@ -0,0 +1,89 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /color 命令单元测试 — 覆盖空参/test 颜色测试面板/reset+default 重置确认/
+/// 任意主题名回显+引导提示/大小写归一/命令元数据
+/// 命令零服务依赖,直接经 CommandTestHarness 构造上下文
+///
+[Collection("command-output")]
+public sealed class ColorCommandTest {
+ private static async Task<(ChatCommandResult Result, string Output)> RunAsync(string arguments) {
+ var services = CommandTestHarness.CreateServices();
+ var context = CommandTestHarness.CreateContext(services, arguments: arguments);
+ return await CommandTestHarness.CaptureOutputAsync(() => new ColorCommand().ExecuteAsync(context));
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_ShowsColorTestPanel() {
+ var (result, output) = await RunAsync("");
+
+ result.ShouldContinue.Should().BeTrue();
+ result.IsHandled.Should().BeTrue();
+ output.Should().Contain("=== 终端颜色测试 ===");
+ output.Should().Contain("基础颜色:");
+ output.Should().Contain("绿色(成功)");
+ output.Should().Contain("红色(错误)");
+ output.Should().Contain("ANSI 256色:");
+ output.Should().Contain("样式:");
+ output.Should().Contain("说明终端支持 ANSI 颜色");
+ }
+
+ [Fact]
+ public async Task Execute_Test_ShowsColorTestPanel() {
+ var (result, output) = await RunAsync("test");
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("=== 终端颜色测试 ===");
+ output.Should().Contain("基础颜色:");
+ }
+
+ [Fact]
+ public async Task Execute_TestArg_IsCaseInsensitive() {
+ var (_, output) = await RunAsync("TEST");
+
+ output.Should().Contain("=== 终端颜色测试 ===", "参数应 ToLowerInvariant 归一后匹配 test");
+ }
+
+ [Fact]
+ public async Task Execute_Reset_ShowsResetConfirmationWithHints() {
+ var (result, output) = await RunAsync("reset");
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("颜色已重置为默认");
+ output.Should().NotContain("颜色主题:");
+ }
+
+ [Fact]
+ public async Task Execute_DefaultAlias_SameAsReset() {
+ var (result, output) = await RunAsync("default");
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("颜色已重置为默认", "default 是 RESET_ALIASES 之一");
+ }
+
+ [Fact]
+ public async Task Execute_ThemeName_EchoesThemeWithGuide() {
+ var (result, output) = await RunAsync("ocean");
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("颜色主题: ocean");
+ output.Should().Contain("使用 /color test 测试终端颜色支持");
+ output.Should().Contain("使用 /color reset 重置为默认");
+ output.Should().NotContain("颜色已重置为默认");
+ }
+
+ [Fact]
+ public async Task Execute_TrimsWhitespaceAroundArgs() {
+ var (_, output) = await RunAsync(" solarized ");
+
+ output.Should().Contain("颜色主题: solarized", "GetNormalizedArgs 应 Trim 后再匹配");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new ColorCommand();
+ cmd.Name.Should().Be("color");
+ cmd.Usage.Should().Be("/color [theme|test|reset]");
+ cmd.Aliases.Should().BeEmpty();
+ }
+}
diff --git a/modules/composition/composition.tests/commands/CompactCommandTest.cs b/modules/composition/composition.tests/commands/CompactCommandTest.cs
index da8076e373..d0a005b107 100644
--- a/modules/composition/composition.tests/commands/CompactCommandTest.cs
+++ b/modules/composition/composition.tests/commands/CompactCommandTest.cs
@@ -132,26 +132,33 @@ public async Task Execute_CompactHistoryPromptTooLong_RewindsAndRetries() {
output.Should().Contain("上下文已压缩");
}
- /// 生产 bug 登记(TASK065 ai 域): PTL 重试耗尽(attempt=2)时 catch 条件
- /// attempt < maxPtRetries - 1 不再满足,第 3 次 PTL 异常从 ExecuteAsync 直接冒泡
- /// 传播 REPL — "最终失败"兜底块只覆盖 Rewind 零移除 break 路径,不覆盖第三次异常路径。
- /// 本测试为现状快照,修复后应翻转为"走最终兜底 + HandleError 渲染"断言。
+ /// 生产 bug 修复验证(TASK065 ai 域): PTL 重试耗尽(attempt=2)时第 3 次异常
+ /// 曾因 catch 过滤器 attempt < maxPtRetries - 1 不满足而直接冒泡传播 REPL
+ /// ("最终失败"兜底当时只覆盖 Rewind 零移除 break 路径)。修复后第 3 次 PTL 同样被捕获,
+ /// 重试耗尽 break 走最终兜底降级路径:兜底再试一次,仍失败则 HandleError 收敛渲染,
+ /// 命令 Continue 而非异常传播。
[Fact]
- public async Task Execute_PtlRetriesExhausted_ThirdAttemptExceptionEscapes() {
+ public async Task Execute_PtlRetriesExhausted_ThirdAttemptFallsToFinalAttempt() {
var contextManager = new Mock();
contextManager.Setup(m => m.RewindLastTurnAsync(It.IsAny()))
.ReturnsAsync(RewindResult.Ok(RewindKind.TrimLastTurn, 2, 1));
var (cmd, context, chat, _) = Arrange(history: CreateHistory(), contextManager: contextManager);
chat.SetupSequence(c => c.CompactHistoryAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("prompt too long"))
.ThrowsAsync(new InvalidOperationException("prompt too long"))
.ThrowsAsync(new InvalidOperationException("prompt too long"))
.ThrowsAsync(new InvalidOperationException("prompt too long"));
- var act = () => CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
- await act.Should().ThrowAsync().WithMessage("prompt too long");
+ result.ShouldContinue.Should().BeTrue("重试耗尽应走最终失败降级路径,命令继续而非异常传播 REPL");
contextManager.Verify(m => m.RewindLastTurnAsync(It.IsAny()), Times.Exactly(2),
- "前两次 PTL 各回滚一次,第三次异常不再捕获");
+ "前两次 PTL 各回滚一次,第 3 次重试耗尽不再回滚直接走最终兜底");
+ chat.Verify(c => c.CompactHistoryAsync(It.IsAny(), It.IsAny()), Times.Exactly(4),
+ "3 次循环尝试 + 最终兜底 1 次");
+ output.Should().NotContain("(3/3)", "重试耗尽不输出第 3 次重试提示");
+ output.Should().Contain("生成摘要失败: prompt too long",
+ "最终兜底仍失败应 HandleError 收敛渲染失败提示");
}
[Fact]
diff --git a/modules/composition/composition.tests/commands/DesktopCommandTest.cs b/modules/composition/composition.tests/commands/DesktopCommandTest.cs
new file mode 100644
index 0000000000..07753c2e15
--- /dev/null
+++ b/modules/composition/composition.tests/commands/DesktopCommandTest.cs
@@ -0,0 +1,86 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /desktop 命令单元测试 — 覆盖服务缺失静默/桌面不可用提示安装引导/
+/// 移交成功(会话 ID+连接信息)/移交失败提示
+/// IDesktopHandoffService 经 DI 注入
+///
+[Collection("command-output")]
+public sealed class DesktopCommandTest {
+ private static (DesktopCommand Command, Mock Handoff, ChatCommandContext Context) Arrange(
+ bool withService = true,
+ bool isAvailable = true,
+ bool handoffResult = true,
+ string? connectionInfo = "named-pipe://jcc-desktop") {
+ var handoff = new Mock();
+ handoff.Setup(h => h.IsDesktopAvailable).Returns(isAvailable);
+ handoff.Setup(h => h.DesktopConnectionInfo).Returns(connectionInfo);
+ handoff.Setup(h => h.HandoffToDesktopAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(handoffResult);
+
+ var services = CommandTestHarness.CreateServices(
+ configureDi: di => {
+ if (withService) di.AddSingleton(handoff.Object);
+ });
+ var context = CommandTestHarness.CreateContext(services, sessionId: "sess-desktop");
+ return (new DesktopCommand(), handoff, context);
+ }
+
+ [Fact]
+ public async Task Execute_WithoutService_ReturnsContinueSilently() {
+ var (cmd, handoff, context) = Arrange(withService: false);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ result.IsHandled.Should().BeTrue();
+ output.Should().BeEmpty("移交服务缺失应静默降级");
+ handoff.Verify(h => h.HandoffToDesktopAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Execute_DesktopUnavailable_ShowsInstallGuide() {
+ var (cmd, handoff, context) = Arrange(isAvailable: false);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("未检测到桌面应用。");
+ output.Should().Contain("请安装 jcc-desktop 桌面应用");
+ output.Should().Contain("/bridge 命令通过命名管道连接");
+ handoff.Verify(h => h.HandoffToDesktopAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Execute_HandoffSuccess_ShowsSessionAndConnectionInfo() {
+ var (cmd, handoff, context) = Arrange(handoffResult: true);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ handoff.Verify(h => h.HandoffToDesktopAsync("sess-desktop", It.IsAny()), Times.Once,
+ "移交应携带当前会话 ID");
+ output.Should().Contain("会话 sess-desktop 已转移到桌面应用");
+ output.Should().Contain("named-pipe://jcc-desktop");
+ }
+
+ [Fact]
+ public async Task Execute_HandoffFailure_ShowsFailureHint() {
+ var (cmd, handoff, context) = Arrange(handoffResult: false);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("会话转移失败,请检查桌面应用是否正在运行");
+ output.Should().NotContain("已转移到桌面应用");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new DesktopCommand();
+ cmd.Name.Should().Be("desktop");
+ cmd.Usage.Should().Be("/desktop");
+ cmd.Aliases.Should().Contain("app");
+ cmd.IsHidden.Should().BeTrue();
+ }
+}
diff --git a/modules/composition/composition.tests/commands/DiagCommandTest.cs b/modules/composition/composition.tests/commands/DiagCommandTest.cs
new file mode 100644
index 0000000000..8182f669e4
--- /dev/null
+++ b/modules/composition/composition.tests/commands/DiagCommandTest.cs
@@ -0,0 +1,179 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /diag 命令单元测试 — 覆盖存储未注册降级/空参 recent(空报告+有快照报告)/
+/// fence 按围栏查询(有/无记录)/ack(非法 ID/不存在/成功确认)/detail(非法 ID/成功详情)/未知操作用法
+/// ICrashSnapshotStore 用真实 CrashSnapshotStore 实例经 DI 注入
+/// (recent 分支强转具体类,不能用 Moq);快照经 Add 构造真实数据
+///
+[Collection("command-output")]
+public sealed class DiagCommandTest {
+ private static CrashSnapshot CreateSnapshot(
+ string fence = "fence-main",
+ CrashSeverity severity = CrashSeverity.Error,
+ string message = "计划执行失败") =>
+ new(fence, severity, new InvalidOperationException(message));
+
+ private static (DiagCommand Command, CrashSnapshotStore Store, ChatCommandContext Context) Arrange(
+ string arguments = "",
+ bool withStore = true,
+ params CrashSnapshot[] snapshots) {
+ var store = new CrashSnapshotStore();
+ foreach (var snapshot in snapshots) {
+ store.Add(snapshot);
+ }
+ var services = CommandTestHarness.CreateServices(
+ configureDi: di => {
+ if (withStore) di.AddSingleton(store);
+ });
+ var context = CommandTestHarness.CreateContext(services, arguments: arguments);
+ return (new DiagCommand(), store, context);
+ }
+
+ [Fact]
+ public async Task Execute_WithoutStore_ShowsUnavailable() {
+ var (cmd, store, context) = Arrange(withStore: false);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ result.IsHandled.Should().BeTrue();
+ output.Should().Contain("CrashSnapshotStore 未初始化 — 诊断功能不可用");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_EmptyStore_ShowsEmptyReport() {
+ var (cmd, _, context) = Arrange(arguments: "");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("无崩溃快照记录。");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_WithSnapshots_ShowsRecentReport() {
+ var snapshot = CreateSnapshot(message: "严重故障");
+ var (cmd, _, context) = Arrange(arguments: "", snapshots: [snapshot]);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("崩溃快照报告(最近 1 条,共 1 条,未确认 1 条)");
+ output.Should().Contain("[ERROR] fence-main");
+ output.Should().Contain("严重故障");
+ output.Should().Contain($"ID: {snapshot.Id:N}");
+ }
+
+ [Fact]
+ public async Task Execute_RecentArg_SameAsDefault() {
+ var (cmd, _, context) = Arrange(arguments: "recent", snapshots: [CreateSnapshot()]);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("崩溃快照报告(最近 1 条");
+ }
+
+ [Fact]
+ public async Task Execute_Fence_WithRecords_ShowsFilteredSnapshots() {
+ var (cmd, _, context) = Arrange(
+ arguments: "fence fence-alpha",
+ snapshots: [
+ CreateSnapshot(fence: "fence-alpha", message: "A 崩溃"),
+ CreateSnapshot(fence: "fence-beta", message: "B 崩溃"),
+ ]);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("围栏 'fence-alpha' 崩溃记录 (1 条):");
+ output.Should().Contain("A 崩溃");
+ output.Should().NotContain("B 崩溃", "fence 过滤不应显示其他围栏的快照");
+ }
+
+ [Fact]
+ public async Task Execute_Fence_NoRecords_ShowsEmptyHint() {
+ var (cmd, _, context) = Arrange(arguments: "fence fence-nope", snapshots: [CreateSnapshot(fence: "fence-other")]);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("围栏 'fence-nope' 无崩溃记录");
+ }
+
+ [Fact]
+ public async Task Execute_Ack_InvalidGuid_ShowsInvalidId() {
+ var (cmd, _, context) = Arrange(arguments: "ack not-a-guid");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("无效的快照 ID: not-a-guid");
+ }
+
+ [Fact]
+ public async Task Execute_Ack_UnknownGuid_ShowsNotFound() {
+ var (cmd, _, context) = Arrange(arguments: "ack 00000000-0000-0000-0000-000000000000");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("不存在");
+ }
+
+ [Fact]
+ public async Task Execute_Ack_ExistingSnapshot_AcknowledgesIt() {
+ var snapshot = CreateSnapshot();
+ var (cmd, store, context) = Arrange(arguments: $"ack {snapshot.Id}", snapshots: [snapshot]);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain($"快照 {snapshot.Id:N} 已确认");
+ store.UnacknowledgedCount.Should().Be(0, "确认后未确认计数应归零");
+ }
+
+ [Fact]
+ public async Task Execute_Detail_InvalidGuid_ShowsInvalidId() {
+ var (cmd, _, context) = Arrange(arguments: "detail abc");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("无效的快照 ID: abc");
+ }
+
+ [Fact]
+ public async Task Execute_Detail_ExistingSnapshot_ShowsFullDetail() {
+ var snapshot = CreateSnapshot(fence: "fence-detail", message: "详情异常消息");
+ var (cmd, _, context) = Arrange(arguments: $"detail {snapshot.Id}", snapshots: [snapshot]);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("═══ 崩溃快照");
+ output.Should().Contain("围栏: fence-detail");
+ output.Should().Contain("严重度: ERROR");
+ output.Should().Contain("详情异常消息");
+ output.Should().Contain("异常类型: System.InvalidOperationException");
+ }
+
+ [Fact]
+ public async Task Execute_UnknownAction_ShowsUsage() {
+ var (cmd, _, context) = Arrange(arguments: "bogus");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("用法: /diag [recent|fence |ack |detail ]");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new DiagCommand();
+ cmd.Name.Should().Be("diag");
+ cmd.Usage.Should().Be("/diag [recent|fence |ack |clear]");
+ cmd.IsHidden.Should().BeTrue();
+ }
+}
diff --git a/modules/composition/composition.tests/commands/ExtraUsageCommandTest.cs b/modules/composition/composition.tests/commands/ExtraUsageCommandTest.cs
new file mode 100644
index 0000000000..5354e96bb6
--- /dev/null
+++ b/modules/composition/composition.tests/commands/ExtraUsageCommandTest.cs
@@ -0,0 +1,102 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /extra-usage 命令单元测试 — 覆盖今日/累计统计渲染(N0/F4 格式化)/空统计全零/
+/// 追踪器缺失降级/统计异常收敛/元数据一致性
+/// UsageTracker 用 Moq 桩经 CommandServices.UsageTracker 可选字段注入
+///
+[Collection("command-output")]
+public sealed class ExtraUsageCommandTest {
+ private static TokenUsageStatistics CreateStats(
+ int requests = 5,
+ long input = 1200,
+ long output = 800,
+ decimal cost = 0.05m) => new() {
+ TotalRequests = requests,
+ TotalInputTokens = input,
+ TotalOutputTokens = output,
+ TotalCostUsd = cost,
+ };
+
+ private static (ExtraUsageCommand Command, ChatCommandContext Context, Mock Tracker) Arrange(
+ string arguments = "",
+ Mock? tracker = null) {
+ var usageTracker = tracker ?? new Mock();
+ var services = CommandTestHarness.CreateServices(usageTracker: usageTracker.Object);
+ var context = CommandTestHarness.CreateContext(services, arguments: arguments);
+ return (new ExtraUsageCommand(), context, usageTracker);
+ }
+
+ [Fact]
+ public async Task Execute_WithTracker_RendersTodayAndTotalStats() {
+ var tracker = new Mock();
+ tracker.Setup(t => t.GetTodayStatistics()).Returns(CreateStats());
+ tracker.Setup(t => t.GetTotalStatistics()).Returns(CreateStats(requests: 42, input: 999999, output: 888888, cost: 12.3456m));
+ var (cmd, context, _) = Arrange(tracker: tracker);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("额外用量:");
+ output.Should().Contain(" 今日统计:");
+ output.Should().Contain(" 请求数: 5");
+ output.Should().Contain(" 输入 Token: 1,200", "Token 数量应 N0 千分位格式化");
+ output.Should().Contain(" 输出 Token: 800");
+ output.Should().Contain(" 估算成本: $0.0500", "成本应 F4 四位小数格式化");
+ output.Should().Contain(" 累计统计:");
+ output.Should().Contain(" 总请求数: 42");
+ output.Should().Contain(" 总输入 Token: 999,999");
+ output.Should().Contain(" 总输出 Token: 888,888");
+ output.Should().Contain(" 总估算成本: $12.3456");
+ tracker.Verify(t => t.GetTodayStatistics(), Times.Once);
+ tracker.Verify(t => t.GetTotalStatistics(), Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_EmptyStats_RendersZeroValues() {
+ var tracker = new Mock();
+ tracker.Setup(t => t.GetTodayStatistics()).Returns(new TokenUsageStatistics());
+ tracker.Setup(t => t.GetTotalStatistics()).Returns(new TokenUsageStatistics());
+ var (cmd, context, _) = Arrange(tracker: tracker);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("请求数: 0", "空统计(全零)应渲染面板而非空输出或崩溃");
+ output.Should().Contain("估算成本: $0.0000");
+ }
+
+ [Fact]
+ public async Task Execute_WithoutTracker_ShowsUnavailable() {
+ var services = CommandTestHarness.CreateServices(usageTracker: null);
+ var context = CommandTestHarness.CreateContext(services);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(
+ () => new ExtraUsageCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("追踪器缺失应降级提示而非崩溃");
+ output.Should().Contain("额外用量:");
+ output.Should().Contain(" 用量追踪器未初始化");
+ }
+
+ [Fact]
+ public async Task Execute_StatsFailure_ConvergesToErrorHint() {
+ var tracker = new Mock();
+ tracker.Setup(t => t.GetTodayStatistics()).Throws(new InvalidOperationException("disk locked"));
+ var (cmd, context, _) = Arrange(tracker: tracker);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("统计异常应收敛为错误提示而非向上抛出");
+ output.Should().Contain("获取用量数据失败: disk locked");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new ExtraUsageCommand();
+ cmd.Name.Should().Be("extra-usage");
+ cmd.Usage.Should().Be("/extra-usage");
+ cmd.Description.Should().Contain("额外用量");
+ cmd.IsHidden.Should().BeTrue("extra-usage 为隐藏命令");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/FalvCommandTest.cs b/modules/composition/composition.tests/commands/FalvCommandTest.cs
new file mode 100644
index 0000000000..53f0d9f8e6
--- /dev/null
+++ b/modules/composition/composition.tests/commands/FalvCommandTest.cs
@@ -0,0 +1,383 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /falv 命令单元测试 — 覆盖帮助(空参/-h)/引擎缺失降级/自由文本添加假定/--status 摘要与数据项渲染/
+/// --budget 预算渲染(正常/耗尽)/--judge 裁决渲染/--evidence 证据链(空/非空)/--reset/
+/// --continue 续费(未耗尽提示/耗尽续费/模式解析)/--cone 视锥/--conflict 冲突检测/元数据一致性
+/// ReasoningEngine 用 Moq 桩经 DI 注入,集合类方法全部 Setup 空返回避免 null 枚举崩溃
+///
+[Collection("command-output")]
+public sealed class FalvCommandTest {
+ /// 剥离 ANSI 转义序列 — 预算/裁决/状态渲染带颜色码,断言基于纯文本
+ private static string StripAnsi(string text) =>
+ System.Text.RegularExpressions.Regex.Replace(text, @"\x1B\[[0-9;]*m", string.Empty);
+
+ /// 构造全方法安全的引擎桩 — 集合方法返回空集合,摘要/预算返回默认实例
+ private static Mock CreateEngine(
+ ReasoningSummary? summary = null,
+ BudgetStatus? budget = null,
+ IReadOnlyList? items = null,
+ IReadOnlyList? facts = null,
+ IReadOnlyList? evidence = null) {
+ var engine = new Mock();
+ engine.Setup(e => e.GetSummary()).Returns(summary ?? new ReasoningSummary());
+ engine.Setup(e => e.GetBudgetStatus()).Returns(budget ?? new BudgetStatus());
+ engine.Setup(e => e.GetAllItems()).Returns((items ?? []).AsEnumerable());
+ engine.Setup(e => e.GetFacts()).Returns((facts ?? []).AsEnumerable());
+ engine.Setup(e => e.GetAllEvidence()).Returns((evidence ?? []).AsEnumerable());
+ engine.Setup(e => e.AddAssumptionsAsync(It.IsAny>(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ engine.Setup(e => e.RunAdversarialProcessAsync(It.IsAny())).Returns(Task.CompletedTask);
+ engine.Setup(e => e.ContinueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ engine.Setup(e => e.ExpandFragment(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((ObservationFragment?)null);
+ engine.Setup(e => e.DetectConeConflict(It.IsAny(), It.IsAny()))
+ .Returns(new ConeConflictResult {
+ RoleA = AgentRole.Prosecutor,
+ RoleB = AgentRole.Defender,
+ RoleAConclusions = [],
+ RoleBConclusions = [],
+ HasConflict = false,
+ });
+ return engine;
+ }
+
+ private static (FalvCommand Command, ChatCommandContext Context, Mock Engine) Arrange(
+ string arguments = "",
+ Mock? engine = null) {
+ var reasoningEngine = engine ?? CreateEngine();
+ var services = CommandTestHarness.CreateServices(
+ configureDi: di => di.AddSingleton(reasoningEngine.Object));
+ var context = CommandTestHarness.CreateContext(services, arguments: arguments);
+ return (new FalvCommand(), context, reasoningEngine);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("-h")]
+ [InlineData("--help")]
+ public async Task Execute_HelpAlias_ShowsUsageHelp(string arguments) {
+ var (cmd, context, _) = Arrange(arguments: arguments);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("用法: /falv <假定内容> | /falv --status |", "空参与 -h/--help 应显示帮助");
+ output.Should().Contain("结构化推理引擎 — 假定→验证→事实 三态跃迁");
+ output.Should().Contain("--judge 触发三权裁决(控方→辩方→法官)");
+ }
+
+ [Fact]
+ public async Task Execute_WithoutEngine_ShowsError() {
+ var services = CommandTestHarness.CreateServices();
+ var context = CommandTestHarness.CreateContext(services, arguments: "--status");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(
+ () => new FalvCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("推理引擎未初始化", "引擎缺失应提示而非崩溃");
+ }
+
+ [Fact]
+ public async Task Execute_FreeText_AddsAssumptionWithDefaults() {
+ var engine = CreateEngine();
+ var (cmd, context, _) = Arrange(arguments: "地球是圆的", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.AddAssumptionsAsync(
+ It.Is>(list =>
+ list.Count == 1
+ && list[0].Content == "地球是圆的"
+ && list[0].State == DataState.Assumption
+ && list[0].Source == "用户输入"),
+ It.IsAny()), Times.Once, "自由文本应构造假定数据项入引擎");
+ clean.Should().Contain("[假定] 地球是圆的");
+ }
+
+ [Fact]
+ public async Task Execute_Status_RendersSummaryAndItems() {
+ var engine = CreateEngine(
+ summary: new ReasoningSummary {
+ TotalAssumptions = 3,
+ TotalVerified = 2,
+ TotalFacts = 1,
+ TotalRejected = 1,
+ TotalPendingEvidence = 2,
+ TotalEvidence = 4,
+ LastRunAt = new DateTime(2026, 1, 15, 10, 30, 0),
+ },
+ items: new List {
+ new() { Content = "已确认事实", State = DataState.Fact, Confidence = 95 },
+ new() { Content = "待验证假定", State = DataState.Assumption, Confidence = 50 },
+ });
+ var (cmd, context, _) = Arrange(arguments: "--status", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ clean.Should().Contain("=== 推理引擎状态 ===");
+ clean.Should().Contain(" 假定: 3");
+ clean.Should().Contain(" 已验证: 2");
+ clean.Should().Contain(" 事实: 1");
+ clean.Should().Contain(" 被驳斥: 1");
+ clean.Should().Contain(" 待补充: 2");
+ clean.Should().Contain(" 证据总数: 4");
+ clean.Should().Contain(" 最近裁决: 10:30:00", "LastRunAt 有值时应渲染裁决时间");
+ clean.Should().Contain("=== 预算状态 ===");
+ clean.Should().Contain("=== 所有数据项 ===");
+ clean.Should().Contain("[事实] 已确认事实 (置信度:95%)");
+ clean.Should().Contain("[假定] 待验证假定 (置信度:50%)", "数据项应按状态标签渲染");
+ }
+
+ [Fact]
+ public async Task Execute_Budget_RendersUsedAndRemaining() {
+ var engine = CreateEngine(budget: new BudgetStatus {
+ RoundsUsed = 2,
+ RoundsBudget = 5,
+ TokensUsed = 100,
+ TokensBudget = 200,
+ });
+ var (cmd, context, _) = Arrange(arguments: "--budget", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ clean.Should().Contain("=== 预算状态 ===");
+ clean.Should().Contain("轮次: 2/5 (剩余 3)");
+ clean.Should().Contain("Token: 100/200 (剩余 100)", "预算应渲染已用/上限与剩余");
+ }
+
+ [Fact]
+ public async Task Execute_BudgetExhausted_ShowsContinueHint() {
+ var engine = CreateEngine(budget: new BudgetStatus {
+ RoundsUsed = 5,
+ RoundsBudget = 5,
+ TokensUsed = 200,
+ TokensBudget = 200,
+ });
+ var (cmd, context, _) = Arrange(arguments: "--budget", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("同时触底 — 使用 /falv --continue 续费", "双预算耗尽应提示续费方式");
+ }
+
+ [Fact]
+ public async Task Execute_Judge_RunsAdversarialProcessAndRendersVerdicts() {
+ var engine = CreateEngine(
+ facts: new List { new() { Content = "裁决事实", State = DataState.Fact, Confidence = 90 } },
+ items: new List {
+ new() { Content = "被驳斥项", State = DataState.Rejected },
+ new() { Content = "待补充项", State = DataState.PendingEvidence },
+ new() { Content = "已验证项", State = DataState.Verified, Confidence = 80 },
+ });
+ var (cmd, context, _) = Arrange(arguments: "--judge", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.RunAdversarialProcessAsync(It.IsAny()), Times.Once,
+ "--judge 应触发三权裁决流程");
+ clean.Should().Contain("=== 裁决结果 ===");
+ clean.Should().Contain("事实: 裁决事实 (置信度:90%)");
+ clean.Should().Contain("被驳斥: 被驳斥项");
+ clean.Should().Contain("待补充: 待补充项");
+ clean.Should().Contain("已验证(部分接受): 已验证项 (置信度:80%)", "裁决应覆盖事实/驳斥/待补充/已验证四类");
+ }
+
+ [Fact]
+ public async Task Execute_EvidenceEmpty_ShowsPlaceholder() {
+ var (cmd, context, _) = Arrange(arguments: "--evidence");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("=== 证据链 ===");
+ output.Should().Contain(" 暂无证据", "空证据链应显示占位提示");
+ }
+
+ [Fact]
+ public async Task Execute_EvidenceWithRecords_RendersCategoryAndTrust() {
+ var engine = CreateEngine(evidence: new List {
+ new() {
+ Content = "日志文件证明重启发生在 03:00",
+ Category = EvidenceCategory.Digital,
+ TrustLevel = TrustLevel.DirectEvidence,
+ SubmittedBy = AgentRole.Prosecutor,
+ IsUrlVerified = true,
+ },
+ });
+ var (cmd, context, _) = Arrange(arguments: "--evidence", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("=== 证据链 ===");
+ output.Should().Contain("[Digital] 日志文件证明重启发生在 03:00 (信任度:DirectEvidence, 提交方:Prosecutor, URL验证:✓)",
+ "证据应渲染分类/内容/信任度/提交方/URL验证状态");
+ }
+
+ [Fact]
+ public async Task Execute_Reset_ResetsEngine() {
+ var engine = CreateEngine();
+ var (cmd, context, _) = Arrange(arguments: "--reset", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.Reset(), Times.Once, "--reset 应清空引擎状态");
+ output.Should().Contain("推理引擎已重置 — DAG清空,预算恢复,视锥重置");
+ }
+
+ [Fact]
+ public async Task Execute_Continue_BudgetNotExhausted_ShowsHintOnly() {
+ var engine = CreateEngine(budget: new BudgetStatus {
+ RoundsUsed = 1,
+ RoundsBudget = 5,
+ TokensUsed = 50,
+ TokensBudget = 200,
+ });
+ var (cmd, context, _) = Arrange(arguments: "--continue", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("预算尚未耗尽 — 轮次:1/5 token:50/200");
+ output.Should().Contain("仍可继续推理,输入 /falv --judge 即可", "未耗尽时应引导直接裁决而非续费");
+ engine.Verify(e => e.ContinueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never,
+ "预算未耗尽时不应触发续费");
+ }
+
+ [Fact]
+ public async Task Execute_Continue_Exhausted_RefillsAndContinues() {
+ var engine = CreateEngine(budget: new BudgetStatus {
+ RoundsUsed = 5,
+ RoundsBudget = 5,
+ TokensUsed = 200,
+ TokensBudget = 200,
+ });
+ var (cmd, context, _) = Arrange(arguments: "--continue both", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.ContinueAsync(BudgetRefillMode.Both, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once,
+ "--continue both 应以 Both 模式续费");
+ clean.Should().Contain("[预算耗尽] 轮次和Token预算均耗尽");
+ clean.Should().Contain("续费方式: Both", "续费方式回显解析后的枚举名");
+ clean.Should().Contain("继续推理中...");
+ }
+
+ [Theory]
+ [InlineData("--continue rounds", BudgetRefillMode.RoundsOnly)]
+ [InlineData("--continue tokens", BudgetRefillMode.TokensOnly)]
+ [InlineData("--continue default", BudgetRefillMode.Default)]
+ [InlineData("--continue", BudgetRefillMode.Default)]
+ public async Task Execute_ContinueModes_ParsedAndForwarded(string arguments, BudgetRefillMode expectedMode) {
+ var engine = CreateEngine(budget: new BudgetStatus {
+ RoundsUsed = 5,
+ RoundsBudget = 5,
+ });
+ var (cmd, context, _) = Arrange(arguments: arguments, engine: engine);
+
+ var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.ContinueAsync(expectedMode, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once,
+ $"'{arguments}' 应解析为 {expectedMode} 续费模式(缺省回退 default)");
+ }
+
+ [Fact]
+ public async Task Execute_Cone_RendersRoleFragments() {
+ var engine = CreateEngine();
+ var (cmd, context, _) = Arrange(arguments: "--cone", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("=== 有限视锥 ===");
+ output.Should().Contain("[Prosecutor] 活跃片段:");
+ output.Should().Contain("[Defender] 活跃片段:");
+ output.Should().Contain("[Judge] 活跃片段:", "视锥应遍历全部三权角色");
+ engine.Verify(e => e.ExpandFragment(It.IsAny(), "", "*"), Times.AtLeast(3),
+ "视锥渲染应对每个角色展开片段");
+ }
+
+ [Fact]
+ public async Task Execute_Conflict_NoConflict_ShowsNone() {
+ var engine = CreateEngine();
+ var (cmd, context, _) = Arrange(arguments: "--conflict", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.DetectConeConflict(AgentRole.Prosecutor, AgentRole.Defender), Times.Once);
+ engine.Verify(e => e.DetectConeConflict(AgentRole.Prosecutor, AgentRole.Judge), Times.Once);
+ engine.Verify(e => e.DetectConeConflict(AgentRole.Defender, AgentRole.Judge), Times.Once,
+ "冲突检测应覆盖控辩/控法官/辩法官三对组合");
+ output.Should().Contain("=== 视锥冲突检测 ===");
+ output.Should().Contain("冲突状态: 无冲突");
+ output.Should().Contain("控方-法官冲突: 无");
+ output.Should().Contain("辩方-法官冲突: 无");
+ }
+
+ [Fact]
+ public async Task Execute_Conflict_WithConflict_ShowsWarning() {
+ var engine = CreateEngine();
+ engine.Setup(e => e.DetectConeConflict(AgentRole.Prosecutor, AgentRole.Defender))
+ .Returns(new ConeConflictResult {
+ RoleA = AgentRole.Prosecutor,
+ RoleB = AgentRole.Defender,
+ RoleAConclusions = ["控方结论A"],
+ RoleBConclusions = ["辩方结论B"],
+ HasConflict = true,
+ });
+ var (cmd, context, _) = Arrange(arguments: "--conflict", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ clean.Should().Contain("控方结论: 控方结论A");
+ clean.Should().Contain("辩方结论: 辩方结论B");
+ clean.Should().Contain("冲突状态: 存在冲突", "检测到冲突时应高亮警示");
+ }
+
+ [Fact]
+ public async Task Execute_AddAssumption_BudgetExhausted_ShowsRefillHint() {
+ var engine = CreateEngine(budget: new BudgetStatus {
+ RoundsUsed = 5,
+ RoundsBudget = 5,
+ TokensUsed = 10,
+ TokensBudget = 100,
+ });
+ var (cmd, context, _) = Arrange(arguments: "新假定", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ clean.Should().Contain("[假定] 新假定");
+ clean.Should().Contain("⚠ 轮次预算耗尽", "添加假定后预算耗尽应附加续费提示");
+ clean.Should().Contain("使用 /falv --continue [rounds|tokens|both|default] 续费并继续推理");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new FalvCommand();
+ cmd.Name.Should().Be("falv");
+ cmd.Description.Should().Contain("结构化推理");
+ cmd.Usage.Should().StartWith("/falv <假定内容>");
+ cmd.ArgumentHint.Should().Contain("--status").And.Contain("--judge");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/FeedbackCommandTest.cs b/modules/composition/composition.tests/commands/FeedbackCommandTest.cs
new file mode 100644
index 0000000000..8fc2b591cf
--- /dev/null
+++ b/modules/composition/composition.tests/commands/FeedbackCommandTest.cs
@@ -0,0 +1,79 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /feedback 命令单元测试 — 覆盖空参输入引导/有参脱敏保存(feedback 目录创建+markdown 落盘+
+/// 成功渲染)/保存路径提示
+/// 写入经 InMemoryFileSystem(0磁盘IO),反馈目录为 AppDataConstants.Paths.JccDirectory 派生路径
+///
+[Collection("command-output")]
+public sealed class FeedbackCommandTest {
+ private static (FeedbackCommand Command, ChatCommandContext Context, IFileSystem Fs) Arrange(string arguments = "") {
+ var fs = TestFileSystem.Current;
+ var services = CommandTestHarness.CreateServices(fileSystem: fs);
+ var context = CommandTestHarness.CreateContext(services, arguments: arguments);
+ return (new FeedbackCommand(), context, fs);
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_ShowsInputGuide() {
+ var (cmd, context, _) = Arrange(arguments: "");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ result.IsHandled.Should().BeTrue();
+ output.Should().Contain("反馈");
+ output.Should().Contain("请输入您的反馈内容:");
+ output.Should().NotContain("已保存到:");
+ }
+
+ [Fact]
+ public async Task Execute_WithFeedbackText_SavesMarkdownFile() {
+ var (cmd, context, fs) = Arrange(arguments: "界面加载有点慢");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ var feedbackDir = Path.Combine(AppDataConstants.Paths.JccDirectory, "feedback");
+ fs.DirectoryExists(feedbackDir).Should().BeTrue("反馈目录应被创建");
+ var files = fs.GetFiles(feedbackDir, "feedback_*.md", System.IO.SearchOption.TopDirectoryOnly);
+ files.Should().HaveCount(1, "应恰好保存一个反馈文件");
+ var content = await fs.ReadAllTextAsync(files[0], CancellationToken.None);
+ content.Should().Contain("# 反馈");
+ content.Should().Contain("**时间**:");
+ content.Should().Contain("界面加载有点慢");
+ }
+
+ [Fact]
+ public async Task Execute_WithFeedbackText_ShowsSuccessAndSavedPath() {
+ var (cmd, context, _) = Arrange(arguments: "功能建议:增加主题切换");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("反馈已提交");
+ output.Should().Contain("功能建议:增加主题切换");
+ output.Should().Contain("已保存到:");
+ output.Should().Contain("feedback");
+ }
+
+ [Fact]
+ public async Task Execute_TrimsSurroundingWhitespace() {
+ var (cmd, context, fs) = Arrange(arguments: " 带空格的反馈 ");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("反馈已提交", "Trim 后非空应走保存主路径");
+ output.Should().Contain("带空格的反馈");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new FeedbackCommand();
+ cmd.Name.Should().Be("feedback");
+ cmd.Usage.Should().Be("/feedback [反馈内容]");
+ cmd.Aliases.Should().Contain("bug");
+ cmd.IsHidden.Should().BeTrue();
+ }
+}
diff --git a/modules/composition/composition.tests/commands/GoalCommandTest.cs b/modules/composition/composition.tests/commands/GoalCommandTest.cs
new file mode 100644
index 0000000000..abaa9bcad2
--- /dev/null
+++ b/modules/composition/composition.tests/commands/GoalCommandTest.cs
@@ -0,0 +1,365 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /goal 命令单元测试 — 覆盖引擎缺失降级/空参(无目标启动 GoalSpec 收集/有目标显示状态)/
+/// pause/resume/clear 生命周期(含别名族)/带初始提示与约束预算启动/StartAsync 异常收敛/
+/// --cron 定时模式(注册任务/存储缺失/表达式缺失)/registry 优先路径(列表/收集/生命周期)/
+/// ParseGoalArgs 参数解析纯函数/元数据一致性
+/// GoalEngine/GoalRegistry/CronTaskStore 用 Moq 桩经 CommandServices 可选字段注入
+///
+[Collection("command-output")]
+public sealed class GoalCommandTest {
+ /// 剥离 ANSI 转义序列 — 目标状态渲染带颜色码,断言基于纯文本
+ private static string StripAnsi(string text) =>
+ System.Text.RegularExpressions.Regex.Replace(text, @"\x1B\[[0-9;]*m", string.Empty);
+
+ private static GoalState Goal(
+ string id = "goal-1",
+ string objective = "优化启动性能",
+ GoalStatus status = GoalStatus.Pursuing) => new() {
+ GoalId = id,
+ Objective = objective,
+ Status = status,
+ TokensUsed = 500,
+ TurnsCompleted = 3,
+ TokenBudget = 1000,
+ Constraints = ["不许改公共 API"],
+ CreatedAt = DateTime.UtcNow.AddMinutes(-10),
+ };
+
+ /// 构造 GoalEngine 桩 — StartAsync 返回运行中目标,生命周期方法空实现
+ private static Mock CreateEngine(GoalState? currentState = null) {
+ var engine = new Mock();
+ engine.Setup(e => e.CurrentState).Returns(currentState);
+ engine.Setup(e => e.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Goal());
+ engine.Setup(e => e.PauseAsync(It.IsAny())).Returns(Task.CompletedTask);
+ engine.Setup(e => e.ResumeAsync(It.IsAny())).Returns(Task.CompletedTask);
+ engine.Setup(e => e.ClearAsync(It.IsAny())).Returns(Task.CompletedTask);
+ engine.Setup(e => e.RehydrateAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask);
+ return engine;
+ }
+
+ private static (GoalCommand Command, ChatCommandContext Context) Arrange(
+ string arguments = "",
+ Mock? engine = null,
+ Mock? registry = null,
+ Mock? cronStore = null) {
+ var commandServices = new CommandServices {
+ ChatService = Mock.Of(),
+ CodeService = Mock.Of(),
+ PlanService = Mock.Of(),
+ FileSystem = TestFileSystem.Current,
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ GoalEngine = engine?.Object,
+ GoalRegistry = registry?.Object,
+ CronTaskStore = cronStore?.Object,
+ };
+ var context = new ChatCommandContext {
+ Arguments = arguments,
+ SessionId = "test-session",
+ CancellationToken = CancellationToken.None,
+ Services = new CommandServiceProvider(commandServices),
+ };
+ return (new GoalCommand(), context);
+ }
+
+ [Fact]
+ public async Task Execute_WithoutEngineOrRegistry_ShowsError() {
+ var (cmd, context) = Arrange(arguments: "--status");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("错误: 目标引擎未注册", "引擎与注册表均缺失应明确报错");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_NoCurrentGoal_StartsGoalSpecCollection() {
+ var engine = CreateEngine(currentState: null);
+ var (cmd, context) = Arrange(arguments: "", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.RehydrateAsync(It.IsAny(), It.IsAny()), Times.Once,
+ "空参应先尝试从持久化恢复目标");
+ engine.Verify(e => e.StartAsync(
+ It.Is(p => p.Contains("GoalSpec") && p.Contains("6 个字段")),
+ It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once,
+ "无当前目标应启动 GoalSpec 收集流程");
+ output.Should().Contain("◎ /goal active — GoalSpec 收集模式");
+ output.Should().Contain(" LLM 将逐个询问目标规格字段,收集完成后开始自主工作。");
+ output.Should().Contain(" 目标: 优化启动性能", "StartAsync 返回的状态应被渲染");
+ }
+
+ [Fact]
+ public async Task Execute_EmptyArgs_WithCurrentGoal_ShowsStatusOnly() {
+ var engine = CreateEngine(currentState: Goal());
+ var (cmd, context) = Arrange(arguments: "", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never,
+ "已有目标时不应重复启动收集");
+ clean.Should().Contain("◎ /goal 运行中");
+ output.Should().Contain(" 目标: 优化启动性能");
+ output.Should().Contain(" ID: goal-1");
+ clean.Should().Contain(" 状态: 运行中");
+ output.Should().Contain(" 轮数: 3");
+ output.Should().Contain(" Token: 500 / 1000", "状态应渲染目标全貌");
+ output.Should().Contain(" 约束: 不许改公共 API");
+ }
+
+ [Fact]
+ public async Task Execute_Pause_PausesGoal() {
+ var engine = CreateEngine();
+ var (cmd, context) = Arrange(arguments: "pause", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.PauseAsync(It.IsAny()), Times.Once, "pause 应暂停目标");
+ output.Should().Contain("◎ /goal 已暂停");
+ }
+
+ [Fact]
+ public async Task Execute_Resume_RehydratesThenResumes() {
+ var engine = CreateEngine(currentState: null);
+ var (cmd, context) = Arrange(arguments: "resume", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.RehydrateAsync(It.IsAny(), It.IsAny()), Times.Once,
+ "无当前目标时 resume 应先恢复持久化状态");
+ engine.Verify(e => e.ResumeAsync(It.IsAny()), Times.Once, "resume 应恢复目标");
+ output.Should().Contain("◎ /goal 已恢复");
+ }
+
+ [Theory]
+ [InlineData("clear")]
+ [InlineData("stop")]
+ [InlineData("off")]
+ [InlineData("reset")]
+ [InlineData("cancel")]
+ public async Task Execute_ClearFamily_ClearsGoal(string arguments) {
+ var engine = CreateEngine();
+ var (cmd, context) = Arrange(arguments: arguments, engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.ClearAsync(It.IsAny()), Times.Once,
+ $"{arguments} 应清除目标(语义等价别名族)");
+ output.Should().Contain("目标已清除");
+ }
+
+ [Fact]
+ public async Task Execute_WithObjective_ConstraintAndBudget_ParsedIntoStart() {
+ var engine = CreateEngine();
+ var (cmd, context) = Arrange(
+ arguments: "--constraint '不许改配置' --budget 5000 优化性能",
+ engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ engine.Verify(e => e.StartAsync(
+ It.Is(p => p.Contains("优化性能")),
+ It.IsAny?>(),
+ It.Is(b => b == 5000),
+ It.IsAny(),
+ It.IsAny()), Times.Once, "--budget 应解析传入启动参数");
+ output.Should().Contain(" 初始提示: 优化性能");
+ output.Should().Contain(" 预填约束: 不许改配置", "引号约束应被剥离引号解析");
+ output.Should().Contain(" 预算: 5000 Token");
+ }
+
+ [Fact]
+ public async Task Execute_StartFailure_ConvergesToErrorHint() {
+ var engine = CreateEngine();
+ engine.Setup(e => e.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("goal already running"));
+ var (cmd, context) = Arrange(arguments: "新目标", engine: engine);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("StartAsync 异常应收敛为错误提示而非向上抛出");
+ output.Should().Contain("目标执行失败: goal already running");
+ }
+
+ [Fact]
+ public async Task Execute_CronGoal_RegistersCronTask() {
+ var engine = CreateEngine();
+ var cronStore = new Mock();
+ cronStore.Setup(s => s.AddTaskAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new CronTask {
+ Id = "cron-1",
+ CronExpression = "*/5 * * * *",
+ Prompt = "每五分钟检查构建",
+ CreatedAt = 1000,
+ IsRecurring = true,
+ IsDurable = true,
+ });
+ var (cmd, context) = Arrange(
+ arguments: "--cron */5 * * * * 每五分钟检查构建",
+ engine: engine,
+ cronStore: cronStore);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ cronStore.Verify(s => s.AddTaskAsync(
+ It.Is(r =>
+ r.CronExpression == "*/5 * * * *"
+ && r.Prompt == "每五分钟检查构建"
+ && r.IsRecurring
+ && r.IsDurable),
+ It.IsAny()), Times.Once, "cron 模式应注册重复且持久的定时任务");
+ output.Should().Contain("定时目标已注册");
+ output.Should().Contain(" Cron: */5 * * * *");
+ output.Should().Contain(" 目标: 每五分钟检查构建");
+ output.Should().Contain(" 任务ID: cron-1");
+ }
+
+ [Fact]
+ public async Task Execute_CronGoal_WithoutStore_ShowsError() {
+ var engine = CreateEngine();
+ var (cmd, context) = Arrange(
+ arguments: "--cron */5 * * * * 描述",
+ engine: engine,
+ cronStore: null);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("错误: Cron 任务存储未注册", "存储缺失应明确报错");
+ }
+
+ [Fact]
+ public async Task Execute_CronGoal_IncompleteExpression_ShowsError() {
+ var engine = CreateEngine();
+ var cronStore = new Mock();
+ var (cmd, context) = Arrange(
+ arguments: "--cron */5 * *",
+ engine: engine,
+ cronStore: cronStore);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("错误: 定时模式需要指定 Cron 表达式",
+ "不足 5 字段的 cron 表达式应提示错误");
+ cronStore.Verify(s => s.AddTaskAsync(It.IsAny(), It.IsAny()), Times.Never,
+ "表达式不完整时不应注册任务");
+ }
+
+ [Fact]
+ public async Task Execute_RegistryPreferred_EmptyGoals_StartsCollectionViaRegistry() {
+ var registry = new Mock();
+ registry.Setup(r => r.ListActiveGoalsAsync(It.IsAny()))
+ .ReturnsAsync(new List());
+ registry.Setup(r => r.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Goal());
+ var engine = CreateEngine();
+ var (cmd, context) = Arrange(arguments: "", engine: engine, registry: registry);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ registry.Verify(r => r.RehydrateAllAsync(It.IsAny()), Times.Once,
+ "registry 模式应先恢复全部持久化目标");
+ registry.Verify(r => r.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny()), Times.Once,
+ "无活跃目标应经 registry 启动 GoalSpec 收集");
+ engine.Verify(e => e.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never,
+ "registry 存在时应优先走 registry 路径");
+ output.Should().Contain("◎ /goal active — GoalSpec 收集模式");
+ }
+
+ [Fact]
+ public async Task Execute_RegistryPreferred_ActiveGoals_ShowsGoalsList() {
+ var registry = new Mock();
+ registry.Setup(r => r.ListActiveGoalsAsync(It.IsAny()))
+ .ReturnsAsync(new List {
+ Goal(id: "goal-1", objective: "重构模块A"),
+ Goal(id: "goal-2", objective: "修复内存泄漏", status: GoalStatus.Paused),
+ });
+ var (cmd, context) = Arrange(arguments: "", registry: registry);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+ var clean = StripAnsi(output);
+
+ result.ShouldContinue.Should().BeTrue();
+ clean.Should().Contain("◎ 活跃目标列表 (2)");
+ clean.Should().Contain(" [goal-1] 重构模块A — 运行中");
+ clean.Should().Contain(" [goal-2] 修复内存泄漏 — 已暂停", "列表应渲染每个目标的运行状态");
+ }
+
+ [Fact]
+ public async Task Execute_RegistryPause_PausesViaRegistry() {
+ var registry = new Mock();
+ registry.Setup(r => r.PauseAsync(It.IsAny())).Returns(Task.CompletedTask);
+ var (cmd, context) = Arrange(arguments: "pause", registry: registry);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ registry.Verify(r => r.PauseAsync(It.IsAny()), Times.Once,
+ "registry 模式 pause 应委托注册表");
+ output.Should().Contain("◎ /goal 已暂停");
+ }
+
+ /// 生产 bug 登记(TASK065 brain 域): usage 提示 "/goal [初始提示] [--constraint '约束'] [--budget N]"
+ /// 的文档形态(目标在前、选项在后)不被 ParseGoalArgs 支持 — 选项剥离仅匹配 remaining 开头,
+ /// 目标在前时整个字符串(含 --constraint/--budget 原文)作为 objective 传入 GoalSpecPromptBuilder。
+ /// 本测试按当前实现以"选项在前"形态验证解析正路径;修复为任意位置剥离后应补充目标在前的用例。
+ [Theory]
+ [InlineData("--cron */5 * * * * 每五分钟检查", true, "*/5 * * * *", "每五分钟检查")]
+ [InlineData("-c 0 9 * * 1 每周一早九点", true, "0 9 * * 1", "每周一早九点")]
+ [InlineData("--cron */5 * *", true, null, "")]
+ [InlineData("--constraint '不许改配置' --constraint 必须有测试 优化性能", false, null, "优化性能")]
+ [InlineData("--budget 5000 优化性能", false, null, "优化性能")]
+ [InlineData("--budget abc 优化性能", false, null, "优化性能")]
+ [InlineData("纯目标文本", false, null, "纯目标文本")]
+ public void ParseGoalArgs_ParsesCronConstraintsAndBudget(
+ string args, bool expectCron, string? expectCronExpr, string expectObjective) {
+ var parsed = GoalCommand.ParseGoalArgs(args);
+
+ parsed.IsCron.Should().Be(expectCron, $"'{args}' 的 cron 判定应为 {expectCron}");
+ if (expectCron) {
+ parsed.CronExpression.Should().Be(expectCronExpr);
+ } else {
+ parsed.Objective.Should().Be(expectObjective);
+ }
+ }
+
+ [Fact]
+ public void ParseGoalArgs_ExtractsQuotedConstraintsAndBudget() {
+ var parsed = GoalCommand.ParseGoalArgs("--constraint '引号约束' --constraint 单词约束 --budget 100 目标描述");
+
+ parsed.Objective.Should().Be("目标描述");
+ parsed.Constraints.Should().Equal("引号约束", "单词约束");
+ parsed.TokenBudget.Should().Be(100);
+ parsed.IsCron.Should().BeFalse();
+ }
+
+ [Fact]
+ public void ParseGoalArgs_InvalidBudget_Ignored() {
+ var parsed = GoalCommand.ParseGoalArgs("--budget abc 目标");
+
+ parsed.TokenBudget.Should().BeNull("非数字预算应被忽略而非崩溃");
+ parsed.Objective.Should().Be("目标");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new GoalCommand();
+ cmd.Name.Should().Be("goal");
+ cmd.Description.Should().Contain("目标自主循环");
+ cmd.Usage.Should().StartWith("/goal [初始提示]");
+ cmd.Usage.Should().Contain("pause").And.Contain("resume").And.Contain("clear").And.Contain("--cron");
+ }
+}
diff --git a/modules/composition/composition.tests/commands/HeapdumpCommandTest.cs b/modules/composition/composition.tests/commands/HeapdumpCommandTest.cs
new file mode 100644
index 0000000000..70c6b45663
--- /dev/null
+++ b/modules/composition/composition.tests/commands/HeapdumpCommandTest.cs
@@ -0,0 +1,53 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /heapdump 命令单元测试 — 覆盖运行时诊断信息渲染(GC 内存/集合代数/线程池/进程内存/句柄)
+/// 命令零服务依赖,纯 Console 输出;GC/线程池为运行时真实值只断言结构不变量
+///
+[Collection("command-output")]
+public sealed class HeapdumpCommandTest {
+ [Fact]
+ public async Task Execute_RendersRuntimeDiagnosticsSections() {
+ var services = CommandTestHarness.CreateServices();
+ var context = CommandTestHarness.CreateContext(services);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(
+ () => new HeapdumpCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ result.IsHandled.Should().BeTrue();
+ output.Should().Contain("堆转储 / 运行时诊断:");
+ output.Should().Contain("当前托管内存:");
+ output.Should().Contain("压缩后内存:");
+ output.Should().Contain("GC 最大代数:");
+ output.Should().Contain("GC 集合次数:");
+ output.Should().Contain("0 代:");
+ output.Should().Contain("1 代:");
+ output.Should().Contain("2 代:");
+ output.Should().Contain("线程池:");
+ output.Should().Contain("工作线程:");
+ output.Should().Contain("I/O 线程:");
+ output.Should().Contain("进程内存:");
+ output.Should().Contain("句柄数:");
+ }
+
+ [Fact]
+ public async Task Execute_IgnoresArguments_AlwaysRendersDiagnostics() {
+ var services = CommandTestHarness.CreateServices();
+ var context = CommandTestHarness.CreateContext(services, arguments: "anything");
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(
+ () => new HeapdumpCommand().ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("多余参数不影响诊断输出");
+ output.Should().Contain("堆转储 / 运行时诊断:");
+ }
+
+ [Fact]
+ public void NameAndUsage_MetadataShouldBeConsistent() {
+ var cmd = new HeapdumpCommand();
+ cmd.Name.Should().Be("heapdump");
+ cmd.Usage.Should().Be("/heapdump");
+ cmd.IsHidden.Should().BeTrue();
+ }
+}
diff --git a/modules/composition/composition.tests/commands/HistoryCommandTest.cs b/modules/composition/composition.tests/commands/HistoryCommandTest.cs
new file mode 100644
index 0000000000..16957b69b4
--- /dev/null
+++ b/modules/composition/composition.tests/commands/HistoryCommandTest.cs
@@ -0,0 +1,86 @@
+namespace Composition.Tests.Commands;
+
+///
+/// /history 命令单元测试 — 覆盖空历史(null/空列表)提示/多角色消息渲染/元数据一致性
+/// ChatService 为 CommandServices 必填 init 字段,用 Moq 桩自建服务包注入(对齐 MemoryCommandTest 形态)
+///
+[Collection("command-output")]
+public sealed class HistoryCommandTest {
+ private static (HistoryCommand Command, ChatCommandContext Context) Arrange(
+ Mock chatService,
+ string arguments = "") {
+ var commandServices = new CommandServices {
+ ChatService = chatService.Object,
+ CodeService = Mock.Of(),
+ PlanService = Mock.Of(),
+ FileSystem = TestFileSystem.Current,
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ };
+ var context = new ChatCommandContext {
+ Arguments = arguments,
+ SessionId = "test-session",
+ CancellationToken = CancellationToken.None,
+ Services = new CommandServiceProvider(commandServices),
+ };
+ return (new HistoryCommand(), context);
+ }
+
+ [Fact]
+ public async Task Execute_EmptyHistory_ShowsPlaceholder() {
+ var chat = new Mock();
+ chat.Setup(c => c.GetMessageListAsync(It.IsAny()))
+ .ReturnsAsync(new List());
+ var (cmd, context) = Arrange(chat);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ output.Should().Contain("=== 聊天历史 ===");
+ output.Should().Contain("(暂无聊天历史)", "空列表应显示占位提示而非空输出");
+ }
+
+ [Fact]
+ public async Task Execute_NullHistory_ShowsPlaceholder() {
+ var chat = new Mock();
+ chat.Setup(c => c.GetMessageListAsync(It.IsAny()))
+#pragma warning disable JCC11003 // 边界用例:生产代码显式处理 null 历史(is null 分支),mock 需注入 null 验证该防御路径
+ .Returns(Task.FromResult>(null!));
+#pragma warning restore JCC11003
+ var (cmd, context) = Arrange(chat);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue("null 历史应降级为占位提示而非崩溃");
+ output.Should().Contain("=== 聊天历史 ===");
+ output.Should().Contain("(暂无聊天历史)");
+ }
+
+ [Fact]
+ public async Task Execute_WithMessages_RendersRoleAndContent() {
+ var chat = new Mock();
+ chat.Setup(c => c.GetMessageListAsync(It.IsAny()))
+ .ReturnsAsync(new List {
+ new() { Role = "user", Content = "你好,帮我写个冒泡排序" },
+ new() { Role = "assistant", Content = "好的,这是冒泡排序实现" },
+ });
+ var (cmd, context) = Arrange(chat);
+
+ var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
+
+ result.ShouldContinue.Should().BeTrue();
+ chat.Verify(c => c.GetMessageListAsync(It.IsAny