diff --git a/modules/composition/composition.tests/commands/FalvCommandTest.cs b/modules/composition/composition.tests/commands/FalvCommandTest.cs index 53f0d9f8e6..7fcb6aa401 100644 --- a/modules/composition/composition.tests/commands/FalvCommandTest.cs +++ b/modules/composition/composition.tests/commands/FalvCommandTest.cs @@ -274,10 +274,35 @@ public async Task Execute_Continue_Exhausted_RefillsAndContinues() { 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("续费方式: both", "续费方式回显 [EnumValue] 真值小写对齐输入形态"); clean.Should().Contain("继续推理中..."); } + /// 生产 bug 修复(TASK065 law 域): --continue both 回显"续费方式: Both"(枚举 ToString + /// PascalCase)与用户小写输入形态不一致。修复后回显用 ToValue()([EnumValue] 真值小写)对齐输入。 + [Theory] + [InlineData("both", "both")] + [InlineData("rounds", "rounds_only")] + [InlineData("tokens", "tokens_only")] + [InlineData("default", "default")] + [InlineData("", "default")] + public async Task Execute_Continue_RefillModeEcho_UsesEnumValueForm(string argument, string echoed) { + var engine = CreateEngine(budget: new BudgetStatus { + RoundsUsed = 5, + RoundsBudget = 5, + TokensUsed = 200, + TokensBudget = 200, + }); + var (cmd, context, _) = Arrange(arguments: $"--continue {argument}".TrimEnd(), engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + clean.Should().Contain($"续费方式: {echoed}", + $"用户输入 '{argument}' 时回显应使用 [EnumValue] 真值 '{echoed}' 而非枚举 ToString"); + } + [Theory] [InlineData("--continue rounds", BudgetRefillMode.RoundsOnly)] [InlineData("--continue tokens", BudgetRefillMode.TokensOnly)] diff --git a/modules/composition/composition.tests/commands/GoalCommandTest.cs b/modules/composition/composition.tests/commands/GoalCommandTest.cs index abaa9bcad2..6e4d2a98e3 100644 --- a/modules/composition/composition.tests/commands/GoalCommandTest.cs +++ b/modules/composition/composition.tests/commands/GoalCommandTest.cs @@ -178,6 +178,30 @@ public async Task Execute_WithObjective_ConstraintAndBudget_ParsedIntoStart() { output.Should().Contain(" 预算: 5000 Token"); } + /// 生产 bug 修复(TASK065 第五波): usage 文档形态 "/goal [初始提示] [--constraint ...] [--budget N]" + /// 目标在前 — 选项解析应为位置无关剥离,objective 纯净不含选项原文,约束与预算正常提取传 StartAsync。 + [Fact] + public async Task Execute_ObjectiveFirstDocForm_PassesCleanObjectiveToStart() { + 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("优化性能") && !p.Contains("--constraint") && !p.Contains("--budget")), + It.IsAny?>(), + It.Is(b => b == 5000), + It.IsAny(), + It.IsAny()), Times.Once, + "目标在前的文档形态应剥离选项后传纯净 objective"); + output.Should().Contain(" 初始提示: 优化性能", "初始提示不应混入选项原文"); + output.Should().Contain(" 预填约束: 不许改配置"); + output.Should().Contain(" 预算: 5000 Token"); + } + [Fact] public async Task Execute_StartFailure_ConvergesToErrorHint() { var engine = CreateEngine(); @@ -312,10 +336,10 @@ public async Task Execute_RegistryPause_PausesViaRegistry() { output.Should().Contain("◎ /goal 已暂停"); } - /// 生产 bug 登记(TASK065 brain 域): usage 提示 "/goal [初始提示] [--constraint '约束'] [--budget N]" - /// 的文档形态(目标在前、选项在后)不被 ParseGoalArgs 支持 — 选项剥离仅匹配 remaining 开头, + /// 生产 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", "每周一早九点")] @@ -354,6 +378,35 @@ public void ParseGoalArgs_InvalidBudget_Ignored() { parsed.Objective.Should().Be("目标"); } + /// 生产 bug 修复(TASK065 第五波): 目标在前的文档形态 — 选项剥离应位置无关, + /// 扫描全串提取 --constraint/--budget,objective 纯净不含选项原文。 + [Theory] + [InlineData("优化性能 --constraint '不许改配置' --budget 5000", "优化性能", 5000, "不许改配置")] + [InlineData("优化性能 --budget 5000", "优化性能", 5000, "")] + [InlineData("优化性能 --constraint '不许改配置'", "优化性能", null, "不许改配置")] + [InlineData("优化性能 --Constraint '大写选项' --Budget 99", "优化性能", 99, "大写选项")] + [InlineData("优化性能 --constraint '步骤A' --constraint 步骤B --budget 100", "优化性能", 100, "步骤A|步骤B")] + public void ParseGoalArgs_ObjectiveFirstDocForm_StripsOptionsAnywhere( + string args, string expectObjective, int? expectBudget, string expectConstraints) { + var parsed = GoalCommand.ParseGoalArgs(args); + + parsed.IsCron.Should().BeFalse($"'{args}' 不应误判为 cron 形态"); + parsed.Objective.Should().Be(expectObjective, "目标在前时 objective 应纯净不含选项原文"); + parsed.TokenBudget.Should().Be(expectBudget); + List expectList = expectConstraints.Length == 0 ? [] : [.. expectConstraints.Split('|')]; + parsed.Constraints.Should().Equal(expectList); + } + + /// 生产 bug 修复(TASK065 第五波): 选项夹在目标中间 — 剥离选项 token 后拼接剩余文本为 objective。 + [Fact] + public void ParseGoalArgs_InlineOptions_JoinsRemainingObjectiveText() { + var parsed = GoalCommand.ParseGoalArgs("重构模块A --constraint '不改API' 完成后通知 --budget 2000"); + + parsed.Objective.Should().Be("重构模块A 完成后通知", "选项夹在目标中间应剥离并拼接剩余文本"); + parsed.Constraints.Should().Equal("不改API"); + parsed.TokenBudget.Should().Be(2000); + } + [Fact] public void NameAndUsage_MetadataShouldBeConsistent() { var cmd = new GoalCommand(); diff --git a/modules/composition/composition.tests/commands/ProactiveCommandTest.cs b/modules/composition/composition.tests/commands/ProactiveCommandTest.cs index b1c3e6713d..588d749c3a 100644 --- a/modules/composition/composition.tests/commands/ProactiveCommandTest.cs +++ b/modules/composition/composition.tests/commands/ProactiveCommandTest.cs @@ -2,7 +2,7 @@ namespace Composition.Tests.Commands; /// /// /proactive 命令单元测试 — 覆盖 on(含别名)激活/off(含别名)停用/pause/resume(含短别名)/ -/// status 状态查询/未识别参数引导/服务缺失静默/元数据一致性 +/// status 状态查询/未识别参数引导/服务缺失降级提示不误导(TASK065 生产 bug 修复)/元数据一致性 /// ProactiveStateService 用 Moq 桩经 DI 注入 /// [Collection("command-output")] @@ -122,21 +122,36 @@ public async Task Execute_UnrecognizedArg_ShowsErrorAndUsage() { service.Verify(s => s.Activate(It.IsAny()), Times.Never, "未识别参数不应触发任何状态变更"); } - /// 生产 bug 登记(TASK065 brain 域): IProactiveStateService 缺失时 OnEnabledAsync 的 - /// "主动模式已激活" 提示在 proactiveService?.Activate() 安全调用之后仍无条件打印 — - /// 服务未注册却向用户宣称激活成功,输出与实际状态不符。本测试按当前实现固化行为 - /// (提示输出 + Activate 未调用);修复后应改为断言提示不输出或改为服务不可用提示。 + /// 生产 bug 修复(TASK065 brain 域): IProactiveStateService 缺失时 OnEnabledAsync 的 + /// "主动模式已激活"提示在 proactiveService?.Activate() 安全调用之后仍无条件打印 — + /// 服务未注册却向用户宣称激活成功,输出与实际状态不符。修复后:服务缺失输出降级提示, + /// 激活提示仅在服务成功路径输出。 [Fact] - public async Task Execute_WithoutService_ContinuesWithMisleadingActivationNotice() { + public async Task Execute_WithoutService_ShowsDegradedNoticeInsteadOfActivation() { var services = CommandTestHarness.CreateServices(); var context = CommandTestHarness.CreateContext(services, arguments: "on"); var (result, output) = await CommandTestHarness.CaptureOutputAsync( () => new ProactiveCommand().ExecuteAsync(context)); - result.ShouldContinue.Should().BeTrue("服务缺失时应静默降级(proactiveService?. 安全访问)而非崩溃"); - output.Should().Contain("主动模式已激活", - "当前实现: 服务缺失时激活提示仍输出(bug 已登记,未擅自修复)"); + result.ShouldContinue.Should().BeTrue("服务缺失时应静默降级而非崩溃"); + output.Should().NotContain("主动模式已激活", "服务缺失时不应宣称激活成功"); + output.Should().Contain("主动模式状态服务不可用", "服务缺失应输出降级提示"); + } + + /// 对称缺陷(TASK065 brain 域): OnDisabledAsync 的"主动模式已停用"提示与激活提示 + /// 同样在服务缺失时无条件打印。修复后停用提示同样仅在服务成功路径输出。 + [Fact] + public async Task Execute_WithoutService_Off_ShowsDegradedNoticeInsteadOfDeactivation() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: "off"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new ProactiveCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("服务缺失时应静默降级而非崩溃"); + output.Should().NotContain("主动模式已停用", "服务缺失时不应宣称停用成功"); + output.Should().Contain("主动模式状态服务不可用", "服务缺失应输出降级提示"); } [Fact] diff --git a/modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs b/modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs index 44c61793ad..10dfe465f0 100644 --- a/modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs +++ b/modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs @@ -3,7 +3,7 @@ namespace Composition.Tests.Commands; /// /// /sandbox-toggle 命令单元测试 — 覆盖 status 默认分支(禁用态/活动沙箱详情)/on+off 非交互确认拒绝降级/ /// switch(缺类型用法/无效类型/有效类型切换/管理器缺失)/exclude(缺参列表/路径不存在/新增持久化/ -/// 重复路径幂等/配置服务缺失)/管理器缺失静默降级 +/// 重复路径幂等/配置服务缺失)/管理器缺失静默降级/混合大小写路径保留与大写子命令分派(TASK065 第五波修复) /// ISandboxManager 与 IConfigurationService 经 DI 注入;确认走静态 Confirmation, /// 测试环境非交互恒拒绝 — 断言以硬编码中文文案+参数不变量为主 /// @@ -195,7 +195,6 @@ public async Task Execute_Exclude_WithoutPath_ShowsCurrentExclusions() { [Fact] public async Task Execute_Exclude_MissingPath_ShowsPathNotFound() { var config = new Mock(); - // 命令侧 GetNormalizedArgs().ToLowerInvariant() 会把路径参数一并小写化 — 用小写路径构造避免大小写歧义 var (cmd, _, configMock, context, _) = Arrange( arguments: @"exclude c:\no-such-dir", configOverride: config); @@ -255,6 +254,56 @@ public async Task Execute_Exclude_WithoutConfigService_DoesNotThrow() { config.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + /// 生产 bug 修复(TASK065 第五波): 整串 ToLowerInvariant 导致路径被小写化 — + /// 修复后仅命令词小写化分派,路径不存在提示应保留用户输入的原始大小写。 + [Fact] + public async Task Execute_Exclude_MixedCasePath_PreservesOriginalCaseInPrompt() { + var config = new Mock(); + var (cmd, _, _, context, _) = Arrange( + arguments: @"exclude C:\No-Such-Dir", configOverride: config); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain(@"C:\No-Such-Dir", "路径不存在提示应保留用户输入的原始大小写"); + output.Should().NotContain(@"c:\no-such-dir", "不应出现整串小写化后的路径"); + } + + /// 生产 bug 修复(TASK065 第五波): 混合大小写目录的排除持久化 — + /// 写入配置的完整路径应保留用户输入的原始大小写,而非小写化形态。 + [Fact] + public async Task Execute_Exclude_MixedCaseDir_PersistsFullPathWithOriginalCase() { + var dir = Path.Combine(Path.GetTempPath(), "SB-Case-Keep"); + var config = new Mock(); + config.Setup(c => c.GetAsync("sandbox.excludedPaths", It.IsAny())) + .ReturnsAsync((string?)null); + var (cmd, _, _, context, fs) = Arrange(arguments: $"exclude {dir}", configOverride: config); + 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("sandbox.excludedPaths", fullPath, It.IsAny()), Times.Once, + "持久化路径应保留用户输入的原始大小写"); + output.Should().Contain(fullPath, "排除成功提示应含原始大小写完整路径"); + } + + /// 回归保障: 修复仅小写化命令词用于分派 — 大写子命令应照常分派到 exclude 列表分支。 + [Fact] + public async Task Execute_UpperCaseSubCommand_StillDispatches() { + var config = new Mock(); + config.Setup(c => c.GetAsync("sandbox.excludedPaths", It.IsAny())) + .ReturnsAsync(@"C:\path-a"); + var (cmd, _, _, context, _) = Arrange(arguments: "EXCLUDE", configOverride: config); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain(@"C:\path-a", "大写子命令应照常小写化分派到 exclude"); + output.Should().Contain("/sandbox-toggle exclude "); + } + [Fact] public void NameAndUsage_MetadataShouldBeConsistent() { var cmd = new SandboxToggleCommand(); diff --git a/modules/composition/composition.tests/commands/VimCommandTest.cs b/modules/composition/composition.tests/commands/VimCommandTest.cs index 274b807a6b..2decc2322e 100644 --- a/modules/composition/composition.tests/commands/VimCommandTest.cs +++ b/modules/composition/composition.tests/commands/VimCommandTest.cs @@ -2,7 +2,8 @@ namespace Composition.Tests.Commands; /// /// /vim 命令单元测试 — 覆盖 on/enable 启用(VimEngine.Enable+SetMode(Vim)+按键提示)/ -/// off/disable 禁用/无参 toggle(Normal→Vim / Vim→Normal 互斥切换)/服务缺失降级不抛/ +/// off/disable 禁用/无参 toggle(Normal→Vim / Vim→Normal 互斥切换)/ +/// status 查询当前模式不切换(TASK065 生产 bug 修复)/服务缺失降级不抛/ /// 大小写别名归一/命令元数据 /// IVimEngine 与 IEditorModeService 经 DI 注入;继承 ToggleCommandBase 三分支骨架 /// @@ -129,6 +130,35 @@ public async Task Execute_Toggle_WithoutModeService_FallsBackToNormalDefault() { output.Should().Contain("编辑模式: Vim"); } + /// 生产 bug 修复(TASK065 guard 域): /vim status 经 FromValue 解析为 ToggleAction.Status + /// 落入基类 default 分支,因 NullAction 未覆写(默认 Toggle)误触发 OnToggleAsync 切换模式。 + /// 修复后 status 仅显示当前编辑模式,不触发引擎启停与模式切换。 + [Fact] + public async Task Execute_Status_ShowsCurrentModeWithoutToggling() { + var (cmd, engine, modeService, context) = Arrange(arguments: "status", currentMode: EditorMode.Vim); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.Enable(), Times.Never, "status 不应启用 Vim 引擎"); + engine.Verify(e => e.Disable(), Times.Never, "status 不应停用 Vim 引擎"); + modeService.Verify(m => m.SetMode(It.IsAny()), Times.Never, "status 不应切换编辑模式"); + output.Should().Contain("编辑模式: Vim", "status 应显示当前编辑模式"); + } + + /// status 参数大小写归一 — 与 on/off 一致按 ToLowerInvariant/OrdinalIgnoreCase 匹配。 + [Fact] + public async Task Execute_StatusUppercase_AlsoShowsModeWithoutToggling() { + var (cmd, engine, modeService, context) = Arrange(arguments: "STATUS", currentMode: EditorMode.Normal); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.Enable(), Times.Never, "STATUS 不应启用 Vim 引擎"); + modeService.Verify(m => m.SetMode(It.IsAny()), Times.Never, "STATUS 不应切换编辑模式"); + output.Should().Contain("编辑模式: Normal (标准 readline 键绑定)", "STATUS 应显示当前编辑模式"); + } + [Fact] public void NameAndUsage_MetadataShouldBeConsistent() { var cmd = new VimCommand(); diff --git a/modules/composition/composition.tests/commands_wave123/ExportCommandTest.cs b/modules/composition/composition.tests/commands_wave123/ExportCommandTest.cs index 1f81583ffd..c36dd5ebcc 100644 --- a/modules/composition/composition.tests/commands_wave123/ExportCommandTest.cs +++ b/modules/composition/composition.tests/commands_wave123/ExportCommandTest.cs @@ -104,6 +104,66 @@ public async Task Execute_NoArgsNonInteractive_AutoSavesWithSmartFilename() { output.Should().Contain("hello-world-query", "智能文件名应从首条用户消息 sanitize 派生(小写+连字符)"); } + [Fact] + public async Task Execute_NoArgsNonInteractive_PureCjkFirstLine_FallsBackToConversationName() { + // 对齐 TS sanitizeFilename: replace(/[^a-z0-9\s-]/g,'') — 纯 CJK 首行全剥离 → 回退 conversation-{timestamp}.txt + var chat = new Mock(); + chat.Setup(c => c.GetMessageListAsync(It.IsAny())).ReturnsAsync(new List { + new() { Role = "user", Content = "帮我写一个单元测试" }, + }); + var (cmd, _, _, context) = Arrange(arguments: "", chatService: chat); + + var (_, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + output.Should().Contain("conversation-", "纯 CJK 首行被 TS 语义全剥离时应回退时间戳文件名"); + output.Should().NotContain("帮我", "CJK 字符不得出现在导出路径中(TS 基准仅保留 [a-z0-9\\s-])"); + } + + [Fact] + public async Task Execute_NoArgsNonInteractive_MixedCjkAscii_StripsCjkKeepsAscii() { + // 对齐 TS: 混合首行仅保留 ASCII 段 — "帮我 Fix Bug 123" → "fix-bug-123" + var chat = new Mock(); + chat.Setup(c => c.GetMessageListAsync(It.IsAny())).ReturnsAsync(new List { + new() { Role = "user", Content = "帮我 Fix Bug 123" }, + }); + var (cmd, _, _, context) = Arrange(arguments: "", chatService: chat); + + var (_, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + output.Should().Contain("fix-bug-123", "CJK 剥离后 ASCII 段应保留并按空白转连字符"); + output.Should().NotContain("帮我", "混合首行中的 CJK 部分应被剥离"); + } + + [Fact] + public async Task Execute_NoArgsNonInteractive_MultipleHyphenRuns_CollapseToSingle() { + // 对齐 TS: replace(/-+/g,'-') — 5 连字符与连续空白均收敛为单个 '-' + var chat = new Mock(); + chat.Setup(c => c.GetMessageListAsync(It.IsAny())).ReturnsAsync(new List { + new() { Role = "user", Content = "Fix-----Bug" }, + }); + var (cmd, _, _, context) = Arrange(arguments: "", chatService: chat); + + var (_, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + output.Should().Contain("fix-bug", "5 连字符应完全收敛为单个(TS -+ 语义,非两次逐级 Replace)"); + output.Should().NotContain("fix--", "不得残留连续连字符"); + } + + [Fact] + public async Task Execute_NoArgsNonInteractive_LongFirstLine_NoEllipsisInFilename() { + // 对齐 TS: 截断发生在 sanitize 前(extractFirstPrompt 49+'…','…' 随后被剥离)— 文件名不含省略号 + var chat = new Mock(); + chat.Setup(c => c.GetMessageListAsync(It.IsAny())).ReturnsAsync(new List { + new() { Role = "user", Content = new string('a', 60) }, + }); + var (cmd, _, _, context) = Arrange(arguments: "", chatService: chat); + + var (_, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + output.Should().Contain(new string('a', 49), "截断应保留前 49 个原始字符"); + output.Should().NotContain("…", "省略号应被 sanitize 剥离,不得出现在导出路径中"); + } + [Fact] public async Task Execute_EmptyHistory_WritesPlaceholderContent() { var (cmd, _, fs, context) = Arrange(arguments: "export-empty.txt", chatService: new Mock()); diff --git a/modules/composition/slash/agents/law/FalvCommand.cs b/modules/composition/slash/agents/law/FalvCommand.cs index ce6003f27f..51cbec4a15 100644 --- a/modules/composition/slash/agents/law/FalvCommand.cs +++ b/modules/composition/slash/agents/law/FalvCommand.cs @@ -135,7 +135,7 @@ private static async Task ContinueReasoningAsync(IReasoningEngine engine, string }; TerminalHelper.WriteLine($"{TerminalColors.Warning}[预算耗尽]{AnsiStyleEnumConstants.Reset} {causeLabel}"); - TerminalHelper.WriteLine($"续费方式: {mode}"); + TerminalHelper.WriteLine($"续费方式: {mode.ToValue()}"); TerminalHelper.WriteLine("继续推理中..."); await engine.ContinueAsync(mode, ct: ct).ConfigureAwait(false); diff --git a/modules/composition/slash/brain/task/GoalCommand.cs b/modules/composition/slash/brain/task/GoalCommand.cs index 1b5f1ef969..65f938bc9d 100644 --- a/modules/composition/slash/brain/task/GoalCommand.cs +++ b/modules/composition/slash/brain/task/GoalCommand.cs @@ -252,48 +252,111 @@ private async Task ExecuteCronGoalAsync(IGoalEngine goalEngine, ChatCommandConte TerminalHelper.WriteLine($" 任务ID: {cronTask.Id}"); } - internal static GoalParseResult ParseGoalArgs(string args) { - if (args.StartsWith("--cron ", StringComparison.OrdinalIgnoreCase) || - args.StartsWith("-c ", StringComparison.OrdinalIgnoreCase)) { - var rest = args.StartsWith("--cron ", StringComparison.OrdinalIgnoreCase) - ? args["--cron ".Length..] - : args["-c ".Length..]; - - var tokens = rest.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length < 5) { - return new GoalParseResult(string.Empty, [], null, null, true); - } + /// + /// 解析 /goal 参数 — cron 前缀形态优先判定;其余按位置无关扫描全串提取 --constraint/--budget 选项 token, + /// 剩余文本拼接为 objective(对齐 usage 文档形态 "/goal [初始提示] [--constraint ...] [--budget N]",目标在前选项在后同样生效)。 + /// + /// 归一化后的命令参数串。 + /// 解析结果(objective/约束/预算或 cron 表达式)。 + internal static GoalParseResult ParseGoalArgs(string args) => + TryParseCronArgs(args) ?? ParsePositionIndependentOptions(args); + + /// + /// 尝试按 --cron/-c 前缀形态解析定时目标参数;cron 用法固定为选项在前,不参与位置无关剥离。 + /// + /// 命令参数串。 + /// cron 解析结果;非 cron 前缀形态返回 null。 + private static GoalParseResult? TryParseCronArgs(string args) { + if (!args.StartsWith("--cron ", StringComparison.OrdinalIgnoreCase) && + !args.StartsWith("-c ", StringComparison.OrdinalIgnoreCase)) { + return null; + } + + var rest = args.StartsWith("--cron ", StringComparison.OrdinalIgnoreCase) + ? args["--cron ".Length..] + : args["-c ".Length..]; - var cronExpr = string.Join(' ', tokens[..5]); - var desc = tokens.Length > 5 ? string.Join(' ', tokens[5..]) : string.Empty; - return new GoalParseResult(desc.Trim(), [], null, cronExpr, true); + var tokens = rest.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 5) { + return new GoalParseResult(string.Empty, [], null, null, true); } + var cronExpr = string.Join(' ', tokens[..5]); + var desc = tokens.Length > 5 ? string.Join(' ', tokens[5..]) : string.Empty; + return new GoalParseResult(desc.Trim(), [], null, cronExpr, true); + } + + /// + /// 位置无关解析 --constraint/--budget 选项 — 逐 token 扫描全串,选项 token(含其值)被剥离,普通 token 累积为 objective。 + /// + /// 命令参数串。 + /// 解析结果(objective 为剥离选项后的剩余文本拼接)。 + private static GoalParseResult ParsePositionIndependentOptions(string args) { var constraints = new List(); int? tokenBudget = null; + var objectiveParts = new List(); var remaining = args; - while (true) { - if (remaining.StartsWith("--constraint ", StringComparison.OrdinalIgnoreCase)) { - var rest = remaining["--constraint ".Length..]; - var constraint = ExtractQuotedOrWord(rest, out var afterConstraint); + while (remaining.Length > 0) { + remaining = remaining.TrimStart(); + if (remaining.Length == 0) { + break; + } + + if (TryExtractOptionValue(remaining, "--constraint", out var constraint, out var afterOption)) { if (!string.IsNullOrWhiteSpace(constraint)) { constraints.Add(constraint); } - remaining = afterConstraint.TrimStart(); - } else if (remaining.StartsWith("--budget ", StringComparison.OrdinalIgnoreCase)) { - var rest = remaining["--budget ".Length..]; - var budgetStr = ExtractQuotedOrWord(rest, out var afterBudget); - if (int.TryParse(budgetStr, out var budget)) { - tokenBudget = budget; + remaining = afterOption; + } else if (TryExtractOptionValue(remaining, "--budget", out var budget, out afterOption)) { + if (int.TryParse(budget, out var parsed)) { + tokenBudget = parsed; } - remaining = afterBudget.TrimStart(); + remaining = afterOption; } else { - break; + remaining = ExtractWord(remaining, out var word); + objectiveParts.Add(word); } } - return new GoalParseResult(remaining.Trim(), constraints, tokenBudget, null, false); + return new GoalParseResult(string.Join(' ', objectiveParts).Trim(), constraints, tokenBudget, null, false); + } + + /// + /// 尝试从 remaining 开头提取指定选项名的值(引号或单词形态); + /// 要求选项名后为空格或结尾(词边界),避免 "--constraintX" 之类前缀误匹配。 + /// + /// 待扫描的剩余参数串。 + /// 选项名(如 --constraint)。 + /// 提取到的选项值。 + /// 提取后剩余的参数串。 + /// remaining 开头匹配指定选项时返回 true。 + private static bool TryExtractOptionValue(string remaining, string optionName, out string value, out string after) { + if (!remaining.StartsWith(optionName, StringComparison.OrdinalIgnoreCase) || + (remaining.Length > optionName.Length && remaining[optionName.Length] != ' ')) { + (value, after) = (string.Empty, remaining); + return false; + } + + value = ExtractQuotedOrWord(remaining[optionName.Length..].TrimStart(), out after); + return true; + } + + /// + /// 提取 remaining 的首个普通 token(至空格为止)入 objective,返回其后的剩余部分。 + /// + /// 待扫描的剩余参数串。 + /// 提取到的普通 token。 + /// token 之后的剩余参数串。 + private static string ExtractWord(string remaining, out string word) { + var spaceIdx = remaining.IndexOf(' '); + if (spaceIdx < 0) { + word = remaining; + return string.Empty; + } + + word = remaining[..spaceIdx]; + return remaining[(spaceIdx + 1)..]; } private static string ExtractQuotedOrWord(string input, out string remaining) { diff --git a/modules/composition/slash/brain/task/ProactiveCommand.cs b/modules/composition/slash/brain/task/ProactiveCommand.cs index b2836409b6..4e279e38e8 100644 --- a/modules/composition/slash/brain/task/ProactiveCommand.cs +++ b/modules/composition/slash/brain/task/ProactiveCommand.cs @@ -37,29 +37,46 @@ public sealed class ProactiveCommand : ToggleCommandBase { protected override ToggleNullAction NullAction => ToggleNullAction.Status; /// - /// 启用主动模式时的处理逻辑。 + /// 启用主动模式时的处理逻辑 — 服务缺失时输出降级提示,激活提示仅在服务成功路径输出。 /// /// 命令执行上下文。 /// 表示异步操作的任务。 protected override Task OnEnabledAsync(ChatCommandContext context) { - var proactiveService = GetService(context); - proactiveService?.Activate("user-command"); + var proactiveService = GetProactiveServiceOrNoticeUnavailable(context); + if (proactiveService is null) return Task.CompletedTask; + + proactiveService.Activate("user-command"); TerminalHelper.WriteLine("主动模式已激活"); return Task.CompletedTask; } /// - /// 停用主动模式时的处理逻辑。 + /// 停用主动模式时的处理逻辑 — 服务缺失时输出降级提示,停用提示仅在服务成功路径输出。 /// /// 命令执行上下文。 /// 表示异步操作的任务。 protected override Task OnDisabledAsync(ChatCommandContext context) { - var proactiveService = GetService(context); - proactiveService?.Deactivate(); + var proactiveService = GetProactiveServiceOrNoticeUnavailable(context); + if (proactiveService is null) return Task.CompletedTask; + + proactiveService.Deactivate(); TerminalHelper.WriteLine("主动模式已停用"); return Task.CompletedTask; } + /// + /// 获取主动模式状态服务 — 服务缺失时输出降级提示并返回 null。 + /// + /// 命令执行上下文。 + /// 主动模式状态服务实例,未注册时为 null。 + private IProactiveStateService? GetProactiveServiceOrNoticeUnavailable(ChatCommandContext context) { + var proactiveService = GetService(context); + if (proactiveService is null) { + TerminalHelper.WriteLine("主动模式状态服务不可用"); + } + return proactiveService; + } + /// /// 默认动作处理(pause/resume 等非开关动作)。 /// diff --git a/modules/composition/slash/guard/config/SandboxToggleCommand.cs b/modules/composition/slash/guard/config/SandboxToggleCommand.cs index 3850e20272..4a5d66838b 100644 --- a/modules/composition/slash/guard/config/SandboxToggleCommand.cs +++ b/modules/composition/slash/guard/config/SandboxToggleCommand.cs @@ -10,14 +10,15 @@ namespace JoinCode.ChatCommands; public sealed class SandboxToggleCommand : ChatCommandBase { /// /// 执行 /sandbox-toggle 命令 — 根据子命令分发到对应沙箱操作 - /// 支持 on/off/status/exclude/switch 五种操作,默认 status + /// 支持 on/off/status/exclude/switch 五种操作,默认 status; + /// 仅命令词小写化用于分派,路径/类型等参数保留用户输入原大小写传入后续逻辑 /// /// 命令执行上下文,提供参数、服务、取消令牌等 /// 命令执行结果,始终返回 Continue 表示继续会话 public override async Task ExecuteAsync(ChatCommandContext context) { var sandboxManager = ChatCommandBase.GetService(context); - var args = ChatCommandBase.GetNormalizedArgs(context).ToLowerInvariant(); - var subCommand = args.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? args; + var args = ChatCommandBase.GetNormalizedArgs(context); + var subCommand = ExtractSubCommand(args); if (subCommand is "exclude") { await HandleExcludeAsync(context, args).ConfigureAwait(false); @@ -38,6 +39,15 @@ public override async Task ExecuteAsync(ChatCommandContext co return ChatCommandResult.Continue(); } + /// + /// 提取参数串首个 token 并小写化为分派命令词 — + /// 后续参数(路径/类型)保留原大小写,输出提示与持久化路径与用户输入保持一致 + /// + /// 归一化后的命令参数串 + /// 小写化的子命令词;空参数返回空字符串 + private static string ExtractSubCommand(string args) => + (args.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty).ToLowerInvariant(); + private static async Task EnableSandboxAsync(ISandboxManager sandboxManager, ChatCommandContext context) { var confirmed = await Confirmation.ConfirmAsync("确定要启用沙箱模式吗?启用后将限制文件系统和网络访问。", context.CancellationToken).ConfigureAwait(false); if (!confirmed) { diff --git a/modules/composition/slash/guard/config/VimCommand.cs b/modules/composition/slash/guard/config/VimCommand.cs index 22deb88113..62c0d73c85 100644 --- a/modules/composition/slash/guard/config/VimCommand.cs +++ b/modules/composition/slash/guard/config/VimCommand.cs @@ -2,7 +2,7 @@ namespace JoinCode.ChatCommands; /// /// /vim 命令 — 切换 Vim 输入模式 -/// 支持显式 on/off 以及无参数时的 toggle 切换 +/// 支持显式 on/off、无参数时的 toggle 切换以及 status 查询当前编辑模式 /// [ChatCommand(Name = ChatCommandNameEnumConstants.Vim, Description = "切换 Vim 输入模式 (无参数时 toggle)", Usage = "/vim [on|off]", Category = ChatCommandCategory.Config)] [ChatCommandArg("action", Type = "string", Description = "Vim 模式动作,省略时 toggle", Enum = new[] { "on", "off" })] @@ -36,6 +36,42 @@ public sealed class VimCommand : ToggleCommandBase { }; } + /// + /// 默认分支 — "status" 参数仅显示当前编辑模式不切换(基类 default 分支按 NullAction=Toggle + /// 会误触发切换,因空参数语义为 toggle 故经此处按 args 区分而非覆写 NullAction) + /// + /// 命令执行上下文,提供参数、服务、取消令牌等 + /// 原始参数字符串 + /// 表示异步操作完成的任务 + protected override Task OnDefaultAsync(ChatCommandContext context, string args) + => ResolveToggleAction(args) == ToggleAction.Status + ? ShowCurrentModeAsync(context) + : base.OnDefaultAsync(context, args); + + /// + /// 显示当前编辑模式 — 读取 IEditorModeService 当前模式并输出,不触发引擎启停与模式切换 + /// + /// 命令执行上下文,提供参数、服务、取消令牌等 + /// 表示异步操作完成的任务 + private async Task ShowCurrentModeAsync(ChatCommandContext context) { + var currentMode = await GetCurrentModeAsync(context).ConfigureAwait(false); + var modeLabel = currentMode == EditorMode.Vim ? "Vim" : "Normal (标准 readline 键绑定)"; + + TerminalHelper.WriteLine($"编辑模式: {modeLabel}"); + } + + /// + /// 读取当前编辑模式 — 服务缺失时按 Normal 默认 + /// + /// 命令执行上下文,提供参数、服务、取消令牌等 + /// 当前编辑模式 + private async Task GetCurrentModeAsync(ChatCommandContext context) { + var editorModeService = GetService(context); + return editorModeService is not null + ? await editorModeService.GetCurrentModeAsync().ConfigureAwait(false) + : EditorMode.Normal; + } + /// /// 启用 Vim 模式 — 激活 Vim 引擎并切换编辑模式,输出按键提示 /// @@ -78,7 +114,7 @@ protected override async Task OnToggleAsync(ChatCommandContext context) { var vimEngine = GetService(context); var editorModeService = GetService(context); - var currentMode = editorModeService is not null ? await editorModeService.GetCurrentModeAsync().ConfigureAwait(false) : EditorMode.Normal; + var currentMode = await GetCurrentModeAsync(context).ConfigureAwait(false); var newMode = currentMode == EditorMode.Vim ? EditorMode.Normal : EditorMode.Vim; if (newMode == EditorMode.Vim) { diff --git a/modules/composition/slash/system/system2/ExportCommand.cs b/modules/composition/slash/system/system2/ExportCommand.cs index c2f8b44e1b..6e45bc69ec 100644 --- a/modules/composition/slash/system/system2/ExportCommand.cs +++ b/modules/composition/slash/system/system2/ExportCommand.cs @@ -134,20 +134,12 @@ private static string ExtractSmartFilename(IReadOnlyList histo if (string.IsNullOrEmpty(firstLine)) return $"conversation-{timestamp}.txt"; - // 对齐 TS: sanitizeFilename — 转小写,移除非 [a-z0-9\s-],空格替换为-,合并连续- - var safeName = new string(firstLine - .Select(c => char.IsLetterOrDigit(c) || c is ' ' or '-' - ? char.IsUpper(c) ? char.ToLowerInvariant(c) : c - : '\0') - .ToArray()) - .Trim('\0') - .Replace(' ', '-') - .Replace("---", "-").Replace("--", "-") - .Trim('-'); - - // 对齐 TS: 长度限制 50 字符 - if (safeName.Length > 50) - safeName = safeName[..49] + "…"; + // 对齐 TS: 长度限制在 sanitize 前发生(extractFirstPrompt 截 49 字符 + '…', + // 其中 '…' 非 [a-z0-9\s-] 会被 sanitize 剥离 — 等效于直接截断且文件名不含省略号) + var firstLineLimited = firstLine.Length > 50 ? firstLine[..49] : firstLine; + + // 对齐 TS: sanitizeFilename — 仅保留 [a-z0-9 空白 -](ASCII),CJK 等非 ASCII 剥离 + var safeName = SanitizeFilename(firstLineLimited); if (string.IsNullOrEmpty(safeName)) return $"conversation-{timestamp}.txt"; @@ -155,4 +147,36 @@ private static string ExtractSmartFilename(IReadOnlyList histo // 对齐 TS: 格式为 {timestamp}-{sanitized}.txt return $"{timestamp}-{safeName}.txt"; } + + /// + /// 对齐 TS sanitizeFilename — 转小写后仅保留 [a-z0-9 空白 -](CJK 等非 ASCII 全剥离), + /// 空白转 '-'、连续 '-' 收敛为单个、去除首尾 '-' + /// + /// 已截断的首条用户消息第一行 + /// sanitize 后的安全文件名片段;全剥离时为空串 + private static string SanitizeFilename(string text) => + JoinHyphenRuns(KeepAsciiRunes(text)); + + /// + /// 字符级白名单过滤 — a-z0-9 原样保留(A-Z 转小写),空白与 '-' 归一为 '-',其余(含 CJK)剥离 + /// + private static string KeepAsciiRunes(string text) => + string.Concat(text.Select(NormalizeRune).Where(c => c != '\0')); + + /// + /// 单字符归一 — 对齐 TS toLowerCase + replace(/[^a-z0-9\s-]/g,'') 的字符级判定; + /// 返回 '\0' 表示剥离('\0' 本身即属剥离集合,可安全用作哨兵) + /// + private static char NormalizeRune(char c) => c switch { + >= 'a' and <= 'z' or >= '0' and <= '9' => c, + >= 'A' and <= 'Z' => char.ToLowerInvariant(c), + '-' or ' ' or '\t' or '\n' or '\r' or '\f' or '\v' => '-', + _ => '\0', + }; + + /// + /// 连字符收敛 — 对齐 TS replace(/-+/g,'-') + replace(/^-|-$/g,''):按 '-' 分段去空后重组 + /// + private static string JoinHyphenRuns(string text) => + string.Join("-", text.Split('-', StringSplitOptions.RemoveEmptyEntries)); } \ No newline at end of file diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 479ee0b160..dbc9850a66 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -32,10 +32,12 @@ public static async Task Main(string[] args) { } } - /// 斜杠命令批量探针(TASK065 — 33 条安全命令集走 TUI 真实链路逐条实测): + /// 斜杠命令批量探针(TASK065 — 52 条安全命令集走 TUI 真实链路逐条实测): /// EngineTurnClient.Submit → DispatchSlashCommandAsync → SlashCommandRunner 真实执行, /// 逐命令分类 OK(有输出)/EMPTY(空回合)/ERROR(异常)/TIMEOUT,输出汇总表供人工核对。 - /// 排除副作用命令(reset-config/logout/clear/状态改写类)与调 LLM 命令(--selftest-turn 已覆盖)。 + /// 排除副作用命令(reset-config/logout/clear/状态改写类)与调 LLM 命令(--selftest-turn 已覆盖)。 + /// 新增仅只读查询类+当前值显示类:无参查询态(/advisor /assistant /buddy /switch /sampling /statusline)、 + /// 默认 list 分支(/hooks /plugin /permissions /workflows /memory /skills)、显式查询分支(/mcp status /plan status /diag /falv)。 private static async Task RunSelftestCommandsAsync() { using var logLevelScope = EnvVarScope.Set("JCC_LOG_LEVEL", "Error"); TestEnvironmentDetector.ForceNonInteractive = true; @@ -49,6 +51,7 @@ private static async Task RunSelftestCommandsAsync() { var probes = new (string Cmd, string Note)[] { ("/tools", "工具-首条时间锚点"), ("/mcp list", "MCP-首条时间锚点"), + ("/mcp status", "MCP-连接状态查询"), ("/status", "综合状态"), ("/version", "版本"), ("/help", "帮助"), @@ -67,14 +70,32 @@ private static async Task RunSelftestCommandsAsync() { ("/privacy-settings show", "隐私"), ("/theme", "主题当前值"), ("/effort", "推理力度当前值"), + ("/sampling", "采样参数当前值"), ("/vendor", "供应商引擎路径"), ("/model info", "模型信息"), ("/agents", "代理列表"), + ("/advisor", "顾问模式当前态"), + ("/assistant", "助手模式当前态"), + ("/buddy", "伙伴精灵信息"), + ("/switch", "输出显示模式当前态"), + ("/memory", "记忆文件列表"), + ("/plan status", "计划模式状态查询"), ("/summary", "摘要"), ("/worktree list", "工作树"), ("/branch list", "分支列表"), ("/session list", "会话列表"), + ("/diff", "未提交变更"), + ("/skills", "自定义技能列表"), + ("/hooks", "Hook配置列表"), + ("/plugin", "插件列表"), + ("/permissions", "权限规则列表"), + ("/workflows", "工作流列表"), ("/debuglog", "调试日志状态"), + ("/statusline", "状态栏当前配置"), + ("/diag", "崩溃快照查询"), + ("/heapdump", "运行时诊断查询"), + ("/falv", "推理引擎帮助"), + ("/peers", "对等节点列表"), ("/quit", "退出类-直接"), ("/exit", "退出类-确认"), ("/nosuchcmd", "未知命令"),