diff --git a/Directory.Build.props b/Directory.Build.props
index 574265b..95073d3 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,7 +1,7 @@
- 0.2.7
+ 0.2.8
10.0.1
diff --git a/README.md b/README.md
index ed80e86..6be66b8 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,25 @@
---
+## 功能目录
+
+- [项目简介](#项目简介)
+- [当前重点能力](#当前重点能力)
+- [多 AI CLI 接入](#多-ai-cli-接入)
+- [Web、移动端、飞书统一会话流](#web移动端飞书统一会话流)
+- [多用户与权限控制](#多用户与权限控制)
+- [外部会话导入与恢复](#外部会话导入与恢复)
+- [Codex `/goal` 快速目标能力](#codex-goal-快速目标能力)
+- [Superpowers 工作流](#superpowers-工作流)
+- [Windows 安装包发布](#windows-安装包发布)
+- [`cc-switch` 托管模型](#cc-switch-托管模型)
+- [快速开始](#快速开始)
+- [配置说明](#配置说明)
+- [Windows 发布维护](#windows-发布维护)
+- [项目结构](#项目结构)
+- [技术栈](#技术栈)
+- [常用文档](#常用文档)
+
## 项目简介
WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它不是单机聊天壳,而是把本地或服务器上的 AI CLI 包装成一个可管理、可部署、可协作、可远程访问的工作系统。
@@ -54,6 +73,47 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它
- 支持导入本地 CLI 会话并继续在 WebCode 中使用
- 支持在移动端抽屉和飞书会话卡片里直接管理会话
+#### 功能入口矩阵
+
+下表用于快速说明三类主要入口当前各自覆盖的能力范围:
+
+| 能力 | 桌面 Web | 移动端 | 飞书 |
+|------|----------|--------|------|
+| 创建、切换、关闭会话 | 支持 | 支持 | 支持 |
+| 导入本地 CLI 会话 | 支持 | 支持 | 支持 |
+| 会话级 Provider 同步 | 支持 | 支持 | 支持 |
+| 工作区浏览与文件操作 | 支持最完整 | 支持常用操作 | 以会话/卡片操作为主 |
+| 流式输出查看 | 支持最完整 | 支持 | 支持卡片流式回显 |
+| `/goal` 快捷目标 | 支持 | 支持 | 支持 |
+| `Superpowers` 快捷工作流 | 支持 | 支持 | 支持 |
+| 多用户管理与系统配置 | 支持最完整 | 适合轻量查看/操作 | 不作为主要入口 |
+| 安装与部署维护 | 主要依赖文档和本地脚本 | 不作为主要入口 | 不作为主要入口 |
+
+补充说明:
+
+- 桌面 Web 是能力最完整的主控制台,适合管理、调试、配置和重度会话操作
+- 移动端保留高频会话操作,重点是随时查看、切换、继续和触发快捷动作
+- 飞书更偏消息驱动和卡片驱动,适合在聊天上下文里直接继续会话、执行快捷动作和接收流式结果
+
+#### 飞书能力清单
+
+飞书当前不是“只做消息通知”,而是已经覆盖一条可直接工作的会话与卡片交互链路:
+
+- 支持把飞书会话绑定到 WebCode 用户和本地/服务器侧 CLI 会话
+- 支持在飞书卡片中创建、切换、关闭、继续会话
+- 支持在飞书端浏览并导入外部 CLI 会话
+- 支持在飞书卡片中触发 `/goal`、`Superpowers` 等快捷动作
+- 支持流式卡片更新,在执行过程中持续回显输出内容
+- 支持会话级 Provider 同步,确保飞书侧也遵循 `cc-switch` 当前激活状态
+- 支持 Reply TTS 相关能力,用于把回复内容进一步转换为语音或语音服务调用链
+- 支持帮助卡片、快捷入口卡片、会话管理卡片等多种交互载体
+
+适合的飞书使用场景包括:
+
+- 在群聊或私聊里直接继续一个已有 AI CLI 会话
+- 在移动办公场景下用卡片完成高频操作,而不打开完整 Web 控制台
+- 用机器人卡片承接流式输出、快捷动作、目标设定和会话切换
+
### 多用户与权限控制
- 用户启用/禁用
@@ -71,6 +131,49 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它
- 同一个 `(ToolId, CliThreadId)` 只能被一个 WebCode 用户占用
- Web 端与飞书端都支持分页浏览、导入并切换到这些会话
+### Codex `/goal` 快速目标能力
+
+当前版本已经补齐 Codex `/goal` 相关能力,覆盖 Web 端、移动端和飞书卡片场景:
+
+- WebCode 会先探测当前 Codex CLI 版本与 `goals` feature 是否可用
+- 当探测结果可用时,会在会话级 `.codex/config.toml` 中自动注入 `goals = true`
+- 用户可以通过快速输入或快捷动作把普通文本自动补成 `/goal ...`
+- `/goal` 能力按会话生效,不要求手工长期维护全局 `config.toml`
+
+说明:
+
+- 当前要求 Codex CLI 版本不低于 `0.128.0`
+- WebCode 在 Windows 下会自动解析 `codex.exe`、`codex.cmd`、`codex.bat`、`codex.ps1` 等入口,避免因包装脚本导致版本探测失败
+- 如果 Codex 本身未提供 `goals` feature,WebCode 不会强行注入该配置
+
+### Superpowers 工作流
+
+当前仓库已经接入 `Superpowers` 工作流增强能力,用来把普通 CLI 会话提升为“带策略和快捷动作的执行流”:
+
+- 支持在 Web 端、移动端和飞书卡片中触发工作流型快捷动作
+- 支持把用户输入快速包装成结构化工作流提示,而不只是原样转发给 CLI
+- 支持能力探测,按当前环境决定是否展示或启用对应的工作流入口
+- 支持把 `Superpowers` 与现有 `Claude Code`、`Codex`、`OpenCode` 会话流结合使用
+
+当前文档和实现已经覆盖的典型工作流包括:
+
+- 计划类工作流,例如 `plan`、`ralplan`
+- 持续执行类工作流,例如 `ralph`
+- 深度澄清类工作流,例如 `deep-interview`
+- 协作/并行类工作流,例如 `team`、`ultrawork`
+- 快捷动作类封装,例如面向会话的一键注入提示词和工作流命令
+
+说明:
+
+- `Superpowers` 不是单独的 Web 页面,而是叠加在现有会话、快捷动作、飞书卡片交互之上的工作流层
+- 哪些工作流可用,取决于当前工具、环境能力探测结果以及会话上下文
+- 相关实现可参考:
+ - [WebCodeCli.Domain/Domain/Service/SuperpowersCapabilityService.cs](./WebCodeCli.Domain/Domain/Service/SuperpowersCapabilityService.cs)
+ - [WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs](./WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs)
+ - [WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs](./WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs)
+ - [WebCodeCli/Pages/CodeAssistant.razor.cs](./WebCodeCli/Pages/CodeAssistant.razor.cs)
+ - [WebCodeCli/Pages/CodeAssistantMobile.razor.cs](./WebCodeCli/Pages/CodeAssistantMobile.razor.cs)
+
### Windows 安装包发布
仓库已经内置 Windows 安装包构建脚本,可直接生成:
@@ -79,6 +182,7 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它
- 便携版 `WebCode-vX.Y.Z-win-x64-portable.zip`
- 校验文件 `SHA256SUMS.txt`
- Release 说明 `RELEASE_NOTES.md`
+- 包含 Reply TTS 服务与运行所需资源的 `tts-bundle/`
## `cc-switch` 托管模型
@@ -257,6 +361,13 @@ dotnet run --project WebCodeCli
powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1
```
+如果你要生成“包含 Reply TTS / Kokoro 能力”的本地 Windows 安装包,应该优先走这条脚本,而不是单独 `dotnet publish`。原因是安装包脚本除了发布主程序外,还会额外处理这些内容:
+
+- 调整发布目录里的 `appsettings.json`
+- 拷贝 `tools/sherpa-kokoro-service`
+- 组装 `tts-bundle`
+- 生成 Inno Setup 安装包与 portable zip
+
脚本会读取 [Directory.Build.props](./Directory.Build.props) 中的版本号,并在 `artifacts/windows-installer/vX.Y.Z/` 下生成:
- `publish/`
@@ -264,6 +375,15 @@ powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1
- `installer/WebCode-Setup-vX.Y.Z-win-x64.exe`
- `SHA256SUMS.txt`
- `RELEASE_NOTES.md`
+- `tts-bundle/`
+
+在 Windows 机器上,如果默认输出目录存在旧文件锁定,或者 Inno Setup 遇到长路径问题,可以显式指定一个较短的输出目录,例如:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1 -OutputRoot D:\wci
+```
+
+这种方式同样会生成完整的安装版、便携版和 TTS 资源目录,适合本地快速出包。
构建机要求:
@@ -311,6 +431,10 @@ WebCode/
- [DEPLOY_DOCKER.md](./DEPLOY_DOCKER.md)
- [docs/QUICKSTART_CodeAssistant.md](./docs/QUICKSTART_CodeAssistant.md)
- [docs/README_CodeAssistant.md](./docs/README_CodeAssistant.md)
+- [docs/superpowers/plans/2026-04-30-superpowers-plan-quick-actions-implementation.md](./docs/superpowers/plans/2026-04-30-superpowers-plan-quick-actions-implementation.md)
+- [docs/superpowers/specs/2026-04-30-superpowers-plan-quick-actions-design.md](./docs/superpowers/specs/2026-04-30-superpowers-plan-quick-actions-design.md)
+- [docs/superpowers/plans/2026-04-24-ralph-runtime-implementation.md](./docs/superpowers/plans/2026-04-24-ralph-runtime-implementation.md)
+- [docs/superpowers/specs/2026-04-24-ralph-runtime-design.md](./docs/superpowers/specs/2026-04-24-ralph-runtime-design.md)
- [docs/workspace-management-guide.md](./docs/workspace-management-guide.md)
- [docs/workspace-management-deployment-guide.md](./docs/workspace-management-deployment-guide.md)
diff --git a/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs b/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs
new file mode 100644
index 0000000..7805db9
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs
@@ -0,0 +1,235 @@
+using Microsoft.Extensions.Options;
+using WebCodeCli.Domain.Common.Options;
+using WebCodeCli.Domain.Domain.Service.Channels;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class AudioTranscodeServiceTests : IDisposable
+{
+ private readonly string _sandboxRoot = Path.Combine(Path.GetTempPath(), "webcode-audio-transcode-tests", Guid.NewGuid().ToString("N"));
+
+ [Fact]
+ public async Task TranscodeChunkAsync_WhenFfmpegPathIsMissing_Throws()
+ {
+ Directory.CreateDirectory(_sandboxRoot);
+ var service = CreateService(
+ new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = Path.Combine(_sandboxRoot, "reply-tts")
+ },
+ new RecordingExternalProcessRunner());
+ var inputPath = CreateInputFile();
+
+ var error = await Assert.ThrowsAsync(() =>
+ service.TranscodeChunkAsync("job-1", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken));
+
+ Assert.Contains("FfmpegExecutablePath", error.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task TranscodeChunkAsync_WhenTempRootIsUnavailable_Throws()
+ {
+ Directory.CreateDirectory(_sandboxRoot);
+ var options = new FeishuReplyTtsOptions
+ {
+ FfmpegExecutablePath = Path.Combine(_sandboxRoot, "ffmpeg.exe")
+ };
+ var service = new AudioTranscodeService(
+ Options.Create(options),
+ new ReplyTtsStorageRootResolver(
+ new MutableOptionsMonitor(options),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true)
+ ])),
+ new RecordingExternalProcessRunner());
+ var inputPath = CreateInputFile();
+
+ var error = await Assert.ThrowsAsync(() =>
+ service.TranscodeChunkAsync("job-1", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken));
+
+ Assert.Contains("unavailable", error.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task TranscodeChunkAsync_WritesUnderResolvedTempRoot_AndInvokesExpectedFfmpegArguments()
+ {
+ Directory.CreateDirectory(_sandboxRoot);
+ var runner = new RecordingExternalProcessRunner();
+ var ffmpegPath = Path.Combine(_sandboxRoot, "ffmpeg.exe");
+ File.WriteAllText(ffmpegPath, "stub");
+
+ var service = CreateService(
+ new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = Path.Combine(_sandboxRoot, "reply-tts"),
+ FfmpegExecutablePath = ffmpegPath
+ },
+ runner);
+ var inputPath = CreateInputFile();
+
+ var outputPath = await service.TranscodeChunkAsync("job-42", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken);
+
+ Assert.Equal(ffmpegPath, runner.FileName);
+ Assert.Contains("-y", runner.Arguments, StringComparison.Ordinal);
+ Assert.Contains("-i", runner.Arguments, StringComparison.Ordinal);
+ Assert.Contains("libopus", runner.Arguments, StringComparison.Ordinal);
+ Assert.Contains("-ac 1", runner.Arguments, StringComparison.Ordinal);
+ Assert.Contains("-ar 16000", runner.Arguments, StringComparison.Ordinal);
+ Assert.Equal(Path.Combine(_sandboxRoot, "reply-tts", "temp", "job-42"), runner.WorkingDirectory);
+ Assert.Equal(Path.Combine(_sandboxRoot, "reply-tts", "temp", "job-42", "chunk-001.opus"), outputPath);
+ }
+
+ [Fact]
+ public async Task TranscodeChunkAsync_WhenRunnerReturnsNonZeroExit_Throws()
+ {
+ Directory.CreateDirectory(_sandboxRoot);
+ var ffmpegPath = Path.Combine(_sandboxRoot, "ffmpeg.exe");
+ File.WriteAllText(ffmpegPath, "stub");
+ var service = CreateService(
+ new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = Path.Combine(_sandboxRoot, "reply-tts"),
+ FfmpegExecutablePath = ffmpegPath
+ },
+ new RecordingExternalProcessRunner
+ {
+ Result = new ExternalProcessResult(1, string.Empty, "ffmpeg failed")
+ });
+ var inputPath = CreateInputFile();
+
+ var error = await Assert.ThrowsAsync(() =>
+ service.TranscodeChunkAsync("job-42", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken));
+
+ Assert.Contains("exit code 1", error.Message, StringComparison.Ordinal);
+ Assert.Contains("ffmpeg failed", error.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task TranscodeChunkAsync_WhenConfiguredPathIsBlank_UsesBundledStorageRootFfmpeg()
+ {
+ Directory.CreateDirectory(_sandboxRoot);
+ var runner = new RecordingExternalProcessRunner();
+ var storageRoot = Path.Combine(_sandboxRoot, "reply-tts");
+ var bundledFfmpegPath = Path.Combine(storageRoot, "ffmpeg", "bin", OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg");
+ Directory.CreateDirectory(Path.GetDirectoryName(bundledFfmpegPath)!);
+ File.WriteAllText(bundledFfmpegPath, "stub");
+
+ var service = CreateService(
+ new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = storageRoot
+ },
+ runner);
+ var inputPath = CreateInputFile();
+
+ await service.TranscodeChunkAsync("job-42", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken);
+
+ Assert.Equal(bundledFfmpegPath, runner.FileName);
+ }
+
+ [Fact]
+ public async Task TranscodeChunkAsync_SanitizesJobIdToStayUnderTempRoot()
+ {
+ Directory.CreateDirectory(_sandboxRoot);
+ var runner = new RecordingExternalProcessRunner();
+ var ffmpegPath = Path.Combine(_sandboxRoot, "ffmpeg.exe");
+ File.WriteAllText(ffmpegPath, "stub");
+ var service = CreateService(
+ new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = Path.Combine(_sandboxRoot, "reply-tts"),
+ FfmpegExecutablePath = ffmpegPath
+ },
+ runner);
+ var inputPath = CreateInputFile();
+
+ var outputPath = await service.TranscodeChunkAsync("..", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken);
+
+ Assert.Equal(Path.Combine(_sandboxRoot, "reply-tts", "temp", "__", "chunk-001.opus"), outputPath);
+ Assert.StartsWith(Path.Combine(_sandboxRoot, "reply-tts", "temp"), outputPath, StringComparison.Ordinal);
+ Assert.Equal(Path.Combine(_sandboxRoot, "reply-tts", "temp", "__"), runner.WorkingDirectory);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_sandboxRoot))
+ {
+ Directory.Delete(_sandboxRoot, recursive: true);
+ }
+ }
+ catch
+ {
+ }
+ }
+
+ private AudioTranscodeService CreateService(FeishuReplyTtsOptions options, RecordingExternalProcessRunner runner)
+ {
+ return new AudioTranscodeService(
+ Options.Create(options),
+ new ReplyTtsStorageRootResolver(
+ new MutableOptionsMonitor(options),
+ new FakeReplyTtsHostEnvironment(isWindows: false, systemDriveRoot: null, drives: [])),
+ runner);
+ }
+
+ private string CreateInputFile()
+ {
+ var inputPath = Path.Combine(_sandboxRoot, "input.wav");
+ File.WriteAllText(inputPath, "wav");
+ return inputPath;
+ }
+
+ private sealed class RecordingExternalProcessRunner : IExternalProcessRunner
+ {
+ public ExternalProcessResult Result { get; set; } = new(0, string.Empty, string.Empty);
+
+ public string? FileName { get; private set; }
+
+ public string? Arguments { get; private set; }
+
+ public string? WorkingDirectory { get; private set; }
+
+ public Task RunAsync(
+ string fileName,
+ string arguments,
+ string? workingDirectory = null,
+ CancellationToken cancellationToken = default)
+ {
+ FileName = fileName;
+ Arguments = arguments;
+ WorkingDirectory = workingDirectory;
+ return Task.FromResult(Result);
+ }
+ }
+
+ private sealed class MutableOptionsMonitor(TOptions currentValue) : IOptionsMonitor
+ {
+ public TOptions CurrentValue { get; private set; } = currentValue;
+
+ public TOptions Get(string? name) => CurrentValue;
+
+ public IDisposable? OnChange(Action listener) => null;
+ }
+
+ private sealed class FakeReplyTtsHostEnvironment(
+ bool isWindows,
+ string? systemDriveRoot,
+ IReadOnlyList drives) : IReplyTtsHostEnvironment
+ {
+ public bool IsWindows { get; } = isWindows;
+
+ public string? SystemDriveRoot { get; } = systemDriveRoot;
+
+ public IReadOnlyList GetFixedDrives() => drives;
+
+ public bool DirectoryExists(string path) => Directory.Exists(path);
+
+ public bool FileExists(string path) => File.Exists(path);
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs b/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs
index 381c5f6..5c10c61 100644
--- a/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs
+++ b/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs
@@ -3037,7 +3037,7 @@ public void BuildCodexConfigContent_UsesNewSchema()
var configContent = (string)typeof(CliExecutorService)
.GetMethod("BuildCodexConfigContent", BindingFlags.Static | BindingFlags.NonPublic)!
- .Invoke(null, [envVars])!;
+ .Invoke(null, [envVars, false])!;
Assert.Contains("model_provider = \"meteor-ai\"", configContent, StringComparison.Ordinal);
Assert.Contains("disable_response_storage = true", configContent, StringComparison.Ordinal);
@@ -3054,6 +3054,17 @@ public void BuildCodexConfigContent_UsesNewSchema()
Assert.DoesNotContain("env_key =", configContent, StringComparison.Ordinal);
}
+ [Fact]
+ public void BuildCodexConfigContent_WhenGoalsEnabled_AppendsGoalsFeatureFlag()
+ {
+ var configContent = (string)typeof(CliExecutorService)
+ .GetMethod("BuildCodexConfigContent", BindingFlags.Static | BindingFlags.NonPublic)!
+ .Invoke(null, [new Dictionary(), true])!;
+
+ Assert.Contains("[features]", configContent, StringComparison.Ordinal);
+ Assert.Contains("goals = true", configContent, StringComparison.Ordinal);
+ }
+
[Fact]
public void ResolveCommandPath_WhenCommandExistsOnWindowsPath_ReturnsFullPath()
{
diff --git a/WebCodeCli.Domain.Tests/CodexThreadProviderSyncServiceTests.cs b/WebCodeCli.Domain.Tests/CodexThreadProviderSyncServiceTests.cs
index ebfe4e3..3ff3e8d 100644
--- a/WebCodeCli.Domain.Tests/CodexThreadProviderSyncServiceTests.cs
+++ b/WebCodeCli.Domain.Tests/CodexThreadProviderSyncServiceTests.cs
@@ -73,6 +73,60 @@ await File.WriteAllTextAsync(
}
}
+ [Fact]
+ public async Task SyncThreadProviderAsync_BacksUpMatchingRolloutBeforeRewrite()
+ {
+ var workspaceRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N"));
+ var workspacePath = Path.Combine(workspaceRoot, "workspace");
+ var codexRoot = Path.Combine(workspacePath, ".codex");
+ var sessionsRoot = Path.Combine(codexRoot, "sessions", "2026", "04", "30");
+ var matchingPath = Path.Combine(sessionsRoot, "rollout-2026-04-30T10-00-00-thread-a.jsonl");
+ var falseCandidatePath = Path.Combine(sessionsRoot, "rollout-2026-04-30T10-00-01-thread-b.jsonl");
+ var expectedBackupPath = Path.Combine(codexRoot, "rollout-backups", "sessions", "2026", "04", "30", "rollout-2026-04-30T10-00-00-thread-a.jsonl");
+ var unexpectedBackupPath = Path.Combine(codexRoot, "rollout-backups", "sessions", "2026", "04", "30", "rollout-2026-04-30T10-00-01-thread-b.jsonl");
+ const string originalMatchingContent =
+ """
+ {"type":"session_meta","payload":{"id":"thread-a","cwd":"D:\\repo","model_provider":"old-provider"}}
+ {"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}
+ """;
+
+ Directory.CreateDirectory(sessionsRoot);
+ await File.WriteAllTextAsync(matchingPath, originalMatchingContent);
+ await File.WriteAllTextAsync(
+ falseCandidatePath,
+ """
+ {"type":"session_meta","payload":{"id":"thread-b","cwd":"D:\\repo","model_provider":"other-provider"}}
+ {"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ignore"}]}}
+ """);
+ CreateStateDatabase(Path.Combine(workspacePath, ".codex", "state_5.sqlite"), "thread-a", "old-provider");
+
+ try
+ {
+ var service = new CodexThreadProviderSyncService(NullLogger.Instance);
+
+ await service.SyncThreadProviderAsync(new CodexThreadProviderSyncRequest
+ {
+ SessionWorkspacePath = workspacePath,
+ ThreadId = "thread-a",
+ TargetProviderId = "new-provider"
+ });
+
+ Assert.True(File.Exists(expectedBackupPath));
+ Assert.Equal(
+ originalMatchingContent.ReplaceLineEndings("\n"),
+ (await File.ReadAllTextAsync(expectedBackupPath)).ReplaceLineEndings("\n"));
+ Assert.False(File.Exists(unexpectedBackupPath));
+ Assert.Equal("new-provider", ReadProviderIdFromFirstLine(matchingPath));
+ }
+ finally
+ {
+ if (Directory.Exists(workspaceRoot))
+ {
+ Directory.Delete(workspaceRoot, recursive: true);
+ }
+ }
+ }
+
[Fact]
public async Task SyncThreadProviderAsync_SeedsLocalSnapshotFromSourceWhenLocalThreadIsMissing()
{
diff --git a/WebCodeCli.Domain.Tests/ExternalCliHistoryTextBuilderTests.cs b/WebCodeCli.Domain.Tests/ExternalCliHistoryTextBuilderTests.cs
new file mode 100644
index 0000000..c1c6a02
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/ExternalCliHistoryTextBuilderTests.cs
@@ -0,0 +1,62 @@
+using WebCodeCli.Domain.Domain.Model;
+using WebCodeCli.Domain.Domain.Service;
+
+namespace WebCodeCli.Domain.Tests;
+
+public class ExternalCliHistoryTextBuilderTests
+{
+ [Fact]
+ public void Build_IncludesCliThreadIdSourcePathAndMessages()
+ {
+ var output = ExternalCliHistoryTextBuilder.Build(
+ "当前 CLI 会话历史 abc12345",
+ [
+ new ExternalCliHistoryMessage
+ {
+ Role = "user",
+ Content = " 第一条\r\n第二行 ",
+ CreatedAt = new DateTime(2026, 5, 4, 9, 30, 0)
+ },
+ new ExternalCliHistoryMessage
+ {
+ Role = "assistant",
+ Content = "回复内容",
+ CreatedAt = new DateTime(2026, 5, 4, 9, 31, 0)
+ }
+ ],
+ "Codex",
+ @"D:\repo",
+ "codex-thread-1",
+ @"D:\repo\.codex\sessions\2026\05\04\rollout-1.jsonl");
+
+ Assert.Contains("当前 CLI 会话历史 abc12345", output);
+ Assert.True(
+ output.IndexOf("当前 CLI 会话历史 abc12345", StringComparison.Ordinal) <
+ output.IndexOf(@"历史来源: D:\repo\.codex\sessions\2026\05\04\rollout-1.jsonl", StringComparison.Ordinal));
+ Assert.Contains("CLI 工具: Codex", output);
+ Assert.Contains(@"工作目录: D:\repo", output);
+ Assert.Contains("原生 Thread ID: codex-thread-1", output);
+ Assert.Contains(@"历史来源: D:\repo\.codex\sessions\2026\05\04\rollout-1.jsonl", output);
+ Assert.Contains("显示条数: 最近 2 条", output);
+ Assert.Contains("[用户] 09:30", output);
+ Assert.Contains("第一条\n第二行", output);
+ Assert.Contains("[助手] 09:31", output);
+ Assert.Contains("回复内容", output);
+ }
+
+ [Fact]
+ public void Build_ShowsFallbacks_WhenThreadIdAndSourcePathAreMissing()
+ {
+ var output = ExternalCliHistoryTextBuilder.Build(
+ "当前 CLI 会话历史",
+ [],
+ "Codex",
+ null,
+ null,
+ null);
+
+ Assert.Contains("原生 Thread ID: 未绑定", output);
+ Assert.Contains("历史来源: 未定位", output);
+ Assert.Contains("该 CLI 会话暂无可解析的历史消息。", output);
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs b/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs
index de3104a..55fbf10 100644
--- a/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs
+++ b/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using WebCodeCli.Domain.Domain.Service;
@@ -98,10 +99,10 @@ public async Task GetRecentMessagesAsync_ForCodex_PrefersWorkspaceScopedRolloutO
var service = new TestExternalCliSessionHistoryService(
codexSessionsRootPath: Path.GetDirectoryName(globalRolloutPath)!);
- var messages = await service.GetRecentMessagesAsync("codex", threadId, workspacePath: workspacePath);
+ var history = await service.GetRecentHistoryAsync("codex", threadId, workspacePath: workspacePath);
Assert.Collection(
- messages,
+ history.Messages,
message =>
{
Assert.Equal("user", message.Role);
@@ -112,6 +113,46 @@ public async Task GetRecentMessagesAsync_ForCodex_PrefersWorkspaceScopedRolloutO
Assert.Equal("assistant", message.Role);
Assert.Equal("workspace assistant", message.Content);
});
+ Assert.Contains($@"workspace\.codex\sessions\2026\04\28\rollout-2026-04-28T09-12-00-{threadId}.jsonl", history.SourcePath ?? string.Empty, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task GetRecentMessagesAsync_ForCodex_LogsResolvedWorkspaceRolloutDiagnostics()
+ {
+ using var sandbox = new HistoryTestSandbox();
+ const string threadId = "codex-thread-diagnostic";
+ var globalRolloutPath = sandbox.WriteFile(
+ Path.Combine("global-codex", "sessions", "2026", "04", "23", $"rollout-2026-04-23T17-19-27-{threadId}.jsonl"),
+ """
+ {"timestamp":"2026-03-23T01:00:00Z","type":"session_meta","payload":{"id":"codex-thread-diagnostic","cwd":"D:\\legacy","model_provider":"global-provider"}}
+ {"timestamp":"2026-03-23T01:00:01Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"global assistant"}]}}
+ """);
+ var workspacePath = sandbox.CreateDirectory("workspace");
+ var workspaceRolloutPath = sandbox.WriteFile(
+ Path.Combine("workspace", ".codex", "sessions", "2026", "04", "28", $"rollout-2026-04-28T09-12-00-{threadId}.jsonl"),
+ """
+ {"timestamp":"2026-04-28T09:12:00Z","type":"session_meta","payload":{"id":"codex-thread-diagnostic","cwd":"D:\\workspace","model_provider":"project-provider"}}
+ {"timestamp":"2026-04-28T09:12:01Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"workspace assistant"}]}}
+ """);
+ var logger = new RecordingLogger();
+ var service = new TestExternalCliSessionHistoryService(
+ codexSessionsRootPath: Path.GetDirectoryName(globalRolloutPath)!,
+ logger: logger);
+
+ var messages = await service.GetRecentMessagesAsync("codex", threadId, workspacePath: workspacePath);
+
+ Assert.Collection(
+ messages,
+ message => Assert.Equal("workspace assistant", message.Content));
+
+ var logText = string.Join("\n", logger.Entries.Select(entry => entry.Message));
+ Assert.Contains("[CodexHistory] Start resolving rollout", logText);
+ Assert.Contains("[CodexHistory] Rollout resolved", logText);
+ Assert.Contains("Scope=workspace", logText);
+ Assert.Contains("MatchKind=filename", logText);
+ Assert.Contains(workspaceRolloutPath, logText);
+ Assert.Contains("FirstLineThreadId=codex-thread-diagnostic", logText);
+ Assert.Contains("FirstLineModelProvider=project-provider", logText);
}
[Fact]
@@ -328,8 +369,9 @@ private sealed class TestExternalCliSessionHistoryService : ExternalCliSessionHi
public TestExternalCliSessionHistoryService(
string? codexSessionsRootPath = null,
string? claudeProjectsRootPath = null,
- Func>? processHandler = null)
- : base(NullLogger.Instance)
+ Func>? processHandler = null,
+ ILogger? logger = null)
+ : base(logger ?? NullLogger.Instance)
{
_codexConfigRootPath = codexSessionsRootPath;
_claudeProjectsRootPath = claudeProjectsRootPath;
@@ -355,6 +397,36 @@ public TestExternalCliSessionHistoryService(
}
}
+ private sealed class RecordingLogger : ILogger
+ {
+ public List Entries { get; } = [];
+
+ public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception));
+ }
+ }
+
+ private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
+
+ private sealed class NullScope : IDisposable
+ {
+ public static NullScope Instance { get; } = new();
+
+ public void Dispose()
+ {
+ }
+ }
+
private sealed class HistoryTestSandbox : IDisposable
{
public HistoryTestSandbox()
diff --git a/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs
new file mode 100644
index 0000000..bb85f25
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs
@@ -0,0 +1,195 @@
+using WebCodeCli.Domain.Common.Options;
+using WebCodeCli.Domain.Domain.Service;
+using WebCodeCli.Domain.Domain.Model.Channels;
+using WebCodeCli.Domain.Domain.Service.Channels;
+using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig;
+using FeishuNetSdk.Im.Dtos;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class FeishuAudioMessageServiceTests
+{
+ [Fact]
+ public async Task SendAudioMessageAsync_UsesUsernameOptionsAndSendsInOrder()
+ {
+ var cardKit = new StubFeishuCardKitClient();
+ var configService = new StubUserFeishuBotConfigService
+ {
+ UsernameOptions = new FeishuOptions
+ {
+ AppId = "user-app",
+ AppSecret = "user-secret"
+ }
+ };
+ var service = new FeishuAudioMessageService(cardKit, configService);
+
+ var messageId = await service.SendAudioMessageAsync(
+ "oc_chat",
+ @"D:\audio\chunk-001.opus",
+ 3200,
+ username: "alice",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal("om_audio_success", messageId);
+ Assert.Equal(["upload", "send"], cardKit.CallOrder);
+ Assert.Equal("user-app", cardKit.LastUploadOptionsOverride?.AppId);
+ Assert.Equal("user-app", cardKit.LastSendOptionsOverride?.AppId);
+ }
+
+ [Fact]
+ public async Task SendAudioMessageAsync_FallsBackToAppIdLookup_WhenUsernameIsMissing()
+ {
+ var cardKit = new StubFeishuCardKitClient();
+ var configService = new StubUserFeishuBotConfigService
+ {
+ AppOptions = new FeishuOptions
+ {
+ AppId = "resolved-app",
+ AppSecret = "resolved-secret"
+ }
+ };
+ var service = new FeishuAudioMessageService(cardKit, configService);
+
+ await service.SendAudioMessageAsync(
+ "oc_chat",
+ @"D:\audio\chunk-001.opus",
+ 3200,
+ appId: "cli_app",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal("resolved-app", cardKit.LastUploadOptionsOverride?.AppId);
+ Assert.Equal("resolved-app", cardKit.LastSendOptionsOverride?.AppId);
+ }
+
+ [Fact]
+ public async Task SendAudioMessageAsync_PrefersAppIdOptions_WhenBothAppIdAndUsernameAreProvided()
+ {
+ var cardKit = new StubFeishuCardKitClient();
+ var configService = new StubUserFeishuBotConfigService
+ {
+ UsernameOptions = new FeishuOptions
+ {
+ AppId = "user-app",
+ AppSecret = "user-secret"
+ },
+ AppOptions = new FeishuOptions
+ {
+ AppId = "resolved-app",
+ AppSecret = "resolved-secret"
+ }
+ };
+ var service = new FeishuAudioMessageService(cardKit, configService);
+
+ await service.SendAudioMessageAsync(
+ "oc_chat",
+ @"D:\audio\chunk-001.opus",
+ 3200,
+ username: "alice",
+ appId: "cli_app",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal("resolved-app", cardKit.LastUploadOptionsOverride?.AppId);
+ Assert.Equal("resolved-app", cardKit.LastSendOptionsOverride?.AppId);
+ }
+
+ [Fact]
+ public async Task SendAudioMessageAsync_UsesSharedDefaults_WhenNoUsernameOrAppIdIsProvided()
+ {
+ var cardKit = new StubFeishuCardKitClient();
+ var configService = new StubUserFeishuBotConfigService();
+ var service = new FeishuAudioMessageService(cardKit, configService);
+
+ await service.SendAudioMessageAsync(
+ "oc_chat",
+ @"D:\audio\chunk-001.opus",
+ 3200,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal("shared-app", cardKit.LastUploadOptionsOverride?.AppId);
+ Assert.Equal("shared-app", cardKit.LastSendOptionsOverride?.AppId);
+ }
+
+ private sealed class StubFeishuCardKitClient : IFeishuCardKitClient
+ {
+ public List CallOrder { get; } = [];
+
+ public FeishuOptions? LastUploadOptionsOverride { get; private set; }
+
+ public FeishuOptions? LastSendOptionsOverride { get; private set; }
+
+ public Task UploadAudioFileAsync(string filePath, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ {
+ CallOrder.Add("upload");
+ LastUploadOptionsOverride = optionsOverride;
+ return Task.FromResult("file_v2_123");
+ }
+
+ public Task SendAudioMessageAsync(string chatId, string fileKey, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ {
+ CallOrder.Add("send");
+ LastSendOptionsOverride = optionsOverride;
+ return Task.FromResult("om_audio_success");
+ }
+
+ public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task UpdateCardAsync(string cardId, string content, int sequence, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task SendCardMessageAsync(string chatId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task ReplyCardMessageAsync(string replyMessageId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task ReplyTextMessageAsync(string replyMessageId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task CreateStreamingHandleAsync(string chatId, string? replyMessageId, string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, FeishuStreamingCardChrome? chrome = null)
+ => throw new NotSupportedException();
+
+ public Task SendRawCardAsync(string chatId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task ReplyElementsCardAsync(string replyMessageId, ElementsCardV2Dto card, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task ReplyRawCardAsync(string replyMessageId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+ }
+
+ private sealed class StubUserFeishuBotConfigService : IUserFeishuBotConfigService
+ {
+ public FeishuOptions UsernameOptions { get; set; } = new();
+
+ public FeishuOptions? AppOptions { get; set; }
+
+ public Task GetByUsernameAsync(string username) => Task.FromResult(null);
+
+ public Task GetByAppIdAsync(string appId) => Task.FromResult(null);
+
+ public Task SaveAsync(UserFeishuBotConfigEntity config) => Task.FromResult(UserFeishuBotConfigSaveResult.Saved());
+
+ public Task DeleteAsync(string username) => Task.FromResult(true);
+
+ public Task FindConflictingUsernameByAppIdAsync(string username, string? appId) => Task.FromResult(null);
+
+ public Task> GetAutoStartCandidatesAsync() => Task.FromResult(new List());
+
+ public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) => Task.FromResult(true);
+
+ public FeishuOptions GetSharedDefaults() => new()
+ {
+ AppId = "shared-app",
+ AppSecret = "shared-secret"
+ };
+
+ public Task GetEffectiveOptionsAsync(string? username) => Task.FromResult(UsernameOptions);
+
+ public Task GetEffectiveOptionsByAppIdAsync(string? appId) => Task.FromResult(AppOptions);
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs
index c10933d..d8f218f 100644
--- a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs
+++ b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs
@@ -38,6 +38,58 @@ await service.HandleCardActionAsync(
Assert.Equal(activeSessionId, usedSessionId);
}
+ [Fact]
+ public async Task HandleCardActionAsync_ToggleReplyTts_DisablesReplyTtsAndRefreshesHelpCard()
+ {
+ var cliExecutor = new RecordingCliExecutorService();
+ var feishuChannel = new StubFeishuChannelService(null)
+ {
+ SessionUsername = "luhaiyan"
+ };
+ var feishuBotConfigService = new StubUserFeishuBotConfigService();
+ feishuBotConfigService.Seed(new UserFeishuBotConfigEntity
+ {
+ Username = "luhaiyan",
+ IsEnabled = true,
+ ReplyTtsEnabled = true,
+ ReplyTtsVoiceId = "voice-a"
+ });
+
+ var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService);
+ var service = CreateService(cliExecutor, feishuChannel, serviceProvider);
+
+ var response = await service.HandleCardActionAsync(
+ """{"action":"toggle_reply_tts"}""",
+ chatId: "oc_tts_toggle_chat");
+
+ var savedConfig = await feishuBotConfigService.GetByUsernameAsync("luhaiyan");
+ Assert.NotNull(savedConfig);
+ Assert.False(savedConfig!.ReplyTtsEnabled);
+ Assert.Equal("voice-a", savedConfig.ReplyTtsVoiceId);
+ Assert.Equal("✅ 已关闭飞书语音回复", ExtractToastContent(response));
+ Assert.Contains("语音回复:关", ExtractCardContentStrings(response));
+ }
+
+ [Fact]
+ public async Task HandleCardActionAsync_ToggleReplyTts_ReturnsErrorToast_WhenUserConfigMissing()
+ {
+ var cliExecutor = new RecordingCliExecutorService();
+ var feishuChannel = new StubFeishuChannelService(null)
+ {
+ SessionUsername = "missing-user"
+ };
+ var feishuBotConfigService = new StubUserFeishuBotConfigService();
+ var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService);
+ var service = CreateService(cliExecutor, feishuChannel, serviceProvider);
+
+ var response = await service.HandleCardActionAsync(
+ """{"action":"toggle_reply_tts"}""",
+ chatId: "oc_tts_toggle_chat");
+
+ Assert.Equal("❌ 未找到当前飞书用户配置", ExtractToastContent(response));
+ Assert.DoesNotContain("语音回复:", SerializeResponse(response));
+ }
+
[Fact]
public async Task HandleCardActionAsync_ExecuteCommand_ForwardsRawPromptWithoutReplyPrefixInstructions()
{
@@ -355,10 +407,25 @@ await service.HandleCardActionAsync(
Assert.Contains($"\"session_id\":\"{activeSessionId}\"", quickInputValueJson);
Assert.Contains($"\"chat_key\":\"{chatId}\"", quickInputValueJson);
- Assert.Equal(2, chrome.BottomActions.Count);
+ var goalPrompt = Assert.Single(chrome.AdditionalBottomPrompts);
+ Assert.Equal(GoalQuickActionDefaults.QuickInputFieldName, goalPrompt.InputName);
+ Assert.Equal(GoalQuickActionDefaults.InstructionText, goalPrompt.InputLabel);
+ Assert.Equal(GoalQuickActionDefaults.QuickInputPlaceholder, goalPrompt.Placeholder);
+
+ var goalInputValueJson = JsonSerializer.Serialize(goalPrompt.Value);
+ Assert.Contains($"\"action\":\"{FeishuHelpCardAction.SubmitGoalQuickInputAction}\"", goalInputValueJson);
+ Assert.Contains($"\"session_id\":\"{activeSessionId}\"", goalInputValueJson);
+ Assert.Contains($"\"chat_key\":\"{chatId}\"", goalInputValueJson);
+
+ Assert.Equal(3, chrome.BottomActions.Count);
+ Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText);
Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText);
Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText);
+ var continueValueJson = JsonSerializer.Serialize(
+ Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText).Value);
+ Assert.Contains($"\"action\":\"{FeishuHelpCardAction.ContinueSuperpowersAction}\"", continueValueJson);
+
var executePlanValueJson = JsonSerializer.Serialize(
Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText).Value);
Assert.Contains($"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersPlanAction}\"", executePlanValueJson);
@@ -374,7 +441,7 @@ await service.HandleCardActionAsync(
}
[Fact]
- public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputButHidesPlanActions_WhenPlanFilesMissing()
+ public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputAndKeepsContinueAction_WhenPlanFilesMissing()
{
const string chatId = "oc_current_chat";
const string activeSessionId = "session-superpowers-no-plan";
@@ -413,11 +480,12 @@ await service.HandleCardActionAsync(
var chrome = cardKit.LastStreamingChrome!;
Assert.NotNull(chrome.BottomPrompt);
Assert.Equal(SuperpowersQuickActionDefaults.QuickInputFieldName, chrome.BottomPrompt!.InputName);
- Assert.Empty(chrome.BottomActions);
+ var continueAction = Assert.Single(chrome.BottomActions);
+ Assert.Equal(SuperpowersQuickActionDefaults.ContinueButtonText, continueAction.Text);
}
[Fact]
- public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputButHidesPlanActions_WhenSessionHistoryLacksSuperpowers()
+ public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputAndKeepsContinueAction_WhenSessionHistoryLacksSuperpowers()
{
const string chatId = "oc_current_chat";
const string activeSessionId = "session-superpowers-no-history";
@@ -453,7 +521,8 @@ await service.HandleCardActionAsync(
var chrome = cardKit.LastStreamingChrome!;
Assert.NotNull(chrome.BottomPrompt);
Assert.Equal(SuperpowersQuickActionDefaults.QuickInputFieldName, chrome.BottomPrompt!.InputName);
- Assert.Empty(chrome.BottomActions);
+ var continueAction = Assert.Single(chrome.BottomActions);
+ Assert.Equal(SuperpowersQuickActionDefaults.ContinueButtonText, continueAction.Text);
}
finally
{
@@ -495,6 +564,36 @@ public async Task HandleCardActionAsync_SubmitSuperpowersQuickInput_AutoPrefixes
Assert.Empty(cliExecutor.LowInterruptionSessionIds);
}
+ [Fact]
+ public async Task HandleCardActionAsync_SubmitSuperpowersQuickInput_UsesInputValuesWhenFormValueIsMissing()
+ {
+ const string chatId = "oc_current_chat";
+ const string activeSessionId = "session-superpowers-quick-input-from-input";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ StandardExecutionContent = "plan completed"
+ };
+ cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers");
+
+ var cardKit = new StubFeishuCardKitClient();
+ var feishuChannel = new StubFeishuChannelService(activeSessionId);
+ var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit);
+
+ var response = await service.HandleCardActionAsync(
+ $$"""{"action":"{{FeishuHelpCardAction.SubmitSuperpowersQuickInputAction}}","chat_key":"{{chatId}}"}""",
+ chatId: chatId,
+ inputValues: "写一个执行步骤");
+
+ Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Info, response.Toast?.Type);
+ await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3));
+ await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3));
+
+ Assert.Equal(
+ SuperpowersPromptBuilder.BuildQuickSkillPrompt("写一个执行步骤"),
+ Assert.Single(cliExecutor.ExecutedPrompts));
+ }
+
[Fact]
public async Task HandleCardActionAsync_SubmitSuperpowersQuickInput_DoesNotDoublePrefix()
{
@@ -516,15 +615,180 @@ await service.HandleCardActionAsync(
chatId: chatId,
formValue: new Dictionary
{
- [SuperpowersQuickActionDefaults.QuickInputFieldName] = "使用superpowers技能,写一个执行步骤"
+ [SuperpowersQuickActionDefaults.QuickInputFieldName] = "$superpowers ,使用superpowers技能,写一个执行步骤"
});
await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3));
await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3));
Assert.Equal(
- SuperpowersPromptBuilder.BuildQuickSkillPrompt("使用superpowers技能,写一个执行步骤"),
+ SuperpowersPromptBuilder.BuildQuickSkillPrompt("$superpowers ,使用superpowers技能,写一个执行步骤"),
+ Assert.Single(cliExecutor.ExecutedPrompts));
+ }
+
+ [Fact]
+ public async Task HandleCardActionAsync_SubmitGoalQuickInput_AutoPrefixesPromptAndUsesStandardExecutionPath()
+ {
+ const string chatId = "oc_current_chat";
+ const string activeSessionId = "session-goal-quick-input";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ StandardExecutionContent = "goal completed"
+ };
+ cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers");
+
+ var capabilityService = new StubGoalCapabilityService
+ {
+ ProbeState = GoalCapabilityState.Available,
+ ProbeOutcome = GoalCapabilityProbeOutcome.Available
+ };
+
+ var feishuChannel = new StubFeishuChannelService(activeSessionId)
+ {
+ ResolvedToolId = "codex"
+ };
+ var service = CreateService(
+ cliExecutor,
+ feishuChannel,
+ new TestServiceProvider(goalCapabilityService: capabilityService));
+
+ var response = await service.HandleCardActionAsync(
+ $$"""{"action":"{{FeishuHelpCardAction.SubmitGoalQuickInputAction}}","chat_key":"{{chatId}}"}""",
+ chatId: chatId,
+ formValue: new Dictionary
+ {
+ [GoalQuickActionDefaults.QuickInputFieldName] = "整理这个目标"
+ });
+
+ Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Info, response.Toast?.Type);
+ await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3));
+
+ Assert.Equal(
+ GoalPromptBuilder.BuildGoalPrompt("整理这个目标"),
+ Assert.Single(cliExecutor.ExecutedPrompts));
+
+ var probeContext = Assert.Single(capabilityService.ProbeContexts);
+ Assert.Equal("codex", probeContext.ToolId);
+ }
+
+ [Fact]
+ public async Task HandleCardActionAsync_SubmitGoalQuickInput_UsesInputValuesWhenFormValueIsMissing()
+ {
+ const string chatId = "oc_current_chat";
+ const string activeSessionId = "session-goal-quick-input-from-input";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ StandardExecutionContent = "goal completed"
+ };
+ cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers");
+
+ var capabilityService = new StubGoalCapabilityService
+ {
+ ProbeState = GoalCapabilityState.Available,
+ ProbeOutcome = GoalCapabilityProbeOutcome.Available
+ };
+
+ var feishuChannel = new StubFeishuChannelService(activeSessionId)
+ {
+ ResolvedToolId = "codex"
+ };
+ var service = CreateService(
+ cliExecutor,
+ feishuChannel,
+ new TestServiceProvider(goalCapabilityService: capabilityService));
+
+ var response = await service.HandleCardActionAsync(
+ $$"""{"action":"{{FeishuHelpCardAction.SubmitGoalQuickInputAction}}","chat_key":"{{chatId}}"}""",
+ chatId: chatId,
+ inputValues: "整理这个目标");
+
+ Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Info, response.Toast?.Type);
+ await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3));
+
+ Assert.Equal(
+ GoalPromptBuilder.BuildGoalPrompt("整理这个目标"),
+ Assert.Single(cliExecutor.ExecutedPrompts));
+ }
+
+ [Fact]
+ public async Task HandleCardActionAsync_SubmitGoalQuickInput_DoesNotDoublePrefix()
+ {
+ const string chatId = "oc_current_chat";
+ const string activeSessionId = "session-goal-quick-input-prefixed";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ StandardExecutionContent = "goal completed"
+ };
+ cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers");
+
+ var capabilityService = new StubGoalCapabilityService
+ {
+ ProbeState = GoalCapabilityState.Available,
+ ProbeOutcome = GoalCapabilityProbeOutcome.Available
+ };
+
+ var feishuChannel = new StubFeishuChannelService(activeSessionId)
+ {
+ ResolvedToolId = "codex"
+ };
+ var service = CreateService(
+ cliExecutor,
+ feishuChannel,
+ new TestServiceProvider(goalCapabilityService: capabilityService));
+
+ await service.HandleCardActionAsync(
+ $$"""{"action":"{{FeishuHelpCardAction.SubmitGoalQuickInputAction}}","chat_key":"{{chatId}}"}""",
+ chatId: chatId,
+ formValue: new Dictionary
+ {
+ [GoalQuickActionDefaults.QuickInputFieldName] = "/goal 整理这个目标"
+ });
+
+ await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3));
+
+ Assert.Equal(
+ GoalPromptBuilder.BuildGoalPrompt("/goal 整理这个目标"),
+ Assert.Single(cliExecutor.ExecutedPrompts));
+ }
+
+ [Fact]
+ public async Task HandleCardActionAsync_ContinueSuperpowers_UsesFixedPromptAndSkipsCapabilityProbe()
+ {
+ const string chatId = "oc_current_chat";
+ const string activeSessionId = "session-superpowers-continue";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ StandardExecutionContent = "continued"
+ };
+ cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers");
+
+ var capabilityService = new StubSuperpowersCapabilityService
+ {
+ ProbeState = SuperpowersCapabilityState.Unavailable,
+ ProbeOutcome = SuperpowersCapabilityProbeOutcome.MissingCapability,
+ ProbeMessage = "missing"
+ };
+
+ var feishuChannel = new StubFeishuChannelService(activeSessionId);
+ var service = CreateService(
+ cliExecutor,
+ feishuChannel,
+ new TestServiceProvider(superpowersCapabilityService: capabilityService));
+
+ await service.HandleCardActionAsync(
+ $$"""{"action":"{{FeishuHelpCardAction.ContinueSuperpowersAction}}","chat_key":"{{chatId}}"}""",
+ chatId: chatId);
+
+ await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3));
+
+ Assert.Equal(
+ SuperpowersPromptBuilder.BuildContinuePrompt(),
Assert.Single(cliExecutor.ExecutedPrompts));
+ Assert.Empty(capabilityService.ProbeContexts);
}
[Fact]
@@ -694,6 +958,47 @@ public async Task HandleCardActionAsync_SuperpowersQuickAction_WhenCapabilityMis
Assert.Empty(cliExecutor.ExecutedPrompts);
}
+ [Fact]
+ public async Task HandleCardActionAsync_GoalQuickAction_WhenCapabilityMissing_ReturnsWarningWithoutExecuting()
+ {
+ const string chatId = "oc_current_chat";
+ const string activeSessionId = "session-goal-missing-capability";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ StandardExecutionContent = "goal completed"
+ };
+ cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers");
+
+ var capabilityService = new StubGoalCapabilityService
+ {
+ ProbeState = GoalCapabilityState.Unavailable,
+ ProbeOutcome = GoalCapabilityProbeOutcome.MissingFeature,
+ ProbeMessage = GoalQuickActionDefaults.CapabilityUnavailableText
+ };
+
+ var feishuChannel = new StubFeishuChannelService(activeSessionId)
+ {
+ ResolvedToolId = "codex"
+ };
+ var service = CreateService(
+ cliExecutor,
+ feishuChannel,
+ new TestServiceProvider(goalCapabilityService: capabilityService));
+
+ var response = await service.HandleCardActionAsync(
+ $$"""{"action":"{{FeishuHelpCardAction.SubmitGoalQuickInputAction}}","chat_key":"{{chatId}}"}""",
+ chatId: chatId,
+ formValue: new Dictionary
+ {
+ [GoalQuickActionDefaults.QuickInputFieldName] = "整理这个目标"
+ });
+
+ Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Warning, response.Toast?.Type);
+ Assert.Contains(GoalQuickActionDefaults.CapabilityUnavailableText, response.Toast?.Content, StringComparison.Ordinal);
+ Assert.Empty(cliExecutor.ExecutedPrompts);
+ }
+
[Fact]
public async Task HandleCardActionAsync_RetrySuperpowersCapabilityDetection_WhenCapabilityAvailable_ReturnsSuccessToast()
{
@@ -750,7 +1055,8 @@ public async Task HandleCardActionAsync_LowInterruptionContinue_StartsNewStreami
Assert.Equal([sessionId], cliExecutor.LowInterruptionSessionIds);
Assert.NotNull(cardKit.LastStreamingChrome);
Assert.NotNull(cardKit.LastStreamingChrome!.BottomPrompt);
- Assert.Empty(cardKit.LastStreamingChrome.BottomActions);
+ var continueAction = Assert.Single(cardKit.LastStreamingChrome.BottomActions);
+ Assert.Equal(SuperpowersQuickActionDefaults.ContinueButtonText, continueAction.Text);
}
[Fact]
@@ -788,6 +1094,156 @@ public async Task HandleCardActionAsync_LowInterruptionContinue_PassesPromptFrom
Assert.Equal(["finish the remaining plan items"], cliExecutor.LowInterruptionPrompts);
}
+ [Fact]
+ public async Task HandleCardActionAsync_ExecuteCommand_QueuesReplyTtsAfterSuccessfulCompletion()
+ {
+ const string chatId = "oc_reply_tts_card_chat";
+ const string sessionId = "session-reply-tts-card";
+ const string appId = "cli_reply_tts_bot";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ StandardExecutionContent = "card action completed"
+ };
+ cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers");
+
+ var chatSessionService = new StubChatSessionService();
+ var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator();
+ replyTtsOrchestrator.OnQueued = request =>
+ {
+ Assert.Contains(
+ chatSessionService.Messages[sessionId],
+ message => message.Role == "assistant" && message.Content == "card action completed" && message.IsCompleted);
+ Assert.Equal("card action completed", request.Output);
+ return Task.CompletedTask;
+ };
+
+ var service = CreateService(
+ cliExecutor,
+ new StubFeishuChannelService(sessionId),
+ new TestServiceProvider(replyTtsOrchestrator: replyTtsOrchestrator),
+ chatSessionService: chatSessionService);
+
+ await service.HandleCardActionAsync(
+ """{"action":"execute_command"}""",
+ chatId: chatId,
+ inputValues: "continue",
+ appId: appId);
+
+ var queued = await replyTtsOrchestrator.WhenQueued.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await replyTtsOrchestrator.WhenCallbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Equal(chatId, queued.ChatId);
+ Assert.Equal("luhaiyan", queued.Username);
+ Assert.Equal(appId, queued.AppId);
+ Assert.Equal(sessionId, queued.SessionId);
+ Assert.Equal("card action completed", queued.Output);
+ }
+
+ [Fact]
+ public async Task HandleCardActionAsync_LowInterruptionContinue_QueuesReplyTtsAfterSuccessfulCompletion()
+ {
+ const string chatId = "oc_reply_tts_low_interruption_chat";
+ const string sessionId = "session-reply-tts-low-interruption";
+ const string appId = "cli_reply_tts_bot";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ SupportsLowInterruption = true,
+ LowInterruptionExecutionContent = "low interruption completed"
+ };
+ cliExecutor.SetCliThreadId(sessionId, "thread-reply-tts-low-interruption");
+ cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers");
+
+ var chatSessionService = new StubChatSessionService();
+ var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator();
+ replyTtsOrchestrator.OnQueued = request =>
+ {
+ Assert.Contains(
+ chatSessionService.Messages[sessionId],
+ message => message.Role == "assistant" && message.Content == "low interruption completed" && message.IsCompleted);
+ Assert.Equal("low interruption completed", request.Output);
+ return Task.CompletedTask;
+ };
+
+ var service = CreateService(
+ cliExecutor,
+ new StubFeishuChannelService(sessionId),
+ new TestServiceProvider(replyTtsOrchestrator: replyTtsOrchestrator),
+ chatSessionService: chatSessionService);
+
+ await service.HandleCardActionAsync(
+ """{"action":"low_interruption_continue","session_id":"session-reply-tts-low-interruption","chat_key":"oc_reply_tts_low_interruption_chat","tool_id":"codex"}""",
+ chatId: chatId,
+ appId: appId);
+
+ var queued = await replyTtsOrchestrator.WhenQueued.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await replyTtsOrchestrator.WhenCallbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Equal(chatId, queued.ChatId);
+ Assert.Equal("luhaiyan", queued.Username);
+ Assert.Equal(appId, queued.AppId);
+ Assert.Equal(sessionId, queued.SessionId);
+ Assert.Equal("low interruption completed", queued.Output);
+ }
+
+ [Fact]
+ public async Task HandleCardActionAsync_ExecuteCommand_DoesNotQueueReplyTtsWhenExecutionErrors()
+ {
+ const string sessionId = "session-reply-tts-error";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ StandardExecutionIsError = true,
+ StandardExecutionErrorMessage = "execution failed"
+ };
+ cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers");
+
+ var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator();
+ var service = CreateService(
+ cliExecutor,
+ new StubFeishuChannelService(sessionId),
+ new TestServiceProvider(replyTtsOrchestrator: replyTtsOrchestrator));
+
+ await service.HandleCardActionAsync(
+ """{"action":"execute_command"}""",
+ chatId: "oc_reply_tts_error_chat",
+ inputValues: "continue");
+
+ await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Empty(replyTtsOrchestrator.Requests);
+ }
+
+ [Fact]
+ public async Task HandleCardActionAsync_LowInterruptionContinue_DoesNotQueueReplyTtsWhenExecutionErrors()
+ {
+ const string sessionId = "session-reply-tts-low-interruption-error";
+
+ var cliExecutor = new RecordingCliExecutorService
+ {
+ SupportsLowInterruption = true,
+ LowInterruptionExecutionIsError = true,
+ LowInterruptionExecutionErrorMessage = "low interruption failed"
+ };
+ cliExecutor.SetCliThreadId(sessionId, "thread-reply-tts-low-interruption-error");
+ cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers");
+
+ var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator();
+ var service = CreateService(
+ cliExecutor,
+ new StubFeishuChannelService(sessionId),
+ new TestServiceProvider(replyTtsOrchestrator: replyTtsOrchestrator));
+
+ await service.HandleCardActionAsync(
+ """{"action":"low_interruption_continue","session_id":"session-reply-tts-low-interruption-error","chat_key":"oc_reply_tts_low_interruption_error_chat","tool_id":"codex"}""",
+ chatId: "oc_reply_tts_low_interruption_error_chat");
+
+ await cliExecutor.WaitForLowInterruptionExecutionCompletionAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Empty(replyTtsOrchestrator.Requests);
+ }
+
[Fact]
public async Task HandleCardActionAsync_LowInterruptionContinue_WhenThreadMissing_ReturnsWarning()
{
@@ -1687,6 +2143,8 @@ public async Task HandleCardActionAsync_ExecuteCommand_HistoryCommand_SendsExter
Assert.Equal("codex", historyService.LastToolId);
Assert.Equal("codex-thread-1", historyService.LastCliThreadId);
Assert.Equal(50, historyService.LastMaxCount);
+ Assert.Contains("原生 Thread ID: codex-thread-1", sent.Content);
+ Assert.Contains(@"历史来源: D:\repo\.codex\sessions\2026\05\04\rollout-history.jsonl", sent.Content);
Assert.Contains("浣犲ソ", sent.Content);
Assert.Contains("涓栫晫", sent.Content);
}
@@ -2679,6 +3137,16 @@ private static List ExtractCardContentStrings(CardActionTriggerResponseD
return contents;
}
+ private static string? ExtractToastContent(CardActionTriggerResponseDto response)
+ {
+ using var document = JsonDocument.Parse(SerializeResponse(response));
+ return document.RootElement.TryGetProperty("toast", out var toastElement)
+ && toastElement.TryGetProperty("content", out var contentElement)
+ && contentElement.ValueKind == JsonValueKind.String
+ ? contentElement.GetString()
+ : null;
+ }
+
private static void CollectContentStrings(JsonElement element, List contents)
{
switch (element.ValueKind)
@@ -2733,7 +3201,9 @@ private static FeishuCardActionService CreateService(
private sealed class RecordingCliExecutorService : ICliExecutorService
{
private readonly TaskCompletionSource _executionStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly TaskCompletionSource _executionCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _lowInterruptionExecutionStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly TaskCompletionSource _lowInterruptionExecutionCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Dictionary _tools = new(StringComparer.OrdinalIgnoreCase)
{
["claude-code"] = new CliToolConfig { Id = "claude-code", Name = "Claude Code" },
@@ -2752,8 +3222,16 @@ private sealed class RecordingCliExecutorService : ICliExecutorService
public string StandardExecutionCompletionContent { get; set; } = string.Empty;
+ public bool StandardExecutionIsError { get; set; }
+
+ public string StandardExecutionErrorMessage { get; set; } = "执行失败";
+
public string LowInterruptionExecutionContent { get; set; } = "continued";
+ public bool LowInterruptionExecutionIsError { get; set; }
+
+ public string LowInterruptionExecutionErrorMessage { get; set; } = "执行失败";
+
public List ExecutedPrompts { get; } = new();
public List<(string SessionId, string ToolId, string Prompt)> StandardExecutionRequests { get; } = new();
@@ -2785,6 +3263,13 @@ public async Task WaitForExecutionAsync(TimeSpan timeout)
return await _executionStarted.Task;
}
+ public async Task WaitForExecutionCompletionAsync(TimeSpan timeout)
+ {
+ using var cts = new CancellationTokenSource(timeout);
+ using var _ = cts.Token.Register(() => _executionCompleted.TrySetCanceled(cts.Token));
+ return await _executionCompleted.Task;
+ }
+
public async Task WaitForLowInterruptionExecutionAsync(TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
@@ -2792,6 +3277,13 @@ public async Task WaitForLowInterruptionExecutionAsync(TimeSpan timeout)
return await _lowInterruptionExecutionStarted.Task;
}
+ public async Task WaitForLowInterruptionExecutionCompletionAsync(TimeSpan timeout)
+ {
+ using var cts = new CancellationTokenSource(timeout);
+ using var _ = cts.Token.Register(() => _lowInterruptionExecutionCompleted.TrySetCanceled(cts.Token));
+ return await _lowInterruptionExecutionCompleted.Task;
+ }
+
public ICliToolAdapter? GetAdapter(CliToolConfig tool) => null;
public ICliToolAdapter? GetAdapterById(string toolId) => null;
@@ -2829,27 +3321,44 @@ public async IAsyncEnumerable ExecuteStreamAsync(
ExecutedPrompts.Add(userPrompt);
StandardExecutionRequests.Add((sessionId, toolId, userPrompt));
_executionStarted.TrySetResult(sessionId);
-
- var hasTrailingCompletionChunk = StandardExecutionCompletionDelay > TimeSpan.Zero
- || !string.IsNullOrEmpty(StandardExecutionCompletionContent);
- yield return new StreamOutputChunk
+ try
{
- Content = StandardExecutionContent,
- IsCompleted = !hasTrailingCompletionChunk
- };
-
- if (hasTrailingCompletionChunk)
- {
- if (StandardExecutionCompletionDelay > TimeSpan.Zero)
+ if (StandardExecutionIsError)
{
- await Task.Delay(StandardExecutionCompletionDelay, cancellationToken);
+ yield return new StreamOutputChunk
+ {
+ IsError = true,
+ IsCompleted = true,
+ ErrorMessage = StandardExecutionErrorMessage
+ };
+ yield break;
}
+ var hasTrailingCompletionChunk = StandardExecutionCompletionDelay > TimeSpan.Zero
+ || !string.IsNullOrEmpty(StandardExecutionCompletionContent);
yield return new StreamOutputChunk
{
- Content = StandardExecutionCompletionContent,
- IsCompleted = true
+ Content = StandardExecutionContent,
+ IsCompleted = !hasTrailingCompletionChunk
};
+
+ if (hasTrailingCompletionChunk)
+ {
+ if (StandardExecutionCompletionDelay > TimeSpan.Zero)
+ {
+ await Task.Delay(StandardExecutionCompletionDelay, cancellationToken);
+ }
+
+ yield return new StreamOutputChunk
+ {
+ Content = StandardExecutionCompletionContent,
+ IsCompleted = true
+ };
+ }
+ }
+ finally
+ {
+ _executionCompleted.TrySetResult(sessionId);
}
await Task.CompletedTask;
@@ -2870,11 +3379,29 @@ public async IAsyncEnumerable ExecuteLowInterruptionContinueS
LowInterruptionSessionIds.Add(sessionId);
LowInterruptionPrompts.Add(prompt);
_lowInterruptionExecutionStarted.TrySetResult(sessionId);
- yield return new StreamOutputChunk
+ try
{
- Content = LowInterruptionExecutionContent,
- IsCompleted = true
- };
+ if (LowInterruptionExecutionIsError)
+ {
+ yield return new StreamOutputChunk
+ {
+ IsError = true,
+ IsCompleted = true,
+ ErrorMessage = LowInterruptionExecutionErrorMessage
+ };
+ yield break;
+ }
+
+ yield return new StreamOutputChunk
+ {
+ Content = LowInterruptionExecutionContent,
+ IsCompleted = true
+ };
+ }
+ finally
+ {
+ _lowInterruptionExecutionCompleted.TrySetResult(sessionId);
+ }
await Task.CompletedTask;
}
@@ -2980,6 +3507,39 @@ public List GetMessages(string sessionId)
public void UpdateMessage(string sessionId, string messageId, Action updateAction) { }
}
+ private sealed class RecordingReplyTtsOrchestrator : IReplyTtsOrchestrator
+ {
+ public List Requests { get; } = new();
+
+ public TaskCompletionSource WhenQueued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public TaskCompletionSource WhenCallbackCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Func? OnQueued { get; set; }
+
+ public async Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request)
+ {
+ Requests.Add(request);
+ WhenQueued.TrySetResult(request);
+ if (OnQueued == null)
+ {
+ WhenCallbackCompleted.TrySetResult(request);
+ return;
+ }
+
+ try
+ {
+ await OnQueued(request);
+ WhenCallbackCompleted.TrySetResult(request);
+ }
+ catch (Exception ex)
+ {
+ WhenCallbackCompleted.TrySetException(ex);
+ throw;
+ }
+ }
+ }
+
private sealed class StubFeishuCardKitClient : IFeishuCardKitClient
{
private readonly TaskCompletionSource<(string ChatId, string CardJson)> _rawCardSent = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -3139,7 +3699,20 @@ public Task ReplyRawCardAsync(string replyMessageId, string cardJson, Ca
ButtonText = chrome.BottomPrompt.ButtonText,
ButtonType = chrome.BottomPrompt.ButtonType,
Value = chrome.BottomPrompt.Value
- }
+ },
+ AdditionalBottomPrompts = chrome.AdditionalBottomPrompts
+ .Select(prompt => new FeishuStreamingCardBottomPrompt
+ {
+ FormName = prompt.FormName,
+ InputName = prompt.InputName,
+ InputLabel = prompt.InputLabel,
+ Placeholder = prompt.Placeholder,
+ DefaultValue = prompt.DefaultValue,
+ ButtonText = prompt.ButtonText,
+ ButtonType = prompt.ButtonType,
+ Value = prompt.Value
+ })
+ .ToList()
};
}
}
@@ -3174,6 +3747,8 @@ private sealed class StubFeishuChannelService(string? currentSessionId) : IFeish
public string ResolvedToolId { get; set; } = "claude-code";
+ public string? SessionUsername { get; set; } = "luhaiyan";
+
public Task SendMessageAsync(string chatId, string content, string? username = null, string? appId = null)
{
LastSentChatId = chatId;
@@ -3225,7 +3800,7 @@ public string CreateNewSession(FeishuIncomingMessage message, string? customWork
return "session-new";
}
- public string? GetSessionUsername(string chatKey) => "luhaiyan";
+ public string? GetSessionUsername(string chatKey) => SessionUsername;
public string ResolveToolId(string chatKey, string? username = null) => ResolvedToolId;
@@ -3248,6 +3823,25 @@ private sealed class StubExternalCliSessionHistoryService(IEnumerable GetRecentHistoryAsync(
+ string toolId,
+ string cliThreadId,
+ int maxCount = 20,
+ string? workspacePath = null,
+ CancellationToken cancellationToken = default)
+ {
+ LastToolId = toolId;
+ LastCliThreadId = cliThreadId;
+ LastMaxCount = maxCount;
+ return Task.FromResult(new ExternalCliHistoryResult
+ {
+ Messages = _messages.TakeLast(maxCount).ToList(),
+ SourcePath = SourcePath
+ });
+ }
+
public Task> GetRecentMessagesAsync(
string toolId,
string cliThreadId,
@@ -3341,7 +3935,7 @@ public Task ImportAsync(
private sealed class TestServiceProvider : IServiceProvider, IServiceScopeFactory, IServiceScope
{
private readonly StubFeishuUserBindingService _bindingService = new();
- private readonly StubUserFeishuBotConfigService _feishuBotConfigService = new();
+ private readonly StubUserFeishuBotConfigService _feishuBotConfigService;
private readonly StubSessionDirectoryService _sessionDirectoryService = new();
private readonly ICcSwitchService _ccSwitchService;
private readonly TestUserContextService _userContextService;
@@ -3350,6 +3944,8 @@ private sealed class TestServiceProvider : IServiceProvider, IServiceScopeFactor
private readonly IExternalCliSessionHistoryService _externalCliSessionHistoryService;
private readonly IExternalCliSessionService _externalCliSessionService;
private readonly ISuperpowersCapabilityService _superpowersCapabilityService;
+ private readonly IGoalCapabilityService _goalCapabilityService;
+ private readonly IReplyTtsOrchestrator? _replyTtsOrchestrator;
public TestServiceProvider(
TestUserContextService? userContextService = null,
@@ -3358,8 +3954,12 @@ public TestServiceProvider(
ICcSwitchService? ccSwitchService = null,
IExternalCliSessionHistoryService? externalCliSessionHistoryService = null,
IExternalCliSessionService? externalCliSessionService = null,
- ISuperpowersCapabilityService? superpowersCapabilityService = null)
+ ISuperpowersCapabilityService? superpowersCapabilityService = null,
+ IGoalCapabilityService? goalCapabilityService = null,
+ IReplyTtsOrchestrator? replyTtsOrchestrator = null,
+ StubUserFeishuBotConfigService? feishuBotConfigService = null)
{
+ _feishuBotConfigService = feishuBotConfigService ?? new StubUserFeishuBotConfigService();
_userContextService = userContextService ?? new TestUserContextService();
_projectService = projectService ?? new TestProjectService(_userContextService);
_chatSessionRepository = chatSessionRepository ?? new StubChatSessionRepository([]);
@@ -3367,6 +3967,8 @@ public TestServiceProvider(
_externalCliSessionHistoryService = externalCliSessionHistoryService ?? new StubExternalCliSessionHistoryService([]);
_externalCliSessionService = externalCliSessionService ?? new StubExternalCliSessionService([]);
_superpowersCapabilityService = superpowersCapabilityService ?? new StubSuperpowersCapabilityService();
+ _goalCapabilityService = goalCapabilityService ?? new StubGoalCapabilityService();
+ _replyTtsOrchestrator = replyTtsOrchestrator;
}
public object? GetService(Type serviceType)
@@ -3426,6 +4028,16 @@ public TestServiceProvider(
return _superpowersCapabilityService;
}
+ if (serviceType == typeof(IGoalCapabilityService))
+ {
+ return _goalCapabilityService;
+ }
+
+ if (serviceType == typeof(IReplyTtsOrchestrator))
+ {
+ return _replyTtsOrchestrator;
+ }
+
return null;
}
@@ -3567,6 +4179,61 @@ public Task ProbeAsync(
}
}
+ private sealed class StubGoalCapabilityService : IGoalCapabilityService
+ {
+ public GoalCapabilityState CachedState { get; set; } = GoalCapabilityState.Unknown;
+
+ public string? CachedMessage { get; set; }
+
+ public GoalCapabilityState ProbeState { get; set; } = GoalCapabilityState.Available;
+
+ public GoalCapabilityProbeOutcome ProbeOutcome { get; set; } = GoalCapabilityProbeOutcome.Available;
+
+ public string? ProbeMessage { get; set; }
+
+ public List ProbeContexts { get; } = new();
+
+ public Task GetStateAsync(
+ GoalCapabilityContext context,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return Task.FromResult(new GoalCapabilitySnapshot
+ {
+ ToolId = context.ToolId,
+ ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId,
+ CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}",
+ State = CachedState,
+ Message = CachedMessage
+ });
+ }
+
+ public Task ProbeAsync(
+ GoalCapabilityContext context,
+ bool forceRefresh = false,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ProbeContexts.Add(new GoalCapabilityContext
+ {
+ ToolId = context.ToolId,
+ ProviderId = context.ProviderId,
+ WorkspacePath = context.WorkspacePath
+ });
+
+ return Task.FromResult(new GoalCapabilityProbeResult
+ {
+ ToolId = context.ToolId,
+ ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId,
+ CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}",
+ State = ProbeState,
+ Outcome = ProbeOutcome,
+ Message = ProbeMessage
+ });
+ }
+ }
+
private sealed class StubChatSessionRepository(IEnumerable sessions) : IChatSessionRepository
{
private readonly List _sessions = sessions.ToList();
@@ -3859,14 +4526,33 @@ private sealed class StubUserFeishuBotConfigService : IUserFeishuBotConfigServic
ThinkingMessage = "鎬濊€冧腑..."
};
+ private readonly Dictionary _configs = new(StringComparer.OrdinalIgnoreCase);
+
+ public void Seed(UserFeishuBotConfigEntity config)
+ {
+ _configs[config.Username] = Clone(config);
+ }
+
public Task GetByUsernameAsync(string username)
- => Task.FromResult(null);
+ {
+ return Task.FromResult(
+ _configs.TryGetValue(username, out var config)
+ ? Clone(config)
+ : null);
+ }
public Task GetByAppIdAsync(string appId)
- => Task.FromResult(null);
+ {
+ var config = _configs.Values.FirstOrDefault(item =>
+ string.Equals(item.AppId, appId, StringComparison.OrdinalIgnoreCase));
+ return Task.FromResult(config == null ? null : Clone(config));
+ }
public Task SaveAsync(UserFeishuBotConfigEntity config)
- => throw new NotSupportedException();
+ {
+ _configs[config.Username] = Clone(config);
+ return Task.FromResult(UserFeishuBotConfigSaveResult.Saved());
+ }
public Task DeleteAsync(string username) => Task.FromResult(true);
@@ -3874,7 +4560,7 @@ public Task SaveAsync(UserFeishuBotConfigEntity c
=> Task.FromResult(null);
public Task> GetAutoStartCandidatesAsync()
- => Task.FromResult(new List());
+ => Task.FromResult(_configs.Values.Select(Clone).ToList());
public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null)
=> Task.FromResult(true);
@@ -3895,6 +4581,30 @@ public Task GetEffectiveOptionsAsync(string? username)
DefaultCardTitle = "AI鍔╂墜",
ThinkingMessage = "鎬濊€冧腑..."
});
+
+ private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity config)
+ {
+ return new UserFeishuBotConfigEntity
+ {
+ Id = config.Id,
+ Username = config.Username,
+ IsEnabled = config.IsEnabled,
+ AutoStartEnabled = config.AutoStartEnabled,
+ AppId = config.AppId,
+ AppSecret = config.AppSecret,
+ EncryptKey = config.EncryptKey,
+ VerificationToken = config.VerificationToken,
+ DefaultCardTitle = config.DefaultCardTitle,
+ ThinkingMessage = config.ThinkingMessage,
+ HttpTimeoutSeconds = config.HttpTimeoutSeconds,
+ StreamingThrottleMs = config.StreamingThrottleMs,
+ ReplyTtsEnabled = config.ReplyTtsEnabled,
+ ReplyTtsVoiceId = config.ReplyTtsVoiceId,
+ LastStartedAt = config.LastStartedAt,
+ CreatedAt = config.CreatedAt,
+ UpdatedAt = config.UpdatedAt
+ };
+ }
}
private sealed class StubSessionDirectoryService : ISessionDirectoryService
diff --git a/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs b/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs
index e5173c7..def81f4 100644
--- a/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs
+++ b/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs
@@ -12,6 +12,71 @@ namespace WebCodeCli.Domain.Tests;
public class FeishuCardKitClientTests
{
+ [Fact]
+ public async Task UploadAudioFileAsync_PostsMultipartFormDataWithDuration()
+ {
+ var tempFile = Path.GetTempFileName();
+ await File.WriteAllTextAsync(tempFile, "opus", TestContext.Current.CancellationToken);
+
+ try
+ {
+ var handler = new StubHttpMessageHandler(
+ [
+ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""),
+ CreateJsonResponse("""{"code":0,"data":{"file_key":"file_v2_123"}}""")
+ ]);
+
+ var client = CreateClient(handler);
+
+ var fileKey = await client.UploadAudioFileAsync(tempFile, 3200, TestContext.Current.CancellationToken);
+
+ Assert.Equal("file_v2_123", fileKey);
+ Assert.Equal(
+ [
+ "/open-apis/auth/v3/tenant_access_token/internal",
+ "/open-apis/im/v1/files"
+ ], handler.RequestPaths);
+ Assert.Contains("multipart/form-data", handler.RequestContentTypes[1], StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("name=file_type", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("opus", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("name=file_name", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("name=duration", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("3200", handler.RequestBodies[1], StringComparison.Ordinal);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task SendAudioMessageAsync_SendsAudioPayload()
+ {
+ var handler = new StubHttpMessageHandler(
+ [
+ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""),
+ CreateJsonResponse("""{"code":0,"data":{"message_id":"om_audio_success"}}""")
+ ]);
+
+ var client = CreateClient(handler);
+
+ var messageId = await client.SendAudioMessageAsync("oc_audio_chat", "file_v2_123", 3200, TestContext.Current.CancellationToken);
+
+ Assert.Equal("om_audio_success", messageId);
+ Assert.Equal(
+ [
+ "/open-apis/auth/v3/tenant_access_token/internal",
+ "/open-apis/im/v1/messages"
+ ], handler.RequestPaths);
+
+ using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]);
+ Assert.Equal("audio", requestDoc.RootElement.GetProperty("msg_type").GetString());
+ Assert.Equal("oc_audio_chat", requestDoc.RootElement.GetProperty("receive_id").GetString());
+
+ using var contentDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("content").GetString()!);
+ Assert.Equal("file_v2_123", contentDoc.RootElement.GetProperty("file_key").GetString());
+ }
+
[Fact]
public async Task SendTextMessageAsync_SendsTextPayload()
{
@@ -241,6 +306,8 @@ await client.CreateStreamingHandleAsync(
using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]);
using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!);
var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements");
+ Assert.Equal("🟥🟥🟥 **回复内容**", elements[1].GetProperty("text").GetProperty("content").GetString());
+ Assert.Equal("🟥🟥🟥 **Superpowers 工作流**", elements[3].GetProperty("text").GetProperty("content").GetString());
var bottomActionModule = elements.EnumerateArray().Last();
Assert.Equal("form", bottomActionModule.GetProperty("tag").GetString());
@@ -261,6 +328,66 @@ await client.CreateStreamingHandleAsync(
Assert.Equal("low_interruption_continue", button.GetProperty("value").GetProperty("action").GetString());
}
+ [Fact]
+ public async Task CreateStreamingHandleAsync_UsesUniqueSubmitButtonNames_ForMultipleBottomPrompts()
+ {
+ var handler = new StubHttpMessageHandler(
+ [
+ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""),
+ CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""),
+ CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}""")
+ ]);
+
+ var client = CreateClient(handler);
+ var chrome = new FeishuStreamingCardChrome
+ {
+ StatusMarkdown = "当前会话",
+ BottomPrompt = new FeishuStreamingCardBottomPrompt
+ {
+ FormName = "superpowers_quick_action_form",
+ InputName = "superpowers_quick_input",
+ InputLabel = "使用 superpowers 工作流",
+ Placeholder = "输入后提交",
+ DefaultValue = string.Empty,
+ ButtonText = "提交",
+ ButtonType = "primary",
+ Value = new { action = "submit_superpowers_quick_input" }
+ },
+ AdditionalBottomPrompts =
+ [
+ new FeishuStreamingCardBottomPrompt
+ {
+ FormName = "goal_quick_action_form",
+ InputName = "goal_quick_input",
+ InputLabel = "使用 /goal 工作流",
+ Placeholder = "输入后提交",
+ DefaultValue = string.Empty,
+ ButtonText = "提交",
+ ButtonType = "primary",
+ Value = new { action = "submit_goal_quick_input" }
+ }
+ ]
+ };
+
+ await client.CreateStreamingHandleAsync(
+ "oc_stream_chat",
+ null,
+ "still have backlog",
+ "AI 助手",
+ TestContext.Current.CancellationToken,
+ chrome: chrome);
+
+ using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]);
+ using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!);
+ var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements");
+
+ var firstFormButton = elements[4].GetProperty("elements")[0].GetProperty("columns")[1].GetProperty("elements")[0];
+ var secondFormButton = elements[5].GetProperty("elements")[0].GetProperty("columns")[1].GetProperty("elements")[0];
+
+ Assert.Equal("superpowers_quick_input_submit", firstFormButton.GetProperty("name").GetString());
+ Assert.Equal("goal_quick_input_submit", secondFormButton.GetProperty("name").GetString());
+ }
+
[Fact]
public async Task CreateStreamingHandleAsync_RendersTopChipGroupsBetweenStatusAndBody()
{
@@ -309,7 +436,9 @@ await client.CreateStreamingHandleAsync(
var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements");
Assert.Equal("div", elements[0].GetProperty("tag").GetString());
+ Assert.Equal("🟥🟥🟥 **思考等级**", elements[1].GetProperty("text").GetProperty("content").GetString());
Assert.Equal("div", elements[2].GetProperty("tag").GetString());
+ Assert.Equal("🟥🟥🟥 **回复内容**", elements[3].GetProperty("text").GetProperty("content").GetString());
Assert.Equal("markdown", elements[4].GetProperty("tag").GetString());
Assert.Equal("🤖 模型:`gpt-5.3-codex-spark`", elements[2].GetProperty("text").GetProperty("content").GetString());
Assert.Equal("overflow", elements[2].GetProperty("extra").GetProperty("tag").GetString());
@@ -369,16 +498,57 @@ await client.CreateStreamingHandleAsync(
using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!);
var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements");
+ Assert.Equal("🟥🟥🟥 **思考等级**", elements[1].GetProperty("text").GetProperty("content").GetString());
Assert.Equal("column_set", elements[2].GetProperty("tag").GetString());
Assert.Equal("column_set", elements[3].GetProperty("tag").GetString());
Assert.Equal(6, elements[2].GetProperty("columns").GetArrayLength());
Assert.Equal(1, elements[3].GetProperty("columns").GetArrayLength());
Assert.Equal("gpt-5.1", elements[2].GetProperty("columns")[0].GetProperty("elements")[0].GetProperty("text").GetProperty("content").GetString());
Assert.Equal("gpt-5.7", elements[3].GetProperty("columns")[0].GetProperty("elements")[0].GetProperty("text").GetProperty("content").GetString());
- Assert.Equal("hr", elements[4].GetProperty("tag").GetString());
+ Assert.Equal("🟥🟥🟥 **回复内容**", elements[4].GetProperty("text").GetProperty("content").GetString());
Assert.Equal("markdown", elements[5].GetProperty("tag").GetString());
}
+ [Fact]
+ public async Task CreateStreamingHandleAsync_RendersWorkflowSectionMarkerBeforeBottomActions()
+ {
+ var handler = new StubHttpMessageHandler(
+ [
+ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""),
+ CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""),
+ CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}""")
+ ]);
+
+ var client = CreateClient(handler);
+ var chrome = new FeishuStreamingCardChrome
+ {
+ StatusMarkdown = "当前会话"
+ };
+ chrome.BottomActions.Add(new FeishuStreamingCardBottomAction
+ {
+ Text = "执行 plan",
+ Type = "primary",
+ Value = new { action = "execute_superpowers_plan", session_id = "session-1" }
+ });
+
+ await client.CreateStreamingHandleAsync(
+ "oc_stream_chat",
+ null,
+ "still have backlog",
+ "AI 助手",
+ TestContext.Current.CancellationToken,
+ chrome: chrome);
+
+ using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]);
+ using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!);
+ var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements");
+
+ Assert.Equal("🟥🟥🟥 **回复内容**", elements[1].GetProperty("text").GetProperty("content").GetString());
+ Assert.Equal("markdown", elements[2].GetProperty("tag").GetString());
+ Assert.Equal("🟥🟥🟥 **Superpowers 工作流**", elements[3].GetProperty("text").GetProperty("content").GetString());
+ Assert.Equal("column_set", elements[4].GetProperty("tag").GetString());
+ }
+
[Fact]
public async Task CreateStreamingHandleAsync_KeepsClientStreamingMode_WhenNoOverflowActionsExist()
{
@@ -515,10 +685,12 @@ private sealed class StubHttpMessageHandler(IEnumerable res
public List RequestPaths { get; } = [];
public List RequestBodies { get; } = [];
+ public List RequestContentTypes { get; } = [];
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestPaths.Add(request.RequestUri!.AbsolutePath);
+ RequestContentTypes.Add(request.Content?.Headers.ContentType?.MediaType);
RequestBodies.Add(request.Content == null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken));
diff --git a/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs
index 7c036f6..ff41fb0 100644
--- a/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs
+++ b/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs
@@ -407,7 +407,10 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage
});
var call = Assert.Single(cliExecutor.ExecuteCalls);
- Assert.Equal(expectedPrompt.Replace("\n", Environment.NewLine, StringComparison.Ordinal), call.Prompt);
+ var normalizedExpectedPrompt = expectedPrompt
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace("\n", Environment.NewLine, StringComparison.Ordinal);
+ Assert.Equal(normalizedExpectedPrompt, call.Prompt);
Assert.Contains(chatSessionService.Messages[sessionId], message => message.Role == "user" && message.Content == call.Prompt);
}
finally
@@ -771,6 +774,214 @@ public async Task HandleIncomingMessageAsync_WithSessionOverflowMenu_ResumesPuls
}
}
+ [Fact]
+ public async Task HandleIncomingMessageAsync_QueuesReplyTtsAfterSuccessfulCompletionAndAssistantPersistence()
+ {
+ var repository = CreateRepository(out var repositoryProxy);
+ var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy);
+ var cardKit = new StreamingRecordingFeishuCardKitClient();
+ var chatSessionService = new RecordingChatSessionService();
+ var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator();
+ var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-reply-tts-success-{Guid.NewGuid():N}");
+ var workspacePath = Path.Combine(workspaceRoot, "superpowers");
+ Directory.CreateDirectory(workspacePath);
+ var cliExecutor = new PromptCapturingCliExecutor(workspacePath);
+ var serviceProvider = new TestServiceProvider(
+ repository,
+ sessionDirectoryService,
+ new StubFeishuUserBindingService(),
+ new StubUserFeishuBotConfigService(),
+ new StubUserContextService());
+
+ var service = new FeishuChannelService(
+ Options.Create(new FeishuOptions
+ {
+ Enabled = true,
+ AppId = "cli_test",
+ AppSecret = "secret"
+ }),
+ NullLogger.Instance,
+ cardKit,
+ serviceProvider,
+ cliExecutor,
+ chatSessionService,
+ replyTtsOrchestrator);
+
+ try
+ {
+ var sessionId = service.CreateNewSession(
+ new FeishuIncomingMessage
+ {
+ ChatId = "oc_reply_tts_chat",
+ SenderName = "luhaiyan"
+ },
+ workspacePath,
+ "codex");
+
+ replyTtsOrchestrator.OnQueued = request =>
+ {
+ Assert.Equal("补充完成", request.Output);
+ Assert.Contains(
+ chatSessionService.Messages[sessionId],
+ message => message.Role == "assistant" && message.Content == "补充完成" && message.IsCompleted);
+ Assert.Equal("补充完成", Assert.Single(cardKit.Handles).FinalContent);
+ return Task.CompletedTask;
+ };
+
+ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage
+ {
+ ChatId = "oc_reply_tts_chat",
+ SenderName = "luhaiyan",
+ AppId = "cli_test",
+ MessageId = "msg-reply-tts",
+ Content = "继续"
+ });
+
+ var queued = await replyTtsOrchestrator.WhenQueued.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal("oc_reply_tts_chat", queued.ChatId);
+ Assert.Equal("luhaiyan", queued.Username);
+ Assert.Equal("cli_test", queued.AppId);
+ Assert.Equal("补充完成", queued.Output);
+ }
+ finally
+ {
+ Directory.Delete(workspaceRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task HandleIncomingMessageAsync_DoesNotQueueReplyTtsWhenExecutionErrors()
+ {
+ var repository = CreateRepository(out var repositoryProxy);
+ var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy);
+ var cardKit = new StreamingRecordingFeishuCardKitClient();
+ var chatSessionService = new RecordingChatSessionService();
+ var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator();
+ var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-reply-tts-error-{Guid.NewGuid():N}");
+ var workspacePath = Path.Combine(workspaceRoot, "superpowers");
+ Directory.CreateDirectory(workspacePath);
+ var cliExecutor = new ErrorCliExecutor(workspacePath);
+ var serviceProvider = new TestServiceProvider(
+ repository,
+ sessionDirectoryService,
+ new StubFeishuUserBindingService(),
+ new StubUserFeishuBotConfigService(),
+ new StubUserContextService());
+
+ var service = new FeishuChannelService(
+ Options.Create(new FeishuOptions
+ {
+ Enabled = true,
+ AppId = "cli_test",
+ AppSecret = "secret"
+ }),
+ NullLogger.Instance,
+ cardKit,
+ serviceProvider,
+ cliExecutor,
+ chatSessionService,
+ replyTtsOrchestrator);
+
+ try
+ {
+ service.CreateNewSession(
+ new FeishuIncomingMessage
+ {
+ ChatId = "oc_reply_tts_error_chat",
+ SenderName = "luhaiyan"
+ },
+ workspacePath,
+ "codex");
+
+ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage
+ {
+ ChatId = "oc_reply_tts_error_chat",
+ SenderName = "luhaiyan",
+ MessageId = "msg-reply-tts-error",
+ Content = "继续"
+ });
+
+ Assert.Empty(replyTtsOrchestrator.Requests);
+ }
+ finally
+ {
+ Directory.Delete(workspaceRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task HandleIncomingMessageAsync_DoesNotQueueReplyTtsForSupersededExecution()
+ {
+ var repository = CreateRepository(out var repositoryProxy);
+ var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy);
+ var cardKit = new StreamingRecordingFeishuCardKitClient();
+ var chatSessionService = new RecordingChatSessionService();
+ var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator();
+ var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-reply-tts-superseded-{Guid.NewGuid():N}");
+ var workspacePath = Path.Combine(workspaceRoot, "superpowers");
+ Directory.CreateDirectory(workspacePath);
+ var cliExecutor = new TakeoverCliExecutor(workspacePath);
+ var serviceProvider = new TestServiceProvider(
+ repository,
+ sessionDirectoryService,
+ new StubFeishuUserBindingService(),
+ new StubUserFeishuBotConfigService(),
+ new StubUserContextService());
+
+ var service = new FeishuChannelService(
+ Options.Create(new FeishuOptions
+ {
+ Enabled = true,
+ AppId = "cli_test",
+ AppSecret = "secret"
+ }),
+ NullLogger.Instance,
+ cardKit,
+ serviceProvider,
+ cliExecutor,
+ chatSessionService,
+ replyTtsOrchestrator);
+
+ try
+ {
+ service.CreateNewSession(
+ new FeishuIncomingMessage
+ {
+ ChatId = "oc_reply_tts_superseded_chat",
+ SenderName = "luhaiyan"
+ },
+ workspacePath,
+ "codex");
+
+ var firstTask = service.HandleIncomingMessageAsync(new FeishuIncomingMessage
+ {
+ ChatId = "oc_reply_tts_superseded_chat",
+ SenderName = "luhaiyan",
+ MessageId = "msg-reply-tts-superseded-1",
+ Content = "先查一下 superpowers 计划文件"
+ });
+
+ await cliExecutor.ThreadIdPersisted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ var secondTask = service.HandleIncomingMessageAsync(new FeishuIncomingMessage
+ {
+ ChatId = "oc_reply_tts_superseded_chat",
+ SenderName = "luhaiyan",
+ MessageId = "msg-reply-tts-superseded-2",
+ Content = @"补充:D:\MMIS\Base\Docs\superpowers"
+ });
+
+ await Task.WhenAll(firstTask, secondTask);
+
+ var queued = Assert.Single(replyTtsOrchestrator.Requests);
+ Assert.Equal("补充完成", queued.Output);
+ }
+ finally
+ {
+ Directory.Delete(workspaceRoot, recursive: true);
+ }
+ }
+
[Fact]
public async Task HandleIncomingMessageAsync_AttachesSuperpowersQuickActions_WhenPlanFilesExistAndSessionHistoryContainsSuperpowers()
{
@@ -856,9 +1067,24 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage
Assert.Contains("\"chat_key\":\"oc_low_interrupt_chat\"", quickInputJson);
Assert.Contains("\"tool_id\":\"codex\"", quickInputJson);
- Assert.Equal(2, chrome.BottomActions.Count);
+ var goalPrompt = Assert.Single(chrome.AdditionalBottomPrompts);
+ Assert.Equal(GoalQuickActionDefaults.QuickInputFieldName, goalPrompt.InputName);
+ Assert.Equal(GoalQuickActionDefaults.InstructionText, goalPrompt.InputLabel);
+ var goalInputJson = JsonSerializer.Serialize(goalPrompt.Value);
+ Assert.Contains($"\"action\":\"{FeishuHelpCardAction.SubmitGoalQuickInputAction}\"", goalInputJson);
+ Assert.Contains($"\"session_id\":\"{sessionId}\"", goalInputJson);
+ Assert.Contains("\"chat_key\":\"oc_low_interrupt_chat\"", goalInputJson);
+ Assert.Contains("\"tool_id\":\"codex\"", goalInputJson);
+
+ Assert.Equal(3, chrome.BottomActions.Count);
+ Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText);
Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText);
Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText);
+ Assert.Contains(
+ $"\"action\":\"{FeishuHelpCardAction.ContinueSuperpowersAction}\"",
+ JsonSerializer.Serialize(
+ Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText).Value),
+ StringComparison.Ordinal);
Assert.Contains(
$"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersPlanAction}\"",
JsonSerializer.Serialize(
@@ -877,7 +1103,7 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage
}
[Fact]
- public async Task HandleIncomingMessageAsync_AttachesQuickInputButHidesPlanActions_WhenWorkspaceHasNoPlanFiles()
+ public async Task HandleIncomingMessageAsync_AttachesQuickInputAndKeepsContinueAction_WhenWorkspaceHasNoPlanFiles()
{
var repository = CreateRepository(out var repositoryProxy);
var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy);
@@ -949,7 +1175,8 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage
Assert.NotNull(handle.Chrome);
Assert.NotNull(handle.Chrome!.BottomPrompt);
Assert.Equal(SuperpowersQuickActionDefaults.QuickInputFieldName, handle.Chrome.BottomPrompt!.InputName);
- Assert.Empty(handle.Chrome.BottomActions);
+ var continueAction = Assert.Single(handle.Chrome.BottomActions);
+ Assert.Equal(SuperpowersQuickActionDefaults.ContinueButtonText, continueAction.Text);
}
finally
{
@@ -1091,9 +1318,11 @@ private sealed class TestServiceProvider(
IFeishuUserBindingService feishuUserBindingService,
IUserFeishuBotConfigService userFeishuBotConfigService,
IUserContextService userContextService,
- ISuperpowersCapabilityService? superpowersCapabilityService = null) : IServiceProvider, IServiceScopeFactory, IServiceScope
+ ISuperpowersCapabilityService? superpowersCapabilityService = null,
+ IGoalCapabilityService? goalCapabilityService = null) : IServiceProvider, IServiceScopeFactory, IServiceScope
{
private readonly ISuperpowersCapabilityService _superpowersCapabilityService = superpowersCapabilityService ?? new StubSuperpowersCapabilityService();
+ private readonly IGoalCapabilityService _goalCapabilityService = goalCapabilityService ?? new StubGoalCapabilityService();
public object? GetService(Type serviceType)
{
@@ -1132,6 +1361,11 @@ private sealed class TestServiceProvider(
return _superpowersCapabilityService;
}
+ if (serviceType == typeof(IGoalCapabilityService))
+ {
+ return _goalCapabilityService;
+ }
+
return null;
}
@@ -1182,6 +1416,44 @@ public Task ProbeAsync(
}
}
+ private sealed class StubGoalCapabilityService : IGoalCapabilityService
+ {
+ public GoalCapabilityState CachedState { get; set; } = GoalCapabilityState.Unknown;
+
+ public string? CachedMessage { get; set; }
+
+ public Task GetStateAsync(
+ GoalCapabilityContext context,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(new GoalCapabilitySnapshot
+ {
+ ToolId = context.ToolId,
+ ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId,
+ CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}",
+ State = CachedState,
+ Message = CachedMessage
+ });
+ }
+
+ public Task ProbeAsync(
+ GoalCapabilityContext context,
+ bool forceRefresh = false,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(new GoalCapabilityProbeResult
+ {
+ ToolId = context.ToolId,
+ ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId,
+ CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}",
+ State = GoalCapabilityState.Available,
+ Outcome = GoalCapabilityProbeOutcome.Available
+ });
+ }
+ }
+
private sealed class StubUserContextService : IUserContextService
{
public string GetCurrentUsername() => "luhaiyan";
@@ -1255,6 +1527,25 @@ public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEn
});
}
+ private sealed class RecordingReplyTtsOrchestrator : IReplyTtsOrchestrator
+ {
+ public List Requests { get; } = new();
+
+ public TaskCompletionSource WhenQueued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Func? OnQueued { get; set; }
+
+ public async Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request)
+ {
+ Requests.Add(request);
+ WhenQueued.TrySetResult(request);
+ if (OnQueued != null)
+ {
+ await OnQueued(request);
+ }
+ }
+ }
+
private sealed class RecordingFeishuCardKitClient : IFeishuCardKitClient
{
public int CreateCardCallCount { get; private set; }
@@ -1460,7 +1751,20 @@ public Task ReplyRawCardAsync(string replyMessageId, string cardJson, Ca
ButtonText = chrome.BottomPrompt.ButtonText,
ButtonType = chrome.BottomPrompt.ButtonType,
Value = chrome.BottomPrompt.Value
- }
+ },
+ AdditionalBottomPrompts = chrome.AdditionalBottomPrompts
+ .Select(prompt => new FeishuStreamingCardBottomPrompt
+ {
+ FormName = prompt.FormName,
+ InputName = prompt.InputName,
+ InputLabel = prompt.InputLabel,
+ Placeholder = prompt.Placeholder,
+ DefaultValue = prompt.DefaultValue,
+ ButtonText = prompt.ButtonText,
+ ButtonType = prompt.ButtonType,
+ Value = prompt.Value
+ })
+ .ToList()
};
}
}
@@ -1880,6 +2184,134 @@ public void RefreshWorkspaceRootCache()
}
}
+ private sealed class ErrorCliExecutor(string workspacePath) : ICliExecutorService
+ {
+ public ICliToolAdapter? GetAdapter(CliToolConfig tool) => null;
+
+ public ICliToolAdapter? GetAdapterById(string toolId) => null;
+
+ public bool SupportsStreamParsing(CliToolConfig tool) => false;
+
+ public string? GetCliThreadId(string sessionId) => null;
+
+ public void SetCliThreadId(string sessionId, string threadId)
+ {
+ }
+
+ public Task ResetSessionRuntimeAsync(string sessionId, bool clearCliThreadId = true, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+
+ public async IAsyncEnumerable ExecuteStreamAsync(
+ string sessionId,
+ string toolId,
+ string userPrompt,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ yield return new StreamOutputChunk
+ {
+ IsError = true,
+ IsCompleted = true,
+ ErrorMessage = "执行失败"
+ };
+
+ await Task.CompletedTask;
+ }
+
+ public bool SupportsLowInterruptionContinue(string toolId) => false;
+
+ public bool CanStartLowInterruptionContinue(string sessionId, string toolId) => false;
+
+ public async IAsyncEnumerable ExecuteLowInterruptionContinueStreamAsync(
+ string sessionId,
+ string toolId,
+ string? prompt = null,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ yield return new StreamOutputChunk
+ {
+ IsError = true,
+ IsCompleted = true,
+ ErrorMessage = "not implemented in test double"
+ };
+
+ await Task.CompletedTask;
+ }
+
+ public List GetAvailableTools(string? username = null)
+ => new() { GetTool("codex", username)! };
+
+ public CliToolConfig? GetTool(string toolId, string? username = null)
+ => string.Equals(toolId, "codex", StringComparison.OrdinalIgnoreCase)
+ ? new CliToolConfig
+ {
+ Id = "codex",
+ Name = "Codex",
+ Description = "Codex",
+ Command = "codex",
+ Enabled = true
+ }
+ : null;
+
+ public bool ValidateTool(string toolId, string? username = null) => true;
+
+ public void CleanupSessionWorkspace(string sessionId)
+ {
+ }
+
+ public void CleanupExpiredWorkspaces()
+ {
+ }
+
+ public string GetSessionWorkspacePath(string sessionId) => workspacePath;
+
+ public Task> GetToolEnvironmentVariablesAsync(string toolId, string? username = null)
+ => Task.FromResult(new Dictionary());
+
+ public Task SyncSessionCcSwitchSnapshotAsync(string sessionId, string? toolId = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(null);
+
+ public Task SyncCodexThreadProviderAsync(string sessionId, string? toolId = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new CodexThreadProviderSyncResult
+ {
+ Message = "thread sync complete"
+ });
+
+ public Task SaveToolEnvironmentVariablesAsync(string toolId, Dictionary envVars, string? username = null)
+ => Task.FromResult(true);
+
+ public byte[]? GetWorkspaceFile(string sessionId, string relativePath) => null;
+
+ public byte[]? GetWorkspaceZip(string sessionId) => null;
+
+ public Task UploadFileToWorkspaceAsync(string sessionId, string fileName, byte[] fileContent, string? relativePath = null)
+ => Task.FromResult(true);
+
+ public Task CreateFolderInWorkspaceAsync(string sessionId, string folderPath)
+ => Task.FromResult(true);
+
+ public Task DeleteWorkspaceItemAsync(string sessionId, string relativePath, bool isDirectory)
+ => Task.FromResult(true);
+
+ public Task MoveFileInWorkspaceAsync(string sessionId, string sourcePath, string targetPath)
+ => Task.FromResult(true);
+
+ public Task CopyFileInWorkspaceAsync(string sessionId, string sourcePath, string targetPath)
+ => Task.FromResult(true);
+
+ public Task RenameFileInWorkspaceAsync(string sessionId, string oldPath, string newName)
+ => Task.FromResult(true);
+
+ public Task BatchDeleteFilesAsync(string sessionId, List relativePaths)
+ => Task.FromResult(0);
+
+ public Task InitializeSessionWorkspaceAsync(string sessionId, string? projectId = null, bool includeGit = false)
+ => Task.FromResult(Path.Combine(Path.GetTempPath(), sessionId));
+
+ public void RefreshWorkspaceRootCache()
+ {
+ }
+ }
+
private sealed class ExecutionCall
{
public string SessionId { get; set; } = string.Empty;
diff --git a/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs b/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs
index 4362228..62c693b 100644
--- a/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs
+++ b/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs
@@ -45,6 +45,27 @@ public void BuildCommandListCardV2_UsesCategoryButtonCallback()
Assert.False(ContainsProperty(bodyDoc.RootElement, "extra"));
}
+ [Fact]
+ public void BuildCommandListCard_IncludesReplyTtsToggle_WhenEnabled()
+ {
+ var cardJson = _builder.BuildCommandListCard(CreateCategories(), replyTtsEnabled: true);
+ using var document = JsonDocument.Parse(cardJson);
+ var elements = document.RootElement.GetProperty("body").GetProperty("elements");
+
+ Assert.True(ContainsStringValue(elements, "语音回复:开"));
+ Assert.True(ContainsAction(elements, "toggle_reply_tts"));
+ }
+
+ [Fact]
+ public void BuildCommandListCardV2_IncludesReplyTtsToggle_WhenDisabled()
+ {
+ var card = _builder.BuildCommandListCardV2(CreateCategories(), replyTtsEnabled: false);
+ using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements));
+
+ Assert.True(ContainsStringValue(bodyDoc.RootElement, "语音回复:关"));
+ Assert.True(ContainsAction(bodyDoc.RootElement, "toggle_reply_tts"));
+ }
+
[Fact]
public void BuildCategoryCommandsCardV2_UsesCommandButtonCallback()
{
@@ -133,6 +154,29 @@ public void BuildCategoryCommandsCardV2_AppendsSuperpowersQuickActionsFooter()
Assert.False(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction));
}
+ [Fact]
+ public void BuildCommandListCard_AppendsGoalQuickActionFooter_BelowSuperpowers()
+ {
+ var cardJson = _builder.BuildCommandListCard(CreateCategories());
+ using var document = JsonDocument.Parse(cardJson);
+ var elements = document.RootElement.GetProperty("body").GetProperty("elements");
+
+ Assert.True(ContainsStringValue(elements, GoalQuickActionDefaults.InstructionText));
+ Assert.Equal(1, CountInputsByName(elements, GoalQuickActionDefaults.QuickInputFieldName));
+
+ var goalInput = GetInputByName(elements, GoalQuickActionDefaults.QuickInputFieldName);
+ Assert.Equal(
+ GoalQuickActionDefaults.QuickInputPlaceholder,
+ goalInput.GetProperty("placeholder").GetProperty("content").GetString());
+ Assert.Equal(
+ FeishuHelpCardAction.SubmitGoalQuickInputAction,
+ goalInput.GetProperty("behaviors")[0].GetProperty("value").GetProperty("action").GetString());
+
+ Assert.True(
+ GetInputIndexByName(elements, GoalQuickActionDefaults.QuickInputFieldName)
+ > GetInputIndexByName(elements, SuperpowersQuickActionDefaults.QuickInputFieldName));
+ }
+
private static JsonElement GetActionValue(JsonElement elements, string action)
{
if (TryGetActionValue(elements, action, out var actionValue))
@@ -363,6 +407,31 @@ private static void CountInputsByName(JsonElement element, string inputName, ref
}
}
+ private static int GetInputIndexByName(JsonElement elements, string inputName)
+ {
+ if (elements.ValueKind != JsonValueKind.Array)
+ {
+ throw new Xunit.Sdk.XunitException("Card body elements must be an array.");
+ }
+
+ var index = 0;
+ foreach (var item in elements.EnumerateArray())
+ {
+ if (item.ValueKind == JsonValueKind.Object
+ && item.TryGetProperty("tag", out var tag)
+ && tag.GetString() == "input"
+ && item.TryGetProperty("name", out var name)
+ && string.Equals(name.GetString(), inputName, StringComparison.Ordinal))
+ {
+ return index;
+ }
+
+ index++;
+ }
+
+ throw new Xunit.Sdk.XunitException($"No input found for name '{inputName}' in card payload.");
+ }
+
private static List CreateCategories()
{
return
diff --git a/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs
new file mode 100644
index 0000000..778bfe0
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs
@@ -0,0 +1,537 @@
+using Microsoft.Extensions.Options;
+using WebCodeCli.Domain.Common.Options;
+using WebCodeCli.Domain.Domain.Model.Channels;
+using WebCodeCli.Domain.Domain.Service.Channels;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class FeishuReplyTtsPlatformServiceTests
+{
+ [Fact]
+ public async Task GetHealthAsync_WhenStorageRootResolutionFails_ReturnsUnavailable()
+ {
+ var resolver = CreateUnavailableResolver();
+ var ttsClient = new StubSherpaKokoroTtsClient();
+ var service = CreateService(resolver, ttsClient);
+
+ var result = await service.GetHealthAsync(TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsAvailable);
+ Assert.Contains("non-system drive", result.Message, StringComparison.Ordinal);
+ Assert.Equal(0, ttsClient.HealthCallCount);
+ }
+
+ [Fact]
+ public async Task GetHealthAsync_MergesResolverAvailabilityWithLocalServiceHealth()
+ {
+ var resolver = CreateAvailableResolver();
+ var ttsClient = new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok",
+ Device = "cpu",
+ DefaultVoiceId = "service-default"
+ }
+ };
+ var service = CreateService(resolver, ttsClient);
+
+ var result = await service.GetHealthAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsAvailable);
+ Assert.Equal(@"D:\reply-tts", result.StorageRoot);
+ Assert.Equal(@"D:\reply-tts\models", result.ModelsRoot);
+ Assert.Equal("ok", result.ServiceStatus);
+ Assert.Equal("cpu", result.Device);
+ Assert.Equal("service-default", result.DefaultVoiceId);
+ }
+
+ [Fact]
+ public async Task GetHealthAsync_WhenFfmpegCannotBeResolved_ReturnsUnavailable()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok"
+ }
+ },
+ ffmpegExecutablePath: string.Empty);
+
+ var result = await service.GetHealthAsync(TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsAvailable);
+ Assert.Equal("ffmpeg-unavailable", result.ServiceStatus);
+ Assert.Contains("ffmpeg executable is unavailable", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task GetHealthAsync_WhenConfiguredDefaultVoiceExists_PrefersConfiguredDefault()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok",
+ DefaultVoiceId = "service-default"
+ }
+ },
+ defaultVoiceId: "configured-default");
+
+ var result = await service.GetHealthAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal("configured-default", result.DefaultVoiceId);
+ }
+
+ [Fact]
+ public async Task GetHealthAsync_WhenCanceled_PropagatesCancellation()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthException = new OperationCanceledException("canceled")
+ });
+
+ await Assert.ThrowsAsync(() =>
+ service.GetHealthAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task EnsureServiceStartedAsync_WhenStorageAndFfmpegAreAvailable_StartsLocalServiceAndReturnsHealth()
+ {
+ var ttsClient = new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok",
+ Device = "cpu",
+ DefaultVoiceId = "service-default"
+ }
+ };
+ var localServiceManager = new StubReplyTtsLocalServiceManager
+ {
+ Result = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok"
+ }
+ };
+ var service = CreateService(CreateAvailableResolver(), ttsClient, localServiceManager: localServiceManager);
+
+ var result = await service.EnsureServiceStartedAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, localServiceManager.EnsureStartedCallCount);
+ Assert.NotNull(localServiceManager.LastStorageHealth);
+ Assert.True(result.IsAvailable);
+ Assert.Equal("ok", result.ServiceStatus);
+ Assert.Equal("cpu", result.Device);
+ }
+
+ [Fact]
+ public async Task EnsureServiceStartedAsync_WhenFfmpegCannotBeResolved_DoesNotStartLocalService()
+ {
+ var localServiceManager = new StubReplyTtsLocalServiceManager();
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient(),
+ ffmpegExecutablePath: string.Empty,
+ localServiceManager: localServiceManager);
+
+ var result = await service.EnsureServiceStartedAsync(TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsAvailable);
+ Assert.Equal("ffmpeg-unavailable", result.ServiceStatus);
+ Assert.Equal(0, localServiceManager.EnsureStartedCallCount);
+ }
+
+ [Fact]
+ public async Task GetVoicesAsync_ReturnsRuntimeVoiceList()
+ {
+ var resolver = CreateAvailableResolver();
+ var ttsClient = new StubSherpaKokoroTtsClient
+ {
+ VoicesResult =
+ [
+ new FeishuReplyTtsVoiceOption
+ {
+ VoiceId = "voice-a",
+ DisplayName = "Voice A"
+ },
+ new FeishuReplyTtsVoiceOption
+ {
+ VoiceId = "voice-b",
+ DisplayName = "Voice B"
+ }
+ ]
+ };
+ var service = CreateService(resolver, ttsClient);
+
+ var result = await service.GetVoicesAsync(TestContext.Current.CancellationToken);
+
+ Assert.Collection(
+ result,
+ voice => Assert.Equal("voice-a", voice.VoiceId),
+ voice => Assert.Equal("voice-b", voice.VoiceId));
+ }
+
+ [Fact]
+ public async Task GetVoicesAsync_WhenLocalServiceIsUnreachable_ReturnsEmptyList()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ VoicesException = new HttpRequestException("connection refused")
+ });
+
+ var result = await service.GetVoicesAsync(TestContext.Current.CancellationToken);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task GetVoicesAsync_WhenCanceled_PropagatesCancellation()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ VoicesException = new OperationCanceledException("canceled")
+ });
+
+ await Assert.ThrowsAsync(() =>
+ service.GetVoicesAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ResolveVoiceOrFallbackAsync_WhenSavedVoiceExists_PrefersSavedVoice()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok"
+ },
+ VoicesResult =
+ [
+ new FeishuReplyTtsVoiceOption { VoiceId = "saved-voice", DisplayName = "Saved" },
+ new FeishuReplyTtsVoiceOption { VoiceId = "default-voice", DisplayName = "Default" }
+ ]
+ },
+ defaultVoiceId: "default-voice");
+
+ var result = await service.ResolveVoiceOrFallbackAsync("saved-voice", TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.Equal("saved-voice", result.VoiceId);
+ Assert.False(result.UsedFallback);
+ }
+
+ [Fact]
+ public async Task ResolveVoiceOrFallbackAsync_WhenSavedVoiceIsMissing_UsesDefaultVoice()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok",
+ DefaultVoiceId = "service-default"
+ },
+ VoicesResult =
+ [
+ new FeishuReplyTtsVoiceOption { VoiceId = "default-zh", DisplayName = "Default" }
+ ]
+ },
+ defaultVoiceId: "default-zh");
+
+ var result = await service.ResolveVoiceOrFallbackAsync("missing-voice", TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.Equal("default-zh", result.VoiceId);
+ Assert.True(result.UsedFallback);
+ }
+
+ [Fact]
+ public async Task ResolveVoiceOrFallbackAsync_WhenConfiguredDefaultIsBlank_UsesServiceDefaultVoice()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok",
+ DefaultVoiceId = "service-default"
+ },
+ VoicesResult =
+ [
+ new FeishuReplyTtsVoiceOption { VoiceId = "service-default", DisplayName = "Service Default" }
+ ]
+ });
+
+ var result = await service.ResolveVoiceOrFallbackAsync("missing-voice", TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.Equal("service-default", result.VoiceId);
+ Assert.True(result.UsedFallback);
+ }
+
+ [Fact]
+ public async Task ResolveVoiceOrFallbackAsync_WhenNoSavedOrDefaultVoiceMatches_FailsCleanly()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok"
+ },
+ VoicesResult =
+ [
+ new FeishuReplyTtsVoiceOption { VoiceId = "voice-a", DisplayName = "Voice A" }
+ ]
+ },
+ defaultVoiceId: "default-zh");
+
+ var result = await service.ResolveVoiceOrFallbackAsync("missing-voice", TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.Null(result.VoiceId);
+ Assert.Contains("No Feishu reply TTS voice", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ResolveVoiceOrFallbackAsync_WhenLocalServiceIsUnreachable_FailsCleanly()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok"
+ },
+ VoicesException = new HttpRequestException("connection refused")
+ },
+ defaultVoiceId: "configured-default");
+
+ var result = await service.ResolveVoiceOrFallbackAsync("saved-voice", TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.Null(result.VoiceId);
+ Assert.Equal("Feishu reply TTS voices are currently unavailable.", result.Message);
+ }
+
+ [Fact]
+ public async Task ResolveVoiceOrFallbackAsync_WhenFfmpegCannotBeResolved_FailsBeforeSynthesizing()
+ {
+ var ttsClient = new StubSherpaKokoroTtsClient
+ {
+ VoicesResult =
+ [
+ new FeishuReplyTtsVoiceOption { VoiceId = "voice-a", DisplayName = "Voice A" }
+ ]
+ };
+ var service = CreateService(
+ CreateAvailableResolver(),
+ ttsClient,
+ ffmpegExecutablePath: string.Empty);
+
+ var result = await service.ResolveVoiceOrFallbackAsync("voice-a", TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.Equal(0, ttsClient.HealthCallCount);
+ Assert.Contains("ffmpeg executable is unavailable", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ResolveVoiceOrFallbackAsync_WhenCanceled_PropagatesCancellation()
+ {
+ var service = CreateService(
+ CreateAvailableResolver(),
+ new StubSherpaKokoroTtsClient
+ {
+ HealthResult = new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok"
+ },
+ VoicesException = new OperationCanceledException("canceled")
+ });
+
+ await Assert.ThrowsAsync(() =>
+ service.ResolveVoiceOrFallbackAsync("saved-voice", TestContext.Current.CancellationToken));
+ }
+
+ private static FeishuReplyTtsPlatformService CreateService(
+ ReplyTtsStorageRootResolver resolver,
+ StubSherpaKokoroTtsClient ttsClient,
+ string? defaultVoiceId = null,
+ string? ffmpegExecutablePath = "ffmpeg",
+ StubReplyTtsLocalServiceManager? localServiceManager = null)
+ {
+ return new FeishuReplyTtsPlatformService(
+ resolver,
+ Options.Create(new FeishuReplyTtsOptions
+ {
+ TtsDefaultVoiceId = defaultVoiceId,
+ FfmpegExecutablePath = ffmpegExecutablePath
+ }),
+ ttsClient,
+ localServiceManager ?? new StubReplyTtsLocalServiceManager());
+ }
+
+ private static ReplyTtsStorageRootResolver CreateAvailableResolver()
+ {
+ return new ReplyTtsStorageRootResolver(
+ new MutableOptionsMonitor(new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = @"D:\reply-tts"
+ }),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true)
+ ]));
+ }
+
+ private static ReplyTtsStorageRootResolver CreateUnavailableResolver()
+ {
+ return new ReplyTtsStorageRootResolver(
+ new MutableOptionsMonitor(new FeishuReplyTtsOptions
+ {
+ }),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true)
+ ]));
+ }
+
+ private sealed class StubSherpaKokoroTtsClient : ISherpaKokoroTtsClient
+ {
+ public int HealthCallCount { get; private set; }
+
+ public FeishuReplyTtsHealthStatus HealthResult { get; set; } = new();
+
+ public IReadOnlyList VoicesResult { get; set; } = [];
+
+ public Exception? HealthException { get; set; }
+
+ public Exception? VoicesException { get; set; }
+
+ public Task GetHealthAsync(CancellationToken cancellationToken = default)
+ {
+ HealthCallCount++;
+ if (HealthException is not null)
+ {
+ throw HealthException;
+ }
+
+ return Task.FromResult(HealthResult);
+ }
+
+ public Task> GetVoicesAsync(CancellationToken cancellationToken = default)
+ {
+ if (VoicesException is not null)
+ {
+ throw VoicesException;
+ }
+
+ return Task.FromResult(VoicesResult);
+ }
+
+ public Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+ }
+
+ private sealed class StubReplyTtsLocalServiceManager : IReplyTtsLocalServiceManager
+ {
+ public int EnsureStartedCallCount { get; private set; }
+
+ public FeishuReplyTtsHealthStatus? LastStorageHealth { get; private set; }
+
+ public FeishuReplyTtsHealthStatus Result { get; set; } = new()
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok"
+ };
+
+ public Task EnsureStartedAsync(
+ FeishuReplyTtsHealthStatus storageHealth,
+ CancellationToken cancellationToken = default)
+ {
+ EnsureStartedCallCount++;
+ LastStorageHealth = storageHealth;
+ return Task.FromResult(Result);
+ }
+ }
+
+ private sealed class FakeReplyTtsHostEnvironment : IReplyTtsHostEnvironment
+ {
+ private readonly IReadOnlyList _drives;
+
+ public FakeReplyTtsHostEnvironment(
+ bool isWindows,
+ string? systemDriveRoot,
+ IReadOnlyList drives)
+ {
+ IsWindows = isWindows;
+ SystemDriveRoot = systemDriveRoot;
+ _drives = drives;
+ }
+
+ public bool IsWindows { get; }
+
+ public string? SystemDriveRoot { get; }
+
+ public IReadOnlyList GetFixedDrives()
+ {
+ return _drives;
+ }
+
+ public bool DirectoryExists(string path)
+ {
+ return Directory.Exists(path);
+ }
+
+ public bool FileExists(string path)
+ {
+ return File.Exists(path);
+ }
+ }
+
+ private sealed class MutableOptionsMonitor(TOptions currentValue) : IOptionsMonitor
+ {
+ public TOptions CurrentValue { get; private set; } = currentValue;
+
+ public TOptions Get(string? name) => CurrentValue;
+
+ public IDisposable? OnChange(Action listener) => null;
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/GoalCapabilityServiceTests.cs b/WebCodeCli.Domain.Tests/GoalCapabilityServiceTests.cs
new file mode 100644
index 0000000..d95e585
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/GoalCapabilityServiceTests.cs
@@ -0,0 +1,221 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using WebCodeCli.Domain.Common.Options;
+using WebCodeCli.Domain.Domain.Model;
+using WebCodeCli.Domain.Domain.Service;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class GoalCapabilityServiceTests
+{
+ [Fact]
+ public async Task ProbeAsync_ReturnsUnavailable_WhenToolIsNotCodex()
+ {
+ var service = CreateService(
+ versionOutput: "codex-cli 0.128.0",
+ featureOutput: "goals under development false");
+
+ var result = await service.ProbeAsync(new GoalCapabilityContext
+ {
+ ToolId = "claude-code",
+ ProviderId = "provider-a"
+ });
+
+ Assert.Equal(GoalCapabilityState.Unavailable, result.State);
+ Assert.Equal(GoalCapabilityProbeOutcome.UnsupportedTool, result.Outcome);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_ReturnsUnavailable_WhenVersionIsTooLow()
+ {
+ var service = CreateService(
+ versionOutput: "codex-cli 0.127.0",
+ featureOutput: "goals under development false");
+
+ var result = await service.ProbeAsync(new GoalCapabilityContext
+ {
+ ToolId = "codex",
+ ProviderId = "provider-b"
+ });
+
+ Assert.Equal(GoalCapabilityState.Unavailable, result.State);
+ Assert.Equal(GoalCapabilityProbeOutcome.UnsupportedVersion, result.Outcome);
+ Assert.Equal("0.127.0", result.DetectedVersion);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_ReturnsUnavailable_WhenFeatureIsMissing()
+ {
+ var service = CreateService(
+ versionOutput: "codex-cli 0.128.0",
+ featureOutput: """
+ multi_agent stable true
+ reasoning_summaries stable true
+ """);
+
+ var result = await service.ProbeAsync(new GoalCapabilityContext
+ {
+ ToolId = "codex",
+ ProviderId = "provider-c"
+ });
+
+ Assert.Equal(GoalCapabilityState.Unavailable, result.State);
+ Assert.Equal(GoalCapabilityProbeOutcome.MissingFeature, result.Outcome);
+ Assert.Equal("0.128.0", result.DetectedVersion);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_ReturnsAvailable_WhenVersionAndFeatureMatch()
+ {
+ var service = CreateService(
+ versionOutput: "codex-cli 0.128.0",
+ featureOutput: """
+ goals under development false
+ multi_agent stable true
+ """);
+
+ var result = await service.ProbeAsync(new GoalCapabilityContext
+ {
+ ToolId = "codex",
+ ProviderId = "provider-d"
+ });
+
+ Assert.Equal(GoalCapabilityState.Available, result.State);
+ Assert.Equal(GoalCapabilityProbeOutcome.Available, result.Outcome);
+ Assert.Equal("0.128.0", result.DetectedVersion);
+ Assert.True(result.HasGoalsFeature);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_UsesCache_UntilForceRefresh()
+ {
+ var service = CreateService(
+ versionOutput: "codex-cli 0.127.0",
+ featureOutput: "goals under development false");
+ var context = new GoalCapabilityContext
+ {
+ ToolId = "codex",
+ ProviderId = "provider-e"
+ };
+
+ var initial = await service.ProbeAsync(context);
+ Assert.Equal(GoalCapabilityState.Unavailable, initial.State);
+
+ service.VersionOutput = "codex-cli 0.128.0";
+ service.FeatureOutput = "goals under development false";
+
+ var cached = await service.ProbeAsync(context);
+ Assert.True(cached.FromCache);
+ Assert.Equal(GoalCapabilityState.Unavailable, cached.State);
+
+ var refreshed = await service.ProbeAsync(context, forceRefresh: true);
+ Assert.False(refreshed.FromCache);
+ Assert.Equal(GoalCapabilityState.Available, refreshed.State);
+ }
+
+ private static TestGoalCapabilityService CreateService(string versionOutput, string featureOutput)
+ {
+ return new TestGoalCapabilityService(
+ new StubCcSwitchService(),
+ Options.Create(new CliToolsOption
+ {
+ Tools =
+ [
+ new CliToolConfig
+ {
+ Id = "codex",
+ Name = "Codex",
+ Command = "codex"
+ }
+ ]
+ }),
+ NullLogger.Instance)
+ {
+ VersionOutput = versionOutput,
+ FeatureOutput = featureOutput
+ };
+ }
+
+ private sealed class TestGoalCapabilityService : GoalCapabilityService
+ {
+ public TestGoalCapabilityService(
+ ICcSwitchService ccSwitchService,
+ IOptions options,
+ Microsoft.Extensions.Logging.ILogger logger)
+ : base(ccSwitchService, options, logger)
+ {
+ }
+
+ public string VersionOutput { get; set; } = "codex-cli 0.128.0";
+
+ public string FeatureOutput { get; set; } = "goals under development false";
+
+ protected override Task RunCommandAsync(
+ string command,
+ string arguments,
+ string? workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (arguments == "--version")
+ {
+ return Task.FromResult(new CommandProbeResult(true, VersionOutput));
+ }
+
+ if (arguments == "features list")
+ {
+ return Task.FromResult(new CommandProbeResult(true, FeatureOutput));
+ }
+
+ return Task.FromResult(new CommandProbeResult(false, string.Empty));
+ }
+ }
+
+ private sealed class StubCcSwitchService : ICcSwitchService
+ {
+ public bool IsManagedTool(string toolId)
+ {
+ return toolId is "claude-code" or "codex" or "opencode";
+ }
+
+ public Task GetStatusAsync(CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public Task GetToolStatusAsync(string toolId, CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(new CcSwitchToolStatus
+ {
+ ToolId = toolId,
+ ActiveProviderId = $"{toolId}-provider",
+ IsManaged = true,
+ IsLaunchReady = true
+ });
+ }
+
+ public Task> GetToolStatusesAsync(IEnumerable toolIds, CancellationToken cancellationToken = default)
+ {
+ IReadOnlyDictionary result = toolIds
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ toolId => toolId,
+ toolId => new CcSwitchToolStatus
+ {
+ ToolId = toolId,
+ ActiveProviderId = $"{toolId}-provider",
+ IsManaged = true,
+ IsLaunchReady = true
+ },
+ StringComparer.OrdinalIgnoreCase);
+
+ return Task.FromResult(result);
+ }
+
+ public Task GetModelCatalogAsync(string toolId, string? providerId = null, CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs b/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs
new file mode 100644
index 0000000..23abd9d
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs
@@ -0,0 +1,25 @@
+using WebCodeCli.Domain.Domain.Model;
+using WebCodeCli.Domain.Domain.Service;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class GoalPromptBuilderTests
+{
+ [Theory]
+ [InlineData("整理这个目标", "/goal 整理这个目标")]
+ [InlineData("/goal 整理这个目标", "/goal 整理这个目标")]
+ [InlineData(" 整理这个目标 ", "/goal 整理这个目标")]
+ public void BuildGoalPrompt_AppliesPrefixOnlyWhenMissing(string input, string expected)
+ {
+ Assert.Equal(expected, GoalPromptBuilder.BuildGoalPrompt(input));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void BuildGoalPrompt_ReturnsNullForBlankInput(string? input)
+ {
+ Assert.Null(GoalPromptBuilder.BuildGoalPrompt(input));
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs
new file mode 100644
index 0000000..5e17389
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs
@@ -0,0 +1,112 @@
+using WebCodeCli.Domain.Domain.Service.Channels;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class ReplyTtsChunkerTests
+{
+ [Fact]
+ public void Split_WhenParagraphsFitLimit_KeepsSingleChunk()
+ {
+ var chunker = new ReplyTtsChunker(maxChars: 80);
+
+ var chunks = chunker.Split("第一段很短。\n\n第二段也很短。");
+
+ Assert.Collection(
+ chunks,
+ chunk => Assert.Equal("第一段很短。\n\n第二段也很短。", chunk));
+ }
+
+ [Fact]
+ public void Split_WhenParagraphBoundaryFitsBetter_PrefersParagraphChunksBeforeSentenceFallback()
+ {
+ var chunker = new ReplyTtsChunker(maxChars: 10);
+
+ var chunks = chunker.Split("第一段很短。\n\n第二段也短。");
+
+ Assert.Collection(
+ chunks,
+ chunk => Assert.Equal("第一段很短。", chunk),
+ chunk => Assert.Equal("第二段也短。", chunk));
+ }
+
+ [Fact]
+ public void Split_WhenParagraphExceedsLimit_SplitsOnSentenceBoundariesFirst()
+ {
+ var chunker = new ReplyTtsChunker(maxChars: 10);
+
+ var chunks = chunker.Split("第一句很短。第二句也短。第三句也短。");
+
+ Assert.Collection(
+ chunks,
+ chunk => Assert.Equal("第一句很短。", chunk),
+ chunk => Assert.Equal("第二句也短。", chunk),
+ chunk => Assert.Equal("第三句也短。", chunk));
+ }
+
+ [Fact]
+ public void Split_WhenStructuredShortLinesFitLimit_KeepsLargerChunkForPrimaryPass()
+ {
+ var chunker = new ReplyTtsChunker(maxChars: 120);
+
+ var chunks = chunker.Split(
+ """
+ 顶部区域只放公共字段:
+ 条码
+ 托盘号
+ 当前库位
+ 分区(可改,下拉)
+ """);
+
+ Assert.Collection(
+ chunks,
+ chunk => Assert.Equal("顶部区域只放公共字段:\n条码\n托盘号\n当前库位\n分区(可改,下拉)", chunk));
+ }
+
+ [Fact]
+ public void SplitForRetry_WhenStructuredShortLines_GroupsAdjacentLinesIntoPairs()
+ {
+ var chunker = new ReplyTtsChunker(maxChars: 120);
+
+ var chunks = chunker.SplitForRetry(
+ """
+ 顶部区域只放公共字段:
+ 条码
+ 托盘号
+ 当前库位
+ 分区(可改,下拉)
+ """);
+
+ Assert.Collection(
+ chunks,
+ chunk => Assert.Equal("顶部区域只放公共字段:\n条码", chunk),
+ chunk => Assert.Equal("托盘号\n当前库位", chunk),
+ chunk => Assert.Equal("分区(可改,下拉)", chunk));
+ }
+
+ [Fact]
+ public void SplitForRetry_WhenOnlyTwoStructuredLines_RemainsAbleToSplitToSingles()
+ {
+ var chunker = new ReplyTtsChunker(maxChars: 120);
+
+ var chunks = chunker.SplitForRetry("第一句。\n第二句。");
+
+ Assert.Collection(
+ chunks,
+ chunk => Assert.Equal("第一句。", chunk),
+ chunk => Assert.Equal("第二句。", chunk));
+ }
+
+ [Fact]
+ public void SplitForRetry_WhenSingleParagraphStillNeedsSmallerChunks_SplitsBySentence()
+ {
+ var chunker = new ReplyTtsChunker(maxChars: 120);
+
+ var chunks = chunker.SplitForRetry("第一句很短。第二句也很短。第三句也很短。");
+
+ Assert.Collection(
+ chunks,
+ chunk => Assert.Equal("第一句很短。", chunk),
+ chunk => Assert.Equal("第二句也很短。", chunk),
+ chunk => Assert.Equal("第三句也很短。", chunk));
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs
new file mode 100644
index 0000000..bcd6989
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs
@@ -0,0 +1,619 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using WebCodeCli.Domain.Common.Options;
+using WebCodeCli.Domain.Domain.Model.Channels;
+using WebCodeCli.Domain.Domain.Service;
+using WebCodeCli.Domain.Domain.Service.Channels;
+using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class ReplyTtsOrchestratorTests
+{
+ [Fact]
+ public async Task QueueCompletedReplyAsync_SkipsWhenReplyTtsDisabled()
+ {
+ using var harness = new ReplyTtsOrchestratorHarness(
+ new UserFeishuBotConfigEntity
+ {
+ Username = "luhaiyan",
+ ReplyTtsEnabled = false
+ });
+
+ await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest
+ {
+ ChatId = "oc-disabled-chat",
+ Username = "luhaiyan",
+ Output = "需要播报"
+ });
+
+ await WaitUntilAsync(() => harness.ConfigService.UsernameLookupCount == 1);
+
+ Assert.Empty(harness.KokoroClient.Calls);
+ Assert.Empty(harness.TranscodeService.Calls);
+ Assert.Empty(harness.AudioService.Calls);
+ Assert.Equal(0, harness.CardKit.SendTextCallCount);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("# **__**")]
+ public async Task QueueCompletedReplyAsync_SkipsWhenNormalizedOutputIsEmpty(string output)
+ {
+ using var harness = new ReplyTtsOrchestratorHarness(
+ new UserFeishuBotConfigEntity
+ {
+ Username = "luhaiyan",
+ ReplyTtsEnabled = true
+ });
+
+ await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest
+ {
+ ChatId = "oc-empty-chat",
+ Username = "luhaiyan",
+ Output = output
+ });
+
+ await WaitUntilAsync(() => harness.ConfigService.UsernameLookupCount == 1);
+
+ Assert.Empty(harness.KokoroClient.Calls);
+ Assert.Empty(harness.TranscodeService.Calls);
+ Assert.Empty(harness.AudioService.Calls);
+ Assert.Equal(0, harness.CardKit.SendTextCallCount);
+ }
+
+ [Fact]
+ public async Task QueueCompletedReplyAsync_FallsBackToDefaultVoiceAndProcessesChunksInOrder()
+ {
+ using var harness = new ReplyTtsOrchestratorHarness(
+ new UserFeishuBotConfigEntity
+ {
+ Username = "luhaiyan",
+ ReplyTtsEnabled = true,
+ ReplyTtsVoiceId = "missing-voice"
+ },
+ chunkMaxChars: 20);
+
+ harness.PlatformService.Resolution = new FeishuReplyTtsVoiceResolutionResult
+ {
+ Success = true,
+ VoiceId = "platform-default",
+ UsedFallback = true,
+ Voice = new FeishuReplyTtsVoiceOption
+ {
+ VoiceId = "platform-default",
+ DisplayName = "Platform Default"
+ }
+ };
+
+ await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest
+ {
+ ChatId = "oc-order-chat",
+ Username = "luhaiyan",
+ Output = "first paragraph.\n\nsecond paragraph."
+ });
+
+ await WaitUntilAsync(() => harness.AudioService.Calls.Count == 2);
+ await WaitUntilAsync(() => harness.JobTempRoot is not null && (!Directory.Exists(harness.JobTempRoot) || !Directory.EnumerateDirectories(harness.JobTempRoot).Any()));
+
+ Assert.Collection(
+ harness.Sequence,
+ item => Assert.Equal("synthesize:1:platform-default:first paragraph.", item),
+ item => Assert.Equal("transcode:1", item),
+ item => Assert.Equal("send:1", item),
+ item => Assert.Equal("synthesize:2:platform-default:second paragraph.", item),
+ item => Assert.Equal("transcode:2", item),
+ item => Assert.Equal("send:2", item));
+
+ Assert.All(harness.KokoroClient.Calls, call => Assert.Equal("platform-default", call.VoiceId));
+ Assert.All(harness.AudioService.Calls, call => Assert.True(call.DurationMs > 0));
+ Assert.Equal(0, harness.CardKit.SendTextCallCount);
+ }
+
+ [Fact]
+ public async Task QueueCompletedReplyAsync_WhenSynthesisFails_StopsAudioWithoutRetryAndSendsTextFallback()
+ {
+ using var harness = new ReplyTtsOrchestratorHarness(
+ new UserFeishuBotConfigEntity
+ {
+ Username = "luhaiyan",
+ ReplyTtsEnabled = true
+ });
+
+ harness.KokoroClient.FailureCondition = static _ => true;
+
+ await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest
+ {
+ ChatId = "oc-synth-failure-chat",
+ Username = "luhaiyan",
+ Output = "first line.\nsecond line.\nthird line."
+ });
+
+ await WaitUntilAsync(() => harness.KokoroClient.Calls.Count >= 1);
+ await Task.Delay(150, TestContext.Current.CancellationToken);
+
+ Assert.Single(harness.KokoroClient.Calls);
+ Assert.Empty(harness.TranscodeService.Calls);
+ Assert.Empty(harness.AudioService.Calls);
+ Assert.Equal(1, harness.CardKit.SendTextCallCount);
+ Assert.Equal("回复语音发送失败,已停止后续音频。", harness.CardKit.TextMessages.Single());
+ }
+
+ [Fact]
+ public async Task QueueCompletedReplyAsync_WhenAudioSendPipelineFails_StopsRemainingAudioAndSendsTextFallback()
+ {
+ using var harness = new ReplyTtsOrchestratorHarness(
+ new UserFeishuBotConfigEntity
+ {
+ Username = "luhaiyan",
+ ReplyTtsEnabled = true
+ },
+ chunkMaxChars: 20);
+
+ harness.TranscodeService.FailChunkIndex = 2;
+
+ await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest
+ {
+ ChatId = "oc-pipeline-failure-chat",
+ Username = "luhaiyan",
+ Output = "first paragraph.\n\nsecond paragraph.\n\nthird paragraph."
+ });
+
+ await WaitUntilAsync(() => harness.TranscodeService.Calls.Count == 2);
+ await Task.Delay(150, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, harness.KokoroClient.Calls.Count);
+ Assert.Equal(2, harness.TranscodeService.Calls.Count);
+ Assert.Single(harness.AudioService.Calls);
+ Assert.Equal(1, harness.CardKit.SendTextCallCount);
+ Assert.Equal("回复语音发送失败,已停止后续音频。", harness.CardKit.TextMessages.Single());
+ Assert.DoesNotContain(harness.Sequence, item => item == "synthesize:3:default-voice:third paragraph.");
+ }
+
+ [Fact]
+ public async Task QueueCompletedReplyAsync_WhenSynthesisTimesOut_RetriesWithSmallerChunksBeforeFallback()
+ {
+ using var harness = new ReplyTtsOrchestratorHarness(
+ new UserFeishuBotConfigEntity
+ {
+ Username = "luhaiyan",
+ ReplyTtsEnabled = true
+ },
+ chunkMaxChars: 120);
+
+ harness.KokoroClient.FailureFactory = static text =>
+ text.Length > 20
+ ? new TaskCanceledException("simulated synth timeout")
+ : null;
+
+ await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest
+ {
+ ChatId = "oc-timeout-retry-chat",
+ Username = "luhaiyan",
+ Output =
+ """
+ 顶部区域只放公共字段:
+ 条码
+ 托盘号
+ 当前库位
+ 分区(可改,下拉)
+ """
+ });
+
+ await Task.Delay(500, TestContext.Current.CancellationToken);
+
+ Assert.Equal(3, harness.KokoroClient.Calls.Count);
+ Assert.Equal(2, harness.TranscodeService.Calls.Count);
+ Assert.Equal(2, harness.AudioService.Calls.Count);
+ Assert.Equal(0, harness.CardKit.SendTextCallCount);
+ Assert.Collection(
+ harness.KokoroClient.Calls.Select(static call => call.Text),
+ call => Assert.Equal("顶部区域只放公共字段:\n条码托盘号当前库位分区(可改,下拉)", call),
+ call => Assert.Equal("顶部区域只放公共字段:", call),
+ call => Assert.Equal("条码托盘号当前库位分区(可改,下拉)", call));
+ }
+
+ [Fact]
+ public async Task QueueCompletedReplyAsync_SerializesJobsPerChat()
+ {
+ using var harness = new ReplyTtsOrchestratorHarness(
+ new UserFeishuBotConfigEntity
+ {
+ Username = "luhaiyan",
+ ReplyTtsEnabled = true
+ });
+
+ harness.AudioService.BlockFirstSend = true;
+
+ await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest
+ {
+ ChatId = "oc-serialized-chat",
+ Username = "luhaiyan",
+ Output = "first reply"
+ });
+
+ await harness.AudioService.FirstSendStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+
+ await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest
+ {
+ ChatId = "oc-serialized-chat",
+ Username = "luhaiyan",
+ Output = "second reply"
+ });
+
+ await Task.Delay(150, TestContext.Current.CancellationToken);
+
+ Assert.Single(harness.KokoroClient.Calls);
+
+ harness.AudioService.ReleaseFirstSend();
+
+ await WaitUntilAsync(() => harness.AudioService.Calls.Count == 2);
+
+ Assert.Collection(
+ harness.KokoroClient.Calls,
+ first => Assert.Equal("first reply", first.Text),
+ second => Assert.Equal("second reply", second.Text));
+ }
+
+ private static async Task WaitUntilAsync(Func condition, int timeoutMs = 5000)
+ {
+ var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
+ while (DateTime.UtcNow < deadline)
+ {
+ if (condition())
+ {
+ return;
+ }
+
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(condition(), "Timed out waiting for the expected condition.");
+ }
+
+ private sealed class ReplyTtsOrchestratorHarness : IDisposable
+ {
+ private readonly ServiceProvider _serviceProvider;
+
+ public ReplyTtsOrchestratorHarness(UserFeishuBotConfigEntity config, int chunkMaxChars = 1200)
+ {
+ TempRoot = Path.Combine(Path.GetTempPath(), $"reply-tts-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(TempRoot);
+
+ ConfigService = new TrackingUserFeishuBotConfigService(config);
+ PlatformService = new TrackingReplyTtsPlatformService();
+ KokoroClient = new TrackingSherpaKokoroTtsClient(Sequence);
+ TranscodeService = new TrackingAudioTranscodeService(Sequence);
+ AudioService = new TrackingFeishuAudioMessageService(Sequence);
+ CardKit = new TrackingFeishuCardKitClient();
+
+ var services = new ServiceCollection();
+ services.AddSingleton(new ReplyTtsStorageRootResolver(
+ new StaticOptionsMonitor(new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = TempRoot
+ }),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: false,
+ systemDriveRoot: null,
+ drives: [])));
+ services.AddSingleton();
+ services.AddScoped(_ => new ReplyTtsChunker(chunkMaxChars));
+ services.AddScoped(_ => PlatformService);
+ services.AddScoped(_ => KokoroClient);
+ services.AddScoped(_ => TranscodeService);
+ services.AddScoped(_ => AudioService);
+ services.AddScoped(_ => ConfigService);
+ services.AddScoped(_ => CardKit);
+ services.AddLogging();
+
+ _serviceProvider = services.BuildServiceProvider();
+ Orchestrator = new ReplyTtsOrchestrator(
+ _serviceProvider,
+ _serviceProvider.GetRequiredService(),
+ NullLogger.Instance);
+ }
+
+ public ConcurrentQueue Sequence { get; } = new();
+
+ public string TempRoot { get; }
+
+ public string JobTempRoot => Path.Combine(TempRoot, "temp");
+
+ public TrackingUserFeishuBotConfigService ConfigService { get; }
+
+ public TrackingReplyTtsPlatformService PlatformService { get; }
+
+ public TrackingSherpaKokoroTtsClient KokoroClient { get; }
+
+ public TrackingAudioTranscodeService TranscodeService { get; }
+
+ public TrackingFeishuAudioMessageService AudioService { get; }
+
+ public TrackingFeishuCardKitClient CardKit { get; }
+
+ public ReplyTtsOrchestrator Orchestrator { get; }
+
+ public void Dispose()
+ {
+ _serviceProvider.Dispose();
+ if (Directory.Exists(TempRoot))
+ {
+ Directory.Delete(TempRoot, recursive: true);
+ }
+ }
+ }
+
+ private sealed class TrackingUserFeishuBotConfigService(UserFeishuBotConfigEntity config) : IUserFeishuBotConfigService
+ {
+ public int UsernameLookupCount { get; private set; }
+
+ public Task GetByUsernameAsync(string username)
+ {
+ UsernameLookupCount++;
+ return Task.FromResult(string.Equals(username, config.Username, StringComparison.OrdinalIgnoreCase)
+ ? config
+ : null);
+ }
+
+ public Task GetByAppIdAsync(string appId)
+ => Task.FromResult(null);
+
+ public Task SaveAsync(UserFeishuBotConfigEntity configEntity)
+ => throw new NotSupportedException();
+
+ public Task DeleteAsync(string username) => Task.FromResult(true);
+
+ public Task FindConflictingUsernameByAppIdAsync(string username, string? appId)
+ => Task.FromResult(null);
+
+ public Task> GetAutoStartCandidatesAsync()
+ => Task.FromResult(new List());
+
+ public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null)
+ => Task.FromResult(true);
+
+ public FeishuOptions GetSharedDefaults() => new()
+ {
+ Enabled = true,
+ AppId = "shared-app-id",
+ AppSecret = "shared-secret"
+ };
+
+ public Task GetEffectiveOptionsAsync(string? username) => Task.FromResult(GetSharedDefaults());
+
+ public Task GetEffectiveOptionsByAppIdAsync(string? appId)
+ => Task.FromResult(null);
+ }
+
+ private sealed class FakeReplyTtsHostEnvironment(
+ bool isWindows,
+ string? systemDriveRoot,
+ IReadOnlyList drives) : IReplyTtsHostEnvironment
+ {
+ public bool IsWindows { get; } = isWindows;
+
+ public string? SystemDriveRoot { get; } = systemDriveRoot;
+
+ public IReadOnlyList GetFixedDrives() => drives;
+
+ public bool DirectoryExists(string path) => Directory.Exists(path);
+
+ public bool FileExists(string path) => File.Exists(path);
+ }
+
+ private sealed class TrackingReplyTtsPlatformService : IFeishuReplyTtsPlatformService
+ {
+ public FeishuReplyTtsVoiceResolutionResult Resolution { get; set; } = new()
+ {
+ Success = true,
+ VoiceId = "default-voice",
+ Voice = new FeishuReplyTtsVoiceOption
+ {
+ VoiceId = "default-voice",
+ DisplayName = "Default Voice"
+ }
+ };
+
+ public Task GetHealthAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ public Task> GetVoicesAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ public Task ResolveVoiceOrFallbackAsync(string? savedVoiceId, CancellationToken cancellationToken = default)
+ => Task.FromResult(Resolution);
+
+ public Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+ }
+
+ private sealed class TrackingSherpaKokoroTtsClient(ConcurrentQueue sequence) : ISherpaKokoroTtsClient
+ {
+ public List Calls { get; } = new();
+
+ public Func? FailureCondition { get; set; }
+
+ public Func? FailureFactory { get; set; }
+
+ public Task GetHealthAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ public Task> GetVoicesAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ public Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default)
+ {
+ var call = new SynthesizeCall(text, voiceId);
+ Calls.Add(call);
+ sequence.Enqueue($"synthesize:{Calls.Count}:{voiceId}:{text}");
+
+ var failure = FailureFactory?.Invoke(text);
+ if (failure != null)
+ {
+ throw failure;
+ }
+
+ if (FailureCondition?.Invoke(text) == true)
+ {
+ throw new HttpRequestException("simulated synth failure");
+ }
+
+ return Task.FromResult(new MemoryStream(CreateWaveBytes(durationMs: 1000)));
+ }
+ }
+
+ private sealed class TrackingAudioTranscodeService(ConcurrentQueue sequence) : IAudioTranscodeService
+ {
+ public int? FailChunkIndex { get; set; }
+
+ public List Calls { get; } = new();
+
+ public Task TranscodeChunkAsync(string jobId, string inputWavPath, int chunkIndex, CancellationToken cancellationToken = default)
+ {
+ Calls.Add(new TranscodeCall(jobId, inputWavPath, chunkIndex));
+ sequence.Enqueue($"transcode:{chunkIndex}");
+
+ if (FailChunkIndex == chunkIndex)
+ {
+ throw new InvalidOperationException($"chunk {chunkIndex} failed");
+ }
+
+ var outputPath = Path.Combine(Path.GetDirectoryName(inputWavPath)!, $"chunk-{chunkIndex:000}.opus");
+ File.WriteAllBytes(outputPath, [1, 2, 3, 4]);
+ return Task.FromResult(outputPath);
+ }
+ }
+
+ private sealed class TrackingFeishuAudioMessageService(ConcurrentQueue sequence) : IFeishuAudioMessageService
+ {
+ private readonly TaskCompletionSource _releaseFirstSend = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public bool BlockFirstSend { get; set; }
+
+ public TaskCompletionSource FirstSendStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public List Calls { get; } = new();
+
+ public async Task SendAudioMessageAsync(
+ string chatId,
+ string filePath,
+ int durationMs,
+ string? username = null,
+ string? appId = null,
+ CancellationToken cancellationToken = default)
+ {
+ var call = new AudioSendCall(chatId, filePath, durationMs, username, appId);
+ Calls.Add(call);
+ sequence.Enqueue($"send:{Calls.Count}");
+
+ if (BlockFirstSend && Calls.Count == 1)
+ {
+ FirstSendStarted.TrySetResult(true);
+ await _releaseFirstSend.Task.WaitAsync(cancellationToken);
+ }
+
+ return $"audio-{Calls.Count}";
+ }
+
+ public void ReleaseFirstSend()
+ {
+ _releaseFirstSend.TrySetResult(true);
+ }
+ }
+
+ private sealed class TrackingFeishuCardKitClient : IFeishuCardKitClient
+ {
+ public int SendTextCallCount { get; private set; }
+
+ public List TextMessages { get; } = new();
+
+ public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task UpdateCardAsync(string cardId, string content, int sequence, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task SendCardMessageAsync(string chatId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ {
+ SendTextCallCount++;
+ TextMessages.Add(content);
+ return Task.FromResult($"text-{SendTextCallCount}");
+ }
+
+ public Task ReplyCardMessageAsync(string replyMessageId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task ReplyTextMessageAsync(string replyMessageId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task UploadAudioFileAsync(string filePath, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task SendAudioMessageAsync(string chatId, string fileKey, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task CreateStreamingHandleAsync(string chatId, string? replyMessageId, string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, FeishuStreamingCardChrome? chrome = null)
+ => throw new NotSupportedException();
+
+ public Task SendRawCardAsync(string chatId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task ReplyElementsCardAsync(string replyMessageId, FeishuNetSdk.Im.Dtos.ElementsCardV2Dto card, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+
+ public Task ReplyRawCardAsync(string replyMessageId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null)
+ => throw new NotSupportedException();
+ }
+
+ private sealed class StaticOptionsMonitor(T currentValue) : IOptionsMonitor
+ {
+ public T CurrentValue => currentValue;
+
+ public T Get(string? name) => currentValue;
+
+ public IDisposable? OnChange(Action listener) => null;
+ }
+
+ private sealed record SynthesizeCall(string Text, string VoiceId);
+
+ private sealed record TranscodeCall(string JobId, string InputWavPath, int ChunkIndex);
+
+ private sealed record AudioSendCall(string ChatId, string FilePath, int DurationMs, string? Username, string? AppId);
+
+ private static byte[] CreateWaveBytes(int durationMs)
+ {
+ const short channels = 1;
+ const int sampleRate = 16000;
+ const short bitsPerSample = 16;
+ var bytesPerSample = bitsPerSample / 8;
+ var sampleCount = sampleRate * durationMs / 1000;
+ var dataSize = sampleCount * channels * bytesPerSample;
+ var byteRate = sampleRate * channels * bytesPerSample;
+ var blockAlign = (short)(channels * bytesPerSample);
+
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter(stream);
+ writer.Write("RIFF"u8.ToArray());
+ writer.Write(36 + dataSize);
+ writer.Write("WAVE"u8.ToArray());
+ writer.Write("fmt "u8.ToArray());
+ writer.Write(16);
+ writer.Write((short)1);
+ writer.Write(channels);
+ writer.Write(sampleRate);
+ writer.Write(byteRate);
+ writer.Write(blockAlign);
+ writer.Write(bitsPerSample);
+ writer.Write("data"u8.ToArray());
+ writer.Write(dataSize);
+ writer.Write(new byte[dataSize]);
+ writer.Flush();
+ return stream.ToArray();
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs
new file mode 100644
index 0000000..5d683e9
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs
@@ -0,0 +1,116 @@
+using WebCodeCli.Domain.Domain.Service.Channels;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class ReplyTtsSpeechTextNormalizerTests
+{
+ [Fact]
+ public void Normalize_RemovesMarkdownLinksAndCodeBlocks()
+ {
+ var normalizer = new ReplyTtsSpeechTextNormalizer();
+
+ var result = normalizer.Normalize(
+ """
+ # Heading
+
+ - **Bold** item with [docs](https://example.com/docs)
+ Visit https://example.com/raw for more.
+
+ ```csharp
+ Console.WriteLine("hi");
+ ```
+ """);
+
+ var expected = """
+ Heading
+ Bold item with docs
+ Visit this link for more.
+
+ Code snippet omitted.
+ """
+ .ReplaceLineEndings("\n")
+ .Trim();
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void Normalize_ReplacesInlineTechnicalReferencesWithFileClassAndMethodNames()
+ {
+ var normalizer = new ReplyTtsSpeechTextNormalizer();
+
+ var result = normalizer.Normalize(
+ """
+ Check `Cimc.Tianda.Wms.Web/src/views/mobile/receiving/index.vue:125`.
+ Fields come from `lotAttr1 / lotAttr8 / attr1 / attr2 / containerAttr2`.
+ Run `dotnet test WebCodeCli.Domain.Tests`.
+ Call `WebCodeCli.Domain.Domain.Service.Channels.SherpaKokoroTtsClient.SynthesizeAsync`.
+ Call `GetStockByContainerCodeOrBarcode(containerCodeOrBarcode)`.
+ """);
+
+ Assert.Contains("index.vue 文件", result, StringComparison.Ordinal);
+ Assert.Contains("若干属性字段", result, StringComparison.Ordinal);
+ Assert.Contains("相关命令", result, StringComparison.Ordinal);
+ Assert.Contains("SherpaKokoroTtsClient 类 SynthesizeAsync 方法", result, StringComparison.Ordinal);
+ Assert.Contains("GetStockByContainerCodeOrBarcode 方法", result, StringComparison.Ordinal);
+ Assert.DoesNotContain("Cimc.Tianda.Wms.Web/src/views/mobile/receiving/index.vue", result, StringComparison.Ordinal);
+ Assert.DoesNotContain("WebCodeCli.Domain.Domain.Service.Channels.SherpaKokoroTtsClient.SynthesizeAsync", result, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Normalize_TreatsBareCodeFileNamesAsFiles()
+ {
+ var normalizer = new ReplyTtsSpeechTextNormalizer();
+
+ var result = normalizer.Normalize(
+ """
+ ConfigurationPublicationControllerTests.cs、ConfigurationPublicationDeliveryInboxControllerTests.cs、
+ ConfigurationPublicationDispatchAuditControllerTests.cs、ConfigurationPublicationApiIntegrationTests.cs、
+ VersionGovernanceApiIntegrationTests.cs 这几组测试继续收口。
+ """);
+
+ Assert.Contains("ConfigurationPublicationControllerTests.cs 文件", result, StringComparison.Ordinal);
+ Assert.Contains("ConfigurationPublicationDeliveryInboxControllerTests.cs 文件", result, StringComparison.Ordinal);
+ Assert.Contains("ConfigurationPublicationDispatchAuditControllerTests.cs 文件", result, StringComparison.Ordinal);
+ Assert.Contains("ConfigurationPublicationApiIntegrationTests.cs 文件", result, StringComparison.Ordinal);
+ Assert.Contains("VersionGovernanceApiIntegrationTests.cs 文件", result, StringComparison.Ordinal);
+ Assert.DoesNotContain(".cs 方法", result, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Normalize_GenericallyHandlesCodeLikeIdentifiersWithoutDomainSentenceTemplates()
+ {
+ var normalizer = new ReplyTtsSpeechTextNormalizer();
+
+ var result = normalizer.Normalize(
+ """
+ 第二步,后端收货入库事务改造,在 1397 重写 MoveInStereo,调用 GetStockByContainerCodeOrBarcode,并检查 container.Type。
+ 继续按 superpowers:brainstorming 收尾设计,不进代码。
+ """);
+
+ Assert.Contains("MoveInStereo 方法", result, StringComparison.Ordinal);
+ Assert.Contains("GetStockByContainerCodeOrBarcode 方法", result, StringComparison.Ordinal);
+ Assert.Contains("container 类 Type 方法", result, StringComparison.Ordinal);
+ Assert.Contains("superpowers 类 brainstorming 方法", result, StringComparison.Ordinal);
+ Assert.DoesNotContain("第二步会改造后端收货入库事务", result, StringComparison.Ordinal);
+ Assert.DoesNotContain("入库流程", result, StringComparison.Ordinal);
+ Assert.DoesNotContain("扫码查询接口", result, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Normalize_DoesNotApplyReceivingPlanSpecificSentenceRewrites()
+ {
+ var normalizer = new ReplyTtsSpeechTextNormalizer();
+
+ var result = normalizer.Normalize(
+ """
+ 前端收口设计页面 `Cimc.Tianda.Wms.Web/src/views/mobile/receiving/index.vue:1` 保留扫码后多记录卡片展示这个方向。
+ 客户简称 整列删掉,不再出现在顶部或列表里。
+ """);
+
+ Assert.DoesNotContain("这个页面会保留扫码后的多记录卡片展示", result, StringComparison.Ordinal);
+ Assert.DoesNotContain("客户简称这一列会删除", result, StringComparison.Ordinal);
+ Assert.Contains("前端收口设计页面", result, StringComparison.Ordinal);
+ Assert.Contains("index.vue 文件", result, StringComparison.Ordinal);
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/ReplyTtsStartupHostedServiceTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsStartupHostedServiceTests.cs
new file mode 100644
index 0000000..f9ef9b9
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/ReplyTtsStartupHostedServiceTests.cs
@@ -0,0 +1,105 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using WebCodeCli.Domain.Domain.Model.Channels;
+using WebCodeCli.Domain.Domain.Service.Channels;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class ReplyTtsStartupHostedServiceTests
+{
+ [Fact]
+ public async Task StartAsync_WhenNoReplyTtsConfigIsEnabled_DoesNotStartLocalService()
+ {
+ using var harness = new Harness(replyTtsEnabled: false);
+
+ await harness.Service.StartAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, harness.EnablementService.CallCount);
+ Assert.Equal(0, harness.PlatformService.EnsureStartedCallCount);
+ }
+
+ [Fact]
+ public async Task StartAsync_WhenAnyReplyTtsConfigIsEnabled_StartsLocalService()
+ {
+ using var harness = new Harness(replyTtsEnabled: true);
+
+ await harness.Service.StartAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, harness.EnablementService.CallCount);
+ Assert.Equal(1, harness.PlatformService.EnsureStartedCallCount);
+ }
+
+ private sealed class Harness : IDisposable
+ {
+ private readonly ServiceProvider _serviceProvider;
+
+ public Harness(bool replyTtsEnabled)
+ {
+ EnablementService = new StubReplyTtsEnablementService(replyTtsEnabled);
+ PlatformService = new StubFeishuReplyTtsPlatformService();
+
+ var services = new ServiceCollection();
+ services.AddScoped(_ => EnablementService);
+ services.AddScoped(_ => PlatformService);
+ _serviceProvider = services.BuildServiceProvider();
+
+ Service = new ReplyTtsStartupHostedService(
+ _serviceProvider.GetRequiredService(),
+ NullLogger.Instance);
+ }
+
+ public StubReplyTtsEnablementService EnablementService { get; }
+
+ public StubFeishuReplyTtsPlatformService PlatformService { get; }
+
+ public ReplyTtsStartupHostedService Service { get; }
+
+ public void Dispose()
+ {
+ _serviceProvider.Dispose();
+ }
+ }
+
+ private sealed class StubReplyTtsEnablementService(bool replyTtsEnabled) : IReplyTtsEnablementService
+ {
+ public int CallCount { get; private set; }
+
+ public Task HasEnabledReplyTtsAsync(CancellationToken cancellationToken = default)
+ {
+ CallCount++;
+ return Task.FromResult(replyTtsEnabled);
+ }
+ }
+
+ private sealed class StubFeishuReplyTtsPlatformService : IFeishuReplyTtsPlatformService
+ {
+ public int EnsureStartedCallCount { get; private set; }
+
+ public Task GetHealthAsync(CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public Task> GetVoicesAsync(CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public Task ResolveVoiceOrFallbackAsync(
+ string? savedVoiceId,
+ CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default)
+ {
+ EnsureStartedCallCount++;
+ return Task.FromResult(new FeishuReplyTtsHealthStatus
+ {
+ IsAvailable = true,
+ ServiceStatus = "ok"
+ });
+ }
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs
new file mode 100644
index 0000000..7893869
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs
@@ -0,0 +1,349 @@
+using Microsoft.Extensions.Options;
+using System.Reflection;
+using WebCodeCli.Domain.Common.Options;
+using WebCodeCli.Domain.Domain.Service.Channels;
+
+namespace WebCodeCli.Domain.Tests;
+
+public class ReplyTtsStorageRootResolverTests
+{
+ [Fact]
+ public void Resolve_WhenExplicitStorageRootIsSet_AlwaysUsesConfiguredRoot()
+ {
+ var options = CreateMonitor(new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = @"E:\custom-kokoro"
+ });
+ var resolver = new ReplyTtsStorageRootResolver(
+ options,
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true)
+ ]));
+
+ var result = resolver.Resolve();
+
+ Assert.True(result.IsAvailable);
+ Assert.Equal(@"E:\custom-kokoro", result.StorageRoot);
+ Assert.Equal(@"E:\custom-kokoro\models", result.ModelsRoot);
+ Assert.Equal(@"E:\custom-kokoro\cache", result.CacheRoot);
+ Assert.Equal(@"E:\custom-kokoro\temp", result.TempRoot);
+ Assert.Equal(@"E:\custom-kokoro\logs", result.LogsRoot);
+ Assert.Equal(@"E:\custom-kokoro\venv", result.VenvRoot);
+ }
+
+ [Fact]
+ public void Resolve_WhenExplicitStorageRootIsWindowsSystemDrive_ReturnsUnavailable()
+ {
+ var resolver = new ReplyTtsStorageRootResolver(
+ CreateMonitor(new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = "C:"
+ }),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true)
+ ]));
+
+ var result = resolver.Resolve();
+
+ Assert.False(result.IsAvailable);
+ Assert.Null(result.StorageRoot);
+ Assert.Contains("non-system drive", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Resolve_WhenExplicitStorageRootIsUnderWindowsSystemDrive_ReturnsUnavailable()
+ {
+ var resolver = new ReplyTtsStorageRootResolver(
+ CreateMonitor(new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = @"C:\WebCodeData\Kokoro"
+ }),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true)
+ ]));
+
+ var result = resolver.Resolve();
+
+ Assert.False(result.IsAvailable);
+ Assert.Null(result.StorageRoot);
+ Assert.Contains("non-system drive", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Resolve_WhenWindowsAndNoExplicitRoot_PicksFirstWritableNonSystemDrive()
+ {
+ var resolver = new ReplyTtsStorageRootResolver(
+ CreateMonitor(new FeishuReplyTtsOptions()),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true),
+ new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true),
+ new ReplyTtsDriveDescriptor(@"E:\", isReady: true, isWritable: true)
+ ]));
+
+ var result = resolver.Resolve();
+
+ Assert.True(result.IsAvailable);
+ Assert.Equal(@"D:\WebCodeData\Kokoro", result.StorageRoot);
+ }
+
+ [Fact]
+ public void Resolve_WhenWindowsAndExistingKokoroRootIsPresent_PrefersThatDrive()
+ {
+ var resolver = new ReplyTtsStorageRootResolver(
+ CreateMonitor(new FeishuReplyTtsOptions()),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true),
+ new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true),
+ new ReplyTtsDriveDescriptor(@"E:\", isReady: true, isWritable: true)
+ ],
+ existingDirectories:
+ [
+ @"E:\WebCodeData\Kokoro",
+ @"E:\WebCodeData\Kokoro\models"
+ ]));
+
+ var result = resolver.Resolve();
+
+ Assert.True(result.IsAvailable);
+ Assert.Equal(@"E:\WebCodeData\Kokoro", result.StorageRoot);
+ Assert.Contains(@"E:\", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Resolve_WhenDriveOnlyHasTempResidue_DoesNotTreatItAsInstalledKokoroRoot()
+ {
+ var resolver = new ReplyTtsStorageRootResolver(
+ CreateMonitor(new FeishuReplyTtsOptions()),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true),
+ new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true),
+ new ReplyTtsDriveDescriptor(@"E:\", isReady: true, isWritable: true)
+ ],
+ existingDirectories:
+ [
+ @"D:\WebCodeData\Kokoro",
+ @"D:\WebCodeData\Kokoro\temp",
+ @"E:\WebCodeData\Kokoro",
+ @"E:\WebCodeData\Kokoro\models"
+ ]));
+
+ var result = resolver.Resolve();
+
+ Assert.True(result.IsAvailable);
+ Assert.Equal(@"E:\WebCodeData\Kokoro", result.StorageRoot);
+ }
+
+ [Fact]
+ public void Resolve_WhenWindowsOnlyHasSystemDrive_ReturnsUnavailable()
+ {
+ var resolver = new ReplyTtsStorageRootResolver(
+ CreateMonitor(new FeishuReplyTtsOptions()),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true)
+ ]));
+
+ var result = resolver.Resolve();
+
+ Assert.False(result.IsAvailable);
+ Assert.Null(result.StorageRoot);
+ Assert.Contains("C:\\", result.Message, StringComparison.Ordinal);
+ Assert.Contains("non-system drive", result.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("D:\\", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Resolve_WhenNonWindowsAndNoExplicitRoot_UsesDefaultDataPath()
+ {
+ var resolver = new ReplyTtsStorageRootResolver(
+ CreateMonitor(new FeishuReplyTtsOptions()),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: false,
+ systemDriveRoot: null,
+ drives: []));
+
+ var result = resolver.Resolve();
+
+ Assert.True(result.IsAvailable);
+ Assert.Equal("/data/webcode/kokoro", result.StorageRoot);
+ }
+
+ [Fact]
+ public void Resolve_WhenStorageRootIsResolved_HelperSubpathsStayUnderStorageRoot()
+ {
+ var resolver = new ReplyTtsStorageRootResolver(
+ CreateMonitor(new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = @"D:\tts-root"
+ }),
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true)
+ ]));
+
+ var result = resolver.Resolve();
+
+ Assert.True(result.IsAvailable);
+ Assert.All(
+ new[]
+ {
+ result.ModelsRoot,
+ result.CacheRoot,
+ result.TempRoot,
+ result.LogsRoot,
+ result.VenvRoot
+ },
+ path => Assert.StartsWith(result.StorageRoot!, path, StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Resolve_WhenOptionsChangeAfterConstruction_UsesUpdatedValues()
+ {
+ var optionsMonitor = CreateMonitor(new FeishuReplyTtsOptions());
+ var resolver = new ReplyTtsStorageRootResolver(
+ optionsMonitor,
+ new FakeReplyTtsHostEnvironment(
+ isWindows: true,
+ systemDriveRoot: @"C:\",
+ drives:
+ [
+ new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true),
+ new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true)
+ ]));
+
+ var initialResult = resolver.Resolve();
+ optionsMonitor.Set(new FeishuReplyTtsOptions
+ {
+ TtsStorageRoot = @"E:\override-root"
+ });
+ var updatedResult = resolver.Resolve();
+
+ Assert.Equal(@"D:\WebCodeData\Kokoro", initialResult.StorageRoot);
+ Assert.Equal(@"E:\override-root", updatedResult.StorageRoot);
+ }
+
+ [Fact]
+ public void ProbeTargetDirectory_UsesDisposableSandboxInsteadOfRealInstallTree()
+ {
+ var systemEnvironmentType = typeof(ReplyTtsStorageRootResolver)
+ .GetNestedType("SystemReplyTtsHostEnvironment", BindingFlags.NonPublic);
+
+ Assert.NotNull(systemEnvironmentType);
+
+ var method = systemEnvironmentType!
+ .GetMethod("BuildProbeTargetDirectory", BindingFlags.NonPublic | BindingFlags.Static);
+
+ Assert.NotNull(method);
+
+ var probeTargetDirectory = (string)method!.Invoke(null, [@"D:\", "probe-token"])!;
+
+ Assert.Equal(
+ @"D:\.webcode-feishu-reply-tts-probe-probe-token\webcode\kokoro",
+ probeTargetDirectory);
+ Assert.False(
+ probeTargetDirectory.StartsWith(@"D:\webcode\kokoro", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static MutableOptionsMonitor CreateMonitor(FeishuReplyTtsOptions options)
+ {
+ return new MutableOptionsMonitor(options);
+ }
+
+ private sealed class FakeReplyTtsHostEnvironment : IReplyTtsHostEnvironment
+ {
+ private readonly IReadOnlyList _drives;
+ private readonly HashSet _existingDirectories;
+ private readonly HashSet _existingFiles;
+
+ public FakeReplyTtsHostEnvironment(
+ bool isWindows,
+ string? systemDriveRoot,
+ IReadOnlyList drives,
+ IReadOnlyList? existingDirectories = null,
+ IReadOnlyList? existingFiles = null)
+ {
+ IsWindows = isWindows;
+ SystemDriveRoot = systemDriveRoot;
+ _drives = drives;
+ _existingDirectories = new HashSet(
+ (existingDirectories ?? []).Select(path => path.TrimEnd('\\', '/')),
+ StringComparer.OrdinalIgnoreCase);
+ _existingFiles = new HashSet(
+ (existingFiles ?? []).Select(path => path.TrimEnd('\\', '/')),
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ public bool IsWindows { get; }
+
+ public string? SystemDriveRoot { get; }
+
+ public IReadOnlyList GetFixedDrives()
+ {
+ return _drives;
+ }
+
+ public bool DirectoryExists(string path)
+ {
+ return _existingDirectories.Contains(path.TrimEnd('\\', '/'));
+ }
+
+ public bool FileExists(string path)
+ {
+ return _existingFiles.Contains(path.TrimEnd('\\', '/'));
+ }
+ }
+
+ private sealed class MutableOptionsMonitor : IOptionsMonitor
+ {
+ private TOptions _currentValue;
+
+ public MutableOptionsMonitor(TOptions currentValue)
+ {
+ _currentValue = currentValue;
+ }
+
+ public TOptions CurrentValue => _currentValue;
+
+ public TOptions Get(string? name) => _currentValue;
+
+ public IDisposable? OnChange(Action listener)
+ {
+ return null;
+ }
+
+ public void Set(TOptions options)
+ {
+ _currentValue = options;
+ }
+ }
+}
diff --git a/WebCodeCli.Domain.Tests/SherpaKokoroTtsClientTests.cs b/WebCodeCli.Domain.Tests/SherpaKokoroTtsClientTests.cs
new file mode 100644
index 0000000..1dca11d
--- /dev/null
+++ b/WebCodeCli.Domain.Tests/SherpaKokoroTtsClientTests.cs
@@ -0,0 +1,144 @@
+using System.Net;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using WebCodeCli.Domain.Common.Options;
+using WebCodeCli.Domain.Domain.Service.Channels;
+
+namespace WebCodeCli.Domain.Tests;
+
+public sealed class SherpaKokoroTtsClientTests
+{
+ [Fact]
+ public async Task GetHealthAsync_ParsesLocalServiceResponse()
+ {
+ var handler = new StubHttpMessageHandler(
+ [
+ CreateJsonResponse("""{"status":"ok","device":"cpu","defaultVoiceId":"zh_female_1"}""")
+ ]);
+
+ var client = CreateClient(handler);
+
+ var result = await client.GetHealthAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsAvailable);
+ Assert.Equal("ok", result.ServiceStatus);
+ Assert.Equal("cpu", result.Device);
+ Assert.Equal("zh_female_1", result.DefaultVoiceId);
+ Assert.Equal("/health", Assert.Single(handler.RequestPaths));
+ }
+
+ [Fact]
+ public async Task GetVoicesAsync_ParsesVoiceList()
+ {
+ var handler = new StubHttpMessageHandler(
+ [
+ CreateJsonResponse(
+ """
+ {
+ "voices": [
+ {
+ "voiceId": "zh_female_1",
+ "displayName": "Kokoro Chinese Female",
+ "language": "zh",
+ "gender": "female"
+ },
+ {
+ "voiceId": "en_male_1",
+ "displayName": "Kokoro English Male",
+ "language": "en",
+ "gender": "male"
+ }
+ ]
+ }
+ """)
+ ]);
+
+ var client = CreateClient(handler);
+
+ var result = await client.GetVoicesAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, result.Count);
+ Assert.Collection(
+ result,
+ voice =>
+ {
+ Assert.Equal("zh_female_1", voice.VoiceId);
+ Assert.Equal("Kokoro Chinese Female", voice.DisplayName);
+ Assert.Equal("zh", voice.Language);
+ Assert.Equal("female", voice.Gender);
+ },
+ voice =>
+ {
+ Assert.Equal("en_male_1", voice.VoiceId);
+ Assert.Equal("Kokoro English Male", voice.DisplayName);
+ Assert.Equal("en", voice.Language);
+ Assert.Equal("male", voice.Gender);
+ });
+ Assert.Equal("/voices", Assert.Single(handler.RequestPaths));
+ }
+
+ [Fact]
+ public async Task GetHealthAsync_UsesDedicatedHttpClientName()
+ {
+ var handler = new StubHttpMessageHandler(
+ [
+ CreateJsonResponse("""{"status":"ok"}""")
+ ]);
+ var factory = new StubHttpClientFactory(new HttpClient(handler));
+ var client = CreateClient(handler, factory);
+
+ await client.GetHealthAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal("SherpaKokoroTtsClient", factory.CreatedClientName);
+ }
+
+ private static SherpaKokoroTtsClient CreateClient(StubHttpMessageHandler handler, StubHttpClientFactory? factory = null)
+ {
+ return new SherpaKokoroTtsClient(
+ Options.Create(new FeishuReplyTtsOptions
+ {
+ TtsServiceBaseUrl = "http://127.0.0.1:5058",
+ TtsServiceTimeoutSeconds = 15
+ }),
+ NullLogger.Instance,
+ factory ?? new StubHttpClientFactory(new HttpClient(handler)));
+ }
+
+ private static HttpResponseMessage CreateJsonResponse(string json, HttpStatusCode statusCode = HttpStatusCode.OK)
+ {
+ return new HttpResponseMessage(statusCode)
+ {
+ Content = new StringContent(json)
+ };
+ }
+
+ private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory
+ {
+ public string? CreatedClientName { get; private set; }
+
+ public HttpClient CreateClient(string name)
+ {
+ CreatedClientName = name;
+ return client;
+ }
+ }
+
+ private sealed class StubHttpMessageHandler(IEnumerable