diff --git a/.gitignore b/.gitignore index 1631a77..f675248 100644 --- a/.gitignore +++ b/.gitignore @@ -381,3 +381,4 @@ webcodecli-workspaces .omx/ .playwright-cli/ .workbuddy/ +/.webcode/ diff --git a/Directory.Build.props b/Directory.Build.props index b2460d8..ac35a6b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 0.2.10 + 0.2.14 10.0.1 diff --git a/README.md b/README.md index b427de6..27bae48 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,23 @@ 简体中文 | English

+--- + +## Feishu Reply Documents + +WebCode has removed reply TTS for Feishu responses. + +Feishu reply delivery now works through cloud documents: + +- Full reply documents keep the complete AI final reply content. +- Conclusion reply documents keep only the conclusion-focused final content. +- The generated cloud document link gives the chat a durable record of the AI reply. +- Users can listen to the AI reply result with Feishu document audio instead of WebCode-managed TTS playback. +- On mobile Feishu, open the document and use the `...` menu to start document audio. +- Referenced local Markdown files mentioned in a completed reply can also be imported as Feishu online documents in the same session document folder. +- When the same local Markdown file changes later, WebCode updates the existing Feishu online document in place so the shared document URL stays stable. +- Windows installer publishing reads the release version from `Directory.Build.props`; bump the patch version before publishing a new installer so the `vX.Y.Z` tag matches the current commit. +

把 AI CLI、Web 会话、移动端和飞书工作流接到同一个控制面板里

@@ -110,7 +127,7 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它 - 支持图片、文件消息的“待提交附件”卡片流程,先暂存到工作区,再补充说明后提交给 CLI - 支持文本框粘贴图片形成的富文本 `post` 消息,直接把内嵌图片和文字一并提交给 CLI - 支持会话级 Provider 同步,确保飞书侧也遵循 `cc-switch` 当前激活状态 -- 支持 Reply TTS 相关能力,用于把回复内容进一步转换为语音或语音服务调用链 +- 支持飞书完整回复文档与结论回复文档能力,可在回复完成后自动生成云文档并回发链接 - 支持帮助卡片、快捷入口卡片、会话管理卡片等多种交互载体 适合的飞书使用场景包括: @@ -187,7 +204,6 @@ 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/` - 发布包页面:`https://github.com/lusile2024/WebCode/releases` ## `cc-switch` 托管模型 @@ -368,11 +384,10 @@ dotnet run --project WebCodeCli powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1 ``` -如果你要生成“包含 Reply TTS / Kokoro 能力”的本地 Windows 安装包,应该优先走这条脚本,而不是单独 `dotnet publish`。原因是安装包脚本除了发布主程序外,还会额外处理这些内容: +如果你要生成本地 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/` 下生成: @@ -382,7 +397,6 @@ 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 遇到长路径问题,可以显式指定一个较短的输出目录,例如: @@ -390,7 +404,7 @@ powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1 powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1 -OutputRoot D:\wci ``` -这种方式同样会生成完整的安装版、便携版和 TTS 资源目录,适合本地快速出包。 +这种方式同样会生成完整的安装版、便携版以及校验与说明文件,适合本地快速出包。 构建机要求: diff --git a/README_EN.md b/README_EN.md index ef3e54c..15a7d7e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -94,7 +94,6 @@ The repository already includes a Windows packaging script that builds: - portable archive `WebCode-vX.Y.Z-win-x64-portable.zip` - checksum file `SHA256SUMS.txt` - release notes `RELEASE_NOTES.md` -- bundled Reply TTS runtime assets under `tts-bundle/` - release package page: `https://github.com/lusile2024/WebCode/releases` ## `cc-switch` Managed Assistant Model diff --git a/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs b/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs deleted file mode 100644 index 7805db9..0000000 --- a/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs +++ /dev/null @@ -1,235 +0,0 @@ -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/CliExecutionRequestAdapterTests.cs b/WebCodeCli.Domain.Tests/CliExecutionRequestAdapterTests.cs index 36d37eb..06a8eb4 100644 --- a/WebCodeCli.Domain.Tests/CliExecutionRequestAdapterTests.cs +++ b/WebCodeCli.Domain.Tests/CliExecutionRequestAdapterTests.cs @@ -75,6 +75,46 @@ public void CodexAdapter_BuildArguments_RequestOverload_EncodesNativeImagesAndRe Assert.Contains("Review the staged files.", arguments, StringComparison.Ordinal); } + [Fact] + public void CodexAdapter_BuildArguments_WithNativeAttachmentAndDashPrefixedPrompt_PlacesAttachmentsBeforeArgumentTerminator() + { + var adapter = new CodexAdapter(); + var tool = new CliToolConfig + { + Id = "codex", + Name = "Codex", + Command = "codex", + Enabled = true + }; + var request = new CliExecutionRequest + { + SessionId = "session-123", + ToolId = "codex", + PromptText = "- Docs/superpowers/plans/test.md", + SessionContext = new CliSessionContext + { + SessionId = "session-123", + WorkingDirectory = Path.GetTempPath() + }, + NativeAttachments = + [ + new CliExecutionAttachment + { + DisplayName = "diagram.png", + Kind = MessageAttachmentKind.Image, + AbsolutePath = @"D:\attachments\diagram.png", + WorkspaceRelativePath = ".webcode/message-inputs/submission-1/diagram.png" + } + ] + }; + + var arguments = adapter.BuildArguments(tool, request); + + Assert.Equal( + "exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json -i \"D:\\attachments\\diagram.png\" -- \"- Docs/superpowers/plans/test.md\"", + arguments); + } + [Fact] public void ClaudeCodeAdapter_BuildArguments_RequestOverload_IncludesReferenceAttachmentPreamble() { diff --git a/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs b/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs index 4a34064..67b37d9 100644 --- a/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs +++ b/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs @@ -5,6 +5,7 @@ using SqlSugar; using System.Diagnostics; using System.Reflection; +using System.Text; using WebCodeCli.Domain.Common.Options; using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Domain.Service; @@ -326,6 +327,11 @@ public async Task SyncCodexThreadProviderAsync_WhenCcSwitchProviderRecordIdDiffe }); var serviceProvider = new NullServiceProvider(repository, new StubSessionOutputService(), threadSyncService); + var stubManager = new StubCodexAppServerSessionManager(); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("ship this task", "complete", null, 12, 6)); + var service = new CliExecutorService( NullLogger.Instance, Options.Create(new CliToolsOption @@ -382,6 +388,7 @@ public void CodexAdapter_BuildLowInterruptionArguments_UsesResumeFullAutoWithout [Fact] public async Task ExecuteStreamAsync_WhenGoalUsesOneTimeProcess_ExecutesWithoutPersistentProcessGate() { + const string sessionId = "session-goal-one-time"; var tool = new CliToolConfig { Id = "codex", @@ -392,6 +399,11 @@ public async Task ExecuteStreamAsync_WhenGoalUsesOneTimeProcess_ExecutesWithoutP Enabled = true }; + var stubManager = new StubCodexAppServerSessionManager(); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("ship this task", "complete", null, 1, 1)); + var service = new CliExecutorService( NullLogger.Instance, Options.Create(new CliToolsOption @@ -404,10 +416,10 @@ public async Task ExecuteStreamAsync_WhenGoalUsesOneTimeProcess_ExecutesWithoutP new StubChatSessionService(), new StubCliAdapterFactory(), new StubCcSwitchService(includeBuiltInManagedTools: false), - codexAppServerSessionManager: new StubCodexAppServerSessionManager()); + codexAppServerSessionManager: stubManager); var chunks = new List(); - await foreach (var chunk in service.ExecuteStreamAsync("session-goal-one-time", tool.Id, "/goal ship this task")) + await foreach (var chunk in service.ExecuteStreamAsync(sessionId, tool.Id, "/goal ship this task")) { chunks.Add(chunk); } @@ -457,6 +469,11 @@ public async Task ExecuteStreamAsync_WhenGoalUsesSessionOverride_PersistsGoalRun TimeoutSeconds = 5 }; + var stubManager = new StubCodexAppServerSessionManager(); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("ship this task", "complete", null, 12, 6)); + var service = new CliExecutorService( NullLogger.Instance, Options.Create(new CliToolsOption @@ -483,7 +500,7 @@ public async Task ExecuteStreamAsync_WhenGoalUsesSessionOverride_PersistsGoalRun StatusMessage = "Codex 已由 cc-switch 管理并可直接启动。" } }), - codexAppServerSessionManager: new StubCodexAppServerSessionManager()); + codexAppServerSessionManager: stubManager); var chunks = new List(); await foreach (var chunk in service.ExecuteStreamAsync(sessionId, tool.Id, "/goal ship this task")) @@ -568,6 +585,9 @@ public async Task ExecuteStreamAsync_WhenGoalRuntimeReentersWithExistingCodexThr var threadSyncService = new RecordingCodexThreadProviderSyncService(); var stubManager = new StubCodexAppServerSessionManager(); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("ship this task", "complete", null, 12, 6)); var service = new CliExecutorService( NullLogger.Instance, Options.Create(new CliToolsOption @@ -619,6 +639,308 @@ public async Task ExecuteStreamAsync_WhenGoalRuntimeReentersWithExistingCodexThr } } + [Fact] + public async Task ExecuteStreamAsync_WhenGoalRuntimeControlCommandReusesLiveAppServerSession_SkipsProviderSync() + { + const string sessionId = "session-goal-control-live-session"; + const string cliThreadId = "thread-goal-control-live-session"; + var tempRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")); + var workspacePath = Path.Combine(tempRoot, "workspace"); + var liveConfigDirectory = Path.Combine(tempRoot, "live"); + var liveConfigPath = Path.Combine(liveConfigDirectory, "config.toml"); + var overrideJson = SessionLaunchOverrideHelper.Serialize(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new() { UseGoalRuntime = true } + }); + + Directory.CreateDirectory(workspacePath); + Directory.CreateDirectory(liveConfigDirectory); + await File.WriteAllTextAsync(liveConfigPath, "provider = \"provider-live\"\n"); + + try + { + var repository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = workspacePath, + CliThreadId = cliThreadId, + ToolLaunchOverridesJson = overrideJson, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + } + ]); + + var tool = new CliToolConfig + { + Id = "codex", + Name = "Codex", + Command = "powershell.exe", + UsePersistentProcess = false, + Enabled = true + }; + + var threadSyncService = new RecordingCodexThreadProviderSyncService(); + var stubManager = new StubCodexAppServerSessionManager(); + stubManager.SeedRunningSession(sessionId, cliThreadId); + stubManager.SeedGoal(sessionId, new AppServerGoalSnapshot("ship this task", "active", 200, 12, 34)); + stubManager.SeedActiveTurn(sessionId, "turn-live-1"); + + var service = new CliExecutorService( + NullLogger.Instance, + Options.Create(new CliToolsOption + { + TempWorkspaceRoot = tempRoot, + Tools = [tool] + }), + NullLogger.Instance, + new NullServiceProvider(repository, new StubSessionOutputService(), threadSyncService), + new StubChatSessionService(), + new StubCliAdapterFactory(), + new StubCcSwitchService(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new() + { + ToolId = "codex", + ToolName = "Codex", + IsManaged = true, + IsLaunchReady = true, + ActiveProviderId = "provider-live", + ActiveProviderName = "Provider Live", + ActiveProviderCategory = "custom", + LiveConfigPath = liveConfigPath, + StatusMessage = "Codex ok" + } + }), + codexAppServerSessionManager: stubManager); + + var chunks = new List(); + await foreach (var chunk in service.ExecuteStreamAsync(sessionId, tool.Id, "/goal pause")) + { + chunks.Add(chunk); + } + + Assert.DoesNotContain(chunks, c => c.IsError && c.IsCompleted); + Assert.Empty(threadSyncService.Requests); + Assert.Equal(1, stubManager.LegacyInterruptCalls); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task TryGetGoalRuntimeGoalAsync_WhenStoredThreadDriftedToSubagent_PrefersLiveAppServerThread() + { + const string sessionId = "session-goal-live-thread-preferred"; + const string parentThreadId = "thread-goal-parent"; + const string subagentThreadId = "thread-goal-subagent"; + var tempRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")); + var workspacePath = Path.Combine(tempRoot, "workspace"); + var codexConfigDirectory = Path.Combine(workspacePath, ".codex"); + var sessionSnapshotRelativePath = Path.Combine(".codex", "config.toml"); + var sessionSnapshotPath = Path.Combine(workspacePath, sessionSnapshotRelativePath); + var liveConfigDirectory = Path.Combine(tempRoot, "live"); + var liveConfigPath = Path.Combine(liveConfigDirectory, "config.toml"); + var overrideJson = SessionLaunchOverrideHelper.Serialize(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new() { UseGoalRuntime = true } + }); + + Directory.CreateDirectory(workspacePath); + Directory.CreateDirectory(codexConfigDirectory); + Directory.CreateDirectory(liveConfigDirectory); + await File.WriteAllTextAsync(liveConfigPath, "provider = \"provider-live\"\n"); + await File.WriteAllTextAsync(sessionSnapshotPath, "model_provider = \"provider-live\"\nprovider = \"provider-live\"\n"); + + try + { + var repository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = workspacePath, + CliThreadId = subagentThreadId, + UsesCcSwitchSnapshot = true, + CcSwitchSnapshotToolId = "codex", + CcSwitchSnapshotRelativePath = sessionSnapshotRelativePath, + CcSwitchProviderId = "provider-live", + ToolLaunchOverridesJson = overrideJson, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + } + ]); + + var tool = new CliToolConfig + { + Id = "codex", + Name = "Codex", + Command = "powershell.exe", + UsePersistentProcess = false, + Enabled = true + }; + + var stubManager = new StubCodexAppServerSessionManager(); + stubManager.SeedRunningSession(sessionId, parentThreadId); + stubManager.SeedGoal(sessionId, new AppServerGoalSnapshot("keep shipping", "active", 100, 12, 34)); + + var service = new CliExecutorService( + NullLogger.Instance, + Options.Create(new CliToolsOption + { + TempWorkspaceRoot = tempRoot, + Tools = [tool] + }), + NullLogger.Instance, + new NullServiceProvider(repository, new StubSessionOutputService()), + new StubChatSessionService(), + new StubCliAdapterFactory(), + new StubCcSwitchService(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new() + { + ToolId = "codex", + ToolName = "Codex", + IsManaged = true, + IsLaunchReady = true, + ActiveProviderId = "provider-live", + ActiveProviderName = "Provider Live", + ActiveProviderCategory = "custom", + LiveConfigPath = liveConfigPath, + StatusMessage = "Codex ok" + } + }), + codexAppServerSessionManager: stubManager); + + var goal = await service.TryGetGoalRuntimeGoalAsync(sessionId, "codex"); + + Assert.NotNull(goal); + Assert.Equal("keep shipping", goal!.Objective); + Assert.Equal("active", goal.Status); + Assert.Equal(parentThreadId, service.GetCliThreadId(sessionId)); + Assert.Equal(parentThreadId, repository.GetById(sessionId).CliThreadId); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task ExecuteStreamAsync_WhenGoalRemainsActive_AutoContinuesNextTurnBeforeCompletingOuterStream() + { + const string sessionId = "session-goal-auto-continue"; + var tempRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")); + var workspacePath = Path.Combine(tempRoot, "workspace"); + var liveConfigDirectory = Path.Combine(tempRoot, "live"); + var liveConfigPath = Path.Combine(liveConfigDirectory, "config.toml"); + Directory.CreateDirectory(workspacePath); + Directory.CreateDirectory(liveConfigDirectory); + await File.WriteAllTextAsync(liveConfigPath, "model = \"gpt-5.4\"\nprovider = \"provider-a\"\n"); + + try + { + var repository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = workspacePath, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + } + ]); + + var tool = new CliToolConfig + { + Id = "codex", + Name = "Codex", + Command = "powershell.exe", + UsePersistentProcess = false, + Enabled = true + }; + + var stubManager = new StubCodexAppServerSessionManager(); + stubManager.QueueTurnOutputs( + sessionId, + [new StreamOutputChunk { Content = "round-1" }], + [new StreamOutputChunk { Content = "round-2" }]); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("ship this task", "active", null, 10, 5), + new AppServerGoalSnapshot("ship this task", "complete", null, 20, 10)); + + var service = new CliExecutorService( + NullLogger.Instance, + Options.Create(new CliToolsOption + { + TempWorkspaceRoot = tempRoot, + Tools = [tool] + }), + NullLogger.Instance, + new NullServiceProvider(repository, new StubSessionOutputService()), + new StubChatSessionService(), + new StubCliAdapterFactory(), + new StubCcSwitchService(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new() + { + ToolId = "codex", + ToolName = "Codex", + IsManaged = true, + IsLaunchReady = true, + ActiveProviderId = "provider-a", + ActiveProviderName = "Provider A", + ActiveProviderCategory = "custom", + LiveConfigPath = liveConfigPath, + StatusMessage = "Codex ok" + } + }), + codexAppServerSessionManager: stubManager); + + var chunks = new List(); + await foreach (var chunk in service.ExecuteStreamAsync(sessionId, tool.Id, "/goal ship this task")) + { + chunks.Add(chunk); + } + + var firstCompletedIndex = chunks.FindIndex(static chunk => chunk.IsCompleted); + var roundTwoIndex = chunks.FindIndex(chunk => string.Equals(chunk.Content, "round-2", StringComparison.Ordinal)); + + Assert.Equal(2, stubManager.StartTurnCalls); + Assert.Contains(chunks, chunk => string.Equals(chunk.Content, "round-1", StringComparison.Ordinal)); + Assert.Contains(chunks, chunk => string.Equals(chunk.Content, "round-2", StringComparison.Ordinal)); + Assert.True(roundTwoIndex >= 0); + Assert.True(firstCompletedIndex > roundTwoIndex); + Assert.Equal(1, chunks.Count(chunk => chunk.IsCompleted)); + Assert.Equal(1, chunks.Count(chunk => chunk.IsTurnBoundary)); + Assert.True(chunks.FindIndex(chunk => chunk.IsTurnBoundary) > chunks.FindIndex(chunk => string.Equals(chunk.Content, "round-1", StringComparison.Ordinal))); + Assert.True(chunks.FindIndex(chunk => chunk.IsTurnBoundary) < roundTwoIndex); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + [Fact] public async Task ExecuteStreamAsync_WhenGoalRuntimeReentersWithStaleSessionProviderId_PrefersSnapshotModelProvider() { @@ -666,6 +988,9 @@ public async Task ExecuteStreamAsync_WhenGoalRuntimeReentersWithStaleSessionProv var threadSyncService = new RecordingCodexThreadProviderSyncService(); var stubManager = new StubCodexAppServerSessionManager(); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("ship this task", "complete", null, 12, 6)); var service = new CliExecutorService( NullLogger.Instance, Options.Create(new CliToolsOption @@ -755,6 +1080,9 @@ public async Task ExecuteStreamAsync_WhenGoalRuntimeResumesWithLingeringActiveTu ThrowOnStartWithActiveTurn = true }; stubManager.SeedActiveTurn(sessionId, "turn-stale"); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("ship this task", "complete", null, 12, 6)); var service = new CliExecutorService( NullLogger.Instance, @@ -844,6 +1172,10 @@ public async Task ExecuteStreamAsync_WhenGoalRuntimeResumes_UsesGoalObjectiveFor var stubManager = new StubCodexAppServerSessionManager(); stubManager.SeedGoal(sessionId, new AppServerGoalSnapshot("ship this task", "paused", null, 12, 34)); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("ship this task", "paused", null, 12, 34), + new AppServerGoalSnapshot("ship this task", "complete", null, 20, 40)); var service = new CliExecutorService( NullLogger.Instance, @@ -943,6 +1275,11 @@ @echo off Enabled = true }; + var stubManager = new StubCodexAppServerSessionManager(); + stubManager.QueueGoalSnapshots( + sessionId, + new AppServerGoalSnapshot("keep working", "complete", null, 1, 1)); + var service = new CliExecutorService( NullLogger.Instance, Options.Create(new CliToolsOption @@ -969,7 +1306,7 @@ @echo off StatusMessage = "Codex 已由 cc-switch 管理并可直接启动。" } }), - codexAppServerSessionManager: new StubCodexAppServerSessionManager()); + codexAppServerSessionManager: stubManager); var chunks = new List(); await foreach (var chunk in service.ExecuteStreamAsync(sessionId, tool.Id, "/goal keep working")) @@ -1068,7 +1405,7 @@ @echo off Assert.Contains( chunks, c => !string.IsNullOrWhiteSpace(c.Content) - && c.Content.Contains($"exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {cliThreadId} \"/goal keep working\"", StringComparison.Ordinal)); + && c.Content.Contains($"exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {cliThreadId} -- \"/goal keep working\"", StringComparison.Ordinal)); Assert.Contains( chunks, c => !string.IsNullOrWhiteSpace(c.Content) && c.Content.Contains("\"turn.completed\"", StringComparison.Ordinal)); @@ -1209,7 +1546,39 @@ public void CodexAdapter_BuildArguments_WhenResuming_UsesExecResumeSyntax() var arguments = adapter.BuildArguments(tool, request); Assert.Equal( - "exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json thread-123 \"/goal resume\"", + "exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json thread-123 -- \"/goal resume\"", + arguments); + } + + [Fact] + public void CodexAdapter_BuildArguments_WhenPromptStartsWithDash_InsertsArgumentTerminator() + { + var adapter = new CodexAdapter(); + var tool = new CliToolConfig + { + Id = "codex", + Name = "Codex", + Command = "codex", + Enabled = true + }; + var context = new CliSessionContext + { + SessionId = "session-resume", + CliThreadId = "thread-123", + WorkingDirectory = Path.GetTempPath() + }; + var request = new CliExecutionRequest + { + SessionId = "session-resume", + ToolId = "codex", + PromptText = "- Docs/superpowers/plans/test.md", + SessionContext = context + }; + + var arguments = adapter.BuildArguments(tool, request); + + Assert.Equal( + "exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json thread-123 -- \"- Docs/superpowers/plans/test.md\"", arguments); } @@ -1521,6 +1890,60 @@ public async Task ExecuteLowInterruptionContinueStreamAsync_ForCodex_IgnoresProv StringComparison.Ordinal)); } + [Fact] + public async Task ExecuteStreamAsync_ForCodexOneTimeProcess_WritesPromptToStandardInput() + { + var tool = new CliToolConfig + { + Id = "codex-like-stdin", + Name = "Codex-like stdin", + Command = "powershell.exe", + Enabled = true + }; + var adapter = new RecordingCodexOneTimeInputAdapter(); + var service = new CliExecutorService( + NullLogger.Instance, + Options.Create(new CliToolsOption + { + TempWorkspaceRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")), + Tools = [tool] + }), + NullLogger.Instance, + new NullServiceProvider(), + new StubChatSessionService(), + new StubCliAdapterFactory(adapter), + new StubCcSwitchService(includeBuiltInManagedTools: false)); + + var request = new CliExecutionRequest + { + SessionId = "session-one-time-stdin", + ToolId = tool.Id, + PromptText = "keep prompt for image submission" + }; + + var chunks = new List(); + await foreach (var chunk in service.ExecuteStreamAsync(request)) + { + chunks.Add(chunk); + } + + Assert.Contains( + chunks, + chunk => string.Equals( + chunk.Content?.Trim(), + "keep prompt for image submission", + StringComparison.Ordinal)); + } + + [Fact] + public void GetCodexTransportEncoding_ReturnsUtf8WithoutBom() + { + Encoding encoding = CliExecutorService.GetCodexTransportEncoding(); + + Assert.Equal("utf-8", encoding.WebName); + Assert.Empty(encoding.GetPreamble()); + } + [Fact] public async Task ResetSessionRuntimeAsync_ClearsCachedAndPersistedThreadIdsWithoutRemovingWorkspace() { @@ -4057,6 +4480,20 @@ public void CodexAdapter_ParseOutputLine_WhenTurnFailed_SetsErrorMessage() Assert.Equal(upstreamError, failureEvent.ErrorMessage); } + [Fact] + public void CodexAdapter_ParseOutputLine_WhenAgentMessageHasPhase_PreservesAssistantPhase() + { + var adapter = new CodexAdapter(); + + var outputEvent = adapter.ParseOutputLine( + """{"type":"item.updated","item":{"type":"agent_message","text":"hello","phase":"final_answer"}}"""); + + var agentMessageEvent = Assert.IsType(outputEvent); + Assert.Equal("agent_message", agentMessageEvent.ItemType); + Assert.Equal("hello", adapter.ExtractAssistantMessage(agentMessageEvent)); + Assert.Equal("final_answer", agentMessageEvent.AssistantPhase); + } + [Fact] public void RewriteCodexLaunchToNode_WhenCommandIsCmdWrapper_RewritesToNodeJsEntry() { @@ -4601,6 +5038,40 @@ public string BuildLowInterruptionArguments(CliToolConfig tool, CliSessionContex public string GetEventBadgeLabel(CliOutputEvent outputEvent) => string.Empty; } + private sealed class RecordingCodexOneTimeInputAdapter : ICliToolAdapter + { + public string[] SupportedToolIds => ["codex"]; + + public bool SupportsStreamParsing => false; + + public bool CanHandle(CliToolConfig tool) + => string.Equals(tool.Id, "codex-like-stdin", StringComparison.OrdinalIgnoreCase); + + public CliAttachmentCapabilities GetAttachmentCapabilities(CliToolConfig tool) + => CliAttachmentCapabilities.ReferenceOnly(); + + public string BuildArguments(CliToolConfig tool, CliExecutionRequest request) + => "-NoProfile -Command \"$inputText = [Console]::In.ReadToEnd(); Write-Output $inputText\""; + + public string BuildArguments(CliToolConfig tool, string prompt, CliSessionContext context) + => throw new NotSupportedException(); + + public string BuildLowInterruptionArguments(CliToolConfig tool, CliSessionContext context) + => throw new NotSupportedException(); + + public CliOutputEvent? ParseOutputLine(string line) => null; + + public string? ExtractSessionId(CliOutputEvent outputEvent) => null; + + public string? ExtractAssistantMessage(CliOutputEvent outputEvent) => null; + + public string GetEventTitle(CliOutputEvent outputEvent) => outputEvent.Title ?? string.Empty; + + public string GetEventBadgeClass(CliOutputEvent outputEvent) => string.Empty; + + public string GetEventBadgeLabel(CliOutputEvent outputEvent) => string.Empty; + } + private sealed class RecordingExecutionRequestAdapter : ICliToolAdapter { public string[] SupportedToolIds => ["recording-request-tool"]; @@ -4729,6 +5200,10 @@ private sealed class StubCodexAppServerSessionManager : ICodexAppServerSessionMa private readonly Dictionary _threadIds = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _goals = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _activeTurnIds = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _serverSideLingeringTurnWithoutCachedActiveTurn = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _runningSessionIds = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary>> _queuedTurnOutputs = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _queuedGoalSnapshots = new(StringComparer.OrdinalIgnoreCase); private int _nextTurnId; public int SimpleInterruptCalls { get; private set; } @@ -4743,16 +5218,41 @@ public void SeedActiveTurn(string sessionId, string turnId) _activeTurnIds[sessionId] = turnId; } + public void SeedServerSideLingeringTurnWithoutCachedActiveTurn(string sessionId) + { + _serverSideLingeringTurnWithoutCachedActiveTurn.Add(sessionId); + } + + public void SeedRunningSession(string sessionId, string threadId) + { + _runningSessionIds.Add(sessionId); + _threadIds[sessionId] = threadId; + } + public void SeedGoal(string sessionId, AppServerGoalSnapshot goal) { _goals[sessionId] = goal; } + public void QueueTurnOutputs(string sessionId, params List[] turnOutputs) + { + _queuedTurnOutputs[sessionId] = new Queue>(turnOutputs); + } + + public void QueueGoalSnapshots(string sessionId, params AppServerGoalSnapshot[] goals) + { + _queuedGoalSnapshots[sessionId] = new Queue(goals); + } + public void Dispose() { _threadIds.Clear(); _goals.Clear(); _activeTurnIds.Clear(); + _serverSideLingeringTurnWithoutCachedActiveTurn.Clear(); + _runningSessionIds.Clear(); + _queuedTurnOutputs.Clear(); + _queuedGoalSnapshots.Clear(); } public Task EnsureThreadAsync( @@ -4779,6 +5279,8 @@ public Task EnsureThreadAsync( _threadIds[sessionId] = threadId; } + _runningSessionIds.Add(sessionId); + return Task.FromResult(threadId); } @@ -4796,7 +5298,9 @@ public async Task StartTurnAsync( StartTurnCalls++; StartedTurnPrompts.Add(userPrompt); - if (ThrowOnStartWithActiveTurn && _activeTurnIds.ContainsKey(sessionId)) + if (ThrowOnStartWithActiveTurn + && (_activeTurnIds.ContainsKey(sessionId) + || _serverSideLingeringTurnWithoutCachedActiveTurn.Contains(sessionId))) { throw new InvalidOperationException("当前 app-server 运行中已有一个 turn,无法再启动新的 turn。"); } @@ -4813,6 +5317,7 @@ public async Task StartTurnAsync( var turnId = $"turn-{Interlocked.Increment(ref _nextTurnId)}"; _activeTurnIds[sessionId] = turnId; + _runningSessionIds.Add(sessionId); return new AppServerTurnRun(threadId, turnId, EmitTurnOutputAsync(sessionId, userPrompt, cancellationToken)); } @@ -4828,6 +5333,12 @@ public async Task StartTurnAsync( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + if (_queuedGoalSnapshots.TryGetValue(sessionId, out var queuedGoals) && queuedGoals.Count > 0) + { + var queuedGoal = queuedGoals.Dequeue(); + _goals[sessionId] = queuedGoal; + } + _goals.TryGetValue(sessionId, out var goal); return Task.FromResult(goal); } @@ -4887,7 +5398,9 @@ public Task InterruptActiveTurnAsync( { cancellationToken.ThrowIfCancellationRequested(); LegacyInterruptCalls++; - return Task.FromResult(_activeTurnIds.Remove(sessionId)); + var removed = _activeTurnIds.Remove(sessionId); + removed = _serverSideLingeringTurnWithoutCachedActiveTurn.Remove(sessionId) || removed; + return Task.FromResult(removed); } public Task InterruptActiveTurnAsync( @@ -4896,17 +5409,35 @@ public Task InterruptActiveTurnAsync( { cancellationToken.ThrowIfCancellationRequested(); SimpleInterruptCalls++; - return Task.FromResult(_activeTurnIds.Remove(sessionId)); + var removed = _activeTurnIds.Remove(sessionId); + removed = _serverSideLingeringTurnWithoutCachedActiveTurn.Remove(sessionId) || removed; + return Task.FromResult(removed); } public bool HasActiveTurn(string sessionId) => _activeTurnIds.ContainsKey(sessionId); + public bool HasRunningSession(string sessionId, string? threadId = null) + => _runningSessionIds.Contains(sessionId) + && (string.IsNullOrWhiteSpace(threadId) + || (_threadIds.TryGetValue(sessionId, out var currentThreadId) + && string.Equals(currentThreadId, threadId, StringComparison.OrdinalIgnoreCase))); + + public string? GetRunningThreadId(string sessionId) + => _runningSessionIds.Contains(sessionId) + && _threadIds.TryGetValue(sessionId, out var currentThreadId) + ? currentThreadId + : null; + public bool CleanupSession(string sessionId) { var removed = _threadIds.Remove(sessionId); removed = _goals.Remove(sessionId) || removed; removed = _activeTurnIds.Remove(sessionId) || removed; + removed = _serverSideLingeringTurnWithoutCachedActiveTurn.Remove(sessionId) || removed; + removed = _runningSessionIds.Remove(sessionId) || removed; + removed = _queuedTurnOutputs.Remove(sessionId) || removed; + removed = _queuedGoalSnapshots.Remove(sessionId) || removed; return removed; } @@ -4916,8 +5447,18 @@ private async IAsyncEnumerable EmitTurnOutputAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + var emittedTerminalChunk = false; - if (userPrompt.Contains("keep working", StringComparison.OrdinalIgnoreCase)) + if (_queuedTurnOutputs.TryGetValue(sessionId, out var queuedTurnOutputs) && queuedTurnOutputs.Count > 0) + { + var scriptedTurnOutputs = queuedTurnOutputs.Dequeue(); + foreach (var chunk in scriptedTurnOutputs) + { + emittedTerminalChunk |= chunk.IsCompleted; + yield return chunk; + } + } + else if (userPrompt.Contains("keep working", StringComparison.OrdinalIgnoreCase)) { yield return new StreamOutputChunk { @@ -4937,10 +5478,13 @@ private async IAsyncEnumerable EmitTurnOutputAsync( } _activeTurnIds.Remove(sessionId); - yield return new StreamOutputChunk + if (!emittedTerminalChunk) { - IsCompleted = true - }; + yield return new StreamOutputChunk + { + IsCompleted = true + }; + } await Task.CompletedTask; } diff --git a/WebCodeCli.Domain.Tests/CodexAdapterTests.cs b/WebCodeCli.Domain.Tests/CodexAdapterTests.cs new file mode 100644 index 0000000..3129c72 --- /dev/null +++ b/WebCodeCli.Domain.Tests/CodexAdapterTests.cs @@ -0,0 +1,34 @@ +using WebCodeCli.Domain.Domain.Service.Adapters; + +namespace WebCodeCli.Domain.Tests; + +public sealed class CodexAdapterTests +{ + [Fact] + public void ParseOutputLine_WhenAgentMessageIncludesPhase_PreservesAssistantPhase() + { + var adapter = new CodexAdapter(); + + var outputEvent = adapter.ParseOutputLine( + """{"type":"item.updated","item":{"type":"agent_message","text":"hello","phase":"final_answer"}}"""); + + Assert.NotNull(outputEvent); + Assert.Equal("agent_message", outputEvent!.ItemType); + Assert.Equal("hello", adapter.ExtractAssistantMessage(outputEvent)); + Assert.Equal("final_answer", outputEvent.AssistantPhase); + } + + [Fact] + public void ParseOutputLine_WhenCompletedAgentMessageIncludesPhase_PreservesAssistantPhase() + { + var adapter = new CodexAdapter(); + + var outputEvent = adapter.ParseOutputLine( + """{"type":"item.completed","item":{"type":"agent_message","text":"done","phase":"final_answer"}}"""); + + Assert.NotNull(outputEvent); + Assert.Equal("agent_message", outputEvent!.ItemType); + Assert.Equal("done", adapter.ExtractAssistantMessage(outputEvent)); + Assert.Equal("final_answer", outputEvent.AssistantPhase); + } +} diff --git a/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs b/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs index a2b0090..aa535b0 100644 --- a/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs +++ b/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Domain.Service.Adapters; using WebCodeCli.Domain.Domain.Service; @@ -75,9 +76,72 @@ public void TryBuildCliOutputJsonl_NormalizesAgentMessageDeltaForCodexAdapter() Assert.NotNull(outputEvent); Assert.Equal("item.updated", outputEvent!.EventType); Assert.Equal("agent_message", outputEvent.ItemType); + Assert.Null(adapter.ExtractSessionId(outputEvent)); Assert.Equal("Hello from app-server", adapter.ExtractAssistantMessage(outputEvent)); } + [Fact] + public void TryBuildCliOutputJsonl_PreservesAgentMessagePhaseForCodexAdapter() + { + var method = GetPrivateStaticMethod("TryBuildCliOutputJsonl"); + Assert.NotNull(method); + + using var document = JsonDocument.Parse(""" + { + "threadId": "thread-123", + "turnId": "turn-456", + "itemId": "msg-789", + "delta": "done", + "phase": "final_answer" + } + """); + + var jsonl = method!.Invoke(null, new object[] { "item/agentMessage/delta", document.RootElement }) as string; + + Assert.False(string.IsNullOrWhiteSpace(jsonl)); + Assert.Contains(@"""phase"":""final_answer""", jsonl, StringComparison.Ordinal); + + var adapter = new CodexAdapter(); + var outputEvent = adapter.ParseOutputLine(jsonl!); + + Assert.NotNull(outputEvent); + Assert.Equal("final_answer", outputEvent!.AssistantPhase); + } + + [Fact] + public void TryBuildCliOutputJsonl_PreservesCompletedAgentMessagePhaseForCodexAdapter() + { + var method = GetPrivateStaticMethod("TryBuildCliOutputJsonl"); + Assert.NotNull(method); + + using var document = JsonDocument.Parse(""" + { + "threadId": "thread-123", + "turnId": "turn-456", + "item": { + "type": "agentMessage", + "id": "msg-789", + "text": "done", + "phase": "final_answer" + } + } + """); + + var jsonl = method!.Invoke(null, new object[] { "item/completed", document.RootElement }) as string; + + Assert.False(string.IsNullOrWhiteSpace(jsonl)); + Assert.Contains(@"""phase"":""final_answer""", jsonl, StringComparison.Ordinal); + + var adapter = new CodexAdapter(); + var outputEvent = adapter.ParseOutputLine(jsonl!); + + Assert.NotNull(outputEvent); + Assert.Equal("item.completed", outputEvent!.EventType); + Assert.Equal("agent_message", outputEvent.ItemType); + Assert.Equal("done", adapter.ExtractAssistantMessage(outputEvent)); + Assert.Equal("final_answer", outputEvent.AssistantPhase); + } + [Fact] public void TryBuildCliOutputJsonl_IgnoresVerboseCommandOutputDeltas() { @@ -129,11 +193,54 @@ public void TryBuildCliOutputJsonl_NormalizesCompactCommandCompletionForCodexAda Assert.NotNull(outputEvent); Assert.Equal("item.completed", outputEvent!.EventType); Assert.Equal("command_execution", outputEvent.ItemType); + Assert.Null(adapter.ExtractSessionId(outputEvent)); Assert.Equal("pwsh -Command Get-Date", outputEvent.CommandExecution?.Command); Assert.Null(outputEvent.CommandExecution?.Output); Assert.DoesNotContain("very large output", outputEvent.Content ?? string.Empty, StringComparison.Ordinal); } + [Fact] + public void TryBuildCliOutputJsonl_FileChangeWithObjectKind_DoesNotThrowAndOmitsInvalidKind() + { + var method = GetPrivateStaticMethod("TryBuildCliOutputJsonl"); + Assert.NotNull(method); + + using var document = JsonDocument.Parse(""" + { + "threadId": "thread-123", + "turnId": "turn-456", + "item": { + "type": "fileChange", + "id": "file-789", + "status": "completed", + "changes": [ + { + "path": "src/Example.cs", + "kind": { + "name": "update" + } + } + ] + } + } + """); + + var jsonl = method!.Invoke(null, new object[] { "item/completed", document.RootElement }) as string; + + Assert.False(string.IsNullOrWhiteSpace(jsonl)); + + using var payload = JsonDocument.Parse(jsonl!); + var item = payload.RootElement.GetProperty("item"); + Assert.Equal("file_change", item.GetProperty("type").GetString()); + Assert.Equal("completed", item.GetProperty("status").GetString()); + + var changes = item.GetProperty("changes"); + Assert.Equal(JsonValueKind.Array, changes.ValueKind); + var change = Assert.Single(changes.EnumerateArray().ToArray()); + Assert.Equal("src/Example.cs", change.GetProperty("path").GetString()); + Assert.False(change.TryGetProperty("kind", out _)); + } + [Fact] public void TryBuildCliOutputJsonl_ExtractsNestedErrorMessage() { @@ -180,7 +287,19 @@ public void IsTransientAppServerErrorMessage_ClassifiesExpectedMessages(string m [InlineData("turn/failed", "Reconnecting... 1/5", false)] public void ShouldSuppressTransientErrorNotification_OnlySuppressesTransientErrorMethod(string methodName, string message, bool expected) { - var method = GetPrivateStaticMethod("ShouldSuppressTransientErrorNotification"); + var managerType = typeof(CliExecutorService).Assembly.GetType( + "WebCodeCli.Domain.Domain.Service.CodexAppServerSessionManager", + throwOnError: true)!; + var method = managerType.GetMethod( + "ShouldSuppressTransientErrorNotification", + BindingFlags.Static | BindingFlags.NonPublic, + binder: null, + types: + [ + typeof(string), + typeof(string) + ], + modifiers: null); Assert.NotNull(method); var actual = Assert.IsType(method!.Invoke(null, new object?[] { methodName, message })); @@ -188,6 +307,80 @@ public void ShouldSuppressTransientErrorNotification_OnlySuppressesTransientErro Assert.Equal(expected, actual); } + [Theory] + [InlineData(true, "rate limit exceeded", true)] + [InlineData(false, "rate limit exceeded", false)] + [InlineData(false, "Reconnecting... 1/5", true)] + public void ShouldSuppressTransientErrorNotification_PrefersWillRetryFromProtocol( + bool willRetry, + string message, + bool expected) + { + var managerType = typeof(CliExecutorService).Assembly.GetType( + "WebCodeCli.Domain.Domain.Service.CodexAppServerSessionManager", + throwOnError: true)!; + var method = managerType.GetMethod( + "ShouldSuppressTransientErrorNotification", + BindingFlags.Static | BindingFlags.NonPublic, + binder: null, + types: + [ + typeof(string), + typeof(JsonElement), + typeof(string) + ], + modifiers: null); + + Assert.NotNull(method); + + using var document = JsonDocument.Parse($$""" + { + "error": { + "message": "{{message}}" + }, + "threadId": "thread-123", + "turnId": "turn-456", + "willRetry": {{willRetry.ToString().ToLowerInvariant()}} + } + """); + + var actual = Assert.IsType(method!.Invoke(null, new object?[] { "error", document.RootElement, message })); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData( + "a36077c9-0a43-4ad6-9bc1-2ec37b67f961", + "expected active turn id 019e440e-097c-7002-a7b8-1fa6ba765d39 but found a36077c9-0a43-4ad6-9bc1-2ec37b67f961", + "019e440e-097c-7002-a7b8-1fa6ba765d39")] + [InlineData( + "019e440e-097c-7002-a7b8-1fa6ba765d39", + "expected active turn id 019e440e-097c-7002-a7b8-1fa6ba765d39 but found a36077c9-0a43-4ad6-9bc1-2ec37b67f961", + "a36077c9-0a43-4ad6-9bc1-2ec37b67f961")] + [InlineData( + "turn-123", + "some other error", + null)] + public void TryResolveReplacementActiveTurnIdForInterruptMismatch_ReturnsAlternateTurnIdWhenMessageMatches( + string currentTurnId, + string errorMessage, + string? expectedReplacementTurnId) + { + var managerType = typeof(CliExecutorService).Assembly.GetType( + "WebCodeCli.Domain.Domain.Service.CodexAppServerSessionManager", + throwOnError: true)!; + var method = managerType.GetMethod( + "TryResolveReplacementActiveTurnIdForInterruptMismatch", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.NotNull(method); + + var actual = method!.Invoke(null, new object?[] { currentTurnId, errorMessage }) as string; + + Assert.Equal(expectedReplacementTurnId, actual); + } + [Fact] public async Task RunWithSessionCreationLockAsync_SerializesCallsPerSession() { @@ -247,6 +440,189 @@ Task RunLockedAsync() Assert.Equal(0, activeCount); } + [Fact] + public async Task GetGoalAsync_WhenOriginalRequestTokenIsCanceled_KeepsLiveSessionReadable() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var tempRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")); + var workspacePath = Path.Combine(tempRoot, "workspace"); + Directory.CreateDirectory(tempRoot); + Directory.CreateDirectory(workspacePath); + + try + { + var commandPath = await CreateFakeCodexAppServerShimAsync(tempRoot); + var managerType = typeof(CliExecutorService).Assembly.GetType( + "WebCodeCli.Domain.Domain.Service.CodexAppServerSessionManager", + throwOnError: true)!; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(typeof(ICodexAppServerSessionManager), managerType); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + using var manager = provider.GetRequiredService(); + + var tool = new CliToolConfig + { + Id = "codex", + Name = "Codex", + Command = commandPath, + Enabled = true + }; + var sessionContext = new CliSessionContext + { + SessionId = "session-goal-token-reuse", + WorkingDirectory = workspacePath + }; + + using var startupCts = new CancellationTokenSource(); + var threadId = await manager.EnsureThreadAsync( + sessionContext.SessionId, + commandPath, + tool, + workspacePath, + environmentVariables: null, + sessionContext, + existingThreadId: null, + startupCts.Token); + + Assert.Equal("thread-1", threadId); + + startupCts.Cancel(); + await Task.Delay(200); + + using var followUpCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var goal = await manager.GetGoalAsync( + sessionContext.SessionId, + commandPath, + tool, + workspacePath, + environmentVariables: null, + sessionContext, + threadId, + followUpCts.Token); + + Assert.NotNull(goal); + Assert.Equal("ship it", goal!.Objective); + Assert.Equal("active", goal.Status); + Assert.Equal(200, goal.TokenBudget); + Assert.Equal(12, goal.TokensUsed); + Assert.Equal(34, goal.TimeUsedSeconds); + } + finally + { + if (Directory.Exists(tempRoot)) + { + DeleteDirectoryWithRetry(tempRoot); + } + } + } + + [Fact] + public async Task StartTurnAsync_WhenInterruptSucceededWithoutCompletionNotification_AllowsImmediateRestart() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var tempRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")); + var workspacePath = Path.Combine(tempRoot, "workspace"); + Directory.CreateDirectory(tempRoot); + Directory.CreateDirectory(workspacePath); + + try + { + var commandPath = await CreateFakeCodexAppServerShimAsync( + tempRoot, + scriptMode: "interrupt-without-completion"); + var managerType = typeof(CliExecutorService).Assembly.GetType( + "WebCodeCli.Domain.Domain.Service.CodexAppServerSessionManager", + throwOnError: true)!; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(typeof(ICodexAppServerSessionManager), managerType); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + using var manager = provider.GetRequiredService(); + + var tool = new CliToolConfig + { + Id = "codex", + Name = "Codex", + Command = commandPath, + Enabled = true + }; + var sessionContext = new CliSessionContext + { + SessionId = "session-goal-immediate-restart", + WorkingDirectory = workspacePath + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var firstTurn = await manager.StartTurnAsync( + sessionContext.SessionId, + commandPath, + tool, + workspacePath, + environmentVariables: null, + sessionContext, + "ship it", + existingThreadId: null, + cts.Token); + + Assert.Equal("thread-1", firstTurn.ThreadId); + Assert.Equal("turn-1", firstTurn.TurnId); + + var interrupted = await manager.InterruptActiveTurnAsync( + sessionContext.SessionId, + commandPath, + tool, + workspacePath, + environmentVariables: null, + sessionContext, + firstTurn.ThreadId, + cts.Token); + + Assert.True(interrupted); + + var secondTurn = await manager.StartTurnAsync( + sessionContext.SessionId, + commandPath, + tool, + workspacePath, + environmentVariables: null, + sessionContext, + "keep working", + firstTurn.ThreadId, + cts.Token); + + Assert.Equal("thread-1", secondTurn.ThreadId); + Assert.Equal("turn-2", secondTurn.TurnId); + } + finally + { + if (Directory.Exists(tempRoot)) + { + DeleteDirectoryWithRetry(tempRoot); + } + } + } + private static MethodInfo? GetPrivateStaticMethod(string methodName) { var managerType = typeof(CliExecutorService).Assembly.GetType( @@ -274,4 +650,137 @@ private static void UpdateMax(ref int target, int value) } } } + + private static async Task CreateFakeCodexAppServerShimAsync(string tempRoot, string scriptMode = "default") + { + var scriptPath = Path.Combine(tempRoot, "fake-codex-app-server.ps1"); + var shimPath = Path.Combine(tempRoot, "fake-codex.cmd"); + + await File.WriteAllTextAsync( + scriptPath, + """ + param( + [string]$Mode = "default" + ) + + $threadId = "thread-1" + $turnCounter = 0 + $goal = @{ + objective = "ship it" + status = "active" + tokenBudget = 200 + tokensUsed = 12 + timeUsedSeconds = 34 + } + + while (($line = [Console]::In.ReadLine()) -ne $null) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + $request = $line | ConvertFrom-Json + if ($null -eq $request.PSObject.Properties["id"]) { + continue + } + + $id = $request.id + $method = [string]$request.method + + switch ($method) { + "initialize" { + $response = @{ + id = $id + result = @{ + serverInfo = @{ + name = "fake-codex" + version = "1.0.0" + } + } + } + } + "thread/start" { + $response = @{ + id = $id + result = @{ + thread = @{ + id = $threadId + } + } + } + } + "thread/resume" { + $response = @{ + id = $id + result = @{ + thread = @{ + id = [string]$request.params.threadId + } + } + } + } + "thread/goal/get" { + $response = @{ + id = $id + result = @{ + goal = $goal + } + } + } + "turn/start" { + $turnCounter += 1 + $response = @{ + id = $id + result = @{ + turn = @{ + id = "turn-$turnCounter" + } + } + } + } + "turn/interrupt" { + $response = @{ + id = $id + result = @{} + } + } + default { + $response = @{ + id = $id + result = @{} + } + } + } + + [Console]::Out.WriteLine(($response | ConvertTo-Json -Compress -Depth 10)) + [Console]::Out.Flush() + } + """); + await File.WriteAllTextAsync( + shimPath, + $"@echo off\r\npowershell.exe -NoProfile -ExecutionPolicy Bypass -File \"%~dp0fake-codex-app-server.ps1\" \"{scriptMode}\" %*\r\n"); + + return shimPath; + } + + private static void DeleteDirectoryWithRetry(string path) + { + const int maxAttempts = 5; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (attempt < maxAttempts) + { + Thread.Sleep(100 * attempt); + } + catch (UnauthorizedAccessException) when (attempt < maxAttempts) + { + Thread.Sleep(100 * attempt); + } + } + } } diff --git a/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs b/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs index f3e5b76..e1b74b9 100644 --- a/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs +++ b/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs @@ -238,6 +238,102 @@ public async Task GetRecentMessagesAsync_ForCodex_ReadsArchivedWorkspaceRolloutW }); } + [Fact] + public async Task GetCodexFinalAnswerTextAsync_WhenRolloutContainsCommentaryAndFinalAnswer_ReturnsLatestFinalOnly() + { + using var sandbox = new HistoryTestSandbox(); + const string threadId = "codex-thread-final-answer"; + var workspacePath = sandbox.CreateDirectory("workspace"); + + sandbox.WriteFile( + Path.Combine("workspace", ".codex", "sessions", "2026", "05", "27", $"rollout-2026-05-27T12-00-00-{threadId}.jsonl"), + """ + {"timestamp":"2026-05-27T12:00:00Z","type":"session_meta","payload":{"id":"codex-thread-final-answer","cwd":"D:\\workspace"}} + {"timestamp":"2026-05-27T12:00:01Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"thinking"}]}} + {"timestamp":"2026-05-27T12:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"first answer"}]}} + {"timestamp":"2026-05-27T12:00:03Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"latest answer"}]}} + """); + + var service = new TestExternalCliSessionHistoryService(); + + var finalAnswer = await service.GetCodexFinalAnswerTextAsync(threadId, workspacePath: workspacePath); + + Assert.Equal("latest answer", finalAnswer); + } + + [Fact] + public async Task GetCodexFinalAnswerTextAsync_WhenRolloutHasNoFinalAnswer_ReturnsNull() + { + using var sandbox = new HistoryTestSandbox(); + const string threadId = "codex-thread-no-final-answer"; + var workspacePath = sandbox.CreateDirectory("workspace"); + + sandbox.WriteFile( + Path.Combine("workspace", ".codex", "sessions", "2026", "05", "27", $"rollout-2026-05-27T12-30-00-{threadId}.jsonl"), + """ + {"timestamp":"2026-05-27T12:30:00Z","type":"session_meta","payload":{"id":"codex-thread-no-final-answer","cwd":"D:\\workspace"}} + {"timestamp":"2026-05-27T12:30:01Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"thinking"}]}} + {"timestamp":"2026-05-27T12:30:02Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"question"}]}} + """); + + var service = new TestExternalCliSessionHistoryService(); + + var finalAnswer = await service.GetCodexFinalAnswerTextAsync(threadId, workspacePath: workspacePath); + + Assert.Null(finalAnswer); + } + + [Fact] + public async Task GetCodexFinalAnswerTextAsync_IgnoresNonAssistantAndNonMessageItems() + { + using var sandbox = new HistoryTestSandbox(); + const string threadId = "codex-thread-ignore-non-assistant"; + var workspacePath = sandbox.CreateDirectory("workspace"); + + sandbox.WriteFile( + Path.Combine("workspace", ".codex", "sessions", "2026", "05", "27", $"rollout-2026-05-27T13-00-00-{threadId}.jsonl"), + """ + {"timestamp":"2026-05-27T13:00:00Z","type":"session_meta","payload":{"id":"codex-thread-ignore-non-assistant","cwd":"D:\\workspace"}} + {"timestamp":"2026-05-27T13:00:01Z","type":"response_item","payload":{"type":"tool_result","role":"assistant","content":[{"type":"output_text","text":"ignore tool result"}]}} + {"timestamp":"2026-05-27T13:00:02Z","type":"response_item","payload":{"type":"message","role":"user","phase":"final_answer","content":[{"type":"output_text","text":"ignore user"}]}} + {"timestamp":"2026-05-27T13:00:03Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"answer"}]}} + """); + + var service = new TestExternalCliSessionHistoryService(); + + var finalAnswer = await service.GetCodexFinalAnswerTextAsync(threadId, workspacePath: workspacePath); + + Assert.Equal("answer", finalAnswer); + } + + [Fact] + public async Task GetCodexFinalAnswerTextAsync_WhenFilenameMatchIsAmbiguous_PrefersPayloadIdExactMatch() + { + using var sandbox = new HistoryTestSandbox(); + const string threadId = "codex-thread-1"; + var workspacePath = sandbox.CreateDirectory("workspace"); + + sandbox.WriteFile( + Path.Combine("workspace", ".codex", "sessions", "2026", "05", "27", "rollout-2026-05-27T13-05-00-codex-thread-12.jsonl"), + """ + {"timestamp":"2026-05-27T13:05:00Z","type":"session_meta","payload":{"id":"codex-thread-12","cwd":"D:\\workspace"}} + {"timestamp":"2026-05-27T13:05:01Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"wrong answer"}]}} + """); + + sandbox.WriteFile( + Path.Combine("workspace", ".codex", "sessions", "2026", "05", "27", "rollout-2026-05-27T13-00-00-codex-thread-1.jsonl"), + """ + {"timestamp":"2026-05-27T13:00:00Z","type":"session_meta","payload":{"id":"codex-thread-1","cwd":"D:\\workspace"}} + {"timestamp":"2026-05-27T13:00:01Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"correct answer"}]}} + """); + + var service = new TestExternalCliSessionHistoryService(); + + var finalAnswer = await service.GetCodexFinalAnswerTextAsync(threadId, workspacePath: workspacePath); + + Assert.Equal("correct answer", finalAnswer); + } + [Fact] public async Task GetRecentMessagesAsync_ForClaudeCode_ReadsMessagesFromTranscriptFile() { diff --git a/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs deleted file mode 100644 index 979fd00..0000000 --- a/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -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(); - - public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync( - string messageId, - string fileKey, - string resourceType, - 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 4dd6a4f..93e65d1 100644 --- a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs @@ -39,7 +39,7 @@ await service.HandleCardActionAsync( } [Fact] - public async Task HandleCardActionAsync_ToggleReplyTts_DisablesReplyTtsAndRefreshesHelpCard() + public async Task HandleCardActionAsync_ToggleFullReplyDoc_DisablesFullReplyDocumentAndRefreshesHelpCard() { var cliExecutor = new RecordingCliExecutorService(); var feishuChannel = new StubFeishuChannelService(null) @@ -51,27 +51,151 @@ public async Task HandleCardActionAsync_ToggleReplyTts_DisablesReplyTtsAndRefres { Username = "luhaiyan", IsEnabled = true, - ReplyTtsEnabled = true, - ReplyTtsVoiceId = "voice-a" + FullReplyDocEnabled = true, + FinalReplyDocEnabled = false, + LegacyReplyTtsEnabled = true, + LegacyReplyTtsMode = ReplyTtsModes.FullReply, + LegacyReplyTtsVoiceId = "voice-a" }); var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService); var service = CreateService(cliExecutor, feishuChannel, serviceProvider); var response = await service.HandleCardActionAsync( - """{"action":"toggle_reply_tts"}""", + $$"""{"action":"{{FeishuHelpCardAction.ToggleFullReplyDocAction}}"}""", 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)); + Assert.False(savedConfig!.FullReplyDocEnabled); + Assert.False(savedConfig.FinalReplyDocEnabled); + Assert.False(savedConfig.LegacyReplyTtsEnabled); + Assert.Equal(ReplyTtsModes.Off, savedConfig.LegacyReplyTtsMode); + Assert.Null(savedConfig.LegacyReplyTtsVoiceId); + Assert.Equal("✅ 已关闭飞书完整回复文档", ExtractToastContent(response)); + Assert.Contains("完整回复文档:关", ExtractCardContentStrings(response)); + Assert.Contains("结论回复文档:关", ExtractCardContentStrings(response)); } [Fact] - public async Task HandleCardActionAsync_ToggleReplyTts_ReturnsErrorToast_WhenUserConfigMissing() + public async Task HandleCardActionAsync_ToggleFinalReplyDoc_EnablesFinalReplyDocumentWithoutDisablingFullReplyDocument() + { + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null) + { + SessionUsername = "luhaiyan" + }; + var feishuBotConfigService = new StubUserFeishuBotConfigService(); + feishuBotConfigService.Seed(new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + IsEnabled = true, + FullReplyDocEnabled = true, + FinalReplyDocEnabled = false, + LegacyReplyTtsEnabled = true, + LegacyReplyTtsMode = ReplyTtsModes.FullReply, + LegacyReplyTtsVoiceId = "voice-a" + }); + + var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ToggleFinalReplyDocAction}}"}""", + chatId: "oc_tts_toggle_chat"); + + var savedConfig = await feishuBotConfigService.GetByUsernameAsync("luhaiyan"); + Assert.NotNull(savedConfig); + Assert.True(savedConfig!.FullReplyDocEnabled); + Assert.True(savedConfig.FinalReplyDocEnabled); + Assert.True(savedConfig.LegacyReplyTtsEnabled); + Assert.Equal(ReplyTtsModes.FullReply, savedConfig.LegacyReplyTtsMode); + Assert.Null(savedConfig.LegacyReplyTtsVoiceId); + Assert.Equal("✅ 已开启飞书结论回复文档", ExtractToastContent(response)); + Assert.Contains("完整回复文档:开", ExtractCardContentStrings(response)); + Assert.Contains("结论回复文档:开", ExtractCardContentStrings(response)); + } + + [Fact] + public async Task HandleCardActionAsync_ToggleAudioFullReplyDoc_EnablesListeningFullReplyDocumentWithoutChangingRawReplyDocuments() + { + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null) + { + SessionUsername = "luhaiyan" + }; + var feishuBotConfigService = new StubUserFeishuBotConfigService(); + feishuBotConfigService.Seed(new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + IsEnabled = true, + FullReplyDocEnabled = true, + FinalReplyDocEnabled = false, + AudioFullReplyDocEnabled = false, + AudioFinalReplyDocEnabled = false, + LegacyReplyTtsEnabled = true, + LegacyReplyTtsMode = ReplyTtsModes.FullReply + }); + + var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ToggleAudioFullReplyDocAction}}"}""", + chatId: "oc_audio_doc_toggle_chat"); + + var savedConfig = await feishuBotConfigService.GetByUsernameAsync("luhaiyan"); + Assert.NotNull(savedConfig); + Assert.True(savedConfig!.FullReplyDocEnabled); + Assert.False(savedConfig.FinalReplyDocEnabled); + Assert.True(savedConfig.AudioFullReplyDocEnabled); + Assert.False(savedConfig.AudioFinalReplyDocEnabled); + Assert.Equal("✅ 已开启飞书听完整文档", ExtractToastContent(response)); + Assert.Contains("完整回复文档:开", ExtractCardContentStrings(response)); + Assert.Contains("听完整文档:开", ExtractCardContentStrings(response)); + Assert.Contains("听结论文档:关", ExtractCardContentStrings(response)); + } + + [Fact] + public async Task HandleCardActionAsync_ToggleFullReplyDoc_FromFinalReplyOnly_EnablesBothReplyDocuments() + { + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null) + { + SessionUsername = "luhaiyan" + }; + var feishuBotConfigService = new StubUserFeishuBotConfigService(); + feishuBotConfigService.Seed(new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + IsEnabled = true, + FullReplyDocEnabled = false, + FinalReplyDocEnabled = true, + LegacyReplyTtsEnabled = true, + LegacyReplyTtsMode = ReplyTtsModes.FinalOnly, + LegacyReplyTtsVoiceId = "voice-a" + }); + + var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ToggleFullReplyDocAction}}"}""", + chatId: "oc_tts_toggle_chat"); + + var savedConfig = await feishuBotConfigService.GetByUsernameAsync("luhaiyan"); + Assert.NotNull(savedConfig); + Assert.True(savedConfig!.FullReplyDocEnabled); + Assert.True(savedConfig.FinalReplyDocEnabled); + Assert.True(savedConfig.LegacyReplyTtsEnabled); + Assert.Equal(ReplyTtsModes.FullReply, savedConfig.LegacyReplyTtsMode); + Assert.Equal("✅ 已开启飞书完整回复文档", ExtractToastContent(response)); + Assert.Contains("完整回复文档:开", ExtractCardContentStrings(response)); + Assert.Contains("结论回复文档:开", ExtractCardContentStrings(response)); + } + + [Fact] + public async Task HandleCardActionAsync_ToggleFullReplyDoc_ReturnsErrorToast_WhenUserConfigMissing() { var cliExecutor = new RecordingCliExecutorService(); var feishuChannel = new StubFeishuChannelService(null) @@ -83,11 +207,73 @@ public async Task HandleCardActionAsync_ToggleReplyTts_ReturnsErrorToast_WhenUse var service = CreateService(cliExecutor, feishuChannel, serviceProvider); var response = await service.HandleCardActionAsync( - """{"action":"toggle_reply_tts"}""", + $$"""{"action":"{{FeishuHelpCardAction.ToggleFullReplyDocAction}}"}""", chatId: "oc_tts_toggle_chat"); Assert.Equal("❌ 未找到当前飞书用户配置", ExtractToastContent(response)); - Assert.DoesNotContain("语音回复:", SerializeResponse(response)); + Assert.DoesNotContain("回复文档:", SerializeResponse(response)); + } + + [Fact] + public async Task HandleCardActionAsync_SetDocumentAdminOpenId_SavesCurrentOperatorOpenIdByAppId() + { + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null) + { + SessionUsername = null + }; + var feishuBotConfigService = new StubUserFeishuBotConfigService(); + feishuBotConfigService.Seed(new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + IsEnabled = true, + AppId = "cli_reply_docs" + }); + + var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + """{"action":"set_document_admin_openid"}""", + chatId: "oc_reply_doc_admin_chat", + operatorUserId: "ou_reply_doc_admin", + appId: "cli_reply_docs"); + + var savedConfig = await feishuBotConfigService.GetByAppIdAsync("cli_reply_docs"); + Assert.NotNull(savedConfig); + Assert.Equal("ou_reply_doc_admin", GetStringProperty(savedConfig!, "DocumentAdminOpenId")); + Assert.Equal("✅ 已将当前操作者保存为文档管理员", ExtractToastContent(response)); + } + + [Fact] + public async Task HandleCardActionAsync_ToggleReferencedMarkdownDocImport_EnablesMarkdownImportAndRefreshesHelpCard() + { + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null) + { + SessionUsername = "luhaiyan" + }; + var feishuBotConfigService = new StubUserFeishuBotConfigService(); + var seededConfig = new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + IsEnabled = true + }; + SetBooleanProperty(seededConfig, "ReferencedMarkdownDocImportEnabled", false); + feishuBotConfigService.Seed(seededConfig); + + var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + """{"action":"toggle_referenced_markdown_doc_import"}""", + chatId: "oc_md_toggle_chat"); + + var savedConfig = await feishuBotConfigService.GetByUsernameAsync("luhaiyan"); + Assert.NotNull(savedConfig); + Assert.True(GetBooleanProperty(savedConfig!, "ReferencedMarkdownDocImportEnabled")); + Assert.Equal("✅ 已开启MD转在线文档", ExtractToastContent(response)); + Assert.Contains("MD转在线文档:开", ExtractCardContentStrings(response)); } [Fact] @@ -346,6 +532,79 @@ await service.HandleCardActionAsync( } } + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_WhenBackgroundReplacementHandleUsesCanceledToken_CompletesReplacementAndSendsNotification() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-card-complete-race"; + + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-action-complete-race-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + try + { + var cliExecutor = new RecordingCliExecutorService + { + Adapter = new CodexAdapter(), + SupportsStreamParsingEnabled = true, + StandardExecutionContent = "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", + StandardExecutionCompletionDelay = TimeSpan.FromMilliseconds(4200), + StandardExecutionCompletionContent = string.Empty + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var historyService = new StubExternalCliSessionHistoryService( + [ + new ExternalCliHistoryMessage + { + Role = "user", + Content = "帮我看下goal命令有执行吗?" + }, + new ExternalCliHistoryMessage + { + Role = "assistant", + Content = "执行了,而且还在执行中。" + } + ]); + + var cardKit = new BackgroundReplacementTokenAwareFeishuCardKitClient(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(externalCliSessionHistoryService: historyService), + cardKit); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + operatorUserId: "ou_test_user", + inputValues: "帮我看下goal命令有执行吗?"); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(6)); + var completionMessage = await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal( + "执行了,而且还在执行中。\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Contains("已停止", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("执行了,而且还在执行中。", cardKit.Handles[1].InitialContent); + Assert.Equal("执行了,而且还在执行中。", cardKit.Handles[1].FinalContent); + Assert.Contains("已完成", cardKit.Handles[1].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Contains("当前会话:superpowers", completionMessage.Content, StringComparison.Ordinal); + Assert.EndsWith("\n已完成", completionMessage.Content, StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + [Fact] public async Task HandleCardActionAsync_ExecuteCommand_KeepsAssistantTextSeparateFromLatestToolCallSummary() { @@ -560,10 +819,9 @@ public async Task HandleCardActionAsync_ExecuteCommand_WhenCardUpdateDisconnects }; cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); - var cardKit = new StubFeishuCardKitClient - { - FailUpdateOnAttempt = 2 - }; + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(2); + cardKit.FailUpdateAttemptSequence.Enqueue(1); var chatSessionService = new StubChatSessionService(); var feishuChannel = new StubFeishuChannelService(activeSessionId) { @@ -578,15 +836,17 @@ await service.HandleCardActionAsync( inputValues: "输出两段内容"); await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); - Assert.Equal(2, cardKit.UpdateAttemptCount); - Assert.Single(cardKit.StreamingUpdates); - Assert.Equal("第一段", cardKit.StreamingUpdates[0]); - Assert.NotNull(cardKit.FinalStreamingContent); - Assert.Contains("第一段", cardKit.FinalStreamingContent!, StringComparison.Ordinal); - Assert.Contains("**错误:飞书流式更新断连,已停止继续推送卡片。**", cardKit.FinalStreamingContent!, StringComparison.Ordinal); - Assert.Contains("执行出错", cardKit.FinalStreamingStatusMarkdown, StringComparison.Ordinal); - Assert.Null(feishuChannel.LastSentMessage); + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal(2, cardKit.Handles[0].UpdateAttemptCount); + Assert.Equal(0, cardKit.Handles[1].UpdateAttemptCount); + Assert.Single(cardKit.Handles[0].Updates); + Assert.Empty(cardKit.Handles[1].Updates); + Assert.Equal("第一段", cardKit.Handles[0].Updates[0]); + Assert.Equal("第一段\n第二段", cardKit.Handles[1].FinalContent); + Assert.False(string.IsNullOrWhiteSpace(cardKit.Handles[1].FinalStatusMarkdown)); + Assert.Equal("当前会话:superpowers -\n已完成", feishuChannel.LastSentMessage); Assert.Contains( chatSessionService.Messages[activeSessionId], message => message.Role == "assistant" && message.Content == "第一段\n第二段"); @@ -598,76 +858,393 @@ await service.HandleCardActionAsync( } [Fact] - public async Task HandleCardActionAsync_ExecuteCommand_CreatesTopChipGroupsDisabledUntilCompletion() + public async Task HandleCardActionAsync_ExecuteCommand_ReplacesBrokenStreamingCardOnceAndFinishesOnReplacement() { const string chatId = "oc_current_chat"; - const string activeSessionId = "session-streaming-top-chips"; + const string activeSessionId = "session-card-recovery"; - var cliExecutor = new RecordingCliExecutorService(); - cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-action-recovery-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); - var cardKit = new StubFeishuCardKitClient(); - var feishuChannel = new StubFeishuChannelService(activeSessionId) + try { - ResolvedToolId = "codex" - }; - var sessionRepository = new StubChatSessionRepository( - [ - new ChatSessionEntity + var cliExecutor = new RecordingCliExecutorService { - SessionId = activeSessionId, - Username = "luhaiyan", - Title = "Top Chips", - ToolId = "codex", - WorkspacePath = @"D:\repo\superpowers", - ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["codex"] = new SessionToolLaunchOverride - { - Model = "gpt-5.4", - ReasoningEffort = "high" - } - }), - FeishuChatKey = chatId, - IsWorkspaceValid = true, - IsFeishuActive = true, - CreatedAt = DateTime.Now.AddMinutes(-30), - UpdatedAt = DateTime.Now - } - ]); + StandardExecutionContent = "第一段\n", + StandardExecutionCompletionContent = "第二段\n" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); - var service = CreateService( - cliExecutor, - feishuChannel, - new TestServiceProvider(chatSessionRepository: sessionRepository), - cardKit); + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + var chatSessionService = new StubChatSessionService(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit, chatSessionService); - await service.HandleCardActionAsync( - """{"action":"execute_command"}""", - chatId: chatId, - inputValues: "缁х画"); + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + operatorUserId: "ou_test_user", + inputValues: "输出两段内容"); - await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); - await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); - var initialChrome = Assert.IsType(cardKit.InitialStreamingChromeSnapshot); - Assert.Contains(initialChrome.OverflowOptions, item => item.Text == "模型:gpt-5.4"); - Assert.Contains(initialChrome.OverflowOptions, item => item.Text == "模型:gpt-5.4-mini"); - Assert.Collection(initialChrome.TopChipGroups, - hintGroup => - { - Assert.Equal("switch_hint", hintGroup.Kind); - Assert.False(hintGroup.IsEnabled); - Assert.False(string.IsNullOrWhiteSpace(hintGroup.SummaryMarkdown)); - }, - reasoningGroup => - { - Assert.Equal("reasoning_effort", reasoningGroup.Kind); - Assert.False(reasoningGroup.IsEnabled); - Assert.All(reasoningGroup.Items, item => Assert.False(item.IsEnabled)); - Assert.Contains(reasoningGroup.Items, item => item.Text == "high" && item.IsActive); - Assert.Equal(["low", "medium", "high", "xhigh"], reasoningGroup.Items.Select(item => item.Text).ToArray()); + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal( + "第一段\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Contains("已停止", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("第一段", cardKit.Handles[1].InitialContent); + Assert.Contains("第一段\n第二段", cardKit.Handles[1].Updates); + Assert.Equal("第一段\n第二段", cardKit.Handles[1].FinalContent); + Assert.Contains("已完成", cardKit.Handles[1].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("当前会话:superpowers -\n已完成", feishuChannel.LastSentMessage); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_WhenFinalCardCompletionFails_ReplacesStreamingCardAndFinishesOnReplacement() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-card-finish-recovery"; + + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-action-finish-recovery-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + try + { + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "第一段\n", + StandardExecutionCompletionContent = "第二段\n" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailFinishAttemptSequence.Enqueue(1); + var chatSessionService = new StubChatSessionService(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit, chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + operatorUserId: "ou_test_user", + inputValues: "输出两段内容"); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal(1, cardKit.Handles[0].FinishAttemptCount); + Assert.Null(cardKit.Handles[0].FinalContent); + Assert.Equal("第一段\n第二段", cardKit.Handles[1].InitialContent); + Assert.Equal("第一段\n第二段", cardKit.Handles[1].FinalContent); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_WhenReplacementCardAlsoFails_AppendsDisconnectMessage() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-card-recovery-fallback"; + + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-action-recovery-fallback-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + try + { + var cliExecutor = new RecordingCliExecutorService + { + StandardStreamChunks = + [ + new StreamOutputChunk { Content = "第1段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第2段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第3段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第4段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第5段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第6段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第7段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第8段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第9段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第10段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第11段\n", IsCompleted = true } + ] + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var cardKit = new StubFeishuCardKitClient(); + for (var attempt = 0; attempt <= 10; attempt++) + { + cardKit.FailUpdateAttemptSequence.Enqueue(1); + } + var chatSessionService = new StubChatSessionService(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit, chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + operatorUserId: "ou_test_user", + inputValues: "输出两段内容"); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal(11, cardKit.Handles.Count); + Assert.NotNull(cardKit.Handles[^1].FinalContent); + Assert.Contains("第1段", cardKit.Handles[^1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("第11段", cardKit.Handles[^1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("**错误:飞书流式更新断连,已停止继续推送卡片。**", cardKit.Handles[^1].FinalContent!, StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_WhenReplacementCardCreationOverflows_FallsBackToPlainTextStreaming() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-card-overflow-fallback"; + + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-action-overflow-fallback-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + try + { + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "第一段\n", + StandardExecutionCompletionContent = "第二段\n" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + cardKit.ThrowOverflowOnCreateHandleSequence.Enqueue(false); + cardKit.ThrowOverflowOnCreateHandleSequence.Enqueue(true); + var chatSessionService = new StubChatSessionService(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit, chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + operatorUserId: "ou_test_user", + inputValues: "输出两段内容"); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); + + Assert.Single(cardKit.Handles); + Assert.Equal(1, cardKit.SendTextCallCount); + Assert.True(cardKit.ReplyTextCallCount >= 1); + Assert.Contains("飞书卡片已超限,后续改为普通文本继续输出。", cardKit.SentTextMessages[0], StringComparison.Ordinal); + Assert.Contains("第一段", cardKit.SentTextMessages[0], StringComparison.Ordinal); + Assert.Contains("第二段", string.Join("\n", cardKit.RepliedTextMessages), StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_WhenInitialCardCreationOverflows_FallsBackToPlainTextStreaming() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-card-initial-overflow-fallback"; + + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-action-initial-overflow-fallback-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + try + { + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "第一段\n", + StandardExecutionCompletionContent = "第二段\n" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var cardKit = new StubFeishuCardKitClient(); + cardKit.ThrowOverflowOnCreateHandleSequence.Enqueue(true); + var chatSessionService = new StubChatSessionService(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit, chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + operatorUserId: "ou_test_user", + inputValues: "输出两段内容"); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Empty(cardKit.Handles); + Assert.Equal(1, cardKit.SendTextCallCount); + Assert.True(cardKit.ReplyTextCallCount >= 1); + Assert.Contains("飞书卡片已超限,后续改为普通文本继续输出。", cardKit.SentTextMessages[0], StringComparison.Ordinal); + Assert.Contains("第一段", string.Join("\n", cardKit.RepliedTextMessages), StringComparison.Ordinal); + Assert.Contains("第二段", string.Join("\n", cardKit.RepliedTextMessages), StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_WhenReplacementCardFinalCompletionAlsoFails_AppendsDisconnectMessage() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-card-finish-fallback"; + + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-action-finish-fallback-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + try + { + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "第一段\n", + StandardExecutionCompletionContent = "第二段\n" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailFinishAttemptSequence.Enqueue(1); + cardKit.FailFinishAttemptSequence.Enqueue(1); + var chatSessionService = new StubChatSessionService(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit, chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + operatorUserId: "ou_test_user", + inputValues: "输出两段内容"); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); + await Task.Delay(200, TestContext.Current.CancellationToken); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal(2, cardKit.Handles[1].FinishAttemptCount); + Assert.NotNull(cardKit.Handles[1].FinalContent); + Assert.Contains("第一段\n第二段", cardKit.Handles[1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("**错误:飞书流式更新断连,已停止继续推送卡片。**", cardKit.Handles[1].FinalContent!, StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_CreatesTopChipGroupsDisabledUntilCompletion() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-streaming-top-chips"; + + var cliExecutor = new RecordingCliExecutorService(); + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var cardKit = new StubFeishuCardKitClient(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = activeSessionId, + Username = "luhaiyan", + Title = "Top Chips", + ToolId = "codex", + WorkspacePath = @"D:\repo\superpowers", + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new SessionToolLaunchOverride + { + Model = "gpt-5.4", + ReasoningEffort = "high" + } + }), + FeishuChatKey = chatId, + IsWorkspaceValid = true, + IsFeishuActive = true, + CreatedAt = DateTime.Now.AddMinutes(-30), + UpdatedAt = DateTime.Now + } + ]); + + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository), + cardKit); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + inputValues: "缁х画"); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + var initialChrome = Assert.IsType(cardKit.InitialStreamingChromeSnapshot); + Assert.Contains(initialChrome.OverflowOptions, item => item.Text == "模型:gpt-5.4"); + Assert.Contains(initialChrome.OverflowOptions, item => item.Text == "模型:gpt-5.4-mini"); + Assert.Collection(initialChrome.TopChipGroups, + hintGroup => + { + Assert.Equal("switch_hint", hintGroup.Kind); + Assert.False(hintGroup.IsEnabled); + Assert.False(string.IsNullOrWhiteSpace(hintGroup.SummaryMarkdown)); + }, + reasoningGroup => + { + Assert.Equal("reasoning_effort", reasoningGroup.Kind); + Assert.False(reasoningGroup.IsEnabled); + Assert.All(reasoningGroup.Items, item => Assert.False(item.IsEnabled)); + Assert.Contains(reasoningGroup.Items, item => item.Text == "high" && item.IsActive); + Assert.Equal(["low", "medium", "high", "xhigh"], reasoningGroup.Items.Select(item => item.Text).ToArray()); }); var finalChrome = Assert.IsType(cardKit.FinalStreamingChromeSnapshot); @@ -840,11 +1417,13 @@ await service.HandleCardActionAsync( "execution_control_row", "execution_control_row", "plan_action_row", - "plan_action_row" + "plan_action_row", + "goal_plan_action_row", + "goal_plan_action_row" ], initialChrome.BottomActions.Select(action => action.RowKey).ToArray()); - Assert.Equal(7, chrome.BottomActions.Count); + Assert.Equal(9, chrome.BottomActions.Count); Assert.Equal( [ GoalQuickActionDefaults.StatusButtonText, @@ -853,12 +1432,17 @@ await service.HandleCardActionAsync( GoalQuickActionDefaults.ResumeButtonText, SuperpowersQuickActionDefaults.ContinueButtonText, SuperpowersQuickActionDefaults.ExecutePlanButtonText, - SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText + SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText, + SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText, + SuperpowersQuickActionDefaults.CompleteWorktreeButtonText ], chrome.BottomActions.Select(action => action.Text).ToArray()); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); 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(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.CompleteWorktreeButtonText); Assert.Contains(chrome.BottomActions, action => action.Text == "/goal"); Assert.Contains(chrome.BottomActions, action => action.Text == "/goal pause"); Assert.Contains(chrome.BottomActions, action => action.Text == "/goal clear"); @@ -877,6 +1461,14 @@ await service.HandleCardActionAsync( Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText).Value); Assert.Contains($"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction}\"", executeSubagentValueJson); + var executeGoalValueJson = JsonSerializer.Serialize( + Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText).Value); + Assert.Contains($"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction}\"", executeGoalValueJson); + + var completeWorktreeValueJson = JsonSerializer.Serialize( + Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.CompleteWorktreeButtonText).Value); + Assert.Contains($"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersCompleteWorktreeAction}\"", completeWorktreeValueJson); + var statusGoalValueJson = JsonSerializer.Serialize( Assert.Single(chrome.BottomActions, action => action.Text == "/goal").Value); Assert.Contains("\"action\":\"status_goal\"", statusGoalValueJson); @@ -892,6 +1484,7 @@ await service.HandleCardActionAsync( var resumeGoalValueJson = JsonSerializer.Serialize( Assert.Single(chrome.BottomActions, action => action.Text == "/goal resume").Value); Assert.Contains("\"action\":\"resume_goal\"", resumeGoalValueJson); + } finally { @@ -967,37 +1560,237 @@ await service.HandleCardActionAsync( "goal_row_1", "goal_row_2", "goal_row_2", - "execution_control_row", - "execution_control_row" - ], - initialChrome.BottomActions.Select(action => action.RowKey).ToArray()); - Assert.Equal( - [ - GoalQuickActionDefaults.StatusButtonText, - GoalQuickActionDefaults.PauseButtonText, - GoalQuickActionDefaults.ClearButtonText, - GoalQuickActionDefaults.ResumeButtonText, - SuperpowersQuickActionDefaults.ContinueButtonText + "execution_control_row", + "execution_control_row" + ], + initialChrome.BottomActions.Select(action => action.RowKey).ToArray()); + Assert.Equal( + [ + GoalQuickActionDefaults.StatusButtonText, + GoalQuickActionDefaults.PauseButtonText, + GoalQuickActionDefaults.ClearButtonText, + GoalQuickActionDefaults.ResumeButtonText, + SuperpowersQuickActionDefaults.ContinueButtonText + ], + chrome.BottomActions.Select(action => action.Text).ToArray()); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.StopButtonText); + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_HidesGoalButtons_WhenUsingOneShotProcess() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-superpowers-one-shot"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "plan completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + cliExecutor.SetToolUsePersistentProcess("codex", false); + + var chatSessionService = new StubChatSessionService(); + chatSessionService.Messages[activeSessionId] = + [ + new ChatMessage + { + Role = "user", + Content = "superpowers", + IsCompleted = true, + CreatedAt = DateTime.UtcNow.AddMinutes(-2) + } + ]; + + var cardKit = new StubFeishuCardKitClient(); + var feishuChannel = new StubFeishuChannelService(activeSessionId); + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = activeSessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = @"D:\repo\superpowers", + FeishuChatKey = chatId, + IsFeishuActive = true, + ToolLaunchOverridesJson = "{\"codex\":{\"usePersistentProcess\":false}}", + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + UpdatedAt = DateTime.UtcNow + } + ]); + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository), + cardKit, + chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + inputValues: "继续"); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.NotNull(cardKit.LastStreamingChrome); + var chrome = cardKit.LastStreamingChrome!; + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_ShowsGoalButtons_WhenUsingGoalRuntime() + { + const string chatId = "oc_goal_runtime_chat"; + const string activeSessionId = "session-superpowers-goal-runtime"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "plan completed" + }; + cliExecutor.SetCliThreadId(activeSessionId, "goal-thread-1"); + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\goal-runtime"); + cliExecutor.SetToolUsePersistentProcess("codex", false); + + var chatSessionService = new StubChatSessionService(); + chatSessionService.Messages[activeSessionId] = + [ + new ChatMessage + { + Role = "user", + Content = "superpowers", + IsCompleted = true, + CreatedAt = DateTime.UtcNow.AddMinutes(-2) + } + ]; + + var cardKit = new StubFeishuCardKitClient(); + var feishuChannel = new StubFeishuChannelService(activeSessionId); + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = activeSessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = @"D:\repo\goal-runtime", + FeishuChatKey = chatId, + IsFeishuActive = true, + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new SessionToolLaunchOverride + { + UsePersistentProcess = false, + UseGoalRuntime = true + } + }), + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + UpdatedAt = DateTime.UtcNow + } + ]); + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository), + cardKit, + chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + inputValues: "继续"); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.NotNull(cardKit.LastStreamingChrome); + var chrome = cardKit.LastStreamingChrome!; + Assert.Null(chrome.BottomPrompt); + Assert.Single(chrome.AdditionalBottomPrompts); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.CompleteWorktreeButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.StopButtonText); + Assert.Equal( + [ + "goal_row_1", + "goal_row_1", + "goal_row_2", + "goal_row_2", + "goal_row_3", + "goal_row_3" ], - chrome.BottomActions.Select(action => action.Text).ToArray()); - Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); - Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); - Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); - Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.StopButtonText); + chrome.BottomActions.Select(action => action.RowKey).ToArray()); + + var temporaryExitValueJson = JsonSerializer.Serialize( + Assert.Single(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText).Value); + Assert.Contains($"\"action\":\"{FeishuHelpCardAction.TemporarilyExitGoalRuntimeAction}\"", temporaryExitValueJson); + + var completeWorktreeValueJson = JsonSerializer.Serialize( + Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.CompleteWorktreeButtonText).Value); + Assert.Contains($"\"action\":\"{FeishuHelpCardAction.TemporarilyExitAndCompleteWorktreeAction}\"", completeWorktreeValueJson); } [Fact] - public async Task HandleCardActionAsync_ExecuteCommand_HidesGoalButtons_WhenUsingOneShotProcess() + public async Task HandleCardActionAsync_ExecuteCommand_WhenGoalRuntimeTurnBoundary_RotatesToNewCardAndKeepsGoalButtonsOnLatestTurn() { - const string chatId = "oc_current_chat"; - const string activeSessionId = "session-superpowers-one-shot"; + const string chatId = "oc_goal_runtime_turn_boundary_chat"; + const string activeSessionId = "session-goal-runtime-turn-boundary"; var cliExecutor = new RecordingCliExecutorService { - StandardExecutionContent = "plan completed" + Adapter = new CodexAdapter(), + SupportsStreamParsingEnabled = true, + StandardStreamChunks = + [ + new StreamOutputChunk + { + Content = """ + {"type":"thread.started","thread_id":"thread-goal-runtime"} + {"type":"item.updated","item":{"type":"agent_message","text":"第一轮过程"}} + {"type":"item.completed","item":{"type":"agent_message","text":"第一轮结论","phase":"final_answer"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsTurnBoundary = true, + IsCompleted = false + }, + new StreamOutputChunk + { + Content = """ + {"type":"item.updated","item":{"type":"agent_message","text":"第二轮过程"}} + {"type":"item.completed","item":{"type":"agent_message","text":"第二轮结论","phase":"final_answer"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ], + GoalRuntimeGoal = new AppServerGoalSnapshot("ship this task", "complete", 200, 12, 34) }; - cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\goal-runtime"); cliExecutor.SetToolUsePersistentProcess("codex", false); var chatSessionService = new StubChatSessionService(); @@ -1014,6 +1807,7 @@ public async Task HandleCardActionAsync_ExecuteCommand_HidesGoalButtons_WhenUsin var cardKit = new StubFeishuCardKitClient(); var feishuChannel = new StubFeishuChannelService(activeSessionId); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); var sessionRepository = new StubChatSessionRepository( [ new ChatSessionEntity @@ -1021,10 +1815,18 @@ public async Task HandleCardActionAsync_ExecuteCommand_HidesGoalButtons_WhenUsin SessionId = activeSessionId, Username = "luhaiyan", ToolId = "codex", - WorkspacePath = @"D:\repo\superpowers", + WorkspacePath = @"D:\repo\goal-runtime", FeishuChatKey = chatId, IsFeishuActive = true, - ToolLaunchOverridesJson = "{\"codex\":{\"usePersistentProcess\":false}}", + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new SessionToolLaunchOverride + { + UsePersistentProcess = false, + UseGoalRuntime = true + } + }), CreatedAt = DateTime.UtcNow.AddMinutes(-10), UpdatedAt = DateTime.UtcNow } @@ -1032,7 +1834,9 @@ public async Task HandleCardActionAsync_ExecuteCommand_HidesGoalButtons_WhenUsin var service = CreateService( cliExecutor, feishuChannel, - new TestServiceProvider(chatSessionRepository: sessionRepository), + new TestServiceProvider( + chatSessionRepository: sessionRepository, + replyTtsOrchestrator: replyTtsOrchestrator), cardKit, chatSessionService); @@ -1041,27 +1845,85 @@ await service.HandleCardActionAsync( chatId: chatId, inputValues: "继续"); - await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); - Assert.NotNull(cardKit.LastStreamingChrome); - var chrome = cardKit.LastStreamingChrome!; - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); - Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); + Assert.Equal(2, replyTtsOrchestrator.Requests.Count); + Assert.Equal("thread-goal-runtime", replyTtsOrchestrator.Requests[0].CliThreadId); + Assert.Equal("继续", replyTtsOrchestrator.Requests[0].OriginalUserQuestion); + Assert.Equal("第一轮过程第一轮结论", replyTtsOrchestrator.Requests[0].Output); + Assert.Equal("第一轮结论", replyTtsOrchestrator.Requests[0].FinalAnswerOutput); + Assert.Equal("thread-goal-runtime", replyTtsOrchestrator.Requests[1].CliThreadId); + Assert.Equal("继续", replyTtsOrchestrator.Requests[1].OriginalUserQuestion); + Assert.Equal("第二轮过程第二轮结论", replyTtsOrchestrator.Requests[1].Output); + Assert.Equal("第二轮结论", replyTtsOrchestrator.Requests[1].FinalAnswerOutput); + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal("第一轮过程第一轮结论", cardKit.Handles[0].FinalContent); + Assert.Contains("Goal继续中", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.NotNull(cardKit.Handles[0].FinalChromeSnapshot); + Assert.Null(cardKit.Handles[0].FinalChromeSnapshot!.BottomPrompt); + Assert.Empty(cardKit.Handles[0].FinalChromeSnapshot.AdditionalBottomPrompts); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); + + Assert.NotNull(cardKit.Handles[1].InitialChromeSnapshot); + Assert.Null(cardKit.Handles[1].InitialChromeSnapshot!.BottomPrompt); + Assert.Single(cardKit.Handles[1].InitialChromeSnapshot.AdditionalBottomPrompts); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); + Assert.False(string.IsNullOrWhiteSpace(cardKit.Handles[1].InitialContent)); + Assert.DoesNotContain("第一轮过程第一轮结论", cardKit.Handles[1].InitialContent, StringComparison.Ordinal); + Assert.Equal("第二轮过程第二轮结论", cardKit.Handles[1].FinalContent); } [Fact] - public async Task HandleCardActionAsync_ExecuteCommand_ShowsGoalButtons_WhenUsingGoalRuntime() + public async Task HandleCardActionAsync_ExecuteCommand_WhenGoalRuntimeCardUpdateFails_DelaysReplacementUntilNextNewOutput() { - const string chatId = "oc_goal_runtime_chat"; - const string activeSessionId = "session-superpowers-goal-runtime"; + const string chatId = "oc_goal_runtime_deferred_replacement_chat"; + const string activeSessionId = "session-goal-runtime-deferred-replacement"; var cliExecutor = new RecordingCliExecutorService { - StandardExecutionContent = "plan completed" + Adapter = new CodexAdapter(), + SupportsStreamParsingEnabled = true, + StandardStreamChunks = + [ + new StreamOutputChunk + { + Content = """ + {"type":"thread.started","thread_id":"thread-goal-runtime-deferred"} + {"type":"item.updated","item":{"type":"agent_message","text":"第一段"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = """ + {"type":"item.updated","item":{"type":"agent_message","text":"第二段"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = """ + {"type":"item.completed","item":{"type":"agent_message","text":"最终结论","phase":"final_answer"}} + """ + "\n", + IsCompleted = true + } + ], + StandardStreamChunkDelays = + [ + TimeSpan.Zero, + TimeSpan.FromMilliseconds(1200), + TimeSpan.Zero + ], + GoalRuntimeGoal = new AppServerGoalSnapshot("ship this task", "complete", 200, 12, 34) }; cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\goal-runtime"); cliExecutor.SetToolUsePersistentProcess("codex", false); @@ -1079,6 +1941,7 @@ public async Task HandleCardActionAsync_ExecuteCommand_ShowsGoalButtons_WhenUsin ]; var cardKit = new StubFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); var feishuChannel = new StubFeishuChannelService(activeSessionId); var sessionRepository = new StubChatSessionRepository( [ @@ -1115,35 +1978,20 @@ await service.HandleCardActionAsync( chatId: chatId, inputValues: "继续"); - await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + await cliExecutor.WaitForExecutionStartedAsync(TimeSpan.FromSeconds(3)); + await Task.Delay(400, TestContext.Current.CancellationToken); + + Assert.Single(cardKit.Handles); + Assert.Null(cardKit.Handles[0].FinalContent); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(5)); await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); - Assert.NotNull(cardKit.LastStreamingChrome); - var chrome = cardKit.LastStreamingChrome!; - Assert.Null(chrome.BottomPrompt); - Assert.Single(chrome.AdditionalBottomPrompts); - Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); - Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); - Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); - Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); - Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText); - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText); - Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.StopButtonText); + Assert.Equal(2, cardKit.Handles.Count); Assert.Equal( - [ - "goal_row_1", - "goal_row_1", - "goal_row_2", - "goal_row_2", - "goal_row_3" - ], - chrome.BottomActions.Select(action => action.RowKey).ToArray()); - - var temporaryExitValueJson = JsonSerializer.Serialize( - Assert.Single(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText).Value); - Assert.Contains($"\"action\":\"{FeishuHelpCardAction.TemporarilyExitGoalRuntimeAction}\"", temporaryExitValueJson); + "第一段第二段\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Equal("第一段第二段最终结论", cardKit.Handles[1].FinalContent); } [Fact] @@ -1212,12 +2060,80 @@ public async Task HandleCardActionAsync_SuperpowersQuickAction_WhenActiveSession var cardContents = ExtractCardContentStrings(response); Assert.Contains(cardContents, content => content.Contains("回复内容", StringComparison.Ordinal)); Assert.Contains(cardContents, content => content.Contains(existingReply, StringComparison.Ordinal)); - Assert.Contains(cardContents, content => content.Contains("Superpowers 工作流", StringComparison.Ordinal)); + Assert.Contains(cardContents, content => content.Contains("Superpowers 工作流/Goal不间断执行", StringComparison.Ordinal)); Assert.Contains(cardContents, content => content.Contains(boundSessionId, StringComparison.Ordinal)); Assert.Contains(cardContents, content => content.Contains(currentSessionId, StringComparison.Ordinal)); Assert.False(cliExecutor.WasExecuted); } + [Fact] + public async Task HandleCardActionAsync_SubmitSuperpowersQuickInput_WhenActiveSessionChanged_ConfirmCardCarriesResolvedPrompt() + { + const string chatId = "oc_current_chat"; + const string boundSessionId = "session-bound"; + const string currentSessionId = "session-current"; + const string rawInput = "写一个执行步骤"; + + var cliExecutor = new RecordingCliExecutorService(); + cliExecutor.SetSessionWorkspacePath(boundSessionId, @"D:\repo\bound"); + cliExecutor.SetSessionWorkspacePath(currentSessionId, @"D:\repo\current"); + + var feishuChannel = new StubFeishuChannelService(currentSessionId); + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = boundSessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = @"D:\repo\bound", + FeishuChatKey = chatId, + IsFeishuActive = false, + ToolLaunchOverridesJson = "{\"codex\":{\"usePersistentProcess\":true}}", + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + UpdatedAt = DateTime.UtcNow.AddMinutes(-5) + }, + new ChatSessionEntity + { + SessionId = currentSessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = @"D:\repo\current", + FeishuChatKey = chatId, + IsFeishuActive = true, + ToolLaunchOverridesJson = "{\"codex\":{\"usePersistentProcess\":true}}", + CreatedAt = DateTime.UtcNow.AddMinutes(-4), + UpdatedAt = DateTime.UtcNow + } + ]); + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository)); + + var response = await service.HandleCardActionAsync( + JsonSerializer.Serialize(new + { + action = FeishuHelpCardAction.SubmitSuperpowersQuickInputAction, + session_id = boundSessionId, + chat_key = chatId, + tool_id = "codex" + }), + chatId: chatId, + formValue: new Dictionary + { + [SuperpowersQuickActionDefaults.QuickInputFieldName] = rawInput + }); + + var expectedPrompt = SuperpowersPromptBuilder.BuildQuickSkillPrompt(rawInput); + + Assert.Equal("⚠️ 当前激活会话已变化,请先确认要执行的会话", ExtractToastContent(response)); + Assert.Contains("Write documentation in English only. 代码注释需要使用中英文双语。", expectedPrompt, StringComparison.Ordinal); + Assert.Equal(expectedPrompt, ExtractActionCommandValue(response, FeishuHelpCardAction.ConfirmBoundSuperpowersAction)); + Assert.Equal(expectedPrompt, ExtractActionCommandValue(response, FeishuHelpCardAction.ConfirmCurrentSuperpowersAction)); + Assert.False(cliExecutor.WasExecuted); + } + [Fact] public async Task HandleCardActionAsync_SuperpowersQuickAction_ConfirmBoundSession_ExecutesOnBoundSession() { @@ -1290,6 +2206,87 @@ await service.HandleCardActionAsync( Assert.NotEmpty(cliExecutor.ExecutedPrompts); } + [Fact] + public async Task HandleCardActionAsync_SuperpowersQuickAction_ConfirmCurrentSession_WithPromptCommand_ExecutesOnCurrentSession() + { + const string chatId = "oc_current_chat"; + const string boundSessionId = "session-bound"; + const string currentSessionId = "session-current"; + const string rawInput = "写一个执行步骤"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "confirmed" + }; + cliExecutor.SetSessionWorkspacePath(boundSessionId, @"D:\repo\bound"); + cliExecutor.SetSessionWorkspacePath(currentSessionId, @"D:\repo\current"); + + var chatSessionService = new StubChatSessionService(); + chatSessionService.Messages[currentSessionId] = + [ + new ChatMessage + { + Role = "user", + Content = "superpowers", + IsCompleted = true, + CreatedAt = DateTime.UtcNow.AddMinutes(-2) + } + ]; + + var cardKit = new StubFeishuCardKitClient(); + var feishuChannel = new StubFeishuChannelService(currentSessionId); + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = boundSessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = @"D:\repo\bound", + FeishuChatKey = chatId, + IsFeishuActive = false, + ToolLaunchOverridesJson = "{\"codex\":{\"usePersistentProcess\":true}}", + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + UpdatedAt = DateTime.UtcNow.AddMinutes(-5) + }, + new ChatSessionEntity + { + SessionId = currentSessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = @"D:\repo\current", + FeishuChatKey = chatId, + IsFeishuActive = true, + ToolLaunchOverridesJson = "{\"codex\":{\"usePersistentProcess\":true}}", + CreatedAt = DateTime.UtcNow.AddMinutes(-4), + UpdatedAt = DateTime.UtcNow + } + ]); + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository), + cardKit, + chatSessionService); + + var expectedPrompt = SuperpowersPromptBuilder.BuildQuickSkillPrompt(rawInput); + + await service.HandleCardActionAsync( + JsonSerializer.Serialize(new + { + action = FeishuHelpCardAction.ConfirmCurrentSuperpowersAction, + session_id = boundSessionId, + chat_key = chatId, + tool_id = "codex", + command = expectedPrompt + }), + chatId: chatId); + + var usedSessionId = await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + Assert.Equal(currentSessionId, usedSessionId); + Assert.Equal(expectedPrompt, Assert.Single(cliExecutor.ExecutedPrompts)); + } + [Fact] public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputAndKeepsContinueAction_WhenSessionHistoryLacksSuperpowers() { @@ -1334,15 +2331,15 @@ await File.WriteAllTextAsync( new TestServiceProvider(chatSessionRepository: sessionRepository), cardKit); - await service.HandleCardActionAsync( - """{"action":"execute_command"}""", - chatId: chatId, - inputValues: "继续"); + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + inputValues: "继续"); - await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); - await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); - Assert.NotNull(cardKit.LastStreamingChrome); + Assert.NotNull(cardKit.LastStreamingChrome); var chrome = cardKit.LastStreamingChrome!; Assert.NotNull(chrome.BottomPrompt); Assert.Equal(SuperpowersQuickActionDefaults.QuickInputFieldName, chrome.BottomPrompt!.InputName); @@ -1385,6 +2382,10 @@ public async Task HandleCardActionAsync_SubmitSuperpowersQuickInput_AutoPrefixes await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + Assert.Contains( + "Write documentation in English only. 代码注释需要使用中英文双语。", + Assert.Single(cliExecutor.ExecutedPrompts), + StringComparison.Ordinal); Assert.Equal( SuperpowersPromptBuilder.BuildQuickSkillPrompt("写一个执行步骤"), Assert.Single(cliExecutor.ExecutedPrompts)); @@ -1742,30 +2743,357 @@ await service.HandleCardActionAsync( } [Fact] - public async Task HandleCardActionAsync_ExecuteSuperpowersSubagentPlan_UsesFixedPromptAndStandardExecutionPath() + public async Task HandleCardActionAsync_ExecuteSuperpowersSubagentPlan_UsesFixedPromptAndStandardExecutionPath() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-superpowers-execute-subagent-plan"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "plan completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var feishuChannel = new StubFeishuChannelService(activeSessionId); + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider()); + + await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal( + SuperpowersPromptBuilder.BuildSubagentExecutePlanPrompt(), + Assert.Single(cliExecutor.ExecutedPrompts)); + Assert.Empty(cliExecutor.LowInterruptionSessionIds); + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteSuperpowersCompleteWorktree_UsesFixedPromptAndStandardExecutionPath() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-superpowers-complete-worktree"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "worktree completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var feishuChannel = new StubFeishuChannelService(activeSessionId); + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider()); + + await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ExecuteSuperpowersCompleteWorktreeAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal( + SuperpowersPromptBuilder.BuildCompleteWorktreePrompt(), + Assert.Single(cliExecutor.ExecutedPrompts)); + Assert.Empty(cliExecutor.LowInterruptionSessionIds); + } + + [Fact] + public async Task HandleCardActionAsync_TemporarilyExitAndCompleteWorktree_DisablesGoalRuntimeThenUsesFixedPrompt() + { + const string chatId = "oc_workspace_chat"; + const string sessionId = "session-goal-runtime-complete-worktree"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "worktree completed" + }; + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\goal-runtime-complete-worktree"); + + var feishuChannel = new StubFeishuChannelService(sessionId); + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + Title = "Goal Runtime Session", + WorkspacePath = @"D:\repo\goal-runtime-complete-worktree", + ToolId = "codex", + FeishuChatKey = chatId, + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new SessionToolLaunchOverride + { + UsePersistentProcess = false, + UseGoalRuntime = true + } + }), + CreatedAt = DateTime.Now.AddMinutes(-30), + UpdatedAt = DateTime.Now, + IsWorkspaceValid = true, + IsFeishuActive = true, + IsCustomWorkspace = true + } + ]); + + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository)); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.TemporarilyExitAndCompleteWorktreeAction}}","session_id":"{{sessionId}}","chat_key":"{{chatId}}"}""", + chatId: chatId); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Info, response.Toast?.Type); + Assert.Equal("🚀 开始执行命令...", response.Toast?.Content); + Assert.Equal( + SuperpowersPromptBuilder.BuildCompleteWorktreePrompt(), + Assert.Single(cliExecutor.ExecutedPrompts)); + + var updatedSession = await sessionRepository.GetByIdAndUsernameAsync(sessionId, "luhaiyan"); + Assert.NotNull(updatedSession); + var launchOverride = SessionLaunchOverrideHelper.GetEffectiveOverride( + SessionLaunchOverrideHelper.Deserialize(updatedSession!.ToolLaunchOverridesJson), + "codex", + updatedSession.ToolId, + updatedSession.CcSwitchSnapshotToolId); + Assert.NotNull(launchOverride); + Assert.False(launchOverride!.UseGoalRuntime); + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteSuperpowersGoalPlan_UsesFixedGoalPromptAndGoalCapabilityPath() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-superpowers-execute-goal-plan"; + + 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.ExecuteSuperpowersGoalPlanAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal( + GoalPromptBuilder.BuildSubagentPlanGoalPrompt(), + Assert.Single(cliExecutor.ExecutedPrompts)); + Assert.Single(capabilityService.ProbeContexts); + Assert.Equal("codex", capabilityService.ProbeContexts[0].ToolId); + Assert.Empty(cliExecutor.LowInterruptionSessionIds); + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteSuperpowersGoalPlan_WhenLatestReplyReferencesPlanMarkdown_UsesReferencedPlanPrompt() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-superpowers-execute-goal-plan-with-reference"; + + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-goal-plan-reference-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(Path.Combine(workspacePath, "docs", "superpowers", "plans")); + await File.WriteAllTextAsync( + Path.Combine(workspacePath, "docs", "superpowers", "plans", "approved-plan.md"), + "# approved"); + + try + { + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "goal completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var capabilityService = new StubGoalCapabilityService + { + ProbeState = GoalCapabilityState.Available, + ProbeOutcome = GoalCapabilityProbeOutcome.Available + }; + + var chatSessionService = new StubChatSessionService(); + chatSessionService.Messages[activeSessionId] = + [ + new ChatMessage + { + Role = "assistant", + Content = """ + Use this plan to continue: + [approved plan](docs/superpowers/plans/approved-plan.md) + """, + IsCompleted = true, + CreatedAt = DateTime.UtcNow.AddMinutes(-1) + } + ]; + + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(goalCapabilityService: capabilityService), + chatSessionService: chatSessionService); + + await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + var executedPrompt = Assert.Single(cliExecutor.ExecutedPrompts); + Assert.Contains("docs/superpowers/plans/approved-plan.md", executedPrompt, StringComparison.Ordinal); + Assert.Contains("[ ]check list", executedPrompt, StringComparison.Ordinal); + Assert.Single(capabilityService.ProbeContexts); + Assert.Equal("codex", capabilityService.ProbeContexts[0].ToolId); + Assert.Empty(cliExecutor.LowInterruptionSessionIds); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteSuperpowersGoalPlan_StreamingCardShowsGoalOnlyFooter() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-superpowers-execute-goal-plan-footer"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "goal completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var capabilityService = new StubGoalCapabilityService + { + ProbeState = GoalCapabilityState.Available, + ProbeOutcome = GoalCapabilityProbeOutcome.Available + }; + + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = activeSessionId, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = @"D:\repo\superpowers", + FeishuChatKey = chatId, + IsFeishuActive = true, + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + UpdatedAt = DateTime.UtcNow + } + ]); + + var cardKit = new StubFeishuCardKitClient(); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider( + chatSessionRepository: sessionRepository, + goalCapabilityService: capabilityService), + cardKit); + + await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.NotNull(cardKit.LastStreamingChrome); + var chrome = cardKit.LastStreamingChrome!; + Assert.Null(chrome.BottomPrompt); + Assert.Single(chrome.AdditionalBottomPrompts); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.CompleteWorktreeButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.StopButtonText); + Assert.Equal( + [ + "goal_row_1", + "goal_row_1", + "goal_row_2", + "goal_row_2", + "goal_row_3", + "goal_row_3" + ], + chrome.BottomActions.Select(action => action.RowKey).ToArray()); + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteSuperpowersGoalPlan_WhenSessionAlreadyRunning_ReturnsOverwriteConfirmCard() { const string chatId = "oc_current_chat"; - const string activeSessionId = "session-superpowers-execute-subagent-plan"; + const string activeSessionId = "session-superpowers-goal-plan-busy"; var cliExecutor = new RecordingCliExecutorService { - StandardExecutionContent = "plan completed" + StandardExecutionContent = "goal completed" }; cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); - var feishuChannel = new StubFeishuChannelService(activeSessionId); - var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider()); + var capabilityService = new StubGoalCapabilityService + { + ProbeState = GoalCapabilityState.Available, + ProbeOutcome = GoalCapabilityProbeOutcome.Available + }; - await service.HandleCardActionAsync( - $$"""{"action":"{{FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction}}","chat_key":"{{chatId}}"}""", - chatId: chatId); + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex", + SessionExecutionActive = true + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(goalCapabilityService: capabilityService)); - await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId); - Assert.Equal( - SuperpowersPromptBuilder.BuildSubagentExecutePlanPrompt(), - Assert.Single(cliExecutor.ExecutedPrompts)); - Assert.Empty(cliExecutor.LowInterruptionSessionIds); + Assert.Equal("⚠️ 当前 goal 正在执行,请确认是否覆盖原有 goal", ExtractToastContent(response)); + var cardContents = ExtractCardContentStrings(response); + Assert.Contains(cardContents, content => content.Contains("继续当前 goal", StringComparison.Ordinal)); + Assert.Contains(cardContents, content => content.Contains("中断并覆盖", StringComparison.Ordinal)); + Assert.Contains(cardContents, content => content.Contains("使用Subagent-Driven完成plan", StringComparison.Ordinal)); + Assert.Empty(cliExecutor.ExecutedPrompts); } [Fact] @@ -2285,7 +3613,184 @@ public async Task HandleCardActionAsync_LowInterruptionContinue_PassesPromptFrom } [Fact] - public async Task HandleCardActionAsync_ExecuteCommand_QueuesReplyTtsAfterSuccessfulCompletion() + public async Task HandleCardActionAsync_LowInterruptionContinue_ReplacesBrokenStreamingCardOnceAndFinishesOnReplacement() + { + const string chatId = "oc_current_chat"; + const string sessionId = "session-low-interruption-recovery"; + + var cliExecutor = new RecordingCliExecutorService + { + SupportsLowInterruption = true, + LowInterruptionExecutionContent = "backlog remains", + LowInterruptionExecutionMidStreamContent = " -> more", + LowInterruptionExecutionCompletionContent = "backlog complete" + }; + cliExecutor.SetCliThreadId(sessionId, "thread-low-interruption-recovery"); + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); + + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + var feishuChannel = new StubFeishuChannelService(sessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit); + + await service.HandleCardActionAsync( + """{"action":"low_interruption_continue","session_id":"session-low-interruption-recovery","chat_key":"oc_current_chat","tool_id":"codex"}""", + chatId: chatId); + + var usedSessionId = await cliExecutor.WaitForLowInterruptionExecutionAsync(TimeSpan.FromSeconds(3)); + await cliExecutor.WaitForLowInterruptionExecutionCompletionAsync(TimeSpan.FromSeconds(5)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal(sessionId, usedSessionId); + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal(1, cardKit.Handles[0].UpdateAttemptCount); + Assert.Empty(cardKit.Handles[0].Updates); + Assert.Equal( + "backlog remains\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Contains("已停止", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("backlog remains", cardKit.Handles[1].InitialContent); + Assert.Equal(2, cardKit.Handles[1].UpdateAttemptCount); + Assert.Contains("backlog remains -> more", cardKit.Handles[1].Updates); + Assert.Contains("backlog remains -> morebacklog complete", cardKit.Handles[1].Updates); + Assert.Equal("backlog remains -> morebacklog complete", cardKit.Handles[1].FinalContent); + Assert.Contains("已完成", cardKit.Handles[1].FinalStatusMarkdown, StringComparison.Ordinal); + } + + [Fact] + public async Task HandleCardActionAsync_LowInterruptionContinue_WhenFinalCardCompletionFails_ReplacesStreamingCardAndFinishesOnReplacement() + { + const string chatId = "oc_current_chat"; + const string sessionId = "session-low-interruption-finish-recovery"; + + var cliExecutor = new RecordingCliExecutorService + { + SupportsLowInterruption = true, + LowInterruptionExecutionContent = "backlog remains", + LowInterruptionExecutionMidStreamContent = " -> more", + LowInterruptionExecutionCompletionContent = "backlog complete" + }; + cliExecutor.SetCliThreadId(sessionId, "thread-low-interruption-finish-recovery"); + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); + + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailFinishAttemptSequence.Enqueue(1); + var feishuChannel = new StubFeishuChannelService(sessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit); + + await service.HandleCardActionAsync( + """{"action":"low_interruption_continue","session_id":"session-low-interruption-finish-recovery","chat_key":"oc_current_chat","tool_id":"codex"}""", + chatId: chatId); + + var usedSessionId = await cliExecutor.WaitForLowInterruptionExecutionAsync(TimeSpan.FromSeconds(3)); + await cliExecutor.WaitForLowInterruptionExecutionCompletionAsync(TimeSpan.FromSeconds(5)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal(sessionId, usedSessionId); + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal(1, cardKit.Handles[0].FinishAttemptCount); + Assert.Null(cardKit.Handles[0].FinalContent); + Assert.Equal("backlog remains -> morebacklog complete", cardKit.Handles[1].InitialContent); + Assert.Equal("backlog remains -> morebacklog complete", cardKit.Handles[1].FinalContent); + } + + [Fact] + public async Task HandleCardActionAsync_LowInterruptionContinue_WhenReplacementCardAlsoFails_AppendsDisconnectMessage() + { + const string chatId = "oc_current_chat"; + const string sessionId = "session-low-interruption-recovery-fallback"; + + var cliExecutor = new RecordingCliExecutorService + { + SupportsLowInterruption = true, + LowInterruptionExecutionContent = "backlog remains", + LowInterruptionExecutionMidStreamContent = " -> more", + LowInterruptionExecutionCompletionContent = "backlog complete" + }; + cliExecutor.SetCliThreadId(sessionId, "thread-low-interruption-recovery-fallback"); + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); + + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + cardKit.FailUpdateAttemptSequence.Enqueue(2); + var feishuChannel = new StubFeishuChannelService(sessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit); + + await service.HandleCardActionAsync( + """{"action":"low_interruption_continue","session_id":"session-low-interruption-recovery-fallback","chat_key":"oc_current_chat","tool_id":"codex"}""", + chatId: chatId); + + await cliExecutor.WaitForLowInterruptionExecutionCompletionAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(3, cardKit.Handles.Count); + Assert.Equal( + "backlog remains\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Contains("已停止", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal(2, cardKit.Handles[1].UpdateAttemptCount); + Assert.Single(cardKit.Handles[1].Updates); + Assert.Equal("backlog remains -> more", cardKit.Handles[1].Updates[0]); + Assert.DoesNotContain("backlog remains -> morebacklog complete", cardKit.Handles[1].Updates); + Assert.Equal( + "backlog remains -> morebacklog complete\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[1].FinalContent); + Assert.Contains("已停止", cardKit.Handles[1].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("backlog remains -> morebacklog complete", cardKit.Handles[2].InitialContent); + Assert.NotNull(cardKit.Handles[2].FinalContent); + Assert.Equal("backlog remains -> morebacklog complete", cardKit.Handles[2].FinalContent); + Assert.Contains("已完成", cardKit.Handles[2].FinalStatusMarkdown, StringComparison.Ordinal); + } + + [Fact] + public async Task HandleCardActionAsync_LowInterruptionContinue_WhenReplacementCardFinalCompletionAlsoFails_AppendsDisconnectMessage() + { + const string chatId = "oc_current_chat"; + const string sessionId = "session-low-interruption-finish-fallback"; + + var cliExecutor = new RecordingCliExecutorService + { + SupportsLowInterruption = true, + LowInterruptionExecutionContent = "backlog remains", + LowInterruptionExecutionMidStreamContent = " -> more", + LowInterruptionExecutionCompletionContent = "backlog complete" + }; + cliExecutor.SetCliThreadId(sessionId, "thread-low-interruption-finish-fallback"); + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); + + var cardKit = new StubFeishuCardKitClient(); + cardKit.FailFinishAttemptSequence.Enqueue(1); + cardKit.FailFinishAttemptSequence.Enqueue(1); + var feishuChannel = new StubFeishuChannelService(sessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit); + + await service.HandleCardActionAsync( + """{"action":"low_interruption_continue","session_id":"session-low-interruption-finish-fallback","chat_key":"oc_current_chat","tool_id":"codex"}""", + chatId: chatId); + + await cliExecutor.WaitForLowInterruptionExecutionCompletionAsync(TimeSpan.FromSeconds(5)); + await Task.Delay(200, TestContext.Current.CancellationToken); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal(2, cardKit.Handles[1].FinishAttemptCount); + Assert.NotNull(cardKit.Handles[1].FinalContent); + Assert.Contains("backlog remains -> morebacklog complete", cardKit.Handles[1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("**错误:飞书流式更新断连,已停止继续推送卡片。**", cardKit.Handles[1].FinalContent!, StringComparison.Ordinal); + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_QueuesReplyDocumentAfterSuccessfulCompletion() { const string chatId = "oc_reply_tts_card_chat"; const string sessionId = "session-reply-tts-card"; @@ -2293,18 +3798,30 @@ public async Task HandleCardActionAsync_ExecuteCommand_QueuesReplyTtsAfterSucces var cliExecutor = new RecordingCliExecutorService { - StandardExecutionContent = "card action completed" + Adapter = new CodexAdapter(), + SupportsStreamParsingEnabled = true, + StandardExecutionContent = + """ + {"type":"thread.started","thread_id":"thread-1"} + {"type":"item.updated","item":{"type":"agent_message","text":"process details"}} + """ + "\n", + StandardExecutionCompletionContent = + """ + {"type":"item.updated","item":{"type":"agent_message","text":"final conclusion","phase":"final_answer"}} + """ + "\n" }; + cliExecutor.SetCliThreadId(sessionId, "thread-1"); cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); var chatSessionService = new StubChatSessionService(); - var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); 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); + message => message.Role == "assistant" && message.Content == "process detailsfinal conclusion" && message.IsCompleted); + Assert.Equal("process detailsfinal conclusion", request.Output); + Assert.Equal("final conclusion", request.FinalAnswerOutput); return Task.CompletedTask; }; @@ -2327,11 +3844,14 @@ await service.HandleCardActionAsync( Assert.Equal("luhaiyan", queued.Username); Assert.Equal(appId, queued.AppId); Assert.Equal(sessionId, queued.SessionId); - Assert.Equal("card action completed", queued.Output); + Assert.Equal("thread-1", queued.CliThreadId); + Assert.Equal("continue", queued.OriginalUserQuestion); + Assert.Equal("process detailsfinal conclusion", queued.Output); + Assert.Equal("final conclusion", queued.FinalAnswerOutput); } [Fact] - public async Task HandleCardActionAsync_LowInterruptionContinue_QueuesReplyTtsAfterSuccessfulCompletion() + public async Task HandleCardActionAsync_LowInterruptionContinue_QueuesReplyDocumentAfterSuccessfulCompletion() { const string chatId = "oc_reply_tts_low_interruption_chat"; const string sessionId = "session-reply-tts-low-interruption"; @@ -2340,19 +3860,30 @@ public async Task HandleCardActionAsync_LowInterruptionContinue_QueuesReplyTtsAf var cliExecutor = new RecordingCliExecutorService { SupportsLowInterruption = true, - LowInterruptionExecutionContent = "low interruption completed" + Adapter = new CodexAdapter(), + SupportsStreamParsingEnabled = true, + LowInterruptionExecutionContent = + """ + {"type":"thread.started","thread_id":"thread-reply-tts-low-interruption"} + {"type":"item.updated","item":{"type":"agent_message","text":"continue context"}} + """ + "\n", + LowInterruptionExecutionCompletionContent = + """ + {"type":"item.updated","item":{"type":"agent_message","text":"continue final","phase":"final_answer"}} + """ + "\n" }; cliExecutor.SetCliThreadId(sessionId, "thread-reply-tts-low-interruption"); cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); var chatSessionService = new StubChatSessionService(); - var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); 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); + message => message.Role == "assistant" && message.Content == "continue contextcontinue final" && message.IsCompleted); + Assert.Equal("continue contextcontinue final", request.Output); + Assert.Equal("continue final", request.FinalAnswerOutput); return Task.CompletedTask; }; @@ -2374,11 +3905,12 @@ await service.HandleCardActionAsync( Assert.Equal("luhaiyan", queued.Username); Assert.Equal(appId, queued.AppId); Assert.Equal(sessionId, queued.SessionId); - Assert.Equal("low interruption completed", queued.Output); + Assert.Equal("continue contextcontinue final", queued.Output); + Assert.Equal("continue final", queued.FinalAnswerOutput); } [Fact] - public async Task HandleCardActionAsync_ExecuteCommand_DoesNotQueueReplyTtsWhenExecutionErrors() + public async Task HandleCardActionAsync_ExecuteCommand_DoesNotQueueReplyDocumentWhenExecutionErrors() { const string sessionId = "session-reply-tts-error"; @@ -2389,7 +3921,7 @@ public async Task HandleCardActionAsync_ExecuteCommand_DoesNotQueueReplyTtsWhenE }; cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); - var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); var service = CreateService( cliExecutor, new StubFeishuChannelService(sessionId), @@ -2406,7 +3938,7 @@ await service.HandleCardActionAsync( } [Fact] - public async Task HandleCardActionAsync_LowInterruptionContinue_DoesNotQueueReplyTtsWhenExecutionErrors() + public async Task HandleCardActionAsync_LowInterruptionContinue_DoesNotQueueReplyDocumentWhenExecutionErrors() { const string sessionId = "session-reply-tts-low-interruption-error"; @@ -2419,7 +3951,7 @@ public async Task HandleCardActionAsync_LowInterruptionContinue_DoesNotQueueRepl cliExecutor.SetCliThreadId(sessionId, "thread-reply-tts-low-interruption-error"); cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); - var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); var service = CreateService( cliExecutor, new StubFeishuChannelService(sessionId), @@ -2508,37 +4040,173 @@ public async Task HandleCardActionAsync_OpenSessionManager_SendAsNewCard_SendsSe new TestServiceProvider(chatSessionRepository: sessionRepository), cardKit); - var response = await service.HandleCardActionAsync( - """{"action":"open_session_manager","chat_key":"oc_workspace_chat","send_as_new_card":true}""", + var response = await service.HandleCardActionAsync( + """{"action":"open_session_manager","chat_key":"oc_workspace_chat","send_as_new_card":true}""", + chatId: chatId); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Success, response.Toast?.Type); + Assert.Contains("已发送会话管理卡片", response.Toast?.Content); + + var (sentChatId, cardJson) = await cardKit.WaitForRawCardSentAsync(TimeSpan.FromSeconds(3)); + Assert.Equal(chatId, sentChatId); + Assert.Contains("\"action\":\"show_create_session_form\"", cardJson); + Assert.Contains("\"action\":\"switch_session\"", cardJson); + Assert.Contains("\"action\":\"show_rename_session_form\"", cardJson); + Assert.Contains("\"action\":\"show_session_launch_settings_form\"", cardJson); + Assert.Contains("\"action\":\"sync_session_provider\"", cardJson); + Assert.Contains("session-new", cardJson); + } + + [Fact] + public async Task HandleCardActionAsync_OpenSessionManager_DefaultsToRecentThreeSessionsUntilPaginated() + { + const string chatId = "oc_workspace_chat"; + const string currentSessionId = "session-visible-01"; + + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(currentSessionId); + var now = DateTime.Now; + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = currentSessionId, + Username = "luhaiyan", + Title = "Visible Session 01", + WorkspacePath = @"D:\repo\visible-01", + ToolId = "codex", + FeishuChatKey = chatId, + CreatedAt = now.AddMinutes(-40), + UpdatedAt = now.AddMinutes(-1), + IsWorkspaceValid = true, + IsFeishuActive = true, + IsCustomWorkspace = true + }, + new ChatSessionEntity + { + SessionId = "session-visible-02", + Username = "luhaiyan", + Title = "Visible Session 02", + WorkspacePath = @"D:\repo\visible-02", + ToolId = "claude-code", + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["claude-code"] = new SessionToolLaunchOverride + { + UseGoalRuntime = true + } + }), + FeishuChatKey = chatId, + CreatedAt = now.AddMinutes(-50), + UpdatedAt = now.AddMinutes(-2), + IsWorkspaceValid = true, + IsFeishuActive = false, + IsCustomWorkspace = true + }, + new ChatSessionEntity + { + SessionId = "session-visible-03", + Username = "luhaiyan", + Title = "Visible Session 03", + WorkspacePath = @"D:\repo\visible-03", + ToolId = "codex", + FeishuChatKey = chatId, + CreatedAt = now.AddMinutes(-60), + UpdatedAt = now.AddMinutes(-3), + IsWorkspaceValid = true, + IsFeishuActive = false, + IsCustomWorkspace = true + }, + new ChatSessionEntity + { + SessionId = "session-page-04", + Username = "luhaiyan", + Title = "Page Session 04", + WorkspacePath = @"D:\repo\page-04", + ToolId = "claude-code", + FeishuChatKey = chatId, + CreatedAt = now.AddMinutes(-70), + UpdatedAt = now.AddMinutes(-4), + IsWorkspaceValid = true, + IsFeishuActive = false, + IsCustomWorkspace = true + } + ]); + + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository)); + + var collapsedResponse = await service.HandleCardActionAsync( + """{"action":"open_session_manager","chat_key":"oc_workspace_chat"}""", + chatId: chatId); + + var collapsedPayload = SerializeResponse(collapsedResponse); + var collapsedContents = ExtractCardContentStrings(collapsedResponse); + Assert.Contains("Visible Session 01", collapsedPayload); + Assert.Contains("Visible Session 02", collapsedPayload); + Assert.Contains("Visible Session 03", collapsedPayload); + Assert.DoesNotContain("Page Session 04", collapsedPayload); + Assert.Contains(collapsedContents, content => content.Contains("🎯 Goal持续会话:**1** 个", StringComparison.Ordinal)); + Assert.Contains(collapsedContents, content => content.Contains("🎯 **Goal持续会话**", StringComparison.Ordinal)); + Assert.Contains(collapsedContents, content => content.Contains("当前默认展示最近 **3** 个会话", StringComparison.Ordinal)); + Assert.Contains(collapsedContents, content => content.Contains("更多会话", StringComparison.Ordinal)); + Assert.Contains("\"show_all_sessions\":true", collapsedPayload); + Assert.Contains("\"session_page\":0", collapsedPayload); + Assert.Contains(FeishuHelpCardAction.StatusGoalAction, collapsedPayload, StringComparison.Ordinal); + Assert.Contains(FeishuHelpCardAction.PauseGoalAction, collapsedPayload, StringComparison.Ordinal); + Assert.Contains(FeishuHelpCardAction.ClearGoalAction, collapsedPayload, StringComparison.Ordinal); + Assert.Contains(FeishuHelpCardAction.ResumeGoalAction, collapsedPayload, StringComparison.Ordinal); + Assert.Contains(FeishuHelpCardAction.TemporarilyExitGoalRuntimeAction, collapsedPayload, StringComparison.Ordinal); + Assert.Contains(collapsedContents, content => content.Contains(GoalQuickActionDefaults.TemporaryExitButtonText, StringComparison.Ordinal)); + + var firstPageResponse = await service.HandleCardActionAsync( + """{"action":"open_session_manager","chat_key":"oc_workspace_chat","show_all_sessions":true,"session_page":0}""", chatId: chatId); - Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Success, response.Toast?.Type); - Assert.Contains("已发送会话管理卡片", response.Toast?.Content); + var firstPagePayload = SerializeResponse(firstPageResponse); + var firstPageContents = ExtractCardContentStrings(firstPageResponse); + Assert.Contains("Visible Session 01", firstPagePayload); + Assert.Contains("Visible Session 02", firstPagePayload); + Assert.Contains("Visible Session 03", firstPagePayload); + Assert.DoesNotContain("Page Session 04", firstPagePayload); + Assert.Contains(firstPageContents, content => content.Contains("当前展示第 **1/2** 页", StringComparison.Ordinal)); + Assert.Contains(firstPageContents, content => content.Contains("下一页", StringComparison.Ordinal)); + Assert.Contains(firstPageContents, content => content.Contains("收起", StringComparison.Ordinal)); + Assert.Contains("\"session_page\":1", firstPagePayload); + Assert.Contains("\"show_all_sessions\":false", firstPagePayload); + + var secondPageResponse = await service.HandleCardActionAsync( + """{"action":"open_session_manager","chat_key":"oc_workspace_chat","show_all_sessions":true,"session_page":1}""", + chatId: chatId); - var (sentChatId, cardJson) = await cardKit.WaitForRawCardSentAsync(TimeSpan.FromSeconds(3)); - Assert.Equal(chatId, sentChatId); - Assert.Contains("\"action\":\"show_create_session_form\"", cardJson); - Assert.Contains("\"action\":\"switch_session\"", cardJson); - Assert.Contains("\"action\":\"show_rename_session_form\"", cardJson); - Assert.Contains("\"action\":\"show_session_launch_settings_form\"", cardJson); - Assert.Contains("\"action\":\"sync_session_provider\"", cardJson); - Assert.Contains("session-new", cardJson); + var secondPagePayload = SerializeResponse(secondPageResponse); + var secondPageContents = ExtractCardContentStrings(secondPageResponse); + Assert.DoesNotContain("Visible Session 01", secondPagePayload); + Assert.DoesNotContain("Visible Session 02", secondPagePayload); + Assert.DoesNotContain("Visible Session 03", secondPagePayload); + Assert.Contains("Page Session 04", secondPagePayload); + Assert.Contains(secondPageContents, content => content.Contains("当前展示第 **2/2** 页", StringComparison.Ordinal)); + Assert.Contains(secondPageContents, content => content.Contains("上一页", StringComparison.Ordinal)); + Assert.DoesNotContain(secondPageContents, content => content.Contains("下一页", StringComparison.Ordinal)); + Assert.Contains("\"session_page\":0", secondPagePayload); } [Fact] - public async Task HandleCardActionAsync_OpenSessionManager_DefaultsToRecentThreeSessionsUntilExpanded() + public async Task HandleCardActionAsync_OpenSessionManager_PageActionsPreserveSessionPage() { const string chatId = "oc_workspace_chat"; - const string currentSessionId = "session-visible-01"; var cliExecutor = new RecordingCliExecutorService(); - var feishuChannel = new StubFeishuChannelService(currentSessionId); + var feishuChannel = new StubFeishuChannelService("session-visible-01"); var now = DateTime.Now; var sessionRepository = new StubChatSessionRepository( [ new ChatSessionEntity { - SessionId = currentSessionId, + SessionId = "session-visible-01", Username = "luhaiyan", Title = "Visible Session 01", WorkspacePath = @"D:\repo\visible-01", @@ -2556,15 +4224,7 @@ public async Task HandleCardActionAsync_OpenSessionManager_DefaultsToRecentThree Username = "luhaiyan", Title = "Visible Session 02", WorkspacePath = @"D:\repo\visible-02", - ToolId = "claude-code", - ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["claude-code"] = new SessionToolLaunchOverride - { - UseGoalRuntime = true - } - }), + ToolId = "codex", FeishuChatKey = chatId, CreatedAt = now.AddMinutes(-50), UpdatedAt = now.AddMinutes(-2), @@ -2588,10 +4248,10 @@ public async Task HandleCardActionAsync_OpenSessionManager_DefaultsToRecentThree }, new ChatSessionEntity { - SessionId = "session-hidden-04", + SessionId = "session-page-04", Username = "luhaiyan", - Title = "Hidden Session 04", - WorkspacePath = @"D:\repo\hidden-04", + Title = "Page Session 04", + WorkspacePath = @"D:\repo\page-04", ToolId = "claude-code", FeishuChatKey = chatId, CreatedAt = now.AddMinutes(-70), @@ -2607,38 +4267,52 @@ public async Task HandleCardActionAsync_OpenSessionManager_DefaultsToRecentThree feishuChannel, new TestServiceProvider(chatSessionRepository: sessionRepository)); - var collapsedResponse = await service.HandleCardActionAsync( - """{"action":"open_session_manager","chat_key":"oc_workspace_chat"}""", + var response = await service.HandleCardActionAsync( + """{"action":"open_session_manager","chat_key":"oc_workspace_chat","show_all_sessions":true,"session_page":1}""", chatId: chatId); - var collapsedPayload = SerializeResponse(collapsedResponse); - var collapsedContents = ExtractCardContentStrings(collapsedResponse); - Assert.Contains("Visible Session 01", collapsedPayload); - Assert.Contains("Visible Session 02", collapsedPayload); - Assert.Contains("Visible Session 03", collapsedPayload); - Assert.DoesNotContain("Hidden Session 04", collapsedPayload); - Assert.Contains(collapsedContents, content => content.Contains("🎯 Goal持续会话:**1** 个", StringComparison.Ordinal)); - Assert.Contains(collapsedContents, content => content.Contains("🎯 **Goal持续会话**", StringComparison.Ordinal)); - Assert.Contains(collapsedContents, content => content.Contains("当前默认展示最近 **3** 个会话", StringComparison.Ordinal)); - Assert.Contains(collapsedContents, content => content.Contains("更多会话", StringComparison.Ordinal)); - Assert.Contains("\"show_all_sessions\":true", collapsedPayload); - Assert.Contains(FeishuHelpCardAction.StatusGoalAction, collapsedPayload, StringComparison.Ordinal); - Assert.Contains(FeishuHelpCardAction.PauseGoalAction, collapsedPayload, StringComparison.Ordinal); - Assert.Contains(FeishuHelpCardAction.ClearGoalAction, collapsedPayload, StringComparison.Ordinal); - Assert.Contains(FeishuHelpCardAction.ResumeGoalAction, collapsedPayload, StringComparison.Ordinal); - Assert.Contains(FeishuHelpCardAction.TemporarilyExitGoalRuntimeAction, collapsedPayload, StringComparison.Ordinal); - Assert.Contains(GoalQuickActionDefaults.TemporaryExitButtonText, collapsedPayload, StringComparison.Ordinal); + var payload = SerializeResponse(response); + Assert.Contains( + "\"action\":\"switch_session\",\"session_id\":\"session-page-04\",\"chat_key\":\"oc_workspace_chat\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + Assert.Contains( + "\"action\":\"show_rename_session_form\",\"session_id\":\"session-page-04\",\"chat_key\":\"oc_workspace_chat\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + Assert.Contains( + "\"action\":\"show_session_launch_settings_form\",\"session_id\":\"session-page-04\",\"chat_key\":\"oc_workspace_chat\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + } - var expandedResponse = await service.HandleCardActionAsync( - """{"action":"open_session_manager","chat_key":"oc_workspace_chat","show_all_sessions":true}""", - chatId: chatId); + [Fact] + public async Task HandleCardActionAsync_ShowCreateSessionForm_PreservesSessionPageInReturnActions() + { + const string chatId = "oc_workspace_chat"; + + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService("session-visible-01"); + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider()); + + var response = await service.HandleCardActionAsync( + """{"action":"show_create_session_form","chat_key":"oc_workspace_chat","show_all_sessions":true,"session_page":1}""", + chatId: chatId, + operatorUserId: "ou_test_user"); - var expandedPayload = SerializeResponse(expandedResponse); - var expandedContents = ExtractCardContentStrings(expandedResponse); - Assert.Contains("Hidden Session 04", expandedPayload); - Assert.Contains(expandedContents, content => content.Contains("当前展示全部 **4** 个会话", StringComparison.Ordinal)); - Assert.Contains(expandedContents, content => content.Contains("收起", StringComparison.Ordinal)); - Assert.Contains("\"show_all_sessions\":false", expandedPayload); + var payload = SerializeResponse(response); + Assert.Contains( + "\"action\":\"show_create_session_form\",\"chat_key\":\"oc_workspace_chat\",\"tool_id\":\"claude-code\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + Assert.Contains( + "\"action\":\"browse_allowed_directory\",\"chat_key\":\"oc_workspace_chat\",\"tool_id\":\"claude-code\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + Assert.Contains( + "\"action\":\"open_session_manager\",\"chat_key\":\"oc_workspace_chat\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); } [Fact] @@ -2921,7 +4595,7 @@ public async Task HandleCardActionAsync_TemporarilyExitGoalRuntime_DisablesGoalR } [Fact] - public async Task HandleCardActionAsync_TemporarilyExitGoalRuntime_WhenGoalStillActive_ReturnsWarningWithoutChangingOverride() + public async Task HandleCardActionAsync_TemporarilyExitGoalRuntime_WhenGoalStillActive_AutoPausesThenDisablesGoalRuntime() { const string chatId = "oc_workspace_chat"; const string sessionId = "session-temporary-exit-goal-runtime-active"; @@ -2968,9 +4642,71 @@ public async Task HandleCardActionAsync_TemporarilyExitGoalRuntime_WhenGoalStill $$"""{"action":"{{FeishuHelpCardAction.TemporarilyExitGoalRuntimeAction}}","session_id":"{{sessionId}}","chat_key":"{{chatId}}","show_all_sessions":true}""", chatId: chatId); - Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Warning, response.Toast?.Type); - Assert.Contains("正在执行", response.Toast?.Content, StringComparison.Ordinal); + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Success, response.Toast?.Type); + Assert.Contains("临时退出", response.Toast?.Content, StringComparison.Ordinal); Assert.Empty(cliExecutor.ResetRequests); + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + Assert.Contains(cliExecutor.ExecutedPrompts, prompt => string.Equals(prompt, "/goal pause", StringComparison.Ordinal)); + + var updatedSession = await sessionRepository.GetByIdAndUsernameAsync(sessionId, "luhaiyan"); + Assert.NotNull(updatedSession); + var launchOverride = SessionLaunchOverrideHelper.GetEffectiveOverride( + SessionLaunchOverrideHelper.Deserialize(updatedSession!.ToolLaunchOverridesJson), + "codex", + updatedSession.ToolId, + updatedSession.CcSwitchSnapshotToolId); + Assert.NotNull(launchOverride); + Assert.False(launchOverride!.UseGoalRuntime); + } + + [Fact] + public async Task HandleCardActionAsync_TemporarilyExitGoalRuntime_WhenAutoPauseCannotStart_ReturnsWarningWithoutChangingOverride() + { + const string chatId = "oc_workspace_chat"; + const string sessionId = "session-temporary-exit-goal-runtime-auto-pause-conflict"; + + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(sessionId); + feishuChannel.SessionExecutionActive = true; + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + Title = "Active Goal Runtime Session", + WorkspacePath = @"D:\repo\goal-runtime-active", + ToolId = "codex", + FeishuChatKey = chatId, + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new SessionToolLaunchOverride + { + UsePersistentProcess = false, + UseGoalRuntime = true + } + }), + CreatedAt = DateTime.Now.AddMinutes(-30), + UpdatedAt = DateTime.Now, + IsWorkspaceValid = true, + IsFeishuActive = true, + IsCustomWorkspace = true + } + ]); + + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository)); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.TemporarilyExitGoalRuntimeAction}}","session_id":"{{sessionId}}","chat_key":"{{chatId}}","show_all_sessions":true}""", + chatId: chatId); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Warning, response.Toast?.Type); + Assert.Contains("无法自动暂停", response.Toast?.Content, StringComparison.Ordinal); + Assert.DoesNotContain(cliExecutor.ExecutedPrompts, prompt => string.Equals(prompt, "/goal pause", StringComparison.Ordinal)); var updatedSession = await sessionRepository.GetByIdAndUsernameAsync(sessionId, "luhaiyan"); Assert.NotNull(updatedSession); @@ -3468,7 +5204,7 @@ public async Task HandleCardActionAsync_SyncSessionProvider_WhenGoalRuntimeTurnA codexAppServerSessionManager: appServerSessionManager)); var response = await service.HandleCardActionAsync( - """{"action":"sync_session_provider","session_id":"session-sync-provider-goal-runtime","chat_key":"oc_workspace_chat"}""", + """{"action":"sync_session_provider","session_id":"session-sync-provider-goal-runtime","chat_key":"oc_workspace_chat","show_all_sessions":true,"session_page":1}""", chatId: chatId); Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Warning, response.Toast?.Type); @@ -3476,6 +5212,8 @@ public async Task HandleCardActionAsync_SyncSessionProvider_WhenGoalRuntimeTurnA var payload = SerializeResponse(response); Assert.Contains("\"action\":\"confirm_sync_session_provider\"", payload, StringComparison.Ordinal); + Assert.Contains("\"show_all_sessions\":true", payload, StringComparison.Ordinal); + Assert.Contains("\"session_page\":1", payload, StringComparison.Ordinal); var cardContents = ExtractCardContentStrings(response); Assert.Contains(cardContents, content => content.Contains("继续当前 goal", StringComparison.Ordinal)); @@ -3864,6 +5602,50 @@ public async Task HandleCardActionAsync_BrowseCurrentSessionDirectory_ReturnsDir } } + [Fact] + public async Task HandleCardActionAsync_BrowseCurrentSessionDirectory_PreservesSessionPageInReturnActions() + { + const string chatId = "oc_workspace_chat"; + const string activeSessionId = "session-files"; + + var workspacePath = Path.Combine(Path.GetTempPath(), $"feishu-browse-pagination-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workspacePath); + Directory.CreateDirectory(Path.Combine(workspacePath, "src")); + await File.WriteAllTextAsync(Path.Combine(workspacePath, "README.md"), "hello from feishu"); + + try + { + var cliExecutor = new RecordingCliExecutorService(); + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var feishuChannel = new StubFeishuChannelService(activeSessionId); + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider()); + + var response = await service.HandleCardActionAsync( + """{"action":"browse_current_session_directory","chat_key":"oc_workspace_chat","show_all_sessions":true,"session_page":1}""", + chatId: chatId, + operatorUserId: "ou_test_user"); + + var payload = SerializeResponse(response); + Assert.Contains( + "\"action\":\"preview_session_file\",\"chat_key\":\"oc_workspace_chat\",\"session_id\":\"session-files\",\"file_path\":\"README.md\",\"directory_path\":\"\",\"page\":0,\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + Assert.Contains( + "\"action\":\"browse_session_directory\",\"chat_key\":\"oc_workspace_chat\",\"session_id\":\"session-files\",\"directory_path\":\"src\",\"page\":0,\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + Assert.Contains( + "\"action\":\"open_session_manager\",\"chat_key\":\"oc_workspace_chat\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspacePath, recursive: true); + } + } + [Fact] public async Task HandleCardActionAsync_BrowseCurrentSessionDirectory_SkipsReservedWindowsDeviceEntries() { @@ -3923,6 +5705,45 @@ public async Task HandleCardActionAsync_PreviewSessionFile_ReturnsTextPreview() } } + [Fact] + public async Task HandleCardActionAsync_PreviewSessionFile_PreservesSessionPageInReturnActions() + { + const string chatId = "oc_workspace_chat"; + const string activeSessionId = "session-files"; + + var workspacePath = Path.Combine(Path.GetTempPath(), $"feishu-preview-pagination-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workspacePath); + await File.WriteAllTextAsync(Path.Combine(workspacePath, "README.md"), "hello from feishu\nsecond line"); + + try + { + var cliExecutor = new RecordingCliExecutorService(); + cliExecutor.SetSessionWorkspacePath(activeSessionId, workspacePath); + + var feishuChannel = new StubFeishuChannelService(activeSessionId); + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider()); + + var response = await service.HandleCardActionAsync( + """{"action":"preview_session_file","chat_key":"oc_workspace_chat","session_id":"session-files","file_path":"README.md","directory_path":"","page":0,"show_all_sessions":true,"session_page":1}""", + chatId: chatId, + operatorUserId: "ou_test_user"); + + var payload = SerializeResponse(response); + Assert.Contains( + "\"action\":\"browse_session_directory\",\"chat_key\":\"oc_workspace_chat\",\"session_id\":\"session-files\",\"directory_path\":\"\",\"page\":0,\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + Assert.Contains( + "\"action\":\"open_session_manager\",\"chat_key\":\"oc_workspace_chat\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspacePath, recursive: true); + } + } + [Fact] public async Task HandleCardActionAsync_BrowseAllowedDirectory_ReturnsWhitelistBrowserCard() { @@ -4256,6 +6077,45 @@ public async Task HandleCardActionAsync_DiscoverExternalCliSessions_ShowsFullCou Assert.Contains("\"page\":1", payload); } + [Fact] + public async Task HandleCardActionAsync_DiscoverExternalCliSessions_PreservesSessionPageInReturnActions() + { + const string chatId = "oc_external_cli_chat"; + + var discovered = Enumerable.Range(1, 15) + .Select(index => new ExternalCliSessionSummary + { + ToolId = "claude-code", + ToolName = "Claude Code", + CliThreadId = $"claude-session-{index:D3}", + Title = $"Claude 会话 {index:D3}", + WorkspacePath = $@"D:\VSWorkshop\allowed\workspace-{index:D3}", + UpdatedAt = new DateTime(2026, 3, 23, 10, 0, 0).AddMinutes(-index) + }) + .ToList(); + + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null); + var serviceProvider = new TestServiceProvider( + externalCliSessionService: new StubExternalCliSessionService(discovered)); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + """{"action":"discover_external_cli_sessions","chat_key":"oc_external_cli_chat","tool_id":"claude-code","show_all_sessions":true,"session_page":1}""", + chatId: chatId, + operatorUserId: "ou_test_user"); + + var payload = SerializeResponse(response); + Assert.Contains( + "\"action\":\"discover_external_cli_sessions\",\"chat_key\":\"oc_external_cli_chat\",\"tool_id\":\"claude-code\",\"page\":1,\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + Assert.Contains( + "\"action\":\"open_session_manager\",\"chat_key\":\"oc_external_cli_chat\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); + } + [Fact] public async Task HandleCardActionAsync_CloseSession_WithMissingWorkspace_ClosesImmediately() { @@ -4344,14 +6204,49 @@ public async Task HandleCardActionAsync_OpenProjectManager_ReturnsProjectListFor var service = CreateService(cliExecutor, feishuChannel, serviceProvider); var response = await service.HandleCardActionAsync( - """{"action":"open_project_manager","chat_key":"oc_project_chat"}""", + """{"action":"open_project_manager","chat_key":"oc_project_chat"}""", + chatId: chatId); + + var payload = SerializeResponse(response); + Assert.Contains("WmsServerV4", payload); + Assert.Contains("create_session_from_project", payload); + Assert.Contains("show_project_branch_switcher", payload); + Assert.Equal("luhaiyan", projectService.LastUsernameSeen); + } + + [Fact] + public async Task HandleCardActionAsync_OpenProjectManager_PreservesSessionPageInReturnAction() + { + const string chatId = "oc_project_chat"; + var userContext = new TestUserContextService(); + var projectService = new TestProjectService(userContext, [ + new ProjectInfo + { + ProjectId = "project-1", + Name = "WmsServerV4", + GitUrl = "http://sql-for-tfs2017:8080/tfs/DefaultCollection/WmsV4/_git/WmsServerV4", + AuthType = "https", + Branch = string.Empty, + Status = "ready", + LocalPath = @"D:\repos\WmsServerV4", + UpdatedAt = DateTime.Now + } + ]); + + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null); + var serviceProvider = new TestServiceProvider(userContext, projectService); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + """{"action":"open_project_manager","chat_key":"oc_project_chat","show_all_sessions":true,"session_page":1}""", chatId: chatId); var payload = SerializeResponse(response); - Assert.Contains("WmsServerV4", payload); - Assert.Contains("create_session_from_project", payload); - Assert.Contains("show_project_branch_switcher", payload); - Assert.Equal("luhaiyan", projectService.LastUsernameSeen); + Assert.Contains( + "\"action\":\"open_session_manager\",\"chat_key\":\"oc_project_chat\",\"show_all_sessions\":true,\"session_page\":1", + payload, + StringComparison.Ordinal); } [Fact] @@ -4729,6 +6624,36 @@ private static List ExtractCardContentStrings(CardActionTriggerResponseD : null; } + private static string? GetStringProperty(object target, string propertyName) + { + return target + .GetType() + .GetProperty(propertyName)? + .GetValue(target) as string; + } + + private static bool GetBooleanProperty(object target, string propertyName) + { + return target + .GetType() + .GetProperty(propertyName)? + .GetValue(target) as bool? + ?? false; + } + + private static void SetBooleanProperty(object target, string propertyName, bool value) + { + target.GetType().GetProperty(propertyName)?.SetValue(target, value); + } + + private static string? ExtractActionCommandValue(CardActionTriggerResponseDto response, string actionName) + { + using var document = JsonDocument.Parse(SerializeResponse(response)); + return TryFindActionCommandValue(document.RootElement, actionName, out var command) + ? command + : null; + } + private static void CollectContentStrings(JsonElement element, List contents) { switch (element.ValueKind) @@ -4758,11 +6683,55 @@ private static void CollectContentStrings(JsonElement element, List cont } } + private static bool TryFindActionCommandValue(JsonElement element, string actionName, out string? command) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + if (element.TryGetProperty("action", out var actionElement) + && actionElement.ValueKind == JsonValueKind.String + && string.Equals(actionElement.GetString(), actionName, StringComparison.Ordinal)) + { + if (element.TryGetProperty("command", out var commandElement) + && commandElement.ValueKind == JsonValueKind.String) + { + command = commandElement.GetString(); + return true; + } + + command = null; + return true; + } + + foreach (var property in element.EnumerateObject()) + { + if (TryFindActionCommandValue(property.Value, actionName, out command)) + { + return true; + } + } + break; + + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + if (TryFindActionCommandValue(item, actionName, out command)) + { + return true; + } + } + break; + } + + command = null; + return false; + } + private static FeishuCardActionService CreateService( RecordingCliExecutorService cliExecutor, StubFeishuChannelService feishuChannel, IServiceProvider serviceProvider, - StubFeishuCardKitClient? cardKit = null, + IFeishuCardKitClient? cardKit = null, StubChatSessionService? chatSessionService = null) { var commandService = new FeishuCommandService( @@ -4804,12 +6773,22 @@ private sealed class RecordingCliExecutorService : ICliExecutorService public string StandardExecutionCompletionContent { get; set; } = string.Empty; + public List? StandardStreamChunks { get; set; } + + public List? StandardStreamChunkDelays { get; set; } + public bool StandardExecutionIsError { get; set; } public string StandardExecutionErrorMessage { get; set; } = "执行失败"; public string LowInterruptionExecutionContent { get; set; } = "continued"; + public string LowInterruptionExecutionMidStreamContent { get; set; } = string.Empty; + + public TimeSpan LowInterruptionExecutionCompletionDelay { get; set; } = TimeSpan.Zero; + + public string LowInterruptionExecutionCompletionContent { get; set; } = string.Empty; + public bool LowInterruptionExecutionIsError { get; set; } public string LowInterruptionExecutionErrorMessage { get; set; } = "执行失败"; @@ -4831,6 +6810,8 @@ private sealed class RecordingCliExecutorService : ICliExecutorService Message = "thread sync complete" }; + public AppServerGoalSnapshot? GoalRuntimeGoal { get; set; } + public TaskCompletionSource? ThreadSyncBlocker { get; set; } public Exception? ThreadSyncException { get; set; } @@ -4861,6 +6842,9 @@ public async Task WaitForExecutionAsync(TimeSpan timeout) return await _executionStarted.Task; } + public Task WaitForExecutionStartedAsync(TimeSpan timeout) + => WaitForExecutionAsync(timeout); + public async Task WaitForExecutionCompletionAsync(TimeSpan timeout) { using var cts = new CancellationTokenSource(timeout); @@ -4949,6 +6933,23 @@ public async IAsyncEnumerable ExecuteStreamAsync( yield break; } + if (StandardStreamChunks is { Count: > 0 }) + { + for (var index = 0; index < StandardStreamChunks.Count; index++) + { + if (StandardStreamChunkDelays is { Count: > 0 } + && index < StandardStreamChunkDelays.Count + && StandardStreamChunkDelays[index] > TimeSpan.Zero) + { + await Task.Delay(StandardStreamChunkDelays[index], cancellationToken); + } + + yield return StandardStreamChunks[index]; + } + + yield break; + } + var hasTrailingCompletionChunk = StandardExecutionCompletionDelay > TimeSpan.Zero || !string.IsNullOrEmpty(StandardExecutionCompletionContent); yield return new StreamOutputChunk @@ -5007,11 +7008,37 @@ public async IAsyncEnumerable ExecuteLowInterruptionContinueS yield break; } + var hasMidStreamChunk = !string.IsNullOrEmpty(LowInterruptionExecutionMidStreamContent); + var hasTrailingCompletionChunk = LowInterruptionExecutionCompletionDelay > TimeSpan.Zero + || !string.IsNullOrEmpty(LowInterruptionExecutionCompletionContent); yield return new StreamOutputChunk { Content = LowInterruptionExecutionContent, - IsCompleted = true + IsCompleted = !hasMidStreamChunk && !hasTrailingCompletionChunk }; + + if (hasMidStreamChunk) + { + yield return new StreamOutputChunk + { + Content = LowInterruptionExecutionMidStreamContent, + IsCompleted = !hasTrailingCompletionChunk + }; + } + + if (hasTrailingCompletionChunk) + { + if (LowInterruptionExecutionCompletionDelay > TimeSpan.Zero) + { + await Task.Delay(LowInterruptionExecutionCompletionDelay, cancellationToken); + } + + yield return new StreamOutputChunk + { + Content = LowInterruptionExecutionCompletionContent, + IsCompleted = true + }; + } } finally { @@ -5076,6 +7103,12 @@ public async Task SyncCodexThreadProviderAsync(st return ThreadSyncResult; } + public Task TryGetGoalRuntimeGoalAsync( + string sessionId, + string? toolId = null, + CancellationToken cancellationToken = default) + => Task.FromResult(GoalRuntimeGoal); + public Task SaveToolEnvironmentVariablesAsync(string toolId, Dictionary envVars, string? username = null) => Task.FromResult(true); @@ -5134,17 +7167,17 @@ public List GetMessages(string sessionId) public void UpdateMessage(string sessionId, string messageId, Action updateAction) { } } - private sealed class RecordingReplyTtsOrchestrator : IReplyTtsOrchestrator + private sealed class RecordingReplyDocumentOrchestrator : IReplyDocumentOrchestrator { - public List Requests { get; } = new(); + public List Requests { get; } = new(); - public TaskCompletionSource WhenQueued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource WhenQueued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public TaskCompletionSource WhenCallbackCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource WhenCallbackCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public Func? OnQueued { get; set; } + public Func? OnQueued { get; set; } - public async Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request) + public async Task QueueCompletedReplyAsync(FeishuCompletedReplyDocumentRequest request) { Requests.Add(request); WhenQueued.TrySetResult(request); @@ -5171,6 +7204,8 @@ private sealed class StubFeishuCardKitClient : IFeishuCardKitClient { private readonly TaskCompletionSource<(string ChatId, string CardJson)> _rawCardSent = new(TaskCreationOptions.RunContinuationsAsynchronously); + public List Handles { get; } = new(); + public FeishuOptions? LastRawCardOptionsOverride { get; private set; } public FeishuStreamingCardChrome? LastStreamingChrome { get; private set; } @@ -5191,7 +7226,21 @@ private sealed class StubFeishuCardKitClient : IFeishuCardKitClient public int? FailUpdateOnAttempt { get; set; } - public int UpdateAttemptCount { get; private set; } + public Queue FailUpdateAttemptSequence { get; } = new(); + + public Queue FailFinishAttemptSequence { get; } = new(); + + public Queue ThrowOverflowOnCreateHandleSequence { get; } = new(); + + public int SendTextCallCount { get; private set; } + + public int ReplyTextCallCount { get; private set; } + + public List SentTextMessages { get; } = []; + + public List RepliedTextMessages { get; } = []; + + public int UpdateAttemptCount => Handles.Sum(handle => handle.UpdateAttemptCount); public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); @@ -5203,13 +7252,21 @@ public Task SendCardMessageAsync(string chatId, string cardId, Cancellat => throw new NotSupportedException(); public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) - => throw new NotSupportedException(); + { + SendTextCallCount++; + SentTextMessages.Add(content); + return Task.FromResult($"message-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(); + { + ReplyTextCallCount++; + RepliedTextMessages.Add(content); + return Task.FromResult($"reply-text-{ReplyTextCallCount}"); + } public Task DownloadIncomingAttachmentAsync( FeishuIncomingAttachment attachment, @@ -5219,28 +7276,60 @@ public Task DownloadIncomingAttachmentAsync( public Task CreateStreamingHandleAsync(string chatId, string? replyMessageId, string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, FeishuStreamingCardChrome? chrome = null) { + if (ThrowOverflowOnCreateHandleSequence.Count > 0 && ThrowOverflowOnCreateHandleSequence.Dequeue()) + { + throw new InvalidOperationException("Create CardKit card failed: card over max size (code: 200860)"); + } + + var failUpdateOnAttempt = FailUpdateAttemptSequence.Count > 0 + ? FailUpdateAttemptSequence.Dequeue() + : FailUpdateOnAttempt; + var failFinishOnAttempt = FailFinishAttemptSequence.Count > 0 + ? FailFinishAttemptSequence.Dequeue() + : null; + LastStreamingChrome = chrome; - InitialStreamingChromeSnapshot = CloneChrome(chrome); - InitialStreamingStatusMarkdown = chrome?.StatusMarkdown; - if (!string.IsNullOrWhiteSpace(InitialStreamingStatusMarkdown)) + var record = new StreamingHandleRecord + { + CardId = $"card-{Handles.Count + 1}", + MessageId = $"message-{Handles.Count + 1}", + InitialContent = initialContent, + ReplyMessageId = replyMessageId, + Chrome = chrome, + InitialStatusMarkdown = chrome?.StatusMarkdown, + InitialChromeSnapshot = CloneChrome(chrome) + }; + + if (Handles.Count == 0) + { + InitialStreamingChromeSnapshot = record.InitialChromeSnapshot; + InitialStreamingStatusMarkdown = record.InitialStatusMarkdown; + } + + if (!string.IsNullOrWhiteSpace(record.InitialStatusMarkdown)) { - StreamingStatusMarkdownSnapshots.Add(InitialStreamingStatusMarkdown); + record.StatusMarkdownSnapshots.Add(record.InitialStatusMarkdown); + StreamingStatusMarkdownSnapshots.Add(record.InitialStatusMarkdown); } + Handles.Add(record); + return Task.FromResult(new FeishuStreamingHandle( - "card-1", - "message-1", + record.CardId, + record.MessageId, (content, _) => { - UpdateAttemptCount++; - if (FailUpdateOnAttempt.HasValue && UpdateAttemptCount >= FailUpdateOnAttempt.Value) + record.UpdateAttemptCount++; + if (failUpdateOnAttempt.HasValue && record.UpdateAttemptCount >= failUpdateOnAttempt.Value) { return Task.FromResult(false); } + record.Updates.Add(content); StreamingUpdates.Add(content); if (!string.IsNullOrWhiteSpace(chrome?.StatusMarkdown)) { + record.StatusMarkdownSnapshots.Add(chrome.StatusMarkdown); StreamingStatusMarkdownSnapshots.Add(chrome.StatusMarkdown); } @@ -5248,12 +7337,24 @@ public Task CreateStreamingHandleAsync(string chatId, str }, (content, _) => { + record.FinishAttemptCount++; + if (failFinishOnAttempt.HasValue && record.FinishAttemptCount == failFinishOnAttempt.Value) + { + return Task.FromResult(false); + } + + record.FinalContent = content; + record.FinalChromeSnapshot = CloneChrome(chrome); + record.FinalStatusMarkdown = chrome?.StatusMarkdown; + FinalStreamingContent = content; - FinalStreamingChromeSnapshot = CloneChrome(chrome); - FinalStreamingStatusMarkdown = chrome?.StatusMarkdown; - if (!string.IsNullOrWhiteSpace(FinalStreamingStatusMarkdown)) + FinalStreamingChromeSnapshot = record.FinalChromeSnapshot; + FinalStreamingStatusMarkdown = record.FinalStatusMarkdown; + + if (!string.IsNullOrWhiteSpace(record.FinalStatusMarkdown)) { - StreamingStatusMarkdownSnapshots.Add(FinalStreamingStatusMarkdown); + record.StatusMarkdownSnapshots.Add(record.FinalStatusMarkdown); + StreamingStatusMarkdownSnapshots.Add(record.FinalStatusMarkdown); } return Task.FromResult(true); @@ -5289,6 +7390,12 @@ public Task ReplyRawCardAsync(string replyMessageId, string cardJson, Ca return await _rawCardSent.Task; } + public List GetAllFinalStreamingContents() + => Handles + .Where(handle => !string.IsNullOrWhiteSpace(handle.FinalContent)) + .Select(handle => handle.FinalContent!) + .ToList(); + private static FeishuStreamingCardChrome? CloneChrome(FeishuStreamingCardChrome? chrome) { if (chrome == null) @@ -5571,6 +7678,12 @@ public Task> GetRecentMessagesAsync( LastMaxCount = maxCount; return Task.FromResult(_messages.TakeLast(maxCount).ToList()); } + + public Task GetCodexFinalAnswerTextAsync( + string cliThreadId, + string? workspacePath = null, + CancellationToken cancellationToken = default) + => Task.FromResult(null); } [Fact] @@ -5619,6 +7732,202 @@ await service.HandleCardActionAsync( } } + private sealed class BackgroundReplacementTokenAwareFeishuCardKitClient : IFeishuCardKitClient + { + public List Handles { get; } = new(); + + public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult($"card-{Handles.Count + 1}"); + + public Task UpdateCardAsync(string cardId, string content, int sequence, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult(true); + + public Task SendCardMessageAsync(string chatId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult($"message-{cardId}"); + + public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult("message-text-1"); + + public Task ReplyCardMessageAsync(string replyMessageId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult($"reply-{cardId}"); + + public Task ReplyTextMessageAsync(string replyMessageId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult("reply-text-1"); + + public Task DownloadIncomingAttachmentAsync( + FeishuIncomingAttachment attachment, + 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) + { + var isReplacementHandle = Handles.Count > 0; + var record = new StreamingHandleRecord + { + CardId = $"card-{Handles.Count + 1}", + MessageId = $"message-{Handles.Count + 1}", + ReplyMessageId = replyMessageId, + InitialContent = initialContent, + InitialStatusMarkdown = chrome?.StatusMarkdown + }; + Handles.Add(record); + + return Task.FromResult(new FeishuStreamingHandle( + record.CardId, + record.MessageId, + (content, _) => + { + record.UpdateAttemptCount++; + if (!isReplacementHandle && record.UpdateAttemptCount >= 2) + { + return Task.FromResult(false); + } + + record.Updates.Add(content); + return Task.FromResult(true); + }, + (content, _) => + { + record.FinishAttemptCount++; + if (isReplacementHandle && cancellationToken.IsCancellationRequested) + { + return Task.FromResult(false); + } + + record.FinalContent = content; + record.FinalStatusMarkdown = chrome?.StatusMarkdown; + return Task.FromResult(true); + }, + throttleMs: 0)); + } + + 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(); + + public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync( + string messageId, + string fileKey, + string resourceType, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + } + + private sealed class StreamingHandleRecord + { + public string CardId { get; init; } = string.Empty; + + public string MessageId { get; init; } = string.Empty; + + public string? ReplyMessageId { get; init; } + + public string InitialContent { get; init; } = string.Empty; + + public FeishuStreamingCardChrome? Chrome { get; init; } + + public FeishuStreamingCardChrome? InitialChromeSnapshot { get; init; } + + public string? InitialStatusMarkdown { get; init; } + + public List Updates { get; } = new(); + + public List StatusMarkdownSnapshots { get; } = new(); + + public int UpdateAttemptCount { get; set; } + + public int FinishAttemptCount { get; set; } + + public string? FinalContent { get; set; } + + public string? FinalStatusMarkdown { get; set; } + + public FeishuStreamingCardChrome? FinalChromeSnapshot { get; set; } + } + + [Fact] + public async Task HandleCardActionAsync_ResumeGoal_WhenGoalStillActive_UsesGoalAwareCompletionNotice() + { + const string chatId = "oc_goal_completion_chat"; + const string sessionId = "session-goal-completion-running"; + + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-goal-completion-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + try + { + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "已恢复 goal,正在继续推进...", + StandardExecutionCompletionContent = "阶段性结论", + GoalRuntimeGoal = new AppServerGoalSnapshot("ship this task", "active", 200, 12, 34) + }; + cliExecutor.SetSessionWorkspacePath(sessionId, workspacePath); + + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + Title = "MMIS 前端中文", + ToolId = "codex", + WorkspacePath = workspacePath, + FeishuChatKey = chatId, + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new() { UseGoalRuntime = true } + }), + IsWorkspaceValid = true, + IsFeishuActive = true, + CreatedAt = DateTime.Now.AddMinutes(-10), + UpdatedAt = DateTime.Now + } + ]); + + var cardKit = new StubFeishuCardKitClient(); + var feishuChannel = new StubFeishuChannelService(sessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(chatSessionRepository: sessionRepository), + cardKit); + + await service.HandleCardActionAsync( + """{"action":"resume_goal"}""", + chatId: chatId, + operatorUserId: "ou_test_user"); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(3)); + var completionMessage = await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Contains("本轮执行已结束,Goal 仍在运行", completionMessage.Content, StringComparison.Ordinal); + Assert.DoesNotContain("\n已完成", completionMessage.Content, StringComparison.Ordinal); + Assert.Contains("Goal继续中", cardKit.FinalStreamingStatusMarkdown, StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + private sealed class StubExternalCliSessionService(IEnumerable sessions) : IExternalCliSessionService { @@ -5693,7 +8002,7 @@ private sealed class TestServiceProvider : IServiceProvider, IServiceScopeFactor private readonly ISuperpowersCapabilityService _superpowersCapabilityService; private readonly IGoalCapabilityService _goalCapabilityService; private readonly ICodexAppServerSessionManager? _codexAppServerSessionManager; - private readonly IReplyTtsOrchestrator? _replyTtsOrchestrator; + private readonly IReplyDocumentOrchestrator? _replyTtsOrchestrator; private readonly IMessageSubmissionService _messageSubmissionService; private readonly IFeishuAttachmentDraftService _attachmentDraftService; @@ -5707,7 +8016,7 @@ public TestServiceProvider( ISuperpowersCapabilityService? superpowersCapabilityService = null, IGoalCapabilityService? goalCapabilityService = null, ICodexAppServerSessionManager? codexAppServerSessionManager = null, - IReplyTtsOrchestrator? replyTtsOrchestrator = null, + IReplyDocumentOrchestrator? replyTtsOrchestrator = null, StubUserFeishuBotConfigService? feishuBotConfigService = null, IMessageSubmissionService? messageSubmissionService = null, IFeishuAttachmentDraftService? attachmentDraftService = null) @@ -5794,7 +8103,7 @@ public TestServiceProvider( return _codexAppServerSessionManager; } - if (serviceType == typeof(IReplyTtsOrchestrator)) + if (serviceType == typeof(IReplyDocumentOrchestrator)) { return _replyTtsOrchestrator; } @@ -5908,6 +8217,12 @@ public Task InterruptActiveTurnAsync( public Task InterruptActiveTurnAsync(string sessionId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public bool HasRunningSession(string sessionId, string? threadId = null) + => false; + + public string? GetRunningThreadId(string sessionId) + => null; + public bool HasActiveTurn(string sessionId) => _activeTurnSessionIds.Contains(sessionId); @@ -6453,7 +8768,7 @@ public Task GetEffectiveOptionsAsync(string? username) private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity config) { - return new UserFeishuBotConfigEntity + var clone = new UserFeishuBotConfigEntity { Id = config.Id, Username = config.Username, @@ -6467,12 +8782,22 @@ private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity config) ThinkingMessage = config.ThinkingMessage, HttpTimeoutSeconds = config.HttpTimeoutSeconds, StreamingThrottleMs = config.StreamingThrottleMs, - ReplyTtsEnabled = config.ReplyTtsEnabled, - ReplyTtsVoiceId = config.ReplyTtsVoiceId, + FullReplyDocEnabled = config.FullReplyDocEnabled, + FinalReplyDocEnabled = config.FinalReplyDocEnabled, + AudioFullReplyDocEnabled = config.AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = config.AudioFinalReplyDocEnabled, + ReferencedMarkdownDocImportEnabled = config.ReferencedMarkdownDocImportEnabled, + LegacyReplyTtsEnabled = config.LegacyReplyTtsEnabled, + LegacyReplyTtsMode = config.LegacyReplyTtsMode, + LegacyReplyTtsVoiceId = config.LegacyReplyTtsVoiceId, LastStartedAt = config.LastStartedAt, CreatedAt = config.CreatedAt, UpdatedAt = config.UpdatedAt }; + + var documentAdminProperty = typeof(UserFeishuBotConfigEntity).GetProperty("DocumentAdminOpenId"); + documentAdminProperty?.SetValue(clone, documentAdminProperty.GetValue(config)); + return clone; } } diff --git a/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs b/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs index cda9250..614efa3 100644 --- a/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Text.Json; using FeishuNetSdk.Im.Dtos; using Microsoft.Extensions.Logging.Abstractions; @@ -13,385 +13,941 @@ namespace WebCodeCli.Domain.Tests; public class FeishuCardKitClientTests { [Fact] - public async Task UploadAudioFileAsync_PostsMultipartFormDataWithDuration() + public async Task CreateCloudDocumentAsync_PostsDocxCreateRequestAndReturnsDocumentInfo() { - var tempFile = Path.GetTempFileName(); - await File.WriteAllTextAsync(tempFile, "opus", TestContext.Current.CancellationToken); + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"document":{"document_id":"doccn123","root_block_id":"root123"}}}""") + ]); - 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 client = CreateClient(handler); + var document = await client.CreateCloudDocumentAsync("thread-1 缁х画 - 瀹屾暣鍥炲", TestContext.Current.CancellationToken); - var fileKey = await client.UploadAudioFileAsync(tempFile, 3200, TestContext.Current.CancellationToken); + Assert.Equal("doccn123", document.DocumentId); + Assert.Equal("root123", document.RootBlockId); + Assert.Equal("https://feishu.cn/docx/doccn123", document.Url); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/docx/v1/documents" + ], handler.RequestPaths); - 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); - } + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal("thread-1 缁х画 - 瀹屾暣鍥炲", requestDoc.RootElement.GetProperty("title").GetString()); + Assert.False(requestDoc.RootElement.TryGetProperty("folder_token", out _)); } [Fact] - public async Task SendAudioMessageAsync_SendsAudioPayload() + public async Task CreateCloudDocumentAsync_WhenFolderTokenProvided_PostsFolderTokenInDocxCreateRequest() { var handler = new StubHttpMessageHandler( [ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), - CreateJsonResponse("""{"code":0,"data":{"message_id":"om_audio_success"}}""") + CreateJsonResponse("""{"code":0,"data":{"document":{"document_id":"doccn123","root_block_id":"root123"}}}""") ]); var client = CreateClient(handler); - var messageId = await client.SendAudioMessageAsync("oc_audio_chat", "file_v2_123", 3200, TestContext.Current.CancellationToken); + var document = await client.CreateCloudDocumentAsync( + "thread-1 缁х画 - 瀹屾暣鍥炲", + TestContext.Current.CancellationToken, + folderToken: "fld-target"); + + Assert.Equal("doccn123", document.DocumentId); + Assert.Equal("root123", document.RootBlockId); + + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal("thread-1 缁х画 - 瀹屾暣鍥炲", requestDoc.RootElement.GetProperty("title").GetString()); + Assert.Equal("fld-target", requestDoc.RootElement.GetProperty("folder_token").GetString()); + } + + [Fact] + public async Task AppendCloudDocumentTextAsync_PostsTextChildrenRequest() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"children":[{"block_id":"blk1"}]}}""") + ]); + + var client = CreateClient(handler); + + await client.AppendCloudDocumentTextAsync( + "doccn123", + "root123", + "缁撹姝f枃", + TestContext.Current.CancellationToken); - Assert.Equal("om_audio_success", messageId); Assert.Equal( [ "/open-apis/auth/v3/tenant_access_token/internal", - "/open-apis/im/v1/messages" + "/open-apis/docx/v1/documents/doccn123/blocks/root123/children" ], 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()); + var children = requestDoc.RootElement.GetProperty("children"); + Assert.Equal(1, children.GetArrayLength()); + Assert.Equal(2, children[0].GetProperty("block_type").GetInt32()); + Assert.Equal("缁撹姝f枃", children[0].GetProperty("text").GetProperty("elements")[0].GetProperty("text_run").GetProperty("content").GetString()); + Assert.Equal(JsonValueKind.Object, children[0].GetProperty("text").GetProperty("elements")[0].GetProperty("text_run").GetProperty("text_element_style").ValueKind); } [Fact] - public async Task DownloadMessageResourceAsync_GetsBinaryBodyAndInfersFileName() + public async Task SetCloudDocumentTenantReadableAsync_PatchesDrivePermission() { - var imageBytes = new byte[] { 1, 2, 3, 4 }; var handler = new StubHttpMessageHandler( [ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(imageBytes) - { - Headers = - { - ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"), - ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment") - { - FileName = "\"screen.png\"" - } - } - } - } + CreateJsonResponse("""{"code":0,"data":{}}""") ]); var client = CreateClient(handler); - var result = await client.DownloadMessageResourceAsync( - "om_message_123", - "img_v2_123", - "image", - TestContext.Current.CancellationToken); + await client.SetCloudDocumentTenantReadableAsync("doccn123", TestContext.Current.CancellationToken); - Assert.Equal(imageBytes, result.Content); - Assert.Equal("screen.png", result.FileName); - Assert.Equal("image/png", result.MimeType); Assert.Equal( [ "/open-apis/auth/v3/tenant_access_token/internal", - "/open-apis/im/v1/messages/om_message_123/resources/img_v2_123" + "/open-apis/drive/v2/permissions/doccn123/public" ], handler.RequestPaths); - Assert.Equal("type=image", handler.RequestQueries[1]); + Assert.Equal("type=docx", handler.RequestQueries[1]); + Assert.Equal("PATCH", handler.RequestMethods[1]); + + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal("open", requestDoc.RootElement.GetProperty("external_access_entity").GetString()); + Assert.Equal("anyone_can_view", requestDoc.RootElement.GetProperty("security_entity").GetString()); } [Fact] - public async Task SendTextMessageAsync_SendsTextPayload() + public async Task GrantCloudDocumentMemberFullAccessAsync_PostsPermissionMemberRequest() { var handler = new StubHttpMessageHandler( [ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), - CreateJsonResponse("""{"code":0,"data":{"message_id":"om_text_success"}}""") + CreateJsonResponse("""{"code":0,"data":{"member_id":"ou_doc_admin"}}""") ]); - var client = CreateClient(handler); + dynamic client = CreateClient(handler); - var messageId = await client.SendTextMessageAsync("oc_text_chat", "已完成", TestContext.Current.CancellationToken); + await client.GrantCloudDocumentMemberFullAccessAsync( + "doccn123", + "ou_doc_admin", + TestContext.Current.CancellationToken); - Assert.Equal("om_text_success", messageId); Assert.Equal( [ "/open-apis/auth/v3/tenant_access_token/internal", - "/open-apis/im/v1/messages" + "/open-apis/drive/v1/permissions/doccn123/members" ], handler.RequestPaths); + Assert.Equal("type=docx", handler.RequestQueries[1]); + Assert.Equal("POST", handler.RequestMethods[1]); using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); - Assert.Equal("text", requestDoc.RootElement.GetProperty("msg_type").GetString()); - Assert.Equal("oc_text_chat", requestDoc.RootElement.GetProperty("receive_id").GetString()); - - using var contentDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("content").GetString()!); - Assert.Equal("已完成", contentDoc.RootElement.GetProperty("text").GetString()); + Assert.Equal("ou_doc_admin", requestDoc.RootElement.GetProperty("member_id").GetString()); + Assert.Equal("openid", requestDoc.RootElement.GetProperty("member_type").GetString()); + Assert.Equal("full_access", requestDoc.RootElement.GetProperty("perm").GetString()); + Assert.Equal("container", requestDoc.RootElement.GetProperty("perm_type").GetString()); + Assert.Equal("user", requestDoc.RootElement.GetProperty("type").GetString()); } [Fact] - public async Task ReplyTextMessageAsync_SendsTextPayload() + public async Task GrantCloudFolderMemberFullAccessAsync_PostsPermissionMemberRequest() { var handler = new StubHttpMessageHandler( [ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), - CreateJsonResponse("""{"code":0,"data":{"message_id":"om_text_reply_success"}}""") + CreateJsonResponse("""{"code":0,"data":{"member_id":"ou_doc_admin"}}""") ]); - var client = CreateClient(handler); + dynamic client = CreateClient(handler); - var messageId = await client.ReplyTextMessageAsync("om_reply", "已完成", TestContext.Current.CancellationToken); + await client.GrantCloudFolderMemberFullAccessAsync( + "fld_123", + "ou_doc_admin", + TestContext.Current.CancellationToken); - Assert.Equal("om_text_reply_success", messageId); Assert.Equal( [ "/open-apis/auth/v3/tenant_access_token/internal", - "/open-apis/im/v1/messages/om_reply/reply" + "/open-apis/drive/v1/permissions/fld_123/members" ], handler.RequestPaths); + Assert.Equal("type=folder", handler.RequestQueries[1]); + Assert.Equal("POST", handler.RequestMethods[1]); using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); - Assert.Equal("text", requestDoc.RootElement.GetProperty("msg_type").GetString()); - - using var contentDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("content").GetString()!); - Assert.Equal("已完成", contentDoc.RootElement.GetProperty("text").GetString()); + Assert.Equal("ou_doc_admin", requestDoc.RootElement.GetProperty("member_id").GetString()); + Assert.Equal("openid", requestDoc.RootElement.GetProperty("member_type").GetString()); + Assert.Equal("full_access", requestDoc.RootElement.GetProperty("perm").GetString()); + Assert.Equal("container", requestDoc.RootElement.GetProperty("perm_type").GetString()); + Assert.Equal("user", requestDoc.RootElement.GetProperty("type").GetString()); } [Fact] - public async Task ReplyRawCardAsync_Throws_WhenReplyReturnsBusinessError() + public async Task EnsureCloudFolderAsync_WhenFolderExists_ReturnsExistingFolderToken() { var handler = new StubHttpMessageHandler( [ CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), - CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""), - CreateJsonResponse("""{"code":10002,"msg":"invalid card payload"}""") + CreateJsonResponse("""{"code":0,"data":{"token":"fld-root"}}"""), + CreateJsonResponse("""{"code":0,"data":{"files":[{"name":"session-folder","token":"fld-existing","type":"folder"}],"has_more":false}}""") ]); var client = CreateClient(handler); - var exception = await Assert.ThrowsAsync(() => - client.ReplyRawCardAsync( - "om_reply", - """{"schema":"2.0","body":{"elements":[]}}""", - TestContext.Current.CancellationToken)); + var folderToken = await client.EnsureCloudFolderAsync("session-folder", TestContext.Current.CancellationToken); - Assert.Contains("Reply raw Feishu card message failed", exception.Message); + Assert.Equal("fld-existing", folderToken); Assert.Equal( [ "/open-apis/auth/v3/tenant_access_token/internal", - "/open-apis/cardkit/v1/cards", - "/open-apis/im/v1/messages/om_reply/reply" + "/open-apis/drive/explorer/v2/root_folder/meta", + "/open-apis/drive/v1/files" ], handler.RequestPaths); + Assert.Equal("GET", handler.RequestMethods[1]); + Assert.Equal("GET", handler.RequestMethods[2]); + Assert.Contains("folder_token=fld-root", handler.RequestQueries[2], StringComparison.Ordinal); } [Fact] - public async Task ReplyElementsCardAsync_CreatesCardThenRepliesWithCardId() + public async Task EnsureCloudFolderAsync_WhenFolderMissing_CreatesFolderUnderRoot() { 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_reply_success"}}""") + CreateJsonResponse("""{"code":0,"data":{"token":"fld-root"}}"""), + CreateJsonResponse("""{"code":0,"data":{"files":[],"has_more":false}}"""), + CreateJsonResponse("""{"code":0,"data":{"token":"fld-created","url":"https://feishu.cn/drive/folder/fld-created"}}""") ]); var client = CreateClient(handler); - var card = new ElementsCardV2Dto - { - Header = new ElementsCardV2Dto.HeaderSuffix - { - Template = "blue", - Title = new HeaderTitleElement { Content = "Help card" } - }, - Body = new ElementsCardV2Dto.BodySuffix - { - Elements = - [ - new - { - tag = "div", - text = new { tag = "plain_text", content = "hello" } - } - ] - } - }; - var messageId = await client.ReplyElementsCardAsync("om_reply", card, TestContext.Current.CancellationToken); + var folderToken = await client.EnsureCloudFolderAsync("session-folder", TestContext.Current.CancellationToken); - Assert.Equal("om_reply_success", messageId); + Assert.Equal("fld-created", folderToken); Assert.Equal( [ "/open-apis/auth/v3/tenant_access_token/internal", - "/open-apis/cardkit/v1/cards", - "/open-apis/im/v1/messages/om_reply/reply" + "/open-apis/drive/explorer/v2/root_folder/meta", + "/open-apis/drive/v1/files", + "/open-apis/drive/v1/files/create_folder" ], handler.RequestPaths); - using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]); - Assert.Equal("card_json", createDoc.RootElement.GetProperty("type").GetString()); - Assert.Equal(JsonValueKind.String, createDoc.RootElement.GetProperty("data").ValueKind); + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[3]); + Assert.Equal("fld-root", requestDoc.RootElement.GetProperty("folder_token").GetString()); + Assert.Equal("session-folder", requestDoc.RootElement.GetProperty("name").GetString()); + } - using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); - Assert.Equal("2.0", cardDoc.RootElement.GetProperty("schema").GetString()); - Assert.Equal("blue", cardDoc.RootElement.GetProperty("header").GetProperty("template").GetString()); - Assert.Equal("Help card", cardDoc.RootElement.GetProperty("header").GetProperty("title").GetProperty("content").GetString()); - Assert.Equal("div", cardDoc.RootElement.GetProperty("body").GetProperty("elements")[0].GetProperty("tag").GetString()); + [Fact] + public async Task MoveCloudDocumentToFolderAsync_PostsDriveMoveRequest() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"task_id":"7360595374803812356"}}""") + ]); - using var requestDoc = JsonDocument.Parse(handler.RequestBodies[2]); - Assert.Equal("interactive", requestDoc.RootElement.GetProperty("msg_type").GetString()); - Assert.Equal(JsonValueKind.String, requestDoc.RootElement.GetProperty("content").ValueKind); + var client = CreateClient(handler); - using var replyDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("content").GetString()!); - Assert.Equal("card", replyDoc.RootElement.GetProperty("type").GetString()); - Assert.Equal("card_123", replyDoc.RootElement.GetProperty("data").GetProperty("card_id").GetString()); + await client.MoveCloudDocumentToFolderAsync("doccn123", "fld-target", TestContext.Current.CancellationToken); + + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/drive/v1/files/doccn123/move" + ], handler.RequestPaths); + Assert.Equal("POST", handler.RequestMethods[1]); + + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal("fld-target", requestDoc.RootElement.GetProperty("folder_token").GetString()); + Assert.Equal("docx", requestDoc.RootElement.GetProperty("type").GetString()); } [Fact] - public async Task CreateStreamingHandleAsync_FallsBackToReadableChineseStatusHeader() + public async Task ConvertMarkdownToCloudDocumentBlocksAsync_PostsMarkdownConvertRequestAndReturnsBlocks() { 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"}}""") + CreateJsonResponse("""{"code":0,"data":{"first_level_block_ids":["blk-heading"],"blocks":[{"block_id":"blk-heading","block_type":3,"children":[],"heading1":{"elements":[{"text_run":{"content":"标题","text_element_style":{}}}]}}]}}""") ]); var client = CreateClient(handler); - var chrome = new FeishuStreamingCardChrome(); - chrome.OverflowOptions.Add(new FeishuStreamingCardOverflowOption - { - Text = "Backend API", - Value = new { action = "switch_session", session_id = "session-2", chat_key = "oc_stream_chat" } - }); - chrome.OverflowOptions.Add(new FeishuStreamingCardOverflowOption - { - Text = "模型/会话管理...", - Value = new { action = "open_session_manager" } - }); - await client.CreateStreamingHandleAsync( - "oc_stream_chat", - null, - "still have backlog", - "AI 助手", - TestContext.Current.CancellationToken, - chrome: chrome); + var blocksData = await client.ConvertMarkdownToCloudDocumentBlocksAsync( + "# 标题", + TestContext.Current.CancellationToken); - using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]); - using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); - Assert.False(cardDoc.RootElement.GetProperty("config").TryGetProperty("streaming_mode", out _)); - var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); - var statusModule = elements[0]; - var overflow = statusModule.GetProperty("extra"); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/docx/v1/documents/blocks/convert" + ], handler.RequestPaths); - Assert.Equal("当前会话", statusModule.GetProperty("text").GetProperty("content").GetString()); - Assert.Equal("overflow", overflow.GetProperty("tag").GetString()); - Assert.Equal("Backend API", overflow.GetProperty("options")[0].GetProperty("text").GetProperty("content").GetString()); - Assert.Equal("{\"action\":\"switch_session\",\"session_id\":\"session-2\",\"chat_key\":\"oc_stream_chat\"}", overflow.GetProperty("options")[0].GetProperty("value").GetString()); + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal("markdown", requestDoc.RootElement.GetProperty("content_type").GetString()); + Assert.Equal("# 标题", requestDoc.RootElement.GetProperty("content").GetString()); + + Assert.Equal("blk-heading", blocksData.GetProperty("first_level_block_ids")[0].GetString()); + Assert.Equal(3, blocksData.GetProperty("blocks")[0].GetProperty("block_type").GetInt32()); + Assert.Equal("标题", blocksData.GetProperty("blocks")[0].GetProperty("heading1").GetProperty("elements")[0].GetProperty("text_run").GetProperty("content").GetString()); } [Fact] - public async Task CreateStreamingHandleAsync_RendersBottomPromptForm() + public async Task AppendCloudDocumentBlocksAsync_PostsProvidedBlocksToChildrenEndpoint() { 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"}}""") + CreateJsonResponse("""{"code":0,"data":{"children":[{"block_id":"blk-created"}]}}""") ]); var client = CreateClient(handler); - var chrome = new FeishuStreamingCardChrome - { - StatusMarkdown = "当前会话" - }; - chrome.BottomPrompt = new FeishuStreamingCardBottomPrompt - { - InputName = LowInterruptionContinueDefaults.PromptFieldName, - InputLabel = "少打断提示词", - Placeholder = LowInterruptionContinueDefaults.PromptPlaceholder, - DefaultValue = LowInterruptionContinueDefaults.DefaultPrompt, - ButtonText = "少打断执行", - ButtonType = "primary", - Value = new - { - action = "low_interruption_continue", - session_id = "session-1", - chat_key = "oc_stream_chat", - tool_id = "codex" - } - }; + var blocks = ParseJsonElementArray("""[{"block_id":"blk-source","block_type":2,"children":[],"text":{"elements":[{"text_run":{"content":"转换后的段落","text_element_style":{}}}]}}]"""); - await client.CreateStreamingHandleAsync( - "oc_stream_chat", - null, - "still have backlog", - "AI 助手", - TestContext.Current.CancellationToken, - chrome: chrome); + await client.AppendCloudDocumentBlocksAsync( + "doccn123", + "root123", + blocks, + TestContext.Current.CancellationToken); - 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( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/docx/v1/documents/doccn123/blocks/root123/children" + ], handler.RequestPaths); - Assert.Equal("form", bottomActionModule.GetProperty("tag").GetString()); + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + var children = requestDoc.RootElement.GetProperty("children"); + Assert.Equal(1, children.GetArrayLength()); + Assert.False(children[0].TryGetProperty("block_id", out _)); + Assert.Equal(2, children[0].GetProperty("block_type").GetInt32()); + Assert.Equal("转换后的段落", children[0].GetProperty("text").GetProperty("elements")[0].GetProperty("text_run").GetProperty("content").GetString()); + Assert.False(children[0].TryGetProperty("children", out _)); + } - var buttonRow = bottomActionModule.GetProperty("elements")[0]; - Assert.Equal("column_set", buttonRow.GetProperty("tag").GetString()); + [Fact] + public async Task AppendCloudDocumentBlocksAsync_WhenMoreThanFiftyBlocks_BatchesChildrenRequests() + { + var responses = new List + { + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}""") + }; + responses.AddRange( + [ + CreateJsonResponse("""{"code":0,"data":{"children":[{"block_id":"blk-created-1"}]}}"""), + CreateJsonResponse("""{"code":0,"data":{"children":[{"block_id":"blk-created-2"}]}}""") + ]); - var input = buttonRow.GetProperty("columns")[0].GetProperty("elements")[0]; - Assert.Equal("input", input.GetProperty("tag").GetString()); - Assert.Equal(LowInterruptionContinueDefaults.PromptFieldName, input.GetProperty("name").GetString()); - Assert.Equal(LowInterruptionContinueDefaults.DefaultPrompt, input.GetProperty("default_value").GetString()); + var handler = new StubHttpMessageHandler(responses); + var client = CreateClient(handler); + var blocks = Enumerable.Range(1, 51) + .Select(index => ParseJsonElementArray( + "[{\"block_id\":\"blk-" + index + "\",\"block_type\":2,\"children\":[],\"text\":{\"elements\":[{\"text_run\":{\"content\":\"段落" + index + "\",\"text_element_style\":{}}}]}}]")[0]) + .ToArray(); - var button = buttonRow.GetProperty("columns")[1].GetProperty("elements")[0]; - Assert.Equal("button", button.GetProperty("tag").GetString()); - Assert.Equal("primary", button.GetProperty("type").GetString()); - Assert.Equal("少打断执行", button.GetProperty("text").GetProperty("content").GetString()); - Assert.Equal("form_submit", button.GetProperty("action_type").GetString()); - Assert.Equal("low_interruption_continue", button.GetProperty("value").GetProperty("action").GetString()); + await client.AppendCloudDocumentBlocksAsync( + "doccn123", + "root123", + blocks, + TestContext.Current.CancellationToken); + + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/docx/v1/documents/doccn123/blocks/root123/children", + "/open-apis/docx/v1/documents/doccn123/blocks/root123/children" + ], handler.RequestPaths); + + using var firstRequestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + using var secondRequestDoc = JsonDocument.Parse(handler.RequestBodies[2]); + var firstChildren = firstRequestDoc.RootElement.GetProperty("children"); + var secondChildren = secondRequestDoc.RootElement.GetProperty("children"); + Assert.Equal(50, firstChildren.GetArrayLength()); + Assert.Equal(1, secondChildren.GetArrayLength()); + Assert.Equal(0, firstRequestDoc.RootElement.GetProperty("index").GetInt32()); + Assert.Equal(50, secondRequestDoc.RootElement.GetProperty("index").GetInt32()); + Assert.Equal("段落1", firstChildren[0].GetProperty("text").GetProperty("elements")[0].GetProperty("text_run").GetProperty("content").GetString()); + Assert.Equal("段落50", firstChildren[49].GetProperty("text").GetProperty("elements")[0].GetProperty("text_run").GetProperty("content").GetString()); + Assert.Equal("段落51", secondChildren[0].GetProperty("text").GetProperty("elements")[0].GetProperty("text_run").GetProperty("content").GetString()); } [Fact] - public async Task CreateStreamingHandleAsync_UsesUniqueSubmitButtonNames_ForMultipleBottomPrompts() + public async Task ListCloudDocumentChildBlockIdsAsync_WhenChildrenSpanMultiplePages_ReturnsAllBlockIdsInOrder() { 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"}}""") + CreateJsonResponse("""{"code":0,"data":{"items":[{"block_id":"blk-1"},{"block_id":"blk-2"}],"has_more":true,"page_token":"next-page"}}"""), + CreateJsonResponse("""{"code":0,"data":{"items":[{"block_id":"blk-3"}],"has_more":false}}""") ]); var client = CreateClient(handler); - var chrome = new FeishuStreamingCardChrome + + var blockIds = await client.ListCloudDocumentChildBlockIdsAsync( + "doccn123", + "root123", + TestContext.Current.CancellationToken); + + Assert.Equal(["blk-1", "blk-2", "blk-3"], blockIds); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/docx/v1/documents/doccn123/blocks/root123/children", + "/open-apis/docx/v1/documents/doccn123/blocks/root123/children" + ], handler.RequestPaths); + Assert.Contains("page_size=500", handler.RequestQueries[1], StringComparison.Ordinal); + Assert.Contains("page_token=next-page", handler.RequestQueries[2], StringComparison.Ordinal); + } + + [Fact] + public async Task DeleteCloudDocumentChildBlocksAsync_DeletesRequestedRange() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{}}""") + ]); + + var client = CreateClient(handler); + + await client.DeleteCloudDocumentChildBlocksAsync( + "doccn123", + "root123", + 0, + 2, + TestContext.Current.CancellationToken); + + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/docx/v1/documents/doccn123/blocks/root123/children/batch_delete" + ], handler.RequestPaths); + Assert.Equal("DELETE", handler.RequestMethods[1]); + + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal(0, requestDoc.RootElement.GetProperty("start_index").GetInt32()); + Assert.Equal(2, requestDoc.RootElement.GetProperty("end_index").GetInt32()); + } + + [Fact] + public async Task FindCloudDocumentInFolderByTitleAsync_WhenExactDocxTitleExists_ReturnsDocumentInfo() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"files":[{"name":"docs/guide.md","token":"file_123","type":"file"},{"name":"docs/guide.md","token":"doccn123","type":"docx","url":"https://feishu.cn/docx/doccn123"}],"has_more":false}}""") + ]); + + var client = CreateClient(handler); + + var document = await client.FindCloudDocumentInFolderByTitleAsync( + "fld-target", + "docs/guide.md", + TestContext.Current.CancellationToken); + + Assert.NotNull(document); + Assert.Equal("doccn123", document!.DocumentId); + Assert.Equal("doccn123", document.RootBlockId); + Assert.Equal("https://feishu.cn/docx/doccn123", document.Url); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/drive/v1/files" + ], handler.RequestPaths); + Assert.Contains("folder_token=fld-target", handler.RequestQueries[1], StringComparison.Ordinal); + Assert.Contains("page_size=200", handler.RequestQueries[1], StringComparison.Ordinal); + } + + [Fact] + public async Task UploadCloudFileAsync_PostsMultipartFormDataAndReturnsFileToken() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"file_token":"file_token_123"}}""") + ]); + + var client = CreateClient(handler); + var fileContent = global::System.Text.Encoding.UTF8.GetBytes("# 标题\r\n\r\n正文"); + + var fileToken = await client.UploadCloudFileAsync( + "notes.md", + fileContent, + "fld-target", + TestContext.Current.CancellationToken); + + Assert.Equal("file_token_123", fileToken); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/drive/v1/files/upload_all" + ], handler.RequestPaths); + Assert.Equal("POST", handler.RequestMethods[1]); + Assert.StartsWith("multipart/form-data", handler.RequestContentTypes[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("name=file_name", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("notes.md", handler.RequestBodies[1], StringComparison.Ordinal); + Assert.Contains("name=parent_type", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("explorer", handler.RequestBodies[1], StringComparison.Ordinal); + Assert.Contains("name=parent_node", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("fld-target", handler.RequestBodies[1], StringComparison.Ordinal); + Assert.Contains("name=size", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ImportMarkdownFileAsCloudDocumentAsync_UploadsCreatesImportTaskPollsAndReturnsDocumentInfo() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"file_token":"file_token_123"}}"""), + CreateJsonResponse("""{"code":0,"data":{"ticket":"ticket_123"}}"""), + CreateJsonResponse("""{"code":0,"data":{"result":{"ticket":"ticket_123","job_status":1,"job_error_msg":"pending"}}}"""), + CreateJsonResponse("""{"code":0,"data":{"result":{"ticket":"ticket_123","job_status":0,"job_error_msg":"success","token":"doccn123","url":"https://feishu.cn/docx/doccn123"}}}""") + ]); + + var client = CreateClient(handler); + var fileContent = global::System.Text.Encoding.UTF8.GetBytes("# 标题\r\n\r\n正文"); + + var document = await client.ImportMarkdownFileAsCloudDocumentAsync( + "notes.md", + fileContent, + "docs/guide.md", + "fld-target", + TestContext.Current.CancellationToken); + + Assert.Equal("doccn123", document.DocumentId); + Assert.Equal("doccn123", document.RootBlockId); + Assert.Equal("https://feishu.cn/docx/doccn123", document.Url); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/drive/v1/files/upload_all", + "/open-apis/drive/v1/import_tasks", + "/open-apis/drive/v1/import_tasks/ticket_123", + "/open-apis/drive/v1/import_tasks/ticket_123" + ], handler.RequestPaths); + + using var createImportTaskRequest = JsonDocument.Parse(handler.RequestBodies[2]); + Assert.Equal("md", createImportTaskRequest.RootElement.GetProperty("file_extension").GetString()); + Assert.Equal("file_token_123", createImportTaskRequest.RootElement.GetProperty("file_token").GetString()); + Assert.Equal("docx", createImportTaskRequest.RootElement.GetProperty("type").GetString()); + Assert.Equal("docs/guide.md", createImportTaskRequest.RootElement.GetProperty("file_name").GetString()); + Assert.Equal(1, createImportTaskRequest.RootElement.GetProperty("point").GetProperty("mount_type").GetInt32()); + Assert.Equal("fld-target", createImportTaskRequest.RootElement.GetProperty("point").GetProperty("mount_key").GetString()); + + var pollInterval = handler.RequestTimestamps[4] - handler.RequestTimestamps[3]; + Assert.True( + pollInterval >= TimeSpan.FromMilliseconds(150), + $"Expected markdown import polling to back off before retrying, but observed only {pollInterval.TotalMilliseconds:F0} ms between polls."); + } + + [Fact] + public async Task ImportMarkdownFileAsCloudDocumentAsync_WhenFolderTokenMissing_UsesRootFolderAndOmitsPoint() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"token":"fld-root","url":"https://feishu.cn/drive/folder/fld-root"}}"""), + CreateJsonResponse("""{"code":0,"data":{"file_token":"file_token_123"}}"""), + CreateJsonResponse("""{"code":0,"data":{"ticket":"ticket_123"}}"""), + CreateJsonResponse("""{"code":0,"data":{"result":{"ticket":"ticket_123","job_status":0,"job_error_msg":"success","token":"doccn123","url":"https://feishu.cn/docx/doccn123"}}}""") + ]); + + var client = CreateClient(handler); + var fileContent = global::System.Text.Encoding.UTF8.GetBytes("# 标题\r\n\r\n正文"); + + var document = await client.ImportMarkdownFileAsCloudDocumentAsync( + "notes.md", + fileContent, + "docs/guide.md", + null, + TestContext.Current.CancellationToken); + + Assert.Equal("doccn123", document.DocumentId); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/drive/explorer/v2/root_folder/meta", + "/open-apis/drive/v1/files/upload_all", + "/open-apis/drive/v1/import_tasks", + "/open-apis/drive/v1/import_tasks/ticket_123" + ], handler.RequestPaths); + + Assert.Contains("fld-root", handler.RequestBodies[2], StringComparison.Ordinal); + + using var createImportTaskRequest = JsonDocument.Parse(handler.RequestBodies[3]); + Assert.Equal("md", createImportTaskRequest.RootElement.GetProperty("file_extension").GetString()); + Assert.Equal("file_token_123", createImportTaskRequest.RootElement.GetProperty("file_token").GetString()); + Assert.Equal("docx", createImportTaskRequest.RootElement.GetProperty("type").GetString()); + Assert.Equal("docs/guide.md", createImportTaskRequest.RootElement.GetProperty("file_name").GetString()); + Assert.False(createImportTaskRequest.RootElement.TryGetProperty("point", out _)); + } + + [Fact] + public async Task ImportMarkdownFileAsCloudDocumentAsync_WhenImportTaskFails_ThrowsChineseError() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"file_token":"file_token_123"}}"""), + CreateJsonResponse("""{"code":0,"data":{"ticket":"ticket_123"}}"""), + CreateJsonResponse("""{"code":0,"data":{"result":{"ticket":"ticket_123","job_status":118,"job_error_msg":"上传文件和导入任务文件后缀不一致"}}}""") + ]); + + var client = CreateClient(handler); + + var exception = await Assert.ThrowsAsync(() => + client.ImportMarkdownFileAsCloudDocumentAsync( + "notes.md", + global::System.Text.Encoding.UTF8.GetBytes("# 标题"), + "docs/guide.md", + "fld-target", + TestContext.Current.CancellationToken)); + + Assert.Contains("Markdown 导入失败", exception.Message, StringComparison.Ordinal); + Assert.Contains("上传文件和导入任务文件后缀不一致", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CreateCloudDocumentAsync_WhenApiReturnsBadRequest_ExceptionIncludesResponseBody() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("""{"code":99991672,"msg":"Access denied","error":{"permission_violations":[{"subject":"docx:document"},{"subject":"docx:document:create"}]}}""") + } + ]); + + var client = CreateClient(handler); + + var exception = await Assert.ThrowsAsync(() => + client.CreateCloudDocumentAsync("thread-1 continue - 瀹屾暣鍥炲", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("BadRequest", exception.Message, StringComparison.Ordinal); + Assert.Contains("99991672", exception.Message, StringComparison.Ordinal); + Assert.Contains("docx:document", exception.Message, StringComparison.Ordinal); + Assert.Contains("docx:document:create", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task DownloadMessageResourceAsync_GetsBinaryBodyAndInfersFileName() + { + var imageBytes = new byte[] { 1, 2, 3, 4 }; + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(imageBytes) + { + Headers = + { + ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"), + ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment") + { + FileName = "\"screen.png\"" + } + } + } + } + ]); + + var client = CreateClient(handler); + + var result = await client.DownloadMessageResourceAsync( + "om_message_123", + "img_v2_123", + "image", + TestContext.Current.CancellationToken); + + Assert.Equal(imageBytes, result.Content); + Assert.Equal("screen.png", result.FileName); + Assert.Equal("image/png", result.MimeType); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/im/v1/messages/om_message_123/resources/img_v2_123" + ], handler.RequestPaths); + Assert.Equal("type=image", handler.RequestQueries[1]); + } + + [Fact] + public async Task SendTextMessageAsync_SendsTextPayload() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"message_id":"om_text_success"}}""") + ]); + + var client = CreateClient(handler); + + var messageId = await client.SendTextMessageAsync("oc_text_chat", "done", TestContext.Current.CancellationToken); + + Assert.Equal("om_text_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("text", requestDoc.RootElement.GetProperty("msg_type").GetString()); + Assert.Equal("oc_text_chat", requestDoc.RootElement.GetProperty("receive_id").GetString()); + + using var contentDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("content").GetString()!); + Assert.Equal("done", contentDoc.RootElement.GetProperty("text").GetString()); + } + + [Fact] + public async Task ReplyTextMessageAsync_SendsTextPayload() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"message_id":"om_text_reply_success"}}""") + ]); + + var client = CreateClient(handler); + + var messageId = await client.ReplyTextMessageAsync("om_reply", "done", TestContext.Current.CancellationToken); + + Assert.Equal("om_text_reply_success", messageId); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/im/v1/messages/om_reply/reply" + ], handler.RequestPaths); + + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal("text", requestDoc.RootElement.GetProperty("msg_type").GetString()); + + using var contentDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("content").GetString()!); + Assert.Equal("done", contentDoc.RootElement.GetProperty("text").GetString()); + } + + [Fact] + public async Task ReplyRawCardAsync_Throws_WhenReplyReturnsBusinessError() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""), + CreateJsonResponse("""{"code":10002,"msg":"invalid card payload"}""") + ]); + + var client = CreateClient(handler); + + var exception = await Assert.ThrowsAsync(() => + client.ReplyRawCardAsync( + "om_reply", + """{"schema":"2.0","body":{"elements":[]}}""", + TestContext.Current.CancellationToken)); + + Assert.Contains("Reply raw Feishu card message failed", exception.Message); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/cardkit/v1/cards", + "/open-apis/im/v1/messages/om_reply/reply" + ], handler.RequestPaths); + } + + [Fact] + public async Task ReplyElementsCardAsync_CreatesCardThenRepliesWithCardId() + { + 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_reply_success"}}""") + ]); + + var client = CreateClient(handler); + var card = new ElementsCardV2Dto + { + Header = new ElementsCardV2Dto.HeaderSuffix + { + Template = "blue", + Title = new HeaderTitleElement { Content = "Help card" } + }, + Body = new ElementsCardV2Dto.BodySuffix + { + Elements = + [ + new + { + tag = "div", + text = new { tag = "plain_text", content = "hello" } + } + ] + } + }; + + var messageId = await client.ReplyElementsCardAsync("om_reply", card, TestContext.Current.CancellationToken); + + Assert.Equal("om_reply_success", messageId); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/cardkit/v1/cards", + "/open-apis/im/v1/messages/om_reply/reply" + ], handler.RequestPaths); + + using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal("card_json", createDoc.RootElement.GetProperty("type").GetString()); + Assert.Equal(JsonValueKind.String, createDoc.RootElement.GetProperty("data").ValueKind); + + using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); + Assert.Equal("2.0", cardDoc.RootElement.GetProperty("schema").GetString()); + Assert.Equal("blue", cardDoc.RootElement.GetProperty("header").GetProperty("template").GetString()); + Assert.Equal("Help card", cardDoc.RootElement.GetProperty("header").GetProperty("title").GetProperty("content").GetString()); + Assert.Equal("div", cardDoc.RootElement.GetProperty("body").GetProperty("elements")[0].GetProperty("tag").GetString()); + + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[2]); + Assert.Equal("interactive", requestDoc.RootElement.GetProperty("msg_type").GetString()); + Assert.Equal(JsonValueKind.String, requestDoc.RootElement.GetProperty("content").ValueKind); + + using var replyDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("content").GetString()!); + Assert.Equal("card", replyDoc.RootElement.GetProperty("type").GetString()); + Assert.Equal("card_123", replyDoc.RootElement.GetProperty("data").GetProperty("card_id").GetString()); + } + + [Fact] + public async Task CreateStreamingHandleAsync_FallsBackToReadableChineseStatusHeader() + { + 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(); + chrome.OverflowOptions.Add(new FeishuStreamingCardOverflowOption + { + Text = "Backend API", + Value = new { action = "switch_session", session_id = "session-2", chat_key = "oc_stream_chat" } + }); + chrome.OverflowOptions.Add(new FeishuStreamingCardOverflowOption + { + Text = "妯″瀷/浼氳瘽绠$悊...", + Value = new { action = "open_session_manager" } + }); + + 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()!); + Assert.False(cardDoc.RootElement.GetProperty("config").TryGetProperty("streaming_mode", out _)); + var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); + var statusModule = elements[0]; + var overflow = statusModule.GetProperty("extra"); + + Assert.Equal("当前会话", statusModule.GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("overflow", overflow.GetProperty("tag").GetString()); + Assert.Equal("Backend API", overflow.GetProperty("options")[0].GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("{\"action\":\"switch_session\",\"session_id\":\"session-2\",\"chat_key\":\"oc_stream_chat\"}", overflow.GetProperty("options")[0].GetProperty("value").GetString()); + } + + [Fact] + public async Task CreateStreamingHandleAsync_RendersBottomPromptForm() + { + 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.BottomPrompt = new FeishuStreamingCardBottomPrompt + { + InputName = LowInterruptionContinueDefaults.PromptFieldName, + InputLabel = "灏戞墦鏂彁绀鸿瘝", + Placeholder = LowInterruptionContinueDefaults.PromptPlaceholder, + DefaultValue = LowInterruptionContinueDefaults.DefaultPrompt, + ButtonText = "Continue", + ButtonType = "primary", + Value = new + { + action = "low_interruption_continue", + session_id = "session-1", + chat_key = "oc_stream_chat", + tool_id = "codex" + } + }; + + 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("🟥🟥🟥 **Superpowers 工作流/Goal不间断执行**", elements[3].GetProperty("text").GetProperty("content").GetString()); + var bottomActionModule = elements.EnumerateArray().Last(); + + Assert.Equal("form", bottomActionModule.GetProperty("tag").GetString()); + + var buttonRow = bottomActionModule.GetProperty("elements")[0]; + Assert.Equal("column_set", buttonRow.GetProperty("tag").GetString()); + + var input = buttonRow.GetProperty("columns")[0].GetProperty("elements")[0]; + Assert.Equal("input", input.GetProperty("tag").GetString()); + Assert.Equal(LowInterruptionContinueDefaults.PromptFieldName, input.GetProperty("name").GetString()); + Assert.Equal(LowInterruptionContinueDefaults.DefaultPrompt, input.GetProperty("default_value").GetString()); + + var button = buttonRow.GetProperty("columns")[1].GetProperty("elements")[0]; + Assert.Equal("button", button.GetProperty("tag").GetString()); + Assert.Equal("primary", button.GetProperty("type").GetString()); + Assert.Equal("Continue", button.GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("form_submit", button.GetProperty("action_type").GetString()); + 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 = "当前会话", + StatusMarkdown = "褰撳墠浼氳瘽", BottomPrompt = new FeishuStreamingCardBottomPrompt { FormName = "superpowers_quick_action_form", InputName = "superpowers_quick_input", - InputLabel = "使用 superpowers 工作流", - Placeholder = "输入后提交", + InputLabel = "Use superpowers workflow", + Placeholder = "Enter text and submit", DefaultValue = string.Empty, - ButtonText = "提交", + ButtonText = "鎻愪氦", ButtonType = "primary", Value = new { action = "submit_superpowers_quick_input" } }, @@ -401,10 +957,10 @@ public async Task CreateStreamingHandleAsync_UsesUniqueSubmitButtonNames_ForMult { FormName = "goal_quick_action_form", InputName = "goal_quick_input", - InputLabel = "使用 /goal 工作流", - Placeholder = "输入后提交", + InputLabel = "Use /goal workflow", + Placeholder = "Enter text and submit", DefaultValue = string.Empty, - ButtonText = "提交", + ButtonText = "鎻愪氦", ButtonType = "primary", Value = new { action = "submit_goal_quick_input" } } @@ -415,7 +971,7 @@ await client.CreateStreamingHandleAsync( "oc_stream_chat", null, "still have backlog", - "AI 助手", + "AI 鍔╂墜", TestContext.Current.CancellationToken, chrome: chrome); @@ -443,7 +999,7 @@ public async Task CreateStreamingHandleAsync_RendersTopChipGroupsBetweenStatusAn var client = CreateClient(handler); var chrome = new FeishuStreamingCardChrome { - StatusMarkdown = "褰撳墠浼氳瘽" + StatusMarkdown = "当前会话" }; chrome.TopChipGroups.Add(new FeishuStreamingCardTopChipGroup { @@ -469,7 +1025,7 @@ await client.CreateStreamingHandleAsync( "oc_stream_chat", null, "still have backlog", - "AI 鍔╂墜", + "AI Assistant", TestContext.Current.CancellationToken, chrome: chrome); @@ -564,11 +1120,11 @@ public async Task CreateStreamingHandleAsync_RendersWorkflowSectionMarkerBeforeB var client = CreateClient(handler); var chrome = new FeishuStreamingCardChrome { - StatusMarkdown = "当前会话" + StatusMarkdown = "褰撳墠浼氳瘽" }; chrome.BottomActions.Add(new FeishuStreamingCardBottomAction { - Text = "执行 plan", + Text = "鎵ц plan", Type = "primary", Value = new { action = "execute_superpowers_plan", session_id = "session-1" } }); @@ -577,7 +1133,7 @@ await client.CreateStreamingHandleAsync( "oc_stream_chat", null, "still have backlog", - "AI 助手", + "AI 鍔╂墜", TestContext.Current.CancellationToken, chrome: chrome); @@ -587,7 +1143,7 @@ await client.CreateStreamingHandleAsync( 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("🟥🟥🟥 **Superpowers 工作流/Goal不间断执行**", elements[3].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("column_set", elements[4].GetProperty("tag").GetString()); } @@ -646,7 +1202,7 @@ await client.CreateStreamingHandleAsync( using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); - Assert.Equal("🟥🟥🟥 **Superpowers 工作流**", elements[3].GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("🟥🟥🟥 **Superpowers 工作流/Goal不间断执行**", elements[3].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("column_set", elements[4].GetProperty("tag").GetString()); Assert.Equal("column_set", elements[5].GetProperty("tag").GetString()); Assert.Equal(2, elements[4].GetProperty("columns").GetArrayLength()); @@ -670,15 +1226,15 @@ public async Task CreateStreamingHandleAsync_RendersLatestToolCallLineBelowReply var client = CreateClient(handler); var chrome = new FeishuStreamingCardChrome { - StatusMarkdown = "当前会话", - LatestToolCallMarkdown = "**调用工具:** `Bash · git status --short`" + StatusMarkdown = "褰撳墠浼氳瘽", + LatestToolCallMarkdown = "**璋冪敤宸ュ叿锛?* `Bash 路 git status --short`" }; await client.CreateStreamingHandleAsync( "oc_stream_chat", null, "assistant output", - "AI 助手", + "AI 鍔╂墜", TestContext.Current.CancellationToken, chrome: chrome); @@ -690,7 +1246,7 @@ await client.CreateStreamingHandleAsync( Assert.Equal("markdown", elements[2].GetProperty("tag").GetString()); Assert.Equal("assistant output", elements[2].GetProperty("content").GetString()); Assert.Equal("div", elements[3].GetProperty("tag").GetString()); - Assert.Equal("**调用工具:** `Bash · git status --short`", elements[3].GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("**璋冪敤宸ュ叿锛?* `Bash 路 git status --short`", elements[3].GetProperty("text").GetProperty("content").GetString()); } [Fact] @@ -706,12 +1262,12 @@ public async Task CreateStreamingHandleAsync_RendersBottomNoticeAboveActions() var client = CreateClient(handler); var chrome = new FeishuStreamingCardChrome { - StatusMarkdown = "当前会话" + StatusMarkdown = "褰撳墠浼氳瘽" }; - chrome.BottomNoticeMarkdowns.Add("⚠️ 当前激活会话已经变化"); + chrome.BottomNoticeMarkdowns.Add("Session binding changed"); chrome.BottomActions.Add(new FeishuStreamingCardBottomAction { - Text = "继续原会话", + Text = "Continue original session", Type = "default", Value = new { action = "confirm_bound_superpowers_action", session_id = "session-1" } }); @@ -720,7 +1276,7 @@ await client.CreateStreamingHandleAsync( "oc_stream_chat", null, "still have backlog", - "AI 助手", + "AI 鍔╂墜", TestContext.Current.CancellationToken, chrome: chrome); @@ -728,9 +1284,9 @@ await client.CreateStreamingHandleAsync( using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); - Assert.Equal("🟥🟥🟥 **Superpowers 工作流**", elements[3].GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("🟥🟥🟥 **Superpowers 工作流/Goal不间断执行**", elements[3].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("div", elements[4].GetProperty("tag").GetString()); - Assert.Equal("⚠️ 当前激活会话已经变化", elements[4].GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("Session binding changed", elements[4].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("column_set", elements[5].GetProperty("tag").GetString()); } @@ -747,14 +1303,14 @@ public async Task CreateStreamingHandleAsync_KeepsClientStreamingMode_WhenNoOver var client = CreateClient(handler); var chrome = new FeishuStreamingCardChrome { - StatusMarkdown = "当前会话" + StatusMarkdown = "褰撳墠浼氳瘽" }; await client.CreateStreamingHandleAsync( "oc_stream_chat", null, "still have backlog", - "AI 助手", + "AI 鍔╂墜", TestContext.Current.CancellationToken, chrome: chrome); @@ -852,30 +1408,323 @@ public async Task CreateStreamingHandleAsync_RetriesTimedOutUpdateOnceWithSameSe "oc_stream_chat", null, "initial", - "AI 助手", + "AI 鍔╂墜", + TestContext.Current.CancellationToken); + + await handle.UpdateAsync("first update"); + await handle.UpdateAsync("second update"); + + Assert.False(handle.AreCardUpdatesStopped); + Assert.Equal( + 3, + handler.RequestPaths.Count(path => string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal))); + + var updateBodies = handler.RequestPaths + .Select((path, index) => new { path, body = handler.RequestBodies[index] }) + .Where(entry => string.Equals(entry.path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + .Select(entry => JsonDocument.Parse(entry.body)) + .ToArray(); + + Assert.Equal(1, updateBodies[0].RootElement.GetProperty("sequence").GetInt32()); + Assert.Equal(1, updateBodies[1].RootElement.GetProperty("sequence").GetInt32()); + Assert.Equal(2, updateBodies[2].RootElement.GetProperty("sequence").GetInt32()); + + var firstUuid = updateBodies[0].RootElement.GetProperty("uuid").GetString(); + Assert.Equal(firstUuid, updateBodies[1].RootElement.GetProperty("uuid").GetString()); + Assert.NotEqual(firstUuid, updateBodies[2].RootElement.GetProperty("uuid").GetString()); + } + + [Fact] + public async Task CreateStreamingHandleAsync_TreatsTimeoutThenSequenceConflictAsSuccessfulPriorWrite() + { + var handler = new TimeoutThenSequenceConflictCardUpdateHandler(); + var client = CreateClient(handler, new FeishuOptions + { + AppId = "app-id", + AppSecret = "app-secret", + HttpTimeoutSeconds = 1, + StreamingThrottleMs = 0 + }); + + var handle = await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "initial", + "AI Assistant", + TestContext.Current.CancellationToken); + + await handle.UpdateAsync("first update"); + await handle.UpdateAsync("second update"); + + Assert.False(handle.AreCardUpdatesStopped); + Assert.Equal(2, handler.SuccessfulLogicalUpdates); + + Assert.Equal( + 3, + handler.RequestPaths.Count(path => string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal))); + + var updateBodies = handler.RequestPaths + .Select((path, index) => new { path, body = handler.RequestBodies[index] }) + .Where(entry => string.Equals(entry.path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + .Select(entry => JsonDocument.Parse(entry.body)) + .ToArray(); + + Assert.Equal(1, updateBodies[0].RootElement.GetProperty("sequence").GetInt32()); + Assert.Equal(1, updateBodies[1].RootElement.GetProperty("sequence").GetInt32()); + Assert.Equal(2, updateBodies[2].RootElement.GetProperty("sequence").GetInt32()); + + var firstUuid = updateBodies[0].RootElement.GetProperty("uuid").GetString(); + Assert.Equal(firstUuid, updateBodies[1].RootElement.GetProperty("uuid").GetString()); + Assert.NotEqual(firstUuid, updateBodies[2].RootElement.GetProperty("uuid").GetString()); + } + + [Fact] + public async Task CreateStreamingHandleAsync_TreatsTimeoutThenDuplicateUuidAsSuccessfulPriorWrite() + { + var handler = new TimeoutThenDuplicateUuidCardUpdateHandler(); + var client = CreateClient(handler, new FeishuOptions + { + AppId = "app-id", + AppSecret = "app-secret", + HttpTimeoutSeconds = 1, + StreamingThrottleMs = 0 + }); + + var handle = await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "initial", + "AI 鍔╂墜", TestContext.Current.CancellationToken); await handle.UpdateAsync("first update"); await handle.UpdateAsync("second update"); Assert.False(handle.AreCardUpdatesStopped); + Assert.Equal(2, handler.SuccessfulLogicalUpdates); + Assert.Equal( 3, handler.RequestPaths.Count(path => string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal))); - var updateBodies = handler.RequestPaths - .Select((path, index) => new { path, body = handler.RequestBodies[index] }) - .Where(entry => string.Equals(entry.path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) - .Select(entry => JsonDocument.Parse(entry.body)) - .ToArray(); + var updateBodies = handler.RequestPaths + .Select((path, index) => new { path, body = handler.RequestBodies[index] }) + .Where(entry => string.Equals(entry.path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + .Select(entry => JsonDocument.Parse(entry.body)) + .ToArray(); + + Assert.Equal(1, updateBodies[0].RootElement.GetProperty("sequence").GetInt32()); + Assert.Equal(1, updateBodies[1].RootElement.GetProperty("sequence").GetInt32()); + Assert.Equal(2, updateBodies[2].RootElement.GetProperty("sequence").GetInt32()); + + var firstUuid = updateBodies[0].RootElement.GetProperty("uuid").GetString(); + Assert.Equal(firstUuid, updateBodies[1].RootElement.GetProperty("uuid").GetString()); + Assert.NotEqual(firstUuid, updateBodies[2].RootElement.GetProperty("uuid").GetString()); + } + + [Fact] + public async Task CreateStreamingHandleAsync_TreatsPlainSequenceConflictAsCardFailure() + { + var handler = new PlainSequenceConflictCardUpdateHandler(); + var client = CreateClient(handler, new FeishuOptions + { + AppId = "app-id", + AppSecret = "app-secret", + HttpTimeoutSeconds = 1, + StreamingThrottleMs = 0 + }); + + var handle = await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "initial", + "AI Assistant", + TestContext.Current.CancellationToken); + + await handle.UpdateAsync("first update"); + + Assert.True(handle.AreCardUpdatesStopped); + } + + [Fact] + public async Task CreateStreamingHandleAsync_RetriesOverflowUpdateWithReducedReplyOnlyPayload() + { + var handler = new OverflowThenReducedCardUpdateHandler(); + var client = CreateClient(handler, new FeishuOptions + { + AppId = "app-id", + AppSecret = "app-secret", + HttpTimeoutSeconds = 30, + StreamingThrottleMs = 0 + }); + + var handle = await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "initial", + "AI 鍔╂墜", + TestContext.Current.CancellationToken, + chrome: CreateVerboseStreamingChrome()); + + await handle.UpdateAsync(BuildLargeStreamingContent()); + + Assert.False(handle.AreCardUpdatesStopped); + + var updateBodies = handler.RequestPaths + .Select((path, index) => new { path, body = handler.RequestBodies[index] }) + .Where(entry => string.Equals(entry.path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + .Select(entry => JsonDocument.Parse(entry.body)) + .ToArray(); + + Assert.Equal(2, updateBodies.Length); + Assert.Equal(1, updateBodies[0].RootElement.GetProperty("sequence").GetInt32()); + Assert.Equal(1, updateBodies[1].RootElement.GetProperty("sequence").GetInt32()); + + using var firstCardDoc = JsonDocument.Parse(updateBodies[0].RootElement.GetProperty("card").GetProperty("data").GetString()!); + using var secondCardDoc = JsonDocument.Parse(updateBodies[1].RootElement.GetProperty("card").GetProperty("data").GetString()!); + + Assert.True(firstCardDoc.RootElement.GetProperty("body").GetProperty("elements").GetArrayLength() > 3); + Assert.Equal(1, secondCardDoc.RootElement.GetProperty("body").GetProperty("elements").GetArrayLength()); + + var reducedContent = secondCardDoc.RootElement + .GetProperty("body") + .GetProperty("elements")[0] + .GetProperty("content") + .GetString(); + + Assert.Contains("卡片已精简", reducedContent); + Assert.Contains("仅显示最新内容", reducedContent); + Assert.Contains("line 359", reducedContent); + Assert.DoesNotContain("line 000", reducedContent); + } + + [Fact] + public async Task CreateStreamingHandleAsync_RetriesOverflowCardCreationWithReducedReplyOnlyPayload() + { + var handler = new OverflowThenReducedCardCreateHandler(); + var client = CreateClient(handler, new FeishuOptions + { + AppId = "app-id", + AppSecret = "app-secret", + HttpTimeoutSeconds = 30, + StreamingThrottleMs = 0 + }); + + var handle = await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + BuildLargeStreamingContent(), + "AI 鍔╂墜", + TestContext.Current.CancellationToken, + chrome: CreateVerboseStreamingChrome()); + + Assert.Equal("card_123", handle.CardId); + + var createBodies = handler.RequestPaths + .Select((path, index) => new { path, body = handler.RequestBodies[index] }) + .Where(entry => string.Equals(entry.path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + .Select(entry => JsonDocument.Parse(entry.body)) + .ToArray(); + + Assert.Equal(2, createBodies.Length); + + using var firstCardDoc = JsonDocument.Parse(createBodies[0].RootElement.GetProperty("data").GetString()!); + using var secondCardDoc = JsonDocument.Parse(createBodies[1].RootElement.GetProperty("data").GetString()!); + + Assert.True(firstCardDoc.RootElement.GetProperty("body").GetProperty("elements").GetArrayLength() > 3); + Assert.Equal(1, secondCardDoc.RootElement.GetProperty("body").GetProperty("elements").GetArrayLength()); + + var reducedContent = secondCardDoc.RootElement + .GetProperty("body") + .GetProperty("elements")[0] + .GetProperty("content") + .GetString(); + + Assert.Contains("卡片已精简", reducedContent); + Assert.Contains("仅显示最新内容", reducedContent); + Assert.Contains("line 359", reducedContent); + Assert.DoesNotContain("line 000", reducedContent); + } + + [Fact] + public async Task CreateStreamingHandleAsync_SticksToReducedPayloadAfterOverflowRecovery() + { + var handler = new OverflowRequiresReducedPayloadHandler(); + var client = CreateClient(handler, new FeishuOptions + { + AppId = "app-id", + AppSecret = "app-secret", + HttpTimeoutSeconds = 30, + StreamingThrottleMs = 0 + }); + + var handle = await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "initial", + "AI 鍔╂墜", + TestContext.Current.CancellationToken, + chrome: CreateVerboseStreamingChrome()); + + await handle.UpdateAsync(BuildLargeStreamingContent()); + await handle.UpdateAsync(BuildLargeStreamingContent() + Environment.NewLine + "tail next"); + + Assert.False(handle.AreCardUpdatesStopped); + + var updateBodies = handler.RequestPaths + .Select((path, index) => new { path, body = handler.RequestBodies[index] }) + .Where(entry => string.Equals(entry.path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + .Select(entry => JsonDocument.Parse(entry.body)) + .ToArray(); + + Assert.Equal(3, updateBodies.Length); + + using var lastCardDoc = JsonDocument.Parse(updateBodies[^1].RootElement.GetProperty("card").GetProperty("data").GetString()!); + Assert.Equal(1, lastCardDoc.RootElement.GetProperty("body").GetProperty("elements").GetArrayLength()); + + var reducedContent = lastCardDoc.RootElement + .GetProperty("body") + .GetProperty("elements")[0] + .GetProperty("content") + .GetString(); + + Assert.Contains("卡片已精简", reducedContent); + Assert.Contains("tail next", reducedContent); + } + + private static FeishuStreamingCardChrome CreateVerboseStreamingChrome() + { + var chrome = new FeishuStreamingCardChrome + { + StatusMarkdown = "Current session / processing", + LatestToolCallMarkdown = "**璋冪敤宸ュ叿锛?* `powershell.exe -Command ...`" + }; + + chrome.BottomNoticeMarkdowns.Add("This is a long tool output card and should shrink on overflow."); + chrome.BottomActions.Add(new FeishuStreamingCardBottomAction + { + Text = "缁х画", + Type = "primary", + Value = new { action = "continue" } + }); + + return chrome; + } - Assert.Equal(1, updateBodies[0].RootElement.GetProperty("sequence").GetInt32()); - Assert.Equal(1, updateBodies[1].RootElement.GetProperty("sequence").GetInt32()); - Assert.Equal(2, updateBodies[2].RootElement.GetProperty("sequence").GetInt32()); + private static string BuildLargeStreamingContent() + { + return string.Join( + Environment.NewLine, + Enumerable.Range(0, 360).Select(index => $"line {index:000} {new string('x', 24)}")); + } - var firstUuid = updateBodies[0].RootElement.GetProperty("uuid").GetString(); - Assert.Equal(firstUuid, updateBodies[1].RootElement.GetProperty("uuid").GetString()); - Assert.NotEqual(firstUuid, updateBodies[2].RootElement.GetProperty("uuid").GetString()); + private static JsonElement[] ParseJsonElementArray(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement + .EnumerateArray() + .Select(element => element.Clone()) + .ToArray(); } private static FeishuCardKitClient CreateClient(HttpMessageHandler handler, FeishuOptions? optionsOverride = null) @@ -912,14 +1761,18 @@ private sealed class StubHttpMessageHandler(IEnumerable res public List RequestPaths { get; } = []; public List RequestQueries { get; } = []; + public List RequestMethods { get; } = []; public List RequestBodies { get; } = []; public List RequestContentTypes { get; } = []; + public List RequestTimestamps { get; } = []; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { RequestPaths.Add(request.RequestUri!.AbsolutePath); RequestQueries.Add(request.RequestUri.Query.TrimStart('?')); + RequestMethods.Add(request.Method.Method); RequestContentTypes.Add(request.Content?.Headers.ContentType?.MediaType); + RequestTimestamps.Add(DateTimeOffset.UtcNow); RequestBodies.Add(request.Content == null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken)); @@ -982,4 +1835,281 @@ protected override async Task SendAsync(HttpRequestMessage throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); } } + + private sealed class TimeoutThenSequenceConflictCardUpdateHandler : HttpMessageHandler + { + private int _updateCount; + + public int SuccessfulLogicalUpdates { get; private set; } + public List RequestPaths { get; } = []; + public List RequestBodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestPaths.Add(request.RequestUri!.AbsolutePath); + RequestBodies.Add(request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); + + var path = request.RequestUri!.AbsolutePath; + if (string.Equals(path, "/open-apis/auth/v3/tenant_access_token/internal", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/im/v1/messages", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}"""); + } + + if (request.Method == HttpMethod.Put && + string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + { + _updateCount++; + if (_updateCount == 1) + { + await Task.Delay(TimeSpan.FromMilliseconds(1500), cancellationToken); + } + + if (_updateCount == 2) + { + SuccessfulLogicalUpdates++; + return CreateJsonResponse("""{"code":300317,"msg":"sequence number compare failed"}"""); + } + + SuccessfulLogicalUpdates++; + return CreateJsonResponse("""{"code":0}"""); + } + + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } + } + + private sealed class PlainSequenceConflictCardUpdateHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (string.Equals(path, "/open-apis/auth/v3/tenant_access_token/internal", StringComparison.Ordinal)) + { + return Task.FromResult(CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}""")); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + { + return Task.FromResult(CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}""")); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/im/v1/messages", StringComparison.Ordinal)) + { + return Task.FromResult(CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}""")); + } + + if (request.Method == HttpMethod.Put && + string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + { + return Task.FromResult(CreateJsonResponse("""{"code":300317,"msg":"sequence number compare failed"}""")); + } + + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } + } + + private sealed class TimeoutThenDuplicateUuidCardUpdateHandler : HttpMessageHandler + { + private int _updateCount; + + public int SuccessfulLogicalUpdates { get; private set; } + + public List RequestPaths { get; } = []; + public List RequestBodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestPaths.Add(request.RequestUri!.AbsolutePath); + RequestBodies.Add(request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); + + var path = request.RequestUri!.AbsolutePath; + if (string.Equals(path, "/open-apis/auth/v3/tenant_access_token/internal", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/im/v1/messages", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}"""); + } + + if (request.Method == HttpMethod.Put && + string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + { + _updateCount++; + if (_updateCount == 1) + { + await Task.Delay(TimeSpan.FromMilliseconds(1500), cancellationToken); + } + + if (_updateCount == 2) + { + SuccessfulLogicalUpdates++; + return CreateJsonResponse("""{"code":200770,"msg":"this UUID has been recently consumed"}"""); + } + + SuccessfulLogicalUpdates++; + return CreateJsonResponse("""{"code":0}"""); + } + + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } + } + + private sealed class OverflowThenReducedCardUpdateHandler : HttpMessageHandler + { + private int _updateCount; + + public List RequestPaths { get; } = []; + public List RequestBodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestPaths.Add(request.RequestUri!.AbsolutePath); + RequestBodies.Add(request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); + + var path = request.RequestUri!.AbsolutePath; + if (string.Equals(path, "/open-apis/auth/v3/tenant_access_token/internal", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/im/v1/messages", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}"""); + } + + if (request.Method == HttpMethod.Put && + string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + { + _updateCount++; + return _updateCount == 1 + ? CreateJsonResponse("""{"code":200860,"msg":"card over max size"}""") + : CreateJsonResponse("""{"code":0}"""); + } + + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } + } + + private sealed class OverflowThenReducedCardCreateHandler : HttpMessageHandler + { + private int _createCount; + + public List RequestPaths { get; } = []; + public List RequestBodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestPaths.Add(request.RequestUri!.AbsolutePath); + RequestBodies.Add(request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); + + var path = request.RequestUri!.AbsolutePath; + if (string.Equals(path, "/open-apis/auth/v3/tenant_access_token/internal", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + { + _createCount++; + return _createCount == 1 + ? CreateJsonResponse("""{"code":200860,"msg":"card over max size"}""") + : CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/im/v1/messages", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}"""); + } + + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } + } + + private sealed class OverflowRequiresReducedPayloadHandler : HttpMessageHandler + { + public List RequestPaths { get; } = []; + public List RequestBodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestPaths.Add(request.RequestUri!.AbsolutePath); + RequestBodies.Add(request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); + + var path = request.RequestUri!.AbsolutePath; + if (string.Equals(path, "/open-apis/auth/v3/tenant_access_token/internal", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/im/v1/messages", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}"""); + } + + if (request.Method == HttpMethod.Put && + string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + { + using var requestDoc = JsonDocument.Parse(RequestBodies[^1]); + using var cardDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("card").GetProperty("data").GetString()!); + var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); + var isReducedPayload = elements.GetArrayLength() == 1 + && elements[0].GetProperty("content").GetString()!.Contains("卡片已精简", StringComparison.Ordinal); + + return isReducedPayload + ? CreateJsonResponse("""{"code":0}""") + : CreateJsonResponse("""{"code":200860,"msg":"card over max size"}"""); + } + + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } + } } diff --git a/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs index 39e2ebb..f952d1a 100644 --- a/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs @@ -1161,12 +1161,667 @@ public async Task HandleIncomingMessageAsync_WhenCardUpdateDisconnects_FreezesCa { var repository = CreateRepository(out var repositoryProxy); var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); - var cardKit = new StreamingRecordingFeishuCardKitClient + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(2); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-disconnect-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + StreamChunks = + [ + new StreamOutputChunk + { + Content = "第一段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = "第二段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ] + }; + + 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); + + try + { + var sessionId = service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_disconnect_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_disconnect_chat", + SenderName = "luhaiyan", + MessageId = "msg-disconnect", + Content = "输出两段内容" + }); + + Assert.Equal(3, cardKit.Handles.Count); + Assert.Equal(2, cardKit.Handles[0].UpdateAttemptCount); + Assert.Equal(1, cardKit.Handles[1].UpdateAttemptCount); + Assert.Equal(0, cardKit.Handles[2].UpdateAttemptCount); + Assert.Single(cardKit.Handles[0].Updates); + Assert.Equal("第一段", cardKit.Handles[0].Updates[0]); + Assert.Equal( + "第一段\n第二段\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Contains("已停止", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("第一段\n第二段", cardKit.Handles[1].InitialContent); + Assert.Empty(cardKit.Handles[1].Updates); + Assert.Equal( + "第一段\n第二段\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[1].FinalContent); + Assert.Contains("已停止", cardKit.Handles[1].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("第一段\n第二段", cardKit.Handles[2].InitialContent); + Assert.Equal("第一段\n第二段", cardKit.Handles[2].FinalContent); + Assert.Contains("已完成", cardKit.Handles[2].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal(1, cardKit.ReplyTextCallCount); + Assert.StartsWith("当前会话:superpowers", cardKit.LastReplyTextContent, StringComparison.Ordinal); + Assert.EndsWith("\n已完成", cardKit.LastReplyTextContent, StringComparison.Ordinal); + Assert.Contains( + chatSessionService.Messages[sessionId], + message => message.Role == "assistant" && message.Content == "第一段\n第二段"); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_ReplacesBrokenStreamingCardOnceAndFinishesOnReplacement() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-recovery-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + StreamChunks = + [ + new StreamOutputChunk + { + Content = "第一段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = "第二段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ] + }; + + 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); + + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_recovery_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_recovery_chat", + SenderName = "luhaiyan", + MessageId = "msg-recovery", + Content = "输出两段内容" + }); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal( + "第一段\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Contains("已停止", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("第一段", cardKit.Handles[1].InitialContent); + Assert.Contains("第一段\n第二段", cardKit.Handles[1].Updates); + Assert.Equal("第一段\n第二段", cardKit.Handles[1].FinalContent); + Assert.Contains("已完成", cardKit.Handles[1].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal(1, cardKit.ReplyTextCallCount); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_WhenFinalCardCompletionFails_ReplacesStreamingCardAndFinishesOnReplacement() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.FailFinishAttemptSequence.Enqueue(1); + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-finish-recovery-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + StreamChunks = + [ + new StreamOutputChunk + { + Content = "第一段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = "第二段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ] + }; + + 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); + + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_finish_recovery_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_finish_recovery_chat", + SenderName = "luhaiyan", + MessageId = "msg-finish-recovery", + Content = "输出两段内容" + }); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal(1, cardKit.Handles[0].FinishAttemptCount); + Assert.Null(cardKit.Handles[0].FinalContent); + Assert.Equal("第一段\n第二段", cardKit.Handles[1].InitialContent); + Assert.Equal("第一段\n第二段", cardKit.Handles[1].FinalContent); + Assert.Equal(1, cardKit.ReplyTextCallCount); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_WhenExternalHistoryBackfillBreaksCurrentCard_ReplacesStreamingCardBeforeCompletion() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(2); + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-history-recovery-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + Adapter = new CodexAdapter(), + EnableStreamParsing = true, + StreamChunks = + [ + new StreamOutputChunk + { + Content = "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ], + StreamChunkDelays = + [ + TimeSpan.Zero, + TimeSpan.FromMilliseconds(4200), + TimeSpan.Zero + ] + }; + + var historyService = new StubExternalCliSessionHistoryService( + [ + new ExternalCliHistoryMessage + { + Role = "user", + Content = "帮我看下goal命令有执行吗?" + }, + new ExternalCliHistoryMessage + { + Role = "assistant", + Content = "执行了,而且还在执行中。" + } + ]); + + var serviceProvider = new TestServiceProvider( + repository, + sessionDirectoryService, + new StubFeishuUserBindingService(), + new StubUserFeishuBotConfigService(), + new StubUserContextService(), + historyService); + + var service = new FeishuChannelService( + Options.Create(new FeishuOptions + { + Enabled = true, + AppId = "cli_test", + AppSecret = "secret" + }), + NullLogger.Instance, + cardKit, + serviceProvider, + cliExecutor, + chatSessionService); + + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_history_recovery_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_history_recovery_chat", + SenderName = "luhaiyan", + MessageId = "msg-history-recovery", + Content = "帮我看下goal命令有执行吗?" + }); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal( + "执行了,而且还在执行中。\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Contains("已停止", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("执行了,而且还在执行中。", cardKit.Handles[1].InitialContent); + Assert.DoesNotContain("思考中...", cardKit.Handles[1].Updates); + Assert.Equal("执行了,而且还在执行中。", cardKit.Handles[1].FinalContent); + Assert.Contains("已完成", cardKit.Handles[1].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal("codex", historyService.LastToolId); + Assert.Equal("thread-1", historyService.LastCliThreadId); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_WhenReplacementCardAlsoFails_AppendsDisconnectMessage() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + for (var attempt = 0; attempt <= 10; attempt++) + { + cardKit.FailUpdateAttemptSequence.Enqueue(1); + } + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-recovery-fallback-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + StreamChunks = + [ + new StreamOutputChunk { Content = "第1段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第2段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第3段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第4段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第5段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第6段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第7段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第8段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第9段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第10段\n", IsCompleted = false }, + new StreamOutputChunk { Content = "第11段\n", IsCompleted = true } + ] + }; + + 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); + + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_recovery_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_recovery_chat", + SenderName = "luhaiyan", + MessageId = "msg-recovery", + Content = "输出两段内容" + }); + + Assert.Equal(11, cardKit.Handles.Count); + Assert.NotNull(cardKit.Handles[^1].FinalContent); + Assert.Contains("第1段", cardKit.Handles[^1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("第11段", cardKit.Handles[^1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("**错误:飞书流式更新断连,已停止继续推送卡片。**", cardKit.Handles[^1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("执行出错", cardKit.Handles[^1].FinalStatusMarkdown, StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_WhenReplacementCardCreationOverflows_FallsBackToPlainTextStreaming() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + cardKit.ThrowOverflowOnCreateHandleSequence.Enqueue(false); + cardKit.ThrowOverflowOnCreateHandleSequence.Enqueue(true); + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-overflow-text-fallback-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + StreamChunks = + [ + new StreamOutputChunk + { + Content = "第一段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = "第二段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ] + }; + + 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); + + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_overflow_fallback_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_overflow_fallback_chat", + SenderName = "luhaiyan", + MessageId = "msg-overflow-fallback", + Content = "输出两段内容" + }); + + Assert.Single(cardKit.Handles); + Assert.Equal(1, cardKit.Handles[0].UpdateAttemptCount); + Assert.Equal(1, cardKit.SendTextCallCount); + Assert.True(cardKit.ReplyTextCallCount >= 1); + Assert.Contains("飞书卡片已超限,后续改为普通文本继续输出。", cardKit.SentTextMessages[0], StringComparison.Ordinal); + Assert.Contains("第一段", cardKit.SentTextMessages[0], StringComparison.Ordinal); + Assert.Contains("第二段", string.Join("\n", cardKit.RepliedTextMessages), StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_WhenInitialCardCreationOverflows_FallsBackToPlainTextStreaming() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.ThrowOverflowOnCreateHandleSequence.Enqueue(true); + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-initial-overflow-text-fallback-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + StreamChunks = + [ + new StreamOutputChunk + { + Content = "第一段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = "第二段\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ] + }; + + 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); + + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_initial_overflow_fallback_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_initial_overflow_fallback_chat", + SenderName = "luhaiyan", + MessageId = "msg-initial-overflow-fallback", + Content = "输出两段内容" + }); + + Assert.Empty(cardKit.Handles); + Assert.Equal(1, cardKit.SendTextCallCount); + Assert.True(cardKit.ReplyTextCallCount >= 1); + Assert.Contains("飞书卡片已超限,后续改为普通文本继续输出。", cardKit.SentTextMessages[0], StringComparison.Ordinal); + Assert.Contains("第一段", string.Join("\n", cardKit.RepliedTextMessages), StringComparison.Ordinal); + Assert.Contains("第二段", string.Join("\n", cardKit.RepliedTextMessages), StringComparison.Ordinal); + } + finally { - FailUpdateOnAttempt = 2 - }; + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_WhenReplacementCardFinalCompletionAlsoFails_AppendsDisconnectMessage() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.FailFinishAttemptSequence.Enqueue(1); + cardKit.FailFinishAttemptSequence.Enqueue(1); var chatSessionService = new RecordingChatSessionService(); - var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-disconnect-{Guid.NewGuid():N}"); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-card-finish-fallback-{Guid.NewGuid():N}"); var workspacePath = Path.Combine(workspaceRoot, "superpowers"); Directory.CreateDirectory(workspacePath); @@ -1214,10 +1869,10 @@ public async Task HandleIncomingMessageAsync_WhenCardUpdateDisconnects_FreezesCa try { - var sessionId = service.CreateNewSession( + service.CreateNewSession( new FeishuIncomingMessage { - ChatId = "oc_disconnect_chat", + ChatId = "oc_finish_fallback_chat", SenderName = "luhaiyan" }, workspacePath, @@ -1225,25 +1880,20 @@ public async Task HandleIncomingMessageAsync_WhenCardUpdateDisconnects_FreezesCa await service.HandleIncomingMessageAsync(new FeishuIncomingMessage { - ChatId = "oc_disconnect_chat", + ChatId = "oc_finish_fallback_chat", SenderName = "luhaiyan", - MessageId = "msg-disconnect", + MessageId = "msg-finish-fallback", Content = "输出两段内容" }); - var handle = Assert.Single(cardKit.Handles); - Assert.Equal(2, handle.UpdateAttemptCount); - Assert.Single(handle.Updates); - Assert.Equal("第一段", handle.Updates[0]); - Assert.NotNull(handle.FinalContent); - Assert.Contains("第一段", handle.FinalContent!, StringComparison.Ordinal); - Assert.Contains("**错误:飞书流式更新断连,已停止继续推送卡片。**", handle.FinalContent!, StringComparison.Ordinal); - Assert.Contains("执行出错", handle.FinalStatusMarkdown, StringComparison.Ordinal); + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal(2, cardKit.Handles[1].FinishAttemptCount); + Assert.NotNull(cardKit.Handles[1].FinalContent); + Assert.Contains("第一段\n第二段", cardKit.Handles[1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("**错误:飞书流式更新断连,已停止继续推送卡片。**", cardKit.Handles[1].FinalContent!, StringComparison.Ordinal); + Assert.Contains("执行出错", cardKit.Handles[1].FinalStatusMarkdown, StringComparison.Ordinal); Assert.Equal(0, cardKit.ReplyTextCallCount); Assert.Null(cardKit.LastReplyTextContent); - Assert.Contains( - chatSessionService.Messages[sessionId], - message => message.Role == "assistant" && message.Content == "第一段\n第二段"); } finally { @@ -1335,6 +1985,114 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage } } + [Fact] + public async Task HandleIncomingMessageAsync_WhenBackgroundReplacementHandleUsesCanceledToken_SkipsNormalCompletion() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new BackgroundReplacementTokenAwareFeishuCardKitClient(); + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-stream-complete-race-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + Adapter = new CodexAdapter(), + EnableStreamParsing = true, + StreamChunks = + [ + new StreamOutputChunk + { + Content = "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ], + StreamChunkDelays = + [ + TimeSpan.Zero, + TimeSpan.FromMilliseconds(4200), + TimeSpan.Zero + ] + }; + + var historyService = new StubExternalCliSessionHistoryService( + [ + new ExternalCliHistoryMessage + { + Role = "user", + Content = "帮我看下goal命令有执行吗?" + }, + new ExternalCliHistoryMessage + { + Role = "assistant", + Content = "执行了,而且还在执行中。" + } + ]); + + var serviceProvider = new TestServiceProvider( + repository, + sessionDirectoryService, + new StubFeishuUserBindingService(), + new StubUserFeishuBotConfigService(), + new StubUserContextService(), + historyService); + + var service = new FeishuChannelService( + Options.Create(new FeishuOptions + { + Enabled = true, + AppId = "cli_test", + AppSecret = "secret" + }), + NullLogger.Instance, + cardKit, + serviceProvider, + cliExecutor, + chatSessionService); + + try + { + var sessionId = service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_complete_race_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_complete_race_chat", + SenderName = "luhaiyan", + MessageId = "msg-complete-race", + Content = "帮我看下goal命令有执行吗?" + }); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal("执行了,而且还在执行中。", cardKit.Handles[1].InitialContent); + Assert.Equal("执行了,而且还在执行中。", cardKit.Handles[1].FinalContent); + Assert.False(string.IsNullOrWhiteSpace(cardKit.Handles[1].FinalStatusMarkdown)); + Assert.Equal(1, cardKit.ReplyTextCallCount); + Assert.Contains(sessionId[..8], cardKit.LastReplyTextContent, StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + [Fact] public async Task HandleIncomingMessageAsync_WithSessionOverflowMenu_ResumesPulseAfterQuietWindow() { @@ -1429,17 +2187,38 @@ public async Task HandleIncomingMessageAsync_WithSessionOverflowMenu_ResumesPuls } [Fact] - public async Task HandleIncomingMessageAsync_QueuesReplyTtsAfterSuccessfulCompletionAndAssistantPersistence() + public async Task HandleIncomingMessageAsync_QueuesReplyDocumentAfterSuccessfulCompletionAndAssistantPersistence() { var repository = CreateRepository(out var repositoryProxy); var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); var cardKit = new StreamingRecordingFeishuCardKitClient(); var chatSessionService = new RecordingChatSessionService(); - var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); 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 cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + Adapter = new CodexAdapter(), + EnableStreamParsing = true, + StreamChunks = + [ + new StreamOutputChunk + { + Content = """ + {"type":"thread.started","thread_id":"thread-1"} + {"type":"item.updated","item":{"type":"agent_message","text":"过程说明"}} + {"type":"item.updated","item":{"type":"agent_message","text":"结论内容","phase":"final_answer"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ] + }; var serviceProvider = new TestServiceProvider( repository, sessionDirectoryService, @@ -1474,11 +2253,12 @@ public async Task HandleIncomingMessageAsync_QueuesReplyTtsAfterSuccessfulComple replyTtsOrchestrator.OnQueued = request => { - Assert.Equal("补充完成", request.Output); + Assert.Equal("过程说明结论内容", request.Output); + Assert.Equal("结论内容", request.FinalAnswerOutput); Assert.Contains( chatSessionService.Messages[sessionId], - message => message.Role == "assistant" && message.Content == "补充完成" && message.IsCompleted); - Assert.Equal("补充完成", Assert.Single(cardKit.Handles).FinalContent); + message => message.Role == "assistant" && message.Content == "过程说明结论内容" && message.IsCompleted); + Assert.Equal("过程说明结论内容", Assert.Single(cardKit.Handles).FinalContent); return Task.CompletedTask; }; @@ -1493,9 +2273,13 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage var queued = await replyTtsOrchestrator.WhenQueued.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal("oc_reply_tts_chat", queued.ChatId); + Assert.Equal(sessionId, queued.SessionId); + Assert.Equal("thread-1", queued.CliThreadId); + Assert.Equal("继续", queued.OriginalUserQuestion); Assert.Equal("luhaiyan", queued.Username); Assert.Equal("cli_test", queued.AppId); - Assert.Equal("补充完成", queued.Output); + Assert.Equal("过程说明结论内容", queued.Output); + Assert.Equal("结论内容", queued.FinalAnswerOutput); } finally { @@ -1504,13 +2288,13 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage } [Fact] - public async Task HandleIncomingMessageAsync_DoesNotQueueReplyTtsWhenExecutionErrors() + public async Task HandleIncomingMessageAsync_DoesNotQueueReplyDocumentWhenExecutionErrors() { var repository = CreateRepository(out var repositoryProxy); var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); var cardKit = new StreamingRecordingFeishuCardKitClient(); var chatSessionService = new RecordingChatSessionService(); - var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-reply-tts-error-{Guid.NewGuid():N}"); var workspacePath = Path.Combine(workspaceRoot, "superpowers"); Directory.CreateDirectory(workspacePath); @@ -1564,13 +2348,13 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage } [Fact] - public async Task HandleIncomingMessageAsync_DoesNotQueueReplyTtsForSupersededExecution() + public async Task HandleIncomingMessageAsync_DoesNotQueueReplyDocumentForSupersededExecution() { var repository = CreateRepository(out var repositoryProxy); var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); var cardKit = new StreamingRecordingFeishuCardKitClient(); var chatSessionService = new RecordingChatSessionService(); - var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-reply-tts-superseded-{Guid.NewGuid():N}"); var workspacePath = Path.Combine(workspaceRoot, "superpowers"); Directory.CreateDirectory(workspacePath); @@ -1741,11 +2525,13 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage "execution_control_row", "execution_control_row", "plan_action_row", - "plan_action_row" + "plan_action_row", + "goal_plan_action_row", + "goal_plan_action_row" ], initialChrome.BottomActions.Select(action => action.RowKey).ToArray()); - Assert.Equal(7, chrome.BottomActions.Count); + Assert.Equal(9, chrome.BottomActions.Count); Assert.Equal( [ GoalQuickActionDefaults.StatusButtonText, @@ -1754,12 +2540,16 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage GoalQuickActionDefaults.ResumeButtonText, SuperpowersQuickActionDefaults.ContinueButtonText, SuperpowersQuickActionDefaults.ExecutePlanButtonText, - SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText + SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText, + SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText, + SuperpowersQuickActionDefaults.CompleteWorktreeButtonText ], chrome.BottomActions.Select(action => action.Text).ToArray()); 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(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.CompleteWorktreeButtonText); Assert.Contains(chrome.BottomActions, action => action.Text == "/goal"); Assert.Contains(chrome.BottomActions, action => action.Text == "/goal pause"); Assert.Contains(chrome.BottomActions, action => action.Text == "/goal clear"); @@ -1780,6 +2570,16 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage JsonSerializer.Serialize( Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText).Value), StringComparison.Ordinal); + Assert.Contains( + $"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction}\"", + JsonSerializer.Serialize( + Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText).Value), + StringComparison.Ordinal); + Assert.Contains( + $"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersCompleteWorktreeAction}\"", + JsonSerializer.Serialize( + Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.CompleteWorktreeButtonText).Value), + StringComparison.Ordinal); Assert.Contains( "\"action\":\"status_goal\"", JsonSerializer.Serialize( @@ -2052,11 +2852,13 @@ private sealed class TestServiceProvider( IExternalCliSessionHistoryService? externalCliSessionHistoryService = null, ISuperpowersCapabilityService? superpowersCapabilityService = null, IGoalCapabilityService? goalCapabilityService = null, + IReplyDocumentOrchestrator? replyTtsOrchestrator = null, IFeishuAttachmentDraftService? attachmentDraftService = null) : IServiceProvider, IServiceScopeFactory, IServiceScope { private readonly IExternalCliSessionHistoryService _externalCliSessionHistoryService = externalCliSessionHistoryService ?? new StubExternalCliSessionHistoryService([]); private readonly ISuperpowersCapabilityService _superpowersCapabilityService = superpowersCapabilityService ?? new StubSuperpowersCapabilityService(); private readonly IGoalCapabilityService _goalCapabilityService = goalCapabilityService ?? new StubGoalCapabilityService(); + private readonly IReplyDocumentOrchestrator? _replyTtsOrchestrator = replyTtsOrchestrator; private readonly IFeishuAttachmentDraftService _attachmentDraftService = attachmentDraftService ?? new FeishuAttachmentDraftService(); public object? GetService(Type serviceType) @@ -2106,6 +2908,11 @@ private sealed class TestServiceProvider( return _goalCapabilityService; } + if (serviceType == typeof(IReplyDocumentOrchestrator)) + { + return _replyTtsOrchestrator; + } + if (serviceType == typeof(IFeishuAttachmentDraftService)) { return _attachmentDraftService; @@ -2277,15 +3084,15 @@ public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEn }); } - private sealed class RecordingReplyTtsOrchestrator : IReplyTtsOrchestrator + private sealed class RecordingReplyDocumentOrchestrator : IReplyDocumentOrchestrator { - public List Requests { get; } = new(); + public List Requests { get; } = new(); - public TaskCompletionSource WhenQueued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource WhenQueued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public Func? OnQueued { get; set; } + public Func? OnQueued { get; set; } - public async Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request) + public async Task QueueCompletedReplyAsync(FeishuCompletedReplyDocumentRequest request) { Requests.Add(request); WhenQueued.TrySetResult(request); @@ -2372,10 +3179,16 @@ private class StreamingRecordingFeishuCardKitClient : IFeishuCardKitClient { public List Handles { get; } = new(); + public int SendTextCallCount { get; private set; } + public int ReplyTextCallCount { get; private set; } public string? LastReplyTextContent { get; private set; } + public List SentTextMessages { get; } = []; + + public List RepliedTextMessages { get; } = []; + public (byte[] Content, string FileName, string MimeType) DownloadedResource { get; set; } = (Array.Empty(), "attachment.bin", "application/octet-stream"); @@ -2387,6 +3200,12 @@ private class StreamingRecordingFeishuCardKitClient : IFeishuCardKitClient public int? FailUpdateOnAttempt { get; set; } + public Queue FailUpdateAttemptSequence { get; } = new(); + + public Queue FailFinishAttemptSequence { get; } = new(); + + public Queue ThrowOverflowOnCreateHandleSequence { get; } = new(); + public string? LastReplyRawCardMessageId { get; private set; } public string? LastReplyRawCardJson { get; private set; } @@ -2401,7 +3220,11 @@ public Task SendCardMessageAsync(string chatId, string cardId, Cancellat => Task.FromResult($"message-{cardId}"); public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) - => Task.FromResult("message-text"); + { + SendTextCallCount++; + SentTextMessages.Add(content); + return Task.FromResult($"message-text-{SendTextCallCount}"); + } public Task ReplyCardMessageAsync(string replyMessageId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => Task.FromResult($"reply-{cardId}"); @@ -2410,6 +3233,7 @@ public Task ReplyTextMessageAsync(string replyMessageId, string content, { ReplyTextCallCount++; LastReplyTextContent = content; + RepliedTextMessages.Add(content); return Task.FromResult($"reply-text-{ReplyTextCallCount}"); } @@ -2421,6 +3245,17 @@ public virtual Task DownloadIncomingAttachmentAsync( public Task CreateStreamingHandleAsync(string chatId, string? replyMessageId, string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, FeishuStreamingCardChrome? chrome = null) { + if (ThrowOverflowOnCreateHandleSequence.Count > 0 && ThrowOverflowOnCreateHandleSequence.Dequeue()) + { + throw new InvalidOperationException("Create CardKit card failed: card over max size (code: 200860)"); + } + + var failUpdateOnAttempt = FailUpdateAttemptSequence.Count > 0 + ? FailUpdateAttemptSequence.Dequeue() + : FailUpdateOnAttempt; + var failFinishOnAttempt = FailFinishAttemptSequence.Count > 0 + ? FailFinishAttemptSequence.Dequeue() + : null; var record = new StreamingHandleRecord { CardId = $"card-{Handles.Count + 1}", @@ -2443,7 +3278,7 @@ public Task CreateStreamingHandleAsync(string chatId, str (content, _) => { record.UpdateAttemptCount++; - if (FailUpdateOnAttempt.HasValue && record.UpdateAttemptCount >= FailUpdateOnAttempt.Value) + if (failUpdateOnAttempt.HasValue && record.UpdateAttemptCount >= failUpdateOnAttempt.Value) { return Task.FromResult(false); } @@ -2457,6 +3292,12 @@ public Task CreateStreamingHandleAsync(string chatId, str }, (content, _) => { + record.FinishAttemptCount++; + if (failFinishOnAttempt.HasValue && record.FinishAttemptCount == failFinishOnAttempt.Value) + { + return Task.FromResult(false); + } + record.FinalContent = content; record.FinalStatusMarkdown = chrome?.StatusMarkdown; record.FinalChromeSnapshot = CloneChrome(chrome); @@ -2578,39 +3419,143 @@ public Task ReplyRawCardAsync(string replyMessageId, string cardJson, Ca } } - private sealed class DraftAttachmentRecordingFeishuCardKitClient : StreamingRecordingFeishuCardKitClient - { - public FeishuOptions? LastDownloadOptionsOverride { get; private set; } + private sealed class DraftAttachmentRecordingFeishuCardKitClient : StreamingRecordingFeishuCardKitClient + { + public FeishuOptions? LastDownloadOptionsOverride { get; private set; } + + public int SendRawCardCallCount { get; private set; } + + public string? LastRawCardJson { get; private set; } + + public override Task DownloadIncomingAttachmentAsync( + FeishuIncomingAttachment attachment, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + LastDownloadOptionsOverride = optionsOverride; + return Task.FromResult(new FeishuDownloadedAttachment + { + DisplayName = attachment.DisplayName, + MimeType = attachment.MimeType, + Content = "draft body"u8.ToArray(), + SizeBytes = "draft body"u8.Length + }); + } + + public override Task SendRawCardAsync( + string chatId, + string cardJson, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + SendRawCardCallCount++; + LastRawCardJson = cardJson; + return Task.FromResult("raw-card-1"); + } + } + + private sealed class BackgroundReplacementTokenAwareFeishuCardKitClient : IFeishuCardKitClient + { + public List Handles { get; } = new(); + + public int ReplyTextCallCount { get; private set; } + + public string? LastReplyTextContent { get; private set; } + + public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult($"card-{Handles.Count + 1}"); + + public Task UpdateCardAsync(string cardId, string content, int sequence, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult(true); + + public Task SendCardMessageAsync(string chatId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult($"message-{cardId}"); + + public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult("message-text-1"); + + public Task ReplyCardMessageAsync(string replyMessageId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => Task.FromResult($"reply-{cardId}"); + + public Task ReplyTextMessageAsync(string replyMessageId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + ReplyTextCallCount++; + LastReplyTextContent = content; + return Task.FromResult($"reply-text-{ReplyTextCallCount}"); + } + + public Task DownloadIncomingAttachmentAsync( + FeishuIncomingAttachment attachment, + 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) + { + var isReplacementHandle = Handles.Count > 0; + var record = new StreamingHandleRecord + { + CardId = $"card-{Handles.Count + 1}", + MessageId = $"message-{Handles.Count + 1}", + ReplyMessageId = replyMessageId, + InitialContent = initialContent, + Chrome = chrome, + InitialStatusMarkdown = chrome?.StatusMarkdown + }; + Handles.Add(record); + + return Task.FromResult(new FeishuStreamingHandle( + record.CardId, + record.MessageId, + (content, _) => + { + record.UpdateAttemptCount++; + if (!isReplacementHandle && record.UpdateAttemptCount >= 2) + { + return Task.FromResult(false); + } + + record.Updates.Add(content); + return Task.FromResult(true); + }, + (content, _) => + { + record.FinishAttemptCount++; + if (isReplacementHandle && cancellationToken.IsCancellationRequested) + { + return Task.FromResult(false); + } - public int SendRawCardCallCount { get; private set; } + record.FinalContent = content; + record.FinalStatusMarkdown = chrome?.StatusMarkdown; + return Task.FromResult(true); + }, + throttleMs: 0)); + } - public string? LastRawCardJson { get; private set; } + public Task SendRawCardAsync(string chatId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); - public override Task DownloadIncomingAttachmentAsync( - FeishuIncomingAttachment attachment, - CancellationToken cancellationToken = default, - FeishuOptions? optionsOverride = null) - { - LastDownloadOptionsOverride = optionsOverride; - return Task.FromResult(new FeishuDownloadedAttachment - { - DisplayName = attachment.DisplayName, - MimeType = attachment.MimeType, - Content = "draft body"u8.ToArray(), - SizeBytes = "draft body"u8.Length - }); - } + public Task ReplyElementsCardAsync(string replyMessageId, FeishuNetSdk.Im.Dtos.ElementsCardV2Dto card, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); - public override Task SendRawCardAsync( - string chatId, - string cardJson, + public Task ReplyRawCardAsync(string replyMessageId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync( + string messageId, + string fileKey, + string resourceType, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) - { - SendRawCardCallCount++; - LastRawCardJson = cardJson; - return Task.FromResult("raw-card-1"); - } + => throw new NotSupportedException(); } private static string? TryGetActionName(object value) @@ -2637,6 +3582,8 @@ private sealed class StreamingHandleRecord public int UpdateAttemptCount { get; set; } + public int FinishAttemptCount { get; set; } + public string? FinalContent { get; set; } public string? FinalStatusMarkdown { get; set; } @@ -2914,6 +3861,8 @@ private sealed class PromptCapturingCliExecutor(string workspacePath) : ICliExec public string FinalContent { get; set; } = "\u8865\u5145\u5b8c\u6210\n"; + public TimeSpan CompletionDelay { get; set; } = TimeSpan.Zero; + public ICliToolAdapter? Adapter { get; set; } public bool EnableStreamParsing { get; set; } @@ -2922,6 +3871,8 @@ private sealed class PromptCapturingCliExecutor(string workspacePath) : ICliExec public List? StreamChunks { get; set; } + public List? StreamChunkDelays { get; set; } + public ICliToolAdapter? GetAdapter(CliToolConfig tool) => Adapter; public ICliToolAdapter? GetAdapterById(string toolId) => Adapter; @@ -2959,9 +3910,14 @@ public async IAsyncEnumerable ExecuteStreamAsync( if (StreamChunks is { Count: > 0 }) { - foreach (var chunk in StreamChunks) + for (var index = 0; index < StreamChunks.Count; index++) { - yield return chunk; + if (StreamChunkDelays is { Count: > 0 } && index < StreamChunkDelays.Count && StreamChunkDelays[index] > TimeSpan.Zero) + { + await Task.Delay(StreamChunkDelays[index], cancellationToken); + } + + yield return StreamChunks[index]; } yield break; @@ -2973,6 +3929,11 @@ public async IAsyncEnumerable ExecuteStreamAsync( IsCompleted = false }; + if (CompletionDelay > TimeSpan.Zero) + { + await Task.Delay(CompletionDelay, cancellationToken); + } + yield return new StreamOutputChunk { Content = string.Empty, @@ -2996,9 +3957,14 @@ public async IAsyncEnumerable ExecuteStreamAsync( if (StreamChunks is { Count: > 0 }) { - foreach (var chunk in StreamChunks) + for (var index = 0; index < StreamChunks.Count; index++) { - yield return chunk; + if (StreamChunkDelays is { Count: > 0 } && index < StreamChunkDelays.Count && StreamChunkDelays[index] > TimeSpan.Zero) + { + await Task.Delay(StreamChunkDelays[index], cancellationToken); + } + + yield return StreamChunks[index]; } yield break; @@ -3010,6 +3976,11 @@ public async IAsyncEnumerable ExecuteStreamAsync( IsCompleted = false }; + if (CompletionDelay > TimeSpan.Zero) + { + await Task.Delay(CompletionDelay, cancellationToken); + } + yield return new StreamOutputChunk { Content = string.Empty, @@ -3272,9 +4243,11 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); Assert.Contains(chrome.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.CompleteWorktreeButtonText); Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText); Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText); + Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText); Assert.DoesNotContain(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.StopButtonText); Assert.Equal( [ @@ -3282,6 +4255,7 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage "goal_row_1", "goal_row_2", "goal_row_2", + "goal_row_3", "goal_row_3" ], chrome.BottomActions.Select(action => action.RowKey).ToArray()); @@ -3293,6 +4267,280 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage } } + [Fact] + public async Task HandleIncomingMessageAsync_WhenGoalRuntimeTurnBoundary_RotatesToNewCardAndKeepsGoalButtonsOnLatestTurn() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + var chatSessionService = new RecordingChatSessionService(); + var replyTtsOrchestrator = new RecordingReplyDocumentOrchestrator(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-goal-runtime-turn-boundary-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + const string sessionId = "44444444-goal-runtime-turn-boundary"; + repositoryProxy.Store(new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + WorkspacePath = workspacePath, + ToolId = "codex", + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new SessionToolLaunchOverride + { + UsePersistentProcess = false, + UseGoalRuntime = true + } + }), + FeishuChatKey = "oc_goal_runtime_turn_boundary_chat", + IsFeishuActive = true, + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + UpdatedAt = DateTime.UtcNow + }); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + Adapter = new CodexAdapter(), + EnableStreamParsing = true, + StreamChunks = + [ + new StreamOutputChunk + { + Content = """ + {"type":"thread.started","thread_id":"thread-1"} + {"type":"item.updated","item":{"type":"agent_message","text":"第一轮过程"}} + {"type":"item.completed","item":{"type":"agent_message","text":"第一轮结论","phase":"final_answer"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsTurnBoundary = true, + IsCompleted = false + }, + new StreamOutputChunk + { + Content = """ + {"type":"item.updated","item":{"type":"agent_message","text":"第二轮过程"}} + {"type":"item.completed","item":{"type":"agent_message","text":"第二轮结论","phase":"final_answer"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = string.Empty, + IsCompleted = true + } + ] + }; + + var serviceProvider = new TestServiceProvider( + repository, + sessionDirectoryService, + new StubFeishuUserBindingService(), + new StubUserFeishuBotConfigService(), + new StubUserContextService(), + replyTtsOrchestrator: replyTtsOrchestrator); + + var service = new FeishuChannelService( + Options.Create(new FeishuOptions + { + Enabled = true, + AppId = "cli_test", + AppSecret = "secret" + }), + NullLogger.Instance, + cardKit, + serviceProvider, + cliExecutor, + chatSessionService, + replyTtsOrchestrator); + + await service.StartAsync(CancellationToken.None); + try + { + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + MessageId = "msg-goal-runtime-turn-boundary", + ChatId = "oc_goal_runtime_turn_boundary_chat", + Content = "继续", + SenderName = "luhaiyan" + }); + + Assert.Equal(2, replyTtsOrchestrator.Requests.Count); + Assert.Equal("第一轮过程第一轮结论", replyTtsOrchestrator.Requests[0].Output); + Assert.Equal("第一轮结论", replyTtsOrchestrator.Requests[0].FinalAnswerOutput); + Assert.Equal("第二轮过程第二轮结论", replyTtsOrchestrator.Requests[1].Output); + Assert.Equal("第二轮结论", replyTtsOrchestrator.Requests[1].FinalAnswerOutput); + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal("第一轮过程第一轮结论", cardKit.Handles[0].FinalContent); + Assert.Contains("Goal继续中", cardKit.Handles[0].FinalStatusMarkdown, StringComparison.Ordinal); + Assert.NotNull(cardKit.Handles[0].FinalChromeSnapshot); + Assert.Null(cardKit.Handles[0].FinalChromeSnapshot!.BottomPrompt); + Assert.Empty(cardKit.Handles[0].FinalChromeSnapshot.AdditionalBottomPrompts); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); + Assert.DoesNotContain(cardKit.Handles[0].FinalChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); + + Assert.NotNull(cardKit.Handles[1].InitialChromeSnapshot); + Assert.Null(cardKit.Handles[1].InitialChromeSnapshot!.BottomPrompt); + Assert.Single(cardKit.Handles[1].InitialChromeSnapshot.AdditionalBottomPrompts); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.StatusButtonText); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.PauseButtonText); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.ClearButtonText); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.ResumeButtonText); + Assert.Contains(cardKit.Handles[1].InitialChromeSnapshot.BottomActions, action => action.Text == GoalQuickActionDefaults.TemporaryExitButtonText); + Assert.False(string.IsNullOrWhiteSpace(cardKit.Handles[1].InitialContent)); + Assert.DoesNotContain("第一轮过程第一轮结论", cardKit.Handles[1].InitialContent, StringComparison.Ordinal); + Assert.Equal("第二轮过程第二轮结论", cardKit.Handles[1].FinalContent); + } + finally + { + await service.StopAsync(CancellationToken.None); + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_WhenGoalRuntimeCardUpdateFails_DelaysReplacementUntilNextNewOutput() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-goal-runtime-deferred-replacement-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + Adapter = new CodexAdapter(), + EnableStreamParsing = true, + StreamChunks = + [ + new StreamOutputChunk + { + Content = """ + {"type":"thread.started","thread_id":"thread-goal-runtime-deferred"} + {"type":"item.updated","item":{"type":"agent_message","text":"第一段"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = """ + {"type":"item.updated","item":{"type":"agent_message","text":"第二段"}} + """ + "\n", + IsCompleted = false + }, + new StreamOutputChunk + { + Content = """ + {"type":"item.completed","item":{"type":"agent_message","text":"最终结论","phase":"final_answer"}} + """ + "\n", + IsCompleted = true + } + ], + StreamChunkDelays = + [ + TimeSpan.Zero, + TimeSpan.FromMilliseconds(1200), + TimeSpan.Zero + ] + }; + + 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); + + await service.StartAsync(CancellationToken.None); + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_goal_runtime_deferred_replacement_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + var sessionId = service.GetCurrentSession("oc_goal_runtime_deferred_replacement_chat", "luhaiyan"); + Assert.NotNull(sessionId); + + await repositoryProxy.UpdateAsync(new ChatSessionEntity + { + SessionId = sessionId!, + Username = "luhaiyan", + ToolId = "codex", + WorkspacePath = workspacePath, + FeishuChatKey = "oc_goal_runtime_deferred_replacement_chat", + IsWorkspaceValid = true, + IsFeishuActive = true, + ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["codex"] = new SessionToolLaunchOverride + { + UsePersistentProcess = false, + UseGoalRuntime = true + } + }), + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + UpdatedAt = DateTime.UtcNow + }); + + var handleMessageTask = service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + MessageId = "msg-goal-runtime-deferred-replacement", + ChatId = "oc_goal_runtime_deferred_replacement_chat", + Content = "继续", + SenderName = "luhaiyan" + }); + + await Task.Delay(400, TestContext.Current.CancellationToken); + + Assert.Single(cardKit.Handles); + Assert.Null(cardKit.Handles[0].FinalContent); + + await handleMessageTask; + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal( + "第一段第二段\n\n当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。", + cardKit.Handles[0].FinalContent); + Assert.Equal("第一段第二段最终结论", cardKit.Handles[1].FinalContent); + } + finally + { + await service.StopAsync(CancellationToken.None); + Directory.Delete(workspaceRoot, recursive: true); + } + } + private sealed class StubExternalCliSessionHistoryService(IEnumerable messages) : IExternalCliSessionHistoryService { @@ -3329,6 +4577,12 @@ public Task> GetRecentMessagesAsync( LastCliThreadId = cliThreadId; return Task.FromResult(_messages.TakeLast(maxCount).ToList()); } + + public Task GetCodexFinalAnswerTextAsync( + string cliThreadId, + string? workspacePath = null, + CancellationToken cancellationToken = default) + => Task.FromResult(null); } private sealed class ErrorCliExecutor(string workspacePath) : ICliExecutorService @@ -3518,6 +4772,12 @@ public void Store(ChatSessionEntity session) _sessions[session.SessionId] = Clone(session); } + public Task UpdateAsync(ChatSessionEntity session) + { + _sessions[session.SessionId] = Clone(session); + return Task.FromResult(true); + } + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) { switch (targetMethod?.Name) diff --git a/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs b/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs index 22d5082..b54ac9d 100644 --- a/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs @@ -46,24 +46,150 @@ public void BuildCommandListCardV2_UsesCategoryButtonCallback() } [Fact] - public void BuildCommandListCard_IncludesReplyTtsToggle_WhenEnabled() + public void BuildCommandListCard_IncludesReplyDocumentButtons_WhenBothReplyDocumentsEnabled() { - var cardJson = _builder.BuildCommandListCard(CreateCategories(), replyTtsEnabled: true); + var cardJson = _builder.BuildCommandListCard( + CreateCategories(), + fullReplyDocEnabled: true, + finalReplyDocEnabled: 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")); + Assert.True(ContainsStringValue(elements, "完整回复文档:开")); + Assert.True(ContainsStringValue(elements, "结论回复文档:开")); + Assert.True(ContainsAction(elements, FeishuHelpCardAction.ToggleFullReplyDocAction)); + Assert.True(ContainsAction(elements, FeishuHelpCardAction.ToggleFinalReplyDocAction)); + } + + [Fact] + public void BuildCommandListCard_IncludesListeningReplyDocumentButtons_WhenListeningReplyDocumentsEnabled() + { + var cardJson = _builder.BuildCommandListCard( + CreateCategories(), + fullReplyDocEnabled: false, + finalReplyDocEnabled: false, + audioFullReplyDocEnabled: true, + audioFinalReplyDocEnabled: true); + using var document = JsonDocument.Parse(cardJson); + var elements = document.RootElement.GetProperty("body").GetProperty("elements"); + + Assert.True(ContainsStringValue(elements, "听完整文档:开")); + Assert.True(ContainsStringValue(elements, "听结论文档:开")); + Assert.True(ContainsAction(elements, FeishuHelpCardAction.ToggleAudioFullReplyDocAction)); + Assert.True(ContainsAction(elements, FeishuHelpCardAction.ToggleAudioFinalReplyDocAction)); + } + + [Fact] + public void BuildCommandListCardV2_IncludesReplyDocumentButtons_WhenOnlyFinalReplyDocumentEnabled() + { + var card = _builder.BuildCommandListCardV2( + CreateCategories(), + fullReplyDocEnabled: false, + finalReplyDocEnabled: true); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsStringValue(bodyDoc.RootElement, "完整回复文档:关")); + Assert.True(ContainsStringValue(bodyDoc.RootElement, "结论回复文档:开")); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleFullReplyDocAction)); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleFinalReplyDocAction)); + } + + [Fact] + public void BuildCommandListCardV2_IncludesSetDocumentAdminButton() + { + var card = _builder.BuildCommandListCardV2( + CreateCategories(), + showRefreshButton: true); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsAction(bodyDoc.RootElement, "set_document_admin_openid")); + Assert.True(ContainsStringFragment(bodyDoc.RootElement, "OpenID")); + } + + [Fact] + public void BuildCommandListCardV2_WhenRefreshButtonHidden_StillIncludesSetDocumentAdminButton() + { + var card = _builder.BuildCommandListCardV2( + CreateCategories(), + showRefreshButton: false); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsAction(bodyDoc.RootElement, "set_document_admin_openid")); + Assert.True(ContainsStringFragment(bodyDoc.RootElement, "OpenID")); + } + + [Fact] + public void BuildCommandListCardV2_WhenRefreshButtonHidden_StillIncludesReplyDocumentButtons() + { + var card = _builder.BuildCommandListCardV2( + CreateCategories(), + showRefreshButton: false, + fullReplyDocEnabled: true, + finalReplyDocEnabled: false, + audioFullReplyDocEnabled: true, + audioFinalReplyDocEnabled: false); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleFullReplyDocAction)); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleFinalReplyDocAction)); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleAudioFullReplyDocAction)); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleAudioFinalReplyDocAction)); + } + + [Fact] + public void BuildCommandListCardV2_WhenReferencedMarkdownImportEnabled_ContainsMarkdownImportToggle() + { + var card = _builder.BuildCommandListCardV2( + CreateCategories(), + showRefreshButton: false, + referencedMarkdownDocImportEnabled: true); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsStringValue(bodyDoc.RootElement, "MD转在线文档:开")); + Assert.True(ContainsAction(bodyDoc.RootElement, "toggle_referenced_markdown_doc_import")); + } + + [Fact] + public void BuildFilteredCardV2_WhenReferencedMarkdownImportDisabled_ContainsMarkdownImportToggle() + { + var card = _builder.BuildFilteredCardV2( + CreateCategories(), + "help", + referencedMarkdownDocImportEnabled: false); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsStringValue(bodyDoc.RootElement, "MD转在线文档:关")); + Assert.True(ContainsAction(bodyDoc.RootElement, "toggle_referenced_markdown_doc_import")); + } + + [Fact] + public void BuildFilteredCardV2_IncludesListeningReplyDocumentButtons_WhenOnlyListeningFinalReplyDocumentEnabled() + { + var card = _builder.BuildFilteredCardV2( + CreateCategories(), + "help", + fullReplyDocEnabled: false, + finalReplyDocEnabled: false, + audioFullReplyDocEnabled: false, + audioFinalReplyDocEnabled: true); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsStringValue(bodyDoc.RootElement, "听完整文档:关")); + Assert.True(ContainsStringValue(bodyDoc.RootElement, "听结论文档:开")); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleAudioFullReplyDocAction)); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleAudioFinalReplyDocAction)); } [Fact] - public void BuildCommandListCardV2_IncludesReplyTtsToggle_WhenDisabled() + public void BuildFilteredCardV2_IncludesSetDocumentAdminButton() { - var card = _builder.BuildCommandListCardV2(CreateCategories(), replyTtsEnabled: false); + var card = _builder.BuildFilteredCardV2( + CreateCategories(), + "help"); using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); - Assert.True(ContainsStringValue(bodyDoc.RootElement, "语音回复:关")); - Assert.True(ContainsAction(bodyDoc.RootElement, "toggle_reply_tts")); + Assert.True(ContainsAction(bodyDoc.RootElement, "set_document_admin_openid")); + Assert.True(ContainsStringFragment(bodyDoc.RootElement, "OpenID")); } [Fact] @@ -93,6 +219,22 @@ public void BuildFilteredCardV2_UsesCommandButtonCallback() Assert.False(ContainsProperty(bodyDoc.RootElement, "extra")); } + [Fact] + public void BuildFilteredCardV2_IncludesReplyDocumentButtons_WhenOnlyFinalReplyDocumentEnabled() + { + var card = _builder.BuildFilteredCardV2( + CreateCategories(), + "help", + fullReplyDocEnabled: false, + finalReplyDocEnabled: true); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsStringValue(bodyDoc.RootElement, "完整回复文档:关")); + Assert.True(ContainsStringValue(bodyDoc.RootElement, "结论回复文档:开")); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleFullReplyDocAction)); + Assert.True(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ToggleFinalReplyDocAction)); + } + [Fact] public void BuildCommandListCard_AppendsSuperpowersQuickActionsFooter() { @@ -175,7 +317,6 @@ public void BuildCommandListCard_AppendsGoalQuickActionFooter_BelowSuperpowers() Assert.True(ContainsStringValue(elements, "/goal pause")); Assert.True(ContainsStringValue(elements, "/goal clear")); Assert.True(ContainsStringValue(elements, "/goal resume")); - Assert.True(ContainsAction(elements, "status_goal")); Assert.True(ContainsAction(elements, "pause_goal")); Assert.True(ContainsAction(elements, "clear_goal")); Assert.True(ContainsAction(elements, "resume_goal")); @@ -414,6 +555,17 @@ private static bool ContainsStringValue(JsonElement element, string expected) }; } + private static bool ContainsStringFragment(JsonElement element, string expectedFragment) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString()?.Contains(expectedFragment, StringComparison.Ordinal) == true, + JsonValueKind.Object => element.EnumerateObject().Any(property => ContainsStringFragment(property.Value, expectedFragment)), + JsonValueKind.Array => element.EnumerateArray().Any(item => ContainsStringFragment(item, expectedFragment)), + _ => false + }; + } + private static JsonElement GetInputByName(JsonElement element, string inputName) { if (TryGetInputByName(element, inputName, out var input)) diff --git a/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs deleted file mode 100644 index 778bfe0..0000000 --- a/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs +++ /dev/null @@ -1,537 +0,0 @@ -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/FeishuStreamingCardSessionTests.cs b/WebCodeCli.Domain.Tests/FeishuStreamingCardSessionTests.cs new file mode 100644 index 0000000..6ae1a9c --- /dev/null +++ b/WebCodeCli.Domain.Tests/FeishuStreamingCardSessionTests.cs @@ -0,0 +1,108 @@ +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public class FeishuStreamingCardSessionTests +{ + [Fact] + public async Task UpdateAsync_AllowsUpToTenReplacementCardsBeforeStopping() + { + var createdHandles = new List(); + var replacementFactoryCalls = 0; + var currentHandle = CreateAlwaysFailingHandle("card-0"); + createdHandles.Add(currentHandle); + + var session = new FeishuStreamingCardSession( + currentHandle, + (stoppedHandle, latestContent, cancellationToken) => + { + replacementFactoryCalls++; + var replacement = CreateAlwaysFailingHandle($"card-{replacementFactoryCalls}"); + createdHandles.Add(replacement); + return Task.FromResult(replacement); + }); + + for (var attempt = 1; attempt <= 10; attempt++) + { + var updated = await session.UpdateAsync($"content-{attempt}", CancellationToken.None); + Assert.True(updated); + Assert.Equal($"card-{attempt}", session.CurrentHandle.CardId); + } + + var eleventhReplacement = await session.UpdateAsync("content-11", CancellationToken.None); + + Assert.False(eleventhReplacement); + Assert.Equal("card-10", session.CurrentHandle.CardId); + Assert.Equal(10, replacementFactoryCalls); + Assert.Equal(11, createdHandles.Count); + } + + [Fact] + public async Task UpdateAsync_WhenDeferredReplacementEnabled_WaitsForForegroundUpdateBeforeCreatingReplacement() + { + var createdHandles = new List(); + var currentHandle = CreateHandle("card-0", failUpdateOnAttempt: 1); + createdHandles.Add(currentHandle); + + var session = new FeishuStreamingCardSession( + currentHandle, + (stoppedHandle, latestContent, cancellationToken) => + { + var replacement = CreateHandle("card-1"); + createdHandles.Add(replacement); + return Task.FromResult(replacement); + }, + deferReplacementUntilNextForegroundUpdate: true); + + var firstUpdate = await session.UpdateAsync( + "content-1", + CancellationToken.None, + allowPendingReplacementActivation: true); + var quietBackgroundUpdate = await session.UpdateAsync( + "content-2", + CancellationToken.None, + allowPendingReplacementActivation: false); + + Assert.True(firstUpdate); + Assert.True(quietBackgroundUpdate); + Assert.True(session.HasPendingReplacement); + Assert.Equal("card-0", session.CurrentHandle.CardId); + Assert.Single(createdHandles); + + var foregroundRecoveryUpdate = await session.UpdateAsync( + "content-2", + CancellationToken.None, + allowPendingReplacementActivation: true); + + Assert.True(foregroundRecoveryUpdate); + Assert.False(session.HasPendingReplacement); + Assert.Equal("card-1", session.CurrentHandle.CardId); + Assert.Equal(2, createdHandles.Count); + } + + private static FeishuStreamingHandle CreateAlwaysFailingHandle(string cardId) + { + return new FeishuStreamingHandle( + cardId, + $"message-{cardId}", + (content, sequence) => Task.FromResult(false), + (content, sequence) => Task.FromResult(false), + throttleMs: 0); + } + + private static FeishuStreamingHandle CreateHandle(string cardId, int? failUpdateOnAttempt = null) + { + var updateAttemptCount = 0; + return new FeishuStreamingHandle( + cardId, + $"message-{cardId}", + (content, sequence) => + { + updateAttemptCount++; + return Task.FromResult(!failUpdateOnAttempt.HasValue || updateAttemptCount < failUpdateOnAttempt.Value); + }, + (content, sequence) => Task.FromResult(true), + throttleMs: 0); + } +} diff --git a/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs b/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs index 23abd9d..2ceeddf 100644 --- a/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs +++ b/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs @@ -1,5 +1,6 @@ using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Domain.Service; +using System.Text; namespace WebCodeCli.Domain.Tests; @@ -22,4 +23,79 @@ public void BuildGoalPrompt_ReturnsNullForBlankInput(string? input) { Assert.Null(GoalPromptBuilder.BuildGoalPrompt(input)); } + + [Fact] + public void BuildSubagentPlanGoalPrompt_RequiresClosingAllPlanChecklistItemsBeforeGoalCompletion() + { + var prompt = GoalPromptBuilder.BuildSubagentPlanGoalPrompt(); + + Assert.StartsWith("/goal ", prompt, StringComparison.Ordinal); + Assert.Contains("plan文档内的[ ]check list都检查收口后,变成[x]后才算goal完成", prompt, StringComparison.Ordinal); + } + + [Fact] + public void BuildSubagentPlanGoalPrompt_WhenReplyReferencesPlanMarkdown_UsesReferencedPlanDocument() + { + var workspaceRoot = CreateWorkspaceWithFiles( + ("docs/superpowers/plans/approved-plan.md", "# approved")); + + try + { + var reply = """ + 先按这个计划收口: + [approved plan](docs/superpowers/plans/approved-plan.md) + """; + + var prompt = GoalPromptBuilder.BuildSubagentPlanGoalPrompt(reply, workspaceRoot); + + Assert.StartsWith("/goal ", prompt, StringComparison.Ordinal); + Assert.Contains("docs/superpowers/plans/approved-plan.md", prompt, StringComparison.Ordinal); + Assert.Contains("该plan文档内的[ ]check list", prompt, StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public void BuildSubagentPlanGoalPrompt_WhenReplyMentionsBarePlanMarkdownFileName_UsesResolvedPlanDocument() + { + var workspaceRoot = CreateWorkspaceWithFiles( + ("docs/superpowers/plans/2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md", "# approved")); + + try + { + var reply = """ + 是,这份 2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md 就是接下来要执行的 plan。 + """; + + var prompt = GoalPromptBuilder.BuildSubagentPlanGoalPrompt(reply, workspaceRoot); + + Assert.StartsWith("/goal ", prompt, StringComparison.Ordinal); + Assert.Contains( + "docs/superpowers/plans/2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md", + prompt, + StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + private static string CreateWorkspaceWithFiles(params (string RelativePath, string Content)[] files) + { + var root = Path.Combine(Path.GetTempPath(), "webcode-goal-prompt-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + foreach (var file in files) + { + var absolutePath = Path.Combine(root, file.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)!); + File.WriteAllText(absolutePath, file.Content, Encoding.UTF8); + } + + return root; + } } diff --git a/WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs b/WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs new file mode 100644 index 0000000..6a668d4 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs @@ -0,0 +1,215 @@ +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ListeningReplyDocumentFormatterTests +{ + private const string FilePlaceholder1 = "\u6587\u4ef6\u5185\u5bb91"; + private const string FilePlaceholder2 = "\u6587\u4ef6\u5185\u5bb92"; + private const string FilePlaceholder3 = "\u6587\u4ef6\u5185\u5bb93"; + private const string FilePlaceholder4 = "\u6587\u4ef6\u5185\u5bb94"; + private const string CommandPlaceholder1 = "\u547d\u4ee4\u5185\u5bb91"; + private const string CommandPlaceholder2 = "\u547d\u4ee4\u5185\u5bb92"; + private const char FullwidthColon = '\uFF1A'; + + [Fact] + public void Format_ReplacesDistinctFileReferencesWithSequentialPlaceholders() + { + const string input = "Build succeeded. Current warnings mention /D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor:812 and /D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedSession.razor:241."; + + var output = ListeningReplyDocumentFormatter.Format(input); + + Assert.Contains(FilePlaceholder1, output, StringComparison.Ordinal); + Assert.Contains(FilePlaceholder2, output, StringComparison.Ordinal); + Assert.Contains($"{FilePlaceholder1}{FullwidthColon}/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor:812", output, StringComparison.Ordinal); + Assert.Contains($"{FilePlaceholder2}{FullwidthColon}/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedSession.razor:241", output, StringComparison.Ordinal); + } + + [Fact] + public void Format_ReusesPlaceholderForRepeatedFileReference() + { + const string input = "Check /D:/repo/a.cs:1 first, then review /D:/repo/a.cs:1 again."; + + var output = ListeningReplyDocumentFormatter.Format(input); + var bodyOnly = GetBody(output); + + Assert.Contains(FilePlaceholder1, output, StringComparison.Ordinal); + Assert.DoesNotContain(FilePlaceholder2, output, StringComparison.Ordinal); + Assert.Equal(2, bodyOnly.Split(FilePlaceholder1, StringSplitOptions.None).Length - 1); + Assert.Contains($"{FilePlaceholder1}{FullwidthColon}/D:/repo/a.cs:1", output, StringComparison.Ordinal); + } + + [Fact] + public void Format_LeavesPlainTextUntouched_WhenNoFileReferenceExists() + { + const string input = "Build succeeded. Current warnings are from existing packages only."; + + var output = ListeningReplyDocumentFormatter.Format(input); + + Assert.Equal(input, output); + } + + [Fact] + public void Format_ReplacesRelativePathAndBareFileNames_InMarkdownLinkText() + { + const string input = """ +Package C is now ready for direct AI usage. +Added dedicated skill and local script: +[mmis-page-metadata-operations/skill.md]() +[page-metadata-ops.ps1]() + +Routed the main workflow entry as well: +[authoring-first-automation.md]() +[2026-05-28-mmis-page-metadata-full-stack-rearchitecture-implementation-plan.md]() +"""; + + var output = ListeningReplyDocumentFormatter.Format(input); + var bodyOnly = GetBody(output); + + Assert.DoesNotContain("mmis-page-metadata-operations/skill.md", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("page-metadata-ops.ps1", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("authoring-first-automation.md", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("2026-05-28-mmis-page-metadata-full-stack-rearchitecture-implementation-plan.md", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"[{FilePlaceholder1}]()", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"[{FilePlaceholder2}]()", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"[{FilePlaceholder3}]()", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"[{FilePlaceholder4}]()", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"{FilePlaceholder1}{FullwidthColon}mmis-page-metadata-operations/skill.md", output, StringComparison.Ordinal); + Assert.Contains($"{FilePlaceholder2}{FullwidthColon}page-metadata-ops.ps1", output, StringComparison.Ordinal); + Assert.Contains($"{FilePlaceholder3}{FullwidthColon}authoring-first-automation.md", output, StringComparison.Ordinal); + Assert.Contains($"{FilePlaceholder4}{FullwidthColon}2026-05-28-mmis-page-metadata-full-stack-rearchitecture-implementation-plan.md", output, StringComparison.Ordinal); + } + + [Fact] + public void Format_ReplacesWindowsAndUnixStyleRelativePaths_AsSingleFileReference() + { + const string input = @"Please inspect WmsServerV4\src\Cimc.Tianda.Wms.Application\1397\Custom\MoveOut\DeliveringBillServiceCus1397.cs and docs/agent-notes/2026-05-29.md."; + + var output = ListeningReplyDocumentFormatter.Format(input); + var bodyOnly = GetBody(output); + + Assert.DoesNotContain(@"WmsServerV4\src\Cimc.Tianda.Wms.Application\1397\Custom\MoveOut\DeliveringBillServiceCus1397.cs", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("docs/agent-notes/2026-05-29.md", bodyOnly, StringComparison.Ordinal); + Assert.Contains(FilePlaceholder1, bodyOnly, StringComparison.Ordinal); + Assert.Contains(FilePlaceholder2, bodyOnly, StringComparison.Ordinal); + } + + [Fact] + public void Format_DoesNotRewrite_UrlsEmailsOrVersions() + { + const string input = "See https://open.feishu.cn/document/server-path, contact alice@example.com, and verify version v1.2.3."; + + var output = ListeningReplyDocumentFormatter.Format(input); + var bodyOnly = GetBody(output); + + Assert.Contains("https://open.feishu.cn/document/server-path", bodyOnly, StringComparison.Ordinal); + Assert.Contains("alice@example.com", bodyOnly, StringComparison.Ordinal); + Assert.Contains("v1.2.3", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("\u6587\u4ef6\u5185\u5bb9", output, StringComparison.Ordinal); + } + + [Fact] + public void Format_ReplacesFencedPowerShellBlock_WithCommandPlaceholderAndAppendsCommandsAtEnd() + { + const string input = """ +Validated commands: +```powershell +powershell -NoProfile -File MMIS-Server/scripts/page-metadata-ops.ps1 -Operation publish -PageCode system/user +powershell -NoProfile -File MMIS-Server/scripts/page-metadata-ops.ps1 -Operation verify -PageCode system/user +``` +"""; + + var output = ListeningReplyDocumentFormatter.Format(input); + var bodyOnly = GetBody(output); + + Assert.Contains($"[{CommandPlaceholder1}]", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("```powershell", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("-Operation publish", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"{CommandPlaceholder1}{FullwidthColon}powershell -NoProfile -File MMIS-Server/scripts/page-metadata-ops.ps1 -Operation publish -PageCode system/user", output, StringComparison.Ordinal); + Assert.Contains("powershell -NoProfile -File MMIS-Server/scripts/page-metadata-ops.ps1 -Operation verify -PageCode system/user", output, StringComparison.Ordinal); + } + + [Fact] + public void Format_LeavesInlineBackticksUntouched_WhenTheyAreNotFencedCommands() + { + const string input = "Run `dotnet build` to inspect the output and then review docs/agent-notes/2026-06-09.md."; + + var output = ListeningReplyDocumentFormatter.Format(input); + var bodyOnly = GetBody(output); + + Assert.DoesNotContain($"[{CommandPlaceholder1}]", bodyOnly, StringComparison.Ordinal); + Assert.Contains("`dotnet build`", bodyOnly, StringComparison.Ordinal); + Assert.Contains(FilePlaceholder1, bodyOnly, StringComparison.Ordinal); + Assert.Contains($"{FilePlaceholder1}{FullwidthColon}docs/agent-notes/2026-06-09.md", output, StringComparison.Ordinal); + } + + [Fact] + public void Format_ReplacesMarkdownListCommandItems_WithCommandPlaceholdersAndAppendsCommandsAtEnd() + { + const string input = """ +- `dotnet build WebCodeCli.sln --no-restore -v minimal` +- `dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ListeningReplyDocumentFormatterTests"` +"""; + + var output = ListeningReplyDocumentFormatter.Format(input); + var bodyOnly = GetBody(output); + + Assert.Contains($"- [{CommandPlaceholder1}]", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"- [{CommandPlaceholder2}]", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet build WebCodeCli.sln --no-restore -v minimal", bodyOnly, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter \"FullyQualifiedName~ListeningReplyDocumentFormatterTests\"", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"{CommandPlaceholder1}{FullwidthColon}dotnet build WebCodeCli.sln --no-restore -v minimal", output, StringComparison.Ordinal); + Assert.Contains($"{CommandPlaceholder2}{FullwidthColon}dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter \"FullyQualifiedName~ListeningReplyDocumentFormatterTests\"", output, StringComparison.Ordinal); + } + + [Fact] + public void Format_ReplacesStandaloneCommandLines_WithoutTouchingInlineCommandProse() + { + const string input = """ +dotnet build WebCodeCli.sln --no-restore -v minimal +powershell -NoProfile -File scripts/check.ps1 + +Run `dotnet build` to inspect the output details. +"""; + + var output = ListeningReplyDocumentFormatter.Format(input); + var bodyOnly = GetBody(output); + + Assert.Contains($"[{CommandPlaceholder1}]", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"[{CommandPlaceholder2}]", bodyOnly, StringComparison.Ordinal); + Assert.Contains("Run `dotnet build` to inspect the output details.", bodyOnly, StringComparison.Ordinal); + Assert.Contains($"{CommandPlaceholder1}{FullwidthColon}dotnet build WebCodeCli.sln --no-restore -v minimal", output, StringComparison.Ordinal); + Assert.Contains($"{CommandPlaceholder2}{FullwidthColon}powershell -NoProfile -File scripts/check.ps1", output, StringComparison.Ordinal); + } + + [Fact] + public void Format_LeavesNonCommandFencedCodeBlockUntouched() + { + const string input = """ +```json +{"message":"keep me"} +``` +"""; + + var output = ListeningReplyDocumentFormatter.Format(input); + + Assert.Equal(input, output); + } + + private static string GetBody(string output) + { + var appendixMarkers = new[] + { + $"{Environment.NewLine}{Environment.NewLine}文件内容1:", + $"{Environment.NewLine}{Environment.NewLine}命令内容1:" + }; + + var bodyEnd = appendixMarkers + .Select(marker => output.IndexOf(marker, StringComparison.Ordinal)) + .Where(index => index >= 0) + .DefaultIfEmpty(output.Length) + .Min(); + + return output[..bodyEnd]; + } +} diff --git a/WebCodeCli.Domain.Tests/MarkdownReferenceExtractorTests.cs b/WebCodeCli.Domain.Tests/MarkdownReferenceExtractorTests.cs new file mode 100644 index 0000000..cd6db7f --- /dev/null +++ b/WebCodeCli.Domain.Tests/MarkdownReferenceExtractorTests.cs @@ -0,0 +1,142 @@ +using System.Text; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class MarkdownReferenceExtractorTests +{ + [Fact] + public void Extract_FindsBareMarkdownPathsAndMarkdownLinkTargets() + { + var workspaceRoot = CreateWorkspaceWithFiles( + ("docs/agent-notes/2026-06-09.md", "# note"), + ("docs/superpowers/specs/2026-06-09-feishu-markdown-doc-import-and-rendering-design.md", "# spec")); + + try + { + var text = """ + 先看 docs/agent-notes/2026-06-09.md + 再看 [设计文档](docs/superpowers/specs/2026-06-09-feishu-markdown-doc-import-and-rendering-design.md) + """; + + var results = MarkdownReferenceExtractor.Extract(text, workspaceRoot); + + Assert.Equal( + [ + "docs/agent-notes/2026-06-09.md", + "docs/superpowers/specs/2026-06-09-feishu-markdown-doc-import-and-rendering-design.md" + ], + results.Select(static item => item.RelativePath).ToArray()); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public void Extract_DeduplicatesRepeatedReferences() + { + var workspaceRoot = CreateWorkspaceWithFiles( + ("docs/agent-notes/2026-06-09.md", "# note")); + + try + { + var text = """ + docs/agent-notes/2026-06-09.md + [再次查看](./docs/agent-notes/2026-06-09.md) + """; + + var results = MarkdownReferenceExtractor.Extract(text, workspaceRoot); + + var candidate = Assert.Single(results); + Assert.Equal("docs/agent-notes/2026-06-09.md", candidate.Title); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public void Extract_FindsBareMarkdownFileNamesAndResolvesKnownPlanLikeDocumentPaths() + { + var workspaceRoot = CreateWorkspaceWithFiles( + ("docs/superpowers/plans/2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md", "# plan")); + + try + { + var text = """ + 是,这份 2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md 就是接下来要执行的 plan。 + """; + + var results = MarkdownReferenceExtractor.Extract(text, workspaceRoot); + + var candidate = Assert.Single(results); + Assert.Equal( + "docs/superpowers/plans/2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md", + candidate.RelativePath); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public void Extract_RejectsPathsOutsideWorkspaceRoot() + { + var workspaceRoot = CreateWorkspaceWithFiles( + ("docs/inside.md", "# inside")); + + try + { + var results = MarkdownReferenceExtractor.Extract(@"..\outside\secret.md", workspaceRoot); + Assert.Empty(results); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public void Extract_RejectsRemoteUrlsAndRemoteMarkdownLinks() + { + var workspaceRoot = CreateWorkspaceWithFiles( + ("docs/inside.md", "# inside")); + + try + { + var text = """ + http://localhost:3000/a/b/c.md + https://example.com/docs/readme.md?query=1 + [remote](https://example.com/docs/guide.md) + [remote-with-anchor](https://example.com/docs/guide.md#part) + """; + + var results = MarkdownReferenceExtractor.Extract(text, workspaceRoot); + + Assert.Empty(results); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + private static string CreateWorkspaceWithFiles(params (string RelativePath, string Content)[] files) + { + var root = Path.Combine(Path.GetTempPath(), "webcode-md-extractor-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + foreach (var file in files) + { + var absolutePath = Path.Combine(root, file.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)!); + File.WriteAllText(absolutePath, file.Content, Encoding.UTF8); + } + + return root; + } +} diff --git a/WebCodeCli.Domain.Tests/ReferencedMarkdownDocumentImporterTests.cs b/WebCodeCli.Domain.Tests/ReferencedMarkdownDocumentImporterTests.cs new file mode 100644 index 0000000..33bc93f --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReferencedMarkdownDocumentImporterTests.cs @@ -0,0 +1,642 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReferencedMarkdownDocumentImporterTests +{ + [Fact] + public async Task ImportMissingAsync_WhenTrackedMarkdownIsUnchanged_ReusesTrackedDocumentWithoutReimport() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + var stateStore = new InMemoryReferencedMarkdownImportStateStore(); + var candidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + + try + { + var existingDocument = new FeishuCloudDocumentInfo + { + DocumentId = "doc-existing", + RootBlockId = "root-existing", + Url = "https://feishu.cn/docx/doc-existing" + }; + + await SeedTrackingStateAsync(stateStore, "fld-session", candidate, existingDocument); + + var cardKit = new TrackingFeishuCardKitClient(); + cardKit.ExistingFolderDocumentsByTitle[candidate.Title] = existingDocument; + + var importer = CreateImporter(stateStore); + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [candidate], + documentAdminOpenId: null, + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(cardKit.ImportedMarkdownDocuments); + Assert.Empty(cardKit.ConvertedMarkdownRequests); + Assert.Empty(cardKit.DeletedChildRanges); + Assert.Single(cardKit.TextMessages); + Assert.Contains("已复用Markdown在线文档", cardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("https://feishu.cn/docx/doc-existing", cardKit.TextMessages[0], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task ImportMissingAsync_WhenTrackedMarkdownChanges_OverwritesExistingDocumentAndKeepsLink() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + var stateStore = new InMemoryReferencedMarkdownImportStateStore(); + var candidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + + try + { + var existingDocument = new FeishuCloudDocumentInfo + { + DocumentId = "doc-existing", + RootBlockId = "root-existing", + Url = "https://feishu.cn/docx/doc-existing" + }; + + await SeedTrackingStateAsync(stateStore, "fld-session", candidate, existingDocument); + await File.WriteAllTextAsync(candidate.AbsolutePath, "# updated note", Encoding.UTF8, TestContext.Current.CancellationToken); + + var cardKit = new TrackingFeishuCardKitClient(); + cardKit.ExistingFolderDocumentsByTitle[candidate.Title] = existingDocument; + cardKit.ChildBlockIdsByDocumentAndBlock[(existingDocument.DocumentId, existingDocument.RootBlockId)] = ["blk-1", "blk-2", "blk-3"]; + cardKit.ConvertedBlocks.Add(ParseJsonElement("""{"block_type":2,"text":{"elements":[{"text_run":{"content":"updated note","text_element_style":{}}}]}}""")); + + var importer = CreateImporter(stateStore); + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [candidate], + documentAdminOpenId: null, + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(cardKit.ImportedMarkdownDocuments); + Assert.Single(cardKit.ConvertedMarkdownRequests); + Assert.Equal("# updated note", cardKit.ConvertedMarkdownRequests[0]); + Assert.Equal([("doc-existing", "root-existing", 0, 2)], cardKit.DeletedChildRanges); + var appendedBatch = Assert.Single(cardKit.AppendedBlockBatches); + Assert.Equal("doc-existing", appendedBatch.DocumentId); + Assert.Equal("root-existing", appendedBatch.BlockId); + Assert.Single(appendedBatch.Blocks); + Assert.Single(cardKit.TextMessages); + Assert.Contains("已更新Markdown在线文档", cardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("https://feishu.cn/docx/doc-existing", cardKit.TextMessages[0], StringComparison.Ordinal); + + var updatedState = await stateStore.GetAsync("fld-session", candidate.AbsolutePath, TestContext.Current.CancellationToken); + Assert.NotNull(updatedState); + Assert.Equal("doc-existing", updatedState!.DocumentId); + Assert.Equal("root-existing", updatedState.RootBlockId); + Assert.Equal("https://feishu.cn/docx/doc-existing", updatedState.DocumentUrl); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task ImportMissingAsync_WhenDocumentAlreadyExistsInFolder_ReusesExistingLink() + { + var cardKit = new TrackingFeishuCardKitClient(); + cardKit.ExistingFolderDocumentsByTitle["docs/agent-notes/2026-06-09.md"] = new FeishuCloudDocumentInfo + { + DocumentId = "doc-existing", + RootBlockId = "doc-existing", + Url = "https://feishu.cn/docx/doc-existing" + }; + + var importer = CreateImporter(); + var candidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: @"D:\VSWorkshop\WebCode\docs\agent-notes\2026-06-09.md", + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [candidate], + documentAdminOpenId: null, + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(cardKit.ImportedMarkdownDocuments); + Assert.Contains("已复用Markdown在线文档:[docs/agent-notes/2026-06-09.md](https://feishu.cn/docx/doc-existing)", cardKit.TextMessages.Single(), StringComparison.Ordinal); + } + + [Fact] + public async Task ImportMissingAsync_WhenDocumentMissing_ImportsMarkdownAndSendsCreatedLink() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + var cardKit = new TrackingFeishuCardKitClient(); + var importer = CreateImporter(); + var candidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [candidate], + documentAdminOpenId: null, + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + var imported = Assert.Single(cardKit.ImportedMarkdownDocuments); + Assert.Equal("2026-06-09.md", imported.FileName); + Assert.Equal("docs/agent-notes/2026-06-09.md", imported.Title); + Assert.Equal("fld-session", imported.FolderToken); + Assert.Contains("已生成Markdown在线文档:[docs/agent-notes/2026-06-09.md](", cardKit.TextMessages.Single(), StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task ImportMissingAsync_WhenDocumentAdminConfigured_AppliesReadablePermissionAndAdminGrant() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + var cardKit = new TrackingFeishuCardKitClient(); + var importer = CreateImporter(); + var candidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [candidate], + documentAdminOpenId: "ou_doc_admin", + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["doc-created-1"], cardKit.TenantReadableDocuments); + Assert.Equal([("doc-created-1", "ou_doc_admin")], cardKit.DocumentAdminGrants); + Assert.Equal([("fld-session", "ou_doc_admin")], cardKit.FolderAdminGrants); + Assert.Contains("已生成Markdown在线文档:[docs/agent-notes/2026-06-09.md](", cardKit.TextMessages[0], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task ImportMissingAsync_WhenOneCandidateFails_ContinuesRemainingCandidates() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + var cardKit = new TrackingFeishuCardKitClient + { + ImportMarkdownException = new InvalidOperationException("导入失败") + }; + + var importer = CreateImporter(); + var missingCandidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + var existingCandidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/reused.md", + Title: "docs/reused.md"); + + cardKit.ExistingFolderDocumentsByTitle["docs/reused.md"] = new FeishuCloudDocumentInfo + { + DocumentId = "doc-reused", + RootBlockId = "doc-reused", + Url = "https://feishu.cn/docx/doc-reused" + }; + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [missingCandidate, existingCandidate], + documentAdminOpenId: null, + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, cardKit.TextMessages.Count); + Assert.Contains("Markdown在线文档处理失败", cardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("已复用Markdown在线文档:[docs/reused.md](https://feishu.cn/docx/doc-reused)", cardKit.TextMessages[1], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task ImportMissingAsync_WhenAdminGrantFails_SendsWarningAfterSuccessLink() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + var cardKit = new TrackingFeishuCardKitClient + { + GrantDocumentAdminException = new InvalidOperationException("grant failed") + }; + + var importer = CreateImporter(); + var candidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [candidate], + documentAdminOpenId: "ou_doc_admin", + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, cardKit.TextMessages.Count); + Assert.Contains("已生成Markdown在线文档", cardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("文档管理员权限授予失败", cardKit.TextMessages[1], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task ImportMissingAsync_WhenFolderPlacementFails_FallsBackToDefaultDirectoryAndSendsPlacementWarning() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + var cardKit = new TrackingFeishuCardKitClient + { + ImportMarkdownExceptionWhenFolderTokenProvided = new HttpRequestException("API request failed: Status=NotFound, Content={\"code\":1061003,\"msg\":\"not found.\"}") + }; + + var importer = CreateImporter(); + var candidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [candidate], + documentAdminOpenId: null, + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + [("2026-06-09.md", "docs/agent-notes/2026-06-09.md", "fld-session"), ("2026-06-09.md", "docs/agent-notes/2026-06-09.md", null)], + cardKit.ImportAttempts); + Assert.Equal(2, cardKit.TextMessages.Count); + Assert.Contains("已生成Markdown在线文档", cardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("归档到会话文档文件夹时", cardKit.TextMessages[1], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task ImportMissingAsync_WhenFailureWarningSendFails_ContinuesRemainingCandidates() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + var cardKit = new TrackingFeishuCardKitClient + { + ImportMarkdownException = new InvalidOperationException("导入失败"), + FailTextMessagePredicate = static content => content.Contains("Markdown在线文档处理失败", StringComparison.Ordinal) + }; + + cardKit.ExistingFolderDocumentsByTitle["docs/reused.md"] = new FeishuCloudDocumentInfo + { + DocumentId = "doc-reused", + RootBlockId = "doc-reused", + Url = "https://feishu.cn/docx/doc-reused" + }; + + var importer = CreateImporter(); + var failingCandidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/agent-notes/2026-06-09.md", + Title: "docs/agent-notes/2026-06-09.md"); + var reusedCandidate = new ReferencedMarkdownDocumentCandidate( + AbsolutePath: Path.Combine(workspaceRoot, "docs", "agent-notes", "2026-06-09.md"), + RelativePath: "docs/reused.md", + Title: "docs/reused.md"); + + await importer.ImportMissingAsync( + cardKit, + "oc-chat", + "fld-session", + [failingCandidate, reusedCandidate], + documentAdminOpenId: null, + optionsOverride: null, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Single(cardKit.TextMessages); + Assert.Contains("已复用Markdown在线文档:[docs/reused.md](https://feishu.cn/docx/doc-reused)", cardKit.TextMessages[0], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + private static ReferencedMarkdownDocumentImporter CreateImporter(IReferencedMarkdownImportStateStore? stateStore = null) + => new( + NullLogger.Instance, + stateStore ?? new InMemoryReferencedMarkdownImportStateStore()); + + private static async Task SeedTrackingStateAsync( + IReferencedMarkdownImportStateStore stateStore, + string folderToken, + ReferencedMarkdownDocumentCandidate candidate, + FeishuCloudDocumentInfo document) + { + var fingerprint = ReferencedMarkdownImportFingerprint.Compute(candidate.AbsolutePath); + await stateStore.UpsertAsync( + new ReferencedMarkdownImportStateEntry + { + FolderToken = folderToken, + AbsolutePath = candidate.AbsolutePath, + RelativePath = candidate.RelativePath, + Title = candidate.Title, + Fingerprint = fingerprint, + DocumentId = document.DocumentId, + RootBlockId = document.RootBlockId, + DocumentUrl = document.Url + }, + TestContext.Current.CancellationToken); + } + + private static string CreateWorkspaceWithFile(string relativePath, string content) + { + var root = Path.Combine(Path.GetTempPath(), "webcode-md-importer-" + Guid.NewGuid().ToString("N")); + var absolutePath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)!); + File.WriteAllText(absolutePath, content, Encoding.UTF8); + return root; + } + + private static JsonElement ParseJsonElement(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private sealed class TrackingFeishuCardKitClient : IFeishuCardKitClient + { + public Exception? ImportMarkdownException { get; set; } + + public Exception? ImportMarkdownExceptionWhenFolderTokenProvided { get; set; } + + public Exception? GrantDocumentAdminException { get; set; } + + public Exception? GrantFolderAdminException { get; set; } + + public Func? FailTextMessagePredicate { get; set; } + + public Dictionary ExistingFolderDocumentsByTitle { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary<(string DocumentId, string BlockId), List> ChildBlockIdsByDocumentAndBlock { get; } = []; + + public List<(string FileName, string Title, string FolderToken, string Content)> ImportedMarkdownDocuments { get; } = []; + + public List<(string FileName, string Title, string? FolderToken)> ImportAttempts { get; } = []; + + public List ConvertedMarkdownRequests { get; } = []; + + public List<(string DocumentId, string BlockId, IReadOnlyList Blocks)> AppendedBlockBatches { get; } = []; + + public List<(string DocumentId, string BlockId, int StartIndex, int EndIndex)> DeletedChildRanges { get; } = []; + + public List TextMessages { get; } = []; + + public List TenantReadableDocuments { get; } = []; + + public List<(string DocumentId, string OpenId)> DocumentAdminGrants { get; } = []; + + public List<(string FolderToken, string OpenId)> FolderAdminGrants { get; } = []; + + public Task FindCloudDocumentInFolderByTitleAsync( + string folderToken, + string title, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + return Task.FromResult(ExistingFolderDocumentsByTitle.TryGetValue(title, out var existing) ? existing : null); + } + + public Task> ListCloudDocumentChildBlockIdsAsync( + string documentId, + string blockId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + return Task.FromResult>( + ChildBlockIdsByDocumentAndBlock.TryGetValue((documentId, blockId), out var blockIds) + ? [.. blockIds] + : []); + } + + public Task DeleteCloudDocumentChildBlocksAsync( + string documentId, + string blockId, + int startIndex, + int endIndex, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + DeletedChildRanges.Add((documentId, blockId, startIndex, endIndex)); + return Task.CompletedTask; + } + + public Task ImportMarkdownFileAsCloudDocumentAsync( + string fileName, + byte[] content, + string title, + string? folderToken, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + ImportAttempts.Add((fileName, title, folderToken)); + + if (!string.IsNullOrWhiteSpace(folderToken) && ImportMarkdownExceptionWhenFolderTokenProvided != null) + { + throw ImportMarkdownExceptionWhenFolderTokenProvided; + } + + if (ImportMarkdownException != null) + { + throw ImportMarkdownException; + } + + ImportedMarkdownDocuments.Add((fileName, title, folderToken ?? string.Empty, Encoding.UTF8.GetString(content))); + var number = ImportedMarkdownDocuments.Count; + return Task.FromResult(new FeishuCloudDocumentInfo + { + DocumentId = $"doc-created-{number}", + RootBlockId = $"doc-created-{number}", + Url = $"https://feishu.cn/docx/doc-created-{number}" + }); + } + + public Task SetCloudDocumentTenantReadableAsync( + string documentId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + TenantReadableDocuments.Add(documentId); + return Task.CompletedTask; + } + + public Task GrantCloudDocumentMemberFullAccessAsync( + string documentId, + string openId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (GrantDocumentAdminException != null) + { + throw GrantDocumentAdminException; + } + + DocumentAdminGrants.Add((documentId, openId)); + return Task.CompletedTask; + } + + public Task GrantCloudFolderMemberFullAccessAsync( + string folderToken, + string openId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (GrantFolderAdminException != null) + { + throw GrantFolderAdminException; + } + + FolderAdminGrants.Add((folderToken, openId)); + return Task.CompletedTask; + } + + public Task SendTextMessageAsync( + string chatId, + string content, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (FailTextMessagePredicate?.Invoke(content) == true) + { + throw new InvalidOperationException("send failed"); + } + + TextMessages.Add(content); + return Task.FromResult($"om_{TextMessages.Count}"); + } + + public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task CreateCloudDocumentAsync(string title, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, string? folderToken = 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 DownloadIncomingAttachmentAsync(FeishuIncomingAttachment attachment, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync(string messageId, string fileKey, string resourceType, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task MoveCloudDocumentToFolderAsync(string documentId, string folderToken, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task EnsureCloudFolderAsync(string folderName, 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 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(); + public Task ReplyTextMessageAsync(string replyMessageId, string content, 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 SendRawCardAsync(string chatId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task AppendCloudDocumentTextAsync(string documentId, string blockId, string text, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task ConvertMarkdownToCloudDocumentBlocksAsync(string markdown, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + ConvertedMarkdownRequests.Add(markdown); + var blocksJson = string.Join(",", ConvertedBlocks.Select(static block => block.GetRawText())); + using var document = JsonDocument.Parse($$"""{"blocks":[{{blocksJson}}]}"""); + return Task.FromResult(document.RootElement.Clone()); + } + public Task AppendCloudDocumentBlocksAsync(string documentId, string blockId, IReadOnlyCollection blocks, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + AppendedBlockBatches.Add((documentId, blockId, blocks.Select(static block => block.Clone()).ToArray())); + return Task.CompletedTask; + } + public List ConvertedBlocks { get; } = []; + public Task UploadCloudFileAsync(string fileName, byte[] content, string? folderToken, 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(); + } + + private sealed class InMemoryReferencedMarkdownImportStateStore : IReferencedMarkdownImportStateStore + { + private readonly Dictionary<(string FolderToken, string AbsolutePath), ReferencedMarkdownImportStateEntry> _entries = new(); + + public Task GetAsync( + string folderToken, + string absolutePath, + CancellationToken cancellationToken = default) + { + return Task.FromResult( + _entries.TryGetValue((folderToken, absolutePath), out var entry) + ? entry with { } + : null); + } + + public Task UpsertAsync( + ReferencedMarkdownImportStateEntry entry, + CancellationToken cancellationToken = default) + { + _entries[(entry.FolderToken, entry.AbsolutePath)] = entry with { }; + return Task.CompletedTask; + } + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyDocumentMarkdownRendererTests.cs b/WebCodeCli.Domain.Tests/ReplyDocumentMarkdownRendererTests.cs new file mode 100644 index 0000000..a0ed4ae --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyDocumentMarkdownRendererTests.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReplyDocumentMarkdownRendererTests +{ + [Fact] + public async Task RenderAsync_WhenConvertSucceeds_AppendsConvertedBlocksOnly() + { + var cardKit = new TrackingFeishuCardKitClient(); + cardKit.ConvertedBlocks.Add(ParseJsonElement("""{"block_type":2,"text":{"elements":[{"text_run":{"content":"结论正文","text_element_style":{}}}]}}""")); + + var renderer = new ReplyDocumentMarkdownRenderer(NullLogger.Instance); + + await renderer.RenderAsync( + cardKit, + "doc-1", + "root-1", + "# 结论正文", + optionsOverride: null, + TestContext.Current.CancellationToken); + + var appendedBatch = Assert.Single(cardKit.AppendedBlockBatches); + Assert.Equal("doc-1", appendedBatch.DocumentId); + Assert.Equal("root-1", appendedBatch.BlockId); + Assert.Single(appendedBatch.Blocks); + Assert.Empty(cardKit.AppendedTexts); + } + + [Fact] + public async Task RenderAsync_WhenConvertFails_FallsBackToPlainTextAppend() + { + var cardKit = new TrackingFeishuCardKitClient + { + ConvertMarkdownException = new HttpRequestException("convert failed") + }; + + var renderer = new ReplyDocumentMarkdownRenderer(NullLogger.Instance); + + await renderer.RenderAsync( + cardKit, + "doc-1", + "root-1", + "# 结论正文", + optionsOverride: null, + TestContext.Current.CancellationToken); + + Assert.Empty(cardKit.AppendedBlockBatches); + var appendedText = Assert.Single(cardKit.AppendedTexts); + Assert.Equal("doc-1", appendedText.DocumentId); + Assert.Equal("root-1", appendedText.BlockId); + Assert.Equal("# 结论正文", appendedText.Text); + } + + [Fact] + public async Task RenderAsync_WhenAppendConvertedBlocksFails_FallsBackToPlainTextAppend() + { + var cardKit = new TrackingFeishuCardKitClient + { + AppendBlocksException = new HttpRequestException("append failed") + }; + cardKit.ConvertedBlocks.Add(ParseJsonElement("""{"block_type":2,"text":{"elements":[{"text_run":{"content":"结论正文","text_element_style":{}}}]}}""")); + + var renderer = new ReplyDocumentMarkdownRenderer(NullLogger.Instance); + + await renderer.RenderAsync( + cardKit, + "doc-1", + "root-1", + "# 结论正文", + optionsOverride: null, + TestContext.Current.CancellationToken); + + Assert.Equal(1, cardKit.AppendBlocksCallCount); + var appendedText = Assert.Single(cardKit.AppendedTexts); + Assert.Equal("doc-1", appendedText.DocumentId); + Assert.Equal("root-1", appendedText.BlockId); + Assert.Equal("# 结论正文", appendedText.Text); + } + + private static JsonElement ParseJsonElement(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private sealed class TrackingFeishuCardKitClient : IFeishuCardKitClient + { + public Exception? ConvertMarkdownException { get; set; } + + public Exception? AppendBlocksException { get; set; } + + public List ConvertedBlocks { get; } = []; + + public List<(string DocumentId, string BlockId, IReadOnlyList Blocks)> AppendedBlockBatches { get; } = []; + + public List<(string DocumentId, string BlockId, string Text)> AppendedTexts { get; } = []; + + public int AppendBlocksCallCount { get; private set; } + + public Task ConvertMarkdownToCloudDocumentBlocksAsync( + string markdown, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (ConvertMarkdownException != null) + { + throw ConvertMarkdownException; + } + + var blocksJson = string.Join(",", ConvertedBlocks.Select(static block => block.GetRawText())); + using var document = JsonDocument.Parse($$"""{"blocks":[{{blocksJson}}]}"""); + return Task.FromResult(document.RootElement.Clone()); + } + + public Task AppendCloudDocumentBlocksAsync( + string documentId, + string blockId, + IReadOnlyCollection blocks, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + AppendBlocksCallCount++; + if (AppendBlocksException != null) + { + throw AppendBlocksException; + } + + AppendedBlockBatches.Add((documentId, blockId, blocks.Select(static block => block.Clone()).ToArray())); + return Task.CompletedTask; + } + + public Task AppendCloudDocumentTextAsync( + string documentId, + string blockId, + string text, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + AppendedTexts.Add((documentId, blockId, text)); + return Task.CompletedTask; + } + + public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task CreateCloudDocumentAsync(string title, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, string? folderToken = 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 DownloadIncomingAttachmentAsync(FeishuIncomingAttachment attachment, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync(string messageId, string fileKey, string resourceType, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task FindCloudDocumentInFolderByTitleAsync(string folderToken, string title, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task> ListCloudDocumentChildBlockIdsAsync(string documentId, string blockId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task DeleteCloudDocumentChildBlocksAsync(string documentId, string blockId, int startIndex, int endIndex, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task GrantCloudDocumentMemberFullAccessAsync(string documentId, string openId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task GrantCloudFolderMemberFullAccessAsync(string folderToken, string openId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task ImportMarkdownFileAsCloudDocumentAsync(string fileName, byte[] content, string title, string? folderToken, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task MoveCloudDocumentToFolderAsync(string documentId, string folderToken, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task EnsureCloudFolderAsync(string folderName, 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 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(); + public Task ReplyTextMessageAsync(string replyMessageId, string content, 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 SendRawCardAsync(string chatId, string cardJson, 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 SetCloudDocumentTenantReadableAsync(string documentId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task UploadCloudFileAsync(string fileName, byte[] content, string? folderToken, 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(); + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorListeningTests.cs b/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorListeningTests.cs new file mode 100644 index 0000000..4eb754b --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorListeningTests.cs @@ -0,0 +1,266 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SqlSugar; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Domain.Service.Channels; +using WebCodeCli.Domain.Model; +using WebCodeCli.Domain.Repositories.Base.ChatSession; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReplyDocumentOrchestratorListeningTests +{ + [Fact] + public async Task QueueCompletedReplyAsync_WhenListeningFullReplyDocumentEnabled_CreatesFormattedListeningDocument() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + AudioFullReplyDocEnabled = true + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-audio-full-chat", + SessionId = "session-1", + CliThreadId = "thread-1", + OriginalUserQuestion = "question", + Username = "luhaiyan", + Output = "构建过了。/D:/repo/a.cs:1" + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 1); + + Assert.Matches(@"^question \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$", harness.CardKit.CreatedDocuments.Single().Title); + Assert.StartsWith("构建过了。文件内容1", harness.CardKit.AppendedTexts.Single().Text, StringComparison.Ordinal); + Assert.Contains("文件内容1", harness.CardKit.AppendedTexts.Single().Text, StringComparison.Ordinal); + Assert.Contains("文件内容1:/D:/repo/a.cs:1", harness.CardKit.AppendedTexts.Single().Text, StringComparison.Ordinal); + Assert.EndsWith("## 用户内容\n\nquestion", harness.CardKit.AppendedTexts.Single().Text, StringComparison.Ordinal); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenListeningFinalReplyDocumentEnabled_FormatsFinalAnswerOnly() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + AudioFinalReplyDocEnabled = true + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-audio-final-chat", + SessionId = "session-2", + CliThreadId = "thread-2", + OriginalUserQuestion = "question", + Username = "luhaiyan", + Output = "过程 /D:/repo/raw.cs:2", + FinalAnswerOutput = "结论 /D:/repo/final.cs:9" + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 1); + + Assert.Matches(@"^question \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$", harness.CardKit.CreatedDocuments.Single().Title); + Assert.DoesNotContain("/D:/repo/raw.cs:2", harness.CardKit.AppendedTexts.Single().Text, StringComparison.Ordinal); + Assert.Contains("文件内容1:/D:/repo/final.cs:9", harness.CardKit.AppendedTexts.Single().Text, StringComparison.Ordinal); + Assert.EndsWith("## 用户内容\n\nquestion", harness.CardKit.AppendedTexts.Single().Text, StringComparison.Ordinal); + } + + 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 ReplyDocumentOrchestratorHarness : IDisposable + { + private readonly ServiceProvider _serviceProvider; + + public ReplyDocumentOrchestratorHarness(UserFeishuBotConfigEntity config) + { + ConfigService = new TrackingUserFeishuBotConfigService(config); + CardKit = new TrackingFeishuCardKitClient(); + ChatSessionRepository = new TrackingChatSessionRepository(); + HistoryService = new TrackingExternalCliSessionHistoryService(); + + var services = new ServiceCollection(); + services.AddScoped(_ => ConfigService); + services.AddScoped(_ => CardKit); + services.AddScoped(_ => ChatSessionRepository); + services.AddScoped(_ => HistoryService); + services.AddSingleton(); + services.AddLogging(); + + _serviceProvider = services.BuildServiceProvider(); + Orchestrator = new ReplyDocumentOrchestrator( + _serviceProvider, + NullLogger.Instance); + } + + public TrackingUserFeishuBotConfigService ConfigService { get; } + public TrackingFeishuCardKitClient CardKit { get; } + public TrackingChatSessionRepository ChatSessionRepository { get; } + public TrackingExternalCliSessionHistoryService HistoryService { get; } + public ReplyDocumentOrchestrator Orchestrator { get; } + + public void Dispose() + { + _serviceProvider.Dispose(); + } + } + + private sealed class InMemoryReferencedMarkdownImportStateStore : IReferencedMarkdownImportStateStore + { + public Task GetAsync(string folderToken, string absolutePath, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task UpsertAsync(ReferencedMarkdownImportStateEntry entry, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + + private sealed class TrackingUserFeishuBotConfigService(UserFeishuBotConfigEntity config) : IUserFeishuBotConfigService + { + public Task GetByUsernameAsync(string username) + => 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 TrackingChatSessionRepository : IChatSessionRepository + { + public SqlSugarScope GetDB() => throw new NotSupportedException(); + public List GetList() => []; + public Task> GetListAsync() => Task.FromResult(new List()); + public List GetList(System.Linq.Expressions.Expression> whereExpression) => []; + public Task> GetListAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(new List()); + public int Count(System.Linq.Expressions.Expression> whereExpression) => 0; + public Task CountAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(0); + public PageList GetPageList(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList

GetPageList

(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync

(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public PageList

GetPageList

(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync

(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public ChatSessionEntity GetById(dynamic id) => throw new NotSupportedException(); + public Task GetByIdAsync(dynamic id) => Task.FromResult(null!); + public ChatSessionEntity GetSingle(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public Task GetSingleAsync(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public ChatSessionEntity GetFirst(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public Task GetFirstAsync(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public bool Insert(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool InsertRange(List objs) => throw new NotSupportedException(); + public Task InsertRangeAsync(List objs) => throw new NotSupportedException(); + public int InsertReturnIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public long InsertReturnBigIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnBigIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool DeleteByIds(dynamic[] ids) => throw new NotSupportedException(); + public Task DeleteByIdsAsync(dynamic[] ids) => throw new NotSupportedException(); + public bool Delete(dynamic id) => throw new NotSupportedException(); + public Task DeleteAsync(dynamic id) => throw new NotSupportedException(); + public bool Delete(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task DeleteAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool Delete(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public Task DeleteAsync(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public bool Update(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool UpdateRange(List objs) => throw new NotSupportedException(); + public bool InsertOrUpdate(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertOrUpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateRangeAsync(List objs) => throw new NotSupportedException(); + public bool IsAny(System.Linq.Expressions.Expression> whereExpression) => false; + public Task IsAnyAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(false); + public Task> GetByUsernameAsync(string username) => Task.FromResult(new List()); + public Task GetByIdAndUsernameAsync(string sessionId, string username) => Task.FromResult(null); + public Task DeleteByIdAndUsernameAsync(string sessionId, string username) => throw new NotSupportedException(); + public Task> GetByUsernameOrderByUpdatedAtAsync(string username) => throw new NotSupportedException(); + public Task GetByUsernameToolAndCliThreadIdAsync(string username, string toolId, string cliThreadId) => throw new NotSupportedException(); + public Task GetByToolAndCliThreadIdAsync(string toolId, string cliThreadId) => throw new NotSupportedException(); + public Task UpdateCliThreadIdAsync(string sessionId, string? cliThreadId) => throw new NotSupportedException(); + public Task UpdateWorkspaceBindingAsync(string sessionId, string? workspacePath, bool isCustomWorkspace) => throw new NotSupportedException(); + public Task UpdateSessionTitleAsync(string sessionId, string title) => throw new NotSupportedException(); + public Task UpdateCcSwitchSnapshotAsync(string sessionId, CcSwitchSessionSnapshot snapshot) => throw new NotSupportedException(); + public Task> GetByFeishuChatKeyAsync(string feishuChatKey) => throw new NotSupportedException(); + public Task GetActiveByFeishuChatKeyAsync(string feishuChatKey) => throw new NotSupportedException(); + public Task SetActiveSessionAsync(string feishuChatKey, string sessionId) => throw new NotSupportedException(); + public Task CloseFeishuSessionAsync(string feishuChatKey, string sessionId) => throw new NotSupportedException(); + public Task CreateFeishuSessionAsync(string feishuChatKey, string username, string? workspacePath = null, string? toolId = null) => throw new NotSupportedException(); + } + + private sealed class TrackingExternalCliSessionHistoryService : IExternalCliSessionHistoryService + { + public Task GetRecentHistoryAsync(string toolId, string cliThreadId, int maxCount = 20, string? workspacePath = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetRecentMessagesAsync(string toolId, string cliThreadId, int maxCount = 20, string? workspacePath = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetCodexFinalAnswerTextAsync(string cliThreadId, string? workspacePath = null, CancellationToken cancellationToken = default) => Task.FromResult(null); + } + + private sealed class TrackingFeishuCardKitClient : IFeishuCardKitClient + { + public List<(string Title, string DocumentId, string RootBlockId, string Url)> CreatedDocuments { get; } = []; + public List<(string DocumentId, string BlockId, string Text)> AppendedTexts { get; } = []; + + public Task CreateCloudDocumentAsync(string title, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, string? folderToken = null) + { + CreatedDocuments.Add((title, $"doc-{CreatedDocuments.Count + 1}", $"root-{CreatedDocuments.Count + 1}", $"https://feishu.cn/docx/doc-{CreatedDocuments.Count + 1}")); + var created = CreatedDocuments[^1]; + return Task.FromResult(new FeishuCloudDocumentInfo + { + DocumentId = created.DocumentId, + RootBlockId = created.RootBlockId, + Url = created.Url + }); + } + + public Task AppendCloudDocumentTextAsync(string documentId, string blockId, string text, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + AppendedTexts.Add((documentId, blockId, text)); + return Task.CompletedTask; + } + + public Task SetCloudDocumentTenantReadableAsync(string documentId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => Task.CompletedTask; + public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => Task.FromResult("om_1"); + 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 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 DownloadIncomingAttachmentAsync(FeishuIncomingAttachment attachment, 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(); + public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync(string messageId, string fileKey, string resourceType, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorMarkdownIntegrationTests.cs b/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorMarkdownIntegrationTests.cs new file mode 100644 index 0000000..ad1c7d0 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorMarkdownIntegrationTests.cs @@ -0,0 +1,785 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SqlSugar; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Domain.Service.Channels; +using WebCodeCli.Domain.Model; +using WebCodeCli.Domain.Repositories.Base.ChatSession; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReplyDocumentOrchestratorMarkdownIntegrationTests +{ + [Fact] + public async Task QueueCompletedReplyAsync_WhenReplyDocumentBodyContainsMarkdown_AppendsPlainTextInsteadOfConvertedBlocks() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }); + + harness.CardKit.ConvertedBlocks.Add(ParseJsonElement("""{"block_type":2,"text":{"elements":[{"text_run":{"content":"转换后的正文","text_element_style":{}}}]}}""")); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-convert-chat", + SessionId = "session-convert", + CliThreadId = "thread-convert", + OriginalUserQuestion = "question", + Username = "luhaiyan", + Output = "# 标题" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + Assert.Empty(harness.CardKit.AppendedBlockBatches); + var appendedText = Assert.Single(harness.CardKit.AppendedTexts); + Assert.Equal("doc-1", appendedText.DocumentId); + Assert.Equal("## 用户内容\n\nquestion\n\n---\n\n# 标题", appendedText.Text); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenReplyDocumentBodyContainsStructuredSections_PreservesOriginalBodyOrderInPlainTextAppend() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }); + + harness.CardKit.ConvertedBlocks.Add(ParseJsonElement("""{"block_type":2,"text":{"elements":[{"text_run":{"content":"乱序块A","text_element_style":{}}}]}}""")); + harness.CardKit.ConvertedBlocks.Add(ParseJsonElement("""{"block_type":2,"text":{"elements":[{"text_run":{"content":"乱序块B","text_element_style":{}}}]}}""")); + + var assistantBody = """ +我会把刚才的 AI-first 操作面确认纳入 Route 07 设计记录,继续做最后几类设计收口:人工 UI 验证、公共/领域组件、后端契约和技能治理。先重新加载本轮必须使用的 superpowers 与 MMIS 约束,然后继续单问题确认。Route 07 第十七个需要确认的问题:人工 UI 验证应该验证哪些主流程,后续是否需要形成独立的人类操作测试技能? + +我推荐 B: + +B. Route 07 spec 先定义人类 UI 验证路径,实施后如果页面稳定再沉淀为技能。 + +Route 07 人工 UI 验证至少覆盖这些路径: +- 巡检路线:创建/编辑路线、配置点位顺序、必检/选检、跳过规则、证据要求,发布后确认运行时可用。 +- 巡检计划:绑定路线、目标范围、频率/日历、默认责任人、生成窗口、工作流/通知策略,执行生成预览。 +- 点检计划:选择设备/部件/维护对象、配置点检项、阈值/判定规则、证据要求,执行生成预览。 +- 计划生成:从已发布计划生成 WorkOrders,检查生成批次、幂等键、失败重试、来源版本。 +- 一次性发起:从计划、异常或人工入口临时发起一次巡检/点检,确认不修改已发布计划。 +- 执行快照:进入 WorkOrders 执行页,确认路线点位、检查项、标准版本、证据要求、目标快照是生成时冻结的。 +- 异常发现:从执行证据创建/合并异常,确认 Product Abnormal 拥有异常生命周期。 +- 调整提案/直接修订:触发低风险直接修订或高风险提案/审批,确认版本化、审计和运行时效果。 + +不建议现在就强制创建 mmis-inspection-ui-verification skill,因为页面和 authoring surface 还没实现,容易写成空泛规则。更稳的是:Route 07 spec 先列预期人类 UI 验证路径,Route 07 实施完成且页面稳定后,如果这些流程可复用,再创建或更新对应的 UI 验证 skill。 + +不推荐 A:只写后端验证,不写人类 UI 路径。巡检/点检大量是现场操作,人工验证路径必须提前定边界。 + +不推荐 C:现在立即创建完整 UI 技能。当前还处于设计期,没有稳定页面和真实路径,技能会过早固化。 + +你是否认可按 B 写入 Route 07 设计? +"""; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-structured-order-chat", + SessionId = "session-structured-order", + CliThreadId = "thread-structured-order", + OriginalUserQuestion = "人工 UI 验证应该验证哪些主流程,后续是否需要形成独立的人类操作测试技能?", + Username = "luhaiyan", + Output = assistantBody + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + Assert.Empty(harness.CardKit.AppendedBlockBatches); + var appendedText = Assert.Single(harness.CardKit.AppendedTexts).Text; + Assert.StartsWith("## 用户内容\n\n人工 UI 验证应该验证哪些主流程,后续是否需要形成独立的人类操作测试技能?\n\n---\n\n我会把刚才的 AI-first 操作面确认纳入 Route 07 设计记录", appendedText, StringComparison.Ordinal); + Assert.Contains("- 巡检计划:绑定路线、目标范围、频率/日历、默认责任人、生成窗口、工作流/通知策略,执行生成预览。", appendedText, StringComparison.Ordinal); + Assert.Contains("- 计划生成:从已发布计划生成 WorkOrders,检查生成批次、幂等键、失败重试、来源版本。", appendedText, StringComparison.Ordinal); + Assert.Contains("你是否认可按 B 写入 Route 07 设计?", appendedText, StringComparison.Ordinal); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenReferencedMarkdownImportEnabled_ImportsMarkdownFileAndSendsLink() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReferencedMarkdownDocImportEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-md-import", + Username = "luhaiyan", + WorkspacePath = workspaceRoot, + Title = "markdown import" + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-md-import-chat", + SessionId = "session-md-import", + Username = "luhaiyan", + Output = "请看 docs/agent-notes/2026-06-09.md" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + Assert.Equal(["markdown import"], harness.CardKit.EnsuredFolderNames); + var imported = Assert.Single(harness.CardKit.ImportedMarkdownDocuments); + Assert.Equal("2026-06-09.md", imported.FileName); + Assert.Equal("docs/agent-notes/2026-06-09.md", imported.Title); + Assert.Equal("folder-1", imported.FolderToken); + Assert.Contains("已生成Markdown在线文档:[docs/agent-notes/2026-06-09.md](", harness.CardKit.TextMessages[0], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenReplyMentionsBarePlanMarkdownFileName_ImportsMarkdownFileAndSendsLink() + { + var workspaceRoot = CreateWorkspaceWithFile( + "docs/superpowers/plans/2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md", + "# plan"); + + try + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReferencedMarkdownDocImportEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-md-bare-plan-import", + Username = "luhaiyan", + WorkspacePath = workspaceRoot, + Title = "markdown import" + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-md-bare-plan-import-chat", + SessionId = "session-md-bare-plan-import", + Username = "luhaiyan", + Output = "这份 2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md 就是接下来要执行的 plan。" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + var imported = Assert.Single(harness.CardKit.ImportedMarkdownDocuments); + Assert.Equal( + "2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md", + imported.FileName); + Assert.Equal( + "docs/superpowers/plans/2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md", + imported.Title); + Assert.Contains( + "docs/superpowers/plans/2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md", + harness.CardKit.TextMessages[0], + StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenFinalOnlyReplyMentionsBareSpecMarkdownFileName_ImportsMarkdownFileAndSendsLink() + { + var workspaceRoot = CreateWorkspaceWithFile( + "docs/superpowers/specs/2026-06-11-mmis-product-v1-total-delivery-roadmap-design.md", + "# spec"); + + try + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReferencedMarkdownDocImportEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-md-final-spec-import", + Username = "luhaiyan", + WorkspacePath = workspaceRoot, + Title = "markdown import" + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-md-final-spec-import-chat", + SessionId = "session-md-final-spec-import", + Username = "luhaiyan", + Output = "提交已完成,开始做收尾验证。", + FinalAnswerOutput = "已创建并提交文档:2026-06-11-mmis-product-v1-total-delivery-roadmap-design.md" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + var imported = Assert.Single(harness.CardKit.ImportedMarkdownDocuments); + Assert.Equal( + "2026-06-11-mmis-product-v1-total-delivery-roadmap-design.md", + imported.FileName); + Assert.Equal( + "docs/superpowers/specs/2026-06-11-mmis-product-v1-total-delivery-roadmap-design.md", + imported.Title); + Assert.Contains( + "docs/superpowers/specs/2026-06-11-mmis-product-v1-total-delivery-roadmap-design.md", + harness.CardKit.TextMessages[0], + StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenMarkdownImportFails_StillCreatesNormalReplyDocument() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true, + ReferencedMarkdownDocImportEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-md-warning", + Username = "luhaiyan", + WorkspacePath = workspaceRoot, + Title = "markdown warning" + }); + + harness.CardKit.ImportMarkdownException = new HttpRequestException("import failed"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-md-warning-chat", + SessionId = "session-md-warning", + CliThreadId = "thread-md-warning", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body\n请看 docs/agent-notes/2026-06-09.md" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + Assert.Single(harness.CardKit.CreatedDocuments); + Assert.Contains("已生成完整回复文档:", harness.CardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("Markdown在线文档处理失败", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + Assert.Contains("docs/agent-notes/2026-06-09.md", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenMarkdownImportSucceeds_AppliesImportedDocumentPermissionsAndAdminGrant() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReferencedMarkdownDocImportEnabled = true, + DocumentAdminOpenId = "ou_doc_admin" + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-md-admin", + Username = "luhaiyan", + WorkspacePath = workspaceRoot, + Title = "markdown admin" + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-md-admin-chat", + SessionId = "session-md-admin", + Username = "luhaiyan", + Output = "请看 docs/agent-notes/2026-06-09.md" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + Assert.Equal(["imported-doc-1"], harness.CardKit.PermissionUpdates); + Assert.Equal([("imported-doc-1", "ou_doc_admin")], harness.CardKit.DocumentAdminGrants); + Assert.Equal([("folder-1", "ou_doc_admin")], harness.CardKit.FolderAdminGrants); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenMarkdownImportAdminGrantFails_SendsWarningWithoutBlockingLink() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReferencedMarkdownDocImportEnabled = true, + DocumentAdminOpenId = "ou_doc_admin" + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-md-admin-warning", + Username = "luhaiyan", + WorkspacePath = workspaceRoot, + Title = "markdown admin warning" + }); + + harness.CardKit.GrantDocumentAdminException = new InvalidOperationException("grant failed"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-md-admin-warning-chat", + SessionId = "session-md-admin-warning", + Username = "luhaiyan", + Output = "请看 docs/agent-notes/2026-06-09.md" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + Assert.Contains("已生成Markdown在线文档", harness.CardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("文档管理员权限授予失败", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenMarkdownImportFolderAdminGrantFails_SendsLinkThenFolderWarning() + { + var workspaceRoot = CreateWorkspaceWithFile("docs/agent-notes/2026-06-09.md", "# note"); + + try + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReferencedMarkdownDocImportEnabled = true, + DocumentAdminOpenId = "ou_doc_admin" + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-md-folder-admin-warning", + Username = "luhaiyan", + WorkspacePath = workspaceRoot, + Title = "markdown folder admin warning" + }); + + harness.CardKit.GrantFolderAdminException = new HttpRequestException( + "API request failed: Status=BadRequest, Content={\"code\":99991672,\"msg\":\"Access denied. One of the following scopes is required: [drive:drive].应用尚未开通所需的应用身份权限:[drive:drive],点击链接申请并开通任一权限即可:https://open.feishu.cn/app/test/auth?q=drive:drive\"}"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-md-folder-admin-warning-chat", + SessionId = "session-md-folder-admin-warning", + Username = "luhaiyan", + Output = "请看 docs/agent-notes/2026-06-09.md" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + Assert.Contains("已生成Markdown在线文档", harness.CardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("会话文档文件夹管理员权限授予失败", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + Assert.Contains("drive:drive", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + private static JsonElement ParseJsonElement(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static string CreateWorkspaceWithFile(string relativePath, string content) + { + var root = Path.Combine(Path.GetTempPath(), "webcode-orchestrator-md-" + Guid.NewGuid().ToString("N")); + var absolutePath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)!); + File.WriteAllText(absolutePath, content, Encoding.UTF8); + return root; + } + + 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 ReplyDocumentOrchestratorHarness : IDisposable + { + private readonly ServiceProvider _serviceProvider; + + public ReplyDocumentOrchestratorHarness( + UserFeishuBotConfigEntity config, + ReplyDocumentSessionContext? session = null) + { + ConfigService = new TrackingUserFeishuBotConfigService(config); + CardKit = new TrackingFeishuCardKitClient(); + ChatSessionRepository = new TrackingChatSessionRepository(session); + HistoryService = new TrackingExternalCliSessionHistoryService(); + + var services = new ServiceCollection(); + services.AddScoped(_ => ConfigService); + services.AddScoped(_ => CardKit); + services.AddScoped(_ => ChatSessionRepository); + services.AddScoped(_ => HistoryService); + services.AddSingleton(); + services.AddLogging(); + + _serviceProvider = services.BuildServiceProvider(); + Orchestrator = new ReplyDocumentOrchestrator( + _serviceProvider, + NullLogger.Instance); + } + + public TrackingUserFeishuBotConfigService ConfigService { get; } + public TrackingFeishuCardKitClient CardKit { get; } + public TrackingChatSessionRepository ChatSessionRepository { get; } + public TrackingExternalCliSessionHistoryService HistoryService { get; } + public ReplyDocumentOrchestrator Orchestrator { get; } + + public void Dispose() + { + _serviceProvider.Dispose(); + } + } + + private sealed class TrackingUserFeishuBotConfigService(UserFeishuBotConfigEntity config) : IUserFeishuBotConfigService + { + public Task GetByUsernameAsync(string username) + => 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 TrackingChatSessionRepository(ReplyDocumentSessionContext? session) : IChatSessionRepository + { + private readonly Dictionary _sessions = session == null + ? [] + : new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [session.SessionId] = new ChatSessionEntity + { + SessionId = session.SessionId, + Username = session.Username, + Title = session.Title, + ToolId = session.ToolId, + CliThreadId = session.CliThreadId, + WorkspacePath = session.WorkspacePath, + CcSwitchSnapshotToolId = session.SnapshotToolId + } + }; + + public SqlSugarScope GetDB() => throw new NotSupportedException(); + public List GetList() => _sessions.Values.ToList(); + public Task> GetListAsync() => Task.FromResult(GetList()); + public List GetList(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().Where(whereExpression).ToList(); + public Task> GetListAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetList(whereExpression)); + public int Count(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().Count(whereExpression); + public Task CountAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(Count(whereExpression)); + public PageList GetPageList(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList

GetPageList

(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync

(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public PageList

GetPageList

(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync

(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public ChatSessionEntity GetById(dynamic id) => _sessions[(id?.ToString() ?? string.Empty)]; + public Task GetByIdAsync(dynamic id) + { + ChatSessionEntity? found = _sessions.TryGetValue(id?.ToString() ?? string.Empty, out ChatSessionEntity? stored) ? stored : null; + return Task.FromResult(found)!; + } + + public ChatSessionEntity GetSingle(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().Single(whereExpression); + public Task GetSingleAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetSingle(whereExpression)); + public ChatSessionEntity GetFirst(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().First(whereExpression); + public Task GetFirstAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetFirst(whereExpression)); + public bool Insert(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool InsertRange(List objs) => throw new NotSupportedException(); + public Task InsertRangeAsync(List objs) => throw new NotSupportedException(); + public int InsertReturnIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public long InsertReturnBigIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnBigIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool DeleteByIds(dynamic[] ids) => throw new NotSupportedException(); + public Task DeleteByIdsAsync(dynamic[] ids) => throw new NotSupportedException(); + public bool Delete(dynamic id) => throw new NotSupportedException(); + public Task DeleteAsync(dynamic id) => throw new NotSupportedException(); + public bool Delete(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task DeleteAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool Delete(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public Task DeleteAsync(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public bool Update(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool UpdateRange(List objs) => throw new NotSupportedException(); + public bool InsertOrUpdate(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertOrUpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateRangeAsync(List objs) => throw new NotSupportedException(); + public bool IsAny(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().Any(whereExpression); + public Task IsAnyAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(IsAny(whereExpression)); + public Task> GetByUsernameAsync(string username) => Task.FromResult(_sessions.Values.Where(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase)).ToList()); + public Task GetByIdAndUsernameAsync(string sessionId, string username) => Task.FromResult(_sessions.Values.FirstOrDefault(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase))); + public Task DeleteByIdAndUsernameAsync(string sessionId, string username) => throw new NotSupportedException(); + public Task> GetByUsernameOrderByUpdatedAtAsync(string username) => throw new NotSupportedException(); + public Task GetByUsernameToolAndCliThreadIdAsync(string username, string toolId, string cliThreadId) => throw new NotSupportedException(); + public Task GetByToolAndCliThreadIdAsync(string toolId, string cliThreadId) => throw new NotSupportedException(); + public Task UpdateCliThreadIdAsync(string sessionId, string? cliThreadId) => throw new NotSupportedException(); + public Task UpdateWorkspaceBindingAsync(string sessionId, string? workspacePath, bool isCustomWorkspace) => throw new NotSupportedException(); + public Task UpdateSessionTitleAsync(string sessionId, string title) => throw new NotSupportedException(); + public Task UpdateCcSwitchSnapshotAsync(string sessionId, CcSwitchSessionSnapshot snapshot) => throw new NotSupportedException(); + public Task> GetByFeishuChatKeyAsync(string feishuChatKey) => throw new NotSupportedException(); + public Task GetActiveByFeishuChatKeyAsync(string feishuChatKey) => throw new NotSupportedException(); + public Task SetActiveSessionAsync(string feishuChatKey, string sessionId) => throw new NotSupportedException(); + public Task CloseFeishuSessionAsync(string feishuChatKey, string sessionId) => throw new NotSupportedException(); + public Task CreateFeishuSessionAsync(string feishuChatKey, string username, string? workspacePath = null, string? toolId = null) => throw new NotSupportedException(); + } + + private sealed class TrackingExternalCliSessionHistoryService : IExternalCliSessionHistoryService + { + public Task GetRecentHistoryAsync(string toolId, string cliThreadId, int maxCount = 20, string? workspacePath = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetRecentMessagesAsync(string toolId, string cliThreadId, int maxCount = 20, string? workspacePath = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetCodexFinalAnswerTextAsync(string cliThreadId, string? workspacePath = null, CancellationToken cancellationToken = default) => Task.FromResult(null); + } + + private sealed class TrackingFeishuCardKitClient : IFeishuCardKitClient + { + public Exception? ImportMarkdownException { get; set; } + public Exception? GrantDocumentAdminException { get; set; } + public Exception? GrantFolderAdminException { get; set; } + + public List ConvertedBlocks { get; } = []; + public List<(string Title, string DocumentId, string RootBlockId, string Url, string? FolderToken)> CreatedDocuments { get; } = []; + public List<(string DocumentId, string BlockId, string Text)> AppendedTexts { get; } = []; + public List<(string DocumentId, string BlockId, IReadOnlyList Blocks)> AppendedBlockBatches { get; } = []; + public Dictionary<(string DocumentId, string BlockId), List> ChildBlockIdsByDocumentAndBlock { get; } = []; + public List PermissionUpdates { get; } = []; + public List TextMessages { get; } = []; + public List EnsuredFolderNames { get; } = []; + public Dictionary ExistingFolderDocumentsByTitle { get; } = new(StringComparer.OrdinalIgnoreCase); + public List<(string FileName, string Title, string FolderToken, string Body)> ImportedMarkdownDocuments { get; } = []; + public List<(string DocumentId, string BlockId, int StartIndex, int EndIndex)> DeletedChildRanges { get; } = []; + public List<(string DocumentId, string OpenId)> DocumentAdminGrants { get; } = []; + public List<(string FolderToken, string OpenId)> FolderAdminGrants { get; } = []; + + public Task CreateCloudDocumentAsync(string title, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, string? folderToken = null) + { + CreatedDocuments.Add((title, $"doc-{CreatedDocuments.Count + 1}", $"root-{CreatedDocuments.Count + 1}", $"https://feishu.cn/docx/doc-{CreatedDocuments.Count + 1}", folderToken)); + var created = CreatedDocuments[^1]; + return Task.FromResult(new FeishuCloudDocumentInfo + { + DocumentId = created.DocumentId, + RootBlockId = created.RootBlockId, + Url = created.Url + }); + } + + public Task ConvertMarkdownToCloudDocumentBlocksAsync(string markdown, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + var blocksJson = string.Join(",", ConvertedBlocks.Select(static block => block.GetRawText())); + using var document = JsonDocument.Parse($$"""{"blocks":[{{blocksJson}}]}"""); + return Task.FromResult(document.RootElement.Clone()); + } + + public Task AppendCloudDocumentBlocksAsync(string documentId, string blockId, IReadOnlyCollection blocks, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + AppendedBlockBatches.Add((documentId, blockId, blocks.Select(static block => block.Clone()).ToArray())); + return Task.CompletedTask; + } + + public Task AppendCloudDocumentTextAsync(string documentId, string blockId, string text, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + AppendedTexts.Add((documentId, blockId, text)); + return Task.CompletedTask; + } + + public Task SetCloudDocumentTenantReadableAsync(string documentId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + PermissionUpdates.Add(documentId); + return Task.CompletedTask; + } + + public Task EnsureCloudFolderAsync(string folderName, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + EnsuredFolderNames.Add(folderName); + return Task.FromResult($"folder-{EnsuredFolderNames.Count}"); + } + + public Task FindCloudDocumentInFolderByTitleAsync(string folderToken, string title, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + return Task.FromResult(ExistingFolderDocumentsByTitle.TryGetValue(title, out var existing) ? existing : null); + } + + public Task> ListCloudDocumentChildBlockIdsAsync(string documentId, string blockId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + return Task.FromResult>( + ChildBlockIdsByDocumentAndBlock.TryGetValue((documentId, blockId), out var blockIds) + ? [.. blockIds] + : []); + } + + public Task DeleteCloudDocumentChildBlocksAsync(string documentId, string blockId, int startIndex, int endIndex, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + DeletedChildRanges.Add((documentId, blockId, startIndex, endIndex)); + return Task.CompletedTask; + } + + public Task ImportMarkdownFileAsCloudDocumentAsync(string fileName, byte[] content, string title, string? folderToken, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + if (ImportMarkdownException != null) + { + throw ImportMarkdownException; + } + + ImportedMarkdownDocuments.Add((fileName, title, folderToken ?? string.Empty, Encoding.UTF8.GetString(content))); + var number = ImportedMarkdownDocuments.Count; + return Task.FromResult(new FeishuCloudDocumentInfo + { + DocumentId = $"imported-doc-{number}", + RootBlockId = $"imported-doc-{number}", + Url = $"https://feishu.cn/docx/imported-doc-{number}" + }); + } + + public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + TextMessages.Add(content); + return Task.FromResult($"om_{TextMessages.Count}"); + } + + public Task GrantCloudDocumentMemberFullAccessAsync(string documentId, string openId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + if (GrantDocumentAdminException != null) + { + throw GrantDocumentAdminException; + } + + DocumentAdminGrants.Add((documentId, openId)); + return Task.CompletedTask; + } + + public Task GrantCloudFolderMemberFullAccessAsync(string folderToken, string openId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + if (GrantFolderAdminException != null) + { + throw GrantFolderAdminException; + } + + FolderAdminGrants.Add((folderToken, openId)); + return Task.CompletedTask; + } + + public Task CreateCardAsync(string initialContent, string? title = null, 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 DownloadIncomingAttachmentAsync(FeishuIncomingAttachment attachment, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync(string messageId, string fileKey, string resourceType, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task MoveCloudDocumentToFolderAsync(string documentId, string folderToken, 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 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(); + public Task ReplyTextMessageAsync(string replyMessageId, string content, 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 SendRawCardAsync(string chatId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + public Task UploadCloudFileAsync(string fileName, byte[] content, string? folderToken, 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(); + } + + private sealed class InMemoryReferencedMarkdownImportStateStore : IReferencedMarkdownImportStateStore + { + private readonly Dictionary<(string FolderToken, string AbsolutePath), ReferencedMarkdownImportStateEntry> _entries = new(); + + public Task GetAsync(string folderToken, string absolutePath, CancellationToken cancellationToken = default) + { + return Task.FromResult( + _entries.TryGetValue((folderToken, absolutePath), out var entry) + ? entry + : null); + } + + public Task UpsertAsync(ReferencedMarkdownImportStateEntry entry, CancellationToken cancellationToken = default) + { + _entries[(entry.FolderToken, entry.AbsolutePath)] = entry; + return Task.CompletedTask; + } + } + + private sealed class ReplyDocumentSessionContext + { + public string SessionId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string ToolId { get; set; } = string.Empty; + public string? CliThreadId { get; set; } + public string? Title { get; set; } + public string? WorkspacePath { get; set; } + public string? SnapshotToolId { get; set; } + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs b/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs new file mode 100644 index 0000000..6203155 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs @@ -0,0 +1,1217 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.RegularExpressions; +using SqlSugar; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Domain.Service.Channels; +using WebCodeCli.Domain.Model; +using WebCodeCli.Domain.Repositories.Base.ChatSession; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReplyDocumentOrchestratorTests +{ + private const string ContinueQuestion = "继续"; + private const string FullReplyText = "完整回复"; + private const string FullReplyBody = "完整回复正文"; + private const string FinalReplyText = "结论"; + private const string FinalReplyBody = "结论正文"; + private const string NamedSessionTitle = "商业基础盘"; + private const string UnnamedSessionTitle = "未命名"; + + [Fact] + public async Task QueueCompletedReplyAsync_SkipsWhenBothReplyDocumentsDisabled() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = false, + FinalReplyDocEnabled = false + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-disabled-chat", + Username = "luhaiyan", + Output = FullReplyText, + FinalAnswerOutput = FinalReplyText + }); + + await WaitUntilAsync(() => harness.ConfigService.UsernameLookupCount == 1); + + Assert.Empty(harness.CardKit.CreatedDocuments); + Assert.Empty(harness.CardKit.TextMessages); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenFullReplyDocumentEnabled_CreatesOneDocumentAndSendsLink() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true, + FinalReplyDocEnabled = false + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-full-chat", + SessionId = "session-1", + CliThreadId = "thread-1", + OriginalUserQuestion = "question", + Username = "luhaiyan", + Output = FullReplyBody, + FinalAnswerOutput = FinalReplyBody + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + var document = Assert.Single(harness.CardKit.CreatedDocuments); + AssertTitleMatches("question", document.Title); + Assert.Equal("## 用户内容\n\nquestion\n\n---\n\n完整回复正文", Assert.Single(harness.CardKit.AppendedTexts).Text); + Assert.Single(harness.CardKit.PermissionUpdates); + Assert.Single(harness.CardKit.TextMessages); + Assert.Contains("已生成完整回复文档:", harness.CardKit.TextMessages.Single(), StringComparison.Ordinal); + Assert.Contains(document.Url, harness.CardKit.TextMessages.Single(), StringComparison.Ordinal); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenFinalReplyDocumentEnabled_UsesLiveFinalAnswerOnly() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = false, + FinalReplyDocEnabled = true + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-final-chat", + SessionId = "session-1", + CliThreadId = "thread-2", + OriginalUserQuestion = ContinueQuestion, + Username = "luhaiyan", + Output = "过程说明", + FinalAnswerOutput = FinalReplyBody + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + var document = Assert.Single(harness.CardKit.CreatedDocuments); + AssertTitleMatches(ContinueQuestion, document.Title); + Assert.Equal("## 用户内容\n\n继续\n\n---\n\n结论正文", Assert.Single(harness.CardKit.AppendedTexts).Text); + Assert.Equal(0, harness.HistoryService.FinalAnswerLookupCount); + Assert.Contains("已生成结论回复文档:", Assert.Single(harness.CardKit.TextMessages), StringComparison.Ordinal); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenFinalLiveTextMissing_UsesCodexFallback() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = false, + FinalReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-fallback", + Username = "luhaiyan", + ToolId = "codex", + CliThreadId = "thread-fallback", + WorkspacePath = @"D:\repo\superpowers" + }); + + harness.HistoryService.FinalAnswerText = "rollout 结论"; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-fallback-chat", + SessionId = "session-fallback", + OriginalUserQuestion = ContinueQuestion, + Username = "luhaiyan", + Output = "过程说明", + FinalAnswerOutput = "" + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 1); + + Assert.Equal(1, harness.HistoryService.FinalAnswerLookupCount); + Assert.Equal("thread-fallback", harness.HistoryService.LastCliThreadId); + Assert.Equal(@"D:\repo\superpowers", harness.HistoryService.LastWorkspacePath); + Assert.Equal("## 用户内容\n\n继续\n\n---\n\nrollout 结论", Assert.Single(harness.CardKit.AppendedTexts).Text); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenBothReplyDocumentsEnabled_CreatesTwoDocuments() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true, + FinalReplyDocEnabled = true + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-both-chat", + SessionId = "session-1", + CliThreadId = "thread-both", + OriginalUserQuestion = "question", + Username = "luhaiyan", + Output = FullReplyBody, + FinalAnswerOutput = FinalReplyBody + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 2); + + Assert.Equal(2, harness.CardKit.CreatedDocuments.Count); + Assert.Equal(2, harness.CardKit.AppendedTexts.Count); + Assert.Equal(2, harness.CardKit.PermissionUpdates.Count); + Assert.Equal(2, harness.CardKit.TextMessages.Count); + Assert.All(harness.CardKit.CreatedDocuments, item => AssertTitleMatches("question", item.Title)); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenDocumentAdminOpenIdConfigured_GrantsAdminPermissionToCreatedDocument() + { + var config = new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }; + SetStringProperty(config, "DocumentAdminOpenId", "ou_doc_admin"); + + using var harness = new ReplyDocumentOrchestratorHarness(config); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-admin-grant-chat", + SessionId = "session-admin-grant", + CliThreadId = "thread-admin-grant", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + var grant = Assert.Single(harness.CardKit.DocumentAdminGrants); + Assert.Equal(harness.CardKit.CreatedDocuments.Single().DocumentId, grant.DocumentId); + Assert.Equal("ou_doc_admin", grant.OpenId); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenDocumentAdminOpenIdConfigured_GrantsAdminPermissionToReplyDocumentFolder() + { + var config = new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }; + SetStringProperty(config, "DocumentAdminOpenId", "ou_doc_admin"); + + using var harness = new ReplyDocumentOrchestratorHarness( + config, + session: new ReplyDocumentSessionContext + { + SessionId = "session-folder-admin-grant", + Username = "luhaiyan", + CliThreadId = "thread-folder-admin-grant", + Title = NamedSessionTitle + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-folder-admin-grant-chat", + SessionId = "session-folder-admin-grant", + CliThreadId = "thread-folder-admin-grant", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + var folderGrant = Assert.Single(harness.CardKit.FolderAdminGrants); + Assert.Equal("folder-1", folderGrant.FolderToken); + Assert.Equal("ou_doc_admin", folderGrant.OpenId); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenGrantingDocumentAdminFails_SendsWarningWithoutBlockingDocumentLink() + { + var config = new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }; + SetStringProperty(config, "DocumentAdminOpenId", "ou_doc_admin"); + + using var harness = new ReplyDocumentOrchestratorHarness(config); + harness.CardKit.GrantDocumentAdminException = new HttpRequestException( + "API request failed: Status=BadRequest, Content={\"code\":99991672,\"msg\":\"Access denied. One of the following scopes is required: [drive:drive].应用尚未开通所需的应用身份权限:[drive:drive],点击链接申请并开通任一权限即可:https://open.feishu.cn/app/test/auth?q=drive:drive\"}"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-admin-grant-warning-chat", + SessionId = "session-admin-grant-warning", + CliThreadId = "thread-admin-grant-warning", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + Assert.Contains("已生成完整回复文档:", harness.CardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("文档管理员权限授予失败", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + Assert.Contains("drive:drive", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenGrantingFolderAdminFails_SendsWarningWithoutBlockingDocumentLink() + { + var config = new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }; + SetStringProperty(config, "DocumentAdminOpenId", "ou_doc_admin"); + + using var harness = new ReplyDocumentOrchestratorHarness( + config, + session: new ReplyDocumentSessionContext + { + SessionId = "session-folder-admin-warning", + Username = "luhaiyan", + CliThreadId = "thread-folder-admin-warning", + Title = NamedSessionTitle + }); + + harness.CardKit.GrantFolderAdminException = new HttpRequestException( + "API request failed: Status=BadRequest, Content={\"code\":99991672,\"msg\":\"Access denied. One of the following scopes is required: [drive:drive].应用尚未开通所需的应用身份权限:[drive:drive],点击链接申请并开通任一权限即可:https://open.feishu.cn/app/test/auth?q=drive:drive\"}"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-folder-admin-warning-chat", + SessionId = "session-folder-admin-warning", + CliThreadId = "thread-folder-admin-warning", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + Assert.Contains("已生成完整回复文档:", harness.CardKit.TextMessages[0], StringComparison.Ordinal); + Assert.Contains("会话文档文件夹管理员权限授予失败", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + Assert.Contains("drive:drive", harness.CardKit.TextMessages[1], StringComparison.Ordinal); + Assert.Equal("folder-1", Assert.Single(harness.CardKit.CreatedDocuments).FolderToken); + Assert.Empty(harness.CardKit.MovedDocuments); + Assert.Single(harness.CardKit.DocumentAdminGrants); + Assert.Empty(harness.CardKit.FolderAdminGrants); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenCliThreadIdMissing_TitleStillUsesQuestionAndTimestamp() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-session-fallback-chat", + SessionId = "session-fallback-id", + Username = "luhaiyan", + OriginalUserQuestion = ContinueQuestion, + Output = FullReplyBody + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 1); + + AssertTitleMatches(ContinueQuestion, harness.CardKit.CreatedDocuments.Single().Title); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenSessionTitlePresent_UsesSessionTitleAsFolderName() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-folder-title", + Username = "luhaiyan", + CliThreadId = "thread-folder-title", + Title = NamedSessionTitle + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-folder-title-chat", + SessionId = "session-folder-title", + CliThreadId = "thread-folder-title", + Username = "luhaiyan", + OriginalUserQuestion = "补充内容", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + Assert.Equal([$"{NamedSessionTitle} [thread-folder-title]"], harness.CardKit.EnsuredFolderNames); + var document = Assert.Single(harness.CardKit.CreatedDocuments); + Assert.Equal("folder-1", document.FolderToken); + Assert.Empty(harness.CardKit.MovedDocuments); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenSessionTitleIsUnnamed_FallsBackToCliThreadIdForFolder() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-folder-unnamed", + Username = "luhaiyan", + CliThreadId = "thread-fallback-folder", + Title = UnnamedSessionTitle + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-folder-unnamed-chat", + SessionId = "session-folder-unnamed", + CliThreadId = "thread-fallback-folder", + Username = "luhaiyan", + OriginalUserQuestion = "补充内容", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + Assert.Equal(["thread-fallback-folder"], harness.CardKit.EnsuredFolderNames); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenTitleAndThreadMissing_FallsBackToSessionIdForFolder() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-folder-id-fallback", + Username = "luhaiyan", + CliThreadId = "", + Title = " " + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-folder-sessionid-chat", + SessionId = "session-folder-id-fallback", + CliThreadId = "", + Username = "luhaiyan", + OriginalUserQuestion = "补充内容", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + Assert.Equal(["session-folder-id-fallback"], harness.CardKit.EnsuredFolderNames); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenDocumentCreationFails_SendsFailureMessageToChat() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }); + + harness.CardKit.CreateDocumentException = new HttpRequestException( + "API request failed: BadRequest | code=99991672 | missing scopes: docx:document,docx:document:create"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-create-failed-chat", + SessionId = "session-create-failed", + CliThreadId = "thread-create-failed", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + var failureMessage = Assert.Single(harness.CardKit.TextMessages); + Assert.Contains("\u751f\u6210\u5931\u8d25", failureMessage, StringComparison.Ordinal); + Assert.Contains("docx:document", failureMessage, StringComparison.Ordinal); + Assert.Contains("docx:document:create", failureMessage, StringComparison.Ordinal); + Assert.Empty(harness.CardKit.AppendedTexts); + Assert.Empty(harness.CardKit.PermissionUpdates); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenFolderPermissionMissing_CreatesDocumentAndSendsWarningToChat() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-folder-permission-failed", + Username = "luhaiyan", + CliThreadId = "thread-folder-permission-failed", + Title = "Need Folder" + }); + + harness.CardKit.EnsureFolderException = new HttpRequestException( + "API request failed: Status=BadRequest, Content={\"code\":99991672,\"msg\":\"Access denied. One of the following scopes is required: [drive:drive, drive:drive.metadata:readonly].应用尚未开通所需的应用身份权限:[drive:drive, drive:drive.metadata:readonly],点击链接申请并开通任一权限即可:https://open.feishu.cn/app/cli_a929ada764389cd4/auth?q=drive:drive,drive:drive.metadata:readonly&op_from=openapi&token_type=tenant\"}"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-folder-failed-chat", + SessionId = "session-folder-permission-failed", + CliThreadId = "thread-folder-permission-failed", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + var document = Assert.Single(harness.CardKit.CreatedDocuments); + var linkMessage = harness.CardKit.TextMessages[0]; + var warningMessage = harness.CardKit.TextMessages[1]; + Assert.Contains("已生成完整回复文档:", linkMessage, StringComparison.Ordinal); + Assert.Contains(document.Url, linkMessage, StringComparison.Ordinal); + Assert.Contains("已生成,但归档到会话文档文件夹失败", warningMessage, StringComparison.Ordinal); + Assert.Contains("drive:drive", warningMessage, StringComparison.Ordinal); + Assert.Contains("drive:drive.metadata:readonly", warningMessage, StringComparison.Ordinal); + Assert.Contains("应用尚未开通所需的应用身份权限", warningMessage, StringComparison.Ordinal); + Assert.Contains("点击链接申请并开通任一权限即可", warningMessage, StringComparison.Ordinal); + Assert.Contains("https://open.feishu.cn/app/cli_a929ada764389cd4/auth?q=drive:drive,drive:drive.metadata:readonly&op_from=openapi&token_type=tenant", warningMessage, StringComparison.Ordinal); + Assert.Single(harness.CardKit.AppendedTexts); + Assert.Single(harness.CardKit.PermissionUpdates); + Assert.Null(document.FolderToken); + Assert.Empty(harness.CardKit.MovedDocuments); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenReplyDocumentFolderNotFound_CreatesDocumentAndSendsFolderWarningToChat() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-folder-not-found", + Username = "luhaiyan", + CliThreadId = "thread-folder-not-found", + Title = "Need Folder" + }); + + harness.CardKit.EnsureFolderException = new HttpRequestException( + "API request failed: Status=NotFound, Content={\"code\":1061003,\"msg\":\"not found.\",\"error\":{\"log_id\":\"2026060216084302B6962999D06CE4F8A4\"}}"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-folder-not-found-chat", + SessionId = "session-folder-not-found", + CliThreadId = "thread-folder-not-found", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + var document = Assert.Single(harness.CardKit.CreatedDocuments); + var linkMessage = harness.CardKit.TextMessages[0]; + var warningMessage = harness.CardKit.TextMessages[1]; + Assert.Contains("已生成完整回复文档:", linkMessage, StringComparison.Ordinal); + Assert.Contains(document.Url, linkMessage, StringComparison.Ordinal); + Assert.Contains("已生成,但在定位会话文档文件夹时", warningMessage, StringComparison.Ordinal); + Assert.Contains("会话文档文件夹", warningMessage, StringComparison.Ordinal); + Assert.DoesNotContain("Status=NotFound", warningMessage, StringComparison.Ordinal); + Assert.DoesNotContain("\"code\":1061003", warningMessage, StringComparison.Ordinal); + Assert.Single(harness.CardKit.AppendedTexts); + Assert.Single(harness.CardKit.PermissionUpdates); + Assert.Null(document.FolderToken); + Assert.Empty(harness.CardKit.MovedDocuments); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenCreateInReplyDocumentFolderFailsWithNotFound_CreatesDocumentAndSendsPlacementWarningToChat() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-folder-move-not-found", + Username = "luhaiyan", + CliThreadId = "thread-folder-move-not-found", + Title = "Need Folder" + }); + + harness.CardKit.CreateDocumentInFolderException = new HttpRequestException( + "API request failed: Status=NotFound, Content={\"code\":1061003,\"msg\":\"not found.\",\"error\":{\"log_id\":\"202606021724410B11BFB987DE9BFA819F\"}}"); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-folder-create-not-found-chat", + SessionId = "session-folder-move-not-found", + CliThreadId = "thread-folder-move-not-found", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + var document = Assert.Single(harness.CardKit.CreatedDocuments); + var linkMessage = harness.CardKit.TextMessages[0]; + var warningMessage = harness.CardKit.TextMessages[1]; + Assert.Contains("已生成完整回复文档:", linkMessage, StringComparison.Ordinal); + Assert.Contains(document.Url, linkMessage, StringComparison.Ordinal); + Assert.Contains("已生成,但在归档到会话文档文件夹时", warningMessage, StringComparison.Ordinal); + Assert.DoesNotContain("Status=NotFound", warningMessage, StringComparison.Ordinal); + Assert.DoesNotContain("\"code\":1061003", warningMessage, StringComparison.Ordinal); + Assert.Single(harness.CardKit.AppendedTexts); + Assert.Single(harness.CardKit.PermissionUpdates); + Assert.Single(harness.CardKit.EnsuredFolders); + Assert.Empty(harness.CardKit.MovedDocuments); + Assert.Null(document.FolderToken); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenSessionFolderResolved_CreatesDocumentDirectlyInsideFolder() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-direct-folder-create", + Username = "luhaiyan", + CliThreadId = "thread-direct-folder-create", + Title = "Need Folder" + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-direct-folder-create-chat", + SessionId = "session-direct-folder-create", + CliThreadId = "thread-direct-folder-create", + Username = "luhaiyan", + OriginalUserQuestion = "continue", + Output = "full reply body" + }); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 1); + + Assert.Single(harness.CardKit.EnsuredFolders); + var document = Assert.Single(harness.CardKit.CreatedDocuments); + Assert.Equal("folder-1", document.FolderToken); + Assert.Empty(harness.CardKit.MovedDocuments); + } + + [Fact] + public async Task QueueCompletedReplyAsync_SerializesJobsPerChat() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }); + + harness.CardKit.BlockFirstCreate = true; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-serialized-chat", + SessionId = "session-1", + Username = "luhaiyan", + OriginalUserQuestion = "first", + Output = "first reply" + }); + + await harness.CardKit.FirstCreateStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-serialized-chat", + SessionId = "session-2", + Username = "luhaiyan", + OriginalUserQuestion = "second", + Output = "second reply" + }); + + await Task.Delay(150, TestContext.Current.CancellationToken); + Assert.Single(harness.CardKit.CreatedDocuments); + + harness.CardKit.ReleaseFirstCreate(); + + await WaitUntilAsync(() => harness.CardKit.TextMessages.Count == 2); + + Assert.Collection( + harness.CardKit.CreatedDocuments, + first => AssertTitleMatches("first", first.Title), + second => AssertTitleMatches("second", second.Title)); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenRolloutHasRealUserMessage_UsesItForTitleInsteadOfControlPrompt() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }, + session: new ReplyDocumentSessionContext + { + SessionId = "session-title-history", + Username = "luhaiyan", + ToolId = "codex", + CliThreadId = "thread-title-history", + WorkspacePath = @"D:\repo\superpowers" + }); + + harness.HistoryService.RecentMessages = [ + new ExternalCliHistoryMessage + { + Role = "user", + Content = "/goal 使用Subagent-Driven完成plan文档 plan.md" + }, + new ExternalCliHistoryMessage + { + Role = "assistant", + Content = "收到" + }, + new ExternalCliHistoryMessage + { + Role = "user", + Content = "先把这个关键产品约束定掉" + } + ]; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-title-history-chat", + SessionId = "session-title-history", + CliThreadId = "thread-title-history", + OriginalUserQuestion = "使用Subagent-Driven完成plan文档 plan.md", + Username = "luhaiyan", + Output = FullReplyBody + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 1); + + Assert.Equal(1, harness.HistoryService.RecentMessagesLookupCount); + Assert.Equal("thread-title-history", harness.HistoryService.LastRecentMessagesCliThreadId); + Assert.Equal(@"D:\repo\superpowers", harness.HistoryService.LastRecentMessagesWorkspacePath); + AssertTitleMatches("先把这个关键产品约束定掉", harness.CardKit.CreatedDocuments.Single().Title); + Assert.Equal("## 用户内容\n\n先把这个关键产品约束定掉\n\n---\n\n完整回复正文", Assert.Single(harness.CardKit.AppendedTexts).Text); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenQuestionTooLong_TruncatesQuestionAndStillUsesTimestampTitle() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }); + + var longQuestion = string.Concat(Enumerable.Repeat("超长用户问题内容", 30)); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-title-truncated-chat", + SessionId = "session-title-truncated", + CliThreadId = "thread-title-visible", + OriginalUserQuestion = longQuestion, + Username = "luhaiyan", + Output = FullReplyBody + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 1); + + var documentTitle = harness.CardKit.CreatedDocuments.Single().Title; + Assert.StartsWith("超长用户问题内容", documentTitle, StringComparison.Ordinal); + Assert.Matches(@"^.+ \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$", documentTitle); + Assert.True(documentTitle.Length <= 180, "Document title should respect the Feishu title length limit."); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenFullReplyContainsStructuredContent_PreservesAssistantBodyOrderAfterSeparator() + { + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + FullReplyDocEnabled = true + }); + + var assistantBody = """ +我会继续按 Route 07 的单问题澄清推进;刚才通知边界已确认:Route 07 产出事件和通知意图,System Notifications 负责模板、规则、收件人和发送。下一步确认 AI-first 操作面边界,因为这会决定后续是否需要脚本/后端 operation surface,而不是让 AI 去点 UI。Route 07 第十六个需要确认的问题:Route 07 是否需要为 AI 提供独立的后端/脚本化操作面,还是只依赖人工 UI 配置? + +我推荐 B: + +B. 需要 AI-first 操作面,但分阶段建设,不要求 V1 一次做全。 +Route 07 的路线、计划、点位、检查项、发布、生成预览、生成执行、回滚、运行时确认,都属于 backend-owned runtime configuration,不应该只靠 AI 模拟浏览器 UI。 +V1 实施时至少要定义 operation contract 目标形态:inspect、validate、save draft、submit approval、publish、rollback、preview generation、execute generation、retry generation、runtime confirm。 +如果本 route 实施量过大,可以先交付核心后端 authoring API,再把 PowerShell 脚本/专用 skill 作为稳定后补项;但 spec 要先明确这是目标边界。 +对 trigger policy 的仿真、发布、执行日志、直接修订验证,也应纳入 AI-first 操作面目标,避免 AI 通过 UI 点复杂规则。 +已存在的菜单、权限、页面元数据、国际化、字典、参数、工作流、通知继续使用已有 AI-first operation skills,不在 Route 07 重造。 +后续如果 Inspection authoring surface 稳定且重复使用,再创建独立 mmis-inspection-authoring-operations skill;否则先记录为 Route 07 的技能治理候选。 +不推荐 A:只靠 UI。路线/计划/发布/生成/回滚/运行时确认是状态型配置,AI 点 UI 容易漏发布、漏确认、误以为草稿已生效。 + +不推荐 C:V1 一次性做完整脚本和独立 skill。方向对,但容易把 Route 07 变成平台工程,建议先定义 contract 和核心 authoring surface,再按重复性升级技能。 + +你是否认可按 B 写入 Route 07 设计? +"""; + + var userQuestion = "Route 07 是否需要为 AI 提供独立的后端/脚本化操作面,还是只依赖人工 UI 配置?"; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-structured-body-chat", + SessionId = "session-structured-body", + CliThreadId = "thread-structured-body", + OriginalUserQuestion = userQuestion, + Username = "luhaiyan", + Output = assistantBody + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 1); + + Assert.Equal( + $"## 用户内容\n\n{userQuestion}\n\n---\n\n{assistantBody}", + Assert.Single(harness.CardKit.AppendedTexts).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 static void AssertTitleMatches(string expectedQuestion, string actualTitle) + { + var pattern = $"^{Regex.Escape(expectedQuestion)} \\d{{4}}-\\d{{2}}-\\d{{2}} \\d{{2}}:\\d{{2}}:\\d{{2}}\\.\\d{{3}}$"; + Assert.Matches(pattern, actualTitle); + } + + private static void SetStringProperty(object target, string propertyName, string value) + { + target.GetType().GetProperty(propertyName)?.SetValue(target, value); + } + + private sealed class ReplyDocumentOrchestratorHarness : IDisposable + { + private readonly ServiceProvider _serviceProvider; + + public ReplyDocumentOrchestratorHarness( + UserFeishuBotConfigEntity config, + ReplyDocumentSessionContext? session = null) + { + ConfigService = new TrackingUserFeishuBotConfigService(config); + CardKit = new TrackingFeishuCardKitClient(); + ChatSessionRepository = new TrackingChatSessionRepository(session); + HistoryService = new TrackingExternalCliSessionHistoryService(); + + var services = new ServiceCollection(); + services.AddScoped(_ => ConfigService); + services.AddScoped(_ => CardKit); + services.AddScoped(_ => ChatSessionRepository); + services.AddScoped(_ => HistoryService); + services.AddSingleton(); + services.AddLogging(); + + _serviceProvider = services.BuildServiceProvider(); + Orchestrator = new ReplyDocumentOrchestrator( + _serviceProvider, + NullLogger.Instance); + } + + public TrackingUserFeishuBotConfigService ConfigService { get; } + + public TrackingFeishuCardKitClient CardKit { get; } + + public TrackingChatSessionRepository ChatSessionRepository { get; } + + public TrackingExternalCliSessionHistoryService HistoryService { get; } + + public ReplyDocumentOrchestrator Orchestrator { get; } + + public void Dispose() + { + _serviceProvider.Dispose(); + } + } + + private sealed class InMemoryReferencedMarkdownImportStateStore : IReferencedMarkdownImportStateStore + { + public Task GetAsync(string folderToken, string absolutePath, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task UpsertAsync(ReferencedMarkdownImportStateEntry entry, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + + 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 TrackingChatSessionRepository(ReplyDocumentSessionContext? session) : IChatSessionRepository + { + private readonly Dictionary _sessions = session == null + ? [] + : new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [session.SessionId] = new ChatSessionEntity + { + SessionId = session.SessionId, + Username = session.Username, + Title = session.Title, + ToolId = session.ToolId, + CliThreadId = session.CliThreadId, + WorkspacePath = session.WorkspacePath, + CcSwitchSnapshotToolId = session.SnapshotToolId + } + }; + + public SqlSugarScope GetDB() => throw new NotSupportedException(); + public List GetList() => _sessions.Values.ToList(); + public Task> GetListAsync() => Task.FromResult(GetList()); + public List GetList(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().Where(whereExpression).ToList(); + public Task> GetListAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetList(whereExpression)); + public int Count(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().Count(whereExpression); + public Task CountAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(Count(whereExpression)); + public PageList GetPageList(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList

GetPageList

(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync

(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public PageList

GetPageList

(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync

(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, SqlSugar.OrderByType orderByType = SqlSugar.OrderByType.Asc) => throw new NotSupportedException(); + public ChatSessionEntity GetById(dynamic id) => _sessions[(id?.ToString() ?? string.Empty)]; + public Task GetByIdAsync(dynamic id) + { + ChatSessionEntity? found = _sessions.TryGetValue(id?.ToString() ?? string.Empty, out ChatSessionEntity? stored) ? stored : null; + return Task.FromResult(found)!; + } + + public ChatSessionEntity GetSingle(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().Single(whereExpression); + public Task GetSingleAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetSingle(whereExpression)); + public ChatSessionEntity GetFirst(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().First(whereExpression); + public Task GetFirstAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetFirst(whereExpression)); + public bool Insert(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool InsertRange(List objs) => throw new NotSupportedException(); + public Task InsertRangeAsync(List objs) => throw new NotSupportedException(); + public int InsertReturnIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public long InsertReturnBigIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnBigIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool DeleteByIds(dynamic[] ids) => throw new NotSupportedException(); + public Task DeleteByIdsAsync(dynamic[] ids) => throw new NotSupportedException(); + public bool Delete(dynamic id) => throw new NotSupportedException(); + public Task DeleteAsync(dynamic id) => throw new NotSupportedException(); + public bool Delete(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task DeleteAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool Delete(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public Task DeleteAsync(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public bool Update(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool UpdateRange(List objs) => throw new NotSupportedException(); + public bool InsertOrUpdate(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertOrUpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateRangeAsync(List objs) => throw new NotSupportedException(); + public bool IsAny(System.Linq.Expressions.Expression> whereExpression) => _sessions.Values.AsQueryable().Any(whereExpression); + public Task IsAnyAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(IsAny(whereExpression)); + public Task> GetByUsernameAsync(string username) => Task.FromResult(_sessions.Values.Where(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase)).ToList()); + public Task GetByIdAndUsernameAsync(string sessionId, string username) => Task.FromResult(_sessions.Values.FirstOrDefault(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase))); + public Task DeleteByIdAndUsernameAsync(string sessionId, string username) => throw new NotSupportedException(); + public Task> GetByUsernameOrderByUpdatedAtAsync(string username) => throw new NotSupportedException(); + public Task GetByUsernameToolAndCliThreadIdAsync(string username, string toolId, string cliThreadId) => throw new NotSupportedException(); + public Task GetByToolAndCliThreadIdAsync(string toolId, string cliThreadId) => throw new NotSupportedException(); + public Task UpdateCliThreadIdAsync(string sessionId, string? cliThreadId) => throw new NotSupportedException(); + public Task UpdateWorkspaceBindingAsync(string sessionId, string? workspacePath, bool isCustomWorkspace) => throw new NotSupportedException(); + public Task UpdateSessionTitleAsync(string sessionId, string title) => throw new NotSupportedException(); + public Task UpdateCcSwitchSnapshotAsync(string sessionId, CcSwitchSessionSnapshot snapshot) => throw new NotSupportedException(); + public Task> GetByFeishuChatKeyAsync(string feishuChatKey) => throw new NotSupportedException(); + public Task GetActiveByFeishuChatKeyAsync(string feishuChatKey) => throw new NotSupportedException(); + public Task SetActiveSessionAsync(string feishuChatKey, string sessionId) => throw new NotSupportedException(); + public Task CloseFeishuSessionAsync(string feishuChatKey, string sessionId) => throw new NotSupportedException(); + public Task CreateFeishuSessionAsync(string feishuChatKey, string username, string? workspacePath = null, string? toolId = null) => throw new NotSupportedException(); + } + + private sealed class TrackingExternalCliSessionHistoryService : IExternalCliSessionHistoryService + { + public int FinalAnswerLookupCount { get; private set; } + public int RecentMessagesLookupCount { get; private set; } + public string? FinalAnswerText { get; set; } + public List RecentMessages { get; set; } = []; + public string? LastCliThreadId { get; private set; } + public string? LastWorkspacePath { get; private set; } + public string? LastRecentMessagesCliThreadId { get; private set; } + public string? LastRecentMessagesWorkspacePath { get; private set; } + + public Task GetRecentHistoryAsync(string toolId, string cliThreadId, int maxCount = 20, string? workspacePath = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetRecentMessagesAsync(string toolId, string cliThreadId, int maxCount = 20, string? workspacePath = null, CancellationToken cancellationToken = default) + { + RecentMessagesLookupCount++; + LastRecentMessagesCliThreadId = cliThreadId; + LastRecentMessagesWorkspacePath = workspacePath; + return Task.FromResult(RecentMessages); + } + + public Task GetCodexFinalAnswerTextAsync(string cliThreadId, string? workspacePath = null, CancellationToken cancellationToken = default) + { + FinalAnswerLookupCount++; + LastCliThreadId = cliThreadId; + LastWorkspacePath = workspacePath; + return Task.FromResult(FinalAnswerText); + } + } + + private sealed class TrackingFeishuCardKitClient : IFeishuCardKitClient + { + private readonly TaskCompletionSource _releaseFirstCreate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool BlockFirstCreate { get; set; } + public Exception? CreateDocumentException { get; set; } + public Exception? EnsureFolderException { get; set; } + public Exception? MoveDocumentException { get; set; } + public Exception? CreateDocumentInFolderException { get; set; } + public Exception? GrantDocumentAdminException { get; set; } + public Exception? GrantFolderAdminException { get; set; } + public TaskCompletionSource FirstCreateStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public List<(string Title, string DocumentId, string RootBlockId, string Url, string? FolderToken)> CreatedDocuments { get; } = []; + public List<(string DocumentId, string BlockId, string Text)> AppendedTexts { get; } = []; + public List PermissionUpdates { get; } = []; + public List TextMessages { get; } = []; + public List EnsuredFolderNames { get; } = []; + public List<(string FolderName, string FolderToken)> EnsuredFolders { get; } = []; + public List<(string DocumentId, string FolderToken)> MovedDocuments { get; } = []; + public List<(string DocumentId, string OpenId)> DocumentAdminGrants { get; } = []; + public List<(string FolderToken, string OpenId)> FolderAdminGrants { get; } = []; + + public void ReleaseFirstCreate() => _releaseFirstCreate.TrySetResult(true); + + public async Task CreateCloudDocumentAsync(string title, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, string? folderToken = null) + { + if (!string.IsNullOrWhiteSpace(folderToken) && CreateDocumentInFolderException != null) + { + throw CreateDocumentInFolderException; + } + + if (CreateDocumentException != null) + { + throw CreateDocumentException; + } + + CreatedDocuments.Add((title, $"doc-{CreatedDocuments.Count + 1}", $"root-{CreatedDocuments.Count + 1}", $"https://feishu.cn/docx/doc-{CreatedDocuments.Count + 1}", folderToken)); + FirstCreateStarted.TrySetResult(true); + if (BlockFirstCreate && CreatedDocuments.Count == 1) + { + await _releaseFirstCreate.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + } + + var created = CreatedDocuments[^1]; + return new FeishuCloudDocumentInfo + { + DocumentId = created.DocumentId, + RootBlockId = created.RootBlockId, + Url = created.Url + }; + } + + public Task AppendCloudDocumentTextAsync(string documentId, string blockId, string text, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + AppendedTexts.Add((documentId, blockId, text)); + return Task.CompletedTask; + } + + public Task SetCloudDocumentTenantReadableAsync(string documentId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + PermissionUpdates.Add(documentId); + return Task.CompletedTask; + } + + public Task EnsureCloudFolderAsync(string folderName, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + if (EnsureFolderException != null) + { + throw EnsureFolderException; + } + + EnsuredFolderNames.Add(folderName); + var folderToken = $"folder-{EnsuredFolders.Count + 1}"; + EnsuredFolders.Add((folderName, folderToken)); + return Task.FromResult(folderToken); + } + + public Task MoveCloudDocumentToFolderAsync(string documentId, string folderToken, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + if (MoveDocumentException != null) + { + throw MoveDocumentException; + } + + MovedDocuments.Add((documentId, folderToken)); + return Task.CompletedTask; + } + + public Task GrantCloudDocumentMemberFullAccessAsync(string documentId, string openId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + if (GrantDocumentAdminException != null) + { + throw GrantDocumentAdminException; + } + + DocumentAdminGrants.Add((documentId, openId)); + return Task.CompletedTask; + } + + public Task GrantCloudFolderMemberFullAccessAsync(string folderToken, string openId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + if (GrantFolderAdminException != null) + { + throw GrantFolderAdminException; + } + + FolderAdminGrants.Add((folderToken, openId)); + return Task.CompletedTask; + } + + public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + TextMessages.Add(content); + return Task.FromResult($"om_{TextMessages.Count}"); + } + + 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 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 DownloadIncomingAttachmentAsync(FeishuIncomingAttachment attachment, 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(); + public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync(string messageId, string fileKey, string resourceType, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) => throw new NotSupportedException(); + } + + private sealed class ReplyDocumentSessionContext + { + public string SessionId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string ToolId { get; set; } = string.Empty; + public string? CliThreadId { get; set; } + public string? Title { get; set; } + public string? WorkspacePath { get; set; } + public string? SnapshotToolId { get; set; } + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs deleted file mode 100644 index 5e17389..0000000 --- a/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 7719e81..0000000 --- a/WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs +++ /dev/null @@ -1,627 +0,0 @@ -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(); - - public Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync( - string messageId, - string fileKey, - string resourceType, - 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 deleted file mode 100644 index 5d683e9..0000000 --- a/WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index f9ef9b9..0000000 --- a/WebCodeCli.Domain.Tests/ReplyTtsStartupHostedServiceTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 7893869..0000000 --- a/WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs +++ /dev/null @@ -1,349 +0,0 @@ -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 deleted file mode 100644 index 1dca11d..0000000 --- a/WebCodeCli.Domain.Tests/SherpaKokoroTtsClientTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -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 responses) : HttpMessageHandler - { - private readonly Queue _responses = new(responses); - - public List RequestPaths { get; } = []; - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - RequestPaths.Add(request.RequestUri!.AbsolutePath); - - if (_responses.Count == 0) - { - throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); - } - - return Task.FromResult(_responses.Dequeue()); - } - } -} diff --git a/WebCodeCli.Domain.Tests/SuperpowersPromptBuilderTests.cs b/WebCodeCli.Domain.Tests/SuperpowersPromptBuilderTests.cs index 0edde10..4e5fa75 100644 --- a/WebCodeCli.Domain.Tests/SuperpowersPromptBuilderTests.cs +++ b/WebCodeCli.Domain.Tests/SuperpowersPromptBuilderTests.cs @@ -11,6 +11,7 @@ public void BuildContinuePrompt_ReturnsApprovedPrompt() Assert.Equal( SuperpowersQuickActionDefaults.ContinuePrompt, SuperpowersPromptBuilder.BuildContinuePrompt()); + Assert.StartsWith("可以,认可", SuperpowersPromptBuilder.BuildContinuePrompt(), StringComparison.Ordinal); } [Fact] @@ -29,10 +30,18 @@ public void BuildSubagentExecutePlanPrompt_ReturnsApprovedCombinedPrompt() SuperpowersPromptBuilder.BuildSubagentExecutePlanPrompt()); } + [Fact] + public void BuildCompleteWorktreePrompt_ReturnsApprovedPrompt() + { + Assert.Equal( + SuperpowersQuickActionDefaults.CompleteWorktreePrompt, + SuperpowersPromptBuilder.BuildCompleteWorktreePrompt()); + } + [Theory] - [InlineData("写一个执行步骤", "$using-superpowers ,使用superpowers技能,写一个执行步骤\n\nReply to the user in Chinese. Write documentation and code comments in English only. Keep exception and error messages in Chinese.")] - [InlineData("$superpowers ,使用superpowers技能,写一个执行步骤", "$using-superpowers ,使用superpowers技能,写一个执行步骤\n\nReply to the user in Chinese. Write documentation and code comments in English only. Keep exception and error messages in Chinese.")] - [InlineData(" 写一个执行步骤 ", "$using-superpowers ,使用superpowers技能,写一个执行步骤\n\nReply to the user in Chinese. Write documentation and code comments in English only. Keep exception and error messages in Chinese.")] + [InlineData("写一个执行步骤", "$using-superpowers ,使用superpowers技能,写一个执行步骤\n\nReply to the user in Chinese. Write documentation in English only. 代码注释需要使用中英文双语。 Keep exception and error messages in Chinese.")] + [InlineData("$superpowers ,使用superpowers技能,写一个执行步骤", "$using-superpowers ,使用superpowers技能,写一个执行步骤\n\nReply to the user in Chinese. Write documentation in English only. 代码注释需要使用中英文双语。 Keep exception and error messages in Chinese.")] + [InlineData(" 写一个执行步骤 ", "$using-superpowers ,使用superpowers技能,写一个执行步骤\n\nReply to the user in Chinese. Write documentation in English only. 代码注释需要使用中英文双语。 Keep exception and error messages in Chinese.")] public void BuildQuickSkillPrompt_AppliesPrefixOnlyWhenMissing(string input, string expected) { Assert.Equal(expected, SuperpowersPromptBuilder.BuildQuickSkillPrompt(input)); @@ -41,7 +50,7 @@ public void BuildQuickSkillPrompt_AppliesPrefixOnlyWhenMissing(string input, str [Fact] public void BuildQuickSkillPrompt_DoesNotDuplicateLanguagePolicy() { - var input = "$using-superpowers ,使用superpowers技能,写一个执行步骤\n\nReply to the user in Chinese. Write documentation and code comments in English only. Keep exception and error messages in Chinese."; + var input = "$using-superpowers ,使用superpowers技能,写一个执行步骤\n\nReply to the user in Chinese. Write documentation in English only. 代码注释需要使用中英文双语。 Keep exception and error messages in Chinese."; Assert.Equal(input, SuperpowersPromptBuilder.BuildQuickSkillPrompt(input)); } diff --git a/WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs b/WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs index f9d26c4..d68df62 100644 --- a/WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs +++ b/WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using SqlSugar; using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; using WebCodeCli.Domain.Domain.Service; using WebCodeCli.Domain.Model; using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; @@ -11,7 +12,83 @@ namespace WebCodeCli.Domain.Tests; public class UserFeishuBotConfigServiceTests { [Fact] - public async Task SaveAsync_PersistsReplyTtsFields() + public async Task SaveAsync_WhenLegacyReplyTtsEnabledTrue_MapsToFullReplyDocument() + { + var repository = new InMemoryUserFeishuBotConfigRepository(); + var service = CreateService(repository); + + await service.SaveAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + LegacyReplyTtsEnabled = true, + LegacyReplyTtsVoiceId = "voice-1" + }); + + var stored = await service.GetByUsernameAsync("alice"); + + Assert.NotNull(stored); + Assert.True(stored!.FullReplyDocEnabled); + Assert.False(stored.FinalReplyDocEnabled); + Assert.True(stored.LegacyReplyTtsEnabled); + Assert.Equal(ReplyTtsModes.FullReply, stored.LegacyReplyTtsMode); + Assert.Null(stored.LegacyReplyTtsVoiceId); + } + + [Fact] + public async Task SaveAsync_WhenLegacyReplyTtsEnabledFalse_MapsToReplyDocumentsOff() + { + var repository = new InMemoryUserFeishuBotConfigRepository(); + var service = CreateService(repository); + + await service.SaveAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + LegacyReplyTtsEnabled = false + }); + + var stored = await service.GetByUsernameAsync("alice"); + + Assert.NotNull(stored); + Assert.False(stored!.FullReplyDocEnabled); + Assert.False(stored.FinalReplyDocEnabled); + Assert.False(stored.LegacyReplyTtsEnabled); + Assert.Equal(ReplyTtsModes.Off, stored.LegacyReplyTtsMode); + } + + [Fact] + public async Task SaveAsync_WhenLegacyReplyTtsModeIsFinalOnly_MapsToFinalReplyDocument() + { + var repository = new InMemoryUserFeishuBotConfigRepository(); + var service = CreateService(repository); + + await service.SaveAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + LegacyReplyTtsMode = ReplyTtsModes.FinalOnly, + LegacyReplyTtsVoiceId = "voice-1" + }); + + var stored = await service.GetByUsernameAsync("alice"); + + Assert.NotNull(stored); + Assert.False(stored!.FullReplyDocEnabled); + Assert.True(stored.FinalReplyDocEnabled); + Assert.True(stored.LegacyReplyTtsEnabled); + Assert.Equal(ReplyTtsModes.FinalOnly, stored.LegacyReplyTtsMode); + Assert.Null(stored.LegacyReplyTtsVoiceId); + } + + [Fact] + public async Task SaveAsync_PersistsReplyDocumentFieldsAndBackfillsLegacyCompatibility() { var repository = new InMemoryUserFeishuBotConfigRepository(); var service = CreateService(repository); @@ -22,8 +99,9 @@ public async Task SaveAsync_PersistsReplyTtsFields() IsEnabled = true, AppId = "cli_alice", AppSecret = "secret", - ReplyTtsEnabled = true, - ReplyTtsVoiceId = " voice-1 " + FullReplyDocEnabled = true, + FinalReplyDocEnabled = true, + LegacyReplyTtsVoiceId = " voice-1 " }); var stored = await service.GetByUsernameAsync("alice"); @@ -32,12 +110,15 @@ public async Task SaveAsync_PersistsReplyTtsFields() Assert.NotNull(stored); Assert.Equal(1, repository.InsertCallCount); Assert.Equal(0, repository.UpdateCallCount); - Assert.True(stored!.ReplyTtsEnabled); - Assert.Equal("voice-1", stored.ReplyTtsVoiceId); + Assert.True(stored!.FullReplyDocEnabled); + Assert.True(stored.FinalReplyDocEnabled); + Assert.True(stored.LegacyReplyTtsEnabled); + Assert.Equal(ReplyTtsModes.FullReply, stored.LegacyReplyTtsMode); + Assert.Null(stored.LegacyReplyTtsVoiceId); } [Fact] - public async Task SaveAsync_OverwritesExistingReplyTtsValues() + public async Task SaveAsync_OverwritesExistingReplyDocumentValues() { var repository = new InMemoryUserFeishuBotConfigRepository(); repository.Store(new UserFeishuBotConfigEntity @@ -46,8 +127,11 @@ public async Task SaveAsync_OverwritesExistingReplyTtsValues() IsEnabled = true, AppId = "cli_alice", AppSecret = "secret", - ReplyTtsEnabled = true, - ReplyTtsVoiceId = "old-voice" + FullReplyDocEnabled = true, + FinalReplyDocEnabled = true, + LegacyReplyTtsEnabled = true, + LegacyReplyTtsMode = ReplyTtsModes.FullReply, + LegacyReplyTtsVoiceId = "old-voice" }); var service = CreateService(repository); @@ -58,8 +142,9 @@ public async Task SaveAsync_OverwritesExistingReplyTtsValues() IsEnabled = true, AppId = "cli_alice", AppSecret = "secret", - ReplyTtsEnabled = false, - ReplyTtsVoiceId = "new-voice" + FullReplyDocEnabled = false, + FinalReplyDocEnabled = true, + LegacyReplyTtsVoiceId = "new-voice" }); var stored = await service.GetByUsernameAsync("alice"); @@ -68,12 +153,15 @@ public async Task SaveAsync_OverwritesExistingReplyTtsValues() Assert.NotNull(stored); Assert.Equal(0, repository.InsertCallCount); Assert.Equal(1, repository.UpdateCallCount); - Assert.False(stored!.ReplyTtsEnabled); - Assert.Equal("new-voice", stored.ReplyTtsVoiceId); + Assert.False(stored!.FullReplyDocEnabled); + Assert.True(stored.FinalReplyDocEnabled); + Assert.True(stored.LegacyReplyTtsEnabled); + Assert.Equal(ReplyTtsModes.FinalOnly, stored.LegacyReplyTtsMode); + Assert.Null(stored.LegacyReplyTtsVoiceId); } [Fact] - public async Task SaveAsync_UpdateWithBlankReplyTtsVoiceId_ClearsPreviousVoice() + public async Task SaveAsync_UpdateWithBlankLegacyReplyTtsVoiceId_ClearsPreviousVoice() { var repository = new InMemoryUserFeishuBotConfigRepository(); repository.Store(new UserFeishuBotConfigEntity @@ -82,8 +170,10 @@ public async Task SaveAsync_UpdateWithBlankReplyTtsVoiceId_ClearsPreviousVoice() IsEnabled = true, AppId = "cli_alice", AppSecret = "secret", - ReplyTtsEnabled = true, - ReplyTtsVoiceId = "old-voice" + FullReplyDocEnabled = true, + LegacyReplyTtsEnabled = true, + LegacyReplyTtsMode = ReplyTtsModes.FullReply, + LegacyReplyTtsVoiceId = "old-voice" }); var service = CreateService(repository); @@ -94,8 +184,8 @@ public async Task SaveAsync_UpdateWithBlankReplyTtsVoiceId_ClearsPreviousVoice() IsEnabled = true, AppId = "cli_alice", AppSecret = "secret", - ReplyTtsEnabled = true, - ReplyTtsVoiceId = " " + FullReplyDocEnabled = true, + LegacyReplyTtsVoiceId = " " }); var stored = await service.GetByUsernameAsync("alice"); @@ -104,12 +194,13 @@ public async Task SaveAsync_UpdateWithBlankReplyTtsVoiceId_ClearsPreviousVoice() Assert.NotNull(stored); Assert.Equal(0, repository.InsertCallCount); Assert.Equal(1, repository.UpdateCallCount); - Assert.True(stored!.ReplyTtsEnabled); - Assert.Null(stored.ReplyTtsVoiceId); + Assert.True(stored!.FullReplyDocEnabled); + Assert.False(stored.FinalReplyDocEnabled); + Assert.Null(stored.LegacyReplyTtsVoiceId); } [Fact] - public async Task SaveAsync_NormalizesBlankReplyTtsVoiceIdToNull() + public async Task SaveAsync_NormalizesBlankLegacyReplyTtsVoiceIdToNull() { var repository = new InMemoryUserFeishuBotConfigRepository(); var service = CreateService(repository); @@ -120,16 +211,38 @@ public async Task SaveAsync_NormalizesBlankReplyTtsVoiceIdToNull() IsEnabled = true, AppId = "cli_alice", AppSecret = "secret", - ReplyTtsEnabled = true, - ReplyTtsVoiceId = " " + FullReplyDocEnabled = true, + LegacyReplyTtsVoiceId = " " }); var stored = await service.GetByUsernameAsync("alice"); Assert.True(result.Success); Assert.NotNull(stored); - Assert.True(stored!.ReplyTtsEnabled); - Assert.Null(stored.ReplyTtsVoiceId); + Assert.True(stored!.FullReplyDocEnabled); + Assert.Null(stored.LegacyReplyTtsVoiceId); + } + + [Fact] + public async Task SaveAsync_PersistsDocumentAdminOpenId() + { + var repository = new InMemoryUserFeishuBotConfigRepository(); + var service = CreateService(repository); + var config = new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret" + }; + SetStringProperty(config, "DocumentAdminOpenId", " ou_admin_alice "); + + var result = await service.SaveAsync(config); + + var stored = await service.GetByUsernameAsync("alice"); + Assert.True(result.Success); + Assert.NotNull(stored); + Assert.Equal("ou_admin_alice", GetStringProperty(stored!, "DocumentAdminOpenId")); } private static UserFeishuBotConfigService CreateService(InMemoryUserFeishuBotConfigRepository repository) @@ -137,6 +250,19 @@ private static UserFeishuBotConfigService CreateService(InMemoryUserFeishuBotCon return new UserFeishuBotConfigService(repository, Options.Create(new FeishuOptions())); } + private static string? GetStringProperty(object target, string propertyName) + { + return target + .GetType() + .GetProperty(propertyName)? + .GetValue(target) as string; + } + + private static void SetStringProperty(object target, string propertyName, string value) + { + target.GetType().GetProperty(propertyName)?.SetValue(target, value); + } + private sealed class InMemoryUserFeishuBotConfigRepository : IUserFeishuBotConfigRepository { private readonly Dictionary _configs = new(StringComparer.OrdinalIgnoreCase); @@ -233,7 +359,7 @@ public Task UpdateAsync(UserFeishuBotConfigEntity obj) private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity entity) { - return new UserFeishuBotConfigEntity + var clone = new UserFeishuBotConfigEntity { Id = entity.Id, Username = entity.Username, @@ -247,12 +373,19 @@ private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity entity) ThinkingMessage = entity.ThinkingMessage, HttpTimeoutSeconds = entity.HttpTimeoutSeconds, StreamingThrottleMs = entity.StreamingThrottleMs, - ReplyTtsEnabled = entity.ReplyTtsEnabled, - ReplyTtsVoiceId = entity.ReplyTtsVoiceId, + FullReplyDocEnabled = entity.FullReplyDocEnabled, + FinalReplyDocEnabled = entity.FinalReplyDocEnabled, + LegacyReplyTtsEnabled = entity.LegacyReplyTtsEnabled, + LegacyReplyTtsMode = entity.LegacyReplyTtsMode, + LegacyReplyTtsVoiceId = entity.LegacyReplyTtsVoiceId, LastStartedAt = entity.LastStartedAt, CreatedAt = entity.CreatedAt, UpdatedAt = entity.UpdatedAt }; + + var documentAdminProperty = typeof(UserFeishuBotConfigEntity).GetProperty("DocumentAdminOpenId"); + documentAdminProperty?.SetValue(clone, documentAdminProperty.GetValue(entity)); + return clone; } } } diff --git a/WebCodeCli.Domain.Tests/UserFeishuBotRuntimeServiceTests.cs b/WebCodeCli.Domain.Tests/UserFeishuBotRuntimeServiceTests.cs index 8774f5f..5373f0b 100644 --- a/WebCodeCli.Domain.Tests/UserFeishuBotRuntimeServiceTests.cs +++ b/WebCodeCli.Domain.Tests/UserFeishuBotRuntimeServiceTests.cs @@ -94,6 +94,41 @@ public async Task StopAsync_ClearsRememberedState_ButHostStopPreservesIt() Assert.True(afterHostStop!.AutoStartEnabled); } + [Fact] + public async Task RecoverAsync_RestartsRememberedBotsWithoutClearingAutoStart() + { + var configService = new InMemoryUserFeishuBotConfigService(); + configService.Store(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret" + }); + + var runtimeService = CreateService(configService); + var initialHostedService = new TestHostedService(); + var recoveredHostedService = new TestHostedService(); + runtimeService.EnqueueHostedService(initialHostedService); + runtimeService.EnqueueHostedService(recoveredHostedService); + + await runtimeService.StartAsync("alice"); + await runtimeService.RecoverAsync(); + + var status = await runtimeService.GetStatusAsync("alice"); + var stored = await configService.GetByUsernameAsync("alice"); + + Assert.Equal(2, runtimeService.CreateRuntimeEntryCallCount); + Assert.Equal(1, initialHostedService.StartCallCount); + Assert.Equal(1, initialHostedService.StopCallCount); + Assert.Equal(1, recoveredHostedService.StartCallCount); + Assert.Equal(0, recoveredHostedService.StopCallCount); + Assert.Equal(UserFeishuBotRuntimeState.Connected, status.State); + Assert.True(status.ShouldAutoStart); + Assert.NotNull(stored); + Assert.True(stored!.AutoStartEnabled); + } + private static TestableUserFeishuBotRuntimeService CreateService(InMemoryUserFeishuBotConfigService configService) { var scopeFactory = new TestScopeFactory(configService); @@ -273,6 +308,21 @@ private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity entity) private sealed class TestHostedService : BackgroundService { + public int StartCallCount { get; private set; } + public int StopCallCount { get; private set; } + + public override Task StartAsync(CancellationToken cancellationToken) + { + StartCallCount++; + return base.StartAsync(cancellationToken); + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + StopCallCount++; + return base.StopAsync(cancellationToken); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(Timeout.Infinite, stoppingToken); diff --git a/WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs b/WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs index 9b66034..066c949 100644 --- a/WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs +++ b/WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs @@ -72,11 +72,9 @@ public static IServiceCollection AddFeishuChannel( IConfiguration configuration) { var feishuSection = configuration.GetSection("Feishu"); - var feishuReplyTtsSection = configuration.GetSection("FeishuReplyTts"); // 绑定配置选项 services.Configure(feishuSection); - services.Configure(feishuReplyTtsSection); // 注册 HttpClient 工厂(用于 CardKit API 调用) services.AddHttpClient("FeishuClient") @@ -110,11 +108,10 @@ public static IServiceCollection AddFeishuChannel( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); - services.AddHostedService(); services.AddHostedService(sp => (UserFeishuBotRuntimeService)sp.GetRequiredService()); + services.AddHostedService(); return services; } diff --git a/WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs b/WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs deleted file mode 100644 index 5ccedf2..0000000 --- a/WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace WebCodeCli.Domain.Common.Options; - -public sealed class FeishuReplyTtsOptions -{ - public string? TtsStorageRoot { get; set; } - - public string TtsServiceBaseUrl { get; set; } = "http://127.0.0.1:5058"; - - public int TtsServiceTimeoutSeconds { get; set; } = 180; - - public string TtsPreferredDevice { get; set; } = "cpu"; - - public string? TtsDefaultVoiceId { get; set; } - - public int TtsChunkMaxChars { get; set; } = 160; - - public string? FfmpegExecutablePath { get; set; } - - public string? TtsServiceStartScriptPath { get; set; } - - public string? TtsServicePythonPath { get; set; } - - public int TtsServiceStartupTimeoutSeconds { get; set; } = 30; -} diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuCloudDocumentInfo.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuCloudDocumentInfo.cs new file mode 100644 index 0000000..1aef280 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuCloudDocumentInfo.cs @@ -0,0 +1,10 @@ +namespace WebCodeCli.Domain.Domain.Model.Channels; + +public sealed class FeishuCloudDocumentInfo +{ + public string DocumentId { get; set; } = string.Empty; + + public string RootBlockId { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; +} diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyDocumentRequest.cs similarity index 58% rename from WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs rename to WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyDocumentRequest.cs index a50f56a..015bd28 100644 --- a/WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyDocumentRequest.cs @@ -1,13 +1,19 @@ namespace WebCodeCli.Domain.Domain.Model.Channels; -public sealed class FeishuCompletedReplyTtsRequest +public sealed class FeishuCompletedReplyDocumentRequest { public string ChatId { get; set; } = string.Empty; public string? SessionId { get; set; } + public string? CliThreadId { get; set; } + public string Output { get; set; } = string.Empty; + public string? FinalAnswerOutput { get; set; } + + public string? OriginalUserQuestion { get; set; } + public string? Username { get; set; } public string? AppId { get; set; } diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs index c3687f1..9b04c37 100644 --- a/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs @@ -19,14 +19,24 @@ public class FeishuHelpCardAction public const string ClearGoalAction = "clear_goal"; public const string ResumeGoalAction = "resume_goal"; public const string TemporarilyExitGoalRuntimeAction = "temporarily_exit_goal_runtime"; + public const string TemporarilyExitAndCompleteWorktreeAction = "temporarily_exit_and_complete_worktree"; public const string ContinueSuperpowersAction = "continue_superpowers"; public const string StopStreamingExecutionAction = "stop_streaming_execution"; public const string ExecuteSuperpowersPlanAction = "execute_superpowers_plan"; public const string ExecuteSuperpowersSubagentPlanAction = "execute_superpowers_subagent_plan"; + public const string ExecuteSuperpowersCompleteWorktreeAction = "execute_superpowers_complete_worktree"; + public const string ExecuteSuperpowersGoalPlanAction = "execute_superpowers_goal_plan"; public const string ConfirmBoundSuperpowersAction = "confirm_bound_superpowers_action"; public const string ConfirmCurrentSuperpowersAction = "confirm_current_superpowers_action"; public const string RetrySuperpowersCapabilityDetectionAction = "retry_superpowers_capability_detection"; - public const string ToggleReplyTtsAction = "toggle_reply_tts"; + public const string ToggleFullReplyDocAction = "toggle_full_reply_doc"; + public const string ToggleFinalReplyDocAction = "toggle_final_reply_doc"; + public const string ToggleAudioFullReplyDocAction = "toggle_audio_full_reply_doc"; + public const string ToggleAudioFinalReplyDocAction = "toggle_audio_final_reply_doc"; + public const string ToggleReferencedMarkdownDocImportAction = "toggle_referenced_markdown_doc_import"; + public const string SetDocumentAdminOpenIdAction = "set_document_admin_openid"; + public const string ToggleReplyTtsAction = ToggleFullReplyDocAction; + public const string ToggleFinalOnlyReplyTtsAction = ToggleFinalReplyDocAction; ///

/// 动作类型 @@ -137,6 +147,12 @@ public class FeishuHelpCardAction [JsonPropertyName("show_all_sessions")] public bool? ShowAllSessions { get; set; } + /// + /// 会话管理分页页码(从 0 开始) + /// + [JsonPropertyName("session_page")] + public int? SessionPage { get; set; } + /// /// 表单中的模型值(会话启动设置时使用) /// diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs deleted file mode 100644 index 52ef53f..0000000 --- a/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace WebCodeCli.Domain.Domain.Model.Channels; - -public sealed class FeishuReplyTtsHealthStatus -{ - public bool IsAvailable { get; set; } - - public string? StorageRoot { get; set; } - - public string Message { get; set; } = string.Empty; - - public string? ModelsRoot { get; set; } - - public string? CacheRoot { get; set; } - - public string? TempRoot { get; set; } - - public string? LogsRoot { get; set; } - - public string? VenvRoot { get; set; } - - public string? ServiceStatus { get; set; } - - public string? Device { get; set; } - - public string? DefaultVoiceId { get; set; } -} diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs deleted file mode 100644 index 9524259..0000000 --- a/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace WebCodeCli.Domain.Domain.Model.Channels; - -public sealed class FeishuReplyTtsVoiceOption -{ - public string VoiceId { get; set; } = string.Empty; - - public string DisplayName { get; set; } = string.Empty; - - public string? Language { get; set; } - - public string? Gender { get; set; } -} diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingHandle.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingHandle.cs index c811741..ddafc1a 100644 --- a/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingHandle.cs +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingHandle.cs @@ -127,11 +127,11 @@ public async Task UpdateAsync(string content) /// /// 完成更新 /// - public async Task FinishAsync(string finalContent) + public async Task FinishAsync(string finalContent) { if (_disposed) { - return; + return true; } await _operationLock.WaitAsync(); @@ -139,13 +139,25 @@ public async Task FinishAsync(string finalContent) { if (_disposed) { - return; + return true; + } + + if (_isFinished) + { + return true; } - _isFinished = true; var sequence = Interlocked.Increment(ref _sequence); - await _finishAsync(finalContent, sequence); + var finished = await _finishAsync(finalContent, sequence); + if (!finished) + { + StopCardUpdates(); + return false; + } + + _isFinished = true; _disposed = true; + return true; } finally { diff --git a/WebCodeCli.Domain/Domain/Model/Channels/ReplyTtsModes.cs b/WebCodeCli.Domain/Domain/Model/Channels/ReplyTtsModes.cs new file mode 100644 index 0000000..8ac990c --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/Channels/ReplyTtsModes.cs @@ -0,0 +1,50 @@ +namespace WebCodeCli.Domain.Domain.Model.Channels; + +public static class ReplyTtsModes +{ + public const string Off = "Off"; + public const string FullReply = "FullReply"; + public const string FinalOnly = "FinalOnly"; + + public static string Resolve(string? mode, bool legacyEnabled) + { + var normalizedMode = Normalize(mode); + if (normalizedMode != null) + { + return normalizedMode; + } + + return legacyEnabled ? FullReply : Off; + } + + public static string? Normalize(string? mode) + { + if (string.IsNullOrWhiteSpace(mode)) + { + return null; + } + + var trimmed = mode.Trim(); + if (string.Equals(trimmed, Off, StringComparison.OrdinalIgnoreCase)) + { + return Off; + } + + if (string.Equals(trimmed, FullReply, StringComparison.OrdinalIgnoreCase)) + { + return FullReply; + } + + if (string.Equals(trimmed, FinalOnly, StringComparison.OrdinalIgnoreCase)) + { + return FinalOnly; + } + + return Off; + } + + public static bool IsEnabled(string? mode) + { + return !string.Equals(mode, Off, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/WebCodeCli.Domain/Domain/Model/CliExecutionResult.cs b/WebCodeCli.Domain/Domain/Model/CliExecutionResult.cs index 166591f..e48fd2f 100644 --- a/WebCodeCli.Domain/Domain/Model/CliExecutionResult.cs +++ b/WebCodeCli.Domain/Domain/Model/CliExecutionResult.cs @@ -56,6 +56,8 @@ public class StreamOutputChunk /// public bool IsCompleted { get; set; } + public bool IsTurnBoundary { get; set; } + /// /// 错误信息 /// diff --git a/WebCodeCli.Domain/Domain/Model/GoalQuickActionDefaults.cs b/WebCodeCli.Domain/Domain/Model/GoalQuickActionDefaults.cs index 9c00be2..ec153a9 100644 --- a/WebCodeCli.Domain/Domain/Model/GoalQuickActionDefaults.cs +++ b/WebCodeCli.Domain/Domain/Model/GoalQuickActionDefaults.cs @@ -4,7 +4,7 @@ public static class GoalQuickActionDefaults { public const string QuickInputFieldName = "goal_quick_input"; public const string QuickInputPlaceholder = "输入内容后回车,未写前缀时会自动补成 /goal "; - public const string InstructionText = "使用 /goal 命令,会自动补前缀:\"/goal \"。用于设置当前工作目标,让 Codex 围绕目标持续推进。"; + public const string InstructionText = "使用 /goal 不间断执行,会自动补前缀:\"/goal \"。用于在当前 app-server 持续会话中设置或更新工作目标,让 Codex 围绕目标持续推进;可配合 /goal pause、/goal clear、/goal resume 管理执行状态。"; public const string QuickSubmitButtonText = "提交"; public const string QuickGoalPrefix = "/goal "; public const string StatusButtonText = "/goal"; diff --git a/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs b/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs index f70012e..26cbf3a 100644 --- a/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs +++ b/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs @@ -10,10 +10,15 @@ public static class SuperpowersQuickActionDefaults public const string ContinueButtonText = "继续"; public const string StopButtonText = "停止"; public const string ExecutePlanButtonText = "执行 plan"; - public const string ExecuteSubagentPlanButtonText = "子代理执行 plan"; + public const string ExecuteSubagentPlanButtonText = "SubAgent执行"; + public const string ExecuteGoalPlanButtonText = "Goal执行"; + public const string CompleteWorktreeButtonText = "完成Worktree"; + public const string ExecuteGoalPlanPromptInput = "使用Subagent-Driven完成plan,如有询问我的,先按你推荐的继续进行,需将plan文档内的[ ]check list都检查收口后,变成[x]后才算goal完成"; + public const string CompleteWorktreePrompt = "使用Worktree技能完成Worktree,合并到当前worktree的父分支"; + public const string WorkflowSectionTitle = "Superpowers 工作流/Goal不间断执行"; - public const string PromptLanguagePolicy = "Reply to the user in Chinese. Write documentation and code comments in English only. Keep exception and error messages in Chinese."; - public const string ContinuePrompt = "Resume the current Codex thread and continue the approved superpowers workflow. Do not send any extra resume command inside the conversation. " + PromptLanguagePolicy; + public const string PromptLanguagePolicy = "Reply to the user in Chinese. Write documentation in English only. 代码注释需要使用中英文双语。 Keep exception and error messages in Chinese."; + public const string ContinuePrompt = "可以,认可。Resume the current Codex thread and continue the approved superpowers workflow. Do not send any extra resume command inside the conversation. " + PromptLanguagePolicy; public const string ExecutePlanPrompt = "Use the superpowers executing-plans skill to execute the approved plan. " + PromptLanguagePolicy; public const string ExecuteSubagentPlanPrompt = "Use the superpowers executing-plans skill to execute the approved plan, and use the superpowers subagent-driven-development skill when parallel implementation helps. " + PromptLanguagePolicy; public const string QuickSkillPrefix = "$using-superpowers ,使用superpowers技能,"; diff --git a/WebCodeCli.Domain/Domain/Service/Adapters/CliOutputEvent.cs b/WebCodeCli.Domain/Domain/Service/Adapters/CliOutputEvent.cs index 414cc68..1ec03aa 100644 --- a/WebCodeCli.Domain/Domain/Service/Adapters/CliOutputEvent.cs +++ b/WebCodeCli.Domain/Domain/Service/Adapters/CliOutputEvent.cs @@ -26,6 +26,8 @@ public class CliOutputEvent /// public string? ItemType { get; set; } + public string? AssistantPhase { get; set; } + /// /// 是否为错误事件 /// diff --git a/WebCodeCli.Domain/Domain/Service/Adapters/CodexAdapter.cs b/WebCodeCli.Domain/Domain/Service/Adapters/CodexAdapter.cs index 3513653..6965006 100644 --- a/WebCodeCli.Domain/Domain/Service/Adapters/CodexAdapter.cs +++ b/WebCodeCli.Domain/Domain/Service/Adapters/CodexAdapter.cs @@ -17,8 +17,8 @@ public class CodexAdapter : ICliToolAdapter /// - {session}: 会话恢复参数(兼容自定义模板,格式为 "resume session_id") /// - {cliThreadId}: 仅线程 ID,默认 resume 模板使用 /// - public const string DefaultArgumentTemplate = "exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {session} \"{prompt}\""; - public const string DefaultResumeArgumentTemplate = "exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {cliThreadId} \"{prompt}\""; + public const string DefaultArgumentTemplate = "exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {session} -- \"{prompt}\""; + public const string DefaultResumeArgumentTemplate = "exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {cliThreadId} -- \"{prompt}\""; public const string DefaultLowInterruptionArgumentTemplate = "exec resume --skip-git-repo-check --json --full-auto {cliThreadId}"; public string[] SupportedToolIds => new[] { "codex" }; @@ -443,6 +443,13 @@ private void ParseItemStarted(JsonElement root, CliOutputEvent outputEvent) outputEvent.ItemType = itemTypeElement.GetString(); } + if (string.Equals(outputEvent.ItemType, "agent_message", StringComparison.Ordinal) + && itemElement.TryGetProperty("phase", out var phaseElement) + && phaseElement.ValueKind == JsonValueKind.String) + { + outputEvent.AssistantPhase = phaseElement.GetString(); + } + if (itemElement.TryGetProperty("thread_id", out var itemThread) && itemThread.ValueKind == JsonValueKind.String) { var threadId = itemThread.GetString(); @@ -503,6 +510,13 @@ private void ParseItemUpdated(JsonElement root, CliOutputEvent outputEvent) outputEvent.ItemType = itemTypeElement.GetString(); } + if (string.Equals(outputEvent.ItemType, "agent_message", StringComparison.Ordinal) + && itemElement.TryGetProperty("phase", out var phaseElement) + && phaseElement.ValueKind == JsonValueKind.String) + { + outputEvent.AssistantPhase = phaseElement.GetString(); + } + if (itemElement.TryGetProperty("thread_id", out var itemThread) && itemThread.ValueKind == JsonValueKind.String) { var threadId = itemThread.GetString(); @@ -539,6 +553,13 @@ private void ParseItemCompleted(JsonElement root, CliOutputEvent outputEvent) outputEvent.ItemType = itemTypeElement.GetString(); } + if (string.Equals(outputEvent.ItemType, "agent_message", StringComparison.Ordinal) + && itemElement.TryGetProperty("phase", out var phaseElement) + && phaseElement.ValueKind == JsonValueKind.String) + { + outputEvent.AssistantPhase = phaseElement.GetString(); + } + if (itemElement.TryGetProperty("thread_id", out var itemThread) && itemThread.ValueKind == JsonValueKind.String) { var threadId = itemThread.GetString(); @@ -797,6 +818,16 @@ private static string InjectAttachmentArguments(string template, string attachme return template; } + if (template.Contains("-- \"{prompt}\"", StringComparison.Ordinal)) + { + return template.Replace("-- \"{prompt}\"", $"{attachmentArguments} -- \"{{prompt}}\"", StringComparison.Ordinal); + } + + if (template.Contains("-- {prompt}", StringComparison.Ordinal)) + { + return template.Replace("-- {prompt}", $"{attachmentArguments} -- {{prompt}}", StringComparison.Ordinal); + } + if (template.Contains("\"{prompt}\"", StringComparison.Ordinal)) { return template.Replace("\"{prompt}\"", $"{attachmentArguments} \"{{prompt}}\"", StringComparison.Ordinal); diff --git a/WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs b/WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs deleted file mode 100644 index 10b8f5d..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using WebCodeCli.Domain.Common.Extensions; -using WebCodeCli.Domain.Common.Options; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(IAudioTranscodeService), ServiceLifetime.Scoped)] -public sealed class AudioTranscodeService : IAudioTranscodeService -{ - private readonly FeishuReplyTtsOptions _options; - private readonly ReplyTtsStorageRootResolver _storageRootResolver; - private readonly IExternalProcessRunner _externalProcessRunner; - - public AudioTranscodeService( - IOptions options, - ReplyTtsStorageRootResolver storageRootResolver, - IExternalProcessRunner externalProcessRunner) - { - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _storageRootResolver = storageRootResolver ?? throw new ArgumentNullException(nameof(storageRootResolver)); - _externalProcessRunner = externalProcessRunner ?? throw new ArgumentNullException(nameof(externalProcessRunner)); - } - - public async Task TranscodeChunkAsync( - string jobId, - string inputWavPath, - int chunkIndex, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(jobId)) - { - throw new ArgumentException("Job ID is required.", nameof(jobId)); - } - - if (string.IsNullOrWhiteSpace(inputWavPath)) - { - throw new ArgumentException("Input WAV path is required.", nameof(inputWavPath)); - } - - if (chunkIndex <= 0) - { - throw new ArgumentOutOfRangeException(nameof(chunkIndex), "Chunk index must be greater than zero."); - } - - var health = _storageRootResolver.Resolve(); - if (!health.IsAvailable || string.IsNullOrWhiteSpace(health.TempRoot)) - { - throw new InvalidOperationException( - string.IsNullOrWhiteSpace(health.Message) - ? "Feishu reply TTS temp storage is unavailable." - : health.Message); - } - - var ffmpegResolution = ReplyTtsFfmpegPathResolver.Resolve(_options, health); - if (!ffmpegResolution.IsAvailable || string.IsNullOrWhiteSpace(ffmpegResolution.ExecutablePath)) - { - throw new InvalidOperationException(ffmpegResolution.Message); - } - - var jobDirectory = Path.Combine(health.TempRoot, SanitizePathSegment(jobId)); - Directory.CreateDirectory(jobDirectory); - - var outputPath = Path.Combine(jobDirectory, $"chunk-{chunkIndex:000}.opus"); - var arguments = string.Join( - ' ', - "-y", - "-i", - Quote(inputWavPath), - "-acodec", - "libopus", - "-ac", - "1", - "-ar", - "16000", - Quote(outputPath)); - - var result = await _externalProcessRunner.RunAsync( - ffmpegResolution.ExecutablePath, - arguments, - jobDirectory, - cancellationToken); - - if (result.ExitCode != 0) - { - throw new InvalidOperationException( - $"ffmpeg transcode failed with exit code {result.ExitCode}: {result.StandardError}".Trim()); - } - - return outputPath; - } - - private static string Quote(string path) - { - return $"\"{path}\""; - } - - private static string SanitizePathSegment(string value) - { - var sanitized = new string(value - .Trim() - .Select(static character => char.IsLetterOrDigit(character) || character is '-' or '_' - ? character - : '_') - .ToArray()); - return string.IsNullOrWhiteSpace(sanitized) ? "reply-tts-job" : sanitized; - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs deleted file mode 100644 index 6a77bf8..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using WebCodeCli.Domain.Common.Extensions; -using WebCodeCli.Domain.Common.Options; -using WebCodeCli.Domain.Domain.Service; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(IFeishuAudioMessageService), ServiceLifetime.Scoped)] -public sealed class FeishuAudioMessageService : IFeishuAudioMessageService -{ - private readonly IFeishuCardKitClient _feishuCardKitClient; - private readonly IUserFeishuBotConfigService _userFeishuBotConfigService; - - public FeishuAudioMessageService( - IFeishuCardKitClient feishuCardKitClient, - IUserFeishuBotConfigService userFeishuBotConfigService) - { - _feishuCardKitClient = feishuCardKitClient ?? throw new ArgumentNullException(nameof(feishuCardKitClient)); - _userFeishuBotConfigService = userFeishuBotConfigService ?? throw new ArgumentNullException(nameof(userFeishuBotConfigService)); - } - - public async Task SendAudioMessageAsync( - string chatId, - string filePath, - int durationMs, - string? username = null, - string? appId = null, - CancellationToken cancellationToken = default) - { - var effectiveOptions = await ResolveEffectiveOptionsAsync(username, appId); - var fileKey = await _feishuCardKitClient.UploadAudioFileAsync( - filePath, - durationMs, - cancellationToken, - effectiveOptions); - - return await _feishuCardKitClient.SendAudioMessageAsync( - chatId, - fileKey, - durationMs, - cancellationToken, - effectiveOptions); - } - - private async Task ResolveEffectiveOptionsAsync(string? username, string? appId) - { - if (!string.IsNullOrWhiteSpace(appId)) - { - var appOptions = await _userFeishuBotConfigService.GetEffectiveOptionsByAppIdAsync(appId); - if (appOptions != null) - { - return appOptions; - } - } - - if (!string.IsNullOrWhiteSpace(username)) - { - return await _userFeishuBotConfigService.GetEffectiveOptionsAsync(username); - } - - return _userFeishuBotConfigService.GetSharedDefaults(); - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs index 51be7cb..0d68b63 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs @@ -10,6 +10,7 @@ using WebCodeCli.Domain.Domain.Service; using WebCodeCli.Domain.Domain.Service.Adapters; using WebCodeCli.Domain.Repositories.Base.ChatSession; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; using System.Text.Json.Nodes; using System.Text; using System.Text.Json.Serialization; @@ -68,6 +69,7 @@ public class FeishuCardActionService private const int SessionFilePreviewLineLimit = 80; private const int SessionFilePreviewCharacterLimit = 4000; private const int SessionManagerDefaultVisibleCount = 3; + private const int SessionManagerPageSize = 3; private const int ProjectBranchPageSize = 12; private const int StreamingStatusPulseIntervalMs = 900; private static readonly TimeSpan StreamingStatusPulseQuietWindow = TimeSpan.FromSeconds(3); @@ -124,6 +126,7 @@ private bool TryRequestStopActiveStreamingExecution(string sessionId) private sealed class ActiveStreamingExecution : IDisposable { private int _stopRequested; + private FeishuStreamingHandle _handle; public ActiveStreamingExecution( string sessionId, @@ -131,18 +134,24 @@ public ActiveStreamingExecution( CancellationTokenSource executionCancellationTokenSource) { SessionId = sessionId; - Handle = handle; + _handle = handle; ExecutionCancellationTokenSource = executionCancellationTokenSource; } public string SessionId { get; } - public FeishuStreamingHandle Handle { get; } + public FeishuStreamingHandle Handle => Volatile.Read(ref _handle); public CancellationTokenSource ExecutionCancellationTokenSource { get; } public bool StopRequested => Volatile.Read(ref _stopRequested) == 1; + public void ReplaceHandle(FeishuStreamingHandle handle) + { + ArgumentNullException.ThrowIfNull(handle); + Volatile.Write(ref _handle, handle); + } + public void RequestStop() { Interlocked.Exchange(ref _stopRequested, 1); @@ -237,8 +246,18 @@ public async Task HandleCardActionAsync( return await HandleSelectCommandAsync(action.CommandId, chatId); case "back_to_list": return await HandleBackToListAsync(chatId); - case FeishuHelpCardAction.ToggleReplyTtsAction: - return await HandleToggleReplyTtsAsync(chatId, operatorUserId); + case FeishuHelpCardAction.ToggleFullReplyDocAction: + return await HandleToggleFullReplyDocAsync(chatId, operatorUserId); + case FeishuHelpCardAction.ToggleFinalReplyDocAction: + return await HandleToggleFinalReplyDocAsync(chatId, operatorUserId); + case FeishuHelpCardAction.ToggleAudioFullReplyDocAction: + return await HandleToggleAudioFullReplyDocAsync(chatId, operatorUserId); + case FeishuHelpCardAction.ToggleAudioFinalReplyDocAction: + return await HandleToggleAudioFinalReplyDocAsync(chatId, operatorUserId); + case FeishuHelpCardAction.ToggleReferencedMarkdownDocImportAction: + return await HandleToggleReferencedMarkdownDocImportAsync(chatId, operatorUserId); + case FeishuHelpCardAction.SetDocumentAdminOpenIdAction: + return await HandleSetDocumentAdminOpenIdAsync(chatId, operatorUserId, appId); case "execute_command": return await HandleExecuteCommandAsync(formValueElement, action.Command, chatId, operatorUserId, inputValues, appId); case FeishuHelpCardAction.SubmitAttachmentPromptAction: @@ -248,6 +267,7 @@ public async Task HandleCardActionAsync( case FeishuHelpCardAction.StopStreamingExecutionAction: case FeishuHelpCardAction.ExecuteSuperpowersPlanAction: case FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction: + case FeishuHelpCardAction.ExecuteSuperpowersCompleteWorktreeAction: case FeishuHelpCardAction.ConfirmBoundSuperpowersAction: case FeishuHelpCardAction.ConfirmCurrentSuperpowersAction: return action.Action == FeishuHelpCardAction.StopStreamingExecutionAction @@ -260,41 +280,44 @@ public async Task HandleCardActionAsync( case FeishuHelpCardAction.PauseGoalAction: case FeishuHelpCardAction.ClearGoalAction: case FeishuHelpCardAction.ResumeGoalAction: + case FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction: return await HandleGoalQuickActionAsync(action, formValueElement, chatId, operatorUserId, appId, inputValues); case FeishuHelpCardAction.TemporarilyExitGoalRuntimeAction: - return await HandleTemporarilyExitGoalRuntimeAsync(action.SessionId, action.ChatKey ?? chatId, operatorUserId, action.ShowAllSessions); + return await HandleTemporarilyExitGoalRuntimeAsync(action.SessionId, action.ChatKey ?? chatId, operatorUserId, action.ShowAllSessions, action.SessionPage); + case FeishuHelpCardAction.TemporarilyExitAndCompleteWorktreeAction: + return await HandleTemporarilyExitAndCompleteWorktreeAsync(action, chatId, operatorUserId, appId); case FeishuHelpCardAction.RetrySuperpowersCapabilityDetectionAction: return await HandleRetrySuperpowersCapabilityDetectionAsync(action, chatId); case LowInterruptionContinueHelper.ActionName: return await HandleLowInterruptionContinueAsync(action.SessionId, action.ChatKey ?? chatId, action.ToolId, formValueElement, operatorUserId, appId); case "switch_session": - return await HandleSwitchSessionAsync(action.SessionId, action.ChatKey, operatorUserId, appId, action.ShowAllSessions == true); + return await HandleSwitchSessionAsync(action.SessionId, action.ChatKey, operatorUserId, appId, action.ShowAllSessions == true, action.SessionPage); case "sync_session_provider": - return await HandleSyncSessionProviderAsync(action.SessionId, action.ChatKey, operatorUserId, appId, action.ShowAllSessions == true); + return await HandleSyncSessionProviderAsync(action.SessionId, action.ChatKey, operatorUserId, appId, action.ShowAllSessions == true, action.SessionPage); case FeishuHelpCardAction.ConfirmSyncSessionProviderAction: return await HandleConfirmSyncSessionProviderAsync(action.SessionId, action.ChatKey, operatorUserId, appId, action.ShowAllSessions == true); case "show_rename_session_form": - return await HandleShowRenameSessionFormAsync(action.SessionId, action.ChatKey, operatorUserId, action.ShowAllSessions == true); + return await HandleShowRenameSessionFormAsync(action.SessionId, action.ChatKey, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "rename_session": - return await HandleRenameSessionAsync(action.SessionId, action.ChatKey, formValueElement, operatorUserId, action.ShowAllSessions == true); + return await HandleRenameSessionAsync(action.SessionId, action.ChatKey, formValueElement, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "close_session": - return await HandleCloseSessionAsync(action.SessionId, action.ChatKey, operatorUserId, action.ShowAllSessions == true); + return await HandleCloseSessionAsync(action.SessionId, action.ChatKey, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "show_session_launch_settings_form": - return await HandleShowSessionLaunchSettingsFormAsync(action.SessionId, action.ChatKey, operatorUserId, action.ShowAllSessions == true); + return await HandleShowSessionLaunchSettingsFormAsync(action.SessionId, action.ChatKey, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "save_session_launch_settings": - return await HandleSaveSessionLaunchSettingsAsync(action.SessionId, action.ChatKey, formValueElement, operatorUserId, action.ShowAllSessions == true); + return await HandleSaveSessionLaunchSettingsAsync(action.SessionId, action.ChatKey, formValueElement, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "clear_session_launch_settings": - return await HandleClearSessionLaunchSettingsAsync(action.SessionId, action.ChatKey, operatorUserId, action.ShowAllSessions == true); + return await HandleClearSessionLaunchSettingsAsync(action.SessionId, action.ChatKey, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "switch_streaming_card_model": return await HandleSwitchStreamingCardModelAsync(action.SessionId, action.ChatKey, action.Model, operatorUserId); case "switch_streaming_card_reasoning_effort": return await HandleSwitchStreamingCardReasoningEffortAsync(action.SessionId, action.ChatKey, action.ReasoningEffort, operatorUserId); case "show_create_session_form": - return await HandleShowCreateSessionFormAsync(action.ChatKey, chatId, operatorUserId, action.ToolId); + return await HandleShowCreateSessionFormAsync(action.ChatKey, chatId, operatorUserId, action.ToolId, action.ShowAllSessions == true, action.SessionPage); case "create_session": return await HandleCreateSessionAsync(action.ChatKey, chatId, formValueElement, operatorUserId, action.CreateMode, action.WorkspacePath, action.ToolId, inputValues); case "browse_allowed_directory": - return await HandleBrowseAllowedDirectoryAsync(action.ChatKey, chatId, action.WorkspacePath, action.Page, operatorUserId, action.ToolId); + return await HandleBrowseAllowedDirectoryAsync(action.ChatKey, chatId, action.WorkspacePath, action.Page, operatorUserId, action.ToolId, action.ShowAllSessions == true, action.SessionPage); case "copy_path_to_chat": return await HandleCopyPathToChatAsync(action.ChatKey ?? chatId, action.CopyPath ?? action.WorkspacePath, operatorUserId); case "switch_tool": @@ -303,10 +326,10 @@ public async Task HandleCardActionAsync( return await HandleBindWebUserAsync(formValueElement, chatId, operatorUserId, appId); case "open_session_manager": return action.SendAsNewCard - ? await HandleOpenSessionManagerAsNewCardAsync(action.ChatKey ?? chatId, operatorUserId, appId, action.ShowAllSessions == true) - : await HandleOpenSessionManagerAsync(action.ChatKey ?? chatId, operatorUserId, action.ShowAllSessions == true); + ? await HandleOpenSessionManagerAsNewCardAsync(action.ChatKey ?? chatId, operatorUserId, appId, action.ShowAllSessions == true, action.SessionPage) + : await HandleOpenSessionManagerAsync(action.ChatKey ?? chatId, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "discover_external_cli_sessions": - return await HandleDiscoverExternalCliSessionsAsync(action.ChatKey ?? chatId, chatId, action.ToolId, action.Page, operatorUserId); + return await HandleDiscoverExternalCliSessionsAsync(action.ChatKey ?? chatId, chatId, action.ToolId, action.Page, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "import_external_cli_session": return await HandleImportExternalCliSessionAsync( action.ChatKey ?? chatId, @@ -316,37 +339,39 @@ public async Task HandleCardActionAsync( action.Title, action.WorkspacePath, operatorUserId, - appId); + appId, + action.ShowAllSessions == true, + action.SessionPage); case "open_project_manager": - return await HandleOpenProjectManagerAsync(action.ChatKey ?? chatId, operatorUserId); + return await HandleOpenProjectManagerAsync(action.ChatKey ?? chatId, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "show_create_project_form": - return await HandleShowCreateProjectFormAsync(action.ChatKey ?? chatId); + return await HandleShowCreateProjectFormAsync(action.ChatKey ?? chatId, action.ShowAllSessions == true, action.SessionPage); case "show_edit_project_form": - return await HandleShowEditProjectFormAsync(action.ChatKey ?? chatId, action.ProjectId, operatorUserId); + return await HandleShowEditProjectFormAsync(action.ChatKey ?? chatId, action.ProjectId, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "create_project": - return await HandleCreateProjectAsync(action.ChatKey ?? chatId, formValueElement, operatorUserId); + return await HandleCreateProjectAsync(action.ChatKey ?? chatId, formValueElement, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "update_project": - return await HandleUpdateProjectAsync(action.ChatKey ?? chatId, action.ProjectId, formValueElement, operatorUserId); + return await HandleUpdateProjectAsync(action.ChatKey ?? chatId, action.ProjectId, formValueElement, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "clone_project": return await HandleCloneProjectAsync(action.ChatKey ?? chatId, action.ProjectId, operatorUserId, appId); case "pull_project": return await HandlePullProjectAsync(action.ChatKey ?? chatId, action.ProjectId, operatorUserId, appId); case "show_project_branch_switcher": - return await HandleShowProjectBranchSwitcherAsync(action.ChatKey ?? chatId, action.ProjectId, action.Page, operatorUserId, appId); + return await HandleShowProjectBranchSwitcherAsync(action.ChatKey ?? chatId, action.ProjectId, action.Page, operatorUserId, appId, action.ShowAllSessions == true, action.SessionPage); case "switch_project_branch": - return await HandleSwitchProjectBranchAsync(action.ChatKey ?? chatId, action.ProjectId, action.Branch, action.Page, operatorUserId, appId); + return await HandleSwitchProjectBranchAsync(action.ChatKey ?? chatId, action.ProjectId, action.Branch, action.Page, operatorUserId, appId, action.ShowAllSessions == true, action.SessionPage); case "delete_project": return await HandleDeleteProjectAsync(action.ChatKey ?? chatId, action.ProjectId, operatorUserId, appId); case "fetch_project_branches": - return await HandleFetchProjectBranchesAsync(action.ChatKey ?? chatId, action.ProjectId, formValueElement, operatorUserId); + return await HandleFetchProjectBranchesAsync(action.ChatKey ?? chatId, action.ProjectId, formValueElement, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "create_session_from_project": return await HandleCreateSessionFromProjectAsync(action.ChatKey ?? chatId, action.ProjectId, operatorUserId); case "browse_current_session_directory": - return await HandleBrowseCurrentSessionDirectoryAsync(action.ChatKey, chatId, operatorUserId); + return await HandleBrowseCurrentSessionDirectoryAsync(action.ChatKey, chatId, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "browse_session_directory": - return await HandleBrowseSessionDirectoryAsync(action.SessionId, action.ChatKey, action.DirectoryPath, action.Page, operatorUserId); + return await HandleBrowseSessionDirectoryAsync(action.SessionId, action.ChatKey, action.DirectoryPath, action.Page, operatorUserId, action.ShowAllSessions == true, action.SessionPage); case "preview_session_file": - return await HandlePreviewSessionFileAsync(action.SessionId, action.ChatKey, action.FilePath, action.DirectoryPath, action.Page, operatorUserId); + return await HandlePreviewSessionFileAsync(action.SessionId, action.ChatKey, action.FilePath, action.DirectoryPath, action.Page, operatorUserId, action.ShowAllSessions == true, action.SessionPage); default: return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 未知动作", "error"); } @@ -428,7 +453,114 @@ private async Task HandleBackToListAsync(string? c return _cardBuilder.BuildCardActionResponseV2(card, "", "info"); } - private async Task HandleToggleReplyTtsAsync(string? chatId, string? operatorUserId) + private async Task HandleToggleFullReplyDocAsync(string? chatId, string? operatorUserId) + { + return await HandleToggleReplyDocumentAsync( + chatId, + operatorUserId, + toggleFullReplyDoc: true, + "飞书完整回复文档", + "飞书完整回复文档更新失败"); + } + + private async Task HandleToggleFinalReplyDocAsync(string? chatId, string? operatorUserId) + { + return await HandleToggleReplyDocumentAsync( + chatId, + operatorUserId, + toggleFullReplyDoc: false, + "飞书结论回复文档", + "飞书结论回复文档更新失败"); + } + + private async Task HandleToggleAudioFullReplyDocAsync(string? chatId, string? operatorUserId) + { + return await HandleToggleReplyDocumentAsync( + chatId, + operatorUserId, + toggleFullReplyDoc: false, + modeDisplayName: "飞书听完整文档", + defaultFailureMessage: "飞书听完整文档更新失败", + toggleAudioFullReplyDoc: true); + } + + private async Task HandleToggleAudioFinalReplyDocAsync(string? chatId, string? operatorUserId) + { + return await HandleToggleReplyDocumentAsync( + chatId, + operatorUserId, + toggleFullReplyDoc: false, + modeDisplayName: "飞书听结论文档", + defaultFailureMessage: "飞书听结论文档更新失败", + toggleAudioFinalReplyDoc: true); + } + + private async Task HandleToggleReferencedMarkdownDocImportAsync(string? chatId, string? operatorUserId) + { + return await HandleToggleReplyDocumentAsync( + chatId, + operatorUserId, + toggleFullReplyDoc: false, + modeDisplayName: "MD转在线文档", + defaultFailureMessage: "MD转在线文档更新失败", + toggleReferencedMarkdownDocImport: true); + } + + private async Task HandleSetDocumentAdminOpenIdAsync( + string? chatId, + string? operatorUserId, + string? appId) + { + if (string.IsNullOrWhiteSpace(operatorUserId)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 无法识别当前飞书用户,保存文档管理员失败", "error"); + } + + using var scope = _serviceProvider.CreateScope(); + var userFeishuBotConfigService = scope.ServiceProvider.GetRequiredService(); + + UserFeishuBotConfigEntity? config = null; + if (!string.IsNullOrWhiteSpace(appId)) + { + config = await userFeishuBotConfigService.GetByAppIdAsync(appId.Trim()); + } + + if (config == null && !string.IsNullOrWhiteSpace(chatId)) + { + var actualChatKey = NormalizeChatKey(chatId); + var username = ResolveFeishuUsername(actualChatKey, operatorUserId); + if (!string.IsNullOrWhiteSpace(username)) + { + config = await userFeishuBotConfigService.GetByUsernameAsync(username); + } + } + + if (config == null) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 未找到当前飞书机器人配置", "error"); + } + + config.DocumentAdminOpenId = operatorUserId.Trim(); + var saveResult = await userFeishuBotConfigService.SaveAsync(config); + if (!saveResult.Success) + { + return _cardBuilder.BuildCardActionToastOnlyResponse( + $"❌ {(string.IsNullOrWhiteSpace(saveResult.ErrorMessage) ? "保存文档管理员失败" : saveResult.ErrorMessage)}", + "error"); + } + + return _cardBuilder.BuildCardActionToastOnlyResponse("✅ 已将当前操作者保存为文档管理员", "success"); + } + + private async Task HandleToggleReplyDocumentAsync( + string? chatId, + string? operatorUserId, + bool toggleFullReplyDoc, + string modeDisplayName, + string defaultFailureMessage, + bool toggleAudioFullReplyDoc = false, + bool toggleAudioFinalReplyDoc = false, + bool toggleReferencedMarkdownDocImport = false) { if (string.IsNullOrWhiteSpace(chatId)) { @@ -450,17 +582,54 @@ private async Task HandleToggleReplyTtsAsync(strin return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 未找到当前飞书用户配置", "error"); } - config.ReplyTtsEnabled = !config.ReplyTtsEnabled; + if (toggleAudioFullReplyDoc) + { + config.AudioFullReplyDocEnabled = !config.AudioFullReplyDocEnabled; + } + else if (toggleAudioFinalReplyDoc) + { + config.AudioFinalReplyDocEnabled = !config.AudioFinalReplyDocEnabled; + } + else if (toggleReferencedMarkdownDocImport) + { + config.ReferencedMarkdownDocImportEnabled = !config.ReferencedMarkdownDocImportEnabled; + } + else if (toggleFullReplyDoc) + { + config.FullReplyDocEnabled = !config.FullReplyDocEnabled; + } + else + { + config.FinalReplyDocEnabled = !config.FinalReplyDocEnabled; + } + + config.LegacyReplyTtsEnabled = config.FullReplyDocEnabled || config.FinalReplyDocEnabled; + config.LegacyReplyTtsMode = config.FullReplyDocEnabled + ? ReplyTtsModes.FullReply + : config.FinalReplyDocEnabled + ? ReplyTtsModes.FinalOnly + : ReplyTtsModes.Off; + config.LegacyReplyTtsVoiceId = null; + var saveResult = await userFeishuBotConfigService.SaveAsync(config); if (!saveResult.Success) { return _cardBuilder.BuildCardActionToastOnlyResponse( - $"❌ {(string.IsNullOrWhiteSpace(saveResult.ErrorMessage) ? "飞书语音回复更新失败" : saveResult.ErrorMessage)}", + $"❌ {(string.IsNullOrWhiteSpace(saveResult.ErrorMessage) ? defaultFailureMessage : saveResult.ErrorMessage)}", "error"); } var card = await BuildHelpCommandListCardAsync(chatId); - var toastMessage = config.ReplyTtsEnabled ? "✅ 已开启飞书语音回复" : "✅ 已关闭飞书语音回复"; + var isEnabled = toggleAudioFullReplyDoc + ? config.AudioFullReplyDocEnabled + : toggleAudioFinalReplyDoc + ? config.AudioFinalReplyDocEnabled + : toggleReferencedMarkdownDocImport + ? config.ReferencedMarkdownDocImportEnabled + : toggleFullReplyDoc + ? config.FullReplyDocEnabled + : config.FinalReplyDocEnabled; + var toastMessage = isEnabled ? $"✅ 已开启{modeDisplayName}" : $"✅ 已关闭{modeDisplayName}"; return _cardBuilder.BuildCardActionResponseV2(card, toastMessage, "success"); } @@ -579,13 +748,13 @@ private async Task HandleExecuteCommandAsync( TryAttachSuperpowersQuickActions(streamingChrome, sessionId, toolId, actualChatKey, showStopAction: true); // 创建流式回复 - var handle = await _cardKit.CreateStreamingHandleAsync( + var handle = await CreateStreamingHandleWithOverflowFallbackAsync( chatId, null, effectiveOptions.ThinkingMessage, - effectiveOptions.DefaultCardTitle, - optionsOverride: effectiveOptions, - chrome: streamingChrome); + effectiveOptions, + streamingChrome, + CancellationToken.None); _logger.LogInformation( "🔥 [FeishuHelp] 流式句柄已创建: CardId={CardId}", @@ -600,7 +769,7 @@ private async Task HandleExecuteCommandAsync( { // 执行 CLI 工具并流式更新卡片 await ExecuteCliAndStreamAsync( - handle, + activeExecution, streamingChrome, baseStatusMarkdown, sessionId, @@ -703,8 +872,9 @@ private async Task HandleSuperpowersQuickActionAsy string? appId, string? inputValues) { - var effectiveAction = string.Equals(action.Action, FeishuHelpCardAction.ConfirmBoundSuperpowersAction, StringComparison.Ordinal) - || string.Equals(action.Action, FeishuHelpCardAction.ConfirmCurrentSuperpowersAction, StringComparison.Ordinal) + var isConfirmSuperpowersAction = string.Equals(action.Action, FeishuHelpCardAction.ConfirmBoundSuperpowersAction, StringComparison.Ordinal) + || string.Equals(action.Action, FeishuHelpCardAction.ConfirmCurrentSuperpowersAction, StringComparison.Ordinal); + var effectiveAction = isConfirmSuperpowersAction ? action.Command : action.Action; @@ -715,8 +885,13 @@ private async Task HandleSuperpowersQuickActionAsy FeishuHelpCardAction.ContinueSuperpowersAction => SuperpowersPromptBuilder.BuildContinuePrompt(), FeishuHelpCardAction.ExecuteSuperpowersPlanAction => SuperpowersPromptBuilder.BuildExecutePlanPrompt(), FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction => SuperpowersPromptBuilder.BuildSubagentExecutePlanPrompt(), + FeishuHelpCardAction.ExecuteSuperpowersCompleteWorktreeAction => SuperpowersPromptBuilder.BuildCompleteWorktreePrompt(), _ => null }; + if (string.IsNullOrWhiteSpace(prompt) && isConfirmSuperpowersAction && !string.IsNullOrWhiteSpace(action.Command)) + { + prompt = action.Command; + } if (string.IsNullOrWhiteSpace(prompt)) { @@ -750,7 +925,7 @@ private async Task HandleSuperpowersQuickActionAsy boundSessionId, currentSessionId, confirmToolId, - effectiveAction ?? action.Action, + prompt, "⚠️ 当前激活会话已变化,请先确认要执行的会话", "warning"); } @@ -881,11 +1056,18 @@ private async Task HandleGoalQuickActionAsync( return _cardBuilder.BuildCardActionToastOnlyResponse("✅ 已保留当前 goal", "success"); } + var latestAssistantReply = string.Equals(action.Action, FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction, StringComparison.OrdinalIgnoreCase) + ? await TryResolveLatestCompletedAssistantReplyContentAsync(activeSessionId, effectiveToolId) + : null; var prompt = string.Equals(action.Action, FeishuHelpCardAction.ConfirmOverwriteGoalAction, StringComparison.OrdinalIgnoreCase) ? GoalPromptBuilder.BuildGoalPrompt(action.Command) - : GoalPromptBuilder.BuildPromptForAction( - action.Action, - ResolveQuickInputValue(formValue, GoalQuickActionDefaults.QuickInputFieldName, inputValues)); + : string.Equals(action.Action, FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction, StringComparison.OrdinalIgnoreCase) + ? GoalPromptBuilder.BuildSubagentPlanGoalPrompt( + latestAssistantReply, + TryGetSessionWorkspacePath(activeSessionId)) + : GoalPromptBuilder.BuildPromptForAction( + action.Action, + ResolveQuickInputValue(formValue, GoalQuickActionDefaults.QuickInputFieldName, inputValues)); if (string.IsNullOrWhiteSpace(prompt)) { return _cardBuilder.BuildCardActionToastOnlyResponse("⚠️ 请输入目标", "warning"); @@ -900,7 +1082,8 @@ private async Task HandleGoalQuickActionAsync( return _cardBuilder.BuildCardActionToastOnlyResponse($"⚠️ {message}", "warning"); } - if (string.Equals(action.Action, FeishuHelpCardAction.SubmitGoalQuickInputAction, StringComparison.OrdinalIgnoreCase) + if ((string.Equals(action.Action, FeishuHelpCardAction.SubmitGoalQuickInputAction, StringComparison.OrdinalIgnoreCase) + || string.Equals(action.Action, FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction, StringComparison.OrdinalIgnoreCase)) && HasGoalExecutionConflict(activeSessionId)) { return BuildGoalOverwriteConfirmResponse(activeSessionId, targetChatKey, effectiveToolId, prompt); @@ -925,6 +1108,8 @@ private async Task HandleGoalQuickActionAsync( await _cliExecutor.StopSessionExecutionAsync(activeSessionId, effectiveToolId); } + await EnsureGoalRuntimeOverrideEnabledAsync(activeSessionId, targetChatKey, effectiveToolId, operatorUserId); + return await HandleExecuteCommandAsync( formValue: null, commandFromAction: prompt, @@ -952,10 +1137,11 @@ private CardActionTriggerResponseDto BuildSyncSessionProviderConfirmResponse( string sessionId, string chatKey, string? toolId, - bool showAllSessions) + bool showAllSessions, + int? sessionPage = null) { return _cardBuilder.BuildCardActionResponseV2( - _cardBuilder.BuildSyncSessionProviderConfirmCardV2(sessionId, chatKey, toolId, showAllSessions), + _cardBuilder.BuildSyncSessionProviderConfirmCardV2(sessionId, chatKey, toolId, showAllSessions, sessionPage), "⚠️ 当前 goal 正在执行,同步 Provider 需要先中断并重启当前运行时,请确认是否继续", "warning"); } @@ -1012,11 +1198,71 @@ private bool HasGoalExecutionConflict(string sessionId) return appServerSessionManager?.HasActiveTurn(sessionId) == true; } + private async Task EnsureGoalRuntimeOverrideEnabledAsync( + string sessionId, + string chatKey, + string? toolId, + string? operatorUserId) + { + var normalizedToolId = SessionLaunchOverrideHelper.NormalizeToolId(toolId); + if (string.IsNullOrWhiteSpace(sessionId) + || !string.Equals(normalizedToolId, "codex", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var actualChatKey = NormalizeChatKey(chatKey); + var username = ResolveFeishuUsername(actualChatKey, operatorUserId); + if (string.IsNullOrWhiteSpace(username)) + { + return; + } + + using var scope = _serviceProvider.CreateScope(); + var repo = scope.ServiceProvider.GetService(); + if (repo == null) + { + return; + } + + var session = await repo.GetByIdAndUsernameAsync(sessionId, username); + if (session == null) + { + return; + } + + var effectiveToolId = SessionLaunchOverrideHelper.ResolveEffectiveToolId(session.ToolId, session.CcSwitchSnapshotToolId); + if (!string.Equals(effectiveToolId, "codex", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var currentOverrides = SessionLaunchOverrideHelper.Deserialize(session.ToolLaunchOverridesJson); + var currentOverride = SessionLaunchOverrideHelper.GetEffectiveOverride( + currentOverrides, + effectiveToolId, + session.ToolId, + session.CcSwitchSnapshotToolId); + if (currentOverride?.UseGoalRuntime == true) + { + return; + } + + var updatedOverrides = SessionLaunchOverrideHelper.ApplyGoalRuntimeOverride( + currentOverrides, + effectiveToolId, + true); + session.ToolLaunchOverridesJson = SessionLaunchOverrideHelper.Serialize(updatedOverrides); + session.UpdatedAt = DateTime.Now; + await repo.UpdateAsync(session); + } + private async Task HandleTemporarilyExitGoalRuntimeAsync( string? sessionId, string? chatKey, string? operatorUserId, - bool? showAllSessions) + bool? showAllSessions, + int? sessionPage) { if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(chatKey)) { @@ -1046,7 +1292,15 @@ private async Task HandleTemporarilyExitGoalRuntim if (HasGoalExecutionConflict(sessionId)) { - return _cardBuilder.BuildCardActionToastOnlyResponse("⚠️ 当前 goal 正在执行,暂时不能临时退出", "warning"); + var autoPauseResult = await TryAutoPauseGoalRuntimeBeforeTemporaryExitAsync( + sessionId, + actualChatKey, + operatorUserId, + effectiveToolId); + if (!autoPauseResult.Success) + { + return _cardBuilder.BuildCardActionToastOnlyResponse(autoPauseResult.Message, "warning"); + } } try @@ -1068,7 +1322,7 @@ private async Task HandleTemporarilyExitGoalRuntim const string successMessage = "✅ 已临时退出 goal 持续会话,后续补充消息将按一次性进程执行"; if (showAllSessions.HasValue) { - var card = await BuildSessionManagerCardAsync(actualChatKey, operatorUserId, username, showAllSessions.Value); + var card = await BuildSessionManagerCardAsync(actualChatKey, operatorUserId, username, showAllSessions.Value, sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, successMessage, "success"); } @@ -1085,6 +1339,85 @@ private async Task HandleTemporarilyExitGoalRuntim } } + private async Task HandleTemporarilyExitAndCompleteWorktreeAsync( + FeishuHelpCardAction action, + string? chatId, + string? operatorUserId, + string? appId) + { + var temporaryExitResponse = await HandleTemporarilyExitGoalRuntimeAsync( + action.SessionId, + action.ChatKey ?? chatId, + operatorUserId, + showAllSessions: null, + sessionPage: action.SessionPage); + + var toastType = temporaryExitResponse.Toast?.Type; + if (toastType == CardActionTriggerResponseDto.ToastSuffix.ToastType.Error + || toastType == CardActionTriggerResponseDto.ToastSuffix.ToastType.Warning) + { + return temporaryExitResponse; + } + + var followupAction = new FeishuHelpCardAction + { + Action = FeishuHelpCardAction.ExecuteSuperpowersCompleteWorktreeAction, + SessionId = action.SessionId, + ChatKey = action.ChatKey ?? chatId, + ToolId = action.ToolId + }; + + return await HandleSuperpowersQuickActionAsync( + followupAction, + formValue: null, + chatId, + operatorUserId, + appId, + inputValues: null); + } + + private async Task<(bool Success, string Message)> TryAutoPauseGoalRuntimeBeforeTemporaryExitAsync( + string sessionId, + string chatKey, + string? operatorUserId, + string? preferredToolId) + { + if (_feishuChannel.IsSessionExecutionActive(sessionId)) + { + return (false, "⚠️ 当前会话还有任务在执行,无法自动暂停后临时退出"); + } + + try + { + var pauseResponse = await HandleExecuteCommandAsync( + formValue: null, + commandFromAction: GoalQuickActionDefaults.PausePrompt, + chatId: chatKey, + operatorUserId: operatorUserId, + inputValues: null, + appId: null, + preferredSessionId: sessionId, + preferredToolId: preferredToolId); + + var toastType = pauseResponse.Toast?.Type; + if (toastType == CardActionTriggerResponseDto.ToastSuffix.ToastType.Error + || toastType == CardActionTriggerResponseDto.ToastSuffix.ToastType.Warning) + { + var message = string.IsNullOrWhiteSpace(pauseResponse.Toast?.Content) + ? "⚠️ 当前 goal 无法自动暂停,暂时不能临时退出" + : $"⚠️ 当前 goal 无法自动暂停,暂时不能临时退出:{pauseResponse.Toast!.Content}"; + return (false, message); + } + + return (true, string.Empty); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "自动暂停 goal runtime 失败: SessionId={SessionId}", sessionId); + return (false, $"⚠️ 当前 goal 无法自动暂停,暂时不能临时退出:{ex.Message}"); + } + } + private async Task HandleLowInterruptionContinueAsync( string? sessionId, string? chatKey, @@ -1133,13 +1466,13 @@ private async Task HandleLowInterruptionContinueAs var (streamingChrome, baseStatusMarkdown) = await BuildStreamingCardChromeAsync(actualChatKey, sessionId, username, effectiveToolId); TryAttachSuperpowersQuickActions(streamingChrome, sessionId, effectiveToolId, actualChatKey, showStopAction: true); - var handle = await _cardKit.CreateStreamingHandleAsync( + var handle = await CreateStreamingHandleWithOverflowFallbackAsync( actualChatKey, null, effectiveOptions.ThinkingMessage, - effectiveOptions.DefaultCardTitle, - optionsOverride: effectiveOptions, - chrome: streamingChrome); + effectiveOptions, + streamingChrome, + CancellationToken.None); using var executionCts = new CancellationTokenSource(); var activeExecution = new ActiveStreamingExecution(sessionId, handle, executionCts); @@ -1149,7 +1482,7 @@ private async Task HandleLowInterruptionContinueAs try { await ExecuteLowInterruptionContinueAndStreamAsync( - handle, + activeExecution, streamingChrome, baseStatusMarkdown, sessionId, @@ -1277,7 +1610,7 @@ private async Task HandleShowCategoryAsync(string? /// 执行 CLI 工具并流式更新卡片(从 FeishuChannelService 复制) /// private async Task ExecuteCliAndStreamAsync( - FeishuStreamingHandle handle, + ActiveStreamingExecution activeExecution, FeishuStreamingCardChrome streamingChrome, string baseStatusMarkdown, string sessionId, @@ -1291,17 +1624,36 @@ private async Task ExecuteCliAndStreamAsync( { var outputBuilder = new System.Text.StringBuilder(); var assistantMessageBuilder = new System.Text.StringBuilder(); + var turnAssistantMessageBuilder = new System.Text.StringBuilder(); + var finalAnswerMessageBuilder = new System.Text.StringBuilder(); var jsonlBuffer = new System.Text.StringBuilder(); var hasStructuredTodoList = false; var latestRenderedContent = thinkingMessage; var cardDisconnected = false; + var effectiveOptions = await ResolveEffectiveOptionsAsync(username, appId); + var cardSession = new FeishuStreamingCardSession( + activeExecution.Handle, + (_, latestContent, token) => TryCreateReplacementStreamingHandleAsync( + chatId, + latestContent, + streamingChrome, + effectiveOptions, + token), + activeExecution.ReplaceHandle, + (stoppedHandle, latestContent, token) => TryFinishReplacementStreamingCardAsync( + stoppedHandle, + streamingChrome, + baseStatusMarkdown, + latestContent, + token), + deferReplacementUntilNextForegroundUpdate: IsGoalRuntimeSession(sessionId, toolId)); var resolvedToolId = NormalizeToolId(toolId) ?? ResolveDefaultToolId(); var tool = _cliExecutor.GetTool(resolvedToolId); if (tool == null) { streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, $"未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。")); _logger.LogWarning("CLI tool not found: {ToolId}", resolvedToolId); @@ -1322,12 +1674,14 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( TryAttachSuperpowersQuickActions(streamingChrome, sessionId, tool.Id, chatId, showStopAction: true); PausePulseForOverflowCard(streamingChrome, pulseGate); var statusPulseTask = RunStreamingStatusPulseAsync( - handle, + activeExecution, streamingChrome, baseStatusMarkdown, () => latestRenderedContent, + cardSession, pulseGate, - statusPulseCts.Token); + statusPulseCts.Token, + executionCancellationToken); var externalHistoryBackfillTask = RunExternalHistoryBackfillAsync( sessionId, tool.Id, @@ -1336,15 +1690,13 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( () => latestRenderedContent, content => { - if (handle.AreCardUpdatesStopped) - { - return; - } - latestRenderedContent = content; PausePulseForOverflowCard(streamingChrome, pulseGate); }, - content => handle.AreCardUpdatesStopped ? Task.CompletedTask : handle.UpdateAsync(content), + content => cardSession.UpdateAsync( + content, + executionCancellationToken, + allowPendingReplacementActivation: false), statusPulseCts.Token); try @@ -1358,23 +1710,97 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( chunk.ErrorMessage ?? "Unknown error"); statusPulseCts.Cancel(); streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, chunk.ErrorMessage ?? "执行失败")); return; } + if (chunk.IsTurnBoundary) + { + if (cardDisconnected) + { + continue; + } + + if (turnAssistantMessageBuilder.Length > 0) + { + await TryQueueCompletedReplyDocumentAsync( + chatId, + username, + appId, + sessionId, + _cliExecutor.GetCliThreadId(sessionId), + userPrompt, + turnAssistantMessageBuilder.ToString().Trim(), + finalAnswerMessageBuilder.ToString().Trim()); + } + + var handoffSucceeded = await TryRotateGoalRuntimeTurnCardAsync( + sessionId, + chatId, + tool.Id, + activeExecution, + cardSession, + streamingChrome, + baseStatusMarkdown, + latestRenderedContent, + username, + appId, + executionCancellationToken); + if (!handoffSucceeded) + { + var disconnectedContent = await TryHandleStreamingCardDisconnectAsync( + activeExecution, + streamingChrome, + baseStatusMarkdown, + statusPulseCts, + latestRenderedContent, + sessionId, + executionCancellationToken); + if (disconnectedContent != null) + { + cardDisconnected = true; + latestRenderedContent = disconnectedContent; + } + + continue; + } + + outputBuilder.Clear(); + assistantMessageBuilder.Clear(); + turnAssistantMessageBuilder.Clear(); + finalAnswerMessageBuilder.Clear(); + jsonlBuffer.Clear(); + hasStructuredTodoList = false; + latestRenderedContent = thinkingMessage; + streamingChrome.LatestToolCallMarkdown = null; + PausePulseForOverflowCard(streamingChrome, pulseGate); + continue; + } + outputBuilder.Append(chunk.Content); string displayContent; if (useAdapter) { - hasStructuredTodoList |= ProcessJsonlChunk(sessionId, chunk.Content, adapter!, assistantMessageBuilder, jsonlBuffer, streamingChrome); + hasStructuredTodoList |= ProcessJsonlChunk( + sessionId, + chunk.Content, + adapter!, + assistantMessageBuilder, + turnAssistantMessageBuilder, + finalAnswerMessageBuilder, + jsonlBuffer, + streamingChrome); displayContent = assistantMessageBuilder.ToString(); if (string.IsNullOrWhiteSpace(displayContent)) { - displayContent = ExtractFallbackOutput(outputBuilder.ToString(), adapter!) ?? thinkingMessage; + displayContent = ExtractFallbackOutput(outputBuilder.ToString(), adapter!) + ?? (ShouldProbeExternalHistory(latestRenderedContent, thinkingMessage) + ? thinkingMessage + : latestRenderedContent); } } else @@ -1386,10 +1812,14 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( { latestRenderedContent = displayContent; PausePulseForOverflowCard(streamingChrome, pulseGate); - await handle.UpdateAsync(displayContent); - - var disconnectedContent = await TryHandleStreamingCardDisconnectAsync( - handle, + var updateSucceeded = await cardSession.UpdateAsync( + displayContent, + executionCancellationToken, + allowPendingReplacementActivation: true); + var disconnectedContent = updateSucceeded + ? null + : await TryHandleStreamingCardDisconnectAsync( + activeExecution, streamingChrome, baseStatusMarkdown, statusPulseCts, @@ -1414,7 +1844,14 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( { if (jsonlBuffer.Length > 0) { - hasStructuredTodoList |= ProcessJsonlLine(sessionId, jsonlBuffer.ToString(), adapter!, assistantMessageBuilder, streamingChrome); + hasStructuredTodoList |= ProcessJsonlLine( + sessionId, + jsonlBuffer.ToString(), + adapter!, + assistantMessageBuilder, + turnAssistantMessageBuilder, + finalAnswerMessageBuilder, + streamingChrome); jsonlBuffer.Clear(); } @@ -1437,10 +1874,12 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( finalOutput = FormatMarkdownOutput(outputBuilder.ToString()); } + var finalAnswerOutput = finalAnswerMessageBuilder.ToString().Trim(); + if (!cardDisconnected) { var disconnectedContent = await TryHandleStreamingCardDisconnectAsync( - handle, + activeExecution, streamingChrome, baseStatusMarkdown, statusPulseCts, @@ -1455,33 +1894,55 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( } statusPulseCts.Cancel(); - if (!cardDisconnected && !executionCancellationToken.IsCancellationRequested && !handle.AreCardUpdatesStopped) + if (!cardDisconnected && !executionCancellationToken.IsCancellationRequested && !activeExecution.Handle.AreCardUpdatesStopped) { + var completionPresentation = await BuildCompletionPresentationAsync( + sessionId, + tool.Id, + baseStatusMarkdown); latestRenderedContent = finalOutput; - streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithCompletedState(baseStatusMarkdown); + streamingChrome.StatusMarkdown = completionPresentation.StatusMarkdown; SetTopChipGroupsEnabled(streamingChrome, true); TryAttachSuperpowersQuickActions(streamingChrome, sessionId, tool.Id, chatId); + var finishSucceeded = await cardSession.FinishAsync(finalOutput); + if (!finishSucceeded) + { + var disconnectedContent = await TryHandleStreamingCardDisconnectAsync( + activeExecution, + streamingChrome, + baseStatusMarkdown, + statusPulseCts, + latestRenderedContent, + sessionId, + executionCancellationToken); + if (disconnectedContent != null) + { + cardDisconnected = true; + latestRenderedContent = disconnectedContent; + } + } try { - // NOTE: 这条带会话标识的“已完成”普通文本通知不能删除。 - // 飞书侧对流式卡片完成的提示不够明显,用户依赖这条文本消息获得显式完成提醒, - // 同时还需要知道是哪一个会话完成了。 - // 后续若调整卡片交互,必须保留等价的完成通知能力。 - await _feishuChannel.SendMessageAsync( - chatId, - BuildCompletionNotificationText(sessionId)); + if (!cardDisconnected) + { + // NOTE: 这条带会话标识的“已完成”普通文本通知不能删除。 + // 飞书侧对流式卡片完成的提示不够明显,用户依赖这条文本消息获得显式完成提醒, + // 同时还需要知道是哪一个会话完成了。 + // 后续若调整卡片交互,必须保留等价的完成通知能力。 + await _feishuChannel.SendMessageAsync( + chatId, + completionPresentation.NotificationText); + } } catch (Exception notificationEx) { _logger.LogWarning(notificationEx, "发送完成通知失败: ChatId={ChatId}", chatId); } - - await handle.FinishAsync(finalOutput); } else { _logger.LogInformation( - executionCancellationToken.IsCancellationRequested || handle.AreCardUpdatesStopped + executionCancellationToken.IsCancellationRequested || activeExecution.Handle.AreCardUpdatesStopped ? "Feishu card updates stopped mid-stream; skipped final card completion update: Session={SessionId}" : "Feishu card completed without final card update: Session={SessionId}", sessionId); @@ -1500,7 +1961,15 @@ await _feishuChannel.SendMessageAsync( if (!cardDisconnected) { - await TryQueueCompletedReplyTtsAsync(chatId, username, appId, sessionId, finalOutput); + await TryQueueCompletedReplyDocumentAsync( + chatId, + username, + appId, + sessionId, + _cliExecutor.GetCliThreadId(sessionId), + userPrompt, + turnAssistantMessageBuilder.ToString().Trim(), + finalAnswerOutput); } } @@ -1518,7 +1987,7 @@ await _feishuChannel.SendMessageAsync( _logger.LogError(ex, "CLI execution failed for session: {SessionId}", sessionId); statusPulseCts.Cancel(); streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, ex.Message)); } @@ -1544,7 +2013,7 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( } private async Task ExecuteLowInterruptionContinueAndStreamAsync( - FeishuStreamingHandle handle, + ActiveStreamingExecution activeExecution, FeishuStreamingCardChrome streamingChrome, string baseStatusMarkdown, string sessionId, @@ -1558,17 +2027,36 @@ private async Task ExecuteLowInterruptionContinueAndStreamAsync( { var outputBuilder = new System.Text.StringBuilder(); var assistantMessageBuilder = new System.Text.StringBuilder(); + var turnAssistantMessageBuilder = new System.Text.StringBuilder(); + var finalAnswerMessageBuilder = new System.Text.StringBuilder(); var jsonlBuffer = new System.Text.StringBuilder(); var hasStructuredTodoList = false; var latestRenderedContent = thinkingMessage; var cardDisconnected = false; + var effectiveOptions = await ResolveEffectiveOptionsAsync(username, appId); + var cardSession = new FeishuStreamingCardSession( + activeExecution.Handle, + (_, latestContent, token) => TryCreateReplacementStreamingHandleAsync( + chatId, + latestContent, + streamingChrome, + effectiveOptions, + token), + activeExecution.ReplaceHandle, + (stoppedHandle, latestContent, token) => TryFinishReplacementStreamingCardAsync( + stoppedHandle, + streamingChrome, + baseStatusMarkdown, + latestContent, + token), + deferReplacementUntilNextForegroundUpdate: IsGoalRuntimeSession(sessionId, toolId)); var resolvedToolId = NormalizeToolId(toolId) ?? ResolveDefaultToolId(); var tool = _cliExecutor.GetTool(resolvedToolId); if (tool == null) { streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, $"未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。")); _logger.LogWarning("CLI tool not found for low interruption continue: {ToolId}", resolvedToolId); @@ -1582,12 +2070,14 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( var pulseGate = new FeishuStreamingStatusPulseGate(); PausePulseForOverflowCard(streamingChrome, pulseGate); var statusPulseTask = RunStreamingStatusPulseAsync( - handle, + activeExecution, streamingChrome, baseStatusMarkdown, () => latestRenderedContent, + cardSession, pulseGate, - statusPulseCts.Token); + statusPulseCts.Token, + executionCancellationToken); var externalHistoryBackfillTask = RunExternalHistoryBackfillAsync( sessionId, tool.Id, @@ -1596,15 +2086,13 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( () => latestRenderedContent, content => { - if (handle.AreCardUpdatesStopped) - { - return; - } - latestRenderedContent = content; PausePulseForOverflowCard(streamingChrome, pulseGate); }, - content => handle.AreCardUpdatesStopped ? Task.CompletedTask : handle.UpdateAsync(content), + content => cardSession.UpdateAsync( + content, + executionCancellationToken, + allowPendingReplacementActivation: false), statusPulseCts.Token); try @@ -1618,23 +2106,97 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( chunk.ErrorMessage ?? "Unknown error"); statusPulseCts.Cancel(); streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, chunk.ErrorMessage ?? "执行失败")); return; } + if (chunk.IsTurnBoundary) + { + if (cardDisconnected) + { + continue; + } + + if (turnAssistantMessageBuilder.Length > 0) + { + await TryQueueCompletedReplyDocumentAsync( + chatId, + username, + appId, + sessionId, + _cliExecutor.GetCliThreadId(sessionId), + prompt, + turnAssistantMessageBuilder.ToString().Trim(), + finalAnswerMessageBuilder.ToString().Trim()); + } + + var handoffSucceeded = await TryRotateGoalRuntimeTurnCardAsync( + sessionId, + chatId, + tool.Id, + activeExecution, + cardSession, + streamingChrome, + baseStatusMarkdown, + latestRenderedContent, + username, + appId, + executionCancellationToken); + if (!handoffSucceeded) + { + var disconnectedContent = await TryHandleStreamingCardDisconnectAsync( + activeExecution, + streamingChrome, + baseStatusMarkdown, + statusPulseCts, + latestRenderedContent, + sessionId, + executionCancellationToken); + if (disconnectedContent != null) + { + cardDisconnected = true; + latestRenderedContent = disconnectedContent; + } + + continue; + } + + outputBuilder.Clear(); + assistantMessageBuilder.Clear(); + turnAssistantMessageBuilder.Clear(); + finalAnswerMessageBuilder.Clear(); + jsonlBuffer.Clear(); + hasStructuredTodoList = false; + latestRenderedContent = thinkingMessage; + streamingChrome.LatestToolCallMarkdown = null; + PausePulseForOverflowCard(streamingChrome, pulseGate); + continue; + } + outputBuilder.Append(chunk.Content); string displayContent; if (useAdapter) { - hasStructuredTodoList |= ProcessJsonlChunk(sessionId, chunk.Content, adapter!, assistantMessageBuilder, jsonlBuffer, streamingChrome); + hasStructuredTodoList |= ProcessJsonlChunk( + sessionId, + chunk.Content, + adapter!, + assistantMessageBuilder, + turnAssistantMessageBuilder, + finalAnswerMessageBuilder, + jsonlBuffer, + streamingChrome); displayContent = assistantMessageBuilder.ToString(); if (string.IsNullOrWhiteSpace(displayContent)) { - displayContent = ExtractFallbackOutput(outputBuilder.ToString(), adapter!) ?? thinkingMessage; + displayContent = ExtractFallbackOutput(outputBuilder.ToString(), adapter!) + ?? (ShouldProbeExternalHistory(latestRenderedContent, thinkingMessage) + ? thinkingMessage + : latestRenderedContent); } } else @@ -1646,10 +2208,14 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( { latestRenderedContent = displayContent; PausePulseForOverflowCard(streamingChrome, pulseGate); - await handle.UpdateAsync(displayContent); - - var disconnectedContent = await TryHandleStreamingCardDisconnectAsync( - handle, + var updateSucceeded = await cardSession.UpdateAsync( + displayContent, + executionCancellationToken, + allowPendingReplacementActivation: true); + var disconnectedContent = updateSucceeded + ? null + : await TryHandleStreamingCardDisconnectAsync( + activeExecution, streamingChrome, baseStatusMarkdown, statusPulseCts, @@ -1674,7 +2240,14 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( { if (jsonlBuffer.Length > 0) { - hasStructuredTodoList |= ProcessJsonlLine(sessionId, jsonlBuffer.ToString(), adapter!, assistantMessageBuilder, streamingChrome); + hasStructuredTodoList |= ProcessJsonlLine( + sessionId, + jsonlBuffer.ToString(), + adapter!, + assistantMessageBuilder, + turnAssistantMessageBuilder, + finalAnswerMessageBuilder, + streamingChrome); jsonlBuffer.Clear(); } @@ -1697,10 +2270,12 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( finalOutput = FormatMarkdownOutput(outputBuilder.ToString()); } + var finalAnswerOutput = finalAnswerMessageBuilder.ToString().Trim(); + if (!cardDisconnected) { var disconnectedContent = await TryHandleStreamingCardDisconnectAsync( - handle, + activeExecution, streamingChrome, baseStatusMarkdown, statusPulseCts, @@ -1715,30 +2290,52 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( } statusPulseCts.Cancel(); - if (!cardDisconnected && !executionCancellationToken.IsCancellationRequested && !handle.AreCardUpdatesStopped) + if (!cardDisconnected && !executionCancellationToken.IsCancellationRequested && !activeExecution.Handle.AreCardUpdatesStopped) { + var completionPresentation = await BuildCompletionPresentationAsync( + sessionId, + tool.Id, + baseStatusMarkdown); latestRenderedContent = finalOutput; - streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithCompletedState(baseStatusMarkdown); + streamingChrome.StatusMarkdown = completionPresentation.StatusMarkdown; SetTopChipGroupsEnabled(streamingChrome, true); TryAttachSuperpowersQuickActions(streamingChrome, sessionId, tool.Id, chatId); + var finishSucceeded = await cardSession.FinishAsync(finalOutput); + if (!finishSucceeded) + { + var disconnectedContent = await TryHandleStreamingCardDisconnectAsync( + activeExecution, + streamingChrome, + baseStatusMarkdown, + statusPulseCts, + latestRenderedContent, + sessionId, + executionCancellationToken); + if (disconnectedContent != null) + { + cardDisconnected = true; + latestRenderedContent = disconnectedContent; + } + } try { - await _feishuChannel.SendMessageAsync( - chatId, - BuildCompletionNotificationText(sessionId)); + if (!cardDisconnected) + { + await _feishuChannel.SendMessageAsync( + chatId, + completionPresentation.NotificationText); + } } catch (Exception notificationEx) { _logger.LogWarning(notificationEx, "发送少打断执行完成通知失败: ChatId={ChatId}", chatId); } - - await handle.FinishAsync(finalOutput); } else { _logger.LogInformation( - executionCancellationToken.IsCancellationRequested || handle.AreCardUpdatesStopped + executionCancellationToken.IsCancellationRequested || activeExecution.Handle.AreCardUpdatesStopped ? "Feishu card updates stopped during low interruption continue; skipped final card completion update: Session={SessionId}" : "Feishu low interruption continue completed without final card update: Session={SessionId}", sessionId); @@ -1757,7 +2354,15 @@ await _feishuChannel.SendMessageAsync( if (!cardDisconnected) { - await TryQueueCompletedReplyTtsAsync(chatId, username, appId, sessionId, finalOutput); + await TryQueueCompletedReplyDocumentAsync( + chatId, + username, + appId, + sessionId, + _cliExecutor.GetCliThreadId(sessionId), + prompt, + turnAssistantMessageBuilder.ToString().Trim(), + finalAnswerOutput); } } @@ -1775,7 +2380,7 @@ await _feishuChannel.SendMessageAsync( _logger.LogError(ex, "Low interruption continue failed for session: {SessionId}", sessionId); statusPulseCts.Cancel(); streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, ex.Message)); } @@ -1889,36 +2494,42 @@ private bool HasSuperpowersPlanFiles(string sessionId) } } - private async Task TryQueueCompletedReplyTtsAsync( + private async Task TryQueueCompletedReplyDocumentAsync( string chatId, string? username, string? appId, string sessionId, - string finalOutput) + string? cliThreadId, + string? originalUserQuestion, + string finalOutput, + string? finalAnswerOutput) { try { using var scope = _serviceProvider.CreateScope(); - var replyTtsOrchestrator = scope.ServiceProvider.GetService(); - if (replyTtsOrchestrator == null) + var replyDocumentOrchestrator = scope.ServiceProvider.GetService(); + if (replyDocumentOrchestrator == null) { return; } - await replyTtsOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + await replyDocumentOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest { ChatId = chatId, SessionId = sessionId, + CliThreadId = cliThreadId, + OriginalUserQuestion = originalUserQuestion, Username = username, AppId = appId, - Output = finalOutput + Output = finalOutput, + FinalAnswerOutput = finalAnswerOutput }); } catch (Exception ex) { _logger.LogWarning( ex, - "Failed to queue reply TTS after Feishu card action completion: SessionId={SessionId}, ChatId={ChatId}", + "Failed to queue reply document after Feishu card action completion: SessionId={SessionId}, ChatId={ChatId}", sessionId, chatId); } @@ -2040,12 +2651,14 @@ private static bool SessionContainsSuperpowers(IEnumerable contentAccessor, + FeishuStreamingCardSession cardSession, FeishuStreamingStatusPulseGate pulseGate, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + CancellationToken cardWriteCancellationToken) { var frameIndex = 0; try @@ -2053,7 +2666,7 @@ private async Task RunStreamingStatusPulseAsync( while (!cancellationToken.IsCancellationRequested) { await Task.Delay(StreamingStatusPulseIntervalMs, cancellationToken); - if (cancellationToken.IsCancellationRequested || handle.AreCardUpdatesStopped) + if (cancellationToken.IsCancellationRequested || activeExecution.Handle.AreCardUpdatesStopped) { break; } @@ -2063,17 +2676,113 @@ private async Task RunStreamingStatusPulseAsync( continue; } - streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithRunningState(baseStatusMarkdown, ++frameIndex); - await handle.UpdateAsync(contentAccessor()); - } + streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithRunningState(baseStatusMarkdown, ++frameIndex); + await cardSession.UpdateAsync( + contentAccessor(), + cardWriteCancellationToken, + allowPendingReplacementActivation: false); + } + } + catch (OperationCanceledException) + { + } + } + + private static async Task TryFinishReplacementStreamingCardAsync( + FeishuStreamingHandle stoppedHandle, + FeishuStreamingCardChrome streamingChrome, + string baseStatusMarkdown, + string latestContent, + CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var previousStatusMarkdown = streamingChrome.StatusMarkdown; + streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithStoppedState(baseStatusMarkdown); + try + { + await stoppedHandle.FinishAsync( + FeishuStreamingReplacementFormatter.BuildTransferredContent(latestContent)); + } + finally + { + streamingChrome.StatusMarkdown = previousStatusMarkdown; + } + } + + private async Task TryRotateGoalRuntimeTurnCardAsync( + string sessionId, + string chatId, + string toolId, + ActiveStreamingExecution activeExecution, + FeishuStreamingCardSession cardSession, + FeishuStreamingCardChrome streamingChrome, + string baseStatusMarkdown, + string latestRenderedContent, + string? username, + string? appId, + CancellationToken cancellationToken) + { + if (!IsGoalRuntimeSession(sessionId, toolId)) + { + return true; + } + + var previousHandle = activeExecution.Handle; + var previousStatusMarkdown = streamingChrome.StatusMarkdown; + var effectiveOptions = await ResolveEffectiveOptionsAsync(username, appId); + ApplyChromeForTurnHandoff(streamingChrome, baseStatusMarkdown); + + try + { + await previousHandle.FinishAsync(string.IsNullOrWhiteSpace(latestRenderedContent) ? "本轮已结束,Goal 继续执行中。" : latestRenderedContent); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Finishing previous goal-runtime turn card failed: Session={SessionId}, CardId={CardId}", sessionId, previousHandle.CardId); + } + + streamingChrome.StatusMarkdown = previousStatusMarkdown; + TryAttachSuperpowersQuickActions(streamingChrome, sessionId, toolId, chatId, showStopAction: true); + + FeishuStreamingHandle nextHandle; + try + { + nextHandle = await CreateStreamingHandleWithOverflowFallbackAsync( + chatId, + null, + effectiveOptions.ThinkingMessage, + effectiveOptions, + streamingChrome, + cancellationToken); } - catch (OperationCanceledException) + catch (Exception ex) { + _logger.LogWarning(ex, "Creating next goal-runtime turn card failed: Session={SessionId}, ChatId={ChatId}", sessionId, chatId); + return false; } + + activeExecution.ReplaceHandle(nextHandle); + await cardSession.SwitchHandleAsync(nextHandle, resetReplacementCount: true, cancellationToken); + return true; + } + + private static void ApplyChromeForTurnHandoff( + FeishuStreamingCardChrome chrome, + string baseStatusMarkdown) + { + chrome.StatusMarkdown = GoalRuntimeCompletionStateFormatter.WithGoalContinuingState(baseStatusMarkdown); + SetTopChipGroupsEnabled(chrome, true); + chrome.BottomPrompt = null; + chrome.AdditionalBottomPrompts.Clear(); + chrome.BottomActions.Clear(); } private async Task TryHandleStreamingCardDisconnectAsync( - FeishuStreamingHandle handle, + ActiveStreamingExecution activeExecution, FeishuStreamingCardChrome streamingChrome, string baseStatusMarkdown, CancellationTokenSource statusPulseCts, @@ -2081,7 +2790,7 @@ private async Task RunStreamingStatusPulseAsync( string sessionId, CancellationToken executionCancellationToken) { - if (!handle.AreCardUpdatesStopped) + if (!activeExecution.Handle.AreCardUpdatesStopped) { return null; } @@ -2100,7 +2809,7 @@ private async Task RunStreamingStatusPulseAsync( try { - await handle.FinishAsync(disconnectedContent); + await activeExecution.Handle.FinishAsync(disconnectedContent); } catch (Exception ex) { @@ -2113,6 +2822,66 @@ private async Task RunStreamingStatusPulseAsync( return disconnectedContent; } + private async Task TryCreateReplacementStreamingHandleAsync( + string chatId, + string latestRenderedContent, + FeishuStreamingCardChrome chrome, + FeishuOptions effectiveOptions, + CancellationToken cancellationToken) + { + try + { + return await CreateStreamingHandleWithOverflowFallbackAsync( + chatId, + null, + latestRenderedContent, + effectiveOptions, + chrome, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create replacement Feishu card-action streaming card for chat {ChatId}", chatId); + return null; + } + } + + private async Task CreateStreamingHandleWithOverflowFallbackAsync( + string chatId, + string? replyMessageId, + string initialContent, + FeishuOptions effectiveOptions, + FeishuStreamingCardChrome chrome, + CancellationToken cancellationToken) + { + try + { + return await _cardKit.CreateStreamingHandleAsync( + chatId, + replyMessageId, + initialContent, + effectiveOptions.DefaultCardTitle, + cancellationToken, + effectiveOptions, + chrome); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("code: 200860", StringComparison.Ordinal)) + { + _logger.LogWarning( + ex, + "Card-action streaming card creation overflowed; switching to plain-text fallback stream (chatId={ChatId}, replyMessageId={ReplyMessageId})", + chatId, + replyMessageId ?? ""); + return await FeishuTextStreamingFallbackHandleFactory.CreateAsync( + _cardKit, + chatId, + replyMessageId, + initialContent, + effectiveOptions, + cancellationToken); + } + } + private static void PausePulseForOverflowCard( FeishuStreamingCardChrome streamingChrome, FeishuStreamingStatusPulseGate pulseGate) @@ -2187,6 +2956,8 @@ private bool ProcessJsonlChunk( string content, ICliToolAdapter adapter, System.Text.StringBuilder assistantMessageBuilder, + System.Text.StringBuilder turnAssistantMessageBuilder, + System.Text.StringBuilder finalAnswerMessageBuilder, System.Text.StringBuilder jsonlBuffer, FeishuStreamingCardChrome? chrome) { @@ -2211,7 +2982,14 @@ private bool ProcessJsonlChunk( var line = bufferContent.Substring(0, newlineIndex).TrimEnd('\r'); jsonlBuffer.Remove(0, newlineIndex + 1); - hasStructuredTodoList |= ProcessJsonlLine(sessionId, line, adapter, assistantMessageBuilder, chrome); + hasStructuredTodoList |= ProcessJsonlLine( + sessionId, + line, + adapter, + assistantMessageBuilder, + turnAssistantMessageBuilder, + finalAnswerMessageBuilder, + chrome); } return hasStructuredTodoList; @@ -2225,6 +3003,8 @@ private bool ProcessJsonlLine( string line, ICliToolAdapter adapter, System.Text.StringBuilder assistantMessageBuilder, + System.Text.StringBuilder turnAssistantMessageBuilder, + System.Text.StringBuilder finalAnswerMessageBuilder, FeishuStreamingCardChrome? chrome) { var trimmedLine = line.Trim(); @@ -2262,6 +3042,11 @@ private bool ProcessJsonlLine( if (!string.IsNullOrEmpty(assistantMessage)) { assistantMessageBuilder.Append(assistantMessage); + turnAssistantMessageBuilder.Append(assistantMessage); + if (string.Equals(outputEvent.AssistantPhase, "final_answer", StringComparison.Ordinal)) + { + finalAnswerMessageBuilder.Append(assistantMessage); + } } return LowInterruptionContinueHelper.HasStructuredTodoList(outputEvent); @@ -2459,17 +3244,22 @@ private static string NormalizeComparableText(string? value) continue; } + var isStructuredLine = line.StartsWith("{", StringComparison.Ordinal) + || line.StartsWith("[", StringComparison.Ordinal); + if (isStructuredLine) + { + sawStructuredOutput = true; + } + var outputEvent = adapter.ParseOutputLine(line); - if (outputEvent == null || string.IsNullOrWhiteSpace(outputEvent.Content)) + if (outputEvent == null) { continue; } - var isStructuredLine = line.StartsWith("{", StringComparison.Ordinal) - || line.StartsWith("[", StringComparison.Ordinal); - if (isStructuredLine) + if (string.IsNullOrWhiteSpace(outputEvent.Content)) { - sawStructuredOutput = true; + continue; } var assistantMessage = adapter.ExtractAssistantMessage(outputEvent); @@ -2561,7 +3351,7 @@ private async Task HandleBindWebUserAsync(JsonElem /// /// 处理切换会话动作 /// - private async Task HandleSwitchSessionAsync(string? sessionId, string? chatKey, string? operatorUserId, string? appId, bool showAllSessions = false) + private async Task HandleSwitchSessionAsync(string? sessionId, string? chatKey, string? operatorUserId, string? appId, bool showAllSessions = false, int? sessionPage = null) { if (string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(chatKey)) { @@ -2640,7 +3430,7 @@ private async Task HandleSwitchSessionAsync(string toolLabel, lastActiveTime); - var card = await BuildSessionManagerCardAsync(actualChatKey, operatorUserId, username, showAllSessions); + var card = await BuildSessionManagerCardAsync(actualChatKey, operatorUserId, username, showAllSessions, sessionPage); // 后台异步发送会话历史卡片 _ = Task.Run(async () => @@ -2718,7 +3508,8 @@ private async Task HandleSyncSessionProviderAsync( string? chatKey, string? operatorUserId, string? appId, - bool showAllSessions = false) + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(chatKey)) { @@ -2747,7 +3538,7 @@ private async Task HandleSyncSessionProviderAsync( if (IsGoalRuntimeSession(session, effectiveToolId) && HasGoalExecutionConflict(sessionId)) { - return BuildSyncSessionProviderConfirmResponse(sessionId, actualChatKey, effectiveToolId, showAllSessions); + return BuildSyncSessionProviderConfirmResponse(sessionId, actualChatKey, effectiveToolId, showAllSessions, sessionPage); } StartSyncSessionProviderInBackground( @@ -2997,7 +3788,7 @@ private async Task HandleSwitchToolAsync(string? t /// /// 处理关闭会话动作 /// - private async Task HandleCloseSessionAsync(string? sessionId, string? chatKey, string? operatorUserId, bool showAllSessions = false) + private async Task HandleCloseSessionAsync(string? sessionId, string? chatKey, string? operatorUserId, bool showAllSessions = false, int? sessionPage = null) { if (string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(chatKey)) { @@ -3078,7 +3869,13 @@ private async Task HandleCloseSessionAsync(string? /// /// 显示新建会话表单 /// - private async Task HandleShowCreateSessionFormAsync(string? chatKey, string? chatId, string? operatorUserId, string? selectedToolId) + private async Task HandleShowCreateSessionFormAsync( + string? chatKey, + string? chatId, + string? operatorUserId, + string? selectedToolId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrEmpty(chatKey) || string.IsNullOrEmpty(chatId)) { @@ -3103,7 +3900,7 @@ private async Task HandleShowCreateSessionFormAsyn } _logger.LogInformation("[Feishu] 新建会话卡片加载可访问目录: User={User}, Count={Count}", username, directories.Count); - var card = BuildCreateSessionFormCard(actualChatKey, directories, availableTools, effectiveToolId); + var card = BuildCreateSessionFormCard(actualChatKey, directories, availableTools, effectiveToolId, showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, $"请选择工作区和 CLI 工具(当前选择:{GetToolDisplayName(effectiveToolId)})"); } @@ -3202,7 +3999,9 @@ private async Task HandleBrowseAllowedDirectoryAsy string? workspacePath, int? page, string? operatorUserId, - string? selectedToolId) + string? selectedToolId, + bool showAllSessions = false, + int? sessionPage = null) { var actualChatKey = !string.IsNullOrWhiteSpace(chatKey) ? NormalizeChatKey(chatKey) @@ -3226,7 +4025,7 @@ private async Task HandleBrowseAllowedDirectoryAsy { var browseResult = await sessionDirectoryService.BrowseAllowedDirectoriesAsync(workspacePath, username); var effectiveToolId = NormalizeToolId(selectedToolId) ?? ResolveToolIdForChat(actualChatKey, username); - var card = BuildAllowedDirectoryCard(actualChatKey, effectiveToolId, browseResult, Math.Max(page ?? 0, 0)); + var card = BuildAllowedDirectoryCard(actualChatKey, effectiveToolId, browseResult, Math.Max(page ?? 0, 0), showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, string.Empty); } catch (UnauthorizedAccessException ex) @@ -3348,15 +4147,19 @@ private async Task BuildHelpCommandListCardAsync(string? chat var username = string.IsNullOrWhiteSpace(actualChatKey) ? null : _feishuChannel.GetSessionUsername(actualChatKey); var toolId = ResolveToolIdForChat(actualChatKey, username); var categories = await _commandService.GetCategorizedCommandsAsync(toolId); - var replyTtsEnabled = await GetReplyTtsEnabledAsync(username); + var replyDocumentSettings = await GetReplyDocumentSettingsAsync(username); var showGoalQuickActionButtons = ResolveShowGoalQuickActionButtons(actualChatKey, username, toolId); var showSuperpowersQuickActions = ResolveShowSuperpowersQuickActions(actualChatKey, username, toolId); return _cardBuilder.BuildCommandListCardV2( categories, showRefreshButton, - replyTtsEnabled, + replyDocumentSettings.FullReplyDocEnabled, + replyDocumentSettings.FinalReplyDocEnabled, showGoalQuickActionButtons, - showSuperpowersQuickActions); + showSuperpowersQuickActions, + replyDocumentSettings.AudioFullReplyDocEnabled, + replyDocumentSettings.AudioFinalReplyDocEnabled, + replyDocumentSettings.ReferencedMarkdownDocImportEnabled); } private bool ResolveShowGoalQuickActionButtons(string? chatKey, string? username, string? toolId) @@ -3444,17 +4247,22 @@ private bool ShouldShowTemporaryExitGoalRuntimeAction(ChatSessionEntity? session && !HasGoalExecutionConflict(session.SessionId); } - private async Task GetReplyTtsEnabledAsync(string? username) + private async Task<(bool FullReplyDocEnabled, bool FinalReplyDocEnabled, bool AudioFullReplyDocEnabled, bool AudioFinalReplyDocEnabled, bool ReferencedMarkdownDocImportEnabled)> GetReplyDocumentSettingsAsync(string? username) { if (string.IsNullOrWhiteSpace(username)) { - return false; + return (false, false, false, false, false); } using var scope = _serviceProvider.CreateScope(); var userFeishuBotConfigService = scope.ServiceProvider.GetRequiredService(); var config = await userFeishuBotConfigService.GetByUsernameAsync(username); - return config?.ReplyTtsEnabled == true; + return ( + config?.FullReplyDocEnabled == true, + config?.FinalReplyDocEnabled == true, + config?.AudioFullReplyDocEnabled == true, + config?.AudioFinalReplyDocEnabled == true, + config?.ReferencedMarkdownDocImportEnabled == true); } private async Task ResolveEffectiveOptionsAsync(string? username, string? appId = null) @@ -3585,7 +4393,13 @@ private static string GetProjectStatusLabel(ProjectInfo project) }; } - private ElementsCardV2Dto BuildCreateSessionFormCard(string chatKey, List directories, List availableTools, string? selectedToolId) + private ElementsCardV2Dto BuildCreateSessionFormCard( + string chatKey, + List directories, + List availableTools, + string? selectedToolId, + bool showAllSessions = false, + int? sessionPage = null) { var effectiveToolId = NormalizeToolId(selectedToolId) ?? ResolveDefaultToolId(); var ownedDirectories = directories @@ -3643,7 +4457,9 @@ private ElementsCardV2Dto BuildCreateSessionFormCard(string chatKey, List ResolveLatestAssistantContentForCardRefreshAsync( return historyContent.Trim(); } - return BuildCompletionNotificationText(sessionId, fallbackWorkspacePath); + return await BuildCompletionNotificationTextAsync(sessionId, toolId, fallbackWorkspacePath); + } + + private async Task TryResolveLatestCompletedAssistantReplyContentAsync( + string sessionId, + string toolId) + { + var latestAssistantContent = _chatSessionService.GetMessages(sessionId) + .Where(message => + message.IsCompleted + && string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(message.Content)) + .Select(message => message.Content) + .LastOrDefault(); + if (!string.IsNullOrWhiteSpace(latestAssistantContent)) + { + return latestAssistantContent.Trim(); + } + + return await TryGetLatestAssistantMessageFromExternalHistoryAsync( + sessionId, + toolId, + expectedUserPrompt: null, + CancellationToken.None); } private static string BuildSessionOptionText(ChatSessionEntity session) @@ -4284,22 +5128,117 @@ private static string BuildSessionOptionText(ChatSessionEntity session) return $"{goalRuntimePrefix}{workspaceName} · {sessionLabel} · {GetToolDisplayName(session.ToolId)}"; } - private string BuildCompletionNotificationText(string sessionId, string? fallbackWorkspacePath = null) + private async Task BuildCompletionPresentationAsync( + string sessionId, + string toolId, + string baseStatusMarkdown, + string? fallbackWorkspacePath = null) + { + ChatSessionEntity? session; + using (var scope = _serviceProvider.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + session = await repo.GetByIdAsync(sessionId); + } + + var goal = await TryGetGoalRuntimeGoalAsync(sessionId, toolId, session); + return new CompletionPresentation( + BuildCompletionStatusMarkdown(baseStatusMarkdown, session, goal), + BuildCompletionNotificationText(sessionId, session, goal, fallbackWorkspacePath)); + } + + private async Task BuildCompletionNotificationTextAsync( + string sessionId, + string toolId, + string? fallbackWorkspacePath = null) { ChatSessionEntity? session; using (var scope = _serviceProvider.CreateScope()) { var repo = scope.ServiceProvider.GetRequiredService(); - session = repo.GetByIdAsync(sessionId).GetAwaiter().GetResult(); + session = await repo.GetByIdAsync(sessionId); + } + + var goal = await TryGetGoalRuntimeGoalAsync(sessionId, toolId, session); + return BuildCompletionNotificationText(sessionId, session, goal, fallbackWorkspacePath); + } + + private async Task TryGetGoalRuntimeGoalAsync( + string sessionId, + string toolId, + ChatSessionEntity? session) + { + if (!IsGoalRuntimeSession(session)) + { + return null; + } + + try + { + return await _cliExecutor.TryGetGoalRuntimeGoalAsync(sessionId, toolId); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "读取 Goal runtime 完成态失败: Session={SessionId}", sessionId); + return null; } + } + private string BuildCompletionNotificationText( + string sessionId, + ChatSessionEntity? session, + AppServerGoalSnapshot? goal, + string? fallbackWorkspacePath = null) + { var workspaceName = GetSessionWorkspaceName(sessionId, fallbackWorkspacePath); var sessionLabel = GetSessionDisplayLabel(session); + var summaryLine = BuildCompletionSummaryLine(session, goal); return BuildSessionStatusMarkdown( - $"当前会话:{workspaceName} {sessionLabel}\n已完成", + $"当前会话:{workspaceName} {sessionLabel}\n{summaryLine}", session); } + private static string BuildCompletionStatusMarkdown( + string baseStatusMarkdown, + ChatSessionEntity? session, + AppServerGoalSnapshot? goal) + { + if (!IsGoalRuntimeSession(session)) + { + return FeishuStreamingStatusFormatter.WithCompletedState(baseStatusMarkdown); + } + + return NormalizeGoalRuntimeStatus(goal?.Status) switch + { + "active" => GoalRuntimeCompletionStateFormatter.WithGoalContinuingState(baseStatusMarkdown), + "paused" => GoalRuntimeCompletionStateFormatter.WithGoalPausedState(baseStatusMarkdown), + "complete" => FeishuStreamingStatusFormatter.WithCompletedState(baseStatusMarkdown), + _ => GoalRuntimeCompletionStateFormatter.WithTurnFinishedState(baseStatusMarkdown) + }; + } + + private static string BuildCompletionSummaryLine(ChatSessionEntity? session, AppServerGoalSnapshot? goal) + { + if (!IsGoalRuntimeSession(session)) + { + return "已完成"; + } + + return NormalizeGoalRuntimeStatus(goal?.Status) switch + { + "active" => "本轮执行已结束,Goal 仍在运行", + "paused" => "Goal 已暂停", + "complete" => "Goal 已完成", + "budgetlimited" => "Goal 已达到预算上限", + _ => "本轮执行已结束" + }; + } + + private static string? NormalizeGoalRuntimeStatus(string? status) + => status?.Trim().ToLowerInvariant(); + + private sealed record CompletionPresentation(string StatusMarkdown, string NotificationText); + private static string? ExtractWorkspaceDirectoryName(string? workspacePath) { if (string.IsNullOrWhiteSpace(workspacePath)) @@ -4339,7 +5278,7 @@ private static string GetSessionDisplayLabel(ChatSessionEntity? session) /// /// 处理打开会话管理器动作 /// - private async Task HandleOpenSessionManagerAsync(string? chatId, string? operatorUserId, bool showAllSessions = false) + private async Task HandleOpenSessionManagerAsync(string? chatId, string? operatorUserId, bool showAllSessions = false, int? sessionPage = null) { if (string.IsNullOrEmpty(chatId)) { @@ -4348,7 +5287,7 @@ private async Task HandleOpenSessionManagerAsync(s try { - var card = await BuildSessionManagerCardAsync(chatId, operatorUserId, showAllSessions: showAllSessions); + var card = await BuildSessionManagerCardAsync(chatId, operatorUserId, showAllSessions: showAllSessions, sessionPage: sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, ""); } catch (Exception ex) @@ -4358,7 +5297,7 @@ private async Task HandleOpenSessionManagerAsync(s } } - private async Task HandleOpenSessionManagerAsNewCardAsync(string? chatId, string? operatorUserId, string? appId, bool showAllSessions = false) + private async Task HandleOpenSessionManagerAsNewCardAsync(string? chatId, string? operatorUserId, string? appId, bool showAllSessions = false, int? sessionPage = null) { if (string.IsNullOrEmpty(chatId)) { @@ -4374,7 +5313,7 @@ private async Task HandleOpenSessionManagerAsNewCa return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 请先绑定 Web 用户,再管理会话", "error"); } - var card = await BuildSessionManagerCardAsync(normalizedChatId, operatorUserId, showAllSessions: showAllSessions); + var card = await BuildSessionManagerCardAsync(normalizedChatId, operatorUserId, showAllSessions: showAllSessions, sessionPage: sessionPage); await SendElementsCardToChatAsync( normalizedChatId, card, @@ -4391,7 +5330,7 @@ await SendElementsCardToChatAsync( } } - public async Task BuildSessionManagerCardAsync(string chatId, string? operatorUserId, string? fallbackUsername = null, bool showAllSessions = false) + public async Task BuildSessionManagerCardAsync(string chatId, string? operatorUserId, string? fallbackUsername = null, bool showAllSessions = false, int? sessionPage = null) { var chatKey = chatId.ToLowerInvariant(); var username = string.IsNullOrWhiteSpace(fallbackUsername) @@ -4406,14 +5345,21 @@ public async Task BuildSessionManagerCardAsync(string chatId, var sessions = sessionEntities.Select(s => s.SessionId).ToList(); var currentSessionId = _feishuChannel.GetCurrentSession(chatKey, username); var goalRuntimeSessionCount = sessionEntities.Count(IsGoalRuntimeSession); + var totalPages = Math.Max(1, (int)Math.Ceiling(sessionEntities.Count / (double)SessionManagerPageSize)); + var normalizedSessionPage = showAllSessions + ? Math.Clamp(sessionPage ?? 0, 0, totalPages - 1) + : 0; var visibleSessions = showAllSessions ? sessionEntities + .Skip(normalizedSessionPage * SessionManagerPageSize) + .Take(SessionManagerPageSize) + .ToList() : sessionEntities.Take(SessionManagerDefaultVisibleCount).ToList(); var elements = new List(); var foldHint = sessionEntities.Count > SessionManagerDefaultVisibleCount ? showAllSessions - ? $"当前展示全部 **{sessionEntities.Count}** 个会话。" + ? $"当前展示第 **{normalizedSessionPage + 1}/{totalPages}** 页,共 **{sessionEntities.Count}** 个会话。" : $"当前默认展示最近 **{SessionManagerDefaultVisibleCount}** 个会话,可点击“更多会话”展开。" : string.Empty; @@ -4449,7 +5395,9 @@ public async Task BuildSessionManagerCardAsync(string chatId, value = new { action = "browse_current_session_directory", - chat_key = chatKey + chat_key = chatKey, + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage } } } @@ -4491,7 +5439,8 @@ public async Task BuildSessionManagerCardAsync(string chatId, action = "switch_session", session_id = sessionId, chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage }), BuildActionButton( "重命名", @@ -4501,7 +5450,8 @@ public async Task BuildSessionManagerCardAsync(string chatId, action = "show_rename_session_form", session_id = sessionId, chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage }) }; @@ -4515,7 +5465,8 @@ public async Task BuildSessionManagerCardAsync(string chatId, action = "show_session_launch_settings_form", session_id = sessionId, chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage })); actions.Add(BuildActionButton( "同步 Provider", @@ -4525,7 +5476,8 @@ public async Task BuildSessionManagerCardAsync(string chatId, action = "sync_session_provider", session_id = sessionId, chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage })); } @@ -4564,7 +5516,8 @@ public async Task BuildSessionManagerCardAsync(string chatId, action = "close_session", session_id = sessionId, chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage })).ToArray() } } @@ -4577,6 +5530,7 @@ public async Task BuildSessionManagerCardAsync(string chatId, chatKey, effectiveToolId, showAllSessions, + normalizedSessionPage, showTemporaryExitGoalRuntimeAction)); } @@ -4594,6 +5548,70 @@ public async Task BuildSessionManagerCardAsync(string chatId, if (sessionEntities.Count > SessionManagerDefaultVisibleCount) { + if (showAllSessions && totalPages > 1) + { + var paginationColumns = new List(); + + if (normalizedSessionPage > 0) + { + paginationColumns.Add(new + { + tag = "column", + width = "weighted", + weight = 1, + vertical_align = "top", + elements = new object[] + { + BuildActionButton( + "上一页", + "default", + new + { + action = "open_session_manager", + chat_key = chatKey, + show_all_sessions = true, + session_page = normalizedSessionPage - 1 + }) + } + }); + } + + if (normalizedSessionPage < totalPages - 1) + { + paginationColumns.Add(new + { + tag = "column", + width = "weighted", + weight = 1, + vertical_align = "top", + elements = new object[] + { + BuildActionButton( + "下一页", + "default", + new + { + action = "open_session_manager", + chat_key = chatKey, + show_all_sessions = true, + session_page = normalizedSessionPage + 1 + }) + } + }); + } + + if (paginationColumns.Count > 0) + { + elements.Add(new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = paginationColumns.ToArray() + }); + } + } + elements.Add(BuildActionButton( showAllSessions ? "收起" : "更多会话", "default", @@ -4601,7 +5619,8 @@ public async Task BuildSessionManagerCardAsync(string chatId, { action = "open_session_manager", chat_key = chatKey, - show_all_sessions = !showAllSessions + show_all_sessions = !showAllSessions, + session_page = showAllSessions ? 0 : normalizedSessionPage })); elements.Add(new { tag = "hr" }); } @@ -4621,7 +5640,9 @@ public async Task BuildSessionManagerCardAsync(string chatId, value = new { action = "open_project_manager", - chat_key = chatKey + chat_key = chatKey, + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage } } } @@ -4640,7 +5661,9 @@ public async Task BuildSessionManagerCardAsync(string chatId, value = new { action = "discover_external_cli_sessions", - chat_key = chatKey + chat_key = chatKey, + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage } } } @@ -4659,7 +5682,9 @@ public async Task BuildSessionManagerCardAsync(string chatId, value = new { action = "show_create_session_form", - chat_key = chatKey + chat_key = chatKey, + show_all_sessions = showAllSessions, + session_page = normalizedSessionPage } } } @@ -4702,7 +5727,7 @@ public async Task BuildSessionManagerCardAsync(string chatId, }; } - private async Task HandleShowRenameSessionFormAsync(string? sessionId, string? chatKey, string? operatorUserId, bool showAllSessions = false) + private async Task HandleShowRenameSessionFormAsync(string? sessionId, string? chatKey, string? operatorUserId, bool showAllSessions = false, int? sessionPage = null) { if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(chatKey)) { @@ -4724,7 +5749,7 @@ private async Task HandleShowRenameSessionFormAsyn return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 会话不存在或已失效,请重新打开会话管理", "error"); } - return _cardBuilder.BuildCardActionResponseV2(BuildRenameSessionFormCard(actualChatKey, session, showAllSessions), string.Empty); + return _cardBuilder.BuildCardActionResponseV2(BuildRenameSessionFormCard(actualChatKey, session, showAllSessions, sessionPage), string.Empty); } private async Task HandleRenameSessionAsync( @@ -4732,7 +5757,8 @@ private async Task HandleRenameSessionAsync( string? chatKey, JsonElement? formValue, string? operatorUserId, - bool showAllSessions = false) + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(chatKey)) { @@ -4772,11 +5798,11 @@ private async Task HandleRenameSessionAsync( return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 重命名会话失败,请稍后重试", "error"); } - var card = await BuildSessionManagerCardAsync(actualChatKey, operatorUserId, username, showAllSessions); + var card = await BuildSessionManagerCardAsync(actualChatKey, operatorUserId, username, showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, $"✅ 已将会话重命名为 {newTitle}", "success"); } - private ElementsCardV2Dto BuildRenameSessionFormCard(string chatKey, ChatSessionEntity session, bool showAllSessions) + private ElementsCardV2Dto BuildRenameSessionFormCard(string chatKey, ChatSessionEntity session, bool showAllSessions, int? sessionPage) { var currentTitle = GetSessionDisplayTitle(session); var elements = new List @@ -4832,7 +5858,8 @@ private ElementsCardV2Dto BuildRenameSessionFormCard(string chatKey, ChatSession action = "rename_session", session_id = session.SessionId, chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -4851,7 +5878,8 @@ private ElementsCardV2Dto BuildRenameSessionFormCard(string chatKey, ChatSession { action = "open_session_manager", chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }) } } @@ -4884,7 +5912,8 @@ private async Task HandleShowSessionLaunchSettings string? sessionId, string? chatKey, string? operatorUserId, - bool showAllSessions = false) + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(chatKey)) { @@ -4914,7 +5943,7 @@ private async Task HandleShowSessionLaunchSettings var ccSwitchService = scope.ServiceProvider.GetService(); return _cardBuilder.BuildCardActionResponseV2( - await BuildSessionLaunchSettingsFormCardAsync(actualChatKey, session, showAllSessions, ccSwitchService), + await BuildSessionLaunchSettingsFormCardAsync(actualChatKey, session, showAllSessions, sessionPage, ccSwitchService), string.Empty); } @@ -4923,7 +5952,8 @@ private async Task HandleSaveSessionLaunchSettings string? chatKey, JsonElement? formValue, string? operatorUserId, - bool showAllSessions = false) + bool showAllSessions = false, + int? sessionPage = null) { return await PersistSessionLaunchSettingsAsync( sessionId, @@ -4931,6 +5961,7 @@ private async Task HandleSaveSessionLaunchSettings formValue, operatorUserId, showAllSessions, + sessionPage, clearOverride: false); } @@ -4938,7 +5969,8 @@ private async Task HandleClearSessionLaunchSetting string? sessionId, string? chatKey, string? operatorUserId, - bool showAllSessions = false) + bool showAllSessions = false, + int? sessionPage = null) { return await PersistSessionLaunchSettingsAsync( sessionId, @@ -4946,6 +5978,7 @@ private async Task HandleClearSessionLaunchSetting formValue: null, operatorUserId, showAllSessions, + sessionPage, clearOverride: true); } @@ -4955,6 +5988,7 @@ private async Task PersistSessionLaunchSettingsAsy JsonElement? formValue, string? operatorUserId, bool showAllSessions, + int? sessionPage, bool clearOverride) { if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(chatKey)) @@ -5009,7 +6043,7 @@ private async Task PersistSessionLaunchSettingsAsy await _cliExecutor.ResetSessionRuntimeAsync(sessionId, clearCliThreadId: false); - var card = await BuildSessionManagerCardAsync(actualChatKey, operatorUserId, username, showAllSessions); + var card = await BuildSessionManagerCardAsync(actualChatKey, operatorUserId, username, showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2( card, clearOverride @@ -5126,7 +6160,12 @@ private async Task HandleSwitchStreamingCardOverri var refreshedSession = await repo.GetByIdAndUsernameAsync(sessionId, username) ?? session; var (chrome, baseStatusMarkdown) = await BuildStreamingCardChromeAsync(actualChatKey, sessionId, username, effectiveToolId); - chrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithCompletedState(baseStatusMarkdown); + var completionPresentation = await BuildCompletionPresentationAsync( + sessionId, + effectiveToolId, + baseStatusMarkdown, + refreshedSession.WorkspacePath); + chrome.StatusMarkdown = completionPresentation.StatusMarkdown; SetTopChipGroupsEnabled(chrome, true); var latestAssistantContent = _chatSessionService.GetMessages(sessionId) @@ -5135,7 +6174,7 @@ private async Task HandleSwitchStreamingCardOverri .LastOrDefault(); if (string.IsNullOrWhiteSpace(latestAssistantContent)) { - latestAssistantContent = BuildCompletionNotificationText(sessionId, refreshedSession.WorkspacePath); + latestAssistantContent = completionPresentation.NotificationText; } var card = BuildStreamingCardRefreshCard(latestAssistantContent, chrome); @@ -5164,6 +6203,7 @@ private async Task BuildSessionLaunchSettingsFormCardAsync( string chatKey, ChatSessionEntity session, bool showAllSessions, + int? sessionPage, ICcSwitchService? ccSwitchService) { var effectiveToolId = SessionLaunchOverrideHelper.ResolveEffectiveToolId(session.ToolId, session.CcSwitchSnapshotToolId); @@ -5220,7 +6260,8 @@ private async Task BuildSessionLaunchSettingsFormCardAsync( action = "save_session_launch_settings", session_id = session.SessionId, chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -5240,7 +6281,8 @@ private async Task BuildSessionLaunchSettingsFormCardAsync( action = "clear_session_launch_settings", session_id = session.SessionId, chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }) } }, @@ -5258,7 +6300,8 @@ private async Task BuildSessionLaunchSettingsFormCardAsync( { action = "open_session_manager", chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }) } } @@ -5532,6 +6575,7 @@ private static IEnumerable BuildGoalRuntimeSessionActionRows( string chatKey, string? toolId, bool showAllSessions, + int sessionPage, bool showTemporaryExitAction) { yield return BuildGoalRuntimeSessionActionRow( @@ -5545,7 +6589,8 @@ private static IEnumerable BuildGoalRuntimeSessionActionRows( session_id = sessionId, chat_key = chatKey, tool_id = toolId, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage })), BuildGoalRuntimeSessionActionColumn( BuildActionButton( @@ -5557,7 +6602,8 @@ private static IEnumerable BuildGoalRuntimeSessionActionRows( session_id = sessionId, chat_key = chatKey, tool_id = toolId, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage }))); yield return BuildGoalRuntimeSessionActionRow( @@ -5571,7 +6617,8 @@ private static IEnumerable BuildGoalRuntimeSessionActionRows( session_id = sessionId, chat_key = chatKey, tool_id = toolId, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage })), BuildGoalRuntimeSessionActionColumn( BuildActionButton( @@ -5583,7 +6630,8 @@ private static IEnumerable BuildGoalRuntimeSessionActionRows( session_id = sessionId, chat_key = chatKey, tool_id = toolId, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage }))); if (!showTemporaryExitAction) @@ -5602,7 +6650,8 @@ private static IEnumerable BuildGoalRuntimeSessionActionRows( session_id = sessionId, chat_key = chatKey, tool_id = toolId, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage }))); } @@ -5634,7 +6683,9 @@ private async Task HandleDiscoverExternalCliSessio string? chatId, string? toolId, int? page, - string? operatorUserId) + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatKey) && string.IsNullOrWhiteSpace(chatId)) { @@ -5712,7 +6763,9 @@ private async Task HandleDiscoverExternalCliSessio action = "discover_external_cli_sessions", chat_key = actualChatKey, tool_id = value, - page = 0 + page = 0, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -5762,7 +6815,9 @@ private async Task HandleDiscoverExternalCliSessio { action = "switch_session", session_id = item.ImportedSessionId, - chat_key = actualChatKey + chat_key = actualChatKey, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -5787,7 +6842,9 @@ private async Task HandleDiscoverExternalCliSessio tool_id = item.ToolId, cli_thread_id = item.CliThreadId, title = item.Title, - workspace_path = item.WorkspacePath + workspace_path = item.WorkspacePath, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -5827,7 +6884,9 @@ private async Task HandleDiscoverExternalCliSessio action = "discover_external_cli_sessions", chat_key = actualChatKey, tool_id = normalizedToolId, - page = safePageIndex - 1 + page = safePageIndex - 1, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -5851,7 +6910,9 @@ private async Task HandleDiscoverExternalCliSessio action = "discover_external_cli_sessions", chat_key = actualChatKey, tool_id = normalizedToolId, - page = safePageIndex + 1 + page = safePageIndex + 1, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -5872,7 +6933,9 @@ private async Task HandleDiscoverExternalCliSessio value = new { action = "open_session_manager", - chat_key = actualChatKey + chat_key = actualChatKey, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -5913,7 +6976,9 @@ private async Task HandleImportExternalCliSessionA string? title, string? workspacePath, string? operatorUserId, - string? appId) + string? appId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatKey) && string.IsNullOrWhiteSpace(chatId)) { @@ -5967,7 +7032,7 @@ await _feishuChannel.SendMessageAsync( _logger.LogDebug(sendEx, "[Feishu] 发送导入完成提示失败(可忽略)"); } - var response = await HandleOpenSessionManagerAsync(actualChatKey, operatorUserId); + var response = await HandleOpenSessionManagerAsync(actualChatKey, operatorUserId, showAllSessions, sessionPage); response.Toast = new CardActionTriggerResponseDto.ToastSuffix { Content = "✅ 已导入本地会话,并切换为当前会话", @@ -5982,16 +7047,24 @@ await _feishuChannel.SendMessageAsync( } } - public async Task BuildProjectManagerCardAsync(string chatId, string? operatorUserId) + public async Task BuildProjectManagerCardAsync( + string chatId, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { var actualChatKey = NormalizeChatKey(chatId); using var projectScope = CreateProjectScopeContext(actualChatKey, operatorUserId) ?? throw new InvalidOperationException("请先绑定 Web 用户,再管理项目"); var projects = await projectScope.ProjectService.GetProjectsAsync(); - return BuildProjectManagerCard(actualChatKey, projects); + return BuildProjectManagerCard(actualChatKey, projects, showAllSessions, sessionPage); } - private async Task HandleOpenProjectManagerAsync(string? chatId, string? operatorUserId) + private async Task HandleOpenProjectManagerAsync( + string? chatId, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatId)) { @@ -6000,7 +7073,7 @@ private async Task HandleOpenProjectManagerAsync(s try { - var card = await BuildProjectManagerCardAsync(chatId, operatorUserId); + var card = await BuildProjectManagerCardAsync(chatId, operatorUserId, showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, string.Empty); } catch (InvalidOperationException ex) @@ -6014,7 +7087,10 @@ private async Task HandleOpenProjectManagerAsync(s } } - private Task HandleShowCreateProjectFormAsync(string? chatId) + private Task HandleShowCreateProjectFormAsync( + string? chatId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatId)) { @@ -6022,11 +7098,16 @@ private Task HandleShowCreateProjectFormAsync(stri } var actualChatKey = NormalizeChatKey(chatId); - var card = BuildProjectFormCard(actualChatKey, null, null, null, null); + var card = BuildProjectFormCard(actualChatKey, null, null, null, null, showAllSessions, sessionPage); return Task.FromResult(_cardBuilder.BuildCardActionResponseV2(card, string.Empty)); } - private async Task HandleShowEditProjectFormAsync(string? chatId, string? projectId, string? operatorUserId) + private async Task HandleShowEditProjectFormAsync( + string? chatId, + string? projectId, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatId) || string.IsNullOrWhiteSpace(projectId)) { @@ -6046,11 +7127,16 @@ private async Task HandleShowEditProjectFormAsync( return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 项目不存在或已被删除", "error"); } - var card = BuildProjectFormCard(actualChatKey, project, null, null, "密码或 Token 留空则保持现有值。"); + var card = BuildProjectFormCard(actualChatKey, project, null, null, "密码或 Token 留空则保持现有值。", showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, string.Empty); } - private async Task HandleCreateProjectAsync(string? chatId, JsonElement? formValue, string? operatorUserId) + private async Task HandleCreateProjectAsync( + string? chatId, + JsonElement? formValue, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatId)) { @@ -6073,15 +7159,21 @@ private async Task HandleCreateProjectAsync(string var (project, errorMessage) = await projectScope.ProjectService.CreateProjectAsync(request); if (project == null) { - var card = BuildProjectFormCard(actualChatKey, null, request, null, errorMessage); + var card = BuildProjectFormCard(actualChatKey, null, request, null, errorMessage, showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, errorMessage ?? "创建项目失败", "error"); } - var managerCard = await BuildProjectManagerCardAsync(actualChatKey, operatorUserId); + var managerCard = await BuildProjectManagerCardAsync(actualChatKey, operatorUserId, showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(managerCard, "✅ 项目已保存,可继续克隆或创建会话", "success"); } - private async Task HandleUpdateProjectAsync(string? chatId, string? projectId, JsonElement? formValue, string? operatorUserId) + private async Task HandleUpdateProjectAsync( + string? chatId, + string? projectId, + JsonElement? formValue, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatId) || string.IsNullOrWhiteSpace(projectId)) { @@ -6106,11 +7198,11 @@ private async Task HandleUpdateProjectAsync(string if (!success) { var requestState = BuildProjectRequestFromForm(formValue); - var card = BuildProjectFormCard(actualChatKey, existingProject, requestState, null, errorMessage ?? "更新项目失败"); + var card = BuildProjectFormCard(actualChatKey, existingProject, requestState, null, errorMessage ?? "更新项目失败", showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(card, errorMessage ?? "更新项目失败", "error"); } - var managerCard = await BuildProjectManagerCardAsync(actualChatKey, operatorUserId); + var managerCard = await BuildProjectManagerCardAsync(actualChatKey, operatorUserId, showAllSessions, sessionPage); return _cardBuilder.BuildCardActionResponseV2(managerCard, "✅ 项目配置已更新", "success"); } @@ -6126,7 +7218,13 @@ private Task HandleDeleteProjectAsync(string? chat return Task.FromResult(_cardBuilder.BuildCardActionToastOnlyResponse("🚀 已开始后台删除项目,完成后会发送结果", "info")); } - private async Task HandleFetchProjectBranchesAsync(string? chatId, string? projectId, JsonElement? formValue, string? operatorUserId) + private async Task HandleFetchProjectBranchesAsync( + string? chatId, + string? projectId, + JsonElement? formValue, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatId)) { @@ -6165,7 +7263,7 @@ private async Task HandleFetchProjectBranchesAsync : $"已获取 {branches.Count} 个分支,可从下方列表复制后填写到分支字段。"; } - var card = BuildProjectFormCard(actualChatKey, project, formState, branches, helperText); + var card = BuildProjectFormCard(actualChatKey, project, formState, branches, helperText, showAllSessions, sessionPage); var toastType = string.IsNullOrWhiteSpace(errorMessage) ? "info" : "warning"; var toastMessage = string.IsNullOrWhiteSpace(errorMessage) ? "🔄 已刷新远程分支列表" : $"⚠️ {errorMessage}"; return _cardBuilder.BuildCardActionResponseV2(card, toastMessage, toastType); @@ -6254,7 +7352,9 @@ private Task HandleShowProjectBranchSwitcherAsync( string? projectId, int? page, string? operatorUserId, - string? appId) + string? appId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatId) || string.IsNullOrWhiteSpace(projectId)) { @@ -6262,7 +7362,7 @@ private Task HandleShowProjectBranchSwitcherAsync( } var actualChatKey = NormalizeChatKey(chatId); - _ = Task.Run(() => SendProjectBranchSwitcherCardAsync(actualChatKey, projectId, page, operatorUserId, appId)); + _ = Task.Run(() => SendProjectBranchSwitcherCardAsync(actualChatKey, projectId, page, operatorUserId, appId, showAllSessions, sessionPage)); return Task.FromResult(_cardBuilder.BuildCardActionToastOnlyResponse("🚀 已开始后台加载分支列表,完成后会发送卡片", "info")); } @@ -6272,7 +7372,9 @@ private Task HandleSwitchProjectBranchAsync( string? branch, int? page, string? operatorUserId, - string? appId) + string? appId, + bool showAllSessions = false, + int? sessionPage = null) { if (string.IsNullOrWhiteSpace(chatId) || string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(branch)) { @@ -6280,11 +7382,18 @@ private Task HandleSwitchProjectBranchAsync( } var actualChatKey = NormalizeChatKey(chatId); - _ = Task.Run(() => SwitchProjectBranchInBackgroundAsync(actualChatKey, projectId, branch, page, operatorUserId, appId)); + _ = Task.Run(() => SwitchProjectBranchInBackgroundAsync(actualChatKey, projectId, branch, page, operatorUserId, appId, showAllSessions, sessionPage)); return Task.FromResult(_cardBuilder.BuildCardActionToastOnlyResponse($"🚀 已开始后台切换到分支 {branch}", "info")); } - private async Task SendProjectBranchSwitcherCardAsync(string chatKey, string projectId, int? page, string? operatorUserId, string? appId) + private async Task SendProjectBranchSwitcherCardAsync( + string chatKey, + string projectId, + int? page, + string? operatorUserId, + string? appId, + bool showAllSessions = false, + int? sessionPage = null) { string? notificationUsername = null; try @@ -6305,7 +7414,7 @@ private async Task SendProjectBranchSwitcherCardAsync(string chatKey, string pro } var (branches, errorMessage) = await projectScope.ProjectService.GetProjectBranchesAsync(projectId); - var card = BuildProjectBranchSwitcherCard(chatKey, project, branches, errorMessage, page ?? 0); + var card = BuildProjectBranchSwitcherCard(chatKey, project, branches, errorMessage, page ?? 0, showAllSessions, sessionPage); await SendElementsCardToChatAsync(chatKey, card, "❌ 分支列表加载完成,但发送卡片失败", notificationUsername, appId); } catch (Exception ex) @@ -6321,7 +7430,9 @@ private async Task SwitchProjectBranchInBackgroundAsync( string branch, int? page, string? operatorUserId, - string? appId) + string? appId, + bool showAllSessions = false, + int? sessionPage = null) { string? notificationUsername = null; try @@ -6354,12 +7465,12 @@ private async Task SwitchProjectBranchInBackgroundAsync( : $"{helperText};{branchErrorMessage}"; } - var retryCard = BuildProjectBranchSwitcherCard(chatKey, latestProject, branches, helperText, page ?? 0); + var retryCard = BuildProjectBranchSwitcherCard(chatKey, latestProject, branches, helperText, page ?? 0, showAllSessions, sessionPage); await SendElementsCardToChatAsync(chatKey, retryCard, $"❌ 切换分支失败:{errorMessage ?? "未知错误"}", notificationUsername, appId); return; } - var managerCard = await BuildProjectManagerCardAsync(chatKey, operatorUserId); + var managerCard = await BuildProjectManagerCardAsync(chatKey, operatorUserId, showAllSessions, sessionPage); await SendElementsCardToChatAsync(chatKey, managerCard, $"✅ 已切换到分支 {branch}", notificationUsername, appId); } catch (Exception ex) @@ -6441,7 +7552,11 @@ private async Task HandleCreateSessionFromProjectA "success"); } - private ElementsCardV2Dto BuildProjectManagerCard(string chatKey, List projects) + private ElementsCardV2Dto BuildProjectManagerCard( + string chatKey, + List projects, + bool showAllSessions = false, + int? sessionPage = null) { var elements = new List { @@ -6461,7 +7576,9 @@ private ElementsCardV2Dto BuildProjectManagerCard(string chatKey, List branches, string? helperText, - int pageIndex) + int pageIndex, + bool showAllSessions = false, + int? sessionPage = null) { var normalizedBranches = branches .Where(branch => !string.IsNullOrWhiteSpace(branch)) @@ -6714,7 +7836,9 @@ private ElementsCardV2Dto BuildProjectBranchSwitcherCard( chat_key = chatKey, project_id = project.ProjectId, branch, - page = safePageIndex + page = safePageIndex, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }) } } @@ -6750,7 +7874,9 @@ private ElementsCardV2Dto BuildProjectBranchSwitcherCard( action = "show_project_branch_switcher", chat_key = chatKey, project_id = project.ProjectId, - page = safePageIndex - 1 + page = safePageIndex - 1, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }) } : new object[] @@ -6798,7 +7924,9 @@ private ElementsCardV2Dto BuildProjectBranchSwitcherCard( action = "show_project_branch_switcher", chat_key = chatKey, project_id = project.ProjectId, - page = safePageIndex + 1 + page = safePageIndex + 1, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }) } : new object[] @@ -6821,7 +7949,9 @@ private ElementsCardV2Dto BuildProjectBranchSwitcherCard( new { action = "open_project_manager", - chat_key = chatKey + chat_key = chatKey, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); return new ElementsCardV2Dto @@ -6848,7 +7978,9 @@ private ElementsCardV2Dto BuildProjectFormCard( ProjectInfo? project, CreateProjectRequest? formState, List? branchSuggestions, - string? helperText) + string? helperText, + bool showAllSessions = false, + int? sessionPage = null) { var effectiveState = formState ?? new CreateProjectRequest { @@ -6980,7 +8112,9 @@ private ElementsCardV2Dto BuildProjectFormCard( { action = "fetch_project_branches", chat_key = chatKey, - project_id = project?.ProjectId + project_id = project?.ProjectId, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -7003,7 +8137,9 @@ private ElementsCardV2Dto BuildProjectFormCard( { action = actionName, chat_key = chatKey, - project_id = project?.ProjectId + project_id = project?.ProjectId, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -7028,7 +8164,9 @@ private ElementsCardV2Dto BuildProjectFormCard( value = new { action = "open_project_manager", - chat_key = chatKey + chat_key = chatKey, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } } } @@ -7074,7 +8212,12 @@ private ElementsCardV2Dto BuildProjectFormCard( }; } - private Task HandleBrowseCurrentSessionDirectoryAsync(string? chatKey, string? chatId, string? operatorUserId) + private Task HandleBrowseCurrentSessionDirectoryAsync( + string? chatKey, + string? chatId, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { var actualChatKey = !string.IsNullOrWhiteSpace(chatKey) ? NormalizeChatKey(chatKey) @@ -7097,10 +8240,17 @@ private Task HandleBrowseCurrentSessionDirectoryAs return Task.FromResult(_cardBuilder.BuildCardActionToastOnlyResponse("⚠️ 当前没有活跃会话,请先切换或创建会话", "warning")); } - return HandleBrowseSessionDirectoryAsync(currentSessionId, actualChatKey, null, 0, operatorUserId); + return HandleBrowseSessionDirectoryAsync(currentSessionId, actualChatKey, null, 0, operatorUserId, showAllSessions, sessionPage); } - private Task HandleBrowseSessionDirectoryAsync(string? sessionId, string? chatKey, string? directoryPath, int? page, string? operatorUserId) + private Task HandleBrowseSessionDirectoryAsync( + string? sessionId, + string? chatKey, + string? directoryPath, + int? page, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { if (!TryResolveActiveSessionContext(sessionId, chatKey, operatorUserId, out var actualChatKey, out var activeSessionId, out var errorResponse)) { @@ -7138,7 +8288,9 @@ private Task HandleBrowseSessionDirectoryAsync(str pageIndex, totalPages, entries.Count, - pagedEntries); + pagedEntries, + showAllSessions, + sessionPage); return Task.FromResult(_cardBuilder.BuildCardActionResponseV2(card, string.Empty)); } @@ -7154,7 +8306,15 @@ private Task HandleBrowseSessionDirectoryAsync(str } } - private Task HandlePreviewSessionFileAsync(string? sessionId, string? chatKey, string? filePath, string? directoryPath, int? page, string? operatorUserId) + private Task HandlePreviewSessionFileAsync( + string? sessionId, + string? chatKey, + string? filePath, + string? directoryPath, + int? page, + string? operatorUserId, + bool showAllSessions = false, + int? sessionPage = null) { if (!TryResolveActiveSessionContext(sessionId, chatKey, operatorUserId, out var actualChatKey, out var activeSessionId, out var errorResponse)) { @@ -7194,7 +8354,9 @@ private Task HandlePreviewSessionFileAsync(string? normalizedFilePath, NormalizeWorkspaceRelativePath(directoryPath), Math.Max(page ?? 0, 0), - fileBytes); + fileBytes, + showAllSessions, + sessionPage); return Task.FromResult(_cardBuilder.BuildCardActionResponseV2(card, string.Empty)); } @@ -7257,7 +8419,9 @@ private ElementsCardV2Dto BuildAllowedDirectoryCard( string chatKey, string toolId, AllowedDirectoryBrowseResult browseResult, - int pageIndex) + int pageIndex, + bool showAllSessions = false, + int? sessionPage = null) { var elements = new List { @@ -7380,7 +8544,9 @@ private ElementsCardV2Dto BuildAllowedDirectoryCard( action = "browse_allowed_directory", chat_key = chatKey, workspace_path = browseResult.ParentPath, - tool_id = toolId + tool_id = toolId, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); } else @@ -7392,7 +8558,9 @@ private ElementsCardV2Dto BuildAllowedDirectoryCard( { action = "browse_allowed_directory", chat_key = chatKey, - tool_id = toolId + tool_id = toolId, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); } @@ -7407,7 +8575,9 @@ private ElementsCardV2Dto BuildAllowedDirectoryCard( chat_key = chatKey, workspace_path = browseResult.CurrentPath, page = clampedPageIndex - 1, - tool_id = toolId + tool_id = toolId, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); } @@ -7422,7 +8592,9 @@ private ElementsCardV2Dto BuildAllowedDirectoryCard( chat_key = chatKey, workspace_path = browseResult.CurrentPath, page = clampedPageIndex + 1, - tool_id = toolId + tool_id = toolId, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); } } @@ -7435,7 +8607,9 @@ private ElementsCardV2Dto BuildAllowedDirectoryCard( { action = "show_create_session_form", chat_key = chatKey, - tool_id = toolId + tool_id = toolId, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); return new ElementsCardV2Dto @@ -7465,7 +8639,9 @@ private ElementsCardV2Dto BuildSessionDirectoryCard( int pageIndex, int totalPages, int totalEntries, - List entries) + List entries, + bool showAllSessions = false, + int? sessionPage = null) { var displayPath = GetDirectoryDisplayPath(directoryPath); var currentDirectoryFullPath = BuildWorkspaceEntryFullPath(workspacePath, directoryPath); @@ -7504,7 +8680,7 @@ private ElementsCardV2Dto BuildSessionDirectoryCard( { foreach (var entry in entries) { - elements.Add(BuildSessionDirectoryEntryRow(entry, chatKey, sessionId, directoryPath, pageIndex)); + elements.Add(BuildSessionDirectoryEntryRow(entry, chatKey, sessionId, directoryPath, pageIndex, showAllSessions, sessionPage)); } } @@ -7520,7 +8696,9 @@ private ElementsCardV2Dto BuildSessionDirectoryCard( chat_key = chatKey, session_id = sessionId, directory_path = parentDirectoryPath, - page = 0 + page = 0, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); } @@ -7535,7 +8713,9 @@ private ElementsCardV2Dto BuildSessionDirectoryCard( chat_key = chatKey, session_id = sessionId, directory_path = directoryPath, - page = pageIndex - 1 + page = pageIndex - 1, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); } @@ -7550,7 +8730,9 @@ private ElementsCardV2Dto BuildSessionDirectoryCard( chat_key = chatKey, session_id = sessionId, directory_path = directoryPath, - page = pageIndex + 1 + page = pageIndex + 1, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); } @@ -7559,7 +8741,10 @@ private ElementsCardV2Dto BuildSessionDirectoryCard( "primary", new { - action = "open_session_manager" + action = "open_session_manager", + chat_key = chatKey, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); return new ElementsCardV2Dto @@ -7588,7 +8773,9 @@ private ElementsCardV2Dto BuildSessionFilePreviewCard( string filePath, string directoryPath, int pageIndex, - byte[] fileBytes) + byte[] fileBytes, + bool showAllSessions = false, + int? sessionPage = null) { var fileName = Path.GetFileName(filePath); var elements = new List @@ -7651,7 +8838,9 @@ private ElementsCardV2Dto BuildSessionFilePreviewCard( chat_key = chatKey, session_id = sessionId, directory_path = directoryPath, - page = pageIndex + page = pageIndex, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); elements.Add(BuildActionButton( @@ -7659,7 +8848,10 @@ private ElementsCardV2Dto BuildSessionFilePreviewCard( "primary", new { - action = "open_session_manager" + action = "open_session_manager", + chat_key = chatKey, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 })); return new ElementsCardV2Dto @@ -7744,7 +8936,9 @@ private object BuildSessionDirectoryEntryRow( string chatKey, string sessionId, string directoryPath, - int pageIndex) + int pageIndex, + bool showAllSessions = false, + int? sessionPage = null) { var icon = entry.IsDirectory ? "📁" : "📄"; var meta = entry.IsDirectory @@ -7758,7 +8952,9 @@ private object BuildSessionDirectoryEntryRow( chat_key = chatKey, session_id = sessionId, directory_path = entry.RelativePath, - page = 0 + page = 0, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 } : new { @@ -7767,7 +8963,9 @@ private object BuildSessionDirectoryEntryRow( session_id = sessionId, file_path = entry.RelativePath, directory_path = directoryPath, - page = pageIndex + page = pageIndex, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }; return new diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs index 600f662..350a804 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs @@ -1,13 +1,16 @@ using System.Collections.Concurrent; +using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using FeishuNetSdk.Im.Dtos; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using WebCodeCli.Domain.Common.Extensions; using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Domain.Model.Channels; namespace WebCodeCli.Domain.Domain.Service.Channels; @@ -19,6 +22,13 @@ namespace WebCodeCli.Domain.Domain.Service.Channels; public class FeishuCardKitClient : IFeishuCardKitClient { private const int CardUpdateMaxAttempts = 2; + private const int CardUpdateSequenceConflictCode = 300317; + private const int CardUpdateDuplicateUuidCode = 200770; + private const int CardOverMaxSizeCode = 200860; + private const int CloudDocumentChildrenMaxBatchSize = 50; + private const string ReducedContentNotice = "> 卡片已精简,前文已截断,仅显示最新内容。"; + private const int ReducedReplyTailChars = 5000; + private const int MinimalReplyTailChars = 2400; private readonly FeishuOptions _defaultOptions; private readonly ILogger _logger; private readonly HttpClient _httpClient; @@ -48,7 +58,8 @@ public async Task CreateCardAsync( title ?? effectiveOptions.DefaultCardTitle, cancellationToken, effectiveOptions, - chrome: null); + chrome: null, + new StreamingCardPayloadState()); } public async Task UpdateCardAsync( @@ -66,7 +77,8 @@ public async Task UpdateCardAsync( title: null, cancellationToken, effectiveOptions, - chrome: null); + chrome: null, + new StreamingCardPayloadState()); } public async Task SendCardMessageAsync( @@ -135,52 +147,51 @@ public async Task SendTextMessageAsync( return ExtractMessageId(result, "send text message"); } - public async Task UploadAudioFileAsync( - string filePath, - int durationMs, + public async Task ReplyCardMessageAsync( + string replyMessageId, + string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) { - if (string.IsNullOrWhiteSpace(filePath)) - { - throw new ArgumentException("Audio file path is required.", nameof(filePath)); - } + _logger.LogInformation("馃摛 [FeishuCardKit] ReplyCardMessageAsync: ReplyMessageId={ReplyMessageId}, CardId={CardId}", + replyMessageId, cardId); var effectiveOptions = GetEffectiveOptions(optionsOverride); var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); - using var fileStream = File.OpenRead(filePath); - using var payload = new MultipartFormDataContent + var payload = new { - { new StringContent("opus"), "file_type" }, - { new StringContent(Path.GetFileName(filePath)), "file_name" }, - { new StringContent(durationMs.ToString()), "duration" } + msg_type = "interactive", + content = JsonSerializer.Serialize(new + { + type = "card", + data = new + { + card_id = cardId + } + }) }; - payload.Add(new StreamContent(fileStream), "file", Path.GetFileName(filePath)); - var response = await PostMultipartAsync( - "/open-apis/im/v1/files", + _logger.LogInformation("馃摛 [FeishuCardKit] 鍙戦€?POST 璇锋眰鍒?/open-apis/im/v1/messages/{ReplyMessageId}/reply", replyMessageId); + var response = await PostAsync( + $"/open-apis/im/v1/messages/{replyMessageId}/reply", token, payload, effectiveOptions, cancellationToken); + _logger.LogInformation("馃摛 [FeishuCardKit] 鍝嶅簲鐘舵€佺爜: {StatusCode}", response.StatusCode); var result = await ParseResponseAsync(response, cancellationToken); - EnsureBusinessSuccess(result, "Upload Feishu audio file"); - - if (result.TryGetProperty("data", out var data) && - data.TryGetProperty("file_key", out var fileKeyProp)) - { - return fileKeyProp.GetString() ?? string.Empty; - } - - throw new InvalidOperationException("Failed to upload audio file: invalid response"); + EnsureBusinessSuccess(result, "Reply Feishu card message"); + _logger.LogDebug("馃摛 [FeishuCardKit] 鍝嶅簲鍐呭: {Response}", result); + var messageId = ExtractMessageId(result, "reply card message"); + _logger.LogInformation("鉁?[FeishuCardKit] 鍥炲鎴愬姛, MessageId={MessageId}", messageId); + return messageId; } - public async Task SendAudioMessageAsync( - string chatId, - string fileKey, - int durationMs, + public async Task ReplyTextMessageAsync( + string replyMessageId, + string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) { @@ -189,96 +200,779 @@ public async Task SendAudioMessageAsync( var payload = new { - receive_id = chatId, - msg_type = "audio", + msg_type = "text", content = JsonSerializer.Serialize(new { - file_key = fileKey + text = content }) }; var response = await PostAsync( - "/open-apis/im/v1/messages?receive_id_type=chat_id", + $"/open-apis/im/v1/messages/{replyMessageId}/reply", token, payload, effectiveOptions, cancellationToken); var result = await ParseResponseAsync(response, cancellationToken); - EnsureBusinessSuccess(result, "Send Feishu audio message"); - return ExtractMessageId(result, "send audio message"); + EnsureBusinessSuccess(result, "Reply Feishu text message"); + return ExtractMessageId(result, "reply text message"); } - public async Task ReplyCardMessageAsync( - string replyMessageId, - string cardId, + public async Task CreateCloudDocumentAsync( + string title, CancellationToken cancellationToken = default, - FeishuOptions? optionsOverride = null) + FeishuOptions? optionsOverride = null, + string? folderToken = null) { - _logger.LogInformation("馃摛 [FeishuCardKit] ReplyCardMessageAsync: ReplyMessageId={ReplyMessageId}, CardId={CardId}", - replyMessageId, cardId); + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("Document title is required.", nameof(title)); + } var effectiveOptions = GetEffectiveOptions(optionsOverride); var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + object payload = string.IsNullOrWhiteSpace(folderToken) + ? new + { + title + } + : new + { + title, + folder_token = folderToken.Trim() + }; + var response = await PostAsync( + "/open-apis/docx/v1/documents", + token, + payload, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Create Feishu cloud document"); + + if (result.TryGetProperty("data", out var data) + && data.TryGetProperty("document", out var document) + && document.TryGetProperty("document_id", out var documentIdProp)) + { + var documentId = documentIdProp.GetString() ?? string.Empty; + var rootBlockId = document.TryGetProperty("revision_id", out _) + && document.TryGetProperty("block_id", out var blockIdProp) + ? blockIdProp.GetString() ?? string.Empty + : string.Empty; + + if (string.IsNullOrWhiteSpace(rootBlockId) + && data.TryGetProperty("document_id", out var dataDocumentIdProp)) + { + documentId = string.IsNullOrWhiteSpace(documentId) + ? dataDocumentIdProp.GetString() ?? string.Empty + : documentId; + } + + if (string.IsNullOrWhiteSpace(rootBlockId) + && data.TryGetProperty("document", out var documentObject) + && documentObject.TryGetProperty("root_block_id", out var rootBlockIdProp)) + { + rootBlockId = rootBlockIdProp.GetString() ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new InvalidOperationException("Failed to create Feishu cloud document: missing document_id."); + } + + if (string.IsNullOrWhiteSpace(rootBlockId)) + { + rootBlockId = documentId; + } + + return new FeishuCloudDocumentInfo + { + DocumentId = documentId, + RootBlockId = rootBlockId, + Url = BuildCloudDocumentUrl(documentId) + }; + } + + throw new InvalidOperationException("Failed to create Feishu cloud document: invalid response."); + } + + public async Task AppendCloudDocumentTextAsync( + string documentId, + string blockId, + string text, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new ArgumentException("Document id is required.", nameof(documentId)); + } + + if (string.IsNullOrWhiteSpace(blockId)) + { + throw new ArgumentException("Block id is required.", nameof(blockId)); + } + + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); var payload = new { - msg_type = "interactive", - content = JsonSerializer.Serialize(new + children = new object[] { - type = "card", - data = new + new { - card_id = cardId + block_type = 2, + text = new + { + elements = new object[] + { + new + { + text_run = new + { + content = text, + text_element_style = new { } + } + } + } + } } - }) + }, + index = 0 }; - _logger.LogInformation("馃摛 [FeishuCardKit] 鍙戦€?POST 璇锋眰鍒?/open-apis/im/v1/messages/{ReplyMessageId}/reply", replyMessageId); var response = await PostAsync( - $"/open-apis/im/v1/messages/{replyMessageId}/reply", + $"/open-apis/docx/v1/documents/{Uri.EscapeDataString(documentId)}/blocks/{Uri.EscapeDataString(blockId)}/children", token, payload, effectiveOptions, cancellationToken); - _logger.LogInformation("馃摛 [FeishuCardKit] 鍝嶅簲鐘舵€佺爜: {StatusCode}", response.StatusCode); var result = await ParseResponseAsync(response, cancellationToken); - EnsureBusinessSuccess(result, "Reply Feishu card message"); - _logger.LogDebug("馃摛 [FeishuCardKit] 鍝嶅簲鍐呭: {Response}", result); - var messageId = ExtractMessageId(result, "reply card message"); - _logger.LogInformation("鉁?[FeishuCardKit] 鍥炲鎴愬姛, MessageId={MessageId}", messageId); - return messageId; + EnsureBusinessSuccess(result, "Append Feishu cloud document text"); } - public async Task ReplyTextMessageAsync( - string replyMessageId, - string content, + public async Task SetCloudDocumentTenantReadableAsync( + string documentId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) { + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new ArgumentException("Document id is required.", nameof(documentId)); + } + var effectiveOptions = GetEffectiveOptions(optionsOverride); var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + var payload = new + { + external_access_entity = "open", + security_entity = "anyone_can_view", + comment_entity = "anyone_can_view" + }; + + var response = await PatchAsync( + $"/open-apis/drive/v2/permissions/{Uri.EscapeDataString(documentId)}/public?type=docx", + token, + payload, + effectiveOptions, + cancellationToken); + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Set Feishu cloud document tenant-readable permission"); + } + + public async Task GrantCloudDocumentMemberFullAccessAsync( + string documentId, + string openId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new ArgumentException("Document id is required.", nameof(documentId)); + } + + if (string.IsNullOrWhiteSpace(openId)) + { + throw new ArgumentException("OpenID is required.", nameof(openId)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); var payload = new { - msg_type = "text", - content = JsonSerializer.Serialize(new - { - text = content - }) + member_type = "openid", + member_id = openId.Trim(), + perm = "full_access", + perm_type = "container", + type = "user" }; var response = await PostAsync( - $"/open-apis/im/v1/messages/{replyMessageId}/reply", + $"/open-apis/drive/v1/permissions/{Uri.EscapeDataString(documentId)}/members?type=docx", token, payload, effectiveOptions, cancellationToken); var result = await ParseResponseAsync(response, cancellationToken); - EnsureBusinessSuccess(result, "Reply Feishu text message"); - return ExtractMessageId(result, "reply text message"); + EnsureBusinessSuccess(result, "Grant Feishu cloud document member full-access permission"); + } + + public async Task GrantCloudFolderMemberFullAccessAsync( + string folderToken, + string openId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(folderToken)) + { + throw new ArgumentException("Folder token is required.", nameof(folderToken)); + } + + if (string.IsNullOrWhiteSpace(openId)) + { + throw new ArgumentException("OpenID is required.", nameof(openId)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + var payload = new + { + member_type = "openid", + member_id = openId.Trim(), + perm = "full_access", + perm_type = "container", + type = "user" + }; + + var response = await PostAsync( + $"/open-apis/drive/v1/permissions/{Uri.EscapeDataString(folderToken)}/members?type=folder", + token, + payload, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Grant Feishu cloud folder member full-access permission"); + } + + public async Task EnsureCloudFolderAsync( + string folderName, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(folderName)) + { + throw new ArgumentException("Folder name is required.", nameof(folderName)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + var rootFolderToken = await GetRootFolderTokenAsync(token, effectiveOptions, cancellationToken); + var existingFolderToken = await TryFindFolderTokenByNameAsync( + rootFolderToken, + folderName.Trim(), + token, + effectiveOptions, + cancellationToken); + + if (!string.IsNullOrWhiteSpace(existingFolderToken)) + { + return existingFolderToken; + } + + var createPayload = new + { + folder_token = rootFolderToken, + name = folderName.Trim() + }; + + var createResponse = await PostAsync( + "/open-apis/drive/v1/files/create_folder", + token, + createPayload, + effectiveOptions, + cancellationToken); + + var createResult = await ParseResponseAsync(createResponse, cancellationToken); + EnsureBusinessSuccess(createResult, "Create Feishu cloud folder"); + + if (createResult.TryGetProperty("data", out var createData) + && createData.TryGetProperty("token", out var folderTokenProp) + && !string.IsNullOrWhiteSpace(folderTokenProp.GetString())) + { + return folderTokenProp.GetString()!; + } + + throw new InvalidOperationException("Failed to create Feishu cloud folder: missing token."); + } + + public async Task MoveCloudDocumentToFolderAsync( + string documentId, + string folderToken, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new ArgumentException("Document id is required.", nameof(documentId)); + } + + if (string.IsNullOrWhiteSpace(folderToken)) + { + throw new ArgumentException("Folder token is required.", nameof(folderToken)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + var payload = new + { + folder_token = folderToken.Trim(), + type = "docx" + }; + + var response = await PostAsync( + $"/open-apis/drive/v1/files/{Uri.EscapeDataString(documentId)}/move", + token, + payload, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Move Feishu cloud document to folder"); + } + + public async Task ConvertMarkdownToCloudDocumentBlocksAsync( + string markdown, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(markdown)) + { + throw new ArgumentException("Markdown 内容不能为空。", nameof(markdown)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + var payload = new + { + content_type = "markdown", + content = markdown + }; + + var response = await PostAsync( + "/open-apis/docx/v1/documents/blocks/convert", + token, + payload, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Convert Feishu markdown to cloud document blocks"); + + if (result.TryGetProperty("data", out var data)) + { + return data.Clone(); + } + + throw new InvalidOperationException("Markdown 转换响应缺少 data。"); + } + + public async Task AppendCloudDocumentBlocksAsync( + string documentId, + string blockId, + IReadOnlyCollection blocks, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new ArgumentException("文档 ID 不能为空。", nameof(documentId)); + } + + if (string.IsNullOrWhiteSpace(blockId)) + { + throw new ArgumentException("块 ID 不能为空。", nameof(blockId)); + } + + ArgumentNullException.ThrowIfNull(blocks); + + if (blocks.Count == 0) + { + return; + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + var blockArray = blocks.ToArray(); + + for (var offset = 0; offset < blockArray.Length; offset += CloudDocumentChildrenMaxBatchSize) + { + var payload = new + { + children = blockArray + .Skip(offset) + .Take(CloudDocumentChildrenMaxBatchSize) + .Select(NormalizeCloudDocumentBlockForAppend) + .ToArray(), + index = offset + }; + + var response = await PostAsync( + $"/open-apis/docx/v1/documents/{Uri.EscapeDataString(documentId)}/blocks/{Uri.EscapeDataString(blockId)}/children", + token, + payload, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Append Feishu cloud document blocks"); + } + } + + public async Task> ListCloudDocumentChildBlockIdsAsync( + string documentId, + string blockId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new ArgumentException("文档 ID 不能为空。", nameof(documentId)); + } + + if (string.IsNullOrWhiteSpace(blockId)) + { + throw new ArgumentException("块 ID 不能为空。", nameof(blockId)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + string? pageToken = null; + var blockIds = new List(); + + do + { + var queryBuilder = new StringBuilder( + $"/open-apis/docx/v1/documents/{Uri.EscapeDataString(documentId)}/blocks/{Uri.EscapeDataString(blockId)}/children?page_size=500"); + if (!string.IsNullOrWhiteSpace(pageToken)) + { + queryBuilder.Append("&page_token=").Append(Uri.EscapeDataString(pageToken)); + } + + var response = await GetAsync( + queryBuilder.ToString(), + token, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "List Feishu cloud document child blocks"); + + if (!result.TryGetProperty("data", out var data)) + { + return blockIds; + } + + if (data.TryGetProperty("items", out var items) + && items.ValueKind == JsonValueKind.Array) + { + foreach (var item in items.EnumerateArray()) + { + var childBlockId = item.TryGetProperty("block_id", out var blockIdProp) + ? blockIdProp.GetString() + : null; + + if (!string.IsNullOrWhiteSpace(childBlockId)) + { + blockIds.Add(childBlockId); + } + } + } + + var hasMore = data.TryGetProperty("has_more", out var hasMoreProp) + && hasMoreProp.ValueKind == JsonValueKind.True; + pageToken = hasMore + && data.TryGetProperty("page_token", out var nextPageTokenProp) + ? nextPageTokenProp.GetString() + : null; + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + return blockIds; + } + + public async Task DeleteCloudDocumentChildBlocksAsync( + string documentId, + string blockId, + int startIndex, + int endIndex, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new ArgumentException("文档 ID 不能为空。", nameof(documentId)); + } + + if (string.IsNullOrWhiteSpace(blockId)) + { + throw new ArgumentException("块 ID 不能为空。", nameof(blockId)); + } + + if (startIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(startIndex), "起始索引不能小于 0。"); + } + + if (endIndex < startIndex) + { + throw new ArgumentOutOfRangeException(nameof(endIndex), "结束索引不能小于起始索引。"); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + var payload = new + { + start_index = startIndex, + end_index = endIndex + }; + + var response = await DeleteAsync( + $"/open-apis/docx/v1/documents/{Uri.EscapeDataString(documentId)}/blocks/{Uri.EscapeDataString(blockId)}/children/batch_delete", + token, + payload, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Delete Feishu cloud document child blocks"); + } + + public async Task FindCloudDocumentInFolderByTitleAsync( + string folderToken, + string title, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(folderToken)) + { + throw new ArgumentException("文件夹 Token 不能为空。", nameof(folderToken)); + } + + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("文档标题不能为空。", nameof(title)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + string? pageToken = null; + + do + { + var queryBuilder = new StringBuilder("/open-apis/drive/v1/files?page_size=200"); + queryBuilder.Append("&folder_token=").Append(Uri.EscapeDataString(folderToken.Trim())); + queryBuilder.Append("&order_by=EditedTime"); + if (!string.IsNullOrWhiteSpace(pageToken)) + { + queryBuilder.Append("&page_token=").Append(Uri.EscapeDataString(pageToken)); + } + + var response = await GetAsync( + queryBuilder.ToString(), + token, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "List Feishu cloud folder items"); + + if (!result.TryGetProperty("data", out var data)) + { + return null; + } + + if (data.TryGetProperty("files", out var files) + && files.ValueKind == JsonValueKind.Array) + { + foreach (var file in files.EnumerateArray()) + { + var currentTitle = file.TryGetProperty("name", out var nameProp) + ? nameProp.GetString() + : null; + var type = file.TryGetProperty("type", out var typeProp) + ? typeProp.GetString() + : null; + var documentId = file.TryGetProperty("token", out var tokenProp) + ? tokenProp.GetString() + : null; + + if (!string.Equals(currentTitle, title, StringComparison.Ordinal) + || !string.Equals(type, "docx", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(documentId)) + { + continue; + } + + var url = file.TryGetProperty("url", out var urlProp) + ? urlProp.GetString() + : null; + + return new FeishuCloudDocumentInfo + { + DocumentId = documentId, + RootBlockId = documentId, + Url = string.IsNullOrWhiteSpace(url) ? BuildCloudDocumentUrl(documentId) : url + }; + } + } + + var hasMore = data.TryGetProperty("has_more", out var hasMoreProp) + && hasMoreProp.ValueKind == JsonValueKind.True; + pageToken = hasMore + && data.TryGetProperty("next_page_token", out var nextPageTokenProp) + ? nextPageTokenProp.GetString() + : null; + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + return null; + } + + public async Task UploadCloudFileAsync( + string fileName, + byte[] content, + string? folderToken, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("文件名不能为空。", nameof(fileName)); + } + + ArgumentNullException.ThrowIfNull(content); + + if (content.Length == 0) + { + throw new ArgumentException("文件内容不能为空。", nameof(content)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + var targetFolderToken = string.IsNullOrWhiteSpace(folderToken) + ? await GetRootFolderTokenAsync(token, effectiveOptions, cancellationToken) + : folderToken.Trim(); + using var formData = new MultipartFormDataContent(); + formData.Add(new StringContent(fileName), "file_name"); + formData.Add(new StringContent("explorer"), "parent_type"); + formData.Add(new StringContent(targetFolderToken), "parent_node"); + formData.Add(new StringContent(content.Length.ToString()), "size"); + + var fileContent = new ByteArrayContent(content); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); + formData.Add(fileContent, "file", fileName); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/open-apis/drive/v1/files/upload_all"); + request.Headers.Add("Authorization", $"Bearer {token}"); + request.Content = formData; + + var response = await SendAsync(request, effectiveOptions, cancellationToken); + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Upload Feishu cloud file"); + + if (result.TryGetProperty("data", out var data) + && data.TryGetProperty("file_token", out var fileTokenProp) + && !string.IsNullOrWhiteSpace(fileTokenProp.GetString())) + { + return fileTokenProp.GetString()!; + } + + throw new InvalidOperationException("上传云空间文件响应缺少 file_token。"); + } + + public async Task ImportMarkdownFileAsCloudDocumentAsync( + string fileName, + byte[] content, + string title, + string? folderToken, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("文档标题不能为空。", nameof(title)); + } + + var fileToken = await UploadCloudFileAsync( + fileName, + content, + folderToken, + cancellationToken, + optionsOverride); + + var extension = Path.GetExtension(fileName); + var normalizedExtension = string.IsNullOrWhiteSpace(extension) + ? "md" + : extension.TrimStart('.').ToLowerInvariant(); + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + object payload = string.IsNullOrWhiteSpace(folderToken) + ? new + { + file_extension = normalizedExtension, + file_token = fileToken, + type = "docx", + file_name = title + } + : new + { + file_extension = normalizedExtension, + file_token = fileToken, + type = "docx", + file_name = title, + point = new + { + mount_type = 1, + mount_key = folderToken.Trim() + } + }; + + var createResponse = await PostAsync( + "/open-apis/drive/v1/import_tasks", + token, + payload, + effectiveOptions, + cancellationToken); + + var createResult = await ParseResponseAsync(createResponse, cancellationToken); + EnsureBusinessSuccess(createResult, "Create Feishu markdown import task"); + + if (!createResult.TryGetProperty("data", out var createData) + || !createData.TryGetProperty("ticket", out var ticketProp) + || string.IsNullOrWhiteSpace(ticketProp.GetString())) + { + throw new InvalidOperationException("Markdown 导入任务响应缺少 ticket。"); + } + + var ticket = ticketProp.GetString()!; + return await PollImportMarkdownFileAsCloudDocumentAsync( + ticket, + token, + effectiveOptions, + cancellationToken); } public async Task<(byte[] Content, string FileName, string MimeType)> DownloadMessageResourceAsync( @@ -385,6 +1079,7 @@ public async Task CreateStreamingHandleAsync( { var effectiveOptions = GetEffectiveOptions(optionsOverride); var cardTitle = title ?? effectiveOptions.DefaultCardTitle; + var payloadState = new StreamingCardPayloadState(); // 1. 鍒涘缓鍗$墖 var cardId = await CreateCardCoreAsync( @@ -392,7 +1087,8 @@ public async Task CreateStreamingHandleAsync( cardTitle, cancellationToken, effectiveOptions, - chrome); + chrome, + payloadState); // 2. 鍙戦€佹垨鍥炲鍗$墖娑堟伅 string messageId; @@ -410,8 +1106,8 @@ public async Task CreateStreamingHandleAsync( return new FeishuStreamingHandle( cardId, messageId, - (content, sequence) => UpdateCardCoreAsync(cardId, content, sequence, cardTitle, cancellationToken, effectiveOptions, chrome), - (content, sequence) => UpdateCardCoreAsync(cardId, content, sequence, cardTitle, cancellationToken, effectiveOptions, chrome), + (content, sequence) => UpdateCardCoreAsync(cardId, content, sequence, cardTitle, cancellationToken, effectiveOptions, chrome, payloadState), + (content, sequence) => UpdateCardCoreAsync(cardId, content, sequence, cardTitle, cancellationToken, effectiveOptions, chrome, payloadState), effectiveOptions.StreamingThrottleMs, quietWindowAfterUpdateMs ); @@ -422,28 +1118,49 @@ private async Task CreateCardCoreAsync( string title, CancellationToken cancellationToken, FeishuOptions effectiveOptions, - FeishuStreamingCardChrome? chrome) + FeishuStreamingCardChrome? chrome, + StreamingCardPayloadState state) { var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); - var cardData = BuildStreamingCardData(initialContent, title, chrome, includeHeader: true); - var payload = new - { - type = "card_json", - data = JsonSerializer.Serialize(cardData) - }; + while (true) + { + var cardData = BuildStreamingCardData( + initialContent, + title, + chrome, + includeHeader: true, + mode: state.Mode, + maxReplyChars: state.MaxReplyChars); + + var payload = new + { + type = "card_json", + data = JsonSerializer.Serialize(cardData) + }; + + var response = await PostAsync("/open-apis/cardkit/v1/cards", token, payload, effectiveOptions, cancellationToken); + var result = await ParseResponseAsync(response, cancellationToken); + + if (IsBusinessSuccess(result)) + { + if (result.TryGetProperty("data", out var data) && + data.TryGetProperty("card_id", out var cardIdProp)) + { + return cardIdProp.GetString() ?? string.Empty; + } - var response = await PostAsync("/open-apis/cardkit/v1/cards", token, payload, effectiveOptions, cancellationToken); - var result = await ParseResponseAsync(response, cancellationToken); - EnsureBusinessSuccess(result, "Create CardKit card"); + throw new InvalidOperationException("Failed to create card: invalid response"); + } - if (result.TryGetProperty("data", out var data) && - data.TryGetProperty("card_id", out var cardIdProp)) - { - return cardIdProp.GetString() ?? string.Empty; - } + if (TryAdvanceOverflowReduction(result, state, cardId: null, sequence: null)) + { + continue; + } - throw new InvalidOperationException("Failed to create card: invalid response"); + EnsureBusinessSuccess(result, "Create CardKit card"); + throw new InvalidOperationException("Failed to create card: invalid response"); + } } private async Task UpdateCardCoreAsync( @@ -453,37 +1170,70 @@ private async Task UpdateCardCoreAsync( string? title, CancellationToken cancellationToken, FeishuOptions effectiveOptions, - FeishuStreamingCardChrome? chrome) + FeishuStreamingCardChrome? chrome, + StreamingCardPayloadState state) { try { var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); - var cardData = BuildStreamingCardData(content, title, chrome, includeHeader: !string.IsNullOrWhiteSpace(title)); var updateUuid = CreateCardUpdateUuid(cardId, sequence); - - var payload = new - { - card = new - { - type = "card_json", - data = JsonSerializer.Serialize(cardData) - }, - sequence, - uuid = updateUuid - }; + var sawRecoverableTimeout = false; for (var attempt = 1; attempt <= CardUpdateMaxAttempts; attempt++) { try { + var payload = BuildUpdatePayload( + content, + title, + chrome, + state.Mode, + state.MaxReplyChars, + sequence, + updateUuid); + var response = await PutAsync($"/open-apis/cardkit/v1/cards/{cardId}", token, payload, effectiveOptions, cancellationToken); var result = await ParseResponseAsync(response, cancellationToken); - EnsureBusinessSuccess(result, "Update CardKit card"); if (result.TryGetProperty("code", out var codeProp)) { var code = codeProp.GetInt32(); - if (code == 0) return true; + if (code == 0) + { + return true; + } + + // A timeout can mean Feishu applied the write but the client never saw the response. + // If the immediate retry for the same sequence then reports a sequence conflict, + // treat that as evidence the prior write likely already succeeded. + if ((code == CardUpdateSequenceConflictCode || code == CardUpdateDuplicateUuidCode) && sawRecoverableTimeout) + { + _logger.LogWarning( + "Update card retry hit duplicate-after-timeout signal; assuming previous write succeeded (cardId={CardId}, seq={Sequence}, code={Code}, uuid={Uuid})", + cardId, + sequence, + code, + updateUuid); + return true; + } + + if (TryAdvanceOverflowReduction(result, state, cardId, sequence)) + { + attempt--; + continue; + } + + if (code == CardOverMaxSizeCode) + { + _logger.LogWarning( + "Update card failed because minimal reduced payload still exceeds CardKit max size (cardId={CardId}, seq={Sequence}, uuid={Uuid})", + cardId, + sequence, + updateUuid); + return false; + } + + EnsureBusinessSuccess(result, "Update CardKit card"); _logger.LogWarning( "Update card failed (cardId={CardId}, seq={Sequence}): Code={Code}, Msg={Msg}", @@ -496,6 +1246,7 @@ private async Task UpdateCardCoreAsync( } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { + sawRecoverableTimeout = true; if (attempt < CardUpdateMaxAttempts) { _logger.LogWarning( @@ -540,12 +1291,16 @@ private object BuildStreamingCardData( string content, string? title, FeishuStreamingCardChrome? chrome, - bool includeHeader) + bool includeHeader, + StreamingCardPayloadMode mode = StreamingCardPayloadMode.Full, + int? maxReplyChars = null) { - var config = BuildStreamingCardConfig(chrome); + var effectiveChrome = mode == StreamingCardPayloadMode.Full ? chrome : null; + var renderedContent = RenderStreamingReplyContent(content, mode, maxReplyChars); + var config = BuildStreamingCardConfig(effectiveChrome); var body = new { - elements = BuildStreamingCardElements(content, chrome) + elements = BuildStreamingCardElements(renderedContent, effectiveChrome) }; if (includeHeader) @@ -671,7 +1426,7 @@ internal static object[] BuildStreamingCardElements(string content, FeishuStream if (hasBottomNotice || hasBottomPrompt || hasBottomActions) { - elements.Add(BuildSectionMarker("Superpowers 工作流")); + elements.Add(BuildSectionMarker(SuperpowersQuickActionDefaults.WorkflowSectionTitle)); foreach (var markdown in chrome.BottomNoticeMarkdowns.Where(markdown => !string.IsNullOrWhiteSpace(markdown))) { @@ -1092,17 +1847,39 @@ private async Task PutAsync( return await SendAsync(request, options, cancellationToken); } - private async Task PostMultipartAsync( + private async Task PatchAsync( string path, string token, - HttpContent payload, + object payload, FeishuOptions options, CancellationToken cancellationToken) { - var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}{path}") + var request = new HttpRequestMessage(HttpMethod.Patch, $"{_baseUrl}{path}"); + request.Content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); + + if (!string.IsNullOrEmpty(token)) { - Content = payload - }; + request.Headers.Add("Authorization", $"Bearer {token}"); + } + + return await SendAsync(request, options, cancellationToken); + } + + private async Task DeleteAsync( + string path, + string token, + object payload, + FeishuOptions options, + CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Delete, $"{_baseUrl}{path}"); + request.Content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); if (!string.IsNullOrEmpty(token)) { @@ -1127,6 +1904,261 @@ private async Task SendAsync( return await _httpClient.SendAsync(request, timeoutCts.Token); } + internal static string BuildCloudDocumentUrl(string documentId) + { + return $"https://feishu.cn/docx/{documentId}"; + } + + private static JsonNode NormalizeCloudDocumentBlockForAppend(JsonElement block) + { + return NormalizeCloudDocumentNode(block) ?? new JsonObject(); + } + + private static JsonNode? NormalizeCloudDocumentNode(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Object => NormalizeCloudDocumentObject(element), + JsonValueKind.Array => NormalizeCloudDocumentArray(element), + JsonValueKind.String => JsonValue.Create(element.GetString()), + JsonValueKind.Number => NormalizeCloudDocumentNumber(element), + JsonValueKind.True => JsonValue.Create(true), + JsonValueKind.False => JsonValue.Create(false), + JsonValueKind.Null or JsonValueKind.Undefined => null, + _ => null + }; + } + + private static JsonObject NormalizeCloudDocumentObject(JsonElement element) + { + var node = new JsonObject(); + + foreach (var property in element.EnumerateObject()) + { + if (ShouldSkipCloudDocumentProperty(property.Name)) + { + continue; + } + + if (string.Equals(property.Name, "children", StringComparison.Ordinal) + && property.Value.ValueKind == JsonValueKind.Array + && property.Value.GetArrayLength() == 0) + { + continue; + } + + var normalized = NormalizeCloudDocumentNode(property.Value); + if (normalized != null) + { + node[property.Name] = normalized; + } + } + + return node; + } + + private static JsonArray NormalizeCloudDocumentArray(JsonElement element) + { + var array = new JsonArray(); + + foreach (var item in element.EnumerateArray()) + { + var normalized = NormalizeCloudDocumentNode(item); + if (normalized != null) + { + array.Add(normalized); + } + } + + return array; + } + + private static JsonNode? NormalizeCloudDocumentNumber(JsonElement element) + { + if (element.TryGetInt64(out var longValue)) + { + return JsonValue.Create(longValue); + } + + if (element.TryGetDecimal(out var decimalValue)) + { + return JsonValue.Create(decimalValue); + } + + return JsonValue.Create(element.GetDouble()); + } + + private static bool ShouldSkipCloudDocumentProperty(string propertyName) + { + return string.Equals(propertyName, "block_id", StringComparison.Ordinal) + || string.Equals(propertyName, "block_uuid", StringComparison.Ordinal) + || string.Equals(propertyName, "parent_id", StringComparison.Ordinal) + || string.Equals(propertyName, "revision_id", StringComparison.Ordinal); + } + + private async Task GetRootFolderTokenAsync( + string token, + FeishuOptions options, + CancellationToken cancellationToken) + { + var response = await GetAsync( + "/open-apis/drive/explorer/v2/root_folder/meta", + token, + options, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Get Feishu root folder metadata"); + + if (result.TryGetProperty("data", out var data) + && data.TryGetProperty("token", out var tokenProp) + && !string.IsNullOrWhiteSpace(tokenProp.GetString())) + { + return tokenProp.GetString()!; + } + + throw new InvalidOperationException("Failed to get Feishu root folder metadata: missing token."); + } + + private async Task TryFindFolderTokenByNameAsync( + string parentFolderToken, + string folderName, + string token, + FeishuOptions options, + CancellationToken cancellationToken) + { + string? pageToken = null; + do + { + var queryBuilder = new StringBuilder("/open-apis/drive/v1/files?page_size=200"); + queryBuilder.Append("&folder_token=").Append(Uri.EscapeDataString(parentFolderToken)); + queryBuilder.Append("&order_by=EditedTime"); + if (!string.IsNullOrWhiteSpace(pageToken)) + { + queryBuilder.Append("&page_token=").Append(Uri.EscapeDataString(pageToken)); + } + + var response = await GetAsync( + queryBuilder.ToString(), + token, + options, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "List Feishu cloud folder items"); + + if (result.TryGetProperty("data", out var data)) + { + if (data.TryGetProperty("files", out var files) + && files.ValueKind == JsonValueKind.Array) + { + foreach (var file in files.EnumerateArray()) + { + var type = file.TryGetProperty("type", out var typeProp) + ? typeProp.GetString() + : null; + var name = file.TryGetProperty("name", out var nameProp) + ? nameProp.GetString() + : null; + var currentToken = file.TryGetProperty("token", out var fileTokenProp) + ? fileTokenProp.GetString() + : null; + + if (string.Equals(type, "folder", StringComparison.OrdinalIgnoreCase) + && string.Equals(name, folderName, StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(currentToken)) + { + return currentToken; + } + } + } + + var hasMore = data.TryGetProperty("has_more", out var hasMoreProp) + && hasMoreProp.ValueKind == JsonValueKind.True; + pageToken = hasMore + && data.TryGetProperty("next_page_token", out var nextPageTokenProp) + ? nextPageTokenProp.GetString() + : null; + } + else + { + pageToken = null; + } + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + return null; + } + + private async Task PollImportMarkdownFileAsCloudDocumentAsync( + string ticket, + string token, + FeishuOptions options, + CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(30); + + while (true) + { + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException("Markdown 导入超时,请稍后重试。"); + } + + var response = await GetAsync( + $"/open-apis/drive/v1/import_tasks/{Uri.EscapeDataString(ticket)}", + token, + options, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Get Feishu markdown import task"); + + if (!result.TryGetProperty("data", out var data) + || !data.TryGetProperty("result", out var importResult)) + { + throw new InvalidOperationException("Markdown 导入结果响应缺少 result。"); + } + + var jobStatus = importResult.TryGetProperty("job_status", out var jobStatusProp) + ? jobStatusProp.GetInt32() + : -1; + + if (jobStatus == 0) + { + var documentId = importResult.TryGetProperty("token", out var tokenProp) + ? tokenProp.GetString() + : null; + var url = importResult.TryGetProperty("url", out var urlProp) + ? urlProp.GetString() + : null; + + if (string.IsNullOrWhiteSpace(documentId)) + { + throw new InvalidOperationException("Markdown 导入成功但缺少文档 token。"); + } + + return new FeishuCloudDocumentInfo + { + DocumentId = documentId, + RootBlockId = documentId, + Url = string.IsNullOrWhiteSpace(url) ? BuildCloudDocumentUrl(documentId) : url + }; + } + + if (jobStatus == 1 || jobStatus == 2) + { + await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken); + continue; + } + + var message = importResult.TryGetProperty("job_error_msg", out var errorProp) + ? errorProp.GetString() + : null; + throw new InvalidOperationException($"Markdown 导入失败:{message ?? $"任务状态 {jobStatus}"}"); + } + } + private static string TryResolveDownloadFileName( HttpResponseMessage response, string fileKey, @@ -1194,20 +2226,136 @@ private async Task ParseResponseAsync( "API request failed: Status={Status}, Content={Content}", response.StatusCode, content); - throw new HttpRequestException($"API request failed: {response.StatusCode}"); + throw new HttpRequestException($"API request failed: {response.StatusCode}, Content={content}"); } return JsonDocument.Parse(content).RootElement; } + private object BuildUpdatePayload( + string content, + string? title, + FeishuStreamingCardChrome? chrome, + StreamingCardPayloadMode mode, + int? maxReplyChars, + int sequence, + string updateUuid) + { + var cardData = BuildStreamingCardData( + content, + title, + chrome, + includeHeader: !string.IsNullOrWhiteSpace(title), + mode, + maxReplyChars); + + return new + { + card = new + { + type = "card_json", + data = JsonSerializer.Serialize(cardData) + }, + sequence, + uuid = updateUuid + }; + } + + private bool TryAdvanceOverflowReduction( + JsonElement result, + StreamingCardPayloadState state, + string? cardId, + int? sequence) + { + if (!TryGetBusinessCode(result, out var code) || code != CardOverMaxSizeCode) + { + return false; + } + + if (!state.TryAdvance()) + { + if (cardId != null || sequence != null) + { + _logger.LogWarning( + "Feishu CardKit payload still exceeds max size after minimal reduction; stopping card updates (cardId={CardId}, seq={Sequence})", + cardId ?? "", + sequence?.ToString() ?? ""); + } + return false; + } + + _logger.LogWarning( + "Feishu CardKit payload exceeded max size; retrying with reduced payload (cardId={CardId}, seq={Sequence}, mode={Mode}, maxReplyChars={MaxReplyChars})", + cardId ?? "", + sequence?.ToString() ?? "", + state.Mode, + state.MaxReplyChars?.ToString() ?? ""); + return true; + } + + private static bool IsBusinessSuccess(JsonElement result) + { + return !TryGetBusinessCode(result, out var code) || code == 0; + } + + private static bool TryGetBusinessCode(JsonElement result, out int code) + { + if (result.TryGetProperty("code", out var codeProp)) + { + code = codeProp.GetInt32(); + return true; + } + + code = 0; + return false; + } + + private static string RenderStreamingReplyContent(string content, StreamingCardPayloadMode mode, int? maxReplyChars) + { + if (mode == StreamingCardPayloadMode.Full) + { + return content; + } + + var effectiveLimit = maxReplyChars.GetValueOrDefault(mode == StreamingCardPayloadMode.Reduced ? ReducedReplyTailChars : MinimalReplyTailChars); + var trimmedContent = TakeContentTail(content, effectiveLimit); + return $"{ReducedContentNotice}\n\n{trimmedContent}"; + } + + private static string TakeContentTail(string content, int maxChars) + { + if (string.IsNullOrEmpty(content)) + { + return string.Empty; + } + + if (content.Length <= maxChars) + { + return content; + } + + var tail = content[^maxChars..].TrimStart(); + if (tail.StartsWith("```", StringComparison.Ordinal)) + { + return tail; + } + + var newlineIndex = tail.IndexOf('\n'); + if (newlineIndex >= 0 && newlineIndex < tail.Length - 1) + { + return tail[(newlineIndex + 1)..].TrimStart(); + } + + return tail; + } + private void EnsureBusinessSuccess(JsonElement result, string operationName) { - if (!result.TryGetProperty("code", out var codeProp)) + if (!TryGetBusinessCode(result, out var code)) { return; } - var code = codeProp.GetInt32(); if (code == 0) { return; @@ -1231,6 +2379,39 @@ private TokenCacheEntry GetTokenCacheEntry(FeishuOptions options) return _tokenCache.GetOrAdd(cacheKey, _ => new TokenCacheEntry()); } + private sealed class StreamingCardPayloadState + { + public StreamingCardPayloadMode Mode { get; private set; } = StreamingCardPayloadMode.Full; + + public int? MaxReplyChars { get; private set; } + + public bool TryAdvance() + { + if (Mode == StreamingCardPayloadMode.Full) + { + Mode = StreamingCardPayloadMode.Reduced; + MaxReplyChars = ReducedReplyTailChars; + return true; + } + + if (Mode == StreamingCardPayloadMode.Reduced) + { + Mode = StreamingCardPayloadMode.Minimal; + MaxReplyChars = MinimalReplyTailChars; + return true; + } + + return false; + } + } + + private enum StreamingCardPayloadMode + { + Full = 0, + Reduced = 1, + Minimal = 2 + } + /// /// 鍙戦€佸師濮婮SON鍗$墖娑堟伅锛堝府鍔╁姛鑳戒笓鐢級 /// 閫氳繃 CardKit 鍒涘缓鍗$墖锛岄伩鍏岼SON鏍煎紡闂 diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs index 96fc96d..1d10008 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs @@ -33,7 +33,7 @@ public class FeishuChannelService : BackgroundService, IFeishuChannelService private readonly ICliExecutorService _cliExecutor; private readonly IChatSessionService _chatSessionService; private readonly IFeishuAttachmentDraftService _attachmentDraftService; - private readonly IReplyTtsOrchestrator? _replyTtsOrchestrator; + private readonly IReplyDocumentOrchestrator? _replyDocumentOrchestrator; private bool _isRunning = false; @@ -243,7 +243,7 @@ public FeishuChannelService( IServiceProvider serviceProvider, ICliExecutorService cliExecutor, IChatSessionService chatSessionService, - IReplyTtsOrchestrator? replyTtsOrchestrator = null, + IReplyDocumentOrchestrator? replyDocumentOrchestrator = null, IFeishuAttachmentDraftService? attachmentDraftService = null) { _options = options.Value; @@ -255,7 +255,7 @@ public FeishuChannelService( _attachmentDraftService = attachmentDraftService ?? serviceProvider.GetService() ?? new FeishuAttachmentDraftService(); - _replyTtsOrchestrator = replyTtsOrchestrator; + _replyDocumentOrchestrator = replyDocumentOrchestrator; } /// @@ -429,13 +429,13 @@ private async Task ExecuteStreamingSubmissionAsync( TryAttachSuperpowersQuickActions(streamingChrome, sessionId, toolId, showStopAction: true); var effectiveOptions = await ResolveEffectiveOptionsAsync(username, chatId, appId); - var handle = await _cardKit.CreateStreamingHandleAsync( + var handle = await CreateStreamingHandleWithOverflowFallbackAsync( chatId, null, effectiveOptions.ThinkingMessage, - effectiveOptions.DefaultCardTitle, - optionsOverride: effectiveOptions, - chrome: streamingChrome); + effectiveOptions, + streamingChrome, + cancellationToken); _logger.LogInformation( "[FeishuChannel] 流式句柄已创建: CardId={CardId}", @@ -448,8 +448,25 @@ private async Task ExecuteStreamingSubmissionAsync( streamingChrome, baseStatusMarkdown, effectiveOptions.ThinkingMessage); + var cardSession = new FeishuStreamingCardSession( + activeExecution.Handle, + (_, latestContent, token) => TryCreateReplacementStreamingHandleAsync( + chatId, + null, + latestContent, + activeExecution.Chrome, + effectiveOptions, + token), + activeExecution.ReplaceHandle, + (stoppedHandle, latestContent, token) => TryFinishReplacementStreamingCardAsync( + stoppedHandle, + activeExecution, + latestContent, + token), + deferReplacementUntilNextForegroundUpdate: IsGoalRuntimeSession(TryGetSessionEntity(sessionId))); activeExecution.PausePulseForOverflowCard(StreamingStatusPulseQuietWindow); - var statusPulseTask = RunStreamingStatusPulseAsync(activeExecution); + using var backgroundUpdatesCts = new CancellationTokenSource(); + var statusPulseTask = RunStreamingStatusPulseAsync(activeExecution, cardSession, backgroundUpdatesCts.Token); var externalHistoryBackfillTask = RunExternalHistoryBackfillAsync( sessionId, toolId, @@ -458,7 +475,7 @@ private async Task ExecuteStreamingSubmissionAsync( () => activeExecution.GetLatestRenderedContent(), content => { - if (handle.AreCardUpdatesStopped) + if (activeExecution.Handle.AreCardUpdatesStopped) { return; } @@ -466,8 +483,11 @@ private async Task ExecuteStreamingSubmissionAsync( activeExecution.SetLatestRenderedContent(content); activeExecution.PausePulseForOverflowCard(StreamingStatusPulseQuietWindow); }, - content => handle.AreCardUpdatesStopped ? Task.CompletedTask : handle.UpdateAsync(content), - activeExecution.UpdateCancellationTokenSource.Token); + content => cardSession.UpdateAsync( + content, + activeExecution.UpdateCancellationTokenSource.Token, + allowPendingReplacementActivation: false), + backgroundUpdatesCts.Token); var previousExecution = RegisterActiveExecution(sessionId, activeExecution); if (previousExecution != null) { @@ -477,7 +497,6 @@ private async Task ExecuteStreamingSubmissionAsync( try { await ExecuteCliAndStreamAsync( - handle, sessionId, chatId, toolId, @@ -485,6 +504,10 @@ await ExecuteCliAndStreamAsync( completionReplyToMessageId, effectiveOptions.ThinkingMessage, activeExecution, + cardSession, + backgroundUpdatesCts, + statusPulseTask, + externalHistoryBackfillTask, username, appId, executionRequest, @@ -492,6 +515,7 @@ await ExecuteCliAndStreamAsync( } finally { + CancelBackgroundUpdates(backgroundUpdatesCts); activeExecution.CancelUpdateWork(); await AwaitStatusPulseAsync(statusPulseTask); await AwaitBackgroundTaskAsync(externalHistoryBackfillTask); @@ -1009,7 +1033,6 @@ private void CleanupUnboundFeishuSessions(IChatSessionRepository repo, IFeishuUs /// 鎵ц CLI 宸ュ叿骞舵祦寮忔洿鏂板崱鐗? /// private async Task ExecuteCliAndStreamAsync( - FeishuStreamingHandle handle, string sessionId, string chatId, string toolId, @@ -1017,6 +1040,10 @@ private async Task ExecuteCliAndStreamAsync( string? completionReplyToMessageId, string thinkingMessage, ActiveSessionExecution activeExecution, + FeishuStreamingCardSession cardSession, + CancellationTokenSource backgroundUpdatesCts, + Task statusPulseTask, + Task externalHistoryBackfillTask, string? username = null, string? appId = null, CliExecutionRequest? executionRequest = null, @@ -1024,6 +1051,8 @@ private async Task ExecuteCliAndStreamAsync( { var outputBuilder = new StringBuilder(); var assistantMessageBuilder = new StringBuilder(); + var turnAssistantMessageBuilder = new StringBuilder(); + var finalAnswerMessageBuilder = new StringBuilder(); var jsonlBuffer = new StringBuilder(); // JSONL 缂撳啿鍖猴紝澶勭悊涓嶅畬鏁寸殑琛? var hasStructuredTodoList = false; var latestRenderedContent = thinkingMessage; @@ -1035,7 +1064,7 @@ private async Task ExecuteCliAndStreamAsync( { activeExecution.CancelUpdateWork(); activeExecution.SetErrorStatus(); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, $"未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。")); _logger.LogWarning("CLI tool not found: {ToolId}", resolvedToolId); @@ -1068,7 +1097,7 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( _logger.LogInformation( "CLI execution superseded by newer message: Session={SessionId}, MessageId={MessageId}", sessionId, - completionReplyToMessageId ?? handle.MessageId); + completionReplyToMessageId ?? activeExecution.Handle.MessageId); return; } @@ -1077,12 +1106,79 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( chunk.ErrorMessage ?? "Unknown error"); activeExecution.CancelUpdateWork(); activeExecution.SetErrorStatus(); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, chunk.ErrorMessage ?? "执行失败")); return; } + if (chunk.IsTurnBoundary) + { + if (cardDisconnected) + { + continue; + } + + if (_replyDocumentOrchestrator != null && turnAssistantMessageBuilder.Length > 0) + { + try + { + await _replyDocumentOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = chatId, + SessionId = sessionId, + CliThreadId = ResolveCliThreadId(sessionId), + OriginalUserQuestion = userPrompt, + Username = username, + AppId = appId, + Output = turnAssistantMessageBuilder.ToString().Trim(), + FinalAnswerOutput = finalAnswerMessageBuilder.ToString().Trim() + }); + } + catch (Exception ttsQueueEx) + { + _logger.LogWarning( + ttsQueueEx, + "Failed to queue reply document at turn boundary: Session={SessionId}, MessageId={MessageId}", + sessionId, + completionReplyToMessageId ?? activeExecution.Handle.MessageId); + } + } + + var handoffSucceeded = await TryRotateGoalRuntimeTurnCardAsync( + sessionId, + chatId, + tool.Id, + activeExecution, + cardSession, + username, + appId, + cancellationToken); + if (!handoffSucceeded) + { + var disconnectedContent = await TryHandleStreamingCardDisconnectAsync(activeExecution, latestRenderedContent, cancellationToken); + if (disconnectedContent != null) + { + cardDisconnected = true; + latestRenderedContent = disconnectedContent; + } + + continue; + } + + outputBuilder.Clear(); + assistantMessageBuilder.Clear(); + turnAssistantMessageBuilder.Clear(); + finalAnswerMessageBuilder.Clear(); + jsonlBuffer.Clear(); + hasStructuredTodoList = false; + latestRenderedContent = thinkingMessage; + activeExecution.SetLatestRenderedContent(thinkingMessage); + activeExecution.Chrome.LatestToolCallMarkdown = null; + activeExecution.PausePulseForOverflowCard(StreamingStatusPulseQuietWindow); + continue; + } + // 绱Н鍘熷杈撳嚭鍐呭 outputBuilder.Append(chunk.Content); @@ -1091,13 +1187,25 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( if (useAdapter) { // 瑙f瀽 JSONL 琛屽苟鎻愬彇鍔╂墜娑堟伅锛堜娇鐢ㄧ紦鍐插尯澶勭悊涓嶅畬鏁寸殑琛岋級 - hasStructuredTodoList |= ProcessJsonlChunk(chunk.Content, sessionId, adapter!, assistantMessageBuilder, jsonlBuffer, activeExecution.Chrome); + hasStructuredTodoList |= ProcessJsonlChunk( + chunk.Content, + sessionId, + adapter!, + assistantMessageBuilder, + turnAssistantMessageBuilder, + finalAnswerMessageBuilder, + jsonlBuffer, + activeExecution.Chrome); displayContent = assistantMessageBuilder.ToString(); // 濡傛灉娌℃湁鍔╂墜娑堟伅锛屾樉绀?鎬濊€冧腑" if (string.IsNullOrWhiteSpace(displayContent)) { - displayContent = ExtractFallbackOutput(outputBuilder.ToString(), adapter!) ?? thinkingMessage; + var latestKnownContent = activeExecution.GetLatestRenderedContent(); + displayContent = ExtractFallbackOutput(outputBuilder.ToString(), adapter!) + ?? (ShouldProbeExternalHistory(latestKnownContent, thinkingMessage) + ? thinkingMessage + : latestKnownContent); } } else @@ -1112,9 +1220,13 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( activeExecution.SetLatestRenderedContent(displayContent); latestRenderedContent = displayContent; activeExecution.PausePulseForOverflowCard(StreamingStatusPulseQuietWindow); - await handle.UpdateAsync(displayContent); - - var disconnectedContent = await TryHandleStreamingCardDisconnectAsync(activeExecution, latestRenderedContent, cancellationToken); + var updateSucceeded = await cardSession.UpdateAsync( + displayContent, + cancellationToken, + allowPendingReplacementActivation: true); + var disconnectedContent = updateSucceeded + ? null + : await TryHandleStreamingCardDisconnectAsync(activeExecution, latestRenderedContent, cancellationToken); if (disconnectedContent != null) { cardDisconnected = true; @@ -1135,7 +1247,14 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( if (useAdapter && jsonlBuffer.Length > 0) { - hasStructuredTodoList |= ProcessJsonlLine(jsonlBuffer.ToString(), sessionId, adapter!, assistantMessageBuilder, activeExecution.Chrome); + hasStructuredTodoList |= ProcessJsonlLine( + jsonlBuffer.ToString(), + sessionId, + adapter!, + assistantMessageBuilder, + turnAssistantMessageBuilder, + finalAnswerMessageBuilder, + activeExecution.Chrome); jsonlBuffer.Clear(); } @@ -1169,6 +1288,8 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( finalOutput = FormatMarkdownOutput(outputBuilder.ToString()); } + var finalAnswerOutput = finalAnswerMessageBuilder.ToString().Trim(); + if (!cardDisconnected) { var disconnectedContent = await TryHandleStreamingCardDisconnectAsync(activeExecution, latestRenderedContent, cancellationToken); @@ -1179,30 +1300,46 @@ await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( } } - activeExecution.CancelUpdateWork(stopCardUpdates: false); + CancelBackgroundUpdates(backgroundUpdatesCts); + await AwaitStatusPulseAsync(statusPulseTask); + await AwaitBackgroundTaskAsync(externalHistoryBackfillTask); if (!cardDisconnected && !cancellationToken.IsCancellationRequested && !activeExecution.Handle.AreCardUpdatesStopped) { + var completionPresentation = await BuildCompletionPresentationAsync( + sessionId, + tool.Id, + activeExecution.BaseStatusMarkdown); activeExecution.SetLatestRenderedContent(finalOutput); latestRenderedContent = finalOutput; - activeExecution.SetCompletedStatus(); + activeExecution.Chrome.StatusMarkdown = completionPresentation.StatusMarkdown; SetTopChipGroupsEnabled(activeExecution.Chrome, true); TryAttachSuperpowersQuickActions(activeExecution.Chrome, sessionId, tool.Id); + var finishSucceeded = await cardSession.FinishAsync(finalOutput); + if (!finishSucceeded) + { + var disconnectedContent = await TryHandleStreamingCardDisconnectAsync(activeExecution, latestRenderedContent, cancellationToken); + if (disconnectedContent != null) + { + cardDisconnected = true; + latestRenderedContent = disconnectedContent; + } + } // NOTE: Keep the explicit completion text notification for Feishu users. try { - if (string.IsNullOrWhiteSpace(completionReplyToMessageId)) + if (!cardDisconnected && string.IsNullOrWhiteSpace(completionReplyToMessageId)) { await SendMessageAsync( chatId, - BuildCompletionNotificationText(sessionId), + completionPresentation.NotificationText, username, appId); } - else + else if (!cardDisconnected) { await ReplyMessageAsync( completionReplyToMessageId, - BuildCompletionNotificationText(sessionId), + completionPresentation.NotificationText, username, appId); } @@ -1212,10 +1349,8 @@ await ReplyMessageAsync( _logger.LogWarning( notificationEx, "鍙戦€佹祦寮忓畬鎴愭枃鏈€氱煡澶辫触: MessageId={MessageId}", - completionReplyToMessageId ?? handle.MessageId); + completionReplyToMessageId ?? activeExecution.Handle.MessageId); } - - await handle.FinishAsync(finalOutput); } else { @@ -1224,7 +1359,7 @@ await ReplyMessageAsync( ? "Feishu card updates stopped mid-stream; skipped final card completion update: Session={SessionId}, MessageId={MessageId}" : "Feishu card completed without final card update: Session={SessionId}, MessageId={MessageId}", sessionId, - completionReplyToMessageId ?? handle.MessageId); + completionReplyToMessageId ?? activeExecution.Handle.MessageId); } if (!cancellationToken.IsCancellationRequested) @@ -1250,25 +1385,29 @@ await ReplyMessageAsync( await repo.UpdateAsync(session); } - if (!cardDisconnected && _replyTtsOrchestrator != null) + if (!cardDisconnected && _replyDocumentOrchestrator != null) { try { - await _replyTtsOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + await _replyDocumentOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest { ChatId = chatId, + SessionId = sessionId, + CliThreadId = ResolveCliThreadId(sessionId), + OriginalUserQuestion = userPrompt, Username = username, AppId = appId, - Output = finalOutput + Output = finalOutput, + FinalAnswerOutput = finalAnswerOutput }); } catch (Exception ttsQueueEx) { _logger.LogWarning( ttsQueueEx, - "Failed to queue reply TTS after Feishu completion: Session={SessionId}, MessageId={MessageId}", + "Failed to queue reply document after Feishu completion: Session={SessionId}, MessageId={MessageId}", sessionId, - completionReplyToMessageId ?? handle.MessageId); + completionReplyToMessageId ?? activeExecution.Handle.MessageId); } } } @@ -1277,7 +1416,7 @@ await _replyTtsOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTts { _logger.LogInformation( "CLI execution completed for message: {MessageId}, session: {SessionId}", - completionReplyToMessageId ?? handle.MessageId, + completionReplyToMessageId ?? activeExecution.Handle.MessageId, sessionId); } } @@ -1288,14 +1427,14 @@ await _replyTtsOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTts ? "CLI execution stopped by user: Session={SessionId}, MessageId={MessageId}" : "CLI execution cancelled because a newer message took over: Session={SessionId}, MessageId={MessageId}", sessionId, - completionReplyToMessageId ?? handle.MessageId); + completionReplyToMessageId ?? activeExecution.Handle.MessageId); } catch (Exception ex) { - _logger.LogError(ex, "CLI execution failed for message: {MessageId}", completionReplyToMessageId ?? handle.MessageId); + _logger.LogError(ex, "CLI execution failed for message: {MessageId}", completionReplyToMessageId ?? activeExecution.Handle.MessageId); activeExecution.CancelUpdateWork(); activeExecution.SetErrorStatus(); - await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + await cardSession.FinishAsync(FeishuStreamingErrorFormatter.AppendError( latestRenderedContent, ex.Message)); } @@ -1468,6 +1607,19 @@ private static bool ShouldAppendAttachmentPromptText( return true; } + private string? ResolveCliThreadId(string sessionId) + { + var cliThreadId = _cliExecutor.GetCliThreadId(sessionId)?.Trim(); + if (!string.IsNullOrWhiteSpace(cliThreadId)) + { + return cliThreadId; + } + + using var scope = _serviceProvider.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + return repo.GetByIdAsync(sessionId).GetAwaiter().GetResult()?.CliThreadId?.Trim(); + } + private static string ExtractInlineAttachmentPromptText(string normalizedPrompt) { if (string.IsNullOrWhiteSpace(normalizedPrompt)) @@ -1597,6 +1749,112 @@ private static string BuildSupersededCardContent(ActiveSessionExecution executio return $"{latestContent}\n\n{SupersededExecutionMessage}"; } + private static async Task TryFinishReplacementStreamingCardAsync( + FeishuStreamingHandle stoppedHandle, + ActiveSessionExecution execution, + string latestContent, + CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var previousStatusMarkdown = execution.Chrome.StatusMarkdown; + execution.SetStoppedStatus(); + try + { + await stoppedHandle.FinishAsync( + FeishuStreamingReplacementFormatter.BuildTransferredContent(latestContent)); + } + finally + { + execution.Chrome.StatusMarkdown = previousStatusMarkdown; + } + } + + private async Task TryRotateGoalRuntimeTurnCardAsync( + string sessionId, + string chatId, + string toolId, + ActiveSessionExecution activeExecution, + FeishuStreamingCardSession cardSession, + string? username, + string? appId, + CancellationToken cancellationToken) + { + if (!IsGoalRuntimeSession(TryGetSessionEntity(sessionId))) + { + return true; + } + + var previousHandle = activeExecution.Handle; + var handoffContent = activeExecution.GetLatestRenderedContent(); + var currentChrome = activeExecution.Chrome; + var previousStatusMarkdown = currentChrome.StatusMarkdown; + var effectiveOptions = await ResolveEffectiveOptionsAsync(username, chatId, appId); + ApplyChromeForTurnHandoff(currentChrome, activeExecution.BaseStatusMarkdown); + + try + { + await previousHandle.FinishAsync(handoffContent); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Finishing previous goal-runtime turn card failed: Session={SessionId}, CardId={CardId}", sessionId, previousHandle.CardId); + } + + currentChrome.StatusMarkdown = previousStatusMarkdown; + TryAttachSuperpowersQuickActions(currentChrome, sessionId, toolId, showStopAction: true); + + FeishuStreamingHandle nextHandle; + try + { + nextHandle = await CreateStreamingHandleWithOverflowFallbackAsync( + chatId, + null, + effectiveOptions.ThinkingMessage, + effectiveOptions, + currentChrome, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Creating next goal-runtime turn card failed: Session={SessionId}, ChatId={ChatId}", sessionId, chatId); + currentChrome.StatusMarkdown = previousStatusMarkdown; + return false; + } + + activeExecution.ReplaceHandle(nextHandle); + await cardSession.SwitchHandleAsync(nextHandle, resetReplacementCount: true, cancellationToken); + return true; + } + + private ChatSessionEntity? TryGetSessionEntity(string sessionId) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + return repo.GetByIdAsync(sessionId).GetAwaiter().GetResult(); + } + catch + { + return null; + } + } + + private static void ApplyChromeForTurnHandoff( + FeishuStreamingCardChrome chrome, + string baseStatusMarkdown) + { + chrome.StatusMarkdown = GoalRuntimeCompletionStateFormatter.WithGoalContinuingState(baseStatusMarkdown); + SetTopChipGroupsEnabled(chrome, true); + chrome.BottomPrompt = null; + chrome.AdditionalBottomPrompts.Clear(); + chrome.BottomActions.Clear(); + } + /// /// 鏍煎紡鍖?Markdown 杈撳嚭 /// 閫傜敤浜庨涔﹀崱鐗囨樉绀? @@ -1882,17 +2140,22 @@ private static string NormalizeComparableText(string? value) continue; } + var isStructuredLine = line.StartsWith("{", StringComparison.Ordinal) + || line.StartsWith("[", StringComparison.Ordinal); + if (isStructuredLine) + { + sawStructuredOutput = true; + } + var outputEvent = adapter.ParseOutputLine(line); - if (outputEvent == null || string.IsNullOrWhiteSpace(outputEvent.Content)) + if (outputEvent == null) { continue; } - var isStructuredLine = line.StartsWith("{", StringComparison.Ordinal) - || line.StartsWith("[", StringComparison.Ordinal); - if (isStructuredLine) + if (string.IsNullOrWhiteSpace(outputEvent.Content)) { - sawStructuredOutput = true; + continue; } var assistantMessage = adapter.ExtractAssistantMessage(outputEvent); @@ -2373,15 +2636,19 @@ private static bool SessionContainsSuperpowers(IEnumerable messages && message.Content.Contains("superpowers", StringComparison.OrdinalIgnoreCase)); } - private async Task RunStreamingStatusPulseAsync(ActiveSessionExecution execution) + private async Task RunStreamingStatusPulseAsync( + ActiveSessionExecution execution, + FeishuStreamingCardSession cardSession, + CancellationToken loopCancellationToken) { try { - while (!execution.UpdateCancellationTokenSource.IsCancellationRequested) + while (!loopCancellationToken.IsCancellationRequested) { - await Task.Delay(StreamingStatusPulseIntervalMs, execution.UpdateCancellationTokenSource.Token); + await Task.Delay(StreamingStatusPulseIntervalMs, loopCancellationToken); - if (execution.UpdateCancellationTokenSource.IsCancellationRequested + if (loopCancellationToken.IsCancellationRequested + || execution.UpdateCancellationTokenSource.IsCancellationRequested || execution.IsSuperseded || execution.Handle.AreCardUpdatesStopped) { @@ -2394,7 +2661,10 @@ private async Task RunStreamingStatusPulseAsync(ActiveSessionExecution execution } execution.AdvanceRunningStatus(); - await execution.Handle.UpdateAsync(execution.GetLatestRenderedContent()); + await cardSession.UpdateAsync( + execution.GetLatestRenderedContent(), + execution.UpdateCancellationTokenSource.Token, + allowPendingReplacementActivation: false); } } catch (OperationCanceledException) @@ -2402,6 +2672,17 @@ private async Task RunStreamingStatusPulseAsync(ActiveSessionExecution execution } } + private static void CancelBackgroundUpdates(CancellationTokenSource backgroundUpdatesCts) + { + try + { + backgroundUpdatesCts.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + private static async Task AwaitStatusPulseAsync(Task statusPulseTask) { try @@ -2463,6 +2744,67 @@ private static async Task AwaitBackgroundTaskAsync(Task task) return disconnectedContent; } + private async Task TryCreateReplacementStreamingHandleAsync( + string chatId, + string? replyMessageId, + string latestRenderedContent, + FeishuStreamingCardChrome chrome, + FeishuOptions effectiveOptions, + CancellationToken cancellationToken) + { + try + { + return await CreateStreamingHandleWithOverflowFallbackAsync( + chatId, + replyMessageId, + latestRenderedContent, + effectiveOptions, + chrome, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create replacement Feishu streaming card for chat {ChatId}", chatId); + return null; + } + } + + private async Task CreateStreamingHandleWithOverflowFallbackAsync( + string chatId, + string? replyMessageId, + string initialContent, + FeishuOptions effectiveOptions, + FeishuStreamingCardChrome chrome, + CancellationToken cancellationToken) + { + try + { + return await _cardKit.CreateStreamingHandleAsync( + chatId, + replyMessageId, + initialContent, + effectiveOptions.DefaultCardTitle, + cancellationToken, + effectiveOptions, + chrome); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("code: 200860", StringComparison.Ordinal)) + { + _logger.LogWarning( + ex, + "Streaming card creation overflowed; switching to plain-text fallback stream (chatId={ChatId}, replyMessageId={ReplyMessageId})", + chatId, + replyMessageId ?? ""); + return await FeishuTextStreamingFallbackHandleFactory.CreateAsync( + _cardKit, + chatId, + replyMessageId, + initialContent, + effectiveOptions, + cancellationToken); + } + } + private List GetChatSessionEntities(string chatKey, string? username) { using var scope = _serviceProvider.CreateScope(); @@ -2483,15 +2825,52 @@ private static string BuildSessionOptionText(ChatSessionEntity session) return $"{goalRuntimePrefix}{workspaceName} · {sessionLabel} · {GetToolDisplayName(session.ToolId)}"; } - private string BuildCompletionNotificationText(string sessionId, string? fallbackWorkspacePath = null) + private async Task BuildCompletionPresentationAsync( + string sessionId, + string toolId, + string baseStatusMarkdown, + string? fallbackWorkspacePath = null) { ChatSessionEntity? session; using (var scope = _serviceProvider.CreateScope()) { var repo = scope.ServiceProvider.GetRequiredService(); - session = repo.GetByIdAsync(sessionId).GetAwaiter().GetResult(); + session = await repo.GetByIdAsync(sessionId); + } + + var goal = await TryGetGoalRuntimeGoalAsync(sessionId, toolId, session); + return new CompletionPresentation( + BuildCompletionStatusMarkdown(baseStatusMarkdown, session, goal), + BuildCompletionNotificationText(sessionId, session, goal, fallbackWorkspacePath)); + } + + private async Task TryGetGoalRuntimeGoalAsync( + string sessionId, + string toolId, + ChatSessionEntity? session) + { + if (!IsGoalRuntimeSession(session)) + { + return null; + } + + try + { + return await _cliExecutor.TryGetGoalRuntimeGoalAsync(sessionId, toolId); } + catch (Exception ex) + { + _logger.LogDebug(ex, "读取 Goal runtime 完成态失败: Session={SessionId}", sessionId); + return null; + } + } + private string BuildCompletionNotificationText( + string sessionId, + ChatSessionEntity? session, + AppServerGoalSnapshot? goal, + string? fallbackWorkspacePath = null) + { var workspaceName = TryGetSessionWorkspaceDirectoryName(sessionId) ?? ExtractWorkspaceDirectoryName(session?.WorkspacePath) ?? ExtractWorkspaceDirectoryName(fallbackWorkspacePath) @@ -2499,10 +2878,51 @@ private string BuildCompletionNotificationText(string sessionId, string? fallbac var sessionLabel = GetSessionDisplayLabel(session); return BuildSessionStatusMarkdown( - $"当前会话:{workspaceName} {sessionLabel}\n已完成", + $"当前会话:{workspaceName} {sessionLabel}\n{BuildCompletionSummaryLine(session, goal)}", session); } + private static string BuildCompletionStatusMarkdown( + string baseStatusMarkdown, + ChatSessionEntity? session, + AppServerGoalSnapshot? goal) + { + if (!IsGoalRuntimeSession(session)) + { + return FeishuStreamingStatusFormatter.WithCompletedState(baseStatusMarkdown); + } + + return NormalizeGoalRuntimeStatus(goal?.Status) switch + { + "active" => GoalRuntimeCompletionStateFormatter.WithGoalContinuingState(baseStatusMarkdown), + "paused" => GoalRuntimeCompletionStateFormatter.WithGoalPausedState(baseStatusMarkdown), + "complete" => FeishuStreamingStatusFormatter.WithCompletedState(baseStatusMarkdown), + _ => GoalRuntimeCompletionStateFormatter.WithTurnFinishedState(baseStatusMarkdown) + }; + } + + private static string BuildCompletionSummaryLine(ChatSessionEntity? session, AppServerGoalSnapshot? goal) + { + if (!IsGoalRuntimeSession(session)) + { + return "已完成"; + } + + return NormalizeGoalRuntimeStatus(goal?.Status) switch + { + "active" => "本轮执行已结束,Goal 仍在运行", + "paused" => "Goal 已暂停", + "complete" => "Goal 已完成", + "budgetlimited" => "Goal 已达到预算上限", + _ => "本轮执行已结束" + }; + } + + private static string? NormalizeGoalRuntimeStatus(string? status) + => status?.Trim().ToLowerInvariant(); + + private sealed record CompletionPresentation(string StatusMarkdown, string NotificationText); + private static string GetSessionDisplayLabel(ChatSessionEntity? session) { if (!string.IsNullOrWhiteSpace(session?.Title)) @@ -2749,6 +3169,8 @@ private bool ProcessJsonlChunk( string sessionId, ICliToolAdapter adapter, StringBuilder assistantMessageBuilder, + StringBuilder turnAssistantMessageBuilder, + StringBuilder finalAnswerMessageBuilder, StringBuilder jsonlBuffer, FeishuStreamingCardChrome? chrome) { @@ -2778,7 +3200,14 @@ private bool ProcessJsonlChunk( jsonlBuffer.Remove(0, newlineIndex + 1); // 澶勭悊杩欎竴琛? - hasStructuredTodoList |= ProcessJsonlLine(line, sessionId, adapter, assistantMessageBuilder, chrome); + hasStructuredTodoList |= ProcessJsonlLine( + line, + sessionId, + adapter, + assistantMessageBuilder, + turnAssistantMessageBuilder, + finalAnswerMessageBuilder, + chrome); } return hasStructuredTodoList; @@ -2792,6 +3221,8 @@ private bool ProcessJsonlLine( string sessionId, ICliToolAdapter adapter, StringBuilder assistantMessageBuilder, + StringBuilder turnAssistantMessageBuilder, + StringBuilder finalAnswerMessageBuilder, FeishuStreamingCardChrome? chrome) { var trimmedLine = line.Trim(); @@ -2826,6 +3257,11 @@ private bool ProcessJsonlLine( if (!string.IsNullOrEmpty(assistantMessage)) { assistantMessageBuilder.Append(assistantMessage); + turnAssistantMessageBuilder.Append(assistantMessage); + if (string.Equals(outputEvent.AssistantPhase, "final_answer", StringComparison.Ordinal)) + { + finalAnswerMessageBuilder.Append(assistantMessage); + } } return LowInterruptionContinueHelper.HasStructuredTodoList(outputEvent); @@ -2856,6 +3292,7 @@ private sealed class ActiveSessionExecution : IDisposable private readonly object _contentLock = new(); private int _superseded; private int _runningFrame; + private FeishuStreamingHandle _handle; private string _latestRenderedContent; public ActiveSessionExecution( @@ -2868,7 +3305,7 @@ public ActiveSessionExecution( { SessionId = sessionId; MessageId = messageId; - Handle = handle; + _handle = handle; Chrome = chrome; BaseStatusMarkdown = baseStatusMarkdown; InitialContent = initialContent; @@ -2885,7 +3322,7 @@ public ActiveSessionExecution( public string MessageId { get; } - public FeishuStreamingHandle Handle { get; } + public FeishuStreamingHandle Handle => Volatile.Read(ref _handle); public FeishuStreamingCardChrome Chrome { get; } @@ -2903,6 +3340,12 @@ public ActiveSessionExecution( public bool IsPulsePaused() => PulseGate.IsPaused(); + public void ReplaceHandle(FeishuStreamingHandle handle) + { + ArgumentNullException.ThrowIfNull(handle); + Volatile.Write(ref _handle, handle); + } + public void SetLatestRenderedContent(string content) { lock (_contentLock) diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs index 077bbc6..d2fea89 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs @@ -21,15 +21,31 @@ public class FeishuHelpCardBuilder public ElementsCardV2Dto BuildCommandListCardV2( List categories, bool showRefreshButton = true, - bool replyTtsEnabled = false, + bool fullReplyDocEnabled = false, + bool finalReplyDocEnabled = false, bool showGoalQuickActionButtons = true, - bool showSuperpowersQuickActions = true) + bool showSuperpowersQuickActions = true, + bool audioFullReplyDocEnabled = false, + bool audioFinalReplyDocEnabled = false, + bool referencedMarkdownDocImportEnabled = false) { var elements = new List(); + var fullReplyDocumentEnabled = fullReplyDocEnabled; + var finalReplyDocumentEnabled = finalReplyDocEnabled; // 顶部操作按钮组 if (showRefreshButton) { + elements.Add(new + { + tag = "div", + text = new + { + tag = "lark_md", + content = "\u624b\u673a\u4f7f\u7528\u98de\u4e66\u6587\u6863\u53ef\u542c\u8bed\u97f3\uff0c\u70b9 `...` \u542c\u6587\u6863" + } + }); + elements.Add(new { tag = "column_set", @@ -96,13 +112,60 @@ public ElementsCardV2Dto BuildCommandListCardV2( columns = new[] { BuildTopActionColumn( - $"语音回复:{(replyTtsEnabled ? "开" : "关")}", - replyTtsEnabled ? "primary" : "default", - new { action = FeishuHelpCardAction.ToggleReplyTtsAction }) + $"完整回复文档:{(fullReplyDocumentEnabled ? "开" : "关")}", + fullReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFullReplyDocAction }), + BuildTopActionColumn( + $"结论回复文档:{(finalReplyDocumentEnabled ? "开" : "关")}", + finalReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFinalReplyDocAction }) } }); + + } + + /* + if (!showRefreshButton) + { + elements.Add(new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + $"瀹屾暣鍥炲鏂囨。锛歿(fullReplyDocumentEnabled ? "寮€" : "鍏?)}", + fullReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFullReplyDocAction }), + BuildTopActionColumn( + $"缁撹鍥炲鏂囨。锛歿(finalReplyDocumentEnabled ? "寮€" : "鍏?)}", + finalReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFinalReplyDocAction }) + } + }); + + elements.Add(BuildAudioReplyDocumentToggleRow( + audioFullReplyDocEnabled, + audioFinalReplyDocEnabled)); + } + + */ + + if (!showRefreshButton) + { + elements.Add(BuildReplyDocumentToggleRow( + fullReplyDocumentEnabled, + finalReplyDocumentEnabled)); } + elements.Add(BuildAudioReplyDocumentToggleRow( + audioFullReplyDocEnabled, + audioFinalReplyDocEnabled)); + elements.Add(BuildReferencedMarkdownDocImportToggleRow(referencedMarkdownDocImportEnabled)); + elements.Add(BuildDocumentAdminHintElement()); + elements.Add(BuildDocumentAdminActionRow()); + // 每个分组显示为分类按钮,避免首页元素超限 foreach (var category in categories) { @@ -139,9 +202,16 @@ public ElementsCardV2Dto BuildCommandListCardV2( public ElementsCardV2Dto BuildFilteredCardV2( List categories, string keyword, + bool fullReplyDocEnabled = false, + bool finalReplyDocEnabled = false, bool showGoalQuickActionButtons = true, - bool showSuperpowersQuickActions = true) + bool showSuperpowersQuickActions = true, + bool audioFullReplyDocEnabled = false, + bool audioFinalReplyDocEnabled = false, + bool referencedMarkdownDocImportEnabled = false) { + var fullReplyDocumentEnabled = fullReplyDocEnabled; + var finalReplyDocumentEnabled = finalReplyDocEnabled; var elements = new List { new @@ -186,9 +256,46 @@ public ElementsCardV2Dto BuildFilteredCardV2( } } }, + new + { + tag = "div", + text = new + { + tag = "lark_md", + content = "\u624b\u673a\u4f7f\u7528\u98de\u4e66\u6587\u6863\u53ef\u542c\u8bed\u97f3\uff0c\u70b9 `...` \u542c\u6587\u6863" + } + }, + new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + $"完整回复文档:{(fullReplyDocumentEnabled ? "开" : "关")}", + fullReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFullReplyDocAction }), + BuildTopActionColumn( + $"结论回复文档:{(finalReplyDocumentEnabled ? "开" : "关")}", + finalReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFinalReplyDocAction }) + } + }, new { tag = "hr" } }; + elements.Insert( + Math.Max(0, elements.Count - 1), + BuildAudioReplyDocumentToggleRow( + audioFullReplyDocEnabled, + audioFinalReplyDocEnabled)); + var documentAdminInsertIndex = Math.Max(0, elements.Count - 1); + elements.Insert(documentAdminInsertIndex, BuildReferencedMarkdownDocImportToggleRow(referencedMarkdownDocImportEnabled)); + documentAdminInsertIndex++; + elements.Insert(documentAdminInsertIndex, BuildDocumentAdminHintElement()); + elements.Insert(documentAdminInsertIndex + 1, BuildDocumentAdminActionRow()); + var allCommands = categories.SelectMany(c => c.Commands).ToList(); if (allCommands.Count > 0) { @@ -819,7 +926,8 @@ public ElementsCardV2Dto BuildSyncSessionProviderConfirmCardV2( string sessionId, string chatKey, string? toolId, - bool showAllSessions = false) + bool showAllSessions = false, + int? sessionPage = null) { var elements = new List { @@ -849,7 +957,8 @@ public ElementsCardV2Dto BuildSyncSessionProviderConfirmCardV2( { action = "open_session_manager", chat_key = chatKey, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }), BuildTopActionColumn( "中断并同步 Provider", @@ -860,7 +969,8 @@ public ElementsCardV2Dto BuildSyncSessionProviderConfirmCardV2( session_id = sessionId, chat_key = chatKey, tool_id = toolId, - show_all_sessions = showAllSessions + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }), BuildTopActionColumn( "查看当前状态", @@ -870,7 +980,9 @@ public ElementsCardV2Dto BuildSyncSessionProviderConfirmCardV2( action = FeishuHelpCardAction.StatusGoalAction, session_id = sessionId, chat_key = chatKey, - tool_id = toolId + tool_id = toolId, + show_all_sessions = showAllSessions, + session_page = sessionPage ?? 0 }) } } @@ -898,15 +1010,31 @@ public ElementsCardV2Dto BuildSyncSessionProviderConfirmCardV2( public string BuildCommandListCard( List categories, bool showRefreshButton = true, - bool replyTtsEnabled = false, + bool fullReplyDocEnabled = false, + bool finalReplyDocEnabled = false, bool showGoalQuickActionButtons = true, - bool showSuperpowersQuickActions = true) + bool showSuperpowersQuickActions = true, + bool audioFullReplyDocEnabled = false, + bool audioFinalReplyDocEnabled = false, + bool referencedMarkdownDocImportEnabled = false) { var elements = new List(); + var fullReplyDocumentEnabled = fullReplyDocEnabled; + var finalReplyDocumentEnabled = finalReplyDocEnabled; // 顶部操作按钮组 if (showRefreshButton) { + elements.Add(new + { + tag = "div", + text = new + { + tag = "lark_md", + content = "\u624b\u673a\u4f7f\u7528\u98de\u4e66\u6587\u6863\u53ef\u542c\u8bed\u97f3\uff0c\u70b9 `...` \u542c\u6587\u6863" + } + }); + elements.Add(new { tag = "column_set", @@ -973,13 +1101,60 @@ public string BuildCommandListCard( columns = new[] { BuildTopActionColumn( - $"语音回复:{(replyTtsEnabled ? "开" : "关")}", - replyTtsEnabled ? "primary" : "default", - new { action = FeishuHelpCardAction.ToggleReplyTtsAction }) + $"完整回复文档:{(fullReplyDocumentEnabled ? "开" : "关")}", + fullReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFullReplyDocAction }), + BuildTopActionColumn( + $"结论回复文档:{(finalReplyDocumentEnabled ? "开" : "关")}", + finalReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFinalReplyDocAction }) } }); + } + /* + if (!showRefreshButton) + { + elements.Add(new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + $"瀹屾暣鍥炲鏂囨。锛歿(fullReplyDocumentEnabled ? "寮€" : "鍏?)}", + fullReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFullReplyDocAction }), + BuildTopActionColumn( + $"缁撹鍥炲鏂囨。锛歿(finalReplyDocumentEnabled ? "寮€" : "鍏?)}", + finalReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFinalReplyDocAction }) + } + }); + + elements.Add(BuildAudioReplyDocumentToggleRow( + audioFullReplyDocEnabled, + audioFinalReplyDocEnabled)); + } + + */ + + if (!showRefreshButton) + { + elements.Add(BuildReplyDocumentToggleRow( + fullReplyDocumentEnabled, + finalReplyDocumentEnabled)); + } + + elements.Add(BuildAudioReplyDocumentToggleRow( + audioFullReplyDocEnabled, + audioFinalReplyDocEnabled)); + elements.Add(BuildReferencedMarkdownDocImportToggleRow(referencedMarkdownDocImportEnabled)); + elements.Add(BuildDocumentAdminHintElement()); + elements.Add(BuildDocumentAdminActionRow()); + // 每个分组显示为分类按钮,避免首页元素超限 foreach (var category in categories) { @@ -1115,6 +1290,7 @@ public string BuildCommandListCard( public string BuildFilteredCard( List categories, string keyword, + bool referencedMarkdownDocImportEnabled = false, bool showGoalQuickActionButtons = true, bool showSuperpowersQuickActions = true) { @@ -1162,6 +1338,8 @@ public string BuildFilteredCard( } }); + elements.Add(BuildReferencedMarkdownDocImportToggleRow(referencedMarkdownDocImportEnabled)); + elements.Add(new { tag = "hr" }); // 匹配的命令(不分组,直接显示) @@ -1440,6 +1618,8 @@ private static object BuildCategoryActionRow(FeishuCommandCategory category) private static object BuildTopActionColumn(string text, string type, object value) { + text = NormalizeTopActionText(text, value); + return new { tag = "column", @@ -1466,6 +1646,300 @@ private static object BuildTopActionColumn(string text, string type, object valu }; } + private static object BuildAudioReplyDocumentToggleRow( + bool audioFullReplyDocEnabled, + bool audioFinalReplyDocEnabled) + { + return new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + $"听完整文档:{(audioFullReplyDocEnabled ? "开" : "关")}", + audioFullReplyDocEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleAudioFullReplyDocAction, enabled = audioFullReplyDocEnabled }), + BuildTopActionColumn( + $"听结论文档:{(audioFinalReplyDocEnabled ? "开" : "关")}", + audioFinalReplyDocEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleAudioFinalReplyDocAction, enabled = audioFinalReplyDocEnabled }) + } + }; + } + + private static object BuildReferencedMarkdownDocImportToggleRow(bool referencedMarkdownDocImportEnabled) + { + return new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + "referenced_markdown_doc_import", + referencedMarkdownDocImportEnabled ? "primary" : "default", + new + { + action = FeishuHelpCardAction.ToggleReferencedMarkdownDocImportAction, + enabled = referencedMarkdownDocImportEnabled + }) + } + }; + } + + #if false + private static object BuildReplyDocumentToggleRow( + bool fullReplyDocumentEnabled, + bool finalReplyDocumentEnabled) + { + return new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + $"瀹屾暣鍥炲鏂囨。锛歿(fullReplyDocumentEnabled ? "寮€" : "鍏?)}", + fullReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFullReplyDocAction }), + BuildTopActionColumn( + $"缁撹鍥炲鏂囨。锛歿(finalReplyDocumentEnabled ? "寮€" : "鍏?)}", + finalReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFinalReplyDocAction }) + } + }; + } + + private static object BuildDocumentAdminHintElement() + { + return new + { + tag = "div", + text = new + { + tag = "lark_md", + content = "点击下方按钮,可将当前操作者保存为回复文档管理员。后续新建文档会自动授予该 OpenID 管理权限。" + } + }; + } + + private static object BuildDocumentAdminActionRow() + { + return new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + "保存当前操作者 OpenID 为文档管理员", + "default", + new { action = FeishuHelpCardAction.SetDocumentAdminOpenIdAction }) + } + }; + } + + private static string NormalizeTopActionText(string text, object value) + { + if (value is null) + { + return text; + } + + var action = value + .GetType() + .GetProperty("action") + ?.GetValue(value) as string; + + if (string.Equals(action, FeishuHelpCardAction.ToggleAudioFullReplyDocAction, StringComparison.Ordinal)) + { + return ResolveToggleState(value) + ? "听完整文档:开" + : "听完整文档:关"; + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleAudioFinalReplyDocAction, StringComparison.Ordinal)) + { + return ResolveToggleState(value) + ? "听结论文档:开" + : "听结论文档:关"; + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleFullReplyDocAction, StringComparison.Ordinal)) + { + return text.Contains("开", StringComparison.Ordinal) + ? "完整回复文档:开" + : "完整回复文档:关"; + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleFinalReplyDocAction, StringComparison.Ordinal)) + { + return text.Contains("开", StringComparison.Ordinal) + ? "结论回复文档:开" + : "结论回复文档:关"; + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleAudioFullReplyDocAction, StringComparison.Ordinal)) + { + return text.Contains("寮€", StringComparison.Ordinal) + ? "听完整文档:开" + : "听完整文档:关"; + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleAudioFinalReplyDocAction, StringComparison.Ordinal)) + { + return text.Contains("寮€", StringComparison.Ordinal) + ? "听结论文档:开" + : "听结论文档:关"; + } + + return text; + } + + private static bool ResolveToggleState(object value) + { + return value + .GetType() + .GetProperty("enabled") + ?.GetValue(value) is true; + } + + #endif + + private static object BuildReplyDocumentToggleRow( + bool fullReplyDocumentEnabled, + bool finalReplyDocumentEnabled) + { + return new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + "full_reply_doc", + fullReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFullReplyDocAction, enabled = fullReplyDocumentEnabled }), + BuildTopActionColumn( + "final_reply_doc", + finalReplyDocumentEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleFinalReplyDocAction, enabled = finalReplyDocumentEnabled }) + } + }; + } + + private static object BuildDocumentAdminHintElement() + { + return new + { + tag = "div", + text = new + { + tag = "lark_md", + content = "\u70b9\u51fb\u4e0b\u65b9\u6309\u94ae\uff0c\u53ef\u5c06\u5f53\u524d\u64cd\u4f5c\u8005\u4fdd\u5b58\u4e3a\u56de\u590d\u6587\u6863\u7ba1\u7406\u5458\u3002\u540e\u7eed\u65b0\u5efa\u6587\u6863\u4f1a\u81ea\u52a8\u6388\u4e88\u8be5 OpenID \u7ba1\u7406\u6743\u9650\u3002" + } + }; + } + + private static object BuildDocumentAdminActionRow() + { + return new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + "\u4fdd\u5b58\u5f53\u524d\u64cd\u4f5c\u8005 OpenID \u4e3a\u6587\u6863\u7ba1\u7406\u5458", + "default", + new { action = FeishuHelpCardAction.SetDocumentAdminOpenIdAction }) + } + }; + } + + private static string NormalizeTopActionText(string text, object value) + { + if (value is null) + { + return text; + } + + var action = value + .GetType() + .GetProperty("action") + ?.GetValue(value) as string; + + if (string.Equals(action, FeishuHelpCardAction.ToggleAudioFullReplyDocAction, StringComparison.Ordinal)) + { + return ResolveToggleState(value) + ? "\u542c\u5b8c\u6574\u6587\u6863\uff1a\u5f00" + : "\u542c\u5b8c\u6574\u6587\u6863\uff1a\u5173"; + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleAudioFinalReplyDocAction, StringComparison.Ordinal)) + { + return ResolveToggleState(value) + ? "\u542c\u7ed3\u8bba\u6587\u6863\uff1a\u5f00" + : "\u542c\u7ed3\u8bba\u6587\u6863\uff1a\u5173"; + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleFullReplyDocAction, StringComparison.Ordinal)) + { + return TryResolveToggleState(value, out var fullReplyEnabled) + ? (fullReplyEnabled ? "\u5b8c\u6574\u56de\u590d\u6587\u6863\uff1a\u5f00" : "\u5b8c\u6574\u56de\u590d\u6587\u6863\uff1a\u5173") + : (text.Contains("\u5f00", StringComparison.Ordinal) + ? "\u5b8c\u6574\u56de\u590d\u6587\u6863\uff1a\u5f00" + : "\u5b8c\u6574\u56de\u590d\u6587\u6863\uff1a\u5173"); + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleFinalReplyDocAction, StringComparison.Ordinal)) + { + return TryResolveToggleState(value, out var finalReplyEnabled) + ? (finalReplyEnabled ? "\u7ed3\u8bba\u56de\u590d\u6587\u6863\uff1a\u5f00" : "\u7ed3\u8bba\u56de\u590d\u6587\u6863\uff1a\u5173") + : (text.Contains("\u5f00", StringComparison.Ordinal) + ? "\u7ed3\u8bba\u56de\u590d\u6587\u6863\uff1a\u5f00" + : "\u7ed3\u8bba\u56de\u590d\u6587\u6863\uff1a\u5173"); + } + + if (string.Equals(action, FeishuHelpCardAction.ToggleReferencedMarkdownDocImportAction, StringComparison.Ordinal)) + { + return TryResolveToggleState(value, out var referencedMarkdownEnabled) + ? (referencedMarkdownEnabled ? "MD转在线文档:开" : "MD转在线文档:关") + : (text.Contains("\u5f00", StringComparison.Ordinal) + ? "MD转在线文档:开" + : "MD转在线文档:关"); + } + + return text; + } + + private static bool TryResolveToggleState(object value, out bool enabled) + { + if (value + .GetType() + .GetProperty("enabled") + ?.GetValue(value) is bool resolvedEnabled) + { + enabled = resolvedEnabled; + return true; + } + + enabled = default; + return false; + } + + private static bool ResolveToggleState(object value) + { + return TryResolveToggleState(value, out var enabled) && enabled; + } + private static object BuildCommandActionRow(FeishuCommand command) { var description = SanitizeMarkdown(command.Description); diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs index b56f8d9..07afea5 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs @@ -487,11 +487,16 @@ private async Task HandleFeishuHelpAsync(string chatId, string replyToMessageId, _logger.LogInformation("🔥 [FeishuHelp] 获取到 {Count} 个分组", categories.Count); var showGoalQuickActionButtons = ResolveShowGoalQuickActionButtons(chatId, webUsername, toolId); var showSuperpowersQuickActions = ResolveShowSuperpowersQuickActions(chatId, webUsername, toolId); + var replyDocumentSettings = await GetReplyDocumentSettingsAsync(chatId, webUsername); card = _cardBuilder.BuildCommandListCardV2( categories, - replyTtsEnabled: await GetReplyTtsEnabledAsync(chatId, webUsername), + fullReplyDocEnabled: replyDocumentSettings.FullReplyDocEnabled, + finalReplyDocEnabled: replyDocumentSettings.FinalReplyDocEnabled, showGoalQuickActionButtons: showGoalQuickActionButtons, - showSuperpowersQuickActions: showSuperpowersQuickActions); + showSuperpowersQuickActions: showSuperpowersQuickActions, + audioFullReplyDocEnabled: replyDocumentSettings.AudioFullReplyDocEnabled, + audioFinalReplyDocEnabled: replyDocumentSettings.AudioFinalReplyDocEnabled, + referencedMarkdownDocImportEnabled: replyDocumentSettings.ReferencedMarkdownDocImportEnabled); } else { @@ -499,11 +504,17 @@ private async Task HandleFeishuHelpAsync(string chatId, string replyToMessageId, _logger.LogInformation("🔥 [FeishuHelp] 过滤后获取到 {Count} 个分组", categories.Count); var showGoalQuickActionButtons = ResolveShowGoalQuickActionButtons(chatId, webUsername, toolId); var showSuperpowersQuickActions = ResolveShowSuperpowersQuickActions(chatId, webUsername, toolId); + var replyDocumentSettings = await GetReplyDocumentSettingsAsync(chatId, webUsername); card = _cardBuilder.BuildFilteredCardV2( categories, keyword, - showGoalQuickActionButtons, - showSuperpowersQuickActions); + fullReplyDocEnabled: replyDocumentSettings.FullReplyDocEnabled, + finalReplyDocEnabled: replyDocumentSettings.FinalReplyDocEnabled, + showGoalQuickActionButtons: showGoalQuickActionButtons, + showSuperpowersQuickActions: showSuperpowersQuickActions, + audioFullReplyDocEnabled: replyDocumentSettings.AudioFullReplyDocEnabled, + audioFinalReplyDocEnabled: replyDocumentSettings.AudioFinalReplyDocEnabled, + referencedMarkdownDocImportEnabled: replyDocumentSettings.ReferencedMarkdownDocImportEnabled); } _logger.LogDebug("🔥 [FeishuHelp] 帮助卡片DTO内容: {Card}", JsonSerializer.Serialize(card)); @@ -583,20 +594,25 @@ private async Task ResolveEffectiveOptionsAsync(string? username, return await userFeishuBotConfigService.GetEffectiveOptionsAsync(username); } - private async Task GetReplyTtsEnabledAsync(string chatId, string? username) + private async Task<(bool FullReplyDocEnabled, bool FinalReplyDocEnabled, bool AudioFullReplyDocEnabled, bool AudioFinalReplyDocEnabled, bool ReferencedMarkdownDocImportEnabled)> GetReplyDocumentSettingsAsync(string chatId, string? username) { var resolvedUsername = string.IsNullOrWhiteSpace(username) ? _feishuChannel.GetSessionUsername(chatId) : username; if (string.IsNullOrWhiteSpace(resolvedUsername)) { - return false; + return (false, false, false, false, false); } using var scope = _serviceProvider.CreateScope(); var userFeishuBotConfigService = scope.ServiceProvider.GetRequiredService(); var config = await userFeishuBotConfigService.GetByUsernameAsync(resolvedUsername); - return config?.ReplyTtsEnabled == true; + return ( + config?.FullReplyDocEnabled == true, + config?.FinalReplyDocEnabled == true, + config?.AudioFullReplyDocEnabled == true, + config?.AudioFinalReplyDocEnabled == true, + config?.ReferencedMarkdownDocImportEnabled == true); } private bool ResolveShowGoalQuickActionButtons(string chatId, string? username, string? toolId) diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs deleted file mode 100644 index 473739e..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs +++ /dev/null @@ -1,258 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using WebCodeCli.Domain.Common.Extensions; -using WebCodeCli.Domain.Common.Options; -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(IFeishuReplyTtsPlatformService), ServiceLifetime.Scoped)] -public sealed class FeishuReplyTtsPlatformService : IFeishuReplyTtsPlatformService -{ - private const string VoicesUnavailableMessage = "Feishu reply TTS voices are currently unavailable."; - - private readonly ReplyTtsStorageRootResolver _storageRootResolver; - private readonly FeishuReplyTtsOptions _options; - private readonly ISherpaKokoroTtsClient _ttsClient; - private readonly IReplyTtsLocalServiceManager _localServiceManager; - - public FeishuReplyTtsPlatformService( - ReplyTtsStorageRootResolver storageRootResolver, - IOptions options, - ISherpaKokoroTtsClient ttsClient, - IReplyTtsLocalServiceManager localServiceManager) - { - _storageRootResolver = storageRootResolver ?? throw new ArgumentNullException(nameof(storageRootResolver)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _ttsClient = ttsClient ?? throw new ArgumentNullException(nameof(ttsClient)); - _localServiceManager = localServiceManager ?? throw new ArgumentNullException(nameof(localServiceManager)); - } - - public async Task GetHealthAsync(CancellationToken cancellationToken = default) - { - var storageHealth = _storageRootResolver.Resolve(); - if (!storageHealth.IsAvailable) - { - return storageHealth; - } - - var ffmpegResolution = ReplyTtsFfmpegPathResolver.Resolve(_options, storageHealth); - if (!ffmpegResolution.IsAvailable) - { - return MergeHealth( - storageHealth, - new FeishuReplyTtsHealthStatus - { - IsAvailable = false, - Message = ffmpegResolution.Message, - ServiceStatus = "ffmpeg-unavailable" - }); - } - - try - { - var serviceHealth = await _ttsClient.GetHealthAsync(cancellationToken); - return MergeHealth(storageHealth, serviceHealth); - } - catch (Exception ex) when (!IsCancellation(ex)) - { - return MergeHealth( - storageHealth, - new FeishuReplyTtsHealthStatus - { - IsAvailable = false, - Message = $"Local Kokoro/sherpa-onnx service is unavailable: {ex.Message}", - ServiceStatus = "unreachable" - }); - } - } - - public async Task> GetVoicesAsync(CancellationToken cancellationToken = default) - { - var storageHealth = _storageRootResolver.Resolve(); - if (!storageHealth.IsAvailable) - { - return []; - } - - try - { - return await _ttsClient.GetVoicesAsync(cancellationToken); - } - catch (Exception ex) when (!IsCancellation(ex)) - { - return []; - } - } - - public async Task ResolveVoiceOrFallbackAsync(string? savedVoiceId, CancellationToken cancellationToken = default) - { - var health = await GetHealthAsync(cancellationToken); - if (!health.IsAvailable) - { - return new FeishuReplyTtsVoiceResolutionResult - { - Success = false, - UsedFallback = false, - Message = string.IsNullOrWhiteSpace(health.Message) - ? VoicesUnavailableMessage - : health.Message - }; - } - - var voices = await GetVoicesAsync(cancellationToken); - if (voices.Count == 0) - { - return new FeishuReplyTtsVoiceResolutionResult - { - Success = false, - UsedFallback = false, - Message = VoicesUnavailableMessage - }; - } - - var normalizedSavedVoiceId = Normalize(savedVoiceId); - var normalizedDefaultVoiceId = await GetEffectiveDefaultVoiceIdAsync(cancellationToken); - - var savedVoice = FindVoice(voices, normalizedSavedVoiceId); - if (savedVoice is not null) - { - return new FeishuReplyTtsVoiceResolutionResult - { - Success = true, - VoiceId = savedVoice.VoiceId, - Voice = savedVoice, - UsedFallback = false - }; - } - - var defaultVoice = FindVoice(voices, normalizedDefaultVoiceId); - if (defaultVoice is not null) - { - return new FeishuReplyTtsVoiceResolutionResult - { - Success = true, - VoiceId = defaultVoice.VoiceId, - Voice = defaultVoice, - UsedFallback = !string.IsNullOrWhiteSpace(normalizedSavedVoiceId), - Message = !string.IsNullOrWhiteSpace(normalizedSavedVoiceId) - ? $"Saved Feishu reply TTS voice '{normalizedSavedVoiceId}' is unavailable. Falling back to '{defaultVoice.VoiceId}'." - : string.Empty - }; - } - - return new FeishuReplyTtsVoiceResolutionResult - { - Success = false, - UsedFallback = false, - Message = "No Feishu reply TTS voice is available. Save a valid voice or configure a default voice." - }; - } - - public async Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default) - { - var storageHealth = _storageRootResolver.Resolve(); - if (!storageHealth.IsAvailable) - { - return storageHealth; - } - - var ffmpegResolution = ReplyTtsFfmpegPathResolver.Resolve(_options, storageHealth); - if (!ffmpegResolution.IsAvailable) - { - return MergeHealth( - storageHealth, - new FeishuReplyTtsHealthStatus - { - IsAvailable = false, - Message = ffmpegResolution.Message, - ServiceStatus = "ffmpeg-unavailable" - }); - } - - var startHealth = await _localServiceManager.EnsureStartedAsync(storageHealth, cancellationToken); - if (!startHealth.IsAvailable) - { - return MergeHealth(storageHealth, startHealth); - } - - return await GetHealthAsync(cancellationToken); - } - - private FeishuReplyTtsHealthStatus MergeHealth( - FeishuReplyTtsHealthStatus storageHealth, - FeishuReplyTtsHealthStatus serviceHealth) - { - var defaultVoiceId = ResolveEffectiveDefaultVoiceId(serviceHealth.DefaultVoiceId); - return new FeishuReplyTtsHealthStatus - { - IsAvailable = storageHealth.IsAvailable && serviceHealth.IsAvailable, - StorageRoot = storageHealth.StorageRoot, - Message = BuildMessage(storageHealth.Message, serviceHealth.Message, serviceHealth.IsAvailable), - ModelsRoot = storageHealth.ModelsRoot, - CacheRoot = storageHealth.CacheRoot, - TempRoot = storageHealth.TempRoot, - LogsRoot = storageHealth.LogsRoot, - VenvRoot = storageHealth.VenvRoot, - ServiceStatus = serviceHealth.ServiceStatus, - Device = serviceHealth.Device, - DefaultVoiceId = defaultVoiceId - }; - } - - private static string BuildMessage(string storageMessage, string serviceMessage, bool isServiceAvailable) - { - if (isServiceAvailable || string.IsNullOrWhiteSpace(serviceMessage)) - { - return storageMessage; - } - - if (string.IsNullOrWhiteSpace(storageMessage)) - { - return serviceMessage; - } - - return $"{storageMessage} {serviceMessage}".Trim(); - } - - private static FeishuReplyTtsVoiceOption? FindVoice( - IReadOnlyList voices, - string? voiceId) - { - if (string.IsNullOrWhiteSpace(voiceId)) - { - return null; - } - - return voices.FirstOrDefault(voice => string.Equals(voice.VoiceId, voiceId, StringComparison.OrdinalIgnoreCase)); - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) - ? null - : value.Trim(); - } - - private async Task GetEffectiveDefaultVoiceIdAsync(CancellationToken cancellationToken) - { - var configuredDefaultVoiceId = Normalize(_options.TtsDefaultVoiceId); - if (!string.IsNullOrWhiteSpace(configuredDefaultVoiceId)) - { - return configuredDefaultVoiceId; - } - - var health = await GetHealthAsync(cancellationToken); - return Normalize(health.DefaultVoiceId); - } - - private string? ResolveEffectiveDefaultVoiceId(string? serviceDefaultVoiceId) - { - return Normalize(_options.TtsDefaultVoiceId) ?? Normalize(serviceDefaultVoiceId); - } - - private static bool IsCancellation(Exception exception) - { - return exception is OperationCanceledException; - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs new file mode 100644 index 0000000..0e671a4 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs @@ -0,0 +1,166 @@ +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +internal sealed class FeishuStreamingCardSession +{ + internal const int MaxReplacementCardsPerLogicalStream = 10; + + private readonly Func> _replacementFactory; + private readonly Action _handleChanged; + private readonly Func? _stoppedHandleFinalizer; + private readonly bool _deferReplacementUntilNextForegroundUpdate; + private readonly SemaphoreSlim _lock = new(1, 1); + private int _replacementCount; + private bool _replacementPending; + + public FeishuStreamingCardSession( + FeishuStreamingHandle initialHandle, + Func> replacementFactory, + Action? handleChanged = null, + Func? stoppedHandleFinalizer = null, + bool deferReplacementUntilNextForegroundUpdate = false) + { + CurrentHandle = initialHandle; + _replacementFactory = replacementFactory; + _handleChanged = handleChanged ?? (_ => { }); + _stoppedHandleFinalizer = stoppedHandleFinalizer; + _deferReplacementUntilNextForegroundUpdate = deferReplacementUntilNextForegroundUpdate; + } + + public FeishuStreamingHandle CurrentHandle { get; private set; } + + public bool HasPendingReplacement => _replacementPending; + + public async Task UpdateAsync( + string content, + CancellationToken cancellationToken, + bool allowPendingReplacementActivation = true) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_replacementPending && CurrentHandle.AreCardUpdatesStopped) + { + if (!allowPendingReplacementActivation) + { + return true; + } + + return await TryCreateReplacementHandleAsync(content, cancellationToken); + } + + await CurrentHandle.UpdateAsync(content); + if (!CurrentHandle.AreCardUpdatesStopped) + { + return true; + } + + if (_deferReplacementUntilNextForegroundUpdate) + { + _replacementPending = true; + return true; + } + + return await TryCreateReplacementHandleAsync(content, cancellationToken); + } + finally + { + _lock.Release(); + } + } + + public async Task FinishAsync(string finalContent) + { + await _lock.WaitAsync(); + try + { + if (_replacementPending || CurrentHandle.AreCardUpdatesStopped) + { + return await TryCreateReplacementAndFinishAsync(finalContent); + } + + var finished = await CurrentHandle.FinishAsync(finalContent); + if (finished) + { + return true; + } + + return await TryCreateReplacementAndFinishAsync(finalContent); + } + finally + { + _lock.Release(); + } + } + + private async Task TryCreateReplacementAndFinishAsync(string finalContent) + { + if (!await TryCreateReplacementHandleAsync(finalContent, CancellationToken.None, finalizeStoppedHandle: false)) + { + return false; + } + + return await CurrentHandle.FinishAsync(finalContent); + } + + private async Task TryCreateReplacementHandleAsync( + string latestContent, + CancellationToken cancellationToken, + bool finalizeStoppedHandle = true) + { + if (_replacementCount >= MaxReplacementCardsPerLogicalStream) + { + return false; + } + + var stoppedHandle = CurrentHandle; + var replacement = await _replacementFactory(stoppedHandle, latestContent, cancellationToken); + if (replacement == null) + { + return false; + } + + if (finalizeStoppedHandle && _stoppedHandleFinalizer != null) + { + try + { + await _stoppedHandleFinalizer(stoppedHandle, latestContent, cancellationToken); + } + catch + { + } + } + + _replacementCount++; + _replacementPending = false; + CurrentHandle = replacement; + _handleChanged(replacement); + return true; + } + + public async Task SwitchHandleAsync( + FeishuStreamingHandle handle, + bool resetReplacementCount = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(handle); + + await _lock.WaitAsync(cancellationToken); + try + { + CurrentHandle = handle; + _replacementPending = false; + if (resetReplacementCount) + { + _replacementCount = 0; + } + + _handleChanged(handle); + } + finally + { + _lock.Release(); + } + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingReplacementFormatter.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingReplacementFormatter.cs new file mode 100644 index 0000000..f4a6f74 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingReplacementFormatter.cs @@ -0,0 +1,21 @@ +namespace WebCodeCli.Domain.Domain.Service.Channels; + +internal static class FeishuStreamingReplacementFormatter +{ + public const string ReplacementMessage = "当前回复已停止:当前卡片已停止更新,请查看新卡片继续结果。"; + + public static string BuildTransferredContent(string latestContent) + { + if (string.IsNullOrWhiteSpace(latestContent)) + { + return ReplacementMessage; + } + + if (latestContent.Contains(ReplacementMessage, StringComparison.Ordinal)) + { + return latestContent; + } + + return $"{latestContent}\n\n{ReplacementMessage}"; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuTextStreamingFallbackHandleFactory.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuTextStreamingFallbackHandleFactory.cs new file mode 100644 index 0000000..e9c6bc2 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuTextStreamingFallbackHandleFactory.cs @@ -0,0 +1,211 @@ +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +internal static class FeishuTextStreamingFallbackHandleFactory +{ + private const int MaxChunkChars = 3000; + private const int InitialTailChars = 6000; + private const string OverflowNotice = "飞书卡片已超限,后续改为普通文本继续输出。"; + private const string TruncationNotice = "前文已截断,仅显示最新内容。"; + + public static async Task CreateAsync( + IFeishuCardKitClient cardKit, + string chatId, + string? replyMessageId, + string initialContent, + FeishuOptions effectiveOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(cardKit); + ArgumentException.ThrowIfNullOrWhiteSpace(chatId); + ArgumentNullException.ThrowIfNull(effectiveOptions); + + var state = new TextStreamingFallbackState(cardKit, chatId, effectiveOptions); + var messageId = await state.SendInitialAsync(replyMessageId, initialContent, cancellationToken); + + return new FeishuStreamingHandle( + "text-fallback", + messageId, + (content, _) => state.UpdateAsync(content, CancellationToken.None), + (content, _) => state.FinishAsync(content, CancellationToken.None), + effectiveOptions.StreamingThrottleMs); + } + + private sealed class TextStreamingFallbackState( + IFeishuCardKitClient cardKit, + string chatId, + FeishuOptions effectiveOptions) + { + private readonly SemaphoreSlim _sendLock = new(1, 1); + private string _lastSourceContent = string.Empty; + private string? _lastReplyTargetMessageId; + + public async Task SendInitialAsync( + string? replyMessageId, + string initialContent, + CancellationToken cancellationToken) + { + await _sendLock.WaitAsync(cancellationToken); + try + { + var truncated = initialContent.Length > InitialTailChars; + var displayContent = truncated + ? TakeTail(initialContent, InitialTailChars) + : initialContent; + var segments = BuildInitialSegments(displayContent, truncated); + var firstTarget = replyMessageId; + string? firstMessageId = null; + string? lastMessageId = null; + + foreach (var segment in segments) + { + string currentMessageId; + if (string.IsNullOrWhiteSpace(lastMessageId) && !string.IsNullOrWhiteSpace(firstTarget)) + { + currentMessageId = await cardKit.ReplyTextMessageAsync(firstTarget, segment, cancellationToken, effectiveOptions); + } + else if (string.IsNullOrWhiteSpace(lastMessageId)) + { + currentMessageId = await cardKit.SendTextMessageAsync(chatId, segment, cancellationToken, effectiveOptions); + } + else + { + currentMessageId = await cardKit.ReplyTextMessageAsync(lastMessageId, segment, cancellationToken, effectiveOptions); + } + + firstMessageId ??= currentMessageId; + lastMessageId = currentMessageId; + } + + _lastReplyTargetMessageId = lastMessageId ?? firstMessageId; + _lastSourceContent = initialContent; + return firstMessageId ?? lastMessageId ?? string.Empty; + } + finally + { + _sendLock.Release(); + } + } + + public async Task UpdateAsync(string content, CancellationToken cancellationToken) + { + await _sendLock.WaitAsync(cancellationToken); + try + { + var delta = ComputeDelta(_lastSourceContent, content); + if (string.IsNullOrEmpty(delta)) + { + _lastSourceContent = content; + return true; + } + + foreach (var segment in SplitIntoChunks(delta)) + { + if (string.IsNullOrWhiteSpace(_lastReplyTargetMessageId)) + { + _lastReplyTargetMessageId = await cardKit.SendTextMessageAsync(chatId, segment, cancellationToken, effectiveOptions); + } + else + { + _lastReplyTargetMessageId = await cardKit.ReplyTextMessageAsync(_lastReplyTargetMessageId, segment, cancellationToken, effectiveOptions); + } + } + + _lastSourceContent = content; + return true; + } + finally + { + _sendLock.Release(); + } + } + + public Task FinishAsync(string content, CancellationToken cancellationToken) + => UpdateAsync(content, cancellationToken); + } + + private static IReadOnlyList BuildInitialSegments(string content, bool truncated) + { + var prefix = truncated + ? $"{OverflowNotice}\n\n{TruncationNotice}" + : OverflowNotice; + + if (string.IsNullOrWhiteSpace(content)) + { + return [prefix]; + } + + var contentSegments = SplitIntoChunks(content); + var segments = new List(contentSegments.Count + 1) + { + $"{prefix}\n\n{contentSegments[0]}" + }; + + for (var index = 1; index < contentSegments.Count; index++) + { + segments.Add(contentSegments[index]); + } + + return segments; + } + + private static List SplitIntoChunks(string content) + { + var normalized = content.Replace("\r\n", "\n", StringComparison.Ordinal); + var segments = new List(); + var start = 0; + while (start < normalized.Length) + { + var length = Math.Min(MaxChunkChars, normalized.Length - start); + var segment = normalized.Substring(start, length); + segments.Add(segment); + start += length; + } + + if (segments.Count == 0) + { + segments.Add(string.Empty); + } + + return segments; + } + + private static string ComputeDelta(string previousContent, string currentContent) + { + if (string.IsNullOrEmpty(currentContent)) + { + return string.Empty; + } + + if (string.IsNullOrEmpty(previousContent)) + { + return currentContent; + } + + if (currentContent.StartsWith(previousContent, StringComparison.Ordinal)) + { + return currentContent[previousContent.Length..]; + } + + return $"{TruncationNotice}\n\n{TakeTail(currentContent, InitialTailChars)}"; + } + + private static string TakeTail(string content, int maxChars) + { + if (string.IsNullOrEmpty(content) || content.Length <= maxChars) + { + return content; + } + + var tail = content[^maxChars..].TrimStart(); + var newlineIndex = tail.IndexOf('\n'); + if (newlineIndex >= 0 && newlineIndex < tail.Length - 1) + { + return tail[(newlineIndex + 1)..].TrimStart(); + } + + return tail; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FileReferencedMarkdownImportStateStore.cs b/WebCodeCli.Domain/Domain/Service/Channels/FileReferencedMarkdownImportStateStore.cs new file mode 100644 index 0000000..e750910 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/FileReferencedMarkdownImportStateStore.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using WebCodeCli.Domain.Common.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IReferencedMarkdownImportStateStore), ServiceLifetime.Singleton)] +public sealed class FileReferencedMarkdownImportStateStore : IReferencedMarkdownImportStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly SemaphoreSlim _mutex = new(1, 1); + private readonly string _stateFilePath; + + public FileReferencedMarkdownImportStateStore() + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "WebCodeCli"); + Directory.CreateDirectory(root); + _stateFilePath = Path.Combine(root, "FeishuMarkdownImportState.json"); + } + + public async Task GetAsync( + string folderToken, + string absolutePath, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(folderToken); + ArgumentException.ThrowIfNullOrWhiteSpace(absolutePath); + + await _mutex.WaitAsync(cancellationToken); + try + { + var entries = await LoadEntriesAsync(cancellationToken); + return entries.TryGetValue(BuildKey(folderToken, absolutePath), out var entry) + ? entry + : null; + } + finally + { + _mutex.Release(); + } + } + + public async Task UpsertAsync( + ReferencedMarkdownImportStateEntry entry, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + + await _mutex.WaitAsync(cancellationToken); + try + { + var entries = await LoadEntriesAsync(cancellationToken); + entries[BuildKey(entry.FolderToken, entry.AbsolutePath)] = entry; + await SaveEntriesAsync(entries, cancellationToken); + } + finally + { + _mutex.Release(); + } + } + + private async Task> LoadEntriesAsync( + CancellationToken cancellationToken) + { + if (!File.Exists(_stateFilePath)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + await using var stream = File.Open(_stateFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var entries = await JsonSerializer.DeserializeAsync>( + stream, + JsonOptions, + cancellationToken); + + return entries ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private async Task SaveEntriesAsync( + Dictionary entries, + CancellationToken cancellationToken) + { + var tempFilePath = _stateFilePath + ".tmp"; + await using (var stream = File.Open(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await JsonSerializer.SerializeAsync(stream, entries, JsonOptions, cancellationToken); + } + + File.Move(tempFilePath, _stateFilePath, overwrite: true); + } + + private static string BuildKey(string folderToken, string absolutePath) + => $"{folderToken.Trim()}\n{absolutePath.Trim()}"; +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/GoalQuickActionCardHelper.cs b/WebCodeCli.Domain/Domain/Service/Channels/GoalQuickActionCardHelper.cs index 314d5a4..b466b17 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/GoalQuickActionCardHelper.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/GoalQuickActionCardHelper.cs @@ -112,6 +112,18 @@ public static IReadOnlyList CreateBottomActions chatKey, toolId) }); + + actions.Add(new FeishuStreamingCardBottomAction + { + Text = SuperpowersQuickActionDefaults.CompleteWorktreeButtonText, + Type = "default", + RowKey = GoalRow3, + Value = BuildActionValue( + FeishuHelpCardAction.TemporarilyExitAndCompleteWorktreeAction, + sessionId, + chatKey, + toolId) + }); } return actions; diff --git a/WebCodeCli.Domain/Domain/Service/Channels/GoalRuntimeCompletionStateFormatter.cs b/WebCodeCli.Domain/Domain/Service/Channels/GoalRuntimeCompletionStateFormatter.cs new file mode 100644 index 0000000..849d319 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/GoalRuntimeCompletionStateFormatter.cs @@ -0,0 +1,18 @@ +namespace WebCodeCli.Domain.Domain.Service.Channels; + +internal static class GoalRuntimeCompletionStateFormatter +{ + public static string WithTurnFinishedState(string baseStatusMarkdown) + => AppendState(baseStatusMarkdown, "本轮已结束"); + + public static string WithGoalContinuingState(string baseStatusMarkdown) + => AppendState(baseStatusMarkdown, "本轮已结束 / Goal继续中"); + + public static string WithGoalPausedState(string baseStatusMarkdown) + => AppendState(baseStatusMarkdown, "Goal 已暂停"); + + private static string AppendState(string baseStatusMarkdown, string state) + => string.IsNullOrWhiteSpace(baseStatusMarkdown) + ? state + : $"{baseStatusMarkdown} · {state}"; +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs b/WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs deleted file mode 100644 index ff0803b..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public interface IAudioTranscodeService -{ - Task TranscodeChunkAsync( - string jobId, - string inputWavPath, - int chunkIndex, - CancellationToken cancellationToken = default); -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs deleted file mode 100644 index 0d522c8..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public interface IFeishuAudioMessageService -{ - Task SendAudioMessageAsync( - string chatId, - string filePath, - int durationMs, - string? username = null, - string? appId = null, - CancellationToken cancellationToken = default); -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs index 0544d7a..759e477 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs @@ -1,6 +1,7 @@ using WebCodeCli.Domain.Common.Options; using WebCodeCli.Domain.Domain.Model.Channels; using FeishuNetSdk.Im.Dtos; +using System.Text.Json; namespace WebCodeCli.Domain.Domain.Service.Channels; @@ -89,42 +90,153 @@ Task ReplyTextMessageAsync( CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null); - Task DownloadIncomingAttachmentAsync( - FeishuIncomingAttachment attachment, + Task CreateCloudDocumentAsync( + string title, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null, + string? folderToken = null) + { + throw new NotSupportedException(); + } + + Task AppendCloudDocumentTextAsync( + string documentId, + string blockId, + string text, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) { throw new NotSupportedException(); } - /// - /// 创建流式回复句柄 - /// - /// 会话 ID - /// 被回复的消息 ID(可选,为空时直接发送) - /// 初始内容 - /// 卡片标题 - /// 取消令牌 - /// 流式回复句柄 - Task UploadAudioFileAsync( - string filePath, - int durationMs, + Task SetCloudDocumentTenantReadableAsync( + string documentId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) { throw new NotSupportedException(); } - Task SendAudioMessageAsync( - string chatId, - string fileKey, - int durationMs, + Task GrantCloudDocumentMemberFullAccessAsync( + string documentId, + string openId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task GrantCloudFolderMemberFullAccessAsync( + string folderToken, + string openId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task EnsureCloudFolderAsync( + string folderName, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task MoveCloudDocumentToFolderAsync( + string documentId, + string folderToken, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task ConvertMarkdownToCloudDocumentBlocksAsync( + string markdown, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task AppendCloudDocumentBlocksAsync( + string documentId, + string blockId, + IReadOnlyCollection blocks, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task> ListCloudDocumentChildBlockIdsAsync( + string documentId, + string blockId, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task DeleteCloudDocumentChildBlocksAsync( + string documentId, + string blockId, + int startIndex, + int endIndex, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task FindCloudDocumentInFolderByTitleAsync( + string folderToken, + string title, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task UploadCloudFileAsync( + string fileName, + byte[] content, + string? folderToken, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task ImportMarkdownFileAsCloudDocumentAsync( + string fileName, + byte[] content, + string title, + string? folderToken, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task DownloadIncomingAttachmentAsync( + FeishuIncomingAttachment attachment, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) { throw new NotSupportedException(); } + /// + /// 创建流式回复句柄 + /// + /// 会话 ID + /// 被回复的消息 ID(可选,为空时直接发送) + /// 初始内容 + /// 卡片标题 + /// 取消令牌 + /// 流式回复句柄 Task CreateStreamingHandleAsync( string chatId, string? replyMessageId, diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs deleted file mode 100644 index 840c8c4..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public interface IFeishuReplyTtsPlatformService -{ - Task GetHealthAsync(CancellationToken cancellationToken = default); - - Task> GetVoicesAsync(CancellationToken cancellationToken = default); - - Task ResolveVoiceOrFallbackAsync(string? savedVoiceId, CancellationToken cancellationToken = default); - - Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default); -} - -public sealed class FeishuReplyTtsVoiceResolutionResult -{ - public bool Success { get; set; } - - public string? VoiceId { get; set; } - - public FeishuReplyTtsVoiceOption? Voice { get; set; } - - public bool UsedFallback { get; set; } - - public string Message { get; set; } = string.Empty; -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IReferencedMarkdownImportStateStore.cs b/WebCodeCli.Domain/Domain/Service/Channels/IReferencedMarkdownImportStateStore.cs new file mode 100644 index 0000000..2d9ef23 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IReferencedMarkdownImportStateStore.cs @@ -0,0 +1,13 @@ +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IReferencedMarkdownImportStateStore +{ + Task GetAsync( + string folderToken, + string absolutePath, + CancellationToken cancellationToken = default); + + Task UpsertAsync( + ReferencedMarkdownImportStateEntry entry, + CancellationToken cancellationToken = default); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IReplyDocumentOrchestrator.cs b/WebCodeCli.Domain/Domain/Service/Channels/IReplyDocumentOrchestrator.cs new file mode 100644 index 0000000..d4070ad --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IReplyDocumentOrchestrator.cs @@ -0,0 +1,8 @@ +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IReplyDocumentOrchestrator +{ + Task QueueCompletedReplyAsync(FeishuCompletedReplyDocumentRequest request); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsEnablementService.cs b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsEnablementService.cs deleted file mode 100644 index b44249e..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsEnablementService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public interface IReplyTtsEnablementService -{ - Task HasEnabledReplyTtsAsync(CancellationToken cancellationToken = default); -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsLocalServiceManager.cs b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsLocalServiceManager.cs deleted file mode 100644 index 9044112..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsLocalServiceManager.cs +++ /dev/null @@ -1,10 +0,0 @@ -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public interface IReplyTtsLocalServiceManager -{ - Task EnsureStartedAsync( - FeishuReplyTtsHealthStatus storageHealth, - CancellationToken cancellationToken = default); -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs deleted file mode 100644 index 7b0022d..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs +++ /dev/null @@ -1,8 +0,0 @@ -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public interface IReplyTtsOrchestrator -{ - Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request); -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ISherpaKokoroTtsClient.cs b/WebCodeCli.Domain/Domain/Service/Channels/ISherpaKokoroTtsClient.cs deleted file mode 100644 index 303394b..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ISherpaKokoroTtsClient.cs +++ /dev/null @@ -1,12 +0,0 @@ -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public interface ISherpaKokoroTtsClient -{ - Task GetHealthAsync(CancellationToken cancellationToken = default); - - Task> GetVoicesAsync(CancellationToken cancellationToken = default); - - Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default); -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ListeningReplyDocumentFormatter.cs b/WebCodeCli.Domain/Domain/Service/Channels/ListeningReplyDocumentFormatter.cs new file mode 100644 index 0000000..ef32215 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ListeningReplyDocumentFormatter.cs @@ -0,0 +1,320 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public static partial class ListeningReplyDocumentFormatter +{ + private const string PlaceholderPrefix = "\u6587\u4ef6\u5185\u5bb9"; + private const string CommandPlaceholderPrefix = "\u547D\u4EE4\u5185\u5BB9"; + private const char ChineseColon = '\uFF1A'; + + public static string Format(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return string.Empty; + } + + var commandExtraction = ExtractCommandBlocks(input); + var rewrittenInput = commandExtraction.RewrittenText; + var matches = FileReferenceRegex().Matches(rewrittenInput); + if (matches.Count == 0 && commandExtraction.OrderedCommands.Count == 0) + { + return input; + } + + var mapping = new Dictionary(StringComparer.Ordinal); + var orderedReferences = new List(); + var rewritten = FileReferenceRegex().Replace(rewrittenInput, match => + { + var value = match.Value; + if (IsLikelyVersionToken(value)) + { + return value; + } + + if (!mapping.TryGetValue(value, out var placeholder)) + { + placeholder = $"{PlaceholderPrefix}{mapping.Count + 1}"; + mapping[value] = placeholder; + orderedReferences.Add(value); + } + + return placeholder; + }); + rewritten = RestoreMaskedCodeBlocks(rewritten, commandExtraction.MaskedCodeBlocks); + + var builder = new StringBuilder(rewritten.Trim()); + AppendFileAppendix(builder, orderedReferences); + AppendCommandAppendix(builder, commandExtraction.OrderedCommands); + return builder.ToString(); + } + + private static CommandExtractionResult ExtractCommandBlocks(string input) + { + var commandMapping = new Dictionary(StringComparer.Ordinal); + var orderedCommands = new List(); + var maskedCodeBlocks = new Dictionary(StringComparer.Ordinal); + var maskedCodeBlockCount = 0; + var rewritten = FencedCodeBlockRegex().Replace(input, match => + { + var language = match.Groups["language"].Value; + var content = NormalizeCommandContent(match.Groups["content"].Value); + if (!IsCommandBlock(language, content)) + { + var mask = $"__LISTENING_CODE_BLOCK_{++maskedCodeBlockCount}__"; + maskedCodeBlocks[mask] = match.Value; + return mask; + } + + var key = $"{language}\n{content}"; + if (!commandMapping.TryGetValue(key, out var placeholder)) + { + placeholder = $"[{CommandPlaceholderPrefix}{commandMapping.Count + 1}]"; + commandMapping[key] = placeholder; + orderedCommands.Add(content); + } + + return placeholder; + }); + + rewritten = ExtractStandaloneCommandLines(rewritten, commandMapping, orderedCommands); + + return new CommandExtractionResult(rewritten, orderedCommands, maskedCodeBlocks); + } + + private static string ExtractStandaloneCommandLines( + string input, + IDictionary commandMapping, + ICollection orderedCommands) + { + var builder = new StringBuilder(input.Length); + var index = 0; + while (index < input.Length) + { + var lineStart = index; + while (index < input.Length && input[index] is not '\r' and not '\n') + { + index++; + } + + var line = input[lineStart..index]; + if (TryExtractStandaloneCommandLine(line, out var commandText, out var replacement)) + { + if (!commandMapping.TryGetValue(commandText, out var placeholder)) + { + placeholder = $"[{CommandPlaceholderPrefix}{commandMapping.Count + 1}]"; + commandMapping[commandText] = placeholder; + orderedCommands.Add(commandText); + } + + builder.Append(replacement(placeholder)); + } + else + { + builder.Append(line); + } + + if (index >= input.Length) + { + continue; + } + + if (input[index] == '\r' && index + 1 < input.Length && input[index + 1] == '\n') + { + builder.Append("\r\n"); + index += 2; + continue; + } + + builder.Append(input[index]); + index++; + } + + return builder.ToString(); + } + + private static bool TryExtractStandaloneCommandLine( + string line, + out string commandText, + out Func replacement) + { + commandText = string.Empty; + replacement = static _ => string.Empty; + + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + var listMatch = MarkdownListCommandRegex().Match(line); + if (listMatch.Success) + { + commandText = NormalizeCommandContent(listMatch.Groups["command"].Value); + if (!IsLikelyCommandLine(commandText)) + { + commandText = string.Empty; + return false; + } + + var prefix = listMatch.Groups["prefix"].Value; + var suffix = listMatch.Groups["suffix"].Value; + replacement = placeholder => $"{prefix}{placeholder}{suffix}"; + return true; + } + + var standaloneMatch = StandaloneCommandLineRegex().Match(line); + if (!standaloneMatch.Success) + { + return false; + } + + commandText = NormalizeCommandContent(standaloneMatch.Groups["command"].Value); + if (!IsLikelyCommandLine(commandText)) + { + commandText = string.Empty; + return false; + } + + var standalonePrefix = standaloneMatch.Groups["prefix"].Value; + var standaloneSuffix = standaloneMatch.Groups["suffix"].Value; + replacement = placeholder => $"{standalonePrefix}{placeholder}{standaloneSuffix}"; + return true; + } + + private static void AppendFileAppendix(StringBuilder builder, IReadOnlyList orderedReferences) + { + if (orderedReferences.Count == 0) + { + return; + } + + builder.AppendLine(); + builder.AppendLine(); + for (var i = 0; i < orderedReferences.Count; i++) + { + var reference = orderedReferences[i]; + builder.Append(PlaceholderPrefix) + .Append(i + 1) + .Append(ChineseColon) + .Append(reference); + + if (i < orderedReferences.Count - 1) + { + builder.AppendLine(); + } + } + } + + private static void AppendCommandAppendix(StringBuilder builder, IReadOnlyList orderedCommands) + { + if (orderedCommands.Count == 0) + { + return; + } + + builder.AppendLine(); + builder.AppendLine(); + for (var i = 0; i < orderedCommands.Count; i++) + { + if (i > 0) + { + builder.AppendLine(); + builder.AppendLine(); + } + + builder.Append(CommandPlaceholderPrefix) + .Append(i + 1) + .Append(ChineseColon) + .Append(orderedCommands[i]); + } + } + + private static bool IsCommandBlock(string? language, string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(language)) + { + var normalizedLanguage = language.Trim().ToLowerInvariant(); + if (normalizedLanguage is "powershell" or "pwsh" or "bash" or "sh" or "shell" or "zsh" or "cmd" or "bat" or "batch" or "console") + { + return true; + } + } + + var nonEmptyLines = content + .Split(["\r\n", "\n"], StringSplitOptions.None) + .Where(static line => !string.IsNullOrWhiteSpace(line)) + .ToArray(); + + return nonEmptyLines.Length > 0 && nonEmptyLines.All(IsLikelyCommandLine); + } + + private static bool IsLikelyCommandLine(string line) + { + var trimmed = line.TrimStart(); + return trimmed.StartsWith("powershell ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("pwsh ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("dotnet ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("git ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("npm ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("pnpm ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("yarn ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("npx ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("node ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("python ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("bash ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("sh ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("cmd ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("curl ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("wget ", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeCommandContent(string content) + { + return content.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); + } + + private static string RestoreMaskedCodeBlocks(string input, IReadOnlyDictionary maskedCodeBlocks) + { + var restored = input; + foreach (var maskedCodeBlock in maskedCodeBlocks) + { + restored = restored.Replace(maskedCodeBlock.Key, maskedCodeBlock.Value, StringComparison.Ordinal); + } + + return restored; + } + + private static bool IsLikelyVersionToken(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return Regex.IsMatch(value, @"^[vV]?\d+(?:\.\d+){1,}$", RegexOptions.CultureInvariant); + } + + [GeneratedRegex(@"(?[A-Za-z0-9_-]+)?[ \\t]*\\r?\\n(?.*?)\\r?\\n```", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline)] + private static partial Regex FencedCodeBlockRegex(); + + [GeneratedRegex(@"^(?\s*[-*]\s*)(?:`(?[^`]+)`|(?.+?))(?\s*)$", RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex MarkdownListCommandRegex(); + + [GeneratedRegex(@"^(?\s*)(?:`(?[^`]+)`|(?(?:powershell|pwsh|dotnet|git|npm|pnpm|yarn|npx|node|python|bash|sh|cmd|curl|wget)\b.+?))(?\s*)$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex StandaloneCommandLineRegex(); + + private sealed record CommandExtractionResult( + string RewrittenText, + IReadOnlyList OrderedCommands, + IReadOnlyDictionary MaskedCodeBlocks); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/MarkdownReferenceExtractor.cs b/WebCodeCli.Domain/Domain/Service/Channels/MarkdownReferenceExtractor.cs new file mode 100644 index 0000000..6e1389d --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/MarkdownReferenceExtractor.cs @@ -0,0 +1,312 @@ +using System.Text.RegularExpressions; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public static partial class MarkdownReferenceExtractor +{ + private static readonly string[] PreferredSearchDirectories = + [ + "docs", + "plans", + "specs", + ".codex", + "skills" + ]; + + private static readonly Regex BareMarkdownPathRegex = new( + @"(?(?:[A-Za-z]:[\\/]|\.{1,2}[\\/]|[A-Za-z0-9_\-]+(?:[./\\][A-Za-z0-9_\-]+)*[\\/])[^\\/:*?""<>|\r\n]+\.md)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly Regex BareMarkdownFileNameRegex = new( + @"(?[A-Za-z0-9][A-Za-z0-9._\-]*\.md)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly Regex MarkdownLinkRegex = new( + @"\[[^\]]+\]\((?[^)\r\n]+)\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public static IReadOnlyList Extract(string? text, string? workspaceRoot) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(workspaceRoot)) + { + return []; + } + + if (!Directory.Exists(workspaceRoot)) + { + return []; + } + + var normalizedWorkspaceRoot = Path.GetFullPath(workspaceRoot.Trim()); + var results = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var rawPath in EnumerateCandidatePaths(text)) + { + var candidate = TryResolveCandidate(rawPath, normalizedWorkspaceRoot); + if (candidate == null) + { + continue; + } + + if (seen.Add(candidate.RelativePath)) + { + results.Add(candidate); + } + } + + return results; + } + + private static IEnumerable EnumerateCandidatePaths(string text) + { + var matches = new List<(int Index, int Length, string Path)>(); + + foreach (Match match in MarkdownLinkRegex.Matches(text)) + { + if (!match.Groups["path"].Success) + { + continue; + } + + var path = match.Groups["path"].Value; + if (IsSupportedMarkdownLinkTarget(path)) + { + matches.Add((match.Index, match.Length, path)); + } + } + + foreach (Match match in BareMarkdownPathRegex.Matches(text)) + { + TryAddBareMarkdownMatch(text, match, matches); + } + + foreach (Match match in BareMarkdownFileNameRegex.Matches(text)) + { + TryAddBareMarkdownMatch(text, match, matches); + } + + foreach (var match in matches.OrderBy(static item => item.Index).ThenBy(static item => item.Length)) + { + yield return match.Path; + } + } + + private static void TryAddBareMarkdownMatch( + string text, + Match match, + List<(int Index, int Length, string Path)> matches) + { + if (!match.Groups["path"].Success) + { + return; + } + + if (IsRemoteUrlContext(text, match.Index, match.Length) + || IsFollowedByQueryOrAnchor(text, match.Index + match.Length)) + { + return; + } + + var path = match.Groups["path"].Value; + if (!string.IsNullOrWhiteSpace(path)) + { + matches.Add((match.Index, match.Length, path)); + } + } + + private static bool IsSupportedMarkdownLinkTarget(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var trimmed = path.Trim().Trim('"', '\'', '`', '<', '>', '(', ')', '{', '}', '[', ']'); + if (string.IsNullOrWhiteSpace(trimmed) + || trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return trimmed.EndsWith(".md", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsRemoteUrlContext(string text, int matchIndex, int matchLength) + { + var tokenStart = matchIndex; + while (tokenStart > 0 && !IsTokenBoundary(text[tokenStart - 1])) + { + tokenStart--; + } + + var tokenEnd = matchIndex + matchLength; + while (tokenEnd < text.Length && !IsTokenBoundary(text[tokenEnd])) + { + tokenEnd++; + } + + var token = text[tokenStart..tokenEnd]; + return token.Contains("://", StringComparison.Ordinal) + || token.StartsWith("//", StringComparison.Ordinal); + } + + private static bool IsTokenBoundary(char value) + { + return char.IsWhiteSpace(value) + || value is '(' or ')' or '[' or ']' or '<' or '>' or '"' or '\''; + } + + private static bool IsFollowedByQueryOrAnchor(string text, int index) + { + return index < text.Length && text[index] is '?' or '#'; + } + + private static ReferencedMarkdownDocumentCandidate? TryResolveCandidate(string rawPath, string workspaceRoot) + { + var trimmed = rawPath.Trim().Trim('"', '\'', '`', '[', ']', '(', ')', '{', '}', '<', '>', ',', ';'); + if (string.IsNullOrWhiteSpace(trimmed) + || trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var absolutePath = TryResolveAbsolutePath(trimmed, workspaceRoot); + if (string.IsNullOrWhiteSpace(absolutePath)) + { + return null; + } + + if (!absolutePath.StartsWith( + workspaceRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + && !string.Equals(absolutePath, workspaceRoot, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!File.Exists(absolutePath)) + { + return null; + } + + var relativePath = Path.GetRelativePath(workspaceRoot, absolutePath).Replace('\\', '/'); + while (relativePath.StartsWith("./", StringComparison.Ordinal)) + { + relativePath = relativePath[2..]; + } + + if (string.IsNullOrWhiteSpace(relativePath) || relativePath.StartsWith("../", StringComparison.Ordinal)) + { + return null; + } + + return new ReferencedMarkdownDocumentCandidate( + AbsolutePath: absolutePath, + RelativePath: relativePath, + Title: relativePath); + } + + private static string? TryResolveAbsolutePath(string trimmed, string workspaceRoot) + { + if (Path.IsPathRooted(trimmed)) + { + return Path.GetFullPath(trimmed); + } + + var directPath = Path.GetFullPath(Path.Combine(workspaceRoot, trimmed)); + if (File.Exists(directPath)) + { + return directPath; + } + + if (!IsBareMarkdownFileName(trimmed)) + { + return directPath; + } + + return TryResolveBareMarkdownFileName(trimmed, workspaceRoot); + } + + private static bool IsBareMarkdownFileName(string trimmed) + { + return !string.IsNullOrWhiteSpace(trimmed) + && string.Equals(Path.GetFileName(trimmed), trimmed, StringComparison.Ordinal) + && trimmed.EndsWith(".md", StringComparison.OrdinalIgnoreCase); + } + + private static string? TryResolveBareMarkdownFileName(string fileName, string workspaceRoot) + { + var candidates = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var preferredDirectory in PreferredSearchDirectories) + { + var preferredRoot = Path.Combine(workspaceRoot, preferredDirectory); + if (!Directory.Exists(preferredRoot)) + { + continue; + } + + foreach (var path in Directory.EnumerateFiles(preferredRoot, fileName, SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(path); + if (seen.Add(fullPath)) + { + candidates.Add(fullPath); + } + } + } + + if (candidates.Count == 0) + { + foreach (var path in Directory.EnumerateFiles(workspaceRoot, fileName, SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(path); + if (seen.Add(fullPath)) + { + candidates.Add(fullPath); + } + } + } + + return candidates + .Where(path => path.StartsWith( + workspaceRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => GetWorkspaceCandidatePriority(Path.GetRelativePath(workspaceRoot, path).Replace('\\', '/'))) + .ThenBy(path => Path.GetRelativePath(workspaceRoot, path).Length) + .ThenBy(path => path, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + } + + private static int GetWorkspaceCandidatePriority(string relativePath) + { + if (relativePath.Contains("/plans/", StringComparison.OrdinalIgnoreCase) + || relativePath.StartsWith("plans/", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + if (relativePath.Contains("/specs/", StringComparison.OrdinalIgnoreCase) + || relativePath.StartsWith("specs/", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + + if (relativePath.Contains("/docs/", StringComparison.OrdinalIgnoreCase) + || relativePath.StartsWith("docs/", StringComparison.OrdinalIgnoreCase)) + { + return 2; + } + + return 3; + } +} + +public sealed record ReferencedMarkdownDocumentCandidate( + string AbsolutePath, + string RelativePath, + string Title); diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownDocumentImporter.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownDocumentImporter.cs new file mode 100644 index 0000000..b76d25e --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownDocumentImporter.cs @@ -0,0 +1,530 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public sealed class ReferencedMarkdownDocumentImporter +{ + private const string MarkdownDocumentTarget = "Markdown在线文档"; + + private readonly ILogger _logger; + private readonly IReferencedMarkdownImportStateStore _stateStore; + + public ReferencedMarkdownDocumentImporter( + ILogger logger, + IReferencedMarkdownImportStateStore stateStore) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); + } + + public async Task ImportMissingAsync( + IFeishuCardKitClient cardKitClient, + string chatId, + string folderToken, + IReadOnlyList candidates, + string? documentAdminOpenId, + FeishuOptions? optionsOverride, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(cardKitClient); + ArgumentException.ThrowIfNullOrWhiteSpace(chatId); + ArgumentException.ThrowIfNullOrWhiteSpace(folderToken); + ArgumentNullException.ThrowIfNull(candidates); + + var folderAdminGrantAttempted = false; + string? deferredFolderAdminWarningMessage = null; + + foreach (var candidate in candidates) + { + try + { + var fingerprint = ReferencedMarkdownImportFingerprint.Compute(candidate.AbsolutePath); + var tracked = await _stateStore.GetAsync( + folderToken, + candidate.AbsolutePath, + cancellationToken); + var existing = await cardKitClient.FindCloudDocumentInFolderByTitleAsync( + folderToken, + candidate.Title, + cancellationToken, + optionsOverride); + + var document = existing; + var successMessage = string.Empty; + string? placementWarningMessage = null; + + if (document == null) + { + (document, placementWarningMessage) = await ImportDocumentWithPlacementFallbackAsync( + cardKitClient, + folderToken, + candidate, + optionsOverride, + cancellationToken); + successMessage = $"已生成Markdown在线文档:[${candidate.Title}]({document.Url})".Replace("[$", "[", StringComparison.Ordinal); + } + else if (tracked != null && string.Equals(tracked.Fingerprint, fingerprint, StringComparison.Ordinal)) + { + successMessage = $"已复用Markdown在线文档:[${candidate.Title}]({document.Url})".Replace("[$", "[", StringComparison.Ordinal); + } + else if (tracked != null) + { + await OverwriteExistingDocumentAsync( + cardKitClient, + document, + candidate, + optionsOverride, + cancellationToken); + successMessage = $"已更新Markdown在线文档:[${candidate.Title}]({document.Url})".Replace("[$", "[", StringComparison.Ordinal); + } + else + { + successMessage = $"已复用Markdown在线文档:[${candidate.Title}]({document.Url})".Replace("[$", "[", StringComparison.Ordinal); + } + + await _stateStore.UpsertAsync( + new ReferencedMarkdownImportStateEntry + { + FolderToken = folderToken, + AbsolutePath = candidate.AbsolutePath, + RelativePath = candidate.RelativePath, + Title = candidate.Title, + Fingerprint = fingerprint, + DocumentId = document.DocumentId, + RootBlockId = document.RootBlockId, + DocumentUrl = document.Url + }, + cancellationToken); + + if (!folderAdminGrantAttempted && !string.IsNullOrWhiteSpace(documentAdminOpenId)) + { + folderAdminGrantAttempted = true; + try + { + await cardKitClient.GrantCloudFolderMemberFullAccessAsync( + folderToken, + documentAdminOpenId, + cancellationToken, + optionsOverride); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "Referenced markdown import folder admin grant failed for chat {ChatId}, title {Title}", + chatId, + candidate.Title); + deferredFolderAdminWarningMessage = BuildFolderAdminGrantWarningMessage(ex); + } + } + + var documentAdminWarningMessage = await EnsureDocumentPermissionsAsync( + cardKitClient, + document, + documentAdminOpenId, + optionsOverride, + cancellationToken); + + await TrySendTextMessageAsync(cardKitClient, chatId, successMessage, cancellationToken, optionsOverride); + + if (!string.IsNullOrWhiteSpace(placementWarningMessage)) + { + await TrySendTextMessageAsync(cardKitClient, chatId, placementWarningMessage, cancellationToken, optionsOverride); + } + + if (!string.IsNullOrWhiteSpace(documentAdminWarningMessage)) + { + await TrySendTextMessageAsync(cardKitClient, chatId, documentAdminWarningMessage, cancellationToken, optionsOverride); + } + + if (!string.IsNullOrWhiteSpace(deferredFolderAdminWarningMessage)) + { + await TrySendTextMessageAsync(cardKitClient, chatId, deferredFolderAdminWarningMessage, cancellationToken, optionsOverride); + deferredFolderAdminWarningMessage = null; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "Referenced markdown import failed for chat {ChatId}, title {Title}", + chatId, + candidate.Title); + await TrySendTextMessageAsync( + cardKitClient, + chatId, + BuildFailureMessage(candidate.Title, ex), + cancellationToken, + optionsOverride); + } + } + } + + private async Task OverwriteExistingDocumentAsync( + IFeishuCardKitClient cardKitClient, + FeishuCloudDocumentInfo document, + ReferencedMarkdownDocumentCandidate candidate, + FeishuOptions? optionsOverride, + CancellationToken cancellationToken) + { + var childBlockIds = await cardKitClient.ListCloudDocumentChildBlockIdsAsync( + document.DocumentId, + document.RootBlockId, + cancellationToken, + optionsOverride); + + if (childBlockIds.Count > 0) + { + await cardKitClient.DeleteCloudDocumentChildBlocksAsync( + document.DocumentId, + document.RootBlockId, + 0, + childBlockIds.Count - 1, + cancellationToken, + optionsOverride); + } + + var markdown = await File.ReadAllTextAsync(candidate.AbsolutePath, cancellationToken); + JsonElement converted; + try + { + converted = await cardKitClient.ConvertMarkdownToCloudDocumentBlocksAsync( + markdown, + cancellationToken, + optionsOverride); + } + catch + { + await cardKitClient.AppendCloudDocumentTextAsync( + document.DocumentId, + document.RootBlockId, + markdown, + cancellationToken, + optionsOverride); + return; + } + + var blocks = ExtractBlocks(converted); + if (blocks.Count == 0) + { + await cardKitClient.AppendCloudDocumentTextAsync( + document.DocumentId, + document.RootBlockId, + markdown, + cancellationToken, + optionsOverride); + return; + } + + try + { + await cardKitClient.AppendCloudDocumentBlocksAsync( + document.DocumentId, + document.RootBlockId, + blocks, + cancellationToken, + optionsOverride); + } + catch + { + await cardKitClient.AppendCloudDocumentTextAsync( + document.DocumentId, + document.RootBlockId, + markdown, + cancellationToken, + optionsOverride); + } + } + + private async Task<(FeishuCloudDocumentInfo Document, string? PlacementWarningMessage)> ImportDocumentWithPlacementFallbackAsync( + IFeishuCardKitClient cardKitClient, + string folderToken, + ReferencedMarkdownDocumentCandidate candidate, + FeishuOptions? optionsOverride, + CancellationToken cancellationToken) + { + var content = await File.ReadAllBytesAsync(candidate.AbsolutePath, cancellationToken); + var fileName = Path.GetFileName(candidate.AbsolutePath); + + try + { + var imported = await cardKitClient.ImportMarkdownFileAsCloudDocumentAsync( + fileName, + content, + candidate.Title, + folderToken, + cancellationToken, + optionsOverride); + return (imported, null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "Referenced markdown import direct placement failed for title {Title}, falling back to default directory", + candidate.Title); + + var imported = await cardKitClient.ImportMarkdownFileAsCloudDocumentAsync( + fileName, + content, + candidate.Title, + folderToken: null, + cancellationToken, + optionsOverride); + + return (imported, BuildPlacementWarningMessage(ex)); + } + } + + private async Task EnsureDocumentPermissionsAsync( + IFeishuCardKitClient cardKitClient, + FeishuCloudDocumentInfo document, + string? documentAdminOpenId, + FeishuOptions? optionsOverride, + CancellationToken cancellationToken) + { + await cardKitClient.SetCloudDocumentTenantReadableAsync( + document.DocumentId, + cancellationToken, + optionsOverride); + + if (string.IsNullOrWhiteSpace(documentAdminOpenId)) + { + return null; + } + + try + { + await cardKitClient.GrantCloudDocumentMemberFullAccessAsync( + document.DocumentId, + documentAdminOpenId, + cancellationToken, + optionsOverride); + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "Referenced markdown import document admin grant failed for document {DocumentId}", + document.DocumentId); + return BuildDocumentAdminGrantWarningMessage(ex); + } + } + + private async Task TrySendTextMessageAsync( + IFeishuCardKitClient cardKitClient, + string chatId, + string content, + CancellationToken cancellationToken, + FeishuOptions? optionsOverride) + { + try + { + await cardKitClient.SendTextMessageAsync( + chatId, + content, + cancellationToken, + optionsOverride); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "Referenced markdown import notification failed for chat {ChatId}", + chatId); + } + } + + private static string BuildFailureMessage(string title, Exception exception) + { + var scopeHint = TryExtractPermissionScopes(exception.Message); + if (!string.IsNullOrWhiteSpace(scopeHint)) + { + var guidance = TryExtractPermissionGuidance(exception.Message); + return string.IsNullOrWhiteSpace(guidance) + ? $"Markdown在线文档处理失败:{title}。飞书应用缺少文档权限,请开通 {scopeHint} 后重试。" + : $"Markdown在线文档处理失败:{title}。飞书应用缺少文档权限,请开通 {scopeHint} 后重试。{guidance}"; + } + + if (IsDriveResourceNotFoundError(exception.Message)) + { + return $"Markdown在线文档处理失败:{title}。飞书文档或目标文件夹资源不存在或已失效,请稍后重试;若持续出现,请检查目标文件夹是否已被删除。"; + } + + var compactReason = BuildCompactErrorReason(exception.Message); + return string.IsNullOrWhiteSpace(compactReason) + ? $"Markdown在线文档处理失败:{title}。请检查飞书文档权限或服务日志。" + : $"Markdown在线文档处理失败:{title}。{compactReason}"; + } + + private static string BuildPlacementWarningMessage(Exception exception) + { + var scopeHint = TryExtractPermissionScopes(exception.Message); + if (!string.IsNullOrWhiteSpace(scopeHint)) + { + var guidance = TryExtractPermissionGuidance(exception.Message); + return string.IsNullOrWhiteSpace(guidance) + ? $"{MarkdownDocumentTarget}已生成,但归档到会话文档文件夹失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。文档已保留在飞书默认目录。" + : $"{MarkdownDocumentTarget}已生成,但归档到会话文档文件夹失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。{guidance}文档已保留在飞书默认目录。"; + } + + if (IsDriveResourceNotFoundError(exception.Message)) + { + return $"{MarkdownDocumentTarget}已生成,但归档到会话文档文件夹时,飞书文档或目标文件夹资源不存在或已失效。文档已保留在飞书默认目录;若持续出现,请检查目标文件夹是否已被删除。"; + } + + var compactReason = BuildCompactErrorReason(exception.Message); + return string.IsNullOrWhiteSpace(compactReason) + ? $"{MarkdownDocumentTarget}已生成,但归档到会话文档文件夹失败。文档已保留在飞书默认目录。" + : $"{MarkdownDocumentTarget}已生成,但归档到会话文档文件夹失败:{compactReason}。文档已保留在飞书默认目录。"; + } + + private static string BuildDocumentAdminGrantWarningMessage(Exception exception) + { + var scopeHint = TryExtractPermissionScopes(exception.Message); + if (!string.IsNullOrWhiteSpace(scopeHint)) + { + var guidance = TryExtractPermissionGuidance(exception.Message); + return string.IsNullOrWhiteSpace(guidance) + ? $"{MarkdownDocumentTarget}已生成,但文档管理员权限授予失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。" + : $"{MarkdownDocumentTarget}已生成,但文档管理员权限授予失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。{guidance}"; + } + + if (IsDriveResourceNotFoundError(exception.Message)) + { + return $"{MarkdownDocumentTarget}已生成,但文档管理员权限授予失败:飞书文档资源不存在或已失效,请稍后重试;若持续出现,请检查目标文档是否已被删除。"; + } + + var compactReason = BuildCompactErrorReason(exception.Message); + return string.IsNullOrWhiteSpace(compactReason) + ? $"{MarkdownDocumentTarget}已生成,但文档管理员权限授予失败,请稍后重试。" + : $"{MarkdownDocumentTarget}已生成,但文档管理员权限授予失败:{compactReason}"; + } + + private static string BuildFolderAdminGrantWarningMessage(Exception exception) + { + var scopeHint = TryExtractPermissionScopes(exception.Message); + if (!string.IsNullOrWhiteSpace(scopeHint)) + { + var guidance = TryExtractPermissionGuidance(exception.Message); + return string.IsNullOrWhiteSpace(guidance) + ? $"{MarkdownDocumentTarget}已生成,但会话文档文件夹管理员权限授予失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。" + : $"{MarkdownDocumentTarget}已生成,但会话文档文件夹管理员权限授予失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。{guidance}"; + } + + if (IsDriveResourceNotFoundError(exception.Message)) + { + return $"{MarkdownDocumentTarget}已生成,但会话文档文件夹管理员权限授予失败:目标文件夹资源不存在或已失效,请稍后重试;若持续出现,请检查目标文件夹是否已被删除。"; + } + + var compactReason = BuildCompactErrorReason(exception.Message); + return string.IsNullOrWhiteSpace(compactReason) + ? $"{MarkdownDocumentTarget}已生成,但会话文档文件夹管理员权限授予失败,请稍后重试。" + : $"{MarkdownDocumentTarget}已生成,但会话文档文件夹管理员权限授予失败:{compactReason}"; + } + + private static string BuildCompactErrorReason(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return string.Empty; + } + + var normalized = message.Replace("\r", " ").Replace("\n", " ").Trim(); + return normalized.Length <= 180 + ? normalized + : normalized[..180].TrimEnd(); + } + + private static string? TryExtractPermissionScopes(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return null; + } + + if (!message.Contains("scope", StringComparison.OrdinalIgnoreCase) + && !message.Contains("permission", StringComparison.OrdinalIgnoreCase) + && !message.Contains("权限", StringComparison.OrdinalIgnoreCase) + && !message.Contains("Access denied", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var scopes = System.Text.RegularExpressions.Regex.Matches( + message, + @"\b(?:docx|drive):[a-z0-9.-]+(?::[a-z0-9.-]+)*\b", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant) + .Select(static match => match.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return scopes.Count == 0 + ? null + : string.Join("、", scopes); + } + + private static string? TryExtractPermissionGuidance(string? message) + { + const string prefix = "应用尚未开通所需的应用身份权限:"; + + if (string.IsNullOrWhiteSpace(message)) + { + return null; + } + + var startIndex = message.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + if (startIndex < 0) + { + return null; + } + + var guidance = message[startIndex..]; + var endIndex = guidance.IndexOfAny(['"', '\r', '\n']); + if (endIndex >= 0) + { + guidance = guidance[..endIndex]; + } + + guidance = guidance.Trim().TrimEnd('}'); + if (!guidance.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return guidance.Length == prefix.Length + ? null + : guidance; + } + + private static bool IsDriveResourceNotFoundError(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return false; + } + + return message.Contains("Status=NotFound", StringComparison.OrdinalIgnoreCase) + || message.Contains("\"code\":1061003", StringComparison.OrdinalIgnoreCase) + || message.Contains("code=1061003", StringComparison.OrdinalIgnoreCase) + || message.Contains("\"msg\":\"not found.\"", StringComparison.OrdinalIgnoreCase) + || message.Contains("msg\":\"not found", StringComparison.OrdinalIgnoreCase); + } + + private static IReadOnlyList ExtractBlocks(JsonElement converted) + { + if (!converted.TryGetProperty("blocks", out var blocksElement) + || blocksElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + return blocksElement.EnumerateArray() + .Select(static block => block.Clone()) + .ToArray(); + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownImportFingerprint.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownImportFingerprint.cs new file mode 100644 index 0000000..b58d839 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownImportFingerprint.cs @@ -0,0 +1,28 @@ +using System.Security.Cryptography; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public static class ReferencedMarkdownImportFingerprint +{ + public static string Compute(string absolutePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(absolutePath); + + var fileInfo = new FileInfo(absolutePath); + if (!fileInfo.Exists) + { + throw new FileNotFoundException("Markdown 文件不存在。", absolutePath); + } + + using var stream = File.OpenRead(absolutePath); + var hash = Convert.ToHexString(SHA256.HashData(stream)); + return string.Create( + absolutePath.Length + hash.Length + 64, + (absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks, hash), + static (span, state) => + { + var text = $"{state.absolutePath}|{state.Length}|{state.Ticks}|{state.hash}"; + text.AsSpan().CopyTo(span); + }); + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownImportStateEntry.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownImportStateEntry.cs new file mode 100644 index 0000000..9dc71c9 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownImportStateEntry.cs @@ -0,0 +1,20 @@ +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public sealed record ReferencedMarkdownImportStateEntry +{ + public string FolderToken { get; init; } = string.Empty; + + public string AbsolutePath { get; init; } = string.Empty; + + public string RelativePath { get; init; } = string.Empty; + + public string Title { get; init; } = string.Empty; + + public string Fingerprint { get; init; } = string.Empty; + + public string DocumentId { get; init; } = string.Empty; + + public string RootBlockId { get; init; } = string.Empty; + + public string DocumentUrl { get; init; } = string.Empty; +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentMarkdownRenderer.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentMarkdownRenderer.cs new file mode 100644 index 0000000..3300835 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentMarkdownRenderer.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using WebCodeCli.Domain.Common.Options; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public sealed class ReplyDocumentMarkdownRenderer +{ + private readonly ILogger _logger; + + public ReplyDocumentMarkdownRenderer(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task RenderAsync( + IFeishuCardKitClient cardKitClient, + string documentId, + string rootBlockId, + string markdown, + FeishuOptions? optionsOverride, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(cardKitClient); + + if (string.IsNullOrWhiteSpace(markdown)) + { + return; + } + + JsonElement converted; + try + { + converted = await cardKitClient.ConvertMarkdownToCloudDocumentBlocksAsync( + markdown, + cancellationToken, + optionsOverride); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogDebug( + ex, + "Reply document markdown conversion fell back to plain text for document {DocumentId}", + documentId); + + await cardKitClient.AppendCloudDocumentTextAsync( + documentId, + rootBlockId, + markdown, + cancellationToken, + optionsOverride); + return; + } + + var blocks = ExtractBlocks(converted); + if (blocks.Count == 0) + { + await cardKitClient.AppendCloudDocumentTextAsync( + documentId, + rootBlockId, + markdown, + cancellationToken, + optionsOverride); + return; + } + + try + { + await cardKitClient.AppendCloudDocumentBlocksAsync( + documentId, + rootBlockId, + blocks, + cancellationToken, + optionsOverride); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Reply document markdown block append failed for document {DocumentId}; falling back to plain text append", + documentId); + + await cardKitClient.AppendCloudDocumentTextAsync( + documentId, + rootBlockId, + markdown, + cancellationToken, + optionsOverride); + } + } + + private static IReadOnlyList ExtractBlocks(JsonElement converted) + { + if (!converted.TryGetProperty("blocks", out var blocksElement) + || blocksElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + return blocksElement.EnumerateArray() + .Select(static block => block.Clone()) + .ToArray(); + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs new file mode 100644 index 0000000..0e0a7c1 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs @@ -0,0 +1,1239 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Repositories.Base.ChatSession; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IReplyDocumentOrchestrator), ServiceLifetime.Singleton)] +public sealed class ReplyDocumentOrchestrator : IReplyDocumentOrchestrator +{ + private const int MaxTitleLength = 180; + private const string FailureStageDataKey = "ReplyDocumentFailureStage"; + private static readonly char[] InvalidFolderNameCharacters = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + private const string FullReplySuffix = " - 完整回复"; + private const string FinalReplySuffix = " - 结论回复"; + private const string FullReplyLinkPrefix = "已生成完整回复文档:"; + private const string FinalReplyLinkPrefix = "已生成结论回复文档:"; + + private const string AudioFullReplySuffix = " - 听完整回复"; + private const string AudioFinalReplySuffix = " - 听结论回复"; + private const string AudioFullReplyLinkPrefix = "已生成听完整回复文档:"; + private const string AudioFinalReplyLinkPrefix = "已生成听结论回复文档:"; + private const string MarkdownImportLinkPrefix = "已生成Markdown在线文档:"; + + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _chatLocks = new(StringComparer.OrdinalIgnoreCase); + private readonly ReplyDocumentMarkdownRenderer _markdownRenderer; + private readonly ReferencedMarkdownDocumentImporter _markdownDocumentImporter; + + public ReplyDocumentOrchestrator( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _markdownRenderer = new ReplyDocumentMarkdownRenderer( + serviceProvider.GetService>() + ?? NullLogger.Instance); + _markdownDocumentImporter = new ReferencedMarkdownDocumentImporter( + serviceProvider.GetService>() + ?? NullLogger.Instance, + serviceProvider.GetRequiredService()); + } + + public Task QueueCompletedReplyAsync(FeishuCompletedReplyDocumentRequest request) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.ChatId)) + { + throw new ArgumentException("Chat ID is required.", nameof(request)); + } + + _ = Task.Run(() => ProcessQueuedReplyAsync(request)); + return Task.CompletedTask; + } + + private async Task ProcessQueuedReplyAsync(FeishuCompletedReplyDocumentRequest request) + { + var chatLock = _chatLocks.GetOrAdd(request.ChatId.Trim(), static _ => new SemaphoreSlim(1, 1)); + await chatLock.WaitAsync(); + try + { + await ProcessReplyCoreAsync(request); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Reply document orchestration failed for chat {ChatId}", request.ChatId); + } + finally + { + chatLock.Release(); + } + } + + private async Task ProcessReplyCoreAsync(FeishuCompletedReplyDocumentRequest request) + { + using var scope = _serviceProvider.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + var userConfig = await ResolveBotConfigAsync(configService, request.Username, request.AppId); + if (userConfig == null) + { + return; + } + + var cardKitClient = scope.ServiceProvider.GetRequiredService(); + var fullReplyContent = NormalizeDocumentBody(request.Output); + var finalReplyContent = NormalizeDocumentBody(await ResolveFinalOnlyOutputAsync(scope.ServiceProvider, request)); + var titleQuestion = await ResolveTitleQuestionAsync(scope.ServiceProvider, request); + var fullReplyTitle = BuildDocumentTitle(request, titleQuestion, FullReplySuffix); + var finalReplyTitle = BuildDocumentTitle(request, titleQuestion, FinalReplySuffix); + var audioFullReplyTitle = BuildDocumentTitle(request, titleQuestion, AudioFullReplySuffix); + var audioFinalReplyTitle = BuildDocumentTitle(request, titleQuestion, AudioFinalReplySuffix); + var fullReplyDocumentBody = BuildReplyDocumentBody(fullReplyContent, titleQuestion, appendUserQuestionToEnd: false); + var finalReplyDocumentBody = BuildReplyDocumentBody(finalReplyContent, titleQuestion, appendUserQuestionToEnd: false); + var audioFullReplyDocumentBody = BuildReplyDocumentBody( + ListeningReplyDocumentFormatter.Format(fullReplyContent), + titleQuestion, + appendUserQuestionToEnd: true); + var audioFinalReplyDocumentBody = BuildReplyDocumentBody( + ListeningReplyDocumentFormatter.Format(finalReplyContent), + titleQuestion, + appendUserQuestionToEnd: true); + + if (userConfig.FullReplyDocEnabled && !string.IsNullOrWhiteSpace(fullReplyContent)) + { + await TryCreateAndSendDocumentAsync( + cardKitClient, + scope.ServiceProvider, + configService, + request, + fullReplyTitle, + fullReplyDocumentBody, + FullReplyLinkPrefix, + userConfig.DocumentAdminOpenId); + } + + if (userConfig.FinalReplyDocEnabled && !string.IsNullOrWhiteSpace(finalReplyContent)) + { + await TryCreateAndSendDocumentAsync( + cardKitClient, + scope.ServiceProvider, + configService, + request, + finalReplyTitle, + finalReplyDocumentBody, + FinalReplyLinkPrefix, + userConfig.DocumentAdminOpenId); + } + + if (userConfig.AudioFullReplyDocEnabled && !string.IsNullOrWhiteSpace(fullReplyContent)) + { + await TryCreateAndSendDocumentAsync( + cardKitClient, + scope.ServiceProvider, + configService, + request, + audioFullReplyTitle, + audioFullReplyDocumentBody, + AudioFullReplyLinkPrefix, + userConfig.DocumentAdminOpenId); + } + + if (userConfig.AudioFinalReplyDocEnabled && !string.IsNullOrWhiteSpace(finalReplyContent)) + { + await TryCreateAndSendDocumentAsync( + cardKitClient, + scope.ServiceProvider, + configService, + request, + audioFinalReplyTitle, + audioFinalReplyDocumentBody, + AudioFinalReplyLinkPrefix, + userConfig.DocumentAdminOpenId); + } + + if (userConfig.ReferencedMarkdownDocImportEnabled) + { + await TryImportReferencedMarkdownDocumentsAsync( + cardKitClient, + scope.ServiceProvider, + configService, + userConfig, + request, + fullReplyContent, + finalReplyContent); + } + } + + private async Task TryCreateAndSendDocumentAsync( + IFeishuCardKitClient cardKitClient, + IServiceProvider serviceProvider, + IUserFeishuBotConfigService configService, + FeishuCompletedReplyDocumentRequest request, + string title, + string body, + string messagePrefix, + string? documentAdminOpenId) + { + try + { + var effectiveOptions = await ResolveEffectiveOptionsAsync(configService, request.Username, request.AppId); + string? placementWarningMessage = null; + string? documentAdminWarningMessage = null; + string? folderAdminWarningMessage = null; + string? folderToken = null; + + var folderName = await ResolveReplyDocumentFolderNameAsync(serviceProvider, request); + if (!string.IsNullOrWhiteSpace(folderName)) + { + try + { + folderToken = await cardKitClient.EnsureCloudFolderAsync( + folderName, + optionsOverride: effectiveOptions); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MarkFailureStage(ex, ReplyDocumentFailureStage.ResolveFolder); + placementWarningMessage = BuildPlacementWarningMessage(messagePrefix, ex); + _logger.LogWarning( + ex, + "Reply document folder placement failed for chat {ChatId}, session {SessionId}, title {Title}, stage {FailureStage}", + request.ChatId, + request.SessionId, + title, + ReplyDocumentFailureStage.ResolveFolder); + folderToken = null; + } + + if (!string.IsNullOrWhiteSpace(folderToken)) + { + if (!string.IsNullOrWhiteSpace(documentAdminOpenId)) + { + try + { + await cardKitClient.GrantCloudFolderMemberFullAccessAsync( + folderToken, + documentAdminOpenId, + optionsOverride: effectiveOptions); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + folderAdminWarningMessage = BuildFolderAdminGrantWarningMessage(messagePrefix, ex); + _logger.LogWarning( + ex, + "Reply document folder admin grant failed for chat {ChatId}, session {SessionId}, title {Title}", + request.ChatId, + request.SessionId, + title); + } + } + } + } + + FeishuCloudDocumentInfo document; + if (!string.IsNullOrWhiteSpace(folderToken)) + { + try + { + document = await cardKitClient.CreateCloudDocumentAsync( + TruncateTitle(title), + optionsOverride: effectiveOptions, + folderToken: folderToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MarkFailureStage(ex, ReplyDocumentFailureStage.CreateInFolder); + _logger.LogWarning( + ex, + "Reply document direct folder creation failed for chat {ChatId}, session {SessionId}, title {Title}, stage {FailureStage}", + request.ChatId, + request.SessionId, + title, + ReplyDocumentFailureStage.CreateInFolder); + + document = await cardKitClient.CreateCloudDocumentAsync( + TruncateTitle(title), + optionsOverride: effectiveOptions); + + if (ShouldAttemptMoveFallbackAfterCreateFailure(ex)) + { + try + { + await cardKitClient.MoveCloudDocumentToFolderAsync( + document.DocumentId, + folderToken, + optionsOverride: effectiveOptions); + } + catch (Exception moveEx) when (moveEx is not OperationCanceledException) + { + MarkFailureStage(moveEx, ReplyDocumentFailureStage.MoveToFolder); + placementWarningMessage = BuildPlacementWarningMessage(messagePrefix, moveEx); + _logger.LogWarning( + moveEx, + "Reply document folder placement failed for chat {ChatId}, session {SessionId}, title {Title}, stage {FailureStage}", + request.ChatId, + request.SessionId, + title, + ReplyDocumentFailureStage.MoveToFolder); + } + } + else + { + placementWarningMessage = BuildPlacementWarningMessage(messagePrefix, ex); + } + } + } + else + { + document = await cardKitClient.CreateCloudDocumentAsync( + TruncateTitle(title), + optionsOverride: effectiveOptions); + } + + await cardKitClient.AppendCloudDocumentTextAsync( + document.DocumentId, + document.RootBlockId, + body, + optionsOverride: effectiveOptions); + + await cardKitClient.SetCloudDocumentTenantReadableAsync( + document.DocumentId, + optionsOverride: effectiveOptions); + + if (!string.IsNullOrWhiteSpace(documentAdminOpenId)) + { + try + { + await cardKitClient.GrantCloudDocumentMemberFullAccessAsync( + document.DocumentId, + documentAdminOpenId, + optionsOverride: effectiveOptions); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + documentAdminWarningMessage = BuildDocumentAdminGrantWarningMessage(messagePrefix, ex); + _logger.LogWarning( + ex, + "Reply document admin grant failed for chat {ChatId}, session {SessionId}, title {Title}", + request.ChatId, + request.SessionId, + title); + } + } + + await cardKitClient.SendTextMessageAsync( + request.ChatId, + $"{messagePrefix}{document.Url}", + optionsOverride: effectiveOptions); + + if (!string.IsNullOrWhiteSpace(placementWarningMessage)) + { + await TrySendPlacementWarningMessageAsync( + cardKitClient, + request, + placementWarningMessage); + } + + if (!string.IsNullOrWhiteSpace(documentAdminWarningMessage)) + { + await TrySendDocumentAdminWarningMessageAsync( + cardKitClient, + request, + documentAdminWarningMessage); + } + + if (!string.IsNullOrWhiteSpace(folderAdminWarningMessage)) + { + await TrySendDocumentAdminWarningMessageAsync( + cardKitClient, + request, + folderAdminWarningMessage); + } + } + catch (Exception ex) + { + var failureStage = GetFailureStage(ex)?.ToString() ?? "unknown"; + _logger.LogWarning( + ex, + "Reply document generation failed for chat {ChatId}, session {SessionId}, title {Title}, stage {FailureStage}", + request.ChatId, + request.SessionId, + title, + failureStage); + + await TrySendFailureMessageAsync( + cardKitClient, + request, + BuildFailureMessage(messagePrefix, ex)); + } + } + + private static string BuildFailureMessage(string messagePrefix, Exception exception) + { + var scopeHint = TryExtractPermissionScopes(exception.Message); + var target = ResolveDocumentTarget(messagePrefix); + var failureStage = GetFailureStage(exception); + + if (!string.IsNullOrWhiteSpace(scopeHint)) + { + var permissionGuidance = TryExtractPermissionGuidance(exception.Message); + return string.IsNullOrWhiteSpace(permissionGuidance) + ? $"{target}生成失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。" + : $"{target}生成失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。{permissionGuidance}"; + } + + if (IsDriveResourceNotFoundError(exception.Message)) + { + return BuildDriveResourceNotFoundMessage(target, failureStage); + } + + var compactReason = BuildCompactErrorReason(exception.Message); + return string.IsNullOrWhiteSpace(compactReason) + ? $"{target}生成失败,请检查飞书文档权限或服务日志。" + : $"{target}生成失败:{compactReason}"; + } + + private static string BuildCompactErrorReason(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return string.Empty; + } + + var normalized = message.Replace("\r", " ").Replace("\n", " ").Trim(); + return normalized.Length <= 180 + ? normalized + : normalized[..180].TrimEnd(); + } + + private static string? TryExtractPermissionScopes(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return null; + } + + if (!message.Contains("scope", StringComparison.OrdinalIgnoreCase) + && !message.Contains("permission", StringComparison.OrdinalIgnoreCase) + && !message.Contains("权限", StringComparison.OrdinalIgnoreCase) + && !message.Contains("Access denied", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var scopes = Regex.Matches( + message, + @"\b(?:docx|drive):[a-z0-9.-]+(?::[a-z0-9.-]+)*\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant) + .Select(static match => match.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return scopes.Count == 0 + ? null + : string.Join("\u3001", scopes); + } + + private static string? TryExtractPermissionGuidance(string? message) + { + const string prefix = "应用尚未开通所需的应用身份权限:"; + + if (string.IsNullOrWhiteSpace(message)) + { + return null; + } + + var startIndex = message.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + if (startIndex < 0) + { + return null; + } + + var guidance = message[startIndex..]; + var endIndex = guidance.IndexOfAny(['"', '\r', '\n']); + if (endIndex >= 0) + { + guidance = guidance[..endIndex]; + } + + guidance = guidance.Trim().TrimEnd('}'); + if (!guidance.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return guidance.Length == prefix.Length + ? null + : guidance; + } + + private static bool IsDriveResourceNotFoundError(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return false; + } + + return message.Contains("Status=NotFound", StringComparison.OrdinalIgnoreCase) + || message.Contains("\"code\":1061003", StringComparison.OrdinalIgnoreCase) + || message.Contains("code=1061003", StringComparison.OrdinalIgnoreCase) + || message.Contains("\"msg\":\"not found.\"", StringComparison.OrdinalIgnoreCase) + || message.Contains("msg\":\"not found", StringComparison.OrdinalIgnoreCase); + } + + private static string BuildDriveResourceNotFoundMessage(string target, ReplyDocumentFailureStage? failureStage) + { + return failureStage switch + { + ReplyDocumentFailureStage.ResolveFolder + => $"{target}生成失败:在定位会话文档文件夹时,飞书文件夹资源不存在或已失效,请稍后重试;若持续出现,请检查该会话文档文件夹是否已被删除。", + ReplyDocumentFailureStage.CreateInFolder + => $"{target}生成失败:在归档到会话文档文件夹时,飞书文档或目标文件夹资源不存在或已失效,请稍后重试;若持续出现,请检查目标文件夹是否已被删除。", + ReplyDocumentFailureStage.MoveToFolder + => $"{target}生成失败:在移动到会话文档文件夹时,飞书文档或目标文件夹资源不存在或已失效,请稍后重试;若持续出现,请检查目标文件夹是否已被删除。", + _ => $"{target}生成失败:飞书文档资源不存在或已失效,请稍后重试;若持续出现,请检查目标文档或文件夹是否已被删除。" + }; + } + + private static string BuildPlacementWarningMessage(string messagePrefix, Exception exception) + { + var target = ResolveDocumentTarget(messagePrefix); + var failureStage = GetFailureStage(exception); + var scopeHint = TryExtractPermissionScopes(exception.Message); + + if (!string.IsNullOrWhiteSpace(scopeHint)) + { + var permissionGuidance = TryExtractPermissionGuidance(exception.Message); + var guidanceSuffix = string.IsNullOrWhiteSpace(permissionGuidance) ? string.Empty : permissionGuidance; + return $"{target}已生成,但归档到会话文档文件夹失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。{guidanceSuffix}文档已保留在飞书默认目录。"; + } + + if (IsDriveResourceNotFoundError(exception.Message)) + { + return failureStage switch + { + ReplyDocumentFailureStage.ResolveFolder + => $"{target}已生成,但在定位会话文档文件夹时,飞书文件夹资源不存在或已失效。文档已保留在飞书默认目录;若持续出现,请检查该会话文档文件夹是否已被删除。", + ReplyDocumentFailureStage.CreateInFolder + => $"{target}已生成,但在归档到会话文档文件夹时,飞书文档或目标文件夹资源不存在或已失效。文档已保留在飞书默认目录;若持续出现,请检查目标文件夹是否已被删除。", + ReplyDocumentFailureStage.MoveToFolder + => $"{target}已生成,但在移动到会话文档文件夹时,飞书文档或目标文件夹资源不存在或已失效。文档已保留在飞书默认目录;若持续出现,请检查目标文件夹是否已被删除。", + _ => $"{target}已生成,但归档到会话文档文件夹时遇到资源不存在或已失效。文档已保留在飞书默认目录;若持续出现,请检查目标文档或文件夹是否已被删除。" + }; + } + + var compactReason = BuildCompactErrorReason(exception.Message); + return string.IsNullOrWhiteSpace(compactReason) + ? $"{target}已生成,但归档到会话文档文件夹失败。文档已保留在飞书默认目录。" + : $"{target}已生成,但归档到会话文档文件夹失败:{compactReason}。文档已保留在飞书默认目录。"; + } + + private static string BuildDocumentAdminGrantWarningMessage(string messagePrefix, Exception exception) + { + var target = ResolveDocumentTarget(messagePrefix); + var scopeHint = TryExtractPermissionScopes(exception.Message); + + if (!string.IsNullOrWhiteSpace(scopeHint)) + { + var permissionGuidance = TryExtractPermissionGuidance(exception.Message); + return string.IsNullOrWhiteSpace(permissionGuidance) + ? $"{target}已生成,但文档管理员权限授予失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。" + : $"{target}已生成,但文档管理员权限授予失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。{permissionGuidance}"; + } + + if (IsDriveResourceNotFoundError(exception.Message)) + { + return $"{target}已生成,但文档管理员权限授予失败:飞书文档资源不存在或已失效,请稍后重试;若持续出现,请检查目标文档是否已被删除。"; + } + + var compactReason = BuildCompactErrorReason(exception.Message); + return string.IsNullOrWhiteSpace(compactReason) + ? $"{target}已生成,但文档管理员权限授予失败,请稍后重试。" + : $"{target}已生成,但文档管理员权限授予失败:{compactReason}"; + } + + private static string BuildFolderAdminGrantWarningMessage(string messagePrefix, Exception exception) + { + var target = ResolveDocumentTarget(messagePrefix); + var scopeHint = TryExtractPermissionScopes(exception.Message); + + if (!string.IsNullOrWhiteSpace(scopeHint)) + { + var permissionGuidance = TryExtractPermissionGuidance(exception.Message); + return string.IsNullOrWhiteSpace(permissionGuidance) + ? $"{target}已生成,但会话文档文件夹管理员权限授予失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。" + : $"{target}已生成,但会话文档文件夹管理员权限授予失败:飞书应用缺少文档权限,请开通 {scopeHint} 后重试。{permissionGuidance}"; + } + + if (IsDriveResourceNotFoundError(exception.Message)) + { + return $"{target}已生成,但会话文档文件夹管理员权限授予失败:目标文件夹资源不存在或已失效,请稍后重试;若持续出现,请检查目标文件夹是否已被删除。"; + } + + var compactReason = BuildCompactErrorReason(exception.Message); + return string.IsNullOrWhiteSpace(compactReason) + ? $"{target}已生成,但会话文档文件夹管理员权限授予失败,请稍后重试。" + : $"{target}已生成,但会话文档文件夹管理员权限授予失败:{compactReason}"; + } + + private static void MarkFailureStage(Exception exception, ReplyDocumentFailureStage failureStage) + { + exception.Data[FailureStageDataKey] = failureStage; + } + + private static ReplyDocumentFailureStage? GetFailureStage(Exception exception) + { + if (exception.Data[FailureStageDataKey] is ReplyDocumentFailureStage failureStage) + { + return failureStage; + } + + if (exception.Data[FailureStageDataKey] is string failureStageText + && Enum.TryParse(failureStageText, ignoreCase: true, out var parsedFailureStage)) + { + return parsedFailureStage; + } + + return null; + } + + private static string ResolveDocumentTarget(string messagePrefix) + { + if (string.Equals(messagePrefix, FinalReplyLinkPrefix, StringComparison.Ordinal)) + { + return "结论回复文档"; + } + + if (string.Equals(messagePrefix, AudioFullReplyLinkPrefix, StringComparison.Ordinal)) + { + return "听完整回复文档"; + } + + if (string.Equals(messagePrefix, AudioFinalReplyLinkPrefix, StringComparison.Ordinal)) + { + return "听结论回复文档"; + } + + if (string.Equals(messagePrefix, MarkdownImportLinkPrefix, StringComparison.Ordinal)) + { + return "Markdown在线文档"; + } + + return "完整回复文档"; + } + + private enum ReplyDocumentFailureStage + { + ResolveFolder, + CreateInFolder, + MoveToFolder + } + + private static bool ShouldAttemptMoveFallbackAfterCreateFailure(Exception exception) + { + var message = exception.Message; + if (string.IsNullOrWhiteSpace(message)) + { + return true; + } + + if (IsDriveResourceNotFoundError(message)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(TryExtractPermissionScopes(message))) + { + return false; + } + + return true; + } + + private async Task TrySendFailureMessageAsync( + IFeishuCardKitClient cardKitClient, + FeishuCompletedReplyDocumentRequest request, + string message) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + var effectiveOptions = await ResolveEffectiveOptionsAsync(configService, request.Username, request.AppId); + await cardKitClient.SendTextMessageAsync( + request.ChatId, + message, + optionsOverride: effectiveOptions); + } + catch (Exception notifyEx) + { + _logger.LogWarning( + notifyEx, + "Reply document failure notification failed for chat {ChatId}, session {SessionId}", + request.ChatId, + request.SessionId); + } + } + + private static string NormalizeDocumentBody(string? content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return string.Empty; + } + + return content.Replace("\r\n", "\n").Trim(); + } + + private static string BuildDocumentTitle( + FeishuCompletedReplyDocumentRequest request, + string? titleQuestion, + string suffix) + { + var normalizedQuestion = NormalizeQuestionForTitle(titleQuestion); + if (string.IsNullOrWhiteSpace(normalizedQuestion)) + { + normalizedQuestion = "未命名"; + } + + var _ = suffix; + const string timestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; + var timestamp = DateTime.Now.ToString(timestampFormat); + var maxQuestionLength = MaxTitleLength - timestamp.Length - 1; + + var truncatedQuestion = maxQuestionLength > 0 && normalizedQuestion.Length > maxQuestionLength + ? normalizedQuestion[..maxQuestionLength].TrimEnd() + : normalizedQuestion; + + if (string.IsNullOrWhiteSpace(truncatedQuestion)) + { + truncatedQuestion = "未命名"; + } + + return $"{truncatedQuestion} {timestamp}"; + } + + private static string? ResolveEffectiveCliThreadId( + ChatSessionEntity? session, + FeishuCompletedReplyDocumentRequest request) + { + var cliThreadId = !string.IsNullOrWhiteSpace(request.CliThreadId) + ? request.CliThreadId + : session?.CliThreadId; + + return string.IsNullOrWhiteSpace(cliThreadId) + ? null + : cliThreadId.Trim(); + } + + private static string? AppendThreadIdToNamedFolder(string? sessionTitle, string? cliThreadId) + { + if (string.IsNullOrWhiteSpace(sessionTitle)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(cliThreadId)) + { + return sessionTitle; + } + + return $"{sessionTitle} [{cliThreadId}]"; + } + + private async Task ResolveTitleQuestionAsync(IServiceProvider serviceProvider, FeishuCompletedReplyDocumentRequest request) + { + var resolvedFromHistory = await TryResolveLastRealUserMessageAsync(serviceProvider, request); + if (!string.IsNullOrWhiteSpace(resolvedFromHistory)) + { + return resolvedFromHistory; + } + + return request.OriginalUserQuestion; + } + + private async Task TryResolveLastRealUserMessageAsync(IServiceProvider serviceProvider, FeishuCompletedReplyDocumentRequest request) + { + if (string.IsNullOrWhiteSpace(request.SessionId)) + { + return null; + } + + try + { + var sessionRepository = serviceProvider.GetService(); + var historyService = serviceProvider.GetService(); + if (sessionRepository == null || historyService == null) + { + return null; + } + + var session = await sessionRepository.GetByIdAsync(request.SessionId.Trim()); + if (session == null) + { + return null; + } + + var effectiveToolId = SessionLaunchOverrideHelper.ResolveEffectiveToolId( + session.ToolId, + session.CcSwitchSnapshotToolId); + if (string.IsNullOrWhiteSpace(effectiveToolId)) + { + return null; + } + + var cliThreadId = string.IsNullOrWhiteSpace(request.CliThreadId) + ? session.CliThreadId?.Trim() + : request.CliThreadId.Trim(); + if (string.IsNullOrWhiteSpace(cliThreadId)) + { + return null; + } + + var messages = await historyService.GetRecentMessagesAsync( + effectiveToolId, + cliThreadId, + maxCount: 12, + workspacePath: session.WorkspacePath); + + if (messages.Count == 0) + { + return null; + } + + var latestUserMessage = messages + .LastOrDefault(message => + string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(message.Content) + && !LooksLikeControlPrompt(message.Content)); + + return latestUserMessage?.Content?.Trim(); + } + catch (Exception ex) + { + _logger.LogDebug( + ex, + "Reply document title question rollout fallback failed for session {SessionId}", + request.SessionId); + return null; + } + } + + private static bool LooksLikeControlPrompt(string? content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return false; + } + + var normalized = content.Trim(); + if (normalized.StartsWith("/goal", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (normalized.StartsWith("/goal ", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return normalized.Contains("使用Subagent-Driven完成plan", StringComparison.Ordinal) + || normalized.Contains("使用Worktree技能完成Worktree", StringComparison.Ordinal); + } + + private static string NormalizeQuestionForTitle(string? originalUserQuestion) + { + if (string.IsNullOrWhiteSpace(originalUserQuestion)) + { + return string.Empty; + } + + return string.Join( + " ", + originalUserQuestion + .Replace("\r\n", "\n") + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + private static string BuildReplyDocumentBody( + string body, + string? userQuestion, + bool appendUserQuestionToEnd) + { + var normalizedBody = NormalizeDocumentBody(body); + var normalizedQuestion = NormalizeDocumentBody(userQuestion); + if (string.IsNullOrWhiteSpace(normalizedQuestion)) + { + return normalizedBody; + } + + var questionBlock = $"## 用户内容\n\n{normalizedQuestion}"; + if (string.IsNullOrWhiteSpace(normalizedBody)) + { + return questionBlock; + } + + return appendUserQuestionToEnd + ? $"{normalizedBody}\n\n{questionBlock}" + : $"{questionBlock}\n\n---\n\n{normalizedBody}"; + } + + private static string TruncateTitle(string title) + { + if (title.Length <= MaxTitleLength) + { + return title; + } + + return title[..MaxTitleLength].TrimEnd(); + } + + private static async Task ResolveBotConfigAsync( + IUserFeishuBotConfigService configService, + string? username, + string? appId) + { + if (!string.IsNullOrWhiteSpace(username)) + { + var userConfig = await configService.GetByUsernameAsync(username.Trim()); + if (userConfig != null) + { + return userConfig; + } + } + + if (!string.IsNullOrWhiteSpace(appId)) + { + return await configService.GetByAppIdAsync(appId.Trim()); + } + + return null; + } + + private async Task ResolveFinalOnlyOutputAsync(IServiceProvider serviceProvider, FeishuCompletedReplyDocumentRequest request) + { + if (!string.IsNullOrWhiteSpace(request.FinalAnswerOutput)) + { + return request.FinalAnswerOutput; + } + + var fallback = await TryResolveCodexFinalAnswerFallbackAsync(serviceProvider, request); + return string.IsNullOrWhiteSpace(fallback) + ? request.FinalAnswerOutput + : fallback; + } + + private async Task TryResolveCodexFinalAnswerFallbackAsync(IServiceProvider serviceProvider, FeishuCompletedReplyDocumentRequest request) + { + if (string.IsNullOrWhiteSpace(request.SessionId)) + { + return null; + } + + try + { + var sessionRepository = serviceProvider.GetService(); + var session = sessionRepository == null + ? null + : await sessionRepository.GetByIdAsync(request.SessionId.Trim()); + if (session == null) + { + return null; + } + + var effectiveToolId = SessionLaunchOverrideHelper.ResolveEffectiveToolId( + session.ToolId, + session.CcSwitchSnapshotToolId); + if (!string.Equals(effectiveToolId, "codex", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var cliThreadId = string.IsNullOrWhiteSpace(request.CliThreadId) + ? session.CliThreadId?.Trim() + : request.CliThreadId.Trim(); + if (string.IsNullOrWhiteSpace(cliThreadId)) + { + return null; + } + + var historyService = serviceProvider.GetService(); + if (historyService == null) + { + return null; + } + + return await historyService.GetCodexFinalAnswerTextAsync( + cliThreadId, + workspacePath: session.WorkspacePath); + } + catch (Exception ex) + { + _logger.LogDebug( + ex, + "Reply document final-only rollout fallback failed for session {SessionId}", + request.SessionId); + return null; + } + } + + private async Task TrySendPlacementWarningMessageAsync( + IFeishuCardKitClient cardKitClient, + FeishuCompletedReplyDocumentRequest request, + string message) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + var effectiveOptions = await ResolveEffectiveOptionsAsync(configService, request.Username, request.AppId); + await cardKitClient.SendTextMessageAsync( + request.ChatId, + message, + optionsOverride: effectiveOptions); + } + catch (Exception notifyEx) + { + _logger.LogWarning( + notifyEx, + "Reply document placement warning notification failed for chat {ChatId}, session {SessionId}", + request.ChatId, + request.SessionId); + } + } + + private async Task TrySendDocumentAdminWarningMessageAsync( + IFeishuCardKitClient cardKitClient, + FeishuCompletedReplyDocumentRequest request, + string message) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + var effectiveOptions = await ResolveEffectiveOptionsAsync(configService, request.Username, request.AppId); + await cardKitClient.SendTextMessageAsync( + request.ChatId, + message, + optionsOverride: effectiveOptions); + } + catch (Exception notifyEx) + { + _logger.LogWarning( + notifyEx, + "Reply document admin warning notification failed for chat {ChatId}, session {SessionId}", + request.ChatId, + request.SessionId); + } + } + + private async Task ResolveReplyDocumentFolderNameAsync( + IServiceProvider serviceProvider, + FeishuCompletedReplyDocumentRequest request) + { + var session = await TryGetSessionAsync(serviceProvider, request.SessionId); + var candidate = ResolveFolderNameCandidate(session, request); + return SanitizeFolderName(candidate); + } + + private static string? ResolveFolderNameCandidate( + ChatSessionEntity? session, + FeishuCompletedReplyDocumentRequest request) + { + var cliThreadId = ResolveEffectiveCliThreadId(session, request); + + if (!IsUnnamedSessionTitle(session?.Title)) + { + return AppendThreadIdToNamedFolder(session?.Title?.Trim(), cliThreadId); + } + + if (!string.IsNullOrWhiteSpace(cliThreadId)) + { + return cliThreadId; + } + + return request.SessionId ?? session?.SessionId; + } + + private static bool IsUnnamedSessionTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return true; + } + + var normalizedTitle = title.Trim(); + return string.Equals(normalizedTitle, "未命名", StringComparison.Ordinal) + || string.Equals(normalizedTitle, "鏈懡鍚?", StringComparison.Ordinal) + || string.Equals(normalizedTitle, "閺堫亜鎳¢崥?", StringComparison.Ordinal); + } + + private static string? SanitizeFolderName(string? folderName) + { + if (string.IsNullOrWhiteSpace(folderName)) + { + return null; + } + + var sanitized = folderName.Trim(); + foreach (var invalidCharacter in InvalidFolderNameCharacters) + { + sanitized = sanitized.Replace(invalidCharacter, '-'); + } + + sanitized = string.Join( + " ", + sanitized.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + return string.IsNullOrWhiteSpace(sanitized) + ? null + : sanitized; + } + + private static async Task TryGetSessionAsync(IServiceProvider serviceProvider, string? sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return null; + } + + var sessionRepository = serviceProvider.GetService(); + return sessionRepository == null + ? null + : await sessionRepository.GetByIdAsync(sessionId.Trim()); + } + + private async Task TryImportReferencedMarkdownDocumentsAsync( + IFeishuCardKitClient cardKitClient, + IServiceProvider serviceProvider, + IUserFeishuBotConfigService configService, + UserFeishuBotConfigEntity userConfig, + FeishuCompletedReplyDocumentRequest request, + string fullReplyContent, + string finalReplyContent) + { + var session = await TryGetSessionAsync(serviceProvider, request.SessionId); + var candidates = ExtractReferencedMarkdownCandidates( + fullReplyContent, + finalReplyContent, + session?.WorkspacePath); + if (candidates.Count == 0) + { + _logger.LogDebug( + "Referenced markdown import skipped because no local markdown candidates were resolved: Session={SessionId}, WorkspacePath={WorkspacePath}", + request.SessionId, + session?.WorkspacePath ?? ""); + return; + } + + var folderName = await ResolveReplyDocumentFolderNameAsync(serviceProvider, request); + if (string.IsNullOrWhiteSpace(folderName)) + { + return; + } + + var effectiveOptions = await ResolveEffectiveOptionsAsync(configService, request.Username, request.AppId); + string folderToken; + try + { + folderToken = await cardKitClient.EnsureCloudFolderAsync( + folderName, + optionsOverride: effectiveOptions); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "Referenced markdown import folder resolution failed for chat {ChatId}, session {SessionId}", + request.ChatId, + request.SessionId); + await TrySendFailureMessageAsync( + cardKitClient, + request, + BuildFailureMessage(MarkdownImportLinkPrefix, ex)); + return; + } + + await _markdownDocumentImporter.ImportMissingAsync( + cardKitClient, + request.ChatId, + folderToken, + candidates, + userConfig.DocumentAdminOpenId, + effectiveOptions, + CancellationToken.None); + } + + private static IReadOnlyList ExtractReferencedMarkdownCandidates( + string fullReplyContent, + string finalReplyContent, + string? workspacePath) + { + if (string.IsNullOrWhiteSpace(workspacePath)) + { + return []; + } + + var results = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + AppendCandidates(fullReplyContent); + AppendCandidates(finalReplyContent); + + return results; + + void AppendCandidates(string sourceText) + { + if (string.IsNullOrWhiteSpace(sourceText)) + { + return; + } + + foreach (var candidate in MarkdownReferenceExtractor.Extract(sourceText, workspacePath)) + { + if (seen.Add(candidate.RelativePath)) + { + results.Add(candidate); + } + } + } + } + + private static async Task ResolveEffectiveOptionsAsync( + IUserFeishuBotConfigService configService, + string? username, + string? appId) + { + if (!string.IsNullOrWhiteSpace(appId)) + { + var appOptions = await configService.GetEffectiveOptionsByAppIdAsync(appId.Trim()); + if (appOptions != null) + { + return appOptions; + } + } + + if (!string.IsNullOrWhiteSpace(username)) + { + return await configService.GetEffectiveOptionsAsync(username.Trim()); + } + + return configService.GetSharedDefaults(); + } +} + diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs deleted file mode 100644 index c541dd3..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs +++ /dev/null @@ -1,327 +0,0 @@ -using System.Text; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using WebCodeCli.Domain.Common.Extensions; -using WebCodeCli.Domain.Common.Options; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(ReplyTtsChunker), ServiceLifetime.Scoped)] -public sealed class ReplyTtsChunker -{ - private static readonly char[] SentenceDelimiters = ['。', '!', '?', '!', '?', ';', ';']; - private static readonly char[] ClauseDelimiters = [',', ',', '、', ':', ':']; - - private readonly int _maxChars; - private readonly int _retryMaxChars; - - [ActivatorUtilitiesConstructor] - public ReplyTtsChunker(IOptions options) - : this(options?.Value?.TtsChunkMaxChars ?? 1200) - { - } - - public ReplyTtsChunker(int maxChars) - { - if (maxChars <= 0) - { - throw new ArgumentOutOfRangeException(nameof(maxChars), "Max chars must be greater than zero."); - } - - _maxChars = maxChars; - _retryMaxChars = ResolveRetryMaxChars(maxChars); - } - - public IReadOnlyList Split(string? text) - { - var normalized = NormalizeInput(text); - if (string.IsNullOrWhiteSpace(normalized)) - { - return []; - } - - var paragraphs = normalized.Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var chunks = new List(); - var current = new StringBuilder(); - - foreach (var paragraph in paragraphs) - { - if (paragraph.Length > _maxChars) - { - FlushCurrentChunk(chunks, current); - foreach (var paragraphChunk in SplitLongSegment(paragraph, _maxChars)) - { - chunks.Add(paragraphChunk); - } - - continue; - } - - if (current.Length == 0) - { - current.Append(paragraph); - continue; - } - - if (current.Length + 2 + paragraph.Length <= _maxChars) - { - current.Append("\n\n"); - current.Append(paragraph); - continue; - } - - FlushCurrentChunk(chunks, current); - current.Append(paragraph); - } - - FlushCurrentChunk(chunks, current); - return chunks; - } - - public IReadOnlyList SplitForRetry(string? text) - { - var normalized = NormalizeInput(text); - if (string.IsNullOrWhiteSpace(normalized)) - { - return []; - } - - var structuredLines = normalized - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (structuredLines.Length > 1) - { - return SplitStructuredLines(structuredLines, _retryMaxChars); - } - - return SplitRetryLongSegment(normalized, _retryMaxChars); - } - - private IReadOnlyList SplitStructuredLines(IReadOnlyList structuredLines, int maxChars) - { - var lineSegments = new List(structuredLines.Count); - foreach (var line in structuredLines) - { - var trimmed = line.Trim(); - if (string.IsNullOrWhiteSpace(trimmed)) - { - continue; - } - - if (trimmed.Length <= maxChars) - { - lineSegments.Add(trimmed); - continue; - } - - lineSegments.AddRange(SplitRetryLongSegment(trimmed, maxChars)); - } - - if (lineSegments.Count <= 2) - { - return lineSegments; - } - - var chunks = new List((lineSegments.Count + 1) / 2); - var current = new StringBuilder(); - var lineCount = 0; - - foreach (var segment in lineSegments) - { - if (current.Length == 0) - { - current.Append(segment); - lineCount = 1; - continue; - } - - if (lineCount < 2 && current.Length + 1 + segment.Length <= maxChars) - { - current.Append('\n'); - current.Append(segment); - lineCount++; - continue; - } - - FlushCurrentChunk(chunks, current); - current.Append(segment); - lineCount = 1; - } - - FlushCurrentChunk(chunks, current); - return chunks; - } - - private IReadOnlyList SplitRetryLongSegment(string segment, int maxChars) - { - var sentencePieces = SplitByDelimiters(segment, SentenceDelimiters); - if (sentencePieces.Count > 1) - { - return sentencePieces - .SelectMany(piece => SplitRetryPiece(piece, maxChars)) - .ToList(); - } - - return SplitRetryPiece(segment, maxChars); - } - - private IEnumerable SplitLongSegment(string segment, int maxChars) - { - var sentenceChunks = CombineWithinLimit(SplitByDelimiters(segment, SentenceDelimiters), maxChars); - if (sentenceChunks.Count > 1 || sentenceChunks[0].Length <= maxChars) - { - return sentenceChunks; - } - - var clauseChunks = CombineWithinLimit(SplitByDelimiters(segment, ClauseDelimiters), maxChars); - if (clauseChunks.Count > 1 || clauseChunks[0].Length <= maxChars) - { - return clauseChunks; - } - - return HardBreak(segment, maxChars); - } - - private static IReadOnlyList SplitRetryPiece(string piece, int maxChars) - { - var trimmed = piece.Trim(); - if (string.IsNullOrWhiteSpace(trimmed)) - { - return []; - } - - if (trimmed.Length <= maxChars) - { - return [trimmed]; - } - - var clausePieces = SplitByDelimiters(trimmed, ClauseDelimiters); - if (clausePieces.Count > 1) - { - return clausePieces - .SelectMany(clause => clause.Length <= maxChars ? [clause.Trim()] : HardBreak(clause, maxChars)) - .Where(static value => !string.IsNullOrWhiteSpace(value)) - .ToList(); - } - - return HardBreak(trimmed, maxChars); - } - - private static List CombineWithinLimit(IReadOnlyList pieces, int maxChars) - { - var chunks = new List(); - var current = new StringBuilder(); - - foreach (var piece in pieces.Where(static value => !string.IsNullOrWhiteSpace(value))) - { - var trimmed = piece.Trim(); - if (trimmed.Length > maxChars) - { - FlushCurrentChunk(chunks, current); - chunks.AddRange(HardBreak(trimmed, maxChars)); - continue; - } - - if (current.Length == 0) - { - current.Append(trimmed); - continue; - } - - if (current.Length + 1 + trimmed.Length <= maxChars) - { - current.Append(' '); - current.Append(trimmed); - continue; - } - - FlushCurrentChunk(chunks, current); - current.Append(trimmed); - } - - FlushCurrentChunk(chunks, current); - return chunks; - } - - private static List HardBreak(string segment, int maxChars) - { - var chunks = new List(); - var remaining = segment.Trim(); - - while (remaining.Length > maxChars) - { - var breakIndex = remaining.LastIndexOf(' ', maxChars); - if (breakIndex <= 0) - { - breakIndex = maxChars; - } - - chunks.Add(remaining[..breakIndex].Trim()); - remaining = remaining[breakIndex..].Trim(); - } - - if (!string.IsNullOrWhiteSpace(remaining)) - { - chunks.Add(remaining); - } - - return chunks; - } - - private static List SplitByDelimiters(string segment, IReadOnlyCollection delimiters) - { - var pieces = new List(); - var current = new StringBuilder(); - - foreach (var character in segment) - { - current.Append(character); - if (delimiters.Contains(character)) - { - pieces.Add(current.ToString().Trim()); - current.Clear(); - } - } - - if (current.Length > 0) - { - pieces.Add(current.ToString().Trim()); - } - - return pieces; - } - - private static void FlushCurrentChunk(ICollection chunks, StringBuilder current) - { - if (current.Length == 0) - { - return; - } - - chunks.Add(current.ToString().Trim()); - current.Clear(); - } - - private static string NormalizeInput(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return string.Empty; - } - - var normalized = text - .Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace('\r', '\n'); - - var lines = normalized - .Split('\n') - .Select(static line => line.TrimEnd()) - .ToArray(); - - return string.Join("\n", lines).Trim(); - } - - private static int ResolveRetryMaxChars(int maxChars) - { - return Math.Min(maxChars, Math.Max(40, Math.Min(maxChars / 2, 160))); - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsEnablementService.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsEnablementService.cs deleted file mode 100644 index c4111da..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsEnablementService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using WebCodeCli.Domain.Common.Extensions; -using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(IReplyTtsEnablementService), ServiceLifetime.Scoped)] -public sealed class ReplyTtsEnablementService : IReplyTtsEnablementService -{ - private readonly IUserFeishuBotConfigRepository _repository; - - public ReplyTtsEnablementService(IUserFeishuBotConfigRepository repository) - { - _repository = repository ?? throw new ArgumentNullException(nameof(repository)); - } - - public async Task HasEnabledReplyTtsAsync(CancellationToken cancellationToken = default) - { - var enabledConfigs = await _repository.GetListAsync(static config => config.ReplyTtsEnabled); - return enabledConfigs.Any(static config => config.ReplyTtsEnabled); - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsFfmpegPathResolver.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsFfmpegPathResolver.cs deleted file mode 100644 index ab8332e..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsFfmpegPathResolver.cs +++ /dev/null @@ -1,80 +0,0 @@ -using WebCodeCli.Domain.Common.Options; -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public static class ReplyTtsFfmpegPathResolver -{ - public static ReplyTtsExecutableResolutionResult Resolve( - FeishuReplyTtsOptions? options, - FeishuReplyTtsHealthStatus storageHealth) - { - var configuredPath = options?.FfmpegExecutablePath?.Trim(); - - if (!string.IsNullOrWhiteSpace(configuredPath)) - { - if (LooksLikeCommandName(configuredPath)) - { - return new ReplyTtsExecutableResolutionResult(true, configuredPath, "Using configured ffmpeg command."); - } - - if (File.Exists(configuredPath)) - { - return new ReplyTtsExecutableResolutionResult(true, configuredPath, "Using configured ffmpeg executable path."); - } - } - - var bundledPath = BuildBundledPath(storageHealth.StorageRoot); - if (!string.IsNullOrWhiteSpace(bundledPath) && File.Exists(bundledPath)) - { - return new ReplyTtsExecutableResolutionResult(true, bundledPath, "Using ffmpeg from Feishu reply TTS storage root."); - } - - return new ReplyTtsExecutableResolutionResult( - false, - null, - BuildFailureMessage(configuredPath, bundledPath)); - } - - private static string BuildFailureMessage(string? configuredPath, string? bundledPath) - { - var details = new List(); - if (!string.IsNullOrWhiteSpace(configuredPath)) - { - details.Add($"configured path '{configuredPath}' was not found"); - } - - if (!string.IsNullOrWhiteSpace(bundledPath)) - { - details.Add($"bundled path '{bundledPath}' was not found"); - } - - return $"Feishu reply TTS ffmpeg executable is unavailable; {string.Join("; ", details)}. Configure FeishuReplyTts:FfmpegExecutablePath or place ffmpeg under the TTS storage root."; - } - - private static string? BuildBundledPath(string? storageRoot) - { - if (string.IsNullOrWhiteSpace(storageRoot)) - { - return null; - } - - return Path.Combine(storageRoot, "ffmpeg", "bin", GetExecutableFileName()); - } - - private static string GetExecutableFileName() - { - return OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg"; - } - - private static bool LooksLikeCommandName(string configuredPath) - { - return !Path.IsPathRooted(configuredPath) && - configuredPath.IndexOfAny(['\\', '/']) < 0; - } - - public sealed record ReplyTtsExecutableResolutionResult( - bool IsAvailable, - string? ExecutablePath, - string Message); -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsLocalServiceManager.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsLocalServiceManager.cs deleted file mode 100644 index b78a7e9..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsLocalServiceManager.cs +++ /dev/null @@ -1,379 +0,0 @@ -using System.Diagnostics; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using WebCodeCli.Domain.Common.Extensions; -using WebCodeCli.Domain.Common.Options; -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(IReplyTtsLocalServiceManager), ServiceLifetime.Singleton)] -public sealed class ReplyTtsLocalServiceManager : IReplyTtsLocalServiceManager -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly IOptionsMonitor _optionsMonitor; - private readonly ILogger _logger; - private readonly SemaphoreSlim _startLock = new(1, 1); - private Process? _startedProcess; - - public ReplyTtsLocalServiceManager( - IServiceScopeFactory scopeFactory, - IOptionsMonitor optionsMonitor, - ILogger logger) - { - _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); - _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task EnsureStartedAsync( - FeishuReplyTtsHealthStatus storageHealth, - CancellationToken cancellationToken = default) - { - if (storageHealth == null) - { - throw new ArgumentNullException(nameof(storageHealth)); - } - - if (!storageHealth.IsAvailable || string.IsNullOrWhiteSpace(storageHealth.StorageRoot)) - { - return storageHealth; - } - - var existingHealth = await TryGetServiceHealthAsync(cancellationToken); - if (existingHealth?.IsAvailable == true) - { - return existingHealth; - } - - await _startLock.WaitAsync(cancellationToken); - try - { - existingHealth = await TryGetServiceHealthAsync(cancellationToken); - if (existingHealth?.IsAvailable == true) - { - return existingHealth; - } - - var startInfoResult = CreateStartInfo(storageHealth); - if (startInfoResult.StartInfo == null) - { - return Unavailable(startInfoResult.Message, "auto-start-unavailable"); - } - - try - { - _startedProcess = Process.Start(startInfoResult.StartInfo); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to start local Kokoro/sherpa-onnx service."); - return Unavailable($"Failed to start local Kokoro/sherpa-onnx service: {ex.Message}", "start-failed"); - } - - if (_startedProcess == null) - { - return Unavailable("Failed to start local Kokoro/sherpa-onnx service: process was not created.", "start-failed"); - } - - _logger.LogInformation( - "Started local Kokoro/sherpa-onnx service process. Pid={ProcessId}", - _startedProcess.Id); - - return await WaitForServiceReadyAsync(_startedProcess, cancellationToken); - } - finally - { - _startLock.Release(); - } - } - - private async Task WaitForServiceReadyAsync(Process process, CancellationToken cancellationToken) - { - var timeout = TimeSpan.FromSeconds(Math.Max(1, _optionsMonitor.CurrentValue.TtsServiceStartupTimeoutSeconds)); - var deadline = DateTimeOffset.UtcNow.Add(timeout); - FeishuReplyTtsHealthStatus? lastHealth = null; - - while (DateTimeOffset.UtcNow < deadline) - { - cancellationToken.ThrowIfCancellationRequested(); - - lastHealth = await TryGetServiceHealthAsync(cancellationToken); - if (lastHealth?.IsAvailable == true) - { - return lastHealth; - } - - if (process.HasExited) - { - return Unavailable( - $"Local Kokoro/sherpa-onnx service exited before becoming healthy. ExitCode={process.ExitCode}.", - "start-exited"); - } - - await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); - } - - return Unavailable( - lastHealth == null - ? $"Local Kokoro/sherpa-onnx service did not become healthy within {timeout.TotalSeconds:0} seconds." - : $"Local Kokoro/sherpa-onnx service did not become healthy within {timeout.TotalSeconds:0} seconds: {lastHealth.Message}", - "startup-timeout"); - } - - private async Task TryGetServiceHealthAsync(CancellationToken cancellationToken) - { - using var scope = _scopeFactory.CreateScope(); - var ttsClient = scope.ServiceProvider.GetRequiredService(); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(2)); - - try - { - return await ttsClient.GetHealthAsync(timeoutCts.Token); - } - catch (Exception ex) when (!IsCancellation(ex, cancellationToken)) - { - _logger.LogDebug(ex, "Local Kokoro/sherpa-onnx health probe failed."); - return null; - } - } - - private StartInfoResult CreateStartInfo(FeishuReplyTtsHealthStatus storageHealth) - { - var options = _optionsMonitor.CurrentValue; - if (!TryGetLocalPort(options.TtsServiceBaseUrl, out var port, out var endpointMessage)) - { - return StartInfoResult.Unavailable(endpointMessage); - } - - var isWindows = OperatingSystem.IsWindows(); - var scriptPath = ResolveStartScriptPath(options, isWindows, out var scriptMessage); - if (scriptPath == null) - { - return StartInfoResult.Unavailable(scriptMessage); - } - - var startInfo = isWindows - ? CreateWindowsStartInfo(scriptPath, storageHealth, options, port) - : CreateUnixStartInfo(scriptPath, storageHealth, options, port); - - return StartInfoResult.Available(startInfo); - } - - private static ProcessStartInfo CreateWindowsStartInfo( - string scriptPath, - FeishuReplyTtsHealthStatus storageHealth, - FeishuReplyTtsOptions options, - int port) - { - var startInfo = new ProcessStartInfo("powershell.exe") - { - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? AppContext.BaseDirectory - }; - - startInfo.ArgumentList.Add("-NoProfile"); - startInfo.ArgumentList.Add("-ExecutionPolicy"); - startInfo.ArgumentList.Add("Bypass"); - startInfo.ArgumentList.Add("-File"); - startInfo.ArgumentList.Add(scriptPath); - startInfo.ArgumentList.Add("-StorageRoot"); - startInfo.ArgumentList.Add(storageHealth.StorageRoot!); - startInfo.ArgumentList.Add("-Port"); - startInfo.ArgumentList.Add(port.ToString()); - startInfo.ArgumentList.Add("-DefaultVoiceId"); - startInfo.ArgumentList.Add(ResolveDefaultVoiceId(options)); - startInfo.ArgumentList.Add("-Provider"); - startInfo.ArgumentList.Add(ResolveProvider(options)); - startInfo.ArgumentList.Add("-Python"); - startInfo.ArgumentList.Add(ResolvePythonPath(storageHealth, options, isWindows: true)); - - return startInfo; - } - - private static ProcessStartInfo CreateUnixStartInfo( - string scriptPath, - FeishuReplyTtsHealthStatus storageHealth, - FeishuReplyTtsOptions options, - int port) - { - var startInfo = new ProcessStartInfo("/usr/bin/env") - { - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? AppContext.BaseDirectory - }; - - startInfo.ArgumentList.Add("bash"); - startInfo.ArgumentList.Add(scriptPath); - startInfo.ArgumentList.Add(storageHealth.StorageRoot!); - startInfo.Environment["KOKORO_PORT"] = port.ToString(); - startInfo.Environment["KOKORO_DEFAULT_VOICE_ID"] = ResolveDefaultVoiceId(options); - startInfo.Environment["KOKORO_PROVIDER"] = ResolveProvider(options); - - return startInfo; - } - - private static string ResolvePythonPath( - FeishuReplyTtsHealthStatus storageHealth, - FeishuReplyTtsOptions options, - bool isWindows) - { - if (!string.IsNullOrWhiteSpace(options.TtsServicePythonPath)) - { - return options.TtsServicePythonPath.Trim(); - } - - if (!string.IsNullOrWhiteSpace(storageHealth.VenvRoot)) - { - var pythonPath = isWindows - ? Path.Combine(storageHealth.VenvRoot, "Scripts", "python.exe") - : Path.Combine(storageHealth.VenvRoot, "bin", "python"); - if (File.Exists(pythonPath)) - { - return pythonPath; - } - } - - return "python"; - } - - private static string ResolveProvider(FeishuReplyTtsOptions options) - { - return string.IsNullOrWhiteSpace(options.TtsPreferredDevice) - ? "cpu" - : options.TtsPreferredDevice.Trim(); - } - - private static string ResolveDefaultVoiceId(FeishuReplyTtsOptions options) - { - return string.IsNullOrWhiteSpace(options.TtsDefaultVoiceId) - ? "zh_47" - : options.TtsDefaultVoiceId.Trim(); - } - - private static string? ResolveStartScriptPath( - FeishuReplyTtsOptions options, - bool isWindows, - out string message) - { - var configuredPath = options.TtsServiceStartScriptPath?.Trim(); - if (!string.IsNullOrWhiteSpace(configuredPath)) - { - if (File.Exists(configuredPath)) - { - message = string.Empty; - return Path.GetFullPath(configuredPath); - } - - message = $"Configured Kokoro/sherpa-onnx start script was not found: {configuredPath}."; - return null; - } - - var scriptName = isWindows ? "start.ps1" : "start.sh"; - foreach (var root in EnumerateSearchRoots()) - { - var candidate = Path.Combine(root, "tools", "sherpa-kokoro-service", scriptName); - if (File.Exists(candidate)) - { - message = string.Empty; - return candidate; - } - } - - message = $"Kokoro/sherpa-onnx start script '{scriptName}' was not found. Set FeishuReplyTts:TtsServiceStartScriptPath."; - return null; - } - - private static IEnumerable EnumerateSearchRoots() - { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var startPath in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) - { - if (string.IsNullOrWhiteSpace(startPath)) - { - continue; - } - - var directory = new DirectoryInfo(startPath); - while (directory != null) - { - if (seen.Add(directory.FullName)) - { - yield return directory.FullName; - } - - directory = directory.Parent; - } - } - } - - private static bool TryGetLocalPort(string? baseUrl, out int port, out string message) - { - port = 0; - var candidate = string.IsNullOrWhiteSpace(baseUrl) - ? "http://127.0.0.1:5058" - : baseUrl.Trim(); - - if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) - { - message = $"Invalid FeishuReplyTts:TtsServiceBaseUrl: {candidate}."; - return false; - } - - if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) - { - message = $"Auto-start only supports local http TTS endpoints. Current endpoint is {uri}."; - return false; - } - - if (!IsLocalHost(uri.Host)) - { - message = $"Auto-start only supports localhost TTS endpoints. Current host is {uri.Host}."; - return false; - } - - port = uri.Port; - message = string.Empty; - return true; - } - - private static bool IsLocalHost(string host) - { - return string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || - string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase) || - string.Equals(host, "::1", StringComparison.OrdinalIgnoreCase) || - string.Equals(host, "[::1]", StringComparison.OrdinalIgnoreCase); - } - - private static FeishuReplyTtsHealthStatus Unavailable(string message, string serviceStatus) - { - return new FeishuReplyTtsHealthStatus - { - IsAvailable = false, - Message = message, - ServiceStatus = serviceStatus - }; - } - - private static bool IsCancellation(Exception exception, CancellationToken cancellationToken) - { - return cancellationToken.IsCancellationRequested && exception is OperationCanceledException; - } - - private sealed record StartInfoResult(ProcessStartInfo? StartInfo, string Message) - { - public static StartInfoResult Available(ProcessStartInfo startInfo) - { - return new StartInfoResult(startInfo, string.Empty); - } - - public static StartInfoResult Unavailable(string message) - { - return new StartInfoResult(null, message); - } - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs deleted file mode 100644 index 6601b65..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs +++ /dev/null @@ -1,458 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using WebCodeCli.Domain.Common.Extensions; -using WebCodeCli.Domain.Common.Options; -using WebCodeCli.Domain.Domain.Model.Channels; -using WebCodeCli.Domain.Domain.Service; -using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(IReplyTtsOrchestrator), ServiceLifetime.Singleton)] -public sealed class ReplyTtsOrchestrator : IReplyTtsOrchestrator -{ - private const string FailureNotice = "回复语音发送失败,已停止后续音频。"; - - private readonly IServiceProvider _serviceProvider; - private readonly ReplyTtsStorageRootResolver _storageRootResolver; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _chatLocks = new(StringComparer.OrdinalIgnoreCase); - - public ReplyTtsOrchestrator( - IServiceProvider serviceProvider, - ReplyTtsStorageRootResolver storageRootResolver, - ILogger logger) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _storageRootResolver = storageRootResolver ?? throw new ArgumentNullException(nameof(storageRootResolver)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request) - { - if (request == null) - { - throw new ArgumentNullException(nameof(request)); - } - - if (string.IsNullOrWhiteSpace(request.ChatId)) - { - throw new ArgumentException("Chat ID is required.", nameof(request)); - } - - _ = Task.Run(() => ProcessQueuedReplyAsync(request)); - return Task.CompletedTask; - } - - private async Task ProcessQueuedReplyAsync(FeishuCompletedReplyTtsRequest request) - { - var chatLock = _chatLocks.GetOrAdd(request.ChatId.Trim(), static _ => new SemaphoreSlim(1, 1)); - await chatLock.WaitAsync(); - try - { - await ProcessReplyCoreAsync(request); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Reply TTS orchestration failed for chat {ChatId}", request.ChatId); - } - finally - { - chatLock.Release(); - } - } - - private async Task ProcessReplyCoreAsync(FeishuCompletedReplyTtsRequest request) - { - using var scope = _serviceProvider.CreateScope(); - var configService = scope.ServiceProvider.GetRequiredService(); - var userConfig = await ResolveBotConfigAsync(configService, request.Username, request.AppId); - if (userConfig?.ReplyTtsEnabled != true) - { - return; - } - - var normalizer = scope.ServiceProvider.GetRequiredService(); - var normalizedOutput = normalizer.Normalize(request.Output); - if (string.IsNullOrWhiteSpace(normalizedOutput)) - { - return; - } - - var storageHealth = _storageRootResolver.Resolve(); - if (!storageHealth.IsAvailable || string.IsNullOrWhiteSpace(storageHealth.TempRoot)) - { - _logger.LogWarning( - "Skipping reply TTS for chat {ChatId} because temp storage is unavailable: {Message}", - request.ChatId, - storageHealth.Message); - return; - } - - var platformService = scope.ServiceProvider.GetRequiredService(); - var voiceResolution = await platformService.ResolveVoiceOrFallbackAsync(userConfig.ReplyTtsVoiceId); - if (!voiceResolution.Success || string.IsNullOrWhiteSpace(voiceResolution.VoiceId)) - { - _logger.LogWarning( - "Skipping reply TTS for chat {ChatId} because voice resolution failed: {Message}", - request.ChatId, - voiceResolution.Message); - return; - } - - var chunker = scope.ServiceProvider.GetRequiredService(); - var chunks = chunker.Split(normalizedOutput); - if (chunks.Count == 0) - { - return; - } - - var ttsClient = scope.ServiceProvider.GetRequiredService(); - var audioTranscodeService = scope.ServiceProvider.GetRequiredService(); - var audioMessageService = scope.ServiceProvider.GetRequiredService(); - var cardKitClient = scope.ServiceProvider.GetRequiredService(); - - var jobId = CreateJobId(); - var jobDirectory = Path.Combine(storageHealth.TempRoot, jobId); - Directory.CreateDirectory(jobDirectory); - - try - { - var sequenceTracker = new ChunkSequenceTracker(); - for (var index = 0; index < chunks.Count; index++) - { - var chunkIndex = index + 1; - var chunkText = chunks[index]; - - try - { - await SendChunkWithRetryAsync( - chunker, - chunkText, - voiceResolution.VoiceId, - jobId, - jobDirectory, - request, - ttsClient, - audioTranscodeService, - audioMessageService, - sequenceTracker); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Reply TTS chunk {ChunkIndex} failed for chat {ChatId}; remaining chunks will be skipped.", - chunkIndex, - request.ChatId); - - await SendFailureNoticeAsync(cardKitClient, configService, request); - return; - } - } - } - finally - { - TryDeleteDirectory(jobDirectory); - } - } - - private async Task SendChunkWithRetryAsync( - ReplyTtsChunker chunker, - string chunkText, - string voiceId, - string jobId, - string jobDirectory, - FeishuCompletedReplyTtsRequest request, - ISherpaKokoroTtsClient ttsClient, - IAudioTranscodeService audioTranscodeService, - IFeishuAudioMessageService audioMessageService, - ChunkSequenceTracker sequenceTracker) - { - try - { - await SendChunkAsync( - chunkText, - voiceId, - jobId, - jobDirectory, - request, - ttsClient, - audioTranscodeService, - audioMessageService, - sequenceTracker); - } - catch (Exception ex) when (IsRetriableChunkFailure(ex)) - { - var retryChunks = chunker.SplitForRetry(chunkText); - if (retryChunks.Count <= 1 || - (retryChunks.Count == 1 && string.Equals(retryChunks[0], chunkText, StringComparison.Ordinal))) - { - throw; - } - - _logger.LogInformation( - "Reply TTS chunk timed out for chat {ChatId}; retrying as {RetryChunkCount} smaller chunks. OriginalLength={OriginalLength}", - request.ChatId, - retryChunks.Count, - chunkText.Length); - - foreach (var retryChunk in retryChunks) - { - await SendChunkAsync( - retryChunk, - voiceId, - jobId, - jobDirectory, - request, - ttsClient, - audioTranscodeService, - audioMessageService, - sequenceTracker); - } - } - } - - private async Task SendChunkAsync( - string chunkText, - string voiceId, - string jobId, - string jobDirectory, - FeishuCompletedReplyTtsRequest request, - ISherpaKokoroTtsClient ttsClient, - IAudioTranscodeService audioTranscodeService, - IFeishuAudioMessageService audioMessageService, - ChunkSequenceTracker sequenceTracker) - { - var chunkIndex = sequenceTracker.Next(); - _logger.LogInformation( - "Starting reply TTS chunk {ChunkIndex} for chat {ChatId}. VoiceId={VoiceId}, TextLength={TextLength}", - chunkIndex, - request.ChatId, - voiceId, - chunkText.Length); - - await using var wavStream = await ttsClient.SynthesizeAsync(chunkText, voiceId); - var wavPath = Path.Combine(jobDirectory, $"chunk-{chunkIndex:000}.wav"); - await WriteStreamToFileAsync(wavStream, wavPath); - var wavInfo = new FileInfo(wavPath); - _logger.LogInformation( - "Reply TTS chunk {ChunkIndex} synthesized for chat {ChatId}. WavePath={WavePath}, WaveBytes={WaveBytes}", - chunkIndex, - request.ChatId, - wavPath, - wavInfo.Exists ? wavInfo.Length : 0); - - var durationMs = GetWaveDurationMs(wavPath); - var opusPath = await audioTranscodeService.TranscodeChunkAsync(jobId, wavPath, chunkIndex); - var opusInfo = new FileInfo(opusPath); - _logger.LogInformation( - "Reply TTS chunk {ChunkIndex} transcoded for chat {ChatId}. OpusPath={OpusPath}, OpusBytes={OpusBytes}, DurationMs={DurationMs}", - chunkIndex, - request.ChatId, - opusPath, - opusInfo.Exists ? opusInfo.Length : 0, - durationMs); - - var messageId = await audioMessageService.SendAudioMessageAsync( - request.ChatId, - opusPath, - durationMs, - request.Username, - request.AppId); - _logger.LogInformation( - "Reply TTS chunk {ChunkIndex} sent for chat {ChatId}. AudioMessageId={MessageId}", - chunkIndex, - request.ChatId, - messageId); - } - - private static bool IsRetriableChunkFailure(Exception exception) - { - return exception is OperationCanceledException or TimeoutException; - } - - private async Task SendFailureNoticeAsync( - IFeishuCardKitClient cardKitClient, - IUserFeishuBotConfigService configService, - FeishuCompletedReplyTtsRequest request) - { - try - { - var options = await ResolveEffectiveOptionsAsync(configService, request.Username, request.AppId); - await cardKitClient.SendTextMessageAsync(request.ChatId, FailureNotice, optionsOverride: options); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to send reply TTS failure notice for chat {ChatId}", request.ChatId); - } - } - - private static async Task WriteStreamToFileAsync(Stream input, string outputPath) - { - input.Position = 0; - await using var output = File.Create(outputPath); - await input.CopyToAsync(output); - } - - private static int GetWaveDurationMs(string wavPath) - { - using var stream = File.OpenRead(wavPath); - using var reader = new BinaryReader(stream); - - if (!IsChunk(reader, "RIFF")) - { - throw new InvalidOperationException("Reply TTS synthesis did not produce a valid RIFF WAV file."); - } - - _ = reader.ReadInt32(); - - if (!IsChunk(reader, "WAVE")) - { - throw new InvalidOperationException("Reply TTS synthesis did not produce a valid WAVE file."); - } - - int? byteRate = null; - int? dataSize = null; - - while (stream.Position <= stream.Length - 8) - { - var chunkId = new string(reader.ReadChars(4)); - var chunkSize = reader.ReadInt32(); - if (chunkSize < 0) - { - throw new InvalidOperationException("Reply TTS synthesis produced an invalid WAV chunk length."); - } - - switch (chunkId) - { - case "fmt ": - if (chunkSize < 16) - { - throw new InvalidOperationException("Reply TTS synthesis produced an invalid WAV format chunk."); - } - - _ = reader.ReadInt16(); - _ = reader.ReadInt16(); - _ = reader.ReadInt32(); - byteRate = reader.ReadInt32(); - _ = reader.ReadInt16(); - _ = reader.ReadInt16(); - SkipRemainingChunkBytes(stream, chunkSize - 16); - break; - - case "data": - dataSize = chunkSize; - SkipRemainingChunkBytes(stream, chunkSize); - break; - - default: - SkipRemainingChunkBytes(stream, chunkSize); - break; - } - - if ((chunkSize & 1) == 1 && stream.Position < stream.Length) - { - stream.Position++; - } - - if (byteRate.HasValue && dataSize.HasValue) - { - break; - } - } - - if (!byteRate.HasValue || !dataSize.HasValue || byteRate.Value <= 0) - { - throw new InvalidOperationException("Reply TTS synthesis produced a WAV file without duration metadata."); - } - - return Math.Max(1, (int)Math.Ceiling(dataSize.Value * 1000d / byteRate.Value)); - } - - private static bool IsChunk(BinaryReader reader, string expected) - { - return string.Equals(new string(reader.ReadChars(4)), expected, StringComparison.Ordinal); - } - - private static void SkipRemainingChunkBytes(Stream stream, int count) - { - if (count <= 0) - { - return; - } - - stream.Position += count; - } - - private static string CreateJobId() - { - return $"reply-tts-{DateTime.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"; - } - - private static void TryDeleteDirectory(string jobDirectory) - { - try - { - if (Directory.Exists(jobDirectory)) - { - Directory.Delete(jobDirectory, recursive: true); - } - } - catch - { - } - } - - private static async Task ResolveBotConfigAsync( - IUserFeishuBotConfigService configService, - string? username, - string? appId) - { - if (!string.IsNullOrWhiteSpace(username)) - { - return await configService.GetByUsernameAsync(username.Trim()); - } - - if (!string.IsNullOrWhiteSpace(appId)) - { - return await configService.GetByAppIdAsync(appId.Trim()); - } - - return null; - } - - private static async Task ResolveEffectiveOptionsAsync( - IUserFeishuBotConfigService configService, - string? username, - string? appId) - { - if (!string.IsNullOrWhiteSpace(appId)) - { - var appOptions = await configService.GetEffectiveOptionsByAppIdAsync(appId.Trim()); - if (appOptions != null) - { - return appOptions; - } - } - - if (!string.IsNullOrWhiteSpace(username)) - { - return await configService.GetEffectiveOptionsAsync(username.Trim()); - } - - return configService.GetSharedDefaults(); - } - - private sealed class ChunkSequenceTracker - { - private int _nextIndex; - - public int Next() - { - _nextIndex++; - return _nextIndex; - } - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs deleted file mode 100644 index 8289e6e..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System.Text.RegularExpressions; -using Microsoft.Extensions.DependencyInjection; -using WebCodeCli.Domain.Common.Extensions; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(ReplyTtsSpeechTextNormalizer), ServiceLifetime.Singleton)] -public sealed class ReplyTtsSpeechTextNormalizer -{ - private static readonly Regex CodeBlockRegex = new("```.*?```", RegexOptions.Singleline | RegexOptions.Compiled); - private static readonly Regex InlineCodeRegex = new("`(?[^`]+)`", RegexOptions.Compiled); - private static readonly Regex MarkdownLinkRegex = new(@"\[(?[^\]]+)\]\((?https?://[^)]+)\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex RawUrlRegex = new(@"https?://\S+", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex HeadingRegex = new(@"^\s{0,3}#{1,6}\s*", RegexOptions.Multiline | RegexOptions.Compiled); - private static readonly Regex QuotePrefixRegex = new(@"^\s{0,3}>\s?", RegexOptions.Multiline | RegexOptions.Compiled); - private static readonly Regex BulletPrefixRegex = new(@"^\s*(?:[-+*]|\d+\.)\s+", RegexOptions.Multiline | RegexOptions.Compiled); - private static readonly Regex FileReferenceRegex = new(@"\b(?:[A-Za-z0-9_.-]+[/\\])+[A-Za-z0-9_.-]+(?::\d+)?\b", RegexOptions.Compiled); - private static readonly Regex SlashSeparatedIdentifierListRegex = new(@"\b[A-Za-z][A-Za-z0-9_]*(?:\s*/\s*[A-Za-z][A-Za-z0-9_]*){2,}\b", RegexOptions.Compiled); - private static readonly Regex CodeLikeIdentifierRegex = new( - @"(?[\p{IsCJKUnifiedIdeographs}A-Za-z0-9]+)\s*/\s*(?[\p{IsCJKUnifiedIdeographs}A-Za-z0-9]+)", RegexOptions.Compiled); - private static readonly Regex CjkInnerSpacesRegex = new(@"(?<=[\p{IsCJKUnifiedIdeographs}])\s+(?=[\p{IsCJKUnifiedIdeographs}])", RegexOptions.Compiled); - private static readonly Regex PunctuationLeadingSpacesRegex = new(@"[ \t]+(?=[,。;:、“”()])", RegexOptions.Compiled); - private static readonly Regex PunctuationTrailingSpacesRegex = new(@"(?<=[,。;:、“”()])[ \t]+", RegexOptions.Compiled); - private static readonly Regex TrailingWhitespaceRegex = new(@"[ \t]+\n", RegexOptions.Compiled); - private static readonly Regex RepeatedBlankLinesRegex = new(@"\n{3,}", RegexOptions.Compiled); - private static readonly Regex RepeatedSpacesRegex = new(@"[ \t]{2,}", RegexOptions.Compiled); - - public string Normalize(string? markdown) - { - if (string.IsNullOrWhiteSpace(markdown)) - { - return string.Empty; - } - - var normalized = markdown - .Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace('\r', '\n'); - - normalized = CodeBlockRegex.Replace(normalized, "\nCode snippet omitted.\n"); - normalized = MarkdownLinkRegex.Replace(normalized, static match => match.Groups["text"].Value.Trim()); - normalized = RawUrlRegex.Replace(normalized, "this link"); - normalized = HeadingRegex.Replace(normalized, string.Empty); - normalized = QuotePrefixRegex.Replace(normalized, string.Empty); - normalized = BulletPrefixRegex.Replace(normalized, string.Empty); - normalized = InlineCodeRegex.Replace(normalized, static match => NormalizeInlineCode(match.Groups["code"].Value)); - normalized = normalized - .Replace("**", string.Empty, StringComparison.Ordinal) - .Replace("__", string.Empty, StringComparison.Ordinal) - .Replace('`', ' '); - normalized = FileReferenceRegex.Replace(normalized, static match => FormatFileReference(match.Value)); - normalized = SlashSeparatedIdentifierListRegex.Replace(normalized, "若干属性字段"); - normalized = CodeLikeIdentifierRegex.Replace(normalized, FormatCodeLikeIdentifierMatch); - normalized = SingleAsteriskRegex.Replace(normalized, string.Empty); - normalized = SingleUnderscoreRegex.Replace(normalized, string.Empty); - normalized = CjkSlashRegex.Replace(normalized, "${left}和${right}"); - normalized = CjkInnerSpacesRegex.Replace(normalized, string.Empty); - normalized = PunctuationLeadingSpacesRegex.Replace(normalized, string.Empty); - normalized = PunctuationTrailingSpacesRegex.Replace(normalized, string.Empty); - normalized = TrailingWhitespaceRegex.Replace(normalized, "\n"); - normalized = RepeatedBlankLinesRegex.Replace(normalized, "\n\n"); - normalized = RepeatedSpacesRegex.Replace(normalized, " "); - - return normalized.Trim(); - } - - private static string NormalizeInlineCode(string code) - { - var trimmed = code.Trim(); - if (string.IsNullOrWhiteSpace(trimmed)) - { - return string.Empty; - } - - if (FileReferenceRegex.IsMatch(trimmed)) - { - return FileReferenceRegex.Replace(trimmed, static match => FormatFileReference(match.Value)); - } - - if (SlashSeparatedIdentifierListRegex.IsMatch(trimmed)) - { - return "若干属性字段"; - } - - if (trimmed.StartsWith("npx ", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("npm ", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("dotnet ", StringComparison.OrdinalIgnoreCase)) - { - return "相关命令"; - } - - if (trimmed.Contains('(', StringComparison.Ordinal) || trimmed.Contains(')', StringComparison.Ordinal)) - { - return FormatCallableReference(trimmed); - } - - if (trimmed.Contains('{', StringComparison.Ordinal) || trimmed.Contains('}', StringComparison.Ordinal)) - { - return "相关调用"; - } - - if (CodeLikeIdentifierRegex.IsMatch(trimmed)) - { - return FormatCodeIdentifier(trimmed); - } - - return trimmed; - } - - private static string FormatCodeLikeIdentifierMatch(Match match) - { - if (LooksLikeFileName(match.Value)) - { - return FormatFileReference(match.Value); - } - - return FormatCodeIdentifier(match.Value); - } - - private static string FormatFileReference(string reference) - { - var withoutLine = RemoveTrailingLineNumber(reference.Trim()); - var normalized = withoutLine.Replace('\\', '/'); - var fileName = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); - - return string.IsNullOrWhiteSpace(fileName) - ? "相关文件" - : $"{fileName} 文件"; - } - - private static string FormatCallableReference(string reference) - { - var methodCandidate = reference.Split('(', 2, StringSplitOptions.TrimEntries)[0]; - return string.IsNullOrWhiteSpace(methodCandidate) - ? "相关调用" - : FormatCodeIdentifier(methodCandidate, preferMethod: true); - } - - private static string FormatCodeIdentifier(string identifier, bool preferMethod = false) - { - var normalized = identifier.Trim().Trim('`', '.', ':', '/', '\\', '-'); - if (string.IsNullOrWhiteSpace(normalized)) - { - return "相关技术标识"; - } - - if (LooksLikeFileName(normalized)) - { - return FormatFileReference(normalized); - } - - var segments = normalized - .Split(['.', ':', '/', '\\', '-'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - if (segments.Length >= 2) - { - var typeName = segments[^2]; - var memberName = segments[^1]; - var memberKind = preferMethod || normalized.Contains(':', StringComparison.Ordinal) || LooksLikeMethodName(memberName) - ? "方法" - : "成员"; - return $"{typeName} 类 {memberName} {memberKind}"; - } - - return $"{normalized} 方法"; - } - - private static string RemoveTrailingLineNumber(string reference) - { - var colonIndex = reference.LastIndexOf(':'); - if (colonIndex <= 1 || colonIndex == reference.Length - 1) - { - return reference; - } - - return reference[(colonIndex + 1)..].All(char.IsDigit) - ? reference[..colonIndex] - : reference; - } - - private static bool LooksLikeMethodName(string value) - { - return value.EndsWith("Async", StringComparison.Ordinal) || - value.Contains("(", StringComparison.Ordinal) || - value.Length > 0 && char.IsUpper(value[0]); - } - - private static bool LooksLikeFileName(string value) - { - var extension = Path.GetExtension(RemoveTrailingLineNumber(value)); - return extension is ".cs" or ".vue" or ".ts" or ".tsx" or ".js" or ".jsx" or ".json" or ".md" or ".py" or ".html" or ".css" or ".scss"; - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStartupHostedService.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStartupHostedService.cs deleted file mode 100644 index 6a58704..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStartupHostedService.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public sealed class ReplyTtsStartupHostedService : IHostedService -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - public ReplyTtsStartupHostedService( - IServiceScopeFactory scopeFactory, - ILogger logger) - { - _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - try - { - using var scope = _scopeFactory.CreateScope(); - var enablementService = scope.ServiceProvider.GetRequiredService(); - if (!await enablementService.HasEnabledReplyTtsAsync(cancellationToken)) - { - _logger.LogDebug("Skipping local reply TTS startup because no Feishu user has reply TTS enabled."); - return; - } - - var platformService = scope.ServiceProvider.GetRequiredService(); - var health = await platformService.EnsureServiceStartedAsync(cancellationToken); - if (health.IsAvailable) - { - _logger.LogInformation("Local reply TTS service is ready at startup. Status={ServiceStatus}", health.ServiceStatus); - return; - } - - _logger.LogWarning( - "Local reply TTS service was requested at startup but is unavailable. Status={ServiceStatus}, Message={Message}", - health.ServiceStatus, - health.Message); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to ensure local reply TTS service at startup."); - } - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs deleted file mode 100644 index c2555f1..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs +++ /dev/null @@ -1,408 +0,0 @@ -using Microsoft.Extensions.Options; -using WebCodeCli.Domain.Common.Options; -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -public sealed class ReplyTtsStorageRootResolver -{ - private const string NonWindowsDefaultRoot = "/data/webcode/kokoro"; - - private readonly IOptionsMonitor _optionsMonitor; - private readonly IReplyTtsHostEnvironment _hostEnvironment; - - public ReplyTtsStorageRootResolver( - IOptionsMonitor optionsMonitor, - IReplyTtsHostEnvironment? hostEnvironment = null) - { - _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); - _hostEnvironment = hostEnvironment ?? new SystemReplyTtsHostEnvironment(); - } - - public FeishuReplyTtsHealthStatus Resolve() - { - var options = _optionsMonitor.CurrentValue ?? new FeishuReplyTtsOptions(); - var explicitRoot = options.TtsStorageRoot?.Trim(); - if (!string.IsNullOrWhiteSpace(explicitRoot)) - { - var useWindowsPaths = UsesWindowsSeparators(explicitRoot) || _hostEnvironment.IsWindows; - if (useWindowsPaths && IsSameDrive(explicitRoot, _hostEnvironment.SystemDriveRoot)) - { - return new FeishuReplyTtsHealthStatus - { - IsAvailable = false, - Message = "Feishu reply TTS storage is unavailable because Kokoro/sherpa-onnx must be installed on a non-system drive. Set FeishuReplyTts:TtsStorageRoot to a non-C drive." - }; - } - - return CreateAvailable( - NormalizeStorageRoot(explicitRoot, useWindowsPaths), - "Using configured Feishu reply TTS storage root.", - useWindowsPaths); - } - - if (!_hostEnvironment.IsWindows) - { - return CreateAvailable( - NonWindowsDefaultRoot, - "Using default non-Windows Feishu reply TTS storage root.", - useWindowsPaths: false); - } - - var systemDriveRoot = NormalizeDriveRoot(_hostEnvironment.SystemDriveRoot); - var writableDrives = _hostEnvironment.GetFixedDrives() - .Where(d => d.IsReady && d.IsWritable) - .ToList(); - - var existingNonSystemDrive = writableDrives.FirstOrDefault(d => - { - if (IsSameDrive(d.RootPath, systemDriveRoot)) - { - return false; - } - - return HasWindowsInstallEvidence(BuildWindowsStorageRoot(d.RootPath)); - }); - if (existingNonSystemDrive is not null) - { - var resolvedRoot = BuildWindowsStorageRoot(existingNonSystemDrive.RootPath); - return CreateAvailable( - resolvedRoot, - $"Using existing Feishu reply TTS storage root on writable non-system drive '{NormalizeDriveRoot(existingNonSystemDrive.RootPath)}'.", - useWindowsPaths: true); - } - - var nonSystemDrive = writableDrives.FirstOrDefault(d => !IsSameDrive(d.RootPath, systemDriveRoot)); - if (nonSystemDrive is not null) - { - var resolvedRoot = BuildWindowsStorageRoot(nonSystemDrive.RootPath); - return CreateAvailable( - resolvedRoot, - $"Using writable non-system drive '{NormalizeDriveRoot(nonSystemDrive.RootPath)}' for Feishu reply TTS storage.", - useWindowsPaths: true); - } - - var systemDrive = writableDrives.FirstOrDefault(d => IsSameDrive(d.RootPath, systemDriveRoot)); - if (systemDrive is not null) - { - var driveLabel = NormalizeDriveRoot(systemDrive.RootPath); - return new FeishuReplyTtsHealthStatus - { - IsAvailable = false, - Message = $"Feishu reply TTS storage is unavailable because only the Windows system drive '{driveLabel}' is writable. Attach a writable non-system drive and set FeishuReplyTts:TtsStorageRoot to that drive." - }; - } - - return new FeishuReplyTtsHealthStatus - { - IsAvailable = false, - Message = "Feishu reply TTS storage is unavailable because no writable fixed drive was found on Windows. Set FeishuReplyTts:TtsStorageRoot explicitly or attach a writable data drive." - }; - } - - private static FeishuReplyTtsHealthStatus CreateAvailable(string storageRoot, string message, bool useWindowsPaths) - { - return new FeishuReplyTtsHealthStatus - { - IsAvailable = true, - StorageRoot = storageRoot, - Message = message, - ModelsRoot = AppendSegment(storageRoot, "models", useWindowsPaths), - CacheRoot = AppendSegment(storageRoot, "cache", useWindowsPaths), - TempRoot = AppendSegment(storageRoot, "temp", useWindowsPaths), - LogsRoot = AppendSegment(storageRoot, "logs", useWindowsPaths), - VenvRoot = AppendSegment(storageRoot, "venv", useWindowsPaths) - }; - } - - private static string BuildWindowsStorageRoot(string driveRoot) - { - return AppendSegment(AppendSegment(NormalizeDriveRoot(driveRoot), "WebCodeData", useWindowsPaths: true), "Kokoro", useWindowsPaths: true); - } - - private bool HasWindowsInstallEvidence(string storageRoot) - { - if (string.IsNullOrWhiteSpace(storageRoot) || !_hostEnvironment.DirectoryExists(storageRoot)) - { - return false; - } - - var ffmpegPath = AppendSegment(AppendSegment(AppendSegment(storageRoot, "ffmpeg", useWindowsPaths: true), "bin", useWindowsPaths: true), "ffmpeg.exe", useWindowsPaths: true); - if (_hostEnvironment.FileExists(ffmpegPath)) - { - return true; - } - - var modelsRoot = AppendSegment(storageRoot, "models", useWindowsPaths: true); - if (_hostEnvironment.DirectoryExists(modelsRoot)) - { - return true; - } - - var venvRoot = AppendSegment(storageRoot, "venv", useWindowsPaths: true); - if (_hostEnvironment.DirectoryExists(venvRoot)) - { - return true; - } - - var serviceRoot = AppendSegment(storageRoot, "service", useWindowsPaths: true); - return _hostEnvironment.DirectoryExists(serviceRoot); - } - - private static string AppendSegment(string root, string segment, bool useWindowsPaths) - { - var separator = useWindowsPaths ? '\\' : '/'; - var normalizedRoot = NormalizeStorageRoot(root, useWindowsPaths); - var normalizedSegment = segment.Trim().Trim('\\', '/'); - - if (normalizedRoot[^1] == separator) - { - return normalizedRoot + normalizedSegment; - } - - return normalizedRoot + separator + normalizedSegment; - } - - private static bool UsesWindowsSeparators(string path) - { - return path.Contains('\\', StringComparison.Ordinal) || - (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':'); - } - - private static bool IsSameDrive(string driveRoot, string systemDriveRoot) - { - return string.Equals( - NormalizeDriveRootForComparison(driveRoot), - NormalizeDriveRootForComparison(systemDriveRoot), - StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizeDriveRootForComparison(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return string.Empty; - } - - var normalized = path.Trim().Replace('/', '\\'); - if (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':') - { - return $"{char.ToUpperInvariant(normalized[0])}:\\"; - } - - return NormalizeDriveRoot(normalized); - } - - private static string NormalizeDriveRoot(string? root) - { - if (string.IsNullOrWhiteSpace(root)) - { - return string.Empty; - } - - var normalized = root.Trim().Replace('/', '\\'); - if (normalized.Length == 2 && normalized[1] == ':') - { - return normalized + "\\"; - } - - normalized = normalized.TrimEnd('\\'); - if (normalized.Length == 2 && normalized[1] == ':') - { - return normalized + "\\"; - } - - if (!normalized.EndsWith('\\')) - { - normalized += "\\"; - } - - return normalized; - } - - private static string NormalizeStorageRoot(string path, bool useWindowsPaths) - { - var separator = useWindowsPaths ? '\\' : '/'; - var alternateSeparator = useWindowsPaths ? '/' : '\\'; - var normalized = path.Trim().Replace(alternateSeparator, separator); - - if (useWindowsPaths && IsWindowsDriveDesignator(normalized)) - { - return normalized + "\\"; - } - - while (normalized.Length > 1 && normalized.EndsWith(separator)) - { - if (!useWindowsPaths && normalized == "/") - { - break; - } - - if (useWindowsPaths && normalized.Length == 3 && normalized[1] == ':' && normalized[2] == '\\') - { - break; - } - - normalized = normalized[..^1]; - } - - return normalized; - } - - private static bool IsWindowsDriveDesignator(string path) - { - return path.Length == 2 && char.IsLetter(path[0]) && path[1] == ':'; - } - - private sealed class SystemReplyTtsHostEnvironment : IReplyTtsHostEnvironment - { - public bool IsWindows => OperatingSystem.IsWindows(); - - public string? SystemDriveRoot - { - get - { - if (!IsWindows) - { - return null; - } - - var systemDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System); - return string.IsNullOrWhiteSpace(systemDirectory) - ? Environment.GetEnvironmentVariable("SystemDrive") - : Path.GetPathRoot(systemDirectory); - } - } - - public IReadOnlyList GetFixedDrives() - { - return DriveInfo.GetDrives() - .Where(drive => drive.DriveType == DriveType.Fixed) - .OrderBy(drive => drive.Name, StringComparer.OrdinalIgnoreCase) - .Select(drive => new ReplyTtsDriveDescriptor( - drive.RootDirectory.FullName, - drive.IsReady, - CanWriteToDrive(drive))) - .ToArray(); - } - - public bool DirectoryExists(string path) - { - return Directory.Exists(path); - } - - public bool FileExists(string path) - { - return File.Exists(path); - } - - private static bool CanWriteToDrive(DriveInfo drive) - { - if (!drive.IsReady) - { - return false; - } - - var probeToken = Guid.NewGuid().ToString("N"); - var probeSandboxRoot = BuildProbeSandboxRoot(drive.RootDirectory.FullName, probeToken); - var probeDirectory = BuildProbeTargetDirectory(drive.RootDirectory.FullName, probeToken); - var probeFilePath = Path.Combine(probeDirectory, "probe.tmp"); - - try - { - Directory.CreateDirectory(probeDirectory); - - using var stream = new FileStream( - probeFilePath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize: 1, - FileOptions.DeleteOnClose); - - return true; - } - catch - { - return false; - } - finally - { - TryDeleteProbePath(probeFilePath); - TryDeleteProbeDirectory(probeSandboxRoot); - } - } - - private static string BuildProbeSandboxRoot(string driveRoot, string probeToken) - { - return Path.Combine( - NormalizeDriveRoot(driveRoot), - $".webcode-feishu-reply-tts-probe-{probeToken}"); - } - - private static string BuildProbeTargetDirectory(string driveRoot, string probeToken) - { - return Path.Combine( - BuildProbeSandboxRoot(driveRoot, probeToken), - "webcode", - "kokoro"); - } - - private static void TryDeleteProbePath(string probeFilePath) - { - try - { - if (File.Exists(probeFilePath)) - { - File.Delete(probeFilePath); - } - } - catch - { - } - } - - private static void TryDeleteProbeDirectory(string probeDirectory) - { - try - { - if (Directory.Exists(probeDirectory)) - { - Directory.Delete(probeDirectory, recursive: true); - } - } - catch - { - } - } - } -} - -public interface IReplyTtsHostEnvironment -{ - bool IsWindows { get; } - - string? SystemDriveRoot { get; } - - IReadOnlyList GetFixedDrives(); - - bool DirectoryExists(string path); - - bool FileExists(string path); -} - -public sealed class ReplyTtsDriveDescriptor -{ - public ReplyTtsDriveDescriptor(string rootPath, bool isReady, bool isWritable) - { - RootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath)); - IsReady = isReady; - IsWritable = isWritable; - } - - public string RootPath { get; } - - public bool IsReady { get; } - - public bool IsWritable { get; } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/SherpaKokoroTtsClient.cs b/WebCodeCli.Domain/Domain/Service/Channels/SherpaKokoroTtsClient.cs deleted file mode 100644 index 3bea847..0000000 --- a/WebCodeCli.Domain/Domain/Service/Channels/SherpaKokoroTtsClient.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using WebCodeCli.Domain.Common.Extensions; -using WebCodeCli.Domain.Common.Options; -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Domain.Domain.Service.Channels; - -[ServiceDescription(typeof(ISherpaKokoroTtsClient), ServiceLifetime.Scoped)] -public sealed class SherpaKokoroTtsClient : ISherpaKokoroTtsClient -{ - private const string HttpClientName = "SherpaKokoroTtsClient"; - - private readonly FeishuReplyTtsOptions _options; - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - private readonly Uri _baseUri; - - public SherpaKokoroTtsClient( - IOptions options, - ILogger logger, - IHttpClientFactory httpClientFactory) - { - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _httpClient = httpClientFactory?.CreateClient(HttpClientName) ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _httpClient.Timeout = Timeout.InfiniteTimeSpan; - _baseUri = CreateBaseUri(_options.TtsServiceBaseUrl); - } - - public async Task GetHealthAsync(CancellationToken cancellationToken = default) - { - using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri("/health")); - using var response = await SendAsync(request, cancellationToken); - using var document = await ParseResponseAsync(response, cancellationToken); - - var root = document.RootElement; - var status = GetString(root, "status"); - var isAvailable = string.Equals(status, "ok", StringComparison.OrdinalIgnoreCase); - - return new FeishuReplyTtsHealthStatus - { - IsAvailable = isAvailable, - Message = isAvailable - ? "Local Kokoro/sherpa-onnx service is healthy." - : $"Local Kokoro/sherpa-onnx service reported status '{status ?? "unknown"}'.", - ServiceStatus = status, - Device = GetString(root, "device"), - DefaultVoiceId = GetString(root, "defaultVoiceId", "default_voice_id") - }; - } - - public async Task> GetVoicesAsync(CancellationToken cancellationToken = default) - { - using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri("/voices")); - using var response = await SendAsync(request, cancellationToken); - using var document = await ParseResponseAsync(response, cancellationToken); - - var voicesElement = document.RootElement.ValueKind switch - { - JsonValueKind.Array => document.RootElement, - _ when document.RootElement.TryGetProperty("voices", out var arrayElement) => arrayElement, - _ => default - }; - - if (voicesElement.ValueKind != JsonValueKind.Array) - { - return []; - } - - var voices = new List(); - foreach (var item in voicesElement.EnumerateArray()) - { - var voiceId = GetString(item, "voiceId", "voice_id"); - if (string.IsNullOrWhiteSpace(voiceId)) - { - continue; - } - - voices.Add(new FeishuReplyTtsVoiceOption - { - VoiceId = voiceId, - DisplayName = GetString(item, "displayName", "display_name", "name") ?? voiceId, - Language = GetString(item, "language"), - Gender = GetString(item, "gender") - }); - } - - return voices; - } - - public async Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(text)) - { - throw new ArgumentException("Text is required.", nameof(text)); - } - - if (string.IsNullOrWhiteSpace(voiceId)) - { - throw new ArgumentException("Voice ID is required.", nameof(voiceId)); - } - - using var request = new HttpRequestMessage(HttpMethod.Post, BuildUri("/synthesize")) - { - Content = new StringContent( - JsonSerializer.Serialize(new - { - text, - voice_id = voiceId - }), - Encoding.UTF8, - "application/json") - }; - - _logger.LogInformation( - "Starting Kokoro/sherpa-onnx synthesis. VoiceId={VoiceId}, TextLength={TextLength}", - voiceId, - text.Length); - - using var response = await SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError("Kokoro/sherpa-onnx synthesize request failed: Status={StatusCode}, Content={Content}", response.StatusCode, error); - throw new HttpRequestException($"Kokoro/sherpa-onnx synthesize request failed: {response.StatusCode}"); - } - - await using var source = await response.Content.ReadAsStreamAsync(cancellationToken); - var output = new MemoryStream(); - await source.CopyToAsync(output, cancellationToken); - output.Position = 0; - _logger.LogInformation( - "Completed Kokoro/sherpa-onnx synthesis. VoiceId={VoiceId}, TextLength={TextLength}, WaveBytes={WaveBytes}", - voiceId, - text.Length, - output.Length); - return output; - } - - private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - if (_options.TtsServiceTimeoutSeconds <= 0) - { - return await _httpClient.SendAsync(request, cancellationToken); - } - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(_options.TtsServiceTimeoutSeconds)); - try - { - return await _httpClient.SendAsync(request, timeoutCts.Token); - } - catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - _logger.LogWarning( - ex, - "Kokoro/sherpa-onnx request timed out after {TimeoutSeconds}s. Method={Method}, Url={Url}", - _options.TtsServiceTimeoutSeconds, - request.Method, - request.RequestUri); - throw new TimeoutException( - $"Kokoro/sherpa-onnx request timed out after {_options.TtsServiceTimeoutSeconds} seconds.", - ex); - } - } - - private static async Task ParseResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) - { - var content = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"Kokoro/sherpa-onnx request failed: {response.StatusCode}"); - } - - return JsonDocument.Parse(content); - } - - private Uri BuildUri(string relativePath) - { - return new Uri(_baseUri, relativePath.TrimStart('/')); - } - - private static Uri CreateBaseUri(string? baseUrl) - { - var candidate = string.IsNullOrWhiteSpace(baseUrl) - ? "http://127.0.0.1:5058/" - : baseUrl.Trim(); - - if (!candidate.EndsWith("/", StringComparison.Ordinal)) - { - candidate += "/"; - } - - return new Uri(candidate, UriKind.Absolute); - } - - private static string? GetString(JsonElement element, params string[] propertyNames) - { - foreach (var propertyName in propertyNames) - { - if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String) - { - return property.GetString(); - } - } - - return null; - } -} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs b/WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs index e1b8651..281971c 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs @@ -8,6 +8,7 @@ internal static class SuperpowersQuickActionCardHelper { private const string ExecutionControlRow = "execution_control_row"; private const string PlanActionRow = "plan_action_row"; + private const string GoalPlanActionRow = "goal_plan_action_row"; private const string CapabilityActionRow = "capability_action_row"; private const string SessionConfirmActionRow = "session_confirm_action_row"; @@ -120,6 +121,28 @@ public static IReadOnlyList CreateBottomActions sessionId, chatKey, toolId) + }, + new FeishuStreamingCardBottomAction + { + Text = SuperpowersQuickActionDefaults.ExecuteGoalPlanButtonText, + Type = "primary", + RowKey = GoalPlanActionRow, + Value = BuildActionValue( + FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction, + sessionId, + chatKey, + toolId) + }, + new FeishuStreamingCardBottomAction + { + Text = SuperpowersQuickActionDefaults.CompleteWorktreeButtonText, + Type = "default", + RowKey = GoalPlanActionRow, + Value = BuildActionValue( + FeishuHelpCardAction.ExecuteSuperpowersCompleteWorktreeAction, + sessionId, + chatKey, + toolId) } ]); } diff --git a/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs b/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs index 27b5b2c..a4c1f7e 100644 --- a/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs +++ b/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs @@ -24,6 +24,8 @@ namespace WebCodeCli.Domain.Domain.Service; public class CliExecutorService : ICliExecutorService { private const string CodexLaunchBaseConfigFileName = "config.webcode.base.toml"; + private const string GoalRuntimeContinuationPromptPrefix = "Continue working toward the current active goal"; + private static readonly Encoding CodexTransportEncoding = new UTF8Encoding(false); private readonly ILogger _logger; private readonly CliToolsOption _options; @@ -199,6 +201,13 @@ public bool SupportsStreamParsing(CliToolConfig tool) return null; } + var liveThreadId = _codexAppServerSessionManager.GetRunningThreadId(sessionId); + if (!string.IsNullOrWhiteSpace(liveThreadId)) + { + RepairCliThreadIdCache(sessionId, liveThreadId, logRepair: true); + return liveThreadId; + } + lock (_cliSessionLock) { if (_cliThreadIds.TryGetValue(sessionId, out var cached) && !string.IsNullOrWhiteSpace(cached)) @@ -266,7 +275,20 @@ public bool SupportsStreamParsing(CliToolConfig tool) public void SetCliThreadId(string sessionId, string threadId) { if (string.IsNullOrEmpty(threadId)) return; - + + var liveThreadId = _codexAppServerSessionManager.GetRunningThreadId(sessionId); + if (!string.IsNullOrWhiteSpace(liveThreadId) + && !string.Equals(liveThreadId, threadId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug( + "忽略与活动 Codex goal runtime 主线程不一致的 CLI ThreadId: Session={SessionId}, ObservedThread={ObservedThread}, LiveThread={LiveThread}", + sessionId, + threadId, + liveThreadId); + RepairCliThreadIdCache(sessionId, liveThreadId, logRepair: false); + return; + } + lock (_cliSessionLock) { _cliThreadIds[sessionId] = threadId; @@ -286,6 +308,44 @@ public void SetCliThreadId(string sessionId, string threadId) } } + private void RepairCliThreadIdCache(string sessionId, string authoritativeThreadId, bool logRepair) + { + string? cachedThreadId; + lock (_cliSessionLock) + { + _cliThreadIds.TryGetValue(sessionId, out cachedThreadId); + _cliThreadIds[sessionId] = authoritativeThreadId; + } + + if (string.Equals(cachedThreadId, authoritativeThreadId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (logRepair) + { + _logger.LogDebug( + "使用活动 Codex goal runtime 主线程修正会话线程绑定: Session={SessionId}, PreviousThread={PreviousThread}, LiveThread={LiveThread}", + sessionId, + cachedThreadId, + authoritativeThreadId); + } + + try + { + using var scope = _serviceProvider.CreateScope(); + var repo = scope.ServiceProvider.GetService(); + if (repo != null) + { + _ = repo.UpdateCliThreadIdAsync(sessionId, authoritativeThreadId).GetAwaiter().GetResult(); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "修正 CLI ThreadId 持久化失败: {SessionId}", sessionId); + } + } + public async Task ResetSessionRuntimeAsync( string sessionId, bool clearCliThreadId = true, @@ -1500,6 +1560,7 @@ private async IAsyncEnumerable ExecuteOneTimeProcessAsync( RedirectStandardInput = true, UseShellExecute = false, CreateNoWindow = true, + StandardInputEncoding = IsCodexExecution(tool, adapter) ? GetCodexTransportEncoding() : Encoding.UTF8, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; @@ -1620,7 +1681,7 @@ private async IAsyncEnumerable ExecuteOneTimeProcessAsync( RegisterActiveSessionProcess(sessionId, process); - WriteStandardInput(process, BuildStandardInput(tool, adapter, sessionContext, useLowInterruption, lowInterruptionPrompt)); + WriteStandardInput(process, BuildStandardInput(tool, adapter, requestWithContext, sessionContext, useLowInterruption, lowInterruptionPrompt)); _logger.LogInformation("进程已启动,PID: {ProcessId},开始读取输出流", process.Id); @@ -2859,10 +2920,16 @@ private static string BuildLowInterruptionArguments( private static string? BuildStandardInput( CliToolConfig tool, ICliToolAdapter? adapter, + CliExecutionRequest request, CliSessionContext sessionContext, bool useLowInterruption, string? lowInterruptionPrompt) { + if (!useLowInterruption && IsCodexExecution(tool, adapter)) + { + return request.BuildPromptText(); + } + if (!useLowInterruption) { return null; @@ -3195,17 +3262,6 @@ await _codexAppServerSessionManager.SetGoalAsync( goal.TokenBudget, threadId, cancellationToken); - - turnRun = await StartGoalTurnWithRetryAsync( - sessionId, - resolvedCommand, - tool, - workingDirectory, - environmentVariables, - sessionContext, - goal.Objective, - threadId, - cancellationToken); } catch (Exception ex) { @@ -3224,18 +3280,25 @@ await _codexAppServerSessionManager.SetGoalAsync( yield break; } - if (turnRun != null) + yield return new StreamOutputChunk { - yield return new StreamOutputChunk - { - Content = "已恢复 goal,正在继续推进...", - IsCompleted = false - }; + Content = "已恢复 goal,正在继续推进...", + IsCompleted = false + }; - await foreach (var chunk in turnRun.Output.WithCancellation(cancellationToken)) - { - yield return chunk; - } + await foreach (var chunk in StreamGoalRuntimeTurnsWhileActiveAsync( + sessionId, + resolvedCommand, + tool, + workingDirectory, + environmentVariables, + sessionContext, + threadId, + goal.Objective, + goal.Objective, + cancellationToken)) + { + yield return chunk; } yield break; @@ -3266,17 +3329,6 @@ await _codexAppServerSessionManager.SetGoalAsync( null, threadId, cancellationToken); - - turnRun = await StartGoalTurnWithRetryAsync( - sessionId, - resolvedCommand, - tool, - workingDirectory, - environmentVariables, - sessionContext, - goalObjective, - threadId, - cancellationToken); } catch (Exception ex) { @@ -3295,18 +3347,25 @@ await _codexAppServerSessionManager.SetGoalAsync( yield break; } - if (turnRun != null) + yield return new StreamOutputChunk { - yield return new StreamOutputChunk - { - Content = $"已提交 goal:{goalObjective}{Environment.NewLine}正在围绕该目标持续推进...", - IsCompleted = false - }; + Content = $"已提交 goal:{goalObjective}{Environment.NewLine}正在围绕该目标持续推进...", + IsCompleted = false + }; - await foreach (var chunk in turnRun.Output.WithCancellation(cancellationToken)) - { - yield return chunk; - } + await foreach (var chunk in StreamGoalRuntimeTurnsWhileActiveAsync( + sessionId, + resolvedCommand, + tool, + workingDirectory, + environmentVariables, + sessionContext, + threadId, + goalObjective, + goalObjective, + cancellationToken)) + { + yield return chunk; } yield break; @@ -3359,6 +3418,132 @@ await _codexAppServerSessionManager.SetGoalAsync( } } + private async IAsyncEnumerable StreamGoalRuntimeTurnsWhileActiveAsync( + string sessionId, + string commandPath, + CliToolConfig tool, + string workingDirectory, + Dictionary? environmentVariables, + CliSessionContext sessionContext, + string threadId, + string initialTurnPrompt, + string goalObjective, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var nextTurnPrompt = initialTurnPrompt; + var currentGoalObjective = goalObjective; + + while (!cancellationToken.IsCancellationRequested) + { + AppServerTurnRun? turnRun = null; + StreamOutputChunk? terminalChunk = null; + try + { + turnRun = await StartGoalTurnWithRetryAsync( + sessionId, + commandPath, + tool, + workingDirectory, + environmentVariables, + sessionContext, + nextTurnPrompt, + threadId, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "启动下一轮 goal runtime turn 失败: Session={SessionId}, Thread={ThreadId}", sessionId, threadId); + terminalChunk = new StreamOutputChunk + { + IsError = true, + IsCompleted = true, + ErrorMessage = $"Codex goal runtime 执行失败: {ex.Message}" + }; + } + + if (terminalChunk != null) + { + yield return terminalChunk; + yield break; + } + + await foreach (var chunk in turnRun!.Output.WithCancellation(cancellationToken)) + { + if (chunk.IsError) + { + yield return chunk; + yield break; + } + + if (chunk.IsCompleted) + { + break; + } + + yield return chunk; + } + + AppServerGoalSnapshot? goalSnapshot = null; + try + { + goalSnapshot = await _codexAppServerSessionManager.GetGoalAsync( + sessionId, + commandPath, + tool, + workingDirectory, + environmentVariables, + sessionContext, + threadId, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "读取 goal runtime 后续状态失败: Session={SessionId}, Thread={ThreadId}", sessionId, threadId); + terminalChunk = new StreamOutputChunk + { + IsError = true, + IsCompleted = true, + ErrorMessage = $"查询 goal 状态失败: {ex.Message}" + }; + } + + if (terminalChunk != null) + { + yield return terminalChunk; + yield break; + } + + if (!string.IsNullOrWhiteSpace(goalSnapshot?.Objective)) + { + currentGoalObjective = goalSnapshot.Objective; + } + + if (!string.Equals(goalSnapshot?.Status, "active", StringComparison.OrdinalIgnoreCase)) + { + yield return new StreamOutputChunk + { + IsCompleted = true + }; + yield break; + } + + yield return new StreamOutputChunk + { + IsTurnBoundary = true + }; + + nextTurnPrompt = BuildGoalRuntimeContinuationPrompt(currentGoalObjective); + } + } + + private static string BuildGoalRuntimeContinuationPrompt(string goalObjective) + { + var trimmedObjective = goalObjective?.Trim(); + return string.IsNullOrWhiteSpace(trimmedObjective) + ? $"{GoalRuntimeContinuationPromptPrefix}. Reuse the current thread context, do not restart from scratch, and keep going until the goal is complete or blocked on user input." + : $"{GoalRuntimeContinuationPromptPrefix}: {trimmedObjective}. Reuse the current thread context, do not restart from scratch, and keep going until the goal is complete or blocked on user input."; + } + private async Task StartGoalTurnWithRetryAsync( string sessionId, string commandPath, @@ -3598,6 +3783,11 @@ private static bool IsCodexExecution(CliToolConfig tool, ICliToolAdapter? adapte string.Equals(id, "codex", StringComparison.OrdinalIgnoreCase)) ?? false); } + internal static Encoding GetCodexTransportEncoding() + { + return CodexTransportEncoding; + } + private static void WriteStandardInput(Process process, string? standardInput) { if (!string.IsNullOrWhiteSpace(standardInput)) @@ -3968,6 +4158,87 @@ public async Task SyncCodexThreadProviderAsync( cancellationToken); } + public async Task TryGetGoalRuntimeGoalAsync( + string sessionId, + string? toolId = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return null; + } + + var session = await TryGetChatSessionAsync(sessionId); + if (session == null) + { + return null; + } + + var effectiveToolId = SessionLaunchOverrideHelper.ResolveEffectiveToolId( + toolId ?? session.ToolId, + session.CcSwitchSnapshotToolId); + if (!string.Equals(effectiveToolId, "codex", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var launchOverride = await GetEffectiveSessionLaunchOverrideAsync(sessionId, effectiveToolId); + if (launchOverride?.UseGoalRuntime != true) + { + return null; + } + + var cliThreadId = GetCliThreadId(sessionId); + if (string.IsNullOrWhiteSpace(cliThreadId) + || !_codexAppServerSessionManager.HasRunningSession(sessionId, cliThreadId)) + { + return null; + } + + var username = ResolveUsernameForToolOperation(null, sessionId); + var tool = GetTool(effectiveToolId, username); + if (tool == null) + { + return null; + } + + tool = ApplySessionLaunchOverride(tool, launchOverride); + + var sessionWorkspace = !string.IsNullOrWhiteSpace(session.WorkspacePath) + ? session.WorkspacePath + : GetOrCreateSessionWorkspace(sessionId); + var workingDirectory = !string.IsNullOrWhiteSpace(tool.WorkingDirectory) + ? tool.WorkingDirectory + : sessionWorkspace; + var environmentVariables = await GetToolEnvironmentVariablesAsync(tool.Id, username); + var sessionContext = await BuildCliSessionContextAsync( + sessionId, + tool.Id, + sessionWorkspace, + cliThreadId, + environmentVariables, + cancellationToken); + sessionContext.WorkingDirectory = workingDirectory; + + try + { + return await _codexAppServerSessionManager.GetGoalAsync( + sessionId, + ResolveCommandPath(tool.Command), + tool, + workingDirectory, + environmentVariables, + sessionContext, + cliThreadId, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "读取 Goal runtime goal 状态失败: Session={SessionId}", sessionId); + return null; + } + } + private async Task BestEffortSyncCodexGoalRuntimeThreadProviderAsync( string sessionId, string sessionWorkspace, @@ -3979,6 +4250,15 @@ private async Task BestEffortSyncCodexGoalRuntimeThreadProviderAsync( return; } + if (_codexAppServerSessionManager.HasRunningSession(sessionId, cliThreadId)) + { + _logger.LogDebug( + "复用现有 Codex goal runtime 会话,跳过线程 Provider 同步: Session={SessionId}, Thread={ThreadId}", + sessionId, + cliThreadId); + return; + } + var targetProviderId = await ResolvePinnedCodexProviderIdAsync(sessionId, sessionWorkspace, cancellationToken); if (string.IsNullOrWhiteSpace(targetProviderId)) { diff --git a/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs b/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs index 8bdfe7a..8394785 100644 --- a/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs +++ b/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Channels; using Microsoft.Extensions.Logging; using WebCodeCli.Domain.Domain.Model; @@ -16,6 +17,9 @@ internal sealed class CodexAppServerSessionManager : ICodexAppServerSessionManag { PropertyNameCaseInsensitive = true }; + private static readonly Regex InterruptActiveTurnMismatchRegex = new( + @"expected active turn id (?[A-Za-z0-9\-]+) but found (?[A-Za-z0-9\-]+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); private readonly ILogger _logger; private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); @@ -330,17 +334,7 @@ public async Task InterruptActiveTurnAsync( return false; } - await SendRequestAsync( - session, - "turn/interrupt", - new Dictionary - { - ["threadId"] = threadId, - ["turnId"] = session.ActiveTurnId - }, - cancellationToken); - - return true; + return await InterruptActiveTurnCoreAsync(session, threadId, cancellationToken); } public async Task InterruptActiveTurnAsync( @@ -359,17 +353,7 @@ public async Task InterruptActiveTurnAsync( return false; } - await SendRequestAsync( - session, - "turn/interrupt", - new Dictionary - { - ["threadId"] = session.ThreadId, - ["turnId"] = session.ActiveTurnId - }, - cancellationToken); - - return true; + return await InterruptActiveTurnCoreAsync(session, session.ThreadId!, cancellationToken); } public bool HasActiveTurn(string sessionId) @@ -379,6 +363,32 @@ public bool HasActiveTurn(string sessionId) && !string.IsNullOrWhiteSpace(session.ActiveTurnId); } + public bool HasRunningSession(string sessionId, string? threadId = null) + { + if (string.IsNullOrWhiteSpace(sessionId) + || !_sessions.TryGetValue(sessionId, out var session) + || !session.IsRunning) + { + return false; + } + + return string.IsNullOrWhiteSpace(threadId) + || string.Equals(session.ThreadId, threadId, StringComparison.OrdinalIgnoreCase); + } + + public string? GetRunningThreadId(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId) + || !_sessions.TryGetValue(sessionId, out var session) + || !session.IsRunning + || string.IsNullOrWhiteSpace(session.ThreadId)) + { + return null; + } + + return session.ThreadId; + } + public bool CleanupSession(string sessionId) { if (_sessions.TryRemove(sessionId, out var session)) @@ -426,11 +436,17 @@ private async Task GetOrCreateSessionAsync( if (_sessions.TryGetValue(sessionId, out var existing)) { - if (existing.IsRunning) + if (existing.IsHealthy) { return existing; } + _logger.LogInformation( + "清理不可复用的 Codex app-server 会话: Session={SessionId}, ProcessRunning={ProcessRunning}, OutputReaderCompleted={OutputReaderCompleted}, ErrorReaderCompleted={ErrorReaderCompleted}", + sessionId, + existing.IsRunning, + existing.OutputReaderTask?.IsCompleted == true, + existing.ErrorReaderTask?.IsCompleted == true); CleanupSession(sessionId); } @@ -540,8 +556,12 @@ private async Task CreateSessionAsync( } var session = new AppServerSession(sessionId, process); - session.OutputReaderTask = Task.Run(() => ReadOutputLoopAsync(session, cancellationToken), CancellationToken.None); - session.ErrorReaderTask = Task.Run(() => ReadErrorLoopAsync(session, cancellationToken), CancellationToken.None); + session.OutputReaderTask = Task.Run( + () => ReadOutputLoopAsync(session, session.SessionCancellationTokenSource.Token), + CancellationToken.None); + session.ErrorReaderTask = Task.Run( + () => ReadErrorLoopAsync(session, session.SessionCancellationTokenSource.Token), + CancellationToken.None); await SendRequestAsync( session, @@ -635,6 +655,62 @@ private async Task ResumeThreadAsync( return response.Thread.Id; } + private async Task InterruptActiveTurnCoreAsync( + AppServerSession session, + string threadId, + CancellationToken cancellationToken) + { + var currentTurnId = session.ActiveTurnId; + if (string.IsNullOrWhiteSpace(currentTurnId)) + { + return false; + } + + try + { + await SendInterruptRequestAsync(session, threadId, currentTurnId, cancellationToken); + session.ActiveTurnId = null; + return true; + } + catch (InvalidOperationException ex) + { + var replacementTurnId = TryResolveReplacementActiveTurnIdForInterruptMismatch(currentTurnId, ex.Message); + if (string.IsNullOrWhiteSpace(replacementTurnId)) + { + throw; + } + + _logger.LogWarning( + "Codex app-server active turn ID mismatch during interrupt, retrying once: Session={SessionId}, Thread={ThreadId}, PreviousTurn={PreviousTurnId}, ReplacementTurn={ReplacementTurnId}", + session.SessionId, + threadId, + currentTurnId, + replacementTurnId); + + session.ActiveTurnId = replacementTurnId; + await SendInterruptRequestAsync(session, threadId, replacementTurnId, cancellationToken); + session.ActiveTurnId = null; + return true; + } + } + + private Task SendInterruptRequestAsync( + AppServerSession session, + string threadId, + string turnId, + CancellationToken cancellationToken) + { + return SendRequestAsync( + session, + "turn/interrupt", + new Dictionary + { + ["threadId"] = threadId, + ["turnId"] = turnId + }, + cancellationToken); + } + private async Task SendRequestAsync( AppServerSession session, string method, @@ -845,7 +921,7 @@ private void HandleNotification(AppServerSession session, string method, JsonEle var errorMessage = string.Equals(method, "error", StringComparison.Ordinal) ? ExtractErrorMessage(parameters) : null; - var suppressTransientError = ShouldSuppressTransientErrorNotification(method, errorMessage); + var suppressTransientError = ShouldSuppressTransientErrorNotification(method, parameters, errorMessage); if (!suppressTransientError && TryBuildCliOutputJsonl(method, parameters) is { Length: > 0 } jsonl) { @@ -922,7 +998,7 @@ private void WriteCliOutputJsonl(AppServerSession session, string jsonl) "turn/completed" => BuildTurnCompletedPayload(parameters), "turn/failed" => BuildTurnFailedPayload(parameters), "item/started" => BuildItemPayload("item.started", parameters, includeAgentMessageText: false), - "item/completed" => BuildItemPayload("item.completed", parameters, includeAgentMessageText: false), + "item/completed" => BuildItemPayload("item.completed", parameters, includeAgentMessageText: true), "item/agentMessage/delta" => BuildAgentMessageDeltaPayload(parameters), "error" => BuildErrorPayload(parameters), _ => null @@ -1014,14 +1090,14 @@ private void WriteCliOutputJsonl(AppServerSession session, string jsonl) ["text"] = delta }; - if (TryGetString(parameters, "threadId", out var threadId)) + if (TryGetString(parameters, "itemId", out var itemId)) { - item["thread_id"] = threadId; + item["id"] = itemId; } - if (TryGetString(parameters, "itemId", out var itemId)) + if (TryGetString(parameters, "phase", out var phase)) { - item["id"] = itemId; + item["phase"] = phase; } return new Dictionary @@ -1211,6 +1287,11 @@ private void WriteCliOutputJsonl(AppServerSession session, string jsonl) item["text"] = text; } + if (TryGetString(itemElement, "phase", out var phase)) + { + item["phase"] = phase; + } + return item; } @@ -1229,11 +1310,6 @@ private void WriteCliOutputJsonl(AppServerSession session, string jsonl) item["id"] = id; } - if (TryGetString(parameters, "threadId", out var threadId)) - { - item["thread_id"] = threadId; - } - return item; } @@ -1261,10 +1337,32 @@ private void WriteCliOutputJsonl(AppServerSession session, string jsonl) ?? GetString(parameters, "message"); } + private static bool ShouldSuppressTransientErrorNotification( + string method, + JsonElement parameters, + string? errorMessage) + { + if (!string.Equals(method, "error", StringComparison.Ordinal)) + { + return false; + } + + if (parameters.ValueKind == JsonValueKind.Object + && parameters.TryGetProperty("willRetry", out var willRetryElement) + && (willRetryElement.ValueKind == JsonValueKind.True || willRetryElement.ValueKind == JsonValueKind.False)) + { + if (willRetryElement.GetBoolean()) + { + return true; + } + } + + return IsTransientAppServerErrorMessage(errorMessage); + } + private static bool ShouldSuppressTransientErrorNotification(string method, string? errorMessage) { - return string.Equals(method, "error", StringComparison.Ordinal) - && IsTransientAppServerErrorMessage(errorMessage); + return ShouldSuppressTransientErrorNotification(method, default, errorMessage); } private static bool IsTransientAppServerErrorMessage(string? errorMessage) @@ -1280,6 +1378,43 @@ private static bool IsTransientAppServerErrorMessage(string? errorMessage) || string.Equals(normalized, "An unknown error occurred.", StringComparison.OrdinalIgnoreCase); } + private static string? TryResolveReplacementActiveTurnIdForInterruptMismatch( + string? currentTurnId, + string? errorMessage) + { + if (string.IsNullOrWhiteSpace(currentTurnId) || string.IsNullOrWhiteSpace(errorMessage)) + { + return null; + } + + var match = InterruptActiveTurnMismatchRegex.Match(errorMessage); + if (!match.Success) + { + return null; + } + + var expectedTurnId = match.Groups["expected"].Value; + var foundTurnId = match.Groups["found"].Value; + if (string.IsNullOrWhiteSpace(expectedTurnId) + || string.IsNullOrWhiteSpace(foundTurnId) + || string.Equals(expectedTurnId, foundTurnId, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (string.Equals(currentTurnId, expectedTurnId, StringComparison.OrdinalIgnoreCase)) + { + return foundTurnId; + } + + if (string.Equals(currentTurnId, foundTurnId, StringComparison.OrdinalIgnoreCase)) + { + return expectedTurnId; + } + + return null; + } + private static string? NormalizeAppServerItemType(string? itemType) { if (string.IsNullOrWhiteSpace(itemType)) @@ -1374,8 +1509,7 @@ private static bool TryGetString(JsonElement parameters, string propertyName, ou if (parameters.TryGetProperty(propertyName, out var element)) { - value = element.GetString(); - return !string.IsNullOrWhiteSpace(value); + return TryGetScalarString(element, out value); } value = null; @@ -1399,8 +1533,25 @@ private static bool TryGetString(JsonElement parameters, string parentPropertyNa return false; } - value = element.GetString(); - return !string.IsNullOrWhiteSpace(value); + return TryGetScalarString(element, out value); + } + + private static bool TryGetScalarString(JsonElement element, out string? value) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + value = element.GetString(); + return !string.IsNullOrWhiteSpace(value); + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + value = element.ToString(); + return !string.IsNullOrWhiteSpace(value); + default: + value = null; + return false; + } } private void FailPendingRequests(AppServerSession session, Exception exception) @@ -1456,6 +1607,7 @@ public AppServerSession(string sessionId, Process process) public string SessionId { get; } public Process Process { get; } + public CancellationTokenSource SessionCancellationTokenSource { get; } = new(); public ConcurrentDictionary> PendingResponses { get; } = new(StringComparer.Ordinal); public SemaphoreSlim WriteLock { get; } = new(1, 1); public Task? OutputReaderTask { get; set; } @@ -1465,9 +1617,21 @@ public AppServerSession(string sessionId, Process process) public ChannelWriter? ActiveOutputWriter { get; set; } public bool IsRunning => !Process.HasExited; + public bool HasLiveTransport => + OutputReaderTask is { IsCompleted: false } + && ErrorReaderTask is { IsCompleted: false }; + public bool IsHealthy => IsRunning && HasLiveTransport; public void Dispose() { + try + { + SessionCancellationTokenSource.Cancel(); + } + catch + { + } + try { ActiveOutputWriter?.TryComplete(); @@ -1489,6 +1653,7 @@ public void Dispose() } finally { + SessionCancellationTokenSource.Dispose(); Process.Dispose(); WriteLock.Dispose(); } diff --git a/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs index 69a8ae0..8fd3966 100644 --- a/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs +++ b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs @@ -27,6 +27,11 @@ Task> GetRecentMessagesAsync( int maxCount = 20, string? workspacePath = null, CancellationToken cancellationToken = default); + + Task GetCodexFinalAnswerTextAsync( + string cliThreadId, + string? workspacePath = null, + CancellationToken cancellationToken = default); } [ServiceDescription(typeof(IExternalCliSessionHistoryService), ServiceLifetime.Scoped)] @@ -133,6 +138,37 @@ public async Task> GetRecentMessagesAsync( } } + public async Task GetCodexFinalAnswerTextAsync( + string cliThreadId, + string? workspacePath = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(cliThreadId)) + { + return null; + } + + try + { + var filePath = FindCodexRolloutFile(cliThreadId.Trim(), workspacePath, cancellationToken); + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return null; + } + + return await ExtractLatestCodexFinalAnswerTextAsync(filePath, cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug( + ex, + "读取 Codex final_answer rollout 失败: CliThreadId={CliThreadId}, WorkspacePath={WorkspacePath}", + cliThreadId, + workspacePath); + return null; + } + } + protected virtual string? GetCodexConfigRootPath() { var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); @@ -274,12 +310,39 @@ private async Task> GetOpenCodeMessagesAsync( .OrderByDescending(File.GetLastWriteTimeUtc) .ToList(); - if (directCandidates.Count > 0) + foreach (var directCandidate in directCandidates) { - var rolloutPath = directCandidates[0]; - RememberCodexRolloutPath(cliThreadId, workspacePath, rolloutPath); - LogCodexRolloutResolved(cliThreadId, workspacePath, sessionsRoot, rolloutPath, "filename", directCandidates.Count); - return rolloutPath; + cancellationToken.ThrowIfCancellationRequested(); + + var firstLine = ReadFirstNonEmptyLine(directCandidate, maxLines: 3); + if (string.IsNullOrWhiteSpace(firstLine)) + { + continue; + } + + try + { + using var document = JsonDocument.Parse(firstLine); + var root = document.RootElement; + if (!TryGetProperty(root, "payload", out var payload) || payload.ValueKind != JsonValueKind.Object) + { + continue; + } + + var sessionId = GetString(payload, "id"); + if (!string.Equals(sessionId, cliThreadId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + RememberCodexRolloutPath(cliThreadId, workspacePath, directCandidate); + LogCodexRolloutResolved(cliThreadId, workspacePath, sessionsRoot, directCandidate, "filename", directCandidates.Count); + return directCandidate; + } + catch + { + // ignore broken lines + } } foreach (var file in Directory.EnumerateFiles(sessionsRoot.Path, "rollout-*.jsonl", SearchOption.AllDirectories)) @@ -563,6 +626,65 @@ private async Task> ParseCodexRolloutFileAsync( return messages.TakeLast(maxCount).ToList(); } + private async Task ExtractLatestCodexFinalAnswerTextAsync( + string filePath, + CancellationToken cancellationToken) + { + string? latestFinalAnswer = null; + + await foreach (var line in ReadLinesAsync(filePath, cancellationToken)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + if (!string.Equals(GetString(root, "type"), "response_item", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!TryGetProperty(root, "payload", out var payload) || payload.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!string.Equals(GetString(payload, "type"), "message", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.Equals(GetString(payload, "role"), "assistant", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.Equals(GetString(payload, "phase"), "final_answer", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var content = ExtractCodexMessageContent(payload); + if (!string.IsNullOrWhiteSpace(content)) + { + latestFinalAnswer = content; + } + } + catch (JsonException) + { + // ignore bad lines + } + } + + return string.IsNullOrWhiteSpace(latestFinalAnswer) + ? null + : latestFinalAnswer.Trim(); + } + private async Task> ParseClaudeTranscriptFileAsync( string filePath, int maxCount, diff --git a/WebCodeCli.Domain/Domain/Service/FeishuDocumentAdminGrantService.cs b/WebCodeCli.Domain/Domain/Service/FeishuDocumentAdminGrantService.cs new file mode 100644 index 0000000..d3ce984 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/FeishuDocumentAdminGrantService.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.DependencyInjection; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Service.Channels; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Domain.Service; + +public interface IFeishuDocumentAdminGrantService +{ + Task GrantConfiguredAdminAsync(string username, string documentId); + Task GrantConfiguredAdminBatchAsync(string username, IEnumerable documentIds); +} + +[ServiceDescription(typeof(IFeishuDocumentAdminGrantService), ServiceLifetime.Scoped)] +public sealed class FeishuDocumentAdminGrantService : IFeishuDocumentAdminGrantService +{ + private readonly IUserFeishuBotConfigService _userFeishuBotConfigService; + private readonly IFeishuCardKitClient _feishuCardKitClient; + + public FeishuDocumentAdminGrantService( + IUserFeishuBotConfigService userFeishuBotConfigService, + IFeishuCardKitClient feishuCardKitClient) + { + _userFeishuBotConfigService = userFeishuBotConfigService; + _feishuCardKitClient = feishuCardKitClient; + } + + public async Task GrantConfiguredAdminAsync(string username, string documentId) + { + var normalizedUsername = NormalizeRequiredValue(username, nameof(username), "用户名不能为空。"); + var normalizedDocumentId = NormalizeRequiredValue(documentId, nameof(documentId), "文档 ID 不能为空。"); + + var config = await _userFeishuBotConfigService.GetByUsernameAsync(normalizedUsername); + if (config == null) + { + return FeishuDocumentAdminGrantResult.NotFound("未找到对应用户的飞书机器人配置。"); + } + + if (string.IsNullOrWhiteSpace(config.DocumentAdminOpenId)) + { + return FeishuDocumentAdminGrantResult.Invalid("当前用户尚未保存文档管理员 OpenID。"); + } + + var effectiveOptions = await _userFeishuBotConfigService.GetEffectiveOptionsAsync(normalizedUsername) + ?? new FeishuOptions(); + + await _feishuCardKitClient.GrantCloudDocumentMemberFullAccessAsync( + normalizedDocumentId, + config.DocumentAdminOpenId.Trim(), + optionsOverride: effectiveOptions); + + return FeishuDocumentAdminGrantResult.Granted( + normalizedUsername, + normalizedDocumentId, + config.DocumentAdminOpenId.Trim()); + } + + public async Task GrantConfiguredAdminBatchAsync(string username, IEnumerable documentIds) + { + var normalizedUsername = NormalizeRequiredValue(username, nameof(username), "用户名不能为空。"); + ArgumentNullException.ThrowIfNull(documentIds); + + var results = new List(); + foreach (var documentId in documentIds) + { + if (string.IsNullOrWhiteSpace(documentId)) + { + continue; + } + + try + { + results.Add(await GrantConfiguredAdminAsync(normalizedUsername, documentId)); + } + catch (Exception ex) + { + results.Add(FeishuDocumentAdminGrantResult.Failure( + normalizedUsername, + documentId.Trim(), + ex.Message)); + } + } + + return new FeishuDocumentAdminGrantBatchResult(results); + } + + private static string NormalizeRequiredValue(string? value, string paramName, string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException(message, paramName); + } + + return value.Trim(); + } +} + +public sealed record FeishuDocumentAdminGrantResult( + bool Success, + string Username, + string DocumentId, + string? OpenId, + string? ErrorMessage, + int? StatusCode = null) +{ + public static FeishuDocumentAdminGrantResult Granted(string username, string documentId, string openId) + => new(true, username, documentId, openId, null); + + public static FeishuDocumentAdminGrantResult Invalid(string errorMessage) + => new(false, string.Empty, string.Empty, null, errorMessage, 400); + + public static FeishuDocumentAdminGrantResult NotFound(string errorMessage) + => new(false, string.Empty, string.Empty, null, errorMessage, 404); + + public static FeishuDocumentAdminGrantResult Failure(string username, string documentId, string errorMessage) + => new(false, username, documentId, null, errorMessage, 500); +} + +public sealed record FeishuDocumentAdminGrantBatchResult(IReadOnlyList Results) +{ + public int SuccessCount => Results.Count(static x => x.Success); + public int FailureCount => Results.Count(static x => !x.Success); +} diff --git a/WebCodeCli.Domain/Domain/Service/FeishuRuntimeRecoveryBackgroundService.cs b/WebCodeCli.Domain/Domain/Service/FeishuRuntimeRecoveryBackgroundService.cs new file mode 100644 index 0000000..931d611 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/FeishuRuntimeRecoveryBackgroundService.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace WebCodeCli.Domain.Domain.Service; + +public sealed class FeishuRuntimeRecoveryBackgroundService : BackgroundService +{ + private static readonly TimeSpan ObservationInterval = TimeSpan.FromSeconds(30); + private static readonly TimeSpan SuspendGapThreshold = TimeSpan.FromSeconds(90); + + private readonly IUserFeishuBotRuntimeService _runtimeService; + private readonly ILogger _logger; + + public FeishuRuntimeRecoveryBackgroundService( + IUserFeishuBotRuntimeService runtimeService, + ILogger logger) + { + _runtimeService = runtimeService; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var lastObservationUtc = DateTimeOffset.UtcNow; + using var timer = new PeriodicTimer(ObservationInterval); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + var now = DateTimeOffset.UtcNow; + var gap = now - lastObservationUtc; + lastObservationUtc = now; + + if (gap < SuspendGapThreshold) + { + continue; + } + + _logger.LogInformation( + "检测到宿主长时间挂起,准备重建飞书机器人运行态: Gap={Gap}", + gap); + + try + { + await _runtimeService.RecoverAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "重建飞书机器人运行态失败: Gap={Gap}", gap); + } + } + } +} diff --git a/WebCodeCli.Domain/Domain/Service/GoalPromptBuilder.cs b/WebCodeCli.Domain/Domain/Service/GoalPromptBuilder.cs index 0f24fdc..d5e818f 100644 --- a/WebCodeCli.Domain/Domain/Service/GoalPromptBuilder.cs +++ b/WebCodeCli.Domain/Domain/Service/GoalPromptBuilder.cs @@ -1,3 +1,4 @@ +using WebCodeCli.Domain.Domain.Service.Channels; using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Domain.Model.Channels; @@ -13,10 +14,23 @@ public static class GoalPromptBuilder FeishuHelpCardAction.PauseGoalAction => GoalQuickActionDefaults.PausePrompt, FeishuHelpCardAction.ClearGoalAction => GoalQuickActionDefaults.ClearPrompt, FeishuHelpCardAction.ResumeGoalAction => GoalQuickActionDefaults.ResumePrompt, + FeishuHelpCardAction.ExecuteSuperpowersGoalPlanAction => BuildSubagentPlanGoalPrompt(), _ => BuildGoalPrompt(input) }; } + public static string BuildSubagentPlanGoalPrompt(string? latestAssistantReply = null, string? workspaceRoot = null) + { + var referencedPlanDocument = TryResolvePlanDocumentReference(latestAssistantReply, workspaceRoot); + if (!string.IsNullOrWhiteSpace(referencedPlanDocument)) + { + return BuildGoalPrompt( + $"使用Subagent-Driven完成plan文档 {referencedPlanDocument},如有询问我的,先按你推荐的继续进行,需将该plan文档内的[ ]check list都检查收口后,变成[x]后才算goal完成")!; + } + + return BuildGoalPrompt(SuperpowersQuickActionDefaults.ExecuteGoalPlanPromptInput)!; + } + public static string? BuildGoalPrompt(string? input) { var trimmed = input?.Trim(); @@ -29,4 +43,50 @@ public static class GoalPromptBuilder ? trimmed : $"{GoalQuickActionDefaults.QuickGoalPrefix}{trimmed}"; } + + private static string? TryResolvePlanDocumentReference(string? latestAssistantReply, string? workspaceRoot) + { + var candidates = MarkdownReferenceExtractor.Extract(latestAssistantReply, workspaceRoot); + return candidates + .Where(IsPlanMarkdownCandidate) + .OrderBy(GetPlanDocumentPriority) + .ThenBy(static candidate => candidate.RelativePath.Length) + .Select(static candidate => candidate.RelativePath) + .FirstOrDefault(); + } + + private static bool IsPlanMarkdownCandidate(ReferencedMarkdownDocumentCandidate candidate) + { + var relativePath = candidate.RelativePath.Replace('\\', '/'); + var fileName = Path.GetFileName(relativePath); + + return relativePath.Contains("/plans/", StringComparison.OrdinalIgnoreCase) + || relativePath.StartsWith("plans/", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("plan", StringComparison.OrdinalIgnoreCase) + || relativePath.Contains("plan", StringComparison.OrdinalIgnoreCase); + } + + private static int GetPlanDocumentPriority(ReferencedMarkdownDocumentCandidate candidate) + { + var relativePath = candidate.RelativePath.Replace('\\', '/'); + var fileName = Path.GetFileName(relativePath); + + if (fileName.Equals("approved-plan.md", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + if (relativePath.Contains("/plans/", StringComparison.OrdinalIgnoreCase) + || relativePath.StartsWith("plans/", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + + if (fileName.Contains("plan", StringComparison.OrdinalIgnoreCase)) + { + return 2; + } + + return 3; + } } diff --git a/WebCodeCli.Domain/Domain/Service/ICliExecutorService.cs b/WebCodeCli.Domain/Domain/Service/ICliExecutorService.cs index 6df158d..2cf8483 100644 --- a/WebCodeCli.Domain/Domain/Service/ICliExecutorService.cs +++ b/WebCodeCli.Domain/Domain/Service/ICliExecutorService.cs @@ -166,6 +166,12 @@ Task SyncCodexThreadProviderAsync( string? toolId = null, CancellationToken cancellationToken = default); + Task TryGetGoalRuntimeGoalAsync( + string sessionId, + string? toolId = null, + CancellationToken cancellationToken = default) + => Task.FromResult(null); + /// /// 保存指定工具的环境变量配置到数据库 /// diff --git a/WebCodeCli.Domain/Domain/Service/ICodexAppServerSessionManager.cs b/WebCodeCli.Domain/Domain/Service/ICodexAppServerSessionManager.cs index 467a03d..5666ffa 100644 --- a/WebCodeCli.Domain/Domain/Service/ICodexAppServerSessionManager.cs +++ b/WebCodeCli.Domain/Domain/Service/ICodexAppServerSessionManager.cs @@ -73,6 +73,12 @@ Task InterruptActiveTurnAsync( string sessionId, CancellationToken cancellationToken = default); + bool HasRunningSession(string sessionId, string? threadId = null) + => false; + + string? GetRunningThreadId(string sessionId) + => null; + bool HasActiveTurn(string sessionId); bool CleanupSession(string sessionId); diff --git a/WebCodeCli.Domain/Domain/Service/IUserFeishuBotRuntimeService.cs b/WebCodeCli.Domain/Domain/Service/IUserFeishuBotRuntimeService.cs index d79d123..ab8df8c 100644 --- a/WebCodeCli.Domain/Domain/Service/IUserFeishuBotRuntimeService.cs +++ b/WebCodeCli.Domain/Domain/Service/IUserFeishuBotRuntimeService.cs @@ -5,4 +5,5 @@ public interface IUserFeishuBotRuntimeService Task GetStatusAsync(string username, CancellationToken cancellationToken = default); Task StartAsync(string username, CancellationToken cancellationToken = default); Task StopAsync(string username, CancellationToken cancellationToken = default); + Task RecoverAsync(CancellationToken cancellationToken = default); } diff --git a/WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs b/WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs index 296fe6f..3664a46 100644 --- a/WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs +++ b/WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs @@ -15,6 +15,9 @@ public static string BuildExecutePlanPrompt() public static string BuildSubagentExecutePlanPrompt() => SuperpowersQuickActionDefaults.ExecuteSubagentPlanPrompt; + public static string BuildCompleteWorktreePrompt() + => SuperpowersQuickActionDefaults.CompleteWorktreePrompt; + public static string? BuildQuickSkillPrompt(string? input) { var trimmed = input?.Trim(); diff --git a/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs b/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs index 2464b3a..69e7e80 100644 --- a/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs +++ b/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using WebCodeCli.Domain.Common.Extensions; using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; namespace WebCodeCli.Domain.Domain.Service; @@ -27,7 +28,13 @@ public UserFeishuBotConfigService( return null; } - return await _repository.GetByUsernameAsync(username.Trim()); + var config = await _repository.GetByUsernameAsync(username.Trim()); + if (config != null) + { + NormalizeConfig(config); + } + + return config; } public async Task GetByAppIdAsync(string appId) @@ -39,6 +46,11 @@ public UserFeishuBotConfigService( } var configs = await _repository.GetListAsync(x => x.AppId != null); + foreach (var config in configs) + { + NormalizeConfig(config); + } + return configs.FirstOrDefault(x => string.Equals( NormalizeValue(x.AppId), normalizedAppId, @@ -82,8 +94,15 @@ public async Task SaveAsync(UserFeishuBotConfigEn existing.ThinkingMessage = config.ThinkingMessage; existing.HttpTimeoutSeconds = config.HttpTimeoutSeconds; existing.StreamingThrottleMs = config.StreamingThrottleMs; - existing.ReplyTtsEnabled = config.ReplyTtsEnabled; - existing.ReplyTtsVoiceId = config.ReplyTtsVoiceId; + existing.FullReplyDocEnabled = config.FullReplyDocEnabled; + existing.FinalReplyDocEnabled = config.FinalReplyDocEnabled; + existing.AudioFullReplyDocEnabled = config.AudioFullReplyDocEnabled; + existing.AudioFinalReplyDocEnabled = config.AudioFinalReplyDocEnabled; + existing.ReferencedMarkdownDocImportEnabled = config.ReferencedMarkdownDocImportEnabled; + existing.DocumentAdminOpenId = config.DocumentAdminOpenId; + existing.LegacyReplyTtsEnabled = config.LegacyReplyTtsEnabled; + existing.LegacyReplyTtsMode = config.LegacyReplyTtsMode; + existing.LegacyReplyTtsVoiceId = config.LegacyReplyTtsVoiceId; existing.UpdatedAt = now; return await _repository.UpdateAsync(existing) @@ -117,6 +136,11 @@ public async Task DeleteAsync(string username) public async Task> GetAutoStartCandidatesAsync() { var configs = await _repository.GetListAsync(x => x.AutoStartEnabled && x.IsEnabled && x.AppId != null && x.AppSecret != null); + foreach (var config in configs) + { + NormalizeConfig(config); + } + return configs .Where(UserFeishuBotOptionsFactory.HasUsableCredentials) .ToList(); @@ -183,7 +207,21 @@ private static void NormalizeConfig(UserFeishuBotConfigEntity config) config.VerificationToken = NormalizeValue(config.VerificationToken); config.DefaultCardTitle = NormalizeValue(config.DefaultCardTitle); config.ThinkingMessage = NormalizeValue(config.ThinkingMessage); - config.ReplyTtsVoiceId = NormalizeValue(config.ReplyTtsVoiceId); + config.LegacyReplyTtsMode = NormalizeValue(config.LegacyReplyTtsMode); + config.LegacyReplyTtsVoiceId = NormalizeValue(config.LegacyReplyTtsVoiceId); + config.DocumentAdminOpenId = NormalizeValue(config.DocumentAdminOpenId); + var legacyReplyTtsMode = ReplyTtsModes.Resolve(config.LegacyReplyTtsMode, config.LegacyReplyTtsEnabled == true); + config.FullReplyDocEnabled = config.FullReplyDocEnabled + || string.Equals(legacyReplyTtsMode, ReplyTtsModes.FullReply, StringComparison.Ordinal); + config.FinalReplyDocEnabled = config.FinalReplyDocEnabled + || string.Equals(legacyReplyTtsMode, ReplyTtsModes.FinalOnly, StringComparison.Ordinal); + config.LegacyReplyTtsEnabled = config.FullReplyDocEnabled || config.FinalReplyDocEnabled; + config.LegacyReplyTtsMode = config.FullReplyDocEnabled + ? ReplyTtsModes.FullReply + : config.FinalReplyDocEnabled + ? ReplyTtsModes.FinalOnly + : ReplyTtsModes.Off; + config.LegacyReplyTtsVoiceId = null; } private static string? NormalizeValue(string? value) diff --git a/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeService.cs b/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeService.cs index df11f74..a212c26 100644 --- a/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeService.cs +++ b/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeService.cs @@ -240,6 +240,16 @@ public async Task StopAsync(string username, Cancell } } + public async Task RecoverAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("开始重建飞书机器人运行态"); + await ((IHostedService)this).StopAsync(cancellationToken); + await ((IHostedService)this).StartAsync(cancellationToken); + _logger.LogInformation("飞书机器人运行态重建完成"); + } + async Task IHostedService.StartAsync(CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); diff --git a/WebCodeCli.Domain/Properties/AssemblyInfo.cs b/WebCodeCli.Domain/Properties/AssemblyInfo.cs index 460d6a2..edfd781 100644 --- a/WebCodeCli.Domain/Properties/AssemblyInfo.cs +++ b/WebCodeCli.Domain/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("WebCodeCli.Tests")] +[assembly: InternalsVisibleTo("WebCodeCli.Domain.Tests")] diff --git a/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs b/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs index 420a147..7c86ebf 100644 --- a/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs +++ b/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs @@ -43,10 +43,52 @@ public class UserFeishuBotConfigEntity public int? StreamingThrottleMs { get; set; } [SugarColumn(IsNullable = false)] - public bool ReplyTtsEnabled { get; set; } + public bool FullReplyDocEnabled { get; set; } - [SugarColumn(Length = 128, IsNullable = true)] - public string? ReplyTtsVoiceId { get; set; } + [SugarColumn(IsNullable = false)] + public bool FinalReplyDocEnabled { get; set; } + + [SugarColumn(IsNullable = false)] + public bool AudioFullReplyDocEnabled { get; set; } + + [SugarColumn(IsNullable = false)] + public bool AudioFinalReplyDocEnabled { get; set; } + + [SugarColumn(IsNullable = false)] + public bool ReferencedMarkdownDocImportEnabled { get; set; } + + [SugarColumn(Length = 256, IsNullable = true)] + public string? DocumentAdminOpenId { get; set; } + + [SugarColumn(ColumnName = "ReplyTtsEnabled", IsNullable = true)] + public bool? LegacyReplyTtsEnabled { get; set; } + + [SugarColumn(ColumnName = "ReplyTtsMode", Length = 64, IsNullable = true)] + public string? LegacyReplyTtsMode { get; set; } + + [SugarColumn(ColumnName = "ReplyTtsVoiceId", Length = 128, IsNullable = true)] + public string? LegacyReplyTtsVoiceId { get; set; } + + [SugarColumn(IsIgnore = true)] + public bool ReplyTtsEnabled + { + get => LegacyReplyTtsEnabled ?? FullReplyDocEnabled || FinalReplyDocEnabled; + set => LegacyReplyTtsEnabled = value; + } + + [SugarColumn(IsIgnore = true)] + public string? ReplyTtsMode + { + get => LegacyReplyTtsMode; + set => LegacyReplyTtsMode = value; + } + + [SugarColumn(IsIgnore = true)] + public string? ReplyTtsVoiceId + { + get => LegacyReplyTtsVoiceId; + set => LegacyReplyTtsVoiceId = value; + } [SugarColumn(IsNullable = true)] public DateTime? LastStartedAt { get; set; } diff --git a/WebCodeCli/Components/AdminUserManagementModal.razor b/WebCodeCli/Components/AdminUserManagementModal.razor index dec73d8..b36224e 100644 --- a/WebCodeCli/Components/AdminUserManagementModal.razor +++ b/WebCodeCli/Components/AdminUserManagementModal.razor @@ -1,6 +1,7 @@ @namespace WebCodeCli.Components @using Microsoft.AspNetCore.Components.Forms @using WebCodeCli.Domain.Domain.Model +@using WebCodeCli.Domain.Domain.Model.Channels @if (_isVisible) { @@ -210,53 +211,54 @@
-
-
-
@Tx("adminUserManagement.replyTtsTitle", "回复语音播报", "Reply TTS")
-

@Tx("adminUserManagement.replyTtsHint", "开启后会为飞书回复生成语音;语音列表来自当前平台运行时。", "When enabled, Feishu replies can generate speech using the current runtime voice catalog.")

-
- +
+
@Tx("adminUserManagement.replyDocsTitle", "回复文档", "Reply Documents")
+

@Tx("adminUserManagement.replyDocsHint", "保存回复后可自动把内容写入飞书云文档,并将链接单独发送到聊天窗口。", "After a reply is saved, WebCode can write it to Feishu docs and send the links as separate chat messages.")

- -
-
diff --git a/WebCodeCli/Components/AdminUserManagementModal.razor.cs b/WebCodeCli/Components/AdminUserManagementModal.razor.cs index 4329ed4..a82403f 100644 --- a/WebCodeCli/Components/AdminUserManagementModal.razor.cs +++ b/WebCodeCli/Components/AdminUserManagementModal.razor.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Components; using WebCodeCli.Domain.Domain.Model; -using WebCodeCli.Domain.Domain.Model.Channels; using WebCodeCli.Domain.Domain.Service; using WebCodeCli.Helpers; @@ -23,7 +22,6 @@ public partial class AdminUserManagementModal : ComponentBase private bool _isSaving; private bool _isDeletingFeishuConfig; private bool _isRefreshingFeishuStatus; - private bool _isRefreshingReplyTtsPlatform; private bool _isStartingFeishuBot; private bool _isStoppingFeishuBot; private string _errorMessage = string.Empty; @@ -36,16 +34,8 @@ public partial class AdminUserManagementModal : ComponentBase private List _users = new(); private EditableUserModel _editor = EditableUserModel.CreateNew(); private UserFeishuBotRuntimeStatusModel _feishuBotStatus = new(); - private FeishuReplyTtsHealthStatus _replyTtsHealth = new(); - private IReadOnlyList _replyTtsVoices = []; - private bool IsBusy => _isLoadingUsers || _isLoadingDetail || _isSaving || _isDeletingFeishuConfig || _isRefreshingFeishuStatus || _isRefreshingReplyTtsPlatform || _isStartingFeishuBot || _isStoppingFeishuBot; - private AdminUserManagementReplyTtsUiStateResult ReplyTtsUiState => AdminUserManagementReplyTtsUiState.Create( - _editor.FeishuBot.ReplyTtsEnabled, - _editor.FeishuBot.ReplyTtsVoiceId, - _replyTtsVoices, - _replyTtsHealth.IsAvailable, - _replyTtsHealth.Message); + private bool IsBusy => _isLoadingUsers || _isLoadingDetail || _isSaving || _isDeletingFeishuConfig || _isRefreshingFeishuStatus || _isStartingFeishuBot || _isStoppingFeishuBot; private IEnumerable FilteredUsers => _users.Where(user => string.IsNullOrWhiteSpace(_userSearch) || @@ -62,7 +52,6 @@ public async Task ShowAsync() _successMessage = string.Empty; _userSearch = string.Empty; _allTools = CliExecutorService.GetAvailableTools(); - await RefreshReplyTtsPlatformAsync(); var usersLoaded = await LoadUsersAsync(); if (!usersLoaded) { @@ -97,7 +86,6 @@ private async Task CloseAsync() private async Task RefreshAsync() { _allTools = CliExecutorService.GetAvailableTools(); - await RefreshReplyTtsPlatformAsync(); var usersLoaded = await LoadUsersAsync(); if (!usersLoaded) { @@ -413,56 +401,6 @@ private async Task RefreshFeishuBotStatusAsync() } } - private async Task RefreshReplyTtsPlatformAsync() - { - _isRefreshingReplyTtsPlatform = true; - StateHasChanged(); - - FeishuReplyTtsHealthStatus? refreshedHealth = null; - List? refreshedVoices = null; - string? healthError = null; - string? voicesError = null; - - try - { - try - { - refreshedHealth = await Http.GetFromJsonAsync("/api/admin/feishu-tts/health") - ?? new FeishuReplyTtsHealthStatus(); - } - catch (Exception ex) - { - healthError = ex.Message; - } - - try - { - refreshedVoices = await Http.GetFromJsonAsync>("/api/admin/feishu-tts/voices") - ?? []; - } - catch (Exception ex) - { - voicesError = ex.Message; - } - - var mergedState = MergeReplyTtsPlatformState( - _replyTtsHealth, - _replyTtsVoices, - refreshedHealth, - refreshedVoices, - healthError, - voicesError); - - _replyTtsHealth = mergedState.Health; - _replyTtsVoices = mergedState.Voices; - } - finally - { - _isRefreshingReplyTtsPlatform = false; - StateHasChanged(); - } - } - private async Task StartFeishuBotAsync() { if (!_editor.IsExistingUser || string.IsNullOrWhiteSpace(_editor.Username)) @@ -659,50 +597,6 @@ private static UserFeishuBotRuntimeStatusModel CreateFeishuStatusSeed(string use }; } - private static (FeishuReplyTtsHealthStatus Health, IReadOnlyList Voices) MergeReplyTtsPlatformState( - FeishuReplyTtsHealthStatus currentHealth, - IReadOnlyList currentVoices, - FeishuReplyTtsHealthStatus? refreshedHealth, - IReadOnlyList? refreshedVoices, - string? healthError, - string? voicesError) - { - var nextHealth = refreshedHealth != null - ? CloneReplyTtsHealth(refreshedHealth) - : !string.IsNullOrWhiteSpace(healthError) - ? new FeishuReplyTtsHealthStatus - { - IsAvailable = false, - Message = healthError.Trim() - } - : CloneReplyTtsHealth(currentHealth); - - var nextVoices = refreshedVoices ?? currentVoices; - - if (!string.IsNullOrWhiteSpace(voicesError)) - { - var voiceRefreshMessage = $"Voice list may be stale because refresh failed: {voicesError.Trim()}"; - nextHealth = CloneReplyTtsHealth(nextHealth); - nextHealth.Message = string.IsNullOrWhiteSpace(nextHealth.Message) - ? voiceRefreshMessage - : $"{nextHealth.Message} {voiceRefreshMessage}"; - } - - return (nextHealth, nextVoices); - } - - private static FeishuReplyTtsHealthStatus CloneReplyTtsHealth(FeishuReplyTtsHealthStatus health) - { - return new FeishuReplyTtsHealthStatus - { - IsAvailable = health.IsAvailable, - Message = health.Message, - DefaultVoiceId = health.DefaultVoiceId, - StorageRoot = health.StorageRoot, - TempRoot = health.TempRoot - }; - } - private static bool HasCustomFeishuConfig(EditableFeishuBotConfigModel config) { return AdminUserManagementFormHelper.HasCustomFeishuConfig( @@ -713,19 +607,116 @@ private static bool HasCustomFeishuConfig(EditableFeishuBotConfigModel config) config.VerificationToken, config.DefaultCardTitle, config.ThinkingMessage, + config.DocumentAdminOpenId, config.HttpTimeoutSeconds, config.StreamingThrottleMs) - || config.ReplyTtsEnabled - || !string.IsNullOrWhiteSpace(config.ReplyTtsVoiceId); + || config.FullReplyDocEnabled + || config.FinalReplyDocEnabled + || config.AudioFullReplyDocEnabled + || config.AudioFinalReplyDocEnabled + || config.ReferencedMarkdownDocImportEnabled; } private sealed class UserSummaryDto { public string Username { get; set; } = string.Empty; public string? DisplayName { get; set; } public string Role { get; set; } = UserAccessConstants.UserRole; public string Status { get; set; } = UserAccessConstants.EnabledStatus; public DateTime? LastLoginAt { get; set; } public DateTime CreatedAt { get; set; } } private sealed class SaveUserPayload { public string Username { get; set; } = string.Empty; public string? DisplayName { get; set; } public string? Password { get; set; } public string Role { get; set; } = UserAccessConstants.UserRole; public string Status { get; set; } = UserAccessConstants.EnabledStatus; } private sealed class SaveToolPolicyPayload { public List AllowedToolIds { get; set; } = new(); } private sealed class SaveWorkspacePolicyPayload { public List AllowedDirectories { get; set; } = new(); } - private sealed class UserFeishuBotConfigModel { public string? Username { get; set; } public bool IsEnabled { get; set; } public string? AppId { get; set; } public string? AppSecret { get; set; } public string? EncryptKey { get; set; } public string? VerificationToken { get; set; } public string? DefaultCardTitle { get; set; } public string? ThinkingMessage { get; set; } public int? HttpTimeoutSeconds { get; set; } public int? StreamingThrottleMs { get; set; } public bool ReplyTtsEnabled { get; set; } public string? ReplyTtsVoiceId { get; set; } } + private sealed class UserFeishuBotConfigModel + { + public string? Username { get; set; } + public bool IsEnabled { get; set; } + public string? AppId { get; set; } + public string? AppSecret { get; set; } + public string? EncryptKey { get; set; } + public string? VerificationToken { get; set; } + public string? DefaultCardTitle { get; set; } + public string? ThinkingMessage { get; set; } + public string? DocumentAdminOpenId { get; set; } + public int? HttpTimeoutSeconds { get; set; } + public int? StreamingThrottleMs { get; set; } + public bool FullReplyDocEnabled { get; set; } + public bool FinalReplyDocEnabled { get; set; } + public bool AudioFullReplyDocEnabled { get; set; } + public bool AudioFinalReplyDocEnabled { get; set; } + public bool ReferencedMarkdownDocImportEnabled { get; set; } + } private sealed class UserFeishuBotRuntimeStatusModel { public string Username { get; set; } = string.Empty; public string? AppId { get; set; } public string State { get; set; } = nameof(UserFeishuBotRuntimeState.NotConfigured); public bool IsConfigured { get; set; } public bool CanStart { get; set; } public bool ShouldAutoStart { get; set; } public string? Message { get; set; } public string? LastError { get; set; } public DateTime? LastStartedAt { get; set; } public DateTime UpdatedAt { get; set; } public UserFeishuBotRuntimeStatusModel Clone() => new() { Username = Username, AppId = AppId, State = State, IsConfigured = IsConfigured, CanStart = CanStart, ShouldAutoStart = ShouldAutoStart, Message = Message, LastError = LastError, LastStartedAt = LastStartedAt, UpdatedAt = UpdatedAt }; } - private sealed class EditableFeishuBotConfigModel { public bool IsEnabled { get; set; } public string? AppId { get; set; } public string? AppSecret { get; set; } public string? EncryptKey { get; set; } public string? VerificationToken { get; set; } public string? DefaultCardTitle { get; set; } public string? ThinkingMessage { get; set; } public int? HttpTimeoutSeconds { get; set; } public int? StreamingThrottleMs { get; set; } public bool ReplyTtsEnabled { get; set; } public string? ReplyTtsVoiceId { get; set; } public EditableFeishuBotConfigModel Clone() => new() { IsEnabled = IsEnabled, AppId = AppId, AppSecret = AppSecret, EncryptKey = EncryptKey, VerificationToken = VerificationToken, DefaultCardTitle = DefaultCardTitle, ThinkingMessage = ThinkingMessage, HttpTimeoutSeconds = HttpTimeoutSeconds, StreamingThrottleMs = StreamingThrottleMs, ReplyTtsEnabled = ReplyTtsEnabled, ReplyTtsVoiceId = ReplyTtsVoiceId }; public static EditableFeishuBotConfigModel From(UserFeishuBotConfigModel model) => new() { IsEnabled = model.IsEnabled, AppId = model.AppId, AppSecret = model.AppSecret, EncryptKey = model.EncryptKey, VerificationToken = model.VerificationToken, DefaultCardTitle = model.DefaultCardTitle, ThinkingMessage = model.ThinkingMessage, HttpTimeoutSeconds = model.HttpTimeoutSeconds, StreamingThrottleMs = model.StreamingThrottleMs, ReplyTtsEnabled = model.ReplyTtsEnabled, ReplyTtsVoiceId = model.ReplyTtsVoiceId }; public UserFeishuBotConfigModel ToPayload(string username) => new() { Username = username, IsEnabled = IsEnabled, AppId = TrimToNull(AppId), AppSecret = TrimToNull(AppSecret), EncryptKey = TrimToNull(EncryptKey), VerificationToken = TrimToNull(VerificationToken), DefaultCardTitle = TrimToNull(DefaultCardTitle), ThinkingMessage = TrimToNull(ThinkingMessage), HttpTimeoutSeconds = HttpTimeoutSeconds, StreamingThrottleMs = StreamingThrottleMs, ReplyTtsEnabled = ReplyTtsEnabled, ReplyTtsVoiceId = TrimToNull(ReplyTtsVoiceId) }; } + private sealed class EditableFeishuBotConfigModel + { + public bool IsEnabled { get; set; } + public string? AppId { get; set; } + public string? AppSecret { get; set; } + public string? EncryptKey { get; set; } + public string? VerificationToken { get; set; } + public string? DefaultCardTitle { get; set; } + public string? ThinkingMessage { get; set; } + public string? DocumentAdminOpenId { get; set; } + public int? HttpTimeoutSeconds { get; set; } + public int? StreamingThrottleMs { get; set; } + public bool FullReplyDocEnabled { get; set; } + public bool FinalReplyDocEnabled { get; set; } + public bool AudioFullReplyDocEnabled { get; set; } + public bool AudioFinalReplyDocEnabled { get; set; } + public bool ReferencedMarkdownDocImportEnabled { get; set; } + + public EditableFeishuBotConfigModel Clone() => new() + { + IsEnabled = IsEnabled, + AppId = AppId, + AppSecret = AppSecret, + EncryptKey = EncryptKey, + VerificationToken = VerificationToken, + DefaultCardTitle = DefaultCardTitle, + ThinkingMessage = ThinkingMessage, + DocumentAdminOpenId = DocumentAdminOpenId, + HttpTimeoutSeconds = HttpTimeoutSeconds, + StreamingThrottleMs = StreamingThrottleMs, + FullReplyDocEnabled = FullReplyDocEnabled, + FinalReplyDocEnabled = FinalReplyDocEnabled, + AudioFullReplyDocEnabled = AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = AudioFinalReplyDocEnabled, + ReferencedMarkdownDocImportEnabled = ReferencedMarkdownDocImportEnabled + }; + + public static EditableFeishuBotConfigModel From(UserFeishuBotConfigModel model) => new() + { + IsEnabled = model.IsEnabled, + AppId = model.AppId, + AppSecret = model.AppSecret, + EncryptKey = model.EncryptKey, + VerificationToken = model.VerificationToken, + DefaultCardTitle = model.DefaultCardTitle, + ThinkingMessage = model.ThinkingMessage, + DocumentAdminOpenId = model.DocumentAdminOpenId, + HttpTimeoutSeconds = model.HttpTimeoutSeconds, + StreamingThrottleMs = model.StreamingThrottleMs, + FullReplyDocEnabled = model.FullReplyDocEnabled, + FinalReplyDocEnabled = model.FinalReplyDocEnabled, + AudioFullReplyDocEnabled = model.AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = model.AudioFinalReplyDocEnabled, + ReferencedMarkdownDocImportEnabled = model.ReferencedMarkdownDocImportEnabled + }; + + public UserFeishuBotConfigModel ToPayload(string username) => new() + { + Username = username, + IsEnabled = IsEnabled, + AppId = TrimToNull(AppId), + AppSecret = TrimToNull(AppSecret), + EncryptKey = TrimToNull(EncryptKey), + VerificationToken = TrimToNull(VerificationToken), + DefaultCardTitle = TrimToNull(DefaultCardTitle), + ThinkingMessage = TrimToNull(ThinkingMessage), + DocumentAdminOpenId = TrimToNull(DocumentAdminOpenId), + HttpTimeoutSeconds = HttpTimeoutSeconds, + StreamingThrottleMs = StreamingThrottleMs, + FullReplyDocEnabled = FullReplyDocEnabled, + FinalReplyDocEnabled = FinalReplyDocEnabled, + AudioFullReplyDocEnabled = AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = AudioFinalReplyDocEnabled, + ReferencedMarkdownDocImportEnabled = ReferencedMarkdownDocImportEnabled + }; + } private sealed class EditableUserModel { public string Username { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public string Role { get; set; } = UserAccessConstants.UserRole; public bool Enabled { get; set; } = true; public bool IsExistingUser { get; set; } public bool HasStoredFeishuConfig { get; set; } public DateTime? LastLoginAt { get; set; } public DateTime? CreatedAt { get; set; } public HashSet AllowedToolIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); public string AllowedDirectoriesText { get; set; } = string.Empty; public EditableFeishuBotConfigModel FeishuBot { get; set; } = new(); public static EditableUserModel CreateNew() => new() { AllowedToolIds = new HashSet(StringComparer.OrdinalIgnoreCase) }; public static EditableUserModel FromSummary(UserSummaryDto user) => new() { Username = user.Username, DisplayName = user.DisplayName ?? string.Empty, Role = user.Role, Enabled = IsEnabled(user.Status), IsExistingUser = true, LastLoginAt = user.LastLoginAt, CreatedAt = user.CreatedAt, AllowedToolIds = new HashSet(StringComparer.OrdinalIgnoreCase) }; } private static string? TrimToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/WebCodeCli/Components/ChatMessageListPanel.razor b/WebCodeCli/Components/ChatMessageListPanel.razor index 870d94e..267f9bf 100644 --- a/WebCodeCli/Components/ChatMessageListPanel.razor +++ b/WebCodeCli/Components/ChatMessageListPanel.razor @@ -123,6 +123,14 @@ @ExecuteSubagentPlanText +
} @ExecuteSubagentPlanText +
} OnContinueSuperpowersAction { get; set; } [Parameter] public EventCallback OnExecuteSuperpowersPlan { get; set; } [Parameter] public EventCallback OnExecuteSuperpowersSubagentPlan { get; set; } + [Parameter] public EventCallback OnExecuteSuperpowersCompleteWorktree { get; set; } [Parameter] public EventCallback OnStopSuperpowersAction { get; set; } [Parameter] public EventCallback OnRetrySuperpowersCapability { get; set; } [Parameter] public string? GoalQuickActionMessageId { get; set; } @@ -560,7 +577,8 @@ || (ShowSuperpowersPlanActions && (!OnContinueSuperpowersAction.HasDelegate || !OnExecuteSuperpowersPlan.HasDelegate - || !OnExecuteSuperpowersSubagentPlan.HasDelegate))) + || !OnExecuteSuperpowersSubagentPlan.HasDelegate + || !OnExecuteSuperpowersCompleteWorktree.HasDelegate))) { return false; } @@ -584,7 +602,8 @@ return !ShowSuperpowersPlanActions || (OnContinueSuperpowersAction.HasDelegate && OnExecuteSuperpowersPlan.HasDelegate - && OnExecuteSuperpowersSubagentPlan.HasDelegate); + && OnExecuteSuperpowersSubagentPlan.HasDelegate + && OnExecuteSuperpowersCompleteWorktree.HasDelegate); } private bool ShouldShowGoalQuickActions(ChatMessage message) diff --git a/WebCodeCli/Controllers/AdminController.cs b/WebCodeCli/Controllers/AdminController.cs index a502daf..0331ed4 100644 --- a/WebCodeCli/Controllers/AdminController.cs +++ b/WebCodeCli/Controllers/AdminController.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using WebCodeCli.Domain.Common.Options; using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Domain.Model.Channels; using WebCodeCli.Domain.Domain.Service; -using WebCodeCli.Domain.Domain.Service.Channels; using WebCodeCli.Domain.Repositories.Base.UserAccount; using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; @@ -20,7 +20,7 @@ public class AdminController : ControllerBase private readonly IUserFeishuBotConfigService _userFeishuBotConfigService; private readonly IUserFeishuBotRuntimeService _userFeishuBotRuntimeService; private readonly ICliExecutorService _cliExecutorService; - private readonly IFeishuReplyTtsPlatformService _feishuReplyTtsPlatformService; + private readonly IFeishuDocumentAdminGrantService _feishuDocumentAdminGrantService; public AdminController( IUserAccountService userAccountService, @@ -29,7 +29,7 @@ public AdminController( IUserFeishuBotConfigService userFeishuBotConfigService, IUserFeishuBotRuntimeService userFeishuBotRuntimeService, ICliExecutorService cliExecutorService, - IFeishuReplyTtsPlatformService feishuReplyTtsPlatformService) + IFeishuDocumentAdminGrantService feishuDocumentAdminGrantService) { _userAccountService = userAccountService; _userToolPolicyService = userToolPolicyService; @@ -37,7 +37,7 @@ public AdminController( _userFeishuBotConfigService = userFeishuBotConfigService; _userFeishuBotRuntimeService = userFeishuBotRuntimeService; _cliExecutorService = cliExecutorService; - _feishuReplyTtsPlatformService = feishuReplyTtsPlatformService; + _feishuDocumentAdminGrantService = feishuDocumentAdminGrantService; } [HttpGet("users")] @@ -142,7 +142,16 @@ public async Task> GetFeishuBotConfig(strin var config = await _userFeishuBotConfigService.GetByUsernameAsync(username); if (config == null) { - return Ok(new UserFeishuBotConfigDto { Username = username, IsEnabled = false }); + return Ok(new UserFeishuBotConfigDto + { + Username = username, + IsEnabled = false, + FullReplyDocEnabled = false, + FinalReplyDocEnabled = false, + AudioFullReplyDocEnabled = false, + AudioFinalReplyDocEnabled = false, + ReferencedMarkdownDocImportEnabled = false + }); } return Ok(MapFeishuConfig(config)); @@ -151,6 +160,12 @@ public async Task> GetFeishuBotConfig(strin [HttpPut("users/{username}/feishu-bot")] public async Task SaveFeishuBotConfig(string username, [FromBody] UserFeishuBotConfigDto request) { + var legacyReplyTtsMode = ReplyTtsModes.Resolve(request.ReplyTtsMode, request.ReplyTtsEnabled); + var fullReplyDocEnabled = request.FullReplyDocEnabled + || string.Equals(legacyReplyTtsMode, ReplyTtsModes.FullReply, StringComparison.Ordinal); + var finalReplyDocEnabled = request.FinalReplyDocEnabled + || string.Equals(legacyReplyTtsMode, ReplyTtsModes.FinalOnly, StringComparison.Ordinal); + var result = await _userFeishuBotConfigService.SaveAsync(new UserFeishuBotConfigEntity { Username = username.Trim(), @@ -163,8 +178,12 @@ public async Task SaveFeishuBotConfig(string username, [FromBody] ThinkingMessage = request.ThinkingMessage, HttpTimeoutSeconds = request.HttpTimeoutSeconds, StreamingThrottleMs = request.StreamingThrottleMs, - ReplyTtsEnabled = request.ReplyTtsEnabled, - ReplyTtsVoiceId = request.ReplyTtsVoiceId + FullReplyDocEnabled = fullReplyDocEnabled, + FinalReplyDocEnabled = finalReplyDocEnabled, + AudioFullReplyDocEnabled = request.AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = request.AudioFinalReplyDocEnabled, + ReferencedMarkdownDocImportEnabled = request.ReferencedMarkdownDocImportEnabled, + DocumentAdminOpenId = request.DocumentAdminOpenId }); if (!result.Success) @@ -177,15 +196,8 @@ public async Task SaveFeishuBotConfig(string username, [FromBody] return StatusCode(500, new { error = result.ErrorMessage ?? "保存飞书机器人配置失败。" }); } - FeishuReplyTtsHealthStatus? ttsHealth = null; - if (request.ReplyTtsEnabled) - { - ttsHealth = await _feishuReplyTtsPlatformService.EnsureServiceStartedAsync( - HttpContext?.RequestAborted ?? CancellationToken.None); - } - var status = await _userFeishuBotRuntimeService.StopAsync(username); - return Ok(new { success = true, status = MapFeishuRuntimeStatus(status), ttsHealth }); + return Ok(new { success = true, status = MapFeishuRuntimeStatus(status) }); } [HttpDelete("users/{username}/feishu-bot")] @@ -234,18 +246,55 @@ public async Task> StopFeishuBot(str return Ok(MapFeishuRuntimeStatus(status)); } - [HttpGet("feishu-tts/health")] - public async Task> GetFeishuTtsHealth() + [HttpPost("users/{username}/feishu-bot/grant-document-admin")] + public async Task GrantFeishuDocumentAdmin(string username, [FromBody] GrantFeishuDocumentAdminRequestDto request) { - var health = await _feishuReplyTtsPlatformService.GetHealthAsync(HttpContext?.RequestAborted ?? CancellationToken.None); - return Ok(health); + var result = await _feishuDocumentAdminGrantService.GrantConfiguredAdminAsync(username, request.DocumentId); + return ToGrantResponse(result); } - [HttpGet("feishu-tts/voices")] - public async Task>> GetFeishuTtsVoices() + [HttpPost("users/{username}/feishu-bot/grant-document-admin/batch")] + public async Task GrantFeishuDocumentAdminBatch(string username, [FromBody] GrantFeishuDocumentAdminBatchRequestDto request) { - var voices = await _feishuReplyTtsPlatformService.GetVoicesAsync(HttpContext?.RequestAborted ?? CancellationToken.None); - return Ok(voices.ToList()); + if (request.DocumentIds == null || request.DocumentIds.Count == 0) + { + return BadRequest(new { error = "文档 ID 列表不能为空。" }); + } + + var result = await _feishuDocumentAdminGrantService.GrantConfiguredAdminBatchAsync(username, request.DocumentIds); + return Ok(new + { + success = result.FailureCount == 0, + successCount = result.SuccessCount, + failureCount = result.FailureCount, + results = result.Results + }); + } + + private ActionResult ToGrantResponse(FeishuDocumentAdminGrantResult result) + { + if (result.Success) + { + return Ok(new + { + success = true, + username = result.Username, + documentId = result.DocumentId, + openId = result.OpenId + }); + } + + if (result.StatusCode == 404) + { + return NotFound(new { error = result.ErrorMessage }); + } + + if (result.StatusCode == 400) + { + return BadRequest(new { error = result.ErrorMessage }); + } + + return StatusCode(500, new { error = result.ErrorMessage ?? "授予文档管理员权限失败。" }); } private static UserAccountResponseDto MapUser(UserAccountEntity account) @@ -276,8 +325,12 @@ private static UserFeishuBotConfigDto MapFeishuConfig(UserFeishuBotConfigEntity ThinkingMessage = config.ThinkingMessage, HttpTimeoutSeconds = config.HttpTimeoutSeconds, StreamingThrottleMs = config.StreamingThrottleMs, - ReplyTtsEnabled = config.ReplyTtsEnabled, - ReplyTtsVoiceId = config.ReplyTtsVoiceId + FullReplyDocEnabled = config.FullReplyDocEnabled, + FinalReplyDocEnabled = config.FinalReplyDocEnabled, + AudioFullReplyDocEnabled = config.AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = config.AudioFinalReplyDocEnabled, + ReferencedMarkdownDocImportEnabled = config.ReferencedMarkdownDocImportEnabled, + DocumentAdminOpenId = config.DocumentAdminOpenId }; } @@ -346,8 +399,14 @@ public sealed class UserFeishuBotConfigDto public string? ThinkingMessage { get; set; } public int? HttpTimeoutSeconds { get; set; } public int? StreamingThrottleMs { get; set; } + public string? ReplyTtsMode { get; set; } public bool ReplyTtsEnabled { get; set; } - public string? ReplyTtsVoiceId { get; set; } + public bool FullReplyDocEnabled { get; set; } + public bool FinalReplyDocEnabled { get; set; } + public bool AudioFullReplyDocEnabled { get; set; } + public bool AudioFinalReplyDocEnabled { get; set; } + public bool ReferencedMarkdownDocImportEnabled { get; set; } + public string? DocumentAdminOpenId { get; set; } } public sealed class UserFeishuBotRuntimeStatusDto @@ -363,3 +422,13 @@ public sealed class UserFeishuBotRuntimeStatusDto public DateTime? LastStartedAt { get; set; } public DateTime UpdatedAt { get; set; } } + +public sealed class GrantFeishuDocumentAdminRequestDto +{ + public string DocumentId { get; set; } = string.Empty; +} + +public sealed class GrantFeishuDocumentAdminBatchRequestDto +{ + public List DocumentIds { get; set; } = new(); +} diff --git a/WebCodeCli/Helpers/AdminUserManagementFormHelper.cs b/WebCodeCli/Helpers/AdminUserManagementFormHelper.cs index 5c72119..50405b2 100644 --- a/WebCodeCli/Helpers/AdminUserManagementFormHelper.cs +++ b/WebCodeCli/Helpers/AdminUserManagementFormHelper.cs @@ -62,6 +62,7 @@ public static bool HasCustomFeishuConfig( string? verificationToken, string? defaultCardTitle, string? thinkingMessage, + string? documentAdminOpenId, int? httpTimeoutSeconds, int? streamingThrottleMs) { @@ -72,6 +73,7 @@ public static bool HasCustomFeishuConfig( !string.IsNullOrWhiteSpace(verificationToken) || !string.IsNullOrWhiteSpace(defaultCardTitle) || !string.IsNullOrWhiteSpace(thinkingMessage) || + !string.IsNullOrWhiteSpace(documentAdminOpenId) || httpTimeoutSeconds.HasValue || streamingThrottleMs.HasValue; } diff --git a/WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs b/WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs deleted file mode 100644 index fb862e7..0000000 --- a/WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs +++ /dev/null @@ -1,134 +0,0 @@ -using WebCodeCli.Domain.Domain.Model.Channels; - -namespace WebCodeCli.Helpers; - -public static class AdminUserManagementReplyTtsUiState -{ - public static AdminUserManagementReplyTtsUiStateResult Create( - bool replyTtsEnabled, - string? savedVoiceId, - IReadOnlyList? availableVoices, - bool platformIsAvailable, - string? platformMessage) - { - var normalizedSavedVoiceId = Normalize(savedVoiceId); - var voiceOptions = BuildVoiceOptions(availableVoices, normalizedSavedVoiceId, out var savedVoiceExistsInRuntimeList); - - return new AdminUserManagementReplyTtsUiStateResult - { - IsVoiceSelectorDisabled = !replyTtsEnabled || !platformIsAvailable, - WarningMessage = BuildWarningMessage( - replyTtsEnabled, - normalizedSavedVoiceId, - platformIsAvailable, - platformMessage, - voiceOptions.Count, - savedVoiceExistsInRuntimeList), - VoiceOptions = voiceOptions - }; - } - - private static string? BuildWarningMessage( - bool replyTtsEnabled, - string? normalizedSavedVoiceId, - bool platformIsAvailable, - string? platformMessage, - int voiceCount, - bool savedVoiceExistsInRuntimeList) - { - if (!platformIsAvailable) - { - return string.IsNullOrWhiteSpace(platformMessage) - ? "Feishu reply TTS is currently unavailable." - : platformMessage.Trim(); - } - - if (!replyTtsEnabled) - { - return null; - } - - if (!string.IsNullOrWhiteSpace(normalizedSavedVoiceId) && !savedVoiceExistsInRuntimeList) - { - return $"Saved Feishu reply TTS voice '{normalizedSavedVoiceId}' is unavailable. Select a different voice before saving."; - } - - if (voiceCount == 0) - { - return "No Feishu reply TTS voices are available right now. Refresh to try again."; - } - - return null; - } - - private static List BuildVoiceOptions( - IReadOnlyList? availableVoices, - string? normalizedSavedVoiceId, - out bool savedVoiceExistsInRuntimeList) - { - var voiceOptions = new List(); - var seenVoiceIds = new HashSet(StringComparer.OrdinalIgnoreCase); - savedVoiceExistsInRuntimeList = false; - - if (availableVoices != null) - { - foreach (var voice in availableVoices) - { - var normalizedVoiceId = Normalize(voice?.VoiceId); - if (string.IsNullOrWhiteSpace(normalizedVoiceId) || !seenVoiceIds.Add(normalizedVoiceId)) - { - continue; - } - - if (string.Equals(normalizedVoiceId, normalizedSavedVoiceId, StringComparison.OrdinalIgnoreCase)) - { - savedVoiceExistsInRuntimeList = true; - } - - var displayName = voice?.DisplayName; - - voiceOptions.Add(new AdminUserManagementReplyTtsVoiceOption - { - VoiceId = normalizedVoiceId, - DisplayName = string.IsNullOrWhiteSpace(displayName) - ? normalizedVoiceId - : displayName.Trim() - }); - } - } - - if (!string.IsNullOrWhiteSpace(normalizedSavedVoiceId) && !savedVoiceExistsInRuntimeList) - { - voiceOptions.Insert(0, new AdminUserManagementReplyTtsVoiceOption - { - VoiceId = normalizedSavedVoiceId, - DisplayName = $"{normalizedSavedVoiceId} (saved)" - }); - } - - return voiceOptions; - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) - ? null - : value.Trim(); - } -} - -public sealed class AdminUserManagementReplyTtsUiStateResult -{ - public bool IsVoiceSelectorDisabled { get; set; } - - public string? WarningMessage { get; set; } - - public IReadOnlyList VoiceOptions { get; set; } = []; -} - -public sealed class AdminUserManagementReplyTtsVoiceOption -{ - public string VoiceId { get; set; } = string.Empty; - - public string DisplayName { get; set; } = string.Empty; -} diff --git a/WebCodeCli/Helpers/RuntimeEnvironmentResolver.cs b/WebCodeCli/Helpers/RuntimeEnvironmentResolver.cs new file mode 100644 index 0000000..3ca17e7 --- /dev/null +++ b/WebCodeCli/Helpers/RuntimeEnvironmentResolver.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Hosting; + +namespace WebCodeCli.Helpers; + +public static class RuntimeEnvironmentResolver +{ + public static string ResolveDefaultEnvironmentName() + { + var configuredEnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + if (string.IsNullOrWhiteSpace(configuredEnvironmentName)) + { + configuredEnvironmentName = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + } + + if (!string.IsNullOrWhiteSpace(configuredEnvironmentName)) + { + return configuredEnvironmentName; + } + + return Environments.Production; + } +} diff --git a/WebCodeCli/Pages/CodeAssistant.razor b/WebCodeCli/Pages/CodeAssistant.razor index d4a228d..7d5f039 100644 --- a/WebCodeCli/Pages/CodeAssistant.razor +++ b/WebCodeCli/Pages/CodeAssistant.razor @@ -435,6 +435,7 @@ ContinueSuperpowersActionDisabled="@CurrentSuperpowersQuickActionViewState.ContinueActionDisabled" ExecutePlanText="@SuperpowersQuickActionDefaults.ExecutePlanButtonText" ExecuteSubagentPlanText="@SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText" + CompleteWorktreeText="@SuperpowersQuickActionDefaults.CompleteWorktreeButtonText" ShowStopSuperpowersAction="@_isLoading" StopSuperpowersActionDisabled="@(!_isLoading)" StopSuperpowersActionText="停止" @@ -446,6 +447,7 @@ OnContinueSuperpowersAction="OnContinueSuperpowersActionAsync" OnExecuteSuperpowersPlan="OnExecuteSuperpowersPlanAsync" OnExecuteSuperpowersSubagentPlan="OnExecuteSuperpowersSubagentPlanAsync" + OnExecuteSuperpowersCompleteWorktree="OnExecuteSuperpowersCompleteWorktreeAsync" OnStopSuperpowersAction="OnStopSuperpowersActionAsync" OnRetrySuperpowersCapability="RetrySuperpowersCapabilityAsync" GoalQuickActionMessageId="@CurrentGoalQuickActionEligibility.MessageId" diff --git a/WebCodeCli/Pages/CodeAssistant.razor.cs b/WebCodeCli/Pages/CodeAssistant.razor.cs index e1ca600..e433a1b 100644 --- a/WebCodeCli/Pages/CodeAssistant.razor.cs +++ b/WebCodeCli/Pages/CodeAssistant.razor.cs @@ -2373,6 +2373,11 @@ private async Task OnExecuteSuperpowersSubagentPlanAsync(ChatMessage sourceMessa await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.ExecuteSubagentPlan); } + private async Task OnExecuteSuperpowersCompleteWorktreeAsync(ChatMessage sourceMessage) + { + await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.ExecuteCompleteWorktree); + } + private async Task OnStopSuperpowersActionAsync(ChatMessage sourceMessage) { var eligibility = CurrentSuperpowersQuickActionEligibility; diff --git a/WebCodeCli/Pages/CodeAssistantMobile.razor b/WebCodeCli/Pages/CodeAssistantMobile.razor index 633404e..ff5c25d 100644 --- a/WebCodeCli/Pages/CodeAssistantMobile.razor +++ b/WebCodeCli/Pages/CodeAssistantMobile.razor @@ -652,6 +652,14 @@ @SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText +
} @SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText + } diff --git a/WebCodeCli/Pages/CodeAssistantMobile.razor.cs b/WebCodeCli/Pages/CodeAssistantMobile.razor.cs index 3bb153b..7825887 100644 --- a/WebCodeCli/Pages/CodeAssistantMobile.razor.cs +++ b/WebCodeCli/Pages/CodeAssistantMobile.razor.cs @@ -1041,6 +1041,11 @@ private async Task OnExecuteSuperpowersSubagentPlanAsync(ChatMessage sourceMessa await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.ExecuteSubagentPlan); } + private async Task OnExecuteSuperpowersCompleteWorktreeAsync(ChatMessage sourceMessage) + { + await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.ExecuteCompleteWorktree); + } + private async Task OnStopSuperpowersActionAsync(ChatMessage sourceMessage) { var eligibility = CurrentSuperpowersQuickActionEligibility; diff --git a/WebCodeCli/Pages/SuperpowersQuickActionSubmissionHelper.cs b/WebCodeCli/Pages/SuperpowersQuickActionSubmissionHelper.cs index 576d900..325f061 100644 --- a/WebCodeCli/Pages/SuperpowersQuickActionSubmissionHelper.cs +++ b/WebCodeCli/Pages/SuperpowersQuickActionSubmissionHelper.cs @@ -7,6 +7,7 @@ public enum SuperpowersQuickActionRequestType Continue, ExecutePlan, ExecuteSubagentPlan, + ExecuteCompleteWorktree, QuickInput } @@ -19,6 +20,7 @@ public static class SuperpowersQuickActionSubmissionHelper SuperpowersQuickActionRequestType.Continue => SuperpowersPromptBuilder.BuildContinuePrompt(), SuperpowersQuickActionRequestType.ExecutePlan => SuperpowersPromptBuilder.BuildExecutePlanPrompt(), SuperpowersQuickActionRequestType.ExecuteSubagentPlan => SuperpowersPromptBuilder.BuildSubagentExecutePlanPrompt(), + SuperpowersQuickActionRequestType.ExecuteCompleteWorktree => SuperpowersPromptBuilder.BuildCompleteWorktreePrompt(), SuperpowersQuickActionRequestType.QuickInput => SuperpowersPromptBuilder.BuildQuickSkillPrompt(quickInput), _ => null }; diff --git a/WebCodeCli/Program.cs b/WebCodeCli/Program.cs index 0d20b8c..d77a4fe 100644 --- a/WebCodeCli/Program.cs +++ b/WebCodeCli/Program.cs @@ -233,26 +233,6 @@ static WebApplicationOptions CreateBuilderOptions(string[] args) { Args = args, WebRootPath = resolvedWebRoot, - EnvironmentName = ResolveDefaultEnvironmentName() + EnvironmentName = RuntimeEnvironmentResolver.ResolveDefaultEnvironmentName() }; } - -static string ResolveDefaultEnvironmentName() -{ - var configuredEnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - if (string.IsNullOrWhiteSpace(configuredEnvironmentName)) - { - configuredEnvironmentName = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); - } - - if (!string.IsNullOrWhiteSpace(configuredEnvironmentName)) - { - return configuredEnvironmentName; - } - -#if DEBUG - return Environments.Development; -#else - return Environments.Production; -#endif -} diff --git a/WebCodeCli/WebCodeCli.csproj b/WebCodeCli/WebCodeCli.csproj index 58ff9d9..fcfa17d 100644 --- a/WebCodeCli/WebCodeCli.csproj +++ b/WebCodeCli/WebCodeCli.csproj @@ -35,6 +35,9 @@ PreserveNewest PreserveNewest
+ + Never + diff --git a/WebCodeCli/appsettings.json b/WebCodeCli/appsettings.json index c58f833..d673398 100644 --- a/WebCodeCli/appsettings.json +++ b/WebCodeCli/appsettings.json @@ -7,6 +7,10 @@ }, "AllowedHosts": "*", "urls": "http://*:6021", + "DBConnection": { + "DbType": "Sqlite", + "ConnectionStrings": "Data Source=WebCodeCli.db" + }, "App": { "DefaultUsername": "luhaiyan" }, @@ -25,18 +29,6 @@ "DefaultCardTitle": "AI助手", "ThinkingMessage": "⏳ 思考中..." }, - "FeishuReplyTts": { - "TtsStorageRoot": "", - "TtsServiceBaseUrl": "http://127.0.0.1:5058", - "TtsServiceTimeoutSeconds": 180, - "TtsPreferredDevice": "cpu", - "TtsDefaultVoiceId": "", - "TtsChunkMaxChars": 160, - "FfmpegExecutablePath": "", - "TtsServiceStartScriptPath": "", - "TtsServicePythonPath": "", - "TtsServiceStartupTimeoutSeconds": 30 - }, "CliTools": { "MaxConcurrentExecutions": 3, "DefaultTimeoutSeconds": 300, @@ -67,7 +59,7 @@ "Description": "Codex", "Command": "codex", "ArgumentTemplate": "", - "_Comment_ArgumentTemplate": "可选,为空时使用默认值: exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {cliThreadId} \"{prompt}\"(恢复时)或 exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json \"{prompt}\"(新会话)", + "_Comment_ArgumentTemplate": "可选,为空时使用默认值: exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {cliThreadId} -- \"{prompt}\"(恢复时)或 exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json -- \"{prompt}\"(新会话)", "LowInterruptionArgumentTemplate": "", "_Comment_LowInterruptionArgumentTemplate": "可选,为空时使用默认值: exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json --full-auto {cliThreadId}", "WorkingDirectory": "", diff --git a/appsettings.json b/appsettings.json index 1eb1123..fcb8612 100644 --- a/appsettings.json +++ b/appsettings.json @@ -12,15 +12,15 @@ "Users": [ { "Username": "wenxin", - "Password": "wenxin@123" + "Password": "CHANGEME_WENXIN_PASSWORD" }, { "Username": "treechat", - "Password": "treechat@123" + "Password": "CHANGEME_TREECHAT_PASSWORD" }, { "Username": "xuzeyu", - "Password": "xuzeyu@123" + "Password": "CHANGEME_XUZEYU_PASSWORD" } ] }, @@ -56,7 +56,7 @@ "Name": "小树", "Description": "Codex", "Command": "codex", - "_Comment_ArgumentTemplate": "可选,为空时使用默认值: exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {cliThreadId} \"{prompt}\"(恢复时)或 exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json \"{prompt}\"(新会话)", + "_Comment_ArgumentTemplate": "可选,为空时使用默认值: exec resume --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {cliThreadId} -- \"{prompt}\"(恢复时)或 exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json -- \"{prompt}\"(新会话)", "PersistentModeArguments": "", "UsePersistentProcess": false, "WorkingDirectory": "", diff --git a/docs/agent-notes/2026-05-18.md b/docs/agent-notes/2026-05-18.md new file mode 100644 index 0000000..cd99480 --- /dev/null +++ b/docs/agent-notes/2026-05-18.md @@ -0,0 +1,7 @@ +# 2026-05-18 + +- Feishu superpowers plan quick actions can reuse the goal quick-action pipeline when the button should start a persistent `/goal` workflow. Add a dedicated Feishu action and route it to `HandleGoalQuickActionAsync` instead of `HandleSuperpowersQuickActionAsync` so goal capability probing, overwrite confirmation, and lingering-turn conflict handling stay consistent with normal `/goal` submission. +- The superpowers execution buttons now use two rows for plan-related actions: `plan_action_row` keeps `执行 plan` and `SubAgent执行`, while `goal_plan_action_row` carries the single `Goal执行` button that submits the fixed goal prompt `使用Subagent-Driven完成plan,如有询问我的,先按你推荐的继续进行`. +- When the bottom action area is backed by app-server goal runtime, the section marker and goal quick-input copy should describe persistent execution instead of one-shot goal submission. Use `Superpowers 工作流/Goal不间断执行` for the section title, and describe `/goal` as setting or updating the current app-server goal session with follow-up control through `/goal pause`, `/goal clear`, and `/goal resume`. +- Feishu long-connection recovery is not owned by WebCode itself. `UserFeishuBotRuntimeService` starts `FeishuNetSdk.WebSocket.WssService`, but `HostedServiceRuntimeMonitorPolicy` explicitly excludes that hosted service from ExecuteTask tracking. There is no wake hook or explicit reconnect orchestration after Windows sleep/resume, so socket recovery depends on the SDK/hosted service behavior rather than WebCode. +- A practical wake-recovery path for the Feishu websocket is to watch for large wall-clock gaps in a background service and then call a single runtime restart path on `UserFeishuBotRuntimeService`. That keeps the reconnect logic in one place, avoids `SystemEvents` message-pump constraints, and leaves goal app-server sessions untouched. diff --git a/docs/agent-notes/2026-05-19.md b/docs/agent-notes/2026-05-19.md new file mode 100644 index 0000000..f248bf6 --- /dev/null +++ b/docs/agent-notes/2026-05-19.md @@ -0,0 +1,6 @@ +# 2026-05-19 + +- Feishu streaming-card superpowers quick input can hit the session-mismatch confirmation card when the card-bound session differs from the current active session. In that branch, the confirmation buttons must carry the already-expanded superpowers prompt, not the action id `submit_superpowers_quick_input`; otherwise clicking `改为当前会话` loses the typed input and falls back to `请输入命令`. +- For backward compatibility, `FeishuCardActionService.HandleSuperpowersQuickActionAsync` should still accept both forms on confirmation callbacks: + - legacy action ids such as `continue_superpowers` or `execute_superpowers_plan` + - fully expanded raw prompts produced from the original card input diff --git a/docs/agent-notes/2026-05-20.md b/docs/agent-notes/2026-05-20.md new file mode 100644 index 0000000..ea736a8 --- /dev/null +++ b/docs/agent-notes/2026-05-20.md @@ -0,0 +1,224 @@ +# 2026-05-20 Agent Notes + +## Goal pause must tolerate app-server active-turn id mismatch by retrying interrupt once with the authoritative turn id + +- Symptom: + - Clicking Feishu `/goal pause` could fail immediately with an app-server error like: + - `expected active turn id 019e440e-097c-7002-a7b8-1fa6ba765d39 but found a36077c9-0a43-4ad6-9bc1-2ec37b67f961` + - The failure happened even though WebCode was correctly reusing the live goal runtime session and thread. +- Root cause: + - `CodexAppServerSessionManager.InterruptActiveTurnAsync(...)` sent `turn/interrupt` with the locally cached `ActiveTurnId` and treated any `InvalidOperationException` from app-server as fatal. + - In some live goal-runtime sessions, app-server can reject that request by reporting that its current authoritative active turn id differs from the one WebCode cached locally. +- Working rule: + - For `turn/interrupt`, if app-server returns the specific `expected active turn id ... but found ...` mismatch, WebCode should treat that as recoverable state drift. + - Resolve the alternate turn id from the error message, update the cached `ActiveTurnId`, and retry `turn/interrupt` exactly once. + - Do not broaden this into a generic retry for unrelated app-server failures. +- Current implementation: + - The retry-and-rebind logic now lives in [CodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs:1) via `InterruptActiveTurnCoreAsync`, `SendInterruptRequestAsync`, and `TryResolveReplacementActiveTurnIdForInterruptMismatch`. + - Regression coverage for the mismatch parser lives in [CodexAppServerSessionManagerTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs:1). +- Search hints: + - `InterruptActiveTurnMismatchRegex` + - `TryResolveReplacementActiveTurnIdForInterruptMismatch` + - `active turn ID mismatch during interrupt` + +## Goal runtime control actions must skip provider/rollout sync when reusing a live app-server session + +- Symptom: + - Clicking Feishu goal control actions such as `/goal`, `/goal pause`, or `/goal resume` on an already running goal runtime session could still log rollout rewrite attempts like: + - `Read rollout file failed ... because it is being used by another process` + - This was confusing because those actions were supposed to reuse the existing app-server process, not re-enter the thread from scratch. +- Root cause: + - `CliExecutorService.ExecuteCodexGoalRuntimeStreamAsync(...)` always called `BestEffortSyncCodexGoalRuntimeThreadProviderAsync(...)` before `EnsureThreadAsync(...)`. + - That sync path rewrites rollout/sqlite provider metadata for preserved threads, which is correct when re-entering goal runtime after a temporary exit. + - But it is the wrong thing to do when the same session already has a live app-server process attached to the same thread, because the rollout can still be open by the active runtime. +- Working rule: + - If `ICodexAppServerSessionManager` already has a running session bound to the same thread id, goal control commands must skip provider/thread sync and directly reuse the live runtime. + - Keep the best-effort sync only for true re-entry into a fresh app-server process. +- Current implementation: + - The running-session check now lives in [ICodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/ICodexAppServerSessionManager.cs:1) and [CodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs:1). + - The skip logic now lives in [CliExecutorService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs:1). + - Regression coverage lives in [CliExecutorServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs:1). +- Search hints: + - `HasRunningSession` + - `复用现有 Codex goal runtime 会话,跳过线程 Provider 同步` + - `ExecuteStreamAsync_WhenGoalRuntimeControlCommandReusesLiveAppServerSession_SkipsProviderSync` + +## Feishu goal-runtime completion notices must describe turn completion, not always claim the goal is finished + +- Symptom: + - After a goal-runtime turn ended, Feishu still sent a plain completion text like: + - `当前会话:...` + - `已完成` + - `🎯 Goal持续会话` + - Users could read that as “the whole goal finished”, even when the goal snapshot was still `active` and only the current turn had ended. +- Root cause: + - Both `FeishuCardActionService` and `FeishuChannelService` used a fixed `BuildCompletionNotificationText(...)` implementation and always set the final card status to the generic completed state. + - That path did not inspect the live goal snapshot from the reused app-server session. +- Working rule: + - Keep the explicit Feishu completion notification, but make it goal-aware for goal runtime sessions: + - `active` -> report that the current round ended and the goal is still running + - `paused` -> report paused + - `complete` -> report goal completed + - unknown / unavailable -> report only that the current round ended + - The final card status should use the same semantics instead of always showing the generic completed state. +- Current implementation: + - Goal-aware completion formatting now lives in [GoalRuntimeCompletionStateFormatter.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/GoalRuntimeCompletionStateFormatter.cs:1). + - Live goal snapshot access now lives in [ICliExecutorService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/ICliExecutorService.cs:1) and [CliExecutorService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs:1). + - Feishu card-action and channel completion paths now live in [FeishuCardActionService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs:1) and [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1). + - Regression coverage lives in [FeishuCardActionServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs:1). +- Search hints: + - `TryGetGoalRuntimeGoalAsync` + - `本轮执行已结束,Goal 仍在运行` + - `GoalRuntimeCompletionStateFormatter` + +## Goal runtime status must keep the live parent thread authoritative when subagents create child threads + +- Symptom: + - Feishu goal runtime could reply `已完成` even though the long-running goal was still progressing. + - A later `/goal` status lookup could then show `paused` for the same session. +- Root cause: + - App-server item events from subagents carried child `thread_id` values. + - WebCode persisted those child ids back into the session-level `CliThreadId`, so the goal runtime stopped reading the original parent goal thread. + - Once status/completion logic looked at the child thread instead of the live parent thread, Feishu could misreport the goal as paused or finished. +- Working rule: + - The live app-server session thread is authoritative for goal runtime sessions. + - Subagent/item output must never overwrite the session's primary Codex thread binding. +- Current implementation: + - Live-thread authority lives in [ICodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/ICodexAppServerSessionManager.cs:1) and [CodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs:1). + - `CliExecutorService` now repairs drifted stored thread ids back to the live goal parent thread in [CliExecutorService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs:1). + - App-server JSONL normalization now avoids leaking item-level `thread_id` into session tracking in [CodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs:1). + - Regression coverage lives in [CliExecutorServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs:1) and [CodexAppServerSessionManagerTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs:1). +- Search hints: + - `GetRunningThreadId` + - `thread_source\":\"subagent` + - `TryGetGoalRuntimeGoalAsync_WhenStoredThreadDriftedToSubagent_PrefersLiveAppServerThread` + +## Goal runtime Feishu streaming must stay on the same card across multiple app-server turns + +- Symptom: + - A Feishu goal-runtime streaming card could end with wording like `本轮已结束 / Goal继续中`, even though the goal was still `active` and Codex should have continued writing into the same card. +- Root cause: + - `CliExecutorService.ExecuteCodexGoalRuntimeStreamAsync(...)` previously streamed only a single app-server turn for `/goal ...` and `/goal resume`. + - The app-server emitted `turn.completed`, which `CodexAppServerSessionManager` surfaced as `StreamOutputChunk { IsCompleted = true }`. + - Feishu streaming consumers stop on `chunk.IsCompleted`, so one finished turn incorrectly terminated the outer Feishu card stream. +- Working rule: + - For goal runtime, `turn.completed` is only an inner turn boundary. + - As long as the live goal snapshot remains `active`, WebCode must immediately start the next app-server turn and keep streaming into the same Feishu card. + - The outer `IsCompleted = true` should be emitted only after the goal leaves `active` or the runtime errors. +- Current implementation: + - Multi-turn goal-runtime streaming now lives in [CliExecutorService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs:1) via `StreamGoalRuntimeTurnsWhileActiveAsync(...)`. + - Regression coverage for the outer-stream lifecycle now lives in [CliExecutorServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs:1) under `ExecuteStreamAsync_WhenGoalRemainsActive_AutoContinuesNextTurnBeforeCompletingOuterStream`. + - Existing single-turn goal-runtime tests now queue explicit goal snapshots so they terminate under the new auto-continue semantics. +- Search hints: + - `StreamGoalRuntimeTurnsWhileActiveAsync` + - `GoalRuntimeContinuationPromptPrefix` + - `ExecuteStreamAsync_WhenGoalRemainsActive_AutoContinuesNextTurnBeforeCompletingOuterStream` + +## Feishu streaming card recovery must first preserve the current card after timeout-plus-sequence-conflict, then fall back to at most one replacement card + +- Symptom: + - CardKit streaming could stop after a timed-out card update was retried and Feishu replied with `300317 sequence number compare failed`. + - Feishu users then lost live card updates even though the CLI stream itself was still running. +- Root cause: + - `FeishuCardKitClient.UpdateCardCoreAsync(...)` previously treated every `300317` as a hard card-write failure. + - In the observed production sequence, the first PUT likely succeeded server-side but timed out client-side, so retrying the same `sequence` only collided with an already-applied write. + - Once a single `FeishuStreamingHandle` entered `AreCardUpdatesStopped`, both channel and card-action flows stopped updating that logical stream instead of attempting controlled recovery. +- Working rule: + - If a card update times out without caller cancellation and the immediate retry for the same `cardId` and `sequence` returns `300317`, treat that retry result as a same-card success signal. + - Only fall back to one replacement streaming card when the active handle is truly no longer writable. + - Seed the replacement card with the latest fully rendered content and continue the same logical stream there. + - If the replacement card also fails, preserve the existing disconnect fallback text instead of creating more cards. +- Additional guardrail: + - Structured-but-empty CLI events such as `thread.started` must not leak raw JSON into Feishu card bodies during fallback-output extraction. +- Current implementation: + - Same-card timeout/sequence-conflict recovery now lives in [FeishuCardKitClient.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs:1). + - Shared replacement-card session handling now lives in [FeishuStreamingCardSession.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs:1). + - Both channel and card-action streaming paths now route through that session in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1) and [FeishuCardActionService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs:1). + - Regression coverage lives in [FeishuCardKitClientTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs:1), [FeishuChannelServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs:1), and [FeishuCardActionServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs:1). +- Search hints: + - `TreatsTimeoutThenSequenceConflictAsSuccessfulPriorWrite` + - `FeishuStreamingCardSession` + - `WhenReplacementCardAlsoFails_AppendsDisconnectMessage` + - `thread.started` + +## Codex app-server item normalization must tolerate non-string file-change fields instead of tearing down the whole goal stream + +- Symptom: + - Feishu goal runtime could suddenly fail with `Codex app-server 输出流已关闭。` + - The visible tool stderr often only showed ordinary command failures like `codex_core::tools::router: error=Exit code: 1`, which made the close look like an upstream/provider problem. +- Root cause: + - In `CodexAppServerSessionManager.BuildFileChangeItem(...)`, `TryGetString(change, "kind", ...)` called `JsonElement.GetString()` without checking the JSON value kind. + - Some app-server `fileChange` events can carry `changes[].kind` as an object rather than a plain string. + - That threw `InvalidOperationException`, broke `ReadOutputLoopAsync(...)`, and only then surfaced as the generic fallback `Codex app-server 输出流已关闭。`. +- Working rule: + - App-server event normalization must be shape-tolerant. + - Non-string scalar fields may be stringified when useful; object/array values must be ignored rather than crashing the session reader. + - A malformed optional field in one item must never tear down the active goal-runtime stream. +- Current implementation: + - Safe scalar extraction now lives in [CodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs:1) via `TryGetScalarString(...)`, used by both `TryGetString(...)` overloads. + - Regression coverage for object-valued `fileChange.kind` now lives in [CodexAppServerSessionManagerTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs:1) under `TryBuildCliOutputJsonl_FileChangeWithObjectKind_DoesNotThrowAndOmitsInvalidKind`. +- Search hints: + - `TryGetScalarString` + - `TryBuildCliOutputJsonl_FileChangeWithObjectKind_DoesNotThrowAndOmitsInvalidKind` + - `Codex app-server 输出流已关闭` +## Feishu replacement-card recovery only works if every live writer shares the same streaming-card session + +- Symptom: + - A replacement Feishu streaming card could be created with stale `思考中...` content even though external-history backfill had already recovered richer assistant text. + - The final replacement card body still looked correct, so earlier regression coverage missed that the replacement card was seeded from stale content. +- Root cause: + - Main chunk streaming already wrote through `FeishuStreamingCardSession`, but external-history backfill and status-pulse updates in both Feishu services still wrote directly to the active handle. + - Once a direct backfill update stopped the old handle, a later structured-but-empty chunk such as `thread.started` could fall back to `thinkingMessage` and trigger replacement-card creation from that stale value instead of the recovered render. +- Working rule: + - All live stream writers for one logical Feishu card must share the same `FeishuStreamingCardSession` above the handle. + - When a structured chunk yields no assistant text and no fallback output, preserve richer already-rendered content instead of regressing to the bare thinking placeholder. +- Current implementation: + - Channel-side submission now creates the replacement-aware `FeishuStreamingCardSession` once and reuses it for main streaming, external-history backfill, and status pulses in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1). + - Card-action execute-command and low-interruption continue now route the same backfill writes through their per-stream `FeishuStreamingCardSession` and preserve recovered content on structured-empty chunks in [FeishuCardActionService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs:1). + - The channel regression that proves replacement-card initial seeding now lives in [FeishuChannelServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs:1). +- Search hints: + - `WhenExternalHistoryBackfillBreaksCurrentCard_ReplacesStreamingCardBeforeCompletion` + - `RunExternalHistoryBackfillAsync` + - `RunStreamingStatusPulseAsync` + - `ShouldProbeExternalHistory` + +## Feishu replacement-card recovery must also cover final completion writes, not only streaming updates + +- Symptom: + - A Feishu stream could survive all incremental `UpdateAsync(...)` writes but still silently lose the final completed card body if the card became unwritable only during `FinishAsync(...)`. + - Earlier recovery coverage missed this because all regressions only failed update-path writes, so completion-path failure never created a replacement card and never appended the disconnect fallback. +- Root cause: + - `FeishuStreamingHandle.FinishAsync(...)` previously ignored the boolean result from its finish delegate, so finish failures never set `AreCardUpdatesStopped`. + - `FeishuStreamingCardSession` only recovered failed `UpdateAsync(...)` writes; final completion writes forwarded straight to the current handle with no replacement/fallback decision. +- Working rule: + - Treat final completion writes with the same recovery semantics as streaming updates. + - If the current card fails during `FinishAsync(...)`, attempt the one allowed replacement card seeded with the full final rendered content. + - If the replacement card also fails its final completion write, fall back to the existing disconnect message and suppress the normal “已完成” notification/TTS path. +- Current implementation: + - Finish-path failure propagation now lives in [FeishuStreamingHandle.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingHandle.cs:1). + - Replacement-aware completion writes now live in [FeishuStreamingCardSession.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs:1). + - Channel/card-action completion branches now route finish failures back into their existing disconnect fallback in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1) and [FeishuCardActionService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs:1). + - Regression coverage for finish-path replacement/fallback now lives in [FeishuChannelServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs:1) and [FeishuCardActionServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs:1). +- Search hints: + - `WhenFinalCardCompletionFails_ReplacesStreamingCardAndFinishesOnReplacement` + - `WhenReplacementCardFinalCompletionAlsoFails_AppendsDisconnectMessage` + - `FinishAttemptCount` + +## Codex app-server goal streams must use protocol `willRetry` before message text when deciding whether an `error` notification is terminal + +- Symptom: + - `/goal` execution or status reading could surface noisy app-server errors even when the underlying goal turn was still retrying. + - The surrounding debug log often only showed tool/router stderr such as `error=Exit code: 1`, which made the failure look like “读 goal 输出报错”. +- Root cause: + - `CodexAppServerSessionManager.HandleNotification(...)` only used a small message-text whitelist like `Reconnecting... 1/5` to decide whether a top-level app-server `error` notification should keep the stream alive. + - The Codex app-server protocol already exposes `ErrorNotification.willRetry`, but WebCode ignored it. +- Working rule: + - For top-level app-server `error` notifications, use `willRetry == true` as the primary signal that the active goal turn is still recoverable and must not be terminated yet. + - Keep the old reconnect-style message whitelist only as a backward-compatible fallback when `willRetry` is absent. +- Current implementation: + - The retry-aware suppression logic now lives in [CodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs:1). + - Regression coverage for `willRetry`-driven suppression lives in [CodexAppServerSessionManagerTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs:1). +- Search hints: + - `willRetry` + - `ShouldSuppressTransientErrorNotification` + - `PrefersWillRetryFromProtocol` diff --git a/docs/agent-notes/2026-05-21.md b/docs/agent-notes/2026-05-21.md new file mode 100644 index 0000000..9ceca2a --- /dev/null +++ b/docs/agent-notes/2026-05-21.md @@ -0,0 +1,102 @@ +# 2026-05-21 Agent Notes + +## Feishu streaming-card overflow recovery must reduce the CardKit payload before retrying the same card or creating a replacement card + +- Symptom: + - A Feishu streaming card could stop updating after large CLI output, then log: + - `Update CardKit card failed: card over max size (code: 200860)` + - The expected fallback replacement card did not appear, so users only saw the existing disconnect text path. +- Root cause: + - The earlier replacement-card recovery assumed the old card became unwritable for transport/state reasons, such as timeout plus sequence conflict. + - For CardKit business error `200860`, both the original card update and the replacement-card creation reused the same oversized rendered payload. + - That meant recovery did run, but the replacement card failed with the same business error before it could be sent. +- Working rule: + - Treat `200860 card over max size` as a payload-shape problem, not a generic card-write failure. + - Before giving up on the current card or trying a replacement card, retry with a reduced payload that: + - keeps only the latest tail of the assistant reply + - adds an explicit truncation marker so users know earlier content was dropped + - strips optional chrome sections such as status/tool summary/bottom actions when needed + - Once a streaming card has overflowed, keep later updates on the reduced payload mode for that handle instead of retrying the full payload first on every update. + - If even the minimal reduced payload still gets `200860`, let the existing replacement/disconnect fallback logic handle the terminal failure. +- Current implementation: + - Overflow-aware payload reduction now lives in [FeishuCardKitClient.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs:1) via `StreamingCardPayloadState`, `TryAdvanceOverflowReduction(...)`, and `RenderStreamingReplyContent(...)`. + - The streaming handle created by `CreateStreamingHandleAsync(...)` now shares one payload-state instance across create, update, and finish writes so overflow recovery remains sticky for that logical card. + - Regression coverage for overflow create/update recovery now lives in [FeishuCardKitClientTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs:1). +- Search hints: + - `card over max size` + - `StreamingCardPayloadState` + - `CreateStreamingHandleAsync_RetriesOverflowUpdateWithReducedReplyOnlyPayload` + - `CreateStreamingHandleAsync_SticksToReducedPayloadAfterOverflowRecovery` + +## Feishu overflow fallback must switch to a text-stream handle that owns its own send lifetime + +- Symptom: + - After adding plain-text fallback for `200860`, initial create overflow or replacement-create overflow could still stop before the final text chunks arrived. + - The first fallback text might be sent, but later completion text could disappear because the stream had already cancelled its update token. +- Root cause: + - The first text-fallback implementation closed over the card-stream's create/update cancellation token and reused it for every later `UpdateAsync(...)` and `FinishAsync(...)` on the text handle. + - Both Feishu streaming services intentionally cancel update work before the final completion write, so reusing that token let the text fallback cancel itself during a normal finish path. +- Working rule: + - Once the logical stream has switched away from CardKit into plain text, the replacement handle must own its own send lifetime instead of depending on the old card-update token. + - Initial fallback creation can still honor the caller cancellation token, but subsequent text-stream updates and finish writes should not be tied to the cancelled update-work token from the card path. + - Test initial create overflow and replacement-create overflow as separate behaviors; they are not the same scenario. +- Current implementation: + - The text fallback handle now lives in [FeishuTextStreamingFallbackHandleFactory.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuTextStreamingFallbackHandleFactory.cs:1) with clean UTF-8 notices and non-capturing follow-up writes. + - Channel/card-action regressions now explicitly cover both: + - initial streaming-card create overflow -> plain-text stream + - replacement-card create overflow -> plain-text stream + - Coverage lives in [FeishuChannelServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs:1620) and [FeishuCardActionServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs:747). + +## Admin credential changes must update both the user hash and the legacy system setting + +- Non-obvious constraint: + - WebCode currently has two admin-password validation/storage paths. + - Normal login validation goes through `UserAccount.PasswordHash` in [UserAccountService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/UserAccountService.cs:122) using ASP.NET Identity `PasswordHasher`. + - System initialization / legacy admin credential validation still reads `system_settings.admin_password` in [SystemSettingsService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/SystemSettingsService.cs:374) as Base64-encoded UTF-8 text. +- Working rule: + - When changing the admin password directly in `WebCodeCli.db`, update both: + - `UserAccount.PasswordHash` for username `admin` + - `system_settings` row with key `admin_password` + - If only one is updated, login and setup/admin-validation flows can disagree about the active password. +- Reference details: + - Current `UserAccount.PasswordHash` format is ASP.NET Identity v3 payload layout: + - format marker `0x01` + - PRF `2` (`HMACSHA512`) + - `100000` PBKDF2 iterations + - `16`-byte salt + - `32`-byte subkey + - Reuse the application service path `UpdateAdminCredentialsAsync(...)` when available; for direct SQLite maintenance, mirror both storage updates without recording the plain password in notes or source. + +## Windows installer and publish outputs must default to Production even when built from Debug + +- Symptom: + - A locally built Windows installer could launch against development-only paths after install, such as a repo-local `bin\Debug\net10.0\WebCodeCli.db`. + - Reply TTS machine-specific paths from `appsettings.Development.json` could also override the production defaults in the installed app. +- Root cause: + - `WebCodeCli` used compile-time `#if DEBUG` fallback logic to choose the runtime environment name when no explicit `ASPNETCORE_ENVIRONMENT` or `DOTNET_ENVIRONMENT` was set. + - The local installer skill intentionally defaults to `Debug` builds for quick machine testing, so those packaged binaries booted in `Development`. + - The project also published `appsettings.Development.json`, which then overrode the production `appsettings.json` values inside the installed app. +- Working rule: + - Installed/published WebCode must not infer `Development` solely from a Debug build. + - If no explicit environment variable is provided at runtime, default to `Production`. + - Publish outputs should exclude `appsettings.Development.json` so the deployed app cannot silently pick up repo-machine overrides. +- Current implementation: + - Runtime environment fallback now lives in [RuntimeEnvironmentResolver.cs](/D:/VSWorkshop/WebCode/WebCodeCli/Helpers/RuntimeEnvironmentResolver.cs:1) and always defaults to `Production` unless the host explicitly sets an environment name. + - `Program.cs` now uses that helper through [Program.cs](/D:/VSWorkshop/WebCode/WebCodeCli/Program.cs:236). + - Publish exclusion for `appsettings.Development.json` now lives in [WebCodeCli.csproj](/D:/VSWorkshop/WebCode/WebCodeCli/WebCodeCli.csproj:34). + - Regression coverage lives in [RuntimeEnvironmentResolverTests.cs](/D:/VSWorkshop/WebCode/tests/WebCodeCli.Tests/RuntimeEnvironmentResolverTests.cs:1) and [PublishOutputConfigurationTests.cs](/D:/VSWorkshop/WebCode/tests/WebCodeCli.Tests/PublishOutputConfigurationTests.cs:1). + +## Installer publish rewriting depends on appsettings.json already containing a DBConnection object + +- Symptom: + - The Windows installer build script could finish successfully, but the packaged app would still use `Data Source=WebCodeCli.db` under the app base directory instead of the intended `data/WebCodeCli.db`. +- Root cause: + - `tools/build-windows-installer.ps1` only rewrote `DBConnection.ConnectionStrings` when the published `appsettings.json` already contained a `DBConnection` object. + - The base [appsettings.json](/D:/VSWorkshop/WebCode/WebCodeCli/appsettings.json:1) had no `DBConnection` section, so the rewrite block was skipped entirely. +- Working rule: + - Keep a base `DBConnection` section in `WebCodeCli/appsettings.json` even if it only mirrors the default runtime values. + - Packaging scripts that rewrite JSON for deployment should also create the section when missing instead of assuming the source config always contains it. +- Current implementation: + - Base `DBConnection` now lives in [appsettings.json](/D:/VSWorkshop/WebCode/WebCodeCli/appsettings.json:1). + - The installer rewrite path now creates/updates `DBConnection` in [build-windows-installer.ps1](/D:/VSWorkshop/WebCode/tools/build-windows-installer.ps1:87). + - Publish verification covers the base publish output shape in [PublishOutputConfigurationTests.cs](/D:/VSWorkshop/WebCode/tests/WebCodeCli.Tests/PublishOutputConfigurationTests.cs:1). diff --git a/docs/agent-notes/2026-05-22.md b/docs/agent-notes/2026-05-22.md new file mode 100644 index 0000000..16a3f3f --- /dev/null +++ b/docs/agent-notes/2026-05-22.md @@ -0,0 +1,40 @@ +# 2026-05-22 Agent Notes + +## Reply TTS runtime must auto-restart the local Kokoro service when the endpoint drops after an earlier healthy probe + +- Symptom: + - Installed WebCode could log one successful `GET http://127.0.0.1:5058/health`, then immediately fail reply TTS with: + - `Local Kokoro/sherpa-onnx service is unavailable: 由于目标计算机积极拒绝,无法连接。 (127.0.0.1:5058)` + - The failure happened during normal Feishu reply completion, even though the bundled TTS payload and storage root were valid. +- Root cause: + - `ResolveVoiceOrFallbackAsync(...)` only called `GetHealthAsync()` and `GetVoicesAsync()`. + - If the local TTS process dropped between the first successful health probe and the later voice fetch / reply-TTS execution window, the runtime returned failure immediately instead of attempting to restart the local service. + - This was especially visible in installed builds because the bundled TTS service is a separate local process and may not stay alive across earlier startup timing or transient exit conditions. +- Working rule: + - Reply TTS runtime paths must be self-healing, not startup-only. + - If runtime health probing reports the local service unavailable, retry by calling `EnsureServiceStartedAsync(...)` before giving up. + - If voice enumeration fails after storage/ffmpeg validation, retry once after re-ensuring the local service instead of returning an empty voice list immediately. +- Current implementation: + - [FeishuReplyTtsPlatformService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs:1) now: + - falls back from `GetHealthAsync()` to `EnsureServiceStartedAsync()` inside `ResolveVoiceOrFallbackAsync(...)` + - retries `GetVoicesAsync()` once after `EnsureStartedAsync(...)` when the first voice fetch throws + - returns the already-confirmed `startHealth` result from `EnsureServiceStartedAsync(...)` instead of forcing an immediate extra health probe + - Regression coverage lives in [FeishuReplyTtsPlatformServiceTests.cs](/D:/VSWorkshop/WebCode/tests/WebCodeCli.Tests/FeishuReplyTtsPlatformServiceTests.cs:1). + +## Reply TTS startup probing must tolerate slow cold starts in the installed bundle + +- Symptom: + - Installed WebCode could start the local Kokoro process, but startup still ended with: + - `Status=startup-timeout, Message=... Local Kokoro/sherpa-onnx service did not become healthy within 30 seconds.` + - The same machine later showed the bundled service healthy on `http://127.0.0.1:5058/health`, so this was a readiness timing problem rather than a missing payload or broken script. +- Root cause: + - `ReplyTtsLocalServiceManager.TryGetServiceHealthAsync(...)` canceled every health probe after 2 seconds. + - Cold-start probes in the installed bundle can legitimately take longer than 2 seconds while Python, models, and the local HTTP service finish initializing. + - The default startup window of 30 seconds was also too tight for the slowest observed cold starts. +- Working rule: + - Startup readiness checks for the bundled local TTS service must use a slower probe timeout than steady-state request assumptions. + - Production defaults for installed builds should allow a longer cold-start window than development-time local runs. +- Current implementation: + - [ReplyTtsLocalServiceManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsLocalServiceManager.cs:1) now uses a 5-second health-probe timeout while waiting for the local service to become ready. + - [FeishuReplyTtsOptions.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs:1), [appsettings.json](/D:/VSWorkshop/WebCode/WebCodeCli/appsettings.json:1), and [build-windows-installer.ps1](/D:/VSWorkshop/WebCode/tools/build-windows-installer.ps1:1) now default `TtsServiceStartupTimeoutSeconds` to `60`. + - Regression coverage lives in [ReplyTtsLocalServiceManagerTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/ReplyTtsLocalServiceManagerTests.cs:1) and [PublishOutputConfigurationTests.cs](/D:/VSWorkshop/WebCode/tests/WebCodeCli.Tests/PublishOutputConfigurationTests.cs:1). diff --git a/docs/agent-notes/2026-05-23.md b/docs/agent-notes/2026-05-23.md new file mode 100644 index 0000000..5abded5 --- /dev/null +++ b/docs/agent-notes/2026-05-23.md @@ -0,0 +1,50 @@ +## Feishu streaming-card `TaskCanceledException` right after `turn.completed` usually comes from normal shutdown of background card-update work, not a primary CardKit failure + +- Symptom: + - A Feishu streaming reply logs a normal successful card update such as: + - `Received HTTP response headers ... - 200` + - The CLI stream then emits completion markers such as: + - `turn.completed` + - `检测到一次进程适配器终止事件,提前结束当前流输出` + - Immediately after that, one or more trailing card updates log: + - `Update card failed ...` + - `System.Threading.Tasks.TaskCanceledException: A task was canceled.` +- Root cause: + - Channel/card-action streaming runs extra background writers alongside the main CLI stream: + - status pulse updates + - external history backfill updates + - On normal completion, the services intentionally stop update work before the final completion flow: + - channel path calls `ActiveSessionExecution.CancelUpdateWork(stopCardUpdates: false)` in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1197) + - `CancelUpdateWork(...)` cancels `UpdateCancellationTokenSource` in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:3159) + - If one of those background update tasks has already entered `FeishuCardKitClient.UpdateCardCoreAsync(...)`, its `PutAsync(...)` call still uses the now-canceled token and `HttpClient.SendAsync(...)` throws `TaskCanceledException`. + - `UpdateCardCoreAsync(...)` only treats timeout cancellation as recoverable when the caller token itself was not canceled; when the linked caller token is canceled, the exception escapes to the outer catch and is logged as `Update card failed`, even though the main stream may already have completed successfully. +- Working rule: + - When the log order is `200 success` -> `turn.completed` / adapter termination -> trailing `TaskCanceledException` on later `seq`, treat this first as a completion-race signal, not evidence that Feishu CardKit was broadly failing. + - Distinguish it from real transport instability by checking whether the failed `seq` happens after the final successful update and after update-work cancellation has begun. + - If future cleanup is needed, target shutdown/logging behavior for canceled trailing updates rather than CardKit business-error recovery. +- Search hints: + - `CancelUpdateWork(stopCardUpdates: false)` + - `UpdateCancellationTokenSource.Cancel()` + - `Update card failed (cardId=` + - `turn.completed` + +## Feishu channel normal completion must stop background update loops before final finish, but must not reuse the loop-cancellation token as the card-write lifetime token + +- Symptom: + - Channel-side Feishu streaming could create a replacement card from a background writer such as external-history backfill or status pulse, but the final completion write on that replacement card could still disappear during normal shutdown. +- Root cause: + - The first shutdown fix direction was only “cancel and await background tasks before final finish”. + - That alone is insufficient when replacement-card creation receives the same cancellation token used to stop the background loop itself. + - Once that loop token is canceled, the replacement handle may already own a canceled write token, so later `FinishAsync(...)` can still fail even after the loop has exited cleanly. +- Working rule: + - Separate two concerns in the channel submission path: + - background-loop stop token: used only to stop status-pulse / external-history loops + - card-write lifetime token: used for `cardSession.UpdateAsync(...)` and replacement-handle creation/finish lifetime + - On normal completion: + - cancel the background-loop token + - await background tasks to exit + - then run final `FinishAsync(...)` + - Do not cancel the card-write lifetime token until the outer cleanup path. +- Current implementation: + - Channel-side background loop shutdown now lives in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1) with a dedicated `backgroundUpdatesCts`. + - The regression proving replacement-handle completion survives loop shutdown now lives in [FeishuChannelServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs:1). diff --git a/docs/agent-notes/2026-05-24.md b/docs/agent-notes/2026-05-24.md new file mode 100644 index 0000000..806726c --- /dev/null +++ b/docs/agent-notes/2026-05-24.md @@ -0,0 +1,16 @@ +## Feishu channel normal completion should stop background pulse/backfill loops before final finish, but keep card-write lifetime tokens independent + +- Context: + - Feishu channel streaming could log tail `TaskCanceledException` right after `turn.completed` even when the last visible card update had already returned `200`. + - The risky window was the normal completion path in `FeishuChannelService`, where background status-pulse and external-history-backfill writers could still be in-flight while the service was transitioning into final card completion. +- Findings: + - Waiting for background loop shutdown before the final `FinishAsync(...)` write reduces the normal tail-race that otherwise cancels `HttpClient.SendAsync(...)` in a late background `PUT`. + - The shutdown token for those loops must stay separate from the token used by replacement-card creation and final completion writes. + - If a replacement handle is created with the same token used to stop the background loop, that replacement handle can inherit an already-canceled token and silently lose the final completion write. +- Implementation note: + - Keep a dedicated background-loop `CancellationTokenSource` for status pulse / history backfill shutdown. + - Cancel and await those background tasks before the final completion write. + - Continue to use the execution update token for replacement-handle card writes so the final finish remains valid after loop shutdown. +- References: + - Channel shutdown sequencing lives in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1). + - Regression coverage for background replacement-handle completion survives in [FeishuChannelServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs:1). diff --git a/docs/agent-notes/2026-05-26.md b/docs/agent-notes/2026-05-26.md new file mode 100644 index 0000000..d496b21 --- /dev/null +++ b/docs/agent-notes/2026-05-26.md @@ -0,0 +1,94 @@ +## Feishu replacement-card handoff should explicitly close the superseded card, and card-action background writers must not create replacement handles with loop-shutdown tokens + +- Symptom: + - When a Feishu streaming card stopped updating and WebCode created a replacement card, the old card could remain stuck at `处理中`. + - In card-action flows, a background-created replacement card could also miss its final `已完成` transition and completion text notification, leaving both the old and the new card looking unfinished. +- Root cause: + - `FeishuStreamingCardSession` switched `CurrentHandle` to the replacement handle but did not perform any best-effort terminal write on the superseded handle, so the old card never left the running state when its last update had already been rendered. + - In `FeishuCardActionService`, status-pulse and external-history backfill updates passed the background loop cancellation token into `cardSession.UpdateAsync(...)`. + - If a background writer created the replacement handle shortly before normal shutdown, that replacement handle could inherit a token that was already being canceled for loop teardown, and its later final `FinishAsync(...)` / completion-notification path would be skipped. +- Working rule: + - Once WebCode successfully creates a replacement Feishu streaming card, it should best-effort finish the superseded card with a stopped-state handoff message that points users to the new card. + - Background loop shutdown tokens must control only the loop lifetime. + - Replacement-handle creation and later card finish writes must use the execution-lifetime card-write token so normal completion can still succeed after background writers stop. +- Current implementation: + - Shared replacement-card handoff text now lives in [FeishuStreamingReplacementFormatter.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingReplacementFormatter.cs:1). + - `FeishuStreamingCardSession` now accepts a stopped-handle finalizer callback and invokes it after replacement-handle creation in [FeishuStreamingCardSession.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs:1). + - Channel-side replacement-card handoff finishing now lives in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1). + - Card-action replacement-card handoff finishing and background card-write token separation now live in [FeishuCardActionService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs:1). + - Regression coverage lives in [FeishuChannelServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs:1) and [FeishuCardActionServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs:1). +- Search hints: + - `FeishuStreamingReplacementFormatter` + - `TryFinishReplacementStreamingCardAsync` + - `WhenBackgroundReplacementHandleUsesCanceledToken` + +## Feishu CardKit `200770 this UUID has been recently consumed` should be treated like a timeout-confirmed prior success when it immediately follows the same-sequence retry + +- Symptom: + - Feishu streaming could stop after a card-update timeout even when the retry immediately came back with: + - `code: 200770` + - `this UUID has been recently consumed` + - The visible log pattern was timeout on one `sequence`, then a retry for the same `cardId + sequence + uuid`, followed by WebCode marking the update as failed and eventually stopping card output. +- Root cause: + - `FeishuCardKitClient.UpdateCardCoreAsync(...)` already treated `300317 sequence number compare failed` after a recoverable timeout as evidence the prior write likely already succeeded. + - The same method still treated `200770 duplicate uuid` as a hard business failure even though WebCode uses a deterministic `uuid` per `cardId + sequence`, so that response can also mean Feishu already consumed the original timed-out write. +- Working rule: + - If a card update times out without caller cancellation and the immediate retry for the same `cardId + sequence + uuid` returns either: + - `300317 sequence number compare failed`, or + - `200770 this UUID has been recently consumed` + then treat that as a same-card success signal for the prior write instead of stopping the stream. +- Current implementation: + - Retry-aware duplicate-uuid handling now lives in [FeishuCardKitClient.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs:1). + - Regression coverage lives in [FeishuCardKitClientTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs:1). +- Search hints: + - `CardUpdateDuplicateUuidCode` + - `TreatsTimeoutThenDuplicateUuidAsSuccessfulPriorWrite` + +## Codex goal-runtime app-server transport must outlive individual `/goal` command cancellation tokens + +- Symptom: + - In a live Codex goal-runtime session, clicking `/goal` or `/goal pause` after an earlier goal turn had already started could fail with: + - `查询 goal 状态失败: A task was canceled.` + - `暂停 goal 失败: A task was canceled.` + - The visible stack landed in `CodexAppServerSessionManager.SendRequestAsync(...)` while reusing an existing session and thread. +- Root cause: + - `CodexAppServerSessionManager.CreateSessionAsync(...)` started the app-server stdout/stderr reader loops with the current command's cancellation token. + - Feishu goal quick actions create a fresh streaming execution and cancel the previous one for the same session before reusing the live app-server runtime. + - That per-command cancellation stopped the shared reader loops even though the app-server process itself was still alive, so later `thread/goal/get`, `turn/interrupt`, or `thread/goal/set` requests had no reader to complete their pending responses. + - `_sessions` then still considered the process reusable because it only checked `Process.HasExited`. +- Working rule: + - The app-server transport reader loops are session-lifetime infrastructure, not turn-lifetime infrastructure. + - Only disposing or explicitly cleaning up the app-server session should stop those reader loops. + - Session reuse must also reject zombie transports where the process is still running but the stdout/stderr reader tasks have already completed. +- Current implementation: + - `CodexAppServerSessionManager` now owns a session-level cancellation source per app-server session and uses it for stdout/stderr reader loops in [CodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs:1). + - Existing sessions are now considered reusable only when both the process and the transport reader tasks are still alive. + - Regression coverage for canceling the original request token and then reusing the same live session lives in [CodexAppServerSessionManagerTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs:1). +- Search hints: + - `GetGoalAsync_WhenOriginalRequestTokenIsCanceled_KeepsLiveSessionReadable` + - `SessionCancellationTokenSource` + - `IsHealthy` + +## Codex `/goal resume` must clear the local active-turn cache as soon as `turn/interrupt` succeeds + +- Symptom: + - Clicking `/goal resume` could first show a normal recovery message like: + - `已恢复 goal,正在继续推进...` + - But then the restarted goal-runtime turn failed immediately with: + - `Codex goal runtime 执行失败: 当前 app-server 运行中已有一个 turn,无法再启动新的 turn。` +- Root cause: + - This was not a thread-id reuse problem. + - WebCode was already reusing the same live app-server session and thread, and `turn/interrupt` could succeed against that thread. + - `CodexAppServerSessionManager.StartTurnAsync(...)` still pre-checked the locally cached `session.ActiveTurnId` before issuing the next `turn/start`. + - When app-server accepted `turn/interrupt` but had not yet emitted a later `turn/completed` notification, WebCode kept the stale `ActiveTurnId` in memory and blocked its own immediate restart path. +- Working rule: + - A successful `turn/interrupt` is authoritative enough to clear WebCode's local `ActiveTurnId` cache immediately. + - WebCode must not wait for a later streamed completion event before allowing the next goal-runtime `turn/start`. + - The same rule also applies after the one-time retry path that repairs `expected active turn id ... but found ...` mismatch errors. +- Current implementation: + - `InterruptActiveTurnCoreAsync(...)` now clears `session.ActiveTurnId` immediately after a successful interrupt request, including the mismatch-retry branch, in [CodexAppServerSessionManager.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs:1). + - Regression coverage for interrupt-without-completion then immediate restart lives in [CodexAppServerSessionManagerTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs:1). +- Search hints: + - `StartTurnAsync_WhenInterruptSucceededWithoutCompletionNotification_AllowsImmediateRestart` + - `InterruptActiveTurnCoreAsync` + - `ActiveTurnId` diff --git a/docs/agent-notes/2026-05-27.md b/docs/agent-notes/2026-05-27.md new file mode 100644 index 0000000..f366821 --- /dev/null +++ b/docs/agent-notes/2026-05-27.md @@ -0,0 +1,179 @@ +# 2026-05-27 Agent Notes + +## Feishu goal-runtime streaming now treats each app-server turn as a new card lifecycle, while replacement-card recovery is bounded at ten attempts + +- Context: + - Goal-runtime streaming was previously designed to keep one Feishu card alive across multiple app-server turns as long as the goal snapshot stayed `active`. + - Replacement-card recovery was separately bounded to one replacement per logical stream. +- New working rule: + - In goal-runtime Feishu flows, each app-server `turn` is its own card lifecycle. + - When one turn ends and the goal is still `active`, WebCode should finalize the current turn card and continue the next turn on a fresh card. + - Goal-runtime quick actions belong only to the newest active card, but continue targeting the same underlying session/thread/runtime state. + - Replacement-card recovery remains bounded, but the limit is now ten attempts per logical stream instead of one. +- Why this matters: + - Turn-per-card avoids one long-lived card accumulating multiple rounds and makes fallback/recovery independent from turn boundaries. + - Raising the replacement limit prevents normal streaming and goal-runtime streaming from failing permanently after a single card disconnect. +- Search hints: + - `GoalTurnBoundary` + - `MaxReplacementCardsPerLogicalStream` + - `turn-per-card` + +## Goal turn-per-card implementation reuses the same runtime/thread but rotates only the Feishu card handle + +- Confirmed implementation: + - `CliExecutorService` emits `StreamOutputChunk.IsTurnBoundary = true` when one app-server turn ends and the goal snapshot is still `active`. + - Feishu channel/card-action consumers treat that boundary as a pure presentation handoff: finish the current card, clear its bottom prompts/actions, create a fresh streaming card, and continue on the same session/runtime/thread. + - `/goal`, `/goal pause`, `/goal clear`, `/goal resume`, and temporary-exit actions remain attached only to the newest active goal-runtime card. +- Verified behavior: + - goal-runtime turn handoff does not create a new session or new CLI thread id + - ordinary streaming-card replacement recovery still works independently of turn handoff + - replacement-card attempts are allowed up to ten times per logical stream + +## Feishu streaming regression tests must now model ten replacement attempts and old-card transfer finalization + +- Context: + - Several existing Feishu tests were written when a logical stream could replace only one stopped card. + - The shared replacement path now finalizes the stopped card with `FeishuStreamingReplacementFormatter.BuildTransferredContent(...)` before moving to the next handle. +- Practical test rule: + - If a test wants to verify the terminal "娴佸紡鏇存柊鏂繛" branch, it must exhaust the full replacement budget instead of failing only one or two handles. + - If a test observes external-history backfill or mid-stream replacement, the old handle should usually end with the transfer message instead of `null` final content. +- Search hints: + - `BuildTransferredContent` + - `MaxReplacementCardsPerLogicalStream` + - `椋炰功娴佸紡鏇存柊鏂繛` + +## Codex final-only reply TTS must preserve assistant message phase instead of guessing a conclusion from merged text + +- Context: + - Local Codex rollout files record assistant `message` items with distinct phases such as `commentary` and `final_answer`. + - The current WebCode Codex streaming path preserves assistant text but drops the `phase` before Feishu completion builds the merged assistant output. + - Existing Feishu reply TTS therefore can only speak the merged completed reply text. +- Working rule: + - `结论语音回复` should mean "speak the current turn's structured `final_answer` content only". + - Do not implement this with text slicing, regex extraction, or summary heuristics over merged assistant text. + - Preserve `phase` in the structured streaming event model, build a separate `final_answer` text buffer per turn, and let reply-TTS mode choose between full text and final-only text. + - If a turn has no structured `final_answer`, `FinalOnly` mode must skip audio silently rather than falling back to the merged reply text. +- Why this matters: + - Commentary text like progress updates or planning questions can otherwise be spoken as if they were the conclusion. + - Goal-runtime now uses turn-per-card, so one turn's structured final answer is the correct speech boundary. +- Search hints: + - `final_answer` + - `AssistantPhase` + - `ReplyTtsMode` + +## Feishu reply TTS now carries both merged assistant text and final-answer-only text in parallel + +- Confirmed implementation: + - `FeishuCompletedReplyTtsRequest` now carries both `Output` and `FinalAnswerOutput`. + - `ReplyTtsOrchestrator` resolves the effective mode with `ReplyTtsModes.Resolve(...)` instead of reading only the legacy boolean. + - `FinalOnly` mode normalizes and speaks only `FinalAnswerOutput`; when that field is empty, it returns silently and does not fall back to merged assistant text. + - `FeishuChannelService` and `FeishuCardActionService` now maintain two assistant buffers per turn: one for all assistant text and one filtered to `AssistantPhase == "final_answer"`. +- Important constraint: + - For Codex JSONL, assistant speech text is sourced from `item.type == "agent_message"`. A plain `assistant` item shape is not enough to drive the current TTS/assistant-text extraction path. + - `FinalAnswerOutput` must come only from structured `agent_message` items tagged with `phase = final_answer`; do not reuse fallback extraction or external-history fallback for this field. +- Search hints: + - `FinalAnswerOutput` + - `ProcessJsonlLine` + - `QueueCompletedReplyAsync` + +## Codex final-only reply TTS fallback is session-bound and reuses only the latest rollout `assistant final_answer` + +- Confirmed implementation: + - `ReplyTtsOrchestrator` always prefers the live `FinalAnswerOutput` buffer for `FinalOnly` mode. + - Rollout fallback is attempted only when `FinalOnly` is enabled and the live final-answer buffer is empty. + - The fallback path requires `SessionId`, a resolvable chat session, effective tool resolution to Codex, and a non-empty `CliThreadId`. + - `ExternalCliSessionHistoryService.GetCodexFinalAnswerTextAsync(...)` reuses the existing rollout-file resolution path and returns only the latest assistant `message` whose `phase` is `final_answer`. + - Normal Feishu completion must pass `SessionId` into `FeishuCompletedReplyTtsRequest`; card-action completion already carried that context. +- Important constraint: + - `FullReply` mode never uses rollout fallback. + - Empty fallback results and fallback exceptions both degrade to a silent skip; they do not fall back to merged assistant text. +- Search hints: + - `GetCodexFinalAnswerTextAsync` + - `SessionLaunchOverrideHelper.ResolveEffectiveToolId` + - `SessionId` + +## Feishu local reply-TTS startup must merge post-start health before reporting runtime capabilities + +- Context: + - `FeishuReplyTtsPlatformService.EnsureServiceStartedAsync(...)` could successfully start the local TTS service while still returning stale pre-start health metadata. + - Tests that validate `Device` and `DefaultVoiceId` therefore failed even though the service start path itself succeeded. +- Working rule: + - After a successful local service start, re-read `_ttsClient.GetHealthAsync(...)` and merge the fresh runtime health into the returned capability snapshot. +- Why this matters: + - Capability-dependent UI and reply-TTS tests need the post-start device and default voice information, not the bootstrap-time placeholder state. +- Search hints: + - `EnsureServiceStartedAsync` + - `GetHealthAsync` + - `DefaultVoiceId` + +## Admin Feishu-bot save DTO must keep `ReplyTtsMode` nullable to preserve legacy boolean compatibility + +- Context: + - The admin save endpoint still supports legacy callers that may send only `ReplyTtsEnabled` without `ReplyTtsMode`. + - If `UserFeishuBotConfigDto.ReplyTtsMode` defaults to `Off` on the server model, model binding fabricates an explicit mode and prevents `ReplyTtsModes.Resolve(...)` from using the legacy boolean fallback. +- Working rule: + - Request DTO `ReplyTtsMode` must remain nullable at the controller boundary. + - The controller may explicitly populate `ReplyTtsMode = Off` in response DTOs and empty-config responses, but it must not force `Off` as the request-model default. +- Why this matters: + - Legacy admin save requests with `ReplyTtsEnabled = true` must still map to `FullReply` and trigger reply-TTS service startup. +- Search hints: + - `UserFeishuBotConfigDto` + - `SaveFeishuBotConfig` + - `ReplyTtsModes.Resolve` + +## Feishu goal turn-boundary reply-TTS must queue the current turn before clearing streaming buffers + +- Confirmed implementation: + - In both `FeishuChannelService` and `FeishuCardActionService`, goal-runtime `StreamOutputChunk.IsTurnBoundary` now queues completed reply TTS before resetting per-turn builders. + - The queued request must use the current turn's merged assistant text plus the current turn's `final_answer`-only text. + - Only after queueing should the code clear turn-local assistant buffers and rotate to the next Feishu card. +- Why this matters: + - Clearing the builders first silently drops audio for the completed turn. + - Goal-runtime now treats each app-server turn as its own speech boundary, so queue timing is part of correctness, not just cleanup order. +- Search hints: + - `IsTurnBoundary` + - `turnAssistantMessageBuilder` + - `FinalAnswerOutput` + +## Filtered Feishu help cards need the same `replyTtsMode` top-row controls as the main help card + +- Confirmed implementation: + - `FeishuHelpCardBuilder.BuildFilteredCardV2(...)` must accept `replyTtsMode` and render the mutually exclusive `语音回复` / `结论语音回复` buttons. + - `FeishuMessageHandler` must pass the resolved mode through filtered-card call sites, not only the main help-card path. +- Why this matters: + - Without this parameter flow, filtered help cards regress to stale or missing reply-TTS button state even when the main help card is correct. +- Search hints: + - `BuildFilteredCardV2` + - `replyTtsMode` + - `ToggleFinalOnlyReplyTtsAction` + +## Windows Codex app-server tests are more stable when shim files and working directory are separated + +- Context: + - `CodexAppServerSessionManagerTests` launches a fake app-server through `fake-codex.cmd -> powershell.exe -File fake-codex-app-server.ps1`. + - Reusing the same temp root for both shim files and `WorkingDirectory` can leave transient Windows directory locks during cleanup. +- Working rule: + - In these tests, keep the shim files under `tempRoot` but run the app-server with a dedicated `tempRoot\\workspace` working directory. + - Test cleanup may still use a short retry loop around `Directory.Delete(...)` to absorb residual Windows handle-release jitter. +- Why this matters: + - This avoids false negatives where the behavior under test passes but `finally` fails with `IOException` while deleting the temp directory. +- Search hints: + - `CreateFakeCodexAppServerShimAsync` + - `WorkingDirectory` + - `DeleteDirectoryWithRetry` + +## Parallel runs of the same .NET test project can produce false cleanup failures in reply-TTS temp-audio tests + +- Context: + - `ReplyTtsOrchestratorTests` writes temp audio chunks such as `chunk-001.wav` under a per-harness temp root and deletes that root during `Dispose()`. + - Running two `dotnet test` commands against `WebCodeCli.Domain.Tests.csproj` in parallel can trigger transient Windows file-handle contention even when each command uses a different `OutDir`. +- Working rule: + - Do not treat `UnauthorizedAccessException` on `chunk-001.wav` during harness cleanup as feature-regression evidence until the same test fails again in a serial rerun. + - For reply-TTS verification evidence, rerun the relevant `WebCodeCli.Domain.Tests` filter serially when a parallel run reports only cleanup-time temp-file access errors. +- Why this matters: + - The feature behavior can be correct while the test process still reports a failure caused only by Windows handle-release timing between concurrent test-host processes. + - A serial rerun cleanly distinguishes product regressions from verification-environment noise. +- Search hints: + - `ReplyTtsOrchestratorHarness` + - `chunk-001.wav` + - `QueueCompletedReplyAsync_WhenFinalOnlyLiveTextMissing_UsesCodexRolloutFallback` diff --git a/docs/agent-notes/2026-05-28.md b/docs/agent-notes/2026-05-28.md new file mode 100644 index 0000000..718fad1 --- /dev/null +++ b/docs/agent-notes/2026-05-28.md @@ -0,0 +1,51 @@ +## Feishu completed-reply delivery now creates cloud documents and sends plain text links + +- The old reply-TTS completion pipeline was repurposed into a reply-document pipeline instead of being rebuilt from scratch. +- Full and final reply documents stay turn-scoped and independent: + - `完整回复文档` writes the merged assistant reply body. + - `结论回复文档` writes only structured `final_answer` content. +- Successful document generation sends a separate plain text Feishu chat message with the cloud doc link rather than injecting links into cards. + +## Final-reply document fallback stays narrow and thread-aware + +- `结论回复文档` still prefers the live `FinalAnswerOutput` buffer. +- Codex rollout fallback is only used when that live final buffer is empty, and only for the final document path. +- Title generation prefers the runtime `CliThreadId`; if missing, it falls back to `SessionId`. + +## Goal-runtime turn-boundary reply documents must queue before buffer rotation + +- Both `FeishuChannelService` and `FeishuCardActionService` preserve the existing ordering: + - queue completed reply side effects for the current turn + - then clear turn-local buffers / rotate presentation state +- In card-action goal-runtime flows, the effective thread id follows the parsed `thread.started` event because runtime cache is updated during adapter parsing. + +## Legacy reply-TTS config fields remain only as compatibility shims + +- Admin/config persistence now uses `FullReplyDocEnabled` and `FinalReplyDocEnabled`. +- Legacy `ReplyTtsEnabled` / `ReplyTtsMode` / `ReplyTtsVoiceId` fields are still backfilled for DB and request compatibility, but voice-specific behavior is removed. + +## Verification snapshot + +- Focused reply-document domain suite previously passed at `195` tests during migration. +- On this checkpoint, `dotnet build WebCodeCli.sln -c Debug` passes with warnings and no compile errors. +- Final focused verification for the Feishu reply-document migration passed on this branch: + - `dotnet test WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~WebCodeCli.Domain.Tests.ReplyDocumentOrchestratorTests"` => `7` passed + - `dotnet test WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~WebCodeCli.Domain.Tests.FeishuCardKitClientTests.CreateCloudDocumentAsync|FullyQualifiedName~WebCodeCli.Domain.Tests.FeishuCardKitClientTests.AppendCloudDocumentTextAsync|FullyQualifiedName~WebCodeCli.Domain.Tests.FeishuCardKitClientTests.SetCloudDocumentTenantReadableAsync"` => `3` passed + - `dotnet test WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~HandleIncomingMessageAsync_QueuesReplyDocument|FullyQualifiedName~DoesNotQueueReplyDocument|FullyQualifiedName~HandleCardActionAsync_ExecuteCommand_QueuesReplyDocument|FullyQualifiedName~HandleCardActionAsync_LowInterruptionContinue_QueuesReplyDocument"` => `7` passed + - `dotnet test WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~WebCodeCli.Domain.Tests.FeishuHelpCardBuilderTests.BuildCommandListCard_IncludesReplyDocumentButtons|FullyQualifiedName~WebCodeCli.Domain.Tests.FeishuHelpCardBuilderTests.BuildFilteredCardV2_IncludesReplyDocumentButtons|FullyQualifiedName~WebCodeCli.Domain.Tests.ExternalCliSessionHistoryServiceTests.GetCodexFinalAnswerTextAsync"` => `6` passed + - `dotnet test tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~WebCodeCli.Tests.AdminControllerReplyDocumentTests|FullyQualifiedName~WebCodeCli.Tests.AdminUserManagementReplyDocumentModeTests|FullyQualifiedName~WebCodeCli.Tests.AdminUserManagementModalStateTests|FullyQualifiedName~WebCodeCli.Tests.PublishOutputConfigurationTests"` => `13` passed +- Web test execution initially failed because an old `WebCodeCli` process locked `WebCodeCli.Domain.dll` / `.pdb`; stopping the stale process and rerunning resolved the issue without code changes. + +## Windows packaging was restored in a non-TTS form + +- `tools/build-windows-installer.ps1` and `installer/windows/WebCode.iss` were restored after the TTS cleanup so the repo keeps its normal Windows packaging path. +- The restored packaging flow still builds: + - `publish/` + - portable zip + - Inno Setup installer + - `SHA256SUMS.txt` + - `RELEASE_NOTES.md` +- TTS-specific bundle handling was removed from the packaging path: + - no `tts-bundle/` + - no Kokoro/sherpa service copy + - no ffmpeg / Python payload assembly diff --git a/docs/agent-notes/2026-05-29.md b/docs/agent-notes/2026-05-29.md new file mode 100644 index 0000000..6cbbbf5 --- /dev/null +++ b/docs/agent-notes/2026-05-29.md @@ -0,0 +1,52 @@ +## Goal-runtime Feishu streaming should defer replacement-card creation until the next real output chunk after a card update failure + +- Context: + - Goal-runtime Feishu cards keep `/goal pause`, `/goal clear`, `/goal resume`, and related controls on the currently visible card. + - Immediately creating a replacement card after a card update failure can fail again during Feishu instability and needlessly discards the old goal-control surface before there is any new content to show. +- Working rule: + - For goal-runtime Feishu streaming only, a card update failure should mark the current card session as `replacement pending` instead of creating a replacement card immediately. + - Background status pulses and external-history backfill must not activate that pending replacement. + - Only the next foreground assistant-output update may activate replacement-card creation. + - If the turn finishes before another output chunk arrives, final completion may still create one last replacement card so the completed result is not lost. +- Current implementation: + - `FeishuStreamingCardSession` now supports deferred replacement state via `deferReplacementUntilNextForegroundUpdate`, `HasPendingReplacement`, and `allowPendingReplacementActivation` in [FeishuStreamingCardSession.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs:1). + - `FeishuCardActionService` enables deferred replacement only for goal-runtime sessions and prevents status-pulse / external-history writers from activating replacement in [FeishuCardActionService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs:1). + - `FeishuChannelService` applies the same gating on the channel-side streaming path in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1). + - Regression coverage lives in [FeishuStreamingCardSessionTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuStreamingCardSessionTests.cs:1), [FeishuCardActionServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs:1768), and [FeishuChannelServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs:4401). +- Search hints: + - `deferReplacementUntilNextForegroundUpdate` + - `allowPendingReplacementActivation` + - `DelaysReplacementUntilNextNewOutput` + +## Feishu inline-image submissions to Codex one-time exec must mirror the prompt to stdin + +- Context: + - Feishu `post` messages that contain inline images plus text are converted into prepared submissions with native image attachments and a non-empty `CliExecutionRequest.PromptText`. + - The prepared submission path was correct, but Codex one-time execution still failed with `Reading prompt from stdin...` and `No prompt provided via stdin.` when the request included `-i `. +- Confirmed root cause: + - The prompt was not lost in `FeishuChannelService` or `MessageSubmissionService`. + - WebCode launches one-time Codex exec on Windows through the normal adapter argument path, then rewrites `codex.cmd` to `node codex.js`. + - In current Codex builds, one-time `exec -i ...` may ignore the positional prompt and require the prompt on stdin instead. + - Before the fix, `CliExecutorService.BuildStandardInput(...)` always returned `null` for non-low-interruption executions, so the process started with an empty stdin stream and Codex exited immediately. +- Working rule: + - For Codex one-time exec/resume launches, WebCode should write `CliExecutionRequest.BuildPromptText()` to stdin even when the prompt is also present in the command arguments. + - Low-interruption continue must remain unchanged and must not inject a continuation prompt into stdin for Codex. +- Current implementation: + - `CliExecutorService.BuildStandardInput(...)` now returns `request.BuildPromptText()` for non-low-interruption Codex executions in [CliExecutorService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs:2918). + - Regression coverage lives in [CliExecutorServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs:1764) and [CliExecutorServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs:1853). +- Search hints: + - `ExecuteStreamAsync_ForCodexOneTimeProcess_WritesPromptToStandardInput` + - `RecordingCodexOneTimeInputAdapter` + - `No prompt provided via stdin` + +## Feishu reply documents now support four independent document toggles + +- `完整回复文档` and `结论回复文档` still keep the raw body behavior unchanged. +- `听完整文档` and `听结论文档` are additional independent outputs and can be enabled at the same time as the raw document variants. +- Listening variants run a formatter before upload: + - file-like references are rewritten to `文件内容N` + - the original matched references are appended as an appendix mapping at the end of the document body +- Search hints: + - `AudioFullReplyDocEnabled` + - `AudioFinalReplyDocEnabled` + - `ListeningReplyDocumentFormatter` diff --git a/docs/agent-notes/2026-05-30.md b/docs/agent-notes/2026-05-30.md new file mode 100644 index 0000000..68cca1b --- /dev/null +++ b/docs/agent-notes/2026-05-30.md @@ -0,0 +1,41 @@ +## Listening reply-document formatter must treat bare filenames and relative paths as full file references + +- Context: + - Feishu listening reply documents rewrite file-like references into `文件内容N` placeholders so the document is easier to consume with document voice playback. + - Real assistant replies often contain Markdown link labels such as `mmis-page-metadata-operations/skill.md` and bare filenames such as `page-metadata-ops.ps1`, not only absolute paths like `/D:/repo/a.cs:1`. +- Confirmed root cause: + - The original `ListeningReplyDocumentFormatter` regex only matched references that started with `/` or `\`. + - That caused relative paths to be truncated from the first slash onward, for example `mmis-page-metadata-operations/skill.md` became `mmis-page-metadata-operations文件内容1`. + - Bare filenames with an extension, such as `page-metadata-ops.ps1` and `authoring-first-automation.md`, were skipped entirely. +- Working rule: + - Any contiguous ASCII file-like token that ends with an extension should be treated as a single file reference when it is composed of letters, digits, `.`, `_`, `-`, `/`, or `\`. + - This includes: + - bare filenames like `page-metadata-ops.ps1` + - relative paths like `docs/agent-notes/2026-05-29.md` + - Windows-style relative paths like `WmsServerV4\src\...\DeliveringBillServiceCus1397.cs` + - `/D:/...` style absolute paths emitted by local markdown file links +- Current implementation: + - `ListeningReplyDocumentFormatter` now matches bare filenames, relative paths, Windows-style relative paths, and `/D:/...` absolute paths as a single reference in [ListeningReplyDocumentFormatter.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/ListeningReplyDocumentFormatter.cs:1). + - Regression coverage lives in [ListeningReplyDocumentFormatterTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs:1). +- Additional rule for listening reply documents: + - Fenced command blocks such as ```powershell ... ``` should be replaced in the main body with `[命令内容N]`. + - The original command lines should be appended at the end as `命令内容N:...`. + - Non-command fenced code blocks must be preserved verbatim so code samples are not rewritten by the file placeholder matcher. +- Search hints: + - `Format_ReplacesRelativePathAndBareFileNames_InMarkdownLinkText` + - `Format_ReplacesWindowsAndUnixStyleRelativePaths_AsSingleFileReference` + - `Format_ReplacesFencedPowerShellBlock_WithCommandPlaceholderAndAppendsCommandsAtEnd` + - `page-metadata-ops.ps1` + +## WebCode Windows release packaging must follow Feishu reply-document delivery, not legacy TTS assumptions + +- Context: + - The product behavior has moved from Feishu reply TTS playback to Feishu cloud reply documents that can be listened to with Feishu document audio. + - Release-facing documentation and generated release notes must describe the document-based flow rather than the removed TTS feature. +- Confirmed constraints: + - `Directory.Build.props` must be bumped before publishing a new GitHub release because the publish flow uses the version tag as the release identity. + - Reusing an existing tag such as `v0.2.10` for new installer assets is not valid for a new product state; use a new patch version instead. + - Windows release notes generated by `tools/build-windows-installer.ps1` should mention Feishu cloud documents and document audio, not reply TTS. +- Working rule: + - Treat Feishu reply documents as the shipped user-facing replacement for reply TTS. + - Release docs should state that the cloud document link records the reply and that playback happens through Feishu document audio. diff --git a/docs/agent-notes/2026-06-01.md b/docs/agent-notes/2026-06-01.md new file mode 100644 index 0000000..86b22f7 --- /dev/null +++ b/docs/agent-notes/2026-06-01.md @@ -0,0 +1,54 @@ +## Listening reply-document formatter must extract standalone markdown command lines without touching inline prose + +- Context: + - `听完整文档` and `听结论文档` already rewrote fenced command blocks into `命令内容N` placeholders and appended the original commands at the end. + - Real reply logs also contain verification commands as standalone markdown lines, especially list items under sections such as `**验证**`, for example `- \`dotnet build ...\``. +- Confirmed root cause: + - `ListeningReplyDocumentFormatter` only extracted fenced command blocks. + - Standalone command lines were left in the main body, so document audio still read full command text inline. +- Working rule: + - Extract a command into `命令内容N` when the entire line or the entire markdown list item is the command. + - Support both backticked and non-backticked command-only lines. + - Do not rewrite inline command snippets inside prose, for example `运行 \`dotnet build\` 即可`. +- Implementation notes: + - Keep the existing fenced code block extraction unchanged. + - Add a second pass that scans each original line while preserving original newline characters. + - Reuse the same command placeholder ordering and appendix format as fenced command blocks. +- Regression coverage: + - `Format_ReplacesMarkdownListCommandItems_WithCommandPlaceholdersAndAppendsCommandsAtEnd` + - `Format_ReplacesStandaloneCommandLines_WithoutTouchingInlineCommandProse` + - Both live in [ListeningReplyDocumentFormatterTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs:1). + +## Feishu `Goal执行` must switch the session into goal runtime before building the streaming card footer + +- Context: + - The superpowers `Goal执行` button is routed through the goal quick-action path and submits a fixed `/goal ...` prompt. + - Users expect the resulting streaming reply card footer to show only goal-runtime controls: goal input plus `/goal`, `/goal pause`, `/goal clear`, `/goal resume`, and related goal-runtime actions. +- Confirmed root cause: + - `FeishuCardActionService.HandleGoalQuickActionAsync(...)` probed goal capability and then immediately called `HandleExecuteCommandAsync(...)`. + - The streaming card footer is built before Codex runtime execution updates session state, and footer rendering decides goal-only vs superpowers controls from the persisted session launch override `UseGoalRuntime`. + - For `Goal执行`, sessions that were not already marked `UseGoalRuntime = true` still rendered the ordinary superpowers bottom prompt/workflow section on the first card. +- Working rule: + - Before starting any Feishu goal quick action that should execute in Codex goal runtime, persist `UseGoalRuntime = true` on the current session first. + - This keeps the first streaming card in the same visual/control mode as later goal-runtime turns. +- Implementation notes: + - Reuse `SessionLaunchOverrideHelper.ApplyGoalRuntimeOverride(..., true)` in `FeishuCardActionService` instead of introducing a separate UI-only flag. + - Limit the eager override enablement to Codex sessions, matching existing goal-runtime support boundaries. +- Regression coverage: + - `HandleCardActionAsync_ExecuteSuperpowersGoalPlan_StreamingCardShowsGoalOnlyFooter` + - Lives in [FeishuCardActionServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs:1). + +## Feishu superpowers `继续` button should send an explicit user-approval prefix before the fixed resume instruction + +- Context: + - The streaming-card `继续` button sends a fixed backend prompt through `SuperpowersPromptBuilder.BuildContinuePrompt()`. + - Users wanted the prompt to explicitly acknowledge approval before the existing resume instruction reaches the CLI. +- Working rule: + - The fixed continue prompt must start with `可以,认可。` + - Keep the existing Codex resume instruction and language-policy suffix unchanged after that prefix. +- Implementation notes: + - The source of truth remains `SuperpowersQuickActionDefaults.ContinuePrompt` in [SuperpowersQuickActionDefaults.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs:1). + - `SuperpowersPromptBuilder.BuildContinuePrompt()` and the Feishu `continue_superpowers` action both reuse that constant, so changing the constant updates both code paths consistently. +- Regression coverage: + - `BuildContinuePrompt_ReturnsApprovedPrompt` + - `HandleCardActionAsync_ContinueSuperpowers_UsesFixedPromptAndSkipsCapabilityProbe` diff --git a/docs/agent-notes/2026-06-02.md b/docs/agent-notes/2026-06-02.md new file mode 100644 index 0000000..ab7a6dd --- /dev/null +++ b/docs/agent-notes/2026-06-02.md @@ -0,0 +1,96 @@ +## Feishu reply cloud documents now land in session-scoped folders + +- `ReplyDocumentOrchestrator` now resolves a target Feishu folder name before appending reply content: + - prefer `ChatSessionEntity.Title` + - if the title is blank or treated as `未命名`, fall back to `CliThreadId` + - if the thread id is blank, fall back to `SessionId` +- Folder-name policy stays in the orchestrator, while Feishu Drive API details stay in `IFeishuCardKitClient` / `FeishuCardKitClient`. +- The Feishu client now: + - reads the root folder token from `/open-apis/drive/explorer/v2/root_folder/meta` + - lists existing root children via `/open-apis/drive/v1/files` + - creates a folder with `/open-apis/drive/v1/files/create_folder` when needed + - moves the generated doc with `/open-apis/drive/v1/files/{file_token}/move` +- Reply-document regression tests needed updated wait conditions after folder placement was added, because document creation is no longer the last async step before append/send. + +## Goal temporary-exit now auto-pauses before leaving runtime + +- The Feishu `temporarily_exit_goal_runtime` action no longer requires the operator to manually click `/goal pause` first. +- When the session is still in goal runtime and the conflict only comes from an active app-server goal turn, the temporary-exit handler now issues the same `/goal pause` command path used by the goal quick action, then disables `UseGoalRuntime`. +- The temporary-exit semantics remain non-destructive after pause succeeds: + - no runtime reset + - no CLI thread clear + - no session/thread detachment +- If the Feishu session itself is already marked as executing another task, temporary exit still refuses to proceed and returns a warning that auto-pause cannot start yet. + +## Reply-document permission failures now recognize `drive:*` scopes + +- `ReplyDocumentOrchestrator` already had a text fallback path for document-generation failures, but its friendly permission-scope extraction only recognized `docx:*`. +- Folder placement introduced new failure points under Feishu Drive APIs (`EnsureCloudFolderAsync`, `MoveCloudDocumentToFolderAsync`), and those can return `99991672` with required scopes such as `drive:drive` and `drive:drive.metadata:readonly`. +- The scope extractor now recognizes both `docx:*` and `drive:*` permission tokens when the error text indicates a scope/permission denial, so the user-facing message becomes: + - missing Feishu document permission + - required scopes listed explicitly + - retry guidance +- This keeps the existing fallback send-text path and only improves how permission-denied document errors are summarized for the chat. + +## Reply-document `1061003 not found` errors now use a friendly chat fallback + +- Some reply-document failures come back from Feishu Drive as `Status=NotFound` / business code `1061003` with a bare `not found.` message. +- Those failures can happen after document creation has already started, for example while resolving folders or moving files. +- `ReplyDocumentOrchestrator.BuildFailureMessage(...)` now detects that not-found signature and sends a friendly reply like: + - the Feishu document resource does not exist or has become invalid + - retry later + - if it keeps happening, check whether the target document or folder was deleted +- The fallback intentionally avoids echoing the raw `Status=NotFound` / JSON payload back into the chat. + +## Reply-document permission failures now echo Feishu scope-opening guidance + +- Feishu `99991672` permission errors may already include operator-facing guidance in the raw `msg`, for example: + - `应用尚未开通所需的应用身份权限:[...],点击链接申请并开通任一权限即可:https://open.feishu.cn/...` +- `ReplyDocumentOrchestrator.BuildFailureMessage(...)` now preserves the existing friendly permission summary and appends that extracted Chinese guidance when present, so the fallback chat reply tells the operator both: + - which scopes are missing + - where to open the required app identity permission +- This keeps the not-found handling and generic fallback unchanged while making Drive permission failures actionable directly from the Feishu chat. + +## Codex one-time exec/resume prompts that start with `-` must be separated by `--` + +- WebCode launches Codex one-time turns by passing the prompt as a positional CLI argument after `exec` or `exec resume`. +- When the prompt itself starts with `- `, for example a multiline bullet list copied from Feishu, Codex/Rust argument parsing treats that prompt as another option unless the command inserts the standard `--` argument terminator first. +- `CodexAdapter` default one-time templates now emit: + - `exec ... -- "{prompt}"` + - `exec resume ... -- "{prompt}"` +- Native image arguments such as `-i ` must remain before that terminator, so attachment injection now places `-i` flags ahead of `--` instead of after it. +- Regression coverage lives in: + - `CliExecutorServiceTests.CodexAdapter_BuildArguments_WhenResuming_UsesExecResumeSyntax` + - `CliExecutorServiceTests.CodexAdapter_BuildArguments_WhenPromptStartsWithDash_InsertsArgumentTerminator` + - `CliExecutionRequestAdapterTests.CodexAdapter_BuildArguments_WithNativeAttachmentAndDashPrefixedPrompt_PlacesAttachmentsBeforeArgumentTerminator` + +## Reply-document `1061003 not found` failures should distinguish folder lookup vs move stage + +- After reply documents started landing in session-scoped Feishu folders, the old fallback message for `Status=NotFound` / business code `1061003` became too generic. +- The actual failure can happen in different Drive stages: + - resolving or creating the session folder + - moving the newly created document into that folder +- `ReplyDocumentOrchestrator` now annotates the thrown exception with a reply-document failure stage before rethrowing, and both logging and user-facing fallback text read that stage back. +- User-visible behavior now distinguishes: + - folder lookup failure: the session document folder resource is missing/invalid + - move failure: the failure happened while moving into the session document folder, so it is not phrased like a post-move document breakage +- Regression coverage lives in: + - `QueueCompletedReplyAsync_WhenReplyDocumentFolderNotFound_SendsFolderSpecificFailureMessageToChat` + - `QueueCompletedReplyAsync_WhenMoveToReplyDocumentFolderFailsWithNotFound_SendsMoveSpecificFailureMessageToChat` + +## Reply-document folder placement failures are now non-fatal after document creation + +- `ReplyDocumentOrchestrator` creates the Feishu cloud document before it resolves the session folder and before it tries to move the document. +- That means a later Drive failure during `EnsureCloudFolderAsync(...)` or `MoveCloudDocumentToFolderAsync(...)` should not hide the fact that the document already exists. +- The orchestrator now treats post-creation placement failures as warnings instead of terminal document-generation failures: + - still append the reply body + - still set tenant-readable permission + - still send the normal document link into the Feishu chat + - then send a second plain-text warning that folder placement failed and the document remains in the default Feishu directory +- The warning message stays stage-aware: + - missing `drive:*` scopes still echo Feishu's permission-opening guidance + - `1061003 not found` still distinguishes folder lookup failure vs move failure +- Regression coverage now includes: + - `QueueCompletedReplyAsync_WhenFolderPermissionMissing_CreatesDocumentAndSendsWarningToChat` + - `QueueCompletedReplyAsync_WhenReplyDocumentFolderNotFound_CreatesDocumentAndSendsFolderWarningToChat` + - `QueueCompletedReplyAsync_WhenMoveToReplyDocumentFolderFailsWithNotFound_CreatesDocumentAndSendsMoveWarningToChat` diff --git a/docs/agent-notes/2026-06-03.md b/docs/agent-notes/2026-06-03.md new file mode 100644 index 0000000..4a54091 --- /dev/null +++ b/docs/agent-notes/2026-06-03.md @@ -0,0 +1,52 @@ +## Feishu help card can persist the current operator OpenID as reply-document admin + +- `FeishuHelpCardBuilder` now exposes a help-card action that explicitly saves the current operator `OpenID` as the reply-document admin. +- The help-card callback action id is `set_document_admin_openid`, and `FeishuCardActionService` persists the operator `OpenID` into `UserFeishuBotConfig.DocumentAdminOpenId`. + +## Reply-document config now stores a dedicated document admin OpenID + +- `UserFeishuBotConfigEntity` now carries `DocumentAdminOpenId`, and the config service/admin controller round-trip that field so admin saves do not wipe it. +- Admin UI displays the configured document admin `OpenID` as a readonly value so operators can confirm which account will receive document-management permission. + +## New reply documents now try to grant full access to the configured admin OpenID + +- `ReplyDocumentOrchestrator` passes the configured `DocumentAdminOpenId` into each reply-document creation flow. +- After the cloud document is created and tenant-readable permission is applied, `IFeishuCardKitClient.GrantCloudDocumentMemberFullAccessAsync(...)` grants the configured `OpenID` `full_access`. +- If that admin-grant step fails, document delivery remains non-fatal: + - the document link is still sent + - a second warning message is sent to chat describing the admin-grant failure + +## Verification should avoid killing the running WebCodeCli process + +- A live `WebCodeCli` process can lock the default `bin/Debug` outputs and break normal `dotnet test` or `dotnet build` copy steps. +- For verification while keeping that process alive, use isolated `BaseOutputPath` and `BaseIntermediateOutputPath` values that point to a temporary directory outside the repo. + +## Corrupted workspace Codex `state_5.sqlite` can be recovered conservatively by archiving only the state DB files + +- A Codex goal-runtime launch failure with SQLite `code = Corrupt (11)` / `database disk image is malformed` can come from the workspace-local `D:\...\\.codex\state_5.sqlite`, not from WebCode's provider-sync logic itself. +- `CodexThreadProviderSyncService` touches that same `state_5.sqlite` before goal-runtime re-entry, so the first visible warning may be the provider-sync step, followed by an app-server bootstrap failure because Codex cannot initialize its sqlite state runtime. +- A conservative working recovery path is: + - move `state_5.sqlite`, `state_5.sqlite-wal`, and `state_5.sqlite-shm` into a timestamped recovery folder under the same workspace `.codex` + - leave `sessions/rollout-*.jsonl` untouched + - start `codex app-server` once against that workspace so Codex recreates a clean `state_5.sqlite*` +- On the recovered workspace used for validation: + - `PRAGMA integrity_check;` returned `ok` + - the rebuilt `threads` table still contained the target thread row for `019e6353-020d-7492-8e63-d0710e1f068b` +- This indicates the rollout/session artifacts remain the critical durable history, while the corrupted state DB can be rotated out without deleting the workspace `.codex` tree. + +## Feishu help-card rebuilds must not hide document-admin controls when `showRefreshButton` is false + +- The `/feishuhelp` refresh flow rebuilds the list card through `FeishuCardActionService.BuildHelpCommandListCardAsync(..., showRefreshButton: false)`. +- `FeishuHelpCardBuilder.BuildCommandListCardV2(...)` had the reply-document controls and the `set_document_admin_openid` button inside the same `showRefreshButton` branch as the refresh/session-management buttons. +- That meant operators could see the document-admin button on the initial help card, but lose it after the card was refreshed or rebuilt from callback flows. +- Keep only the refresh/session-management row behind `showRefreshButton`; the reply-document toggles, listening-document toggles, and document-admin hint/button must remain visible on rebuilt help cards. + +## Old Feishu cloud documents can be backfilled by reusing the existing member full-access grant API + +- Existing reply-document generation already uses `IFeishuCardKitClient.GrantCloudDocumentMemberFullAccessAsync(documentId, openId, optionsOverride)` to add the configured `DocumentAdminOpenId` as a `full_access` member on newly created docx documents. +- For old documents, the smallest safe recovery path is an admin-only HTTP endpoint that: + - resolves the target user's effective Feishu credentials from `IUserFeishuBotConfigService` + - reads the persisted `DocumentAdminOpenId` + - calls the same `GrantCloudDocumentMemberFullAccessAsync(...)` method against a supplied existing document token +- This avoids touching the reply-document orchestration flow and keeps old-document permission repair as an explicit admin action. +- If repeated backfill is expected, move that logic into a reusable domain service so both single-document and batch-document admin grants call the same path instead of duplicating controller logic. diff --git a/docs/agent-notes/2026-06-04.md b/docs/agent-notes/2026-06-04.md new file mode 100644 index 0000000..5135fe4 --- /dev/null +++ b/docs/agent-notes/2026-06-04.md @@ -0,0 +1,20 @@ +## Codex one-time exec/resume stdin on Windows must also use UTF-8 without BOM + +- Symptom: + - On some Windows machines, Feishu-triggered Codex one-time execution fails immediately with: + - `Reading additional input from stdin...` + - `Failed to read prompt from stdin: input is not valid UTF-8 (invalid byte at offset 0).` +- Confirmed root cause: + - `CodexAppServerSessionManager` already forces no-BOM UTF-8 for `codex app-server`, but the separate one-time `CliExecutorService` launch path still relied on the process default stdin encoding. + - On Windows machines with a non-UTF-8 default console/input encoding, the first bytes written to Codex stdin are not valid UTF-8 for the CLI, which causes the parse failure at byte offset 0. +- Working rule: + - Any WebCode launch path that writes prompts to Codex stdin must explicitly use `new UTF8Encoding(false)` for `ProcessStartInfo.StandardInputEncoding`. + - Keep using the normal default behavior for non-Codex tools unless they prove they need the same transport constraint. +- Current implementation: + - One-time Codex stdin encoding is centralized by `CliExecutorService.GetCodexTransportEncoding()` in [CliExecutorService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs:24). + - Regression coverage lives in [CliExecutorServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs:1). +- Search hints: + - `Failed to read prompt from stdin` + - `invalid byte at offset 0` + - `GetCodexTransportEncoding` + - `StandardInputEncoding` diff --git a/docs/agent-notes/2026-06-05.md b/docs/agent-notes/2026-06-05.md new file mode 100644 index 0000000..24bade5 --- /dev/null +++ b/docs/agent-notes/2026-06-05.md @@ -0,0 +1,22 @@ +## Feishu reply document foldering and admin grants are best-effort steps + +- Confirmed orchestration rule: + - Reply document creation must succeed independently from follow-up folder placement or document-admin grant operations. + - The document link should still be sent when folder lookup, folder move, or admin-grant fails. +- User-facing error rule: + - For Feishu `1061003` not-found errors, reply text should explain that the document or folder resource is missing or invalid, without exposing raw OpenAPI payload text. + - For missing `drive:*` scopes, reuse the permission-opening guidance already present in Feishu's error text so the operator can directly open the correct authorization page. +- Folder naming rule: + - Prefer the chat session title as the cloud folder name. + - If the title is `未命名` or blank, fall back to `CliThreadId`; if that is also missing, fall back to `SessionId`. + +## Help card can store the current operator OpenID as reply-document admin + +- The help card now exposes an action that saves the current Feishu operator OpenID into `UserFeishuBotConfig.DocumentAdminOpenId`. +- New Feishu reply documents can automatically grant full access to that configured OpenID after creation. + +## Codex Windows transport constraints + +- Two Codex launch details must stay aligned on Windows: + - stdin written to one-shot `codex exec` flows must use UTF-8 without BOM + - prompt arguments for `codex exec` and `codex exec resume` must include `--` before the prompt so leading markdown list items are not parsed as flags diff --git a/docs/agent-notes/2026-06-08.md b/docs/agent-notes/2026-06-08.md new file mode 100644 index 0000000..94734b6 --- /dev/null +++ b/docs/agent-notes/2026-06-08.md @@ -0,0 +1,67 @@ +# 2026-06-08 Agent Notes + +## Session-manager "更多会话" callback expansion should paginate instead of rendering all sessions at once + +- Symptom: + - Clicking `更多会话` in the Feishu session-manager card could return HTTP 200 from the callback path, but the client-side card sometimes failed to update. + - The failure reproduced most clearly in chats with many sessions and goal quick-action rows. +- Root cause: + - `open_session_manager` with `show_all_sessions=true` previously rebuilt the full session-manager card inline through callback `SetCard(...)`. + - With enough sessions, the callback replacement card became too large or too complex for reliable Feishu client-side application, even though the backend completed normally. +- Working rule: + - Keep the default collapsed view unchanged: latest 3 sessions plus `更多会话`. + - Treat `show_all_sessions=true` as paginated expanded mode, not "render every session in one callback card". + - Use a fixed session-manager page size of 3 with `session_page` starting at 0. + - Expanded mode should render: + - page hint `当前展示第 x/y 页` + - `上一页` / `下一页` + - `收起` +- State-preservation rule: + - Once the user enters paginated session-manager mode, preserve `session_page` across child-card actions launched from that manager card. + - At minimum this includes: + - `sync_session_provider` confirm card + - `show_create_session_form` + - `discover_external_cli_sessions` + - `open_project_manager` + - `browse_current_session_directory` + - `browse_session_directory` + - `preview_session_file` + - Return buttons from those cards must carry both: + - `show_all_sessions` + - `session_page` +- Current implementation: + - Session-manager pagination and `session_page` action model now live in: + - [FeishuHelpCardAction.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs:1) + - [FeishuCardActionService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs:1) + - Goal-runtime provider-sync confirm card now preserves session-manager pagination context through: + - [FeishuHelpCardBuilder.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs:920) +- Regression coverage: + - [FeishuCardActionServiceTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs:1) + - Targeted tests now cover: + - paginated session-manager page actions + - provider-sync confirm card pagination preservation + - create-session / import-external-session / project-manager pagination preservation + - session-directory / file-preview pagination preservation + +## Reply-document archiving should create docx directly inside the target folder when possible + +- Symptom: + - Reply-document archive warnings could report that the docx had been generated but failed while moving into the session document folder. + - Operators could see shared folders in Feishu Drive UI, yet the generated docx was not reliably visible in the same place. +- Root cause: + - The old flow always created the docx first and depended on a second Drive move step for placement. + - Feishu docx creation already supports `folder_token`, so that extra move was avoidable. + - The old move payload also labeled the moved resource as `file`; docx moves should use `type = "docx"`. +- Working rule: + - Resolve the session-scoped target folder first. + - If a folder token is available, create the docx directly in that folder. + - If direct in-folder creation fails, create the docx in the default app directory, send the link, and then send a placement warning. + - Keep move-based placement only as a compatibility fallback, and use `type = "docx"` when that fallback runs. +- Current implementation: + - `IFeishuCardKitClient.CreateCloudDocumentAsync(...)` now accepts an optional `folderToken`. + - `ReplyDocumentOrchestrator` now prefers direct in-folder docx creation after resolving the session folder and only falls back when needed. + - `MoveCloudDocumentToFolderAsync(...)` now posts a docx move payload instead of a generic file move payload. +- Regression coverage: + - [FeishuCardKitClientTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs:1) + - [ReplyDocumentOrchestratorTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs:1) + - [ReplyDocumentOrchestratorListeningTests.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorListeningTests.cs:1) diff --git a/docs/agent-notes/2026-06-09.md b/docs/agent-notes/2026-06-09.md new file mode 100644 index 0000000..4bc79dd --- /dev/null +++ b/docs/agent-notes/2026-06-09.md @@ -0,0 +1,82 @@ +# 2026-06-09 Agent Notes + +## Feishu reply documents should prefer official Markdown conversion and referenced `.md` files should use official import tasks + +- For reply-document body rendering, Feishu online docs do not expose a native `.md` document type that can be created directly as the final artifact. +- The preferred platform-native path for reply text is: + - create `docx` + - call Feishu's official Markdown-to-document conversion capability + - append the returned document blocks +- The preferred platform-native path for real local `.md` files is: + - upload the `.md` file + - create a Feishu import task + - let Feishu convert the source file into an online document +- This split avoids maintaining a custom Markdown AST renderer in application code while preserving higher-fidelity Feishu output for both generated reply documents and referenced local Markdown documents. + +## Feishu markdown raw client primitives can stay fully client-side and reuse existing token and error helpers + +- `docx/v1/documents/blocks/convert` accepts `content_type = "markdown"` plus raw markdown text and returns `data.first_level_block_ids` plus `data.blocks`; the raw client can return that `data` payload directly for higher layers to interpret. +- Appending converted blocks should reuse the existing docx children endpoint at `/open-apis/docx/v1/documents/{documentId}/blocks/{blockId}/children` with `children = [...]` and `index = 0`, passing the converted block payloads through without reshaping. +- Existing online-document reuse inside a folder can be handled by listing `/open-apis/drive/v1/files?page_size=200&folder_token=...` and matching exact `name` with `type = "docx"`. +- Markdown file upload for import should use the normal Drive upload endpoint `/open-apis/drive/v1/files/upload_all` with multipart fields `file_name`, `parent_type`, `parent_node`, `size`, and `file`. +- Markdown import can remain a client primitive by chaining file upload, import-task creation, and import-task polling. +- For the raw client layer, new validation and import-task terminal failures should continue using Chinese runtime exception text, while shared HTTP and business helper patterns remain unchanged to avoid broader behavior churn. + +## Reply-document markdown rendering should stay convert-first with per-document plain-text fallback + +- `ReplyDocumentOrchestrator` can keep all existing title, folder, permission, and link-message behavior intact if markdown rendering is isolated behind a tiny `ReplyDocumentMarkdownRenderer`. +- The renderer only needs two fallback branches: + - fall back to the existing plain-text append path when conversion throws + - fall back to the existing plain-text append path when conversion returns no usable blocks +- This keeps markdown downgrade local to one document variant and avoids changing the existing reply-document failure and warning pipeline. + +## Referenced `.md` import should inspect completed reply text only and reuse session folders + +- The completed-reply importer can stay inside `ReplyDocumentOrchestrator` as a post-document side effect: + - inspect full reply text first, otherwise final-only text + - resolve local `.md` references against the current session workspace + - reuse the same session-scoped Feishu folder resolution already used by reply documents +- Safe extraction is sufficient with two regex passes: + - Markdown links ending in `.md` + - bare local-looking `.md` paths +- Keep only candidates that: + - resolve under the current workspace root + - exist on disk + - normalize to a relative `/`-separated path + - dedupe by normalized relative path +- Reuse behavior can stay simple: + - exact title match in the session folder means reuse the existing online document and send its link + - otherwise import the local Markdown file into a new online document and send the new link +- Per-candidate failures should send a Chinese warning to chat but must not block normal reply-document generation or the remaining Markdown candidates. +- The Markdown-import-only folder-admin warning path should use a Markdown-online-document target label instead of reusing the full-reply document target label. + +## Feishu help-card reply-document toggles can carry independent persisted modes beyond legacy reply-document compatibility + +- `UserFeishuBotConfigEntity` can persist a separate `ReferencedMarkdownDocImportEnabled` flag without changing the existing legacy `ReplyDocEnabled` compatibility path. +- Admin round-trip support for new Feishu bot toggles needs all three layers updated together: + - controller DTO mapping + - modal seed and save state + - test harness object cloning used by admin controller tests +- Help-card toggle actions follow the existing constant-driven pattern: + - action: `toggle_referenced_markdown_doc_import` + - one button state for enabled mode + - one button state for disabled mode +- For parity with the active runtime and tests, the help-card builder and card-action refresh path both need the new toggle included in reply-document settings tuples. + +## Implementation closure + +- `ReplyDocumentMarkdownRenderer` now stays strictly convert-first: + - conversion exceptions or empty `blocks` payloads degrade to the existing plain-text append path + - block-append failures are logged and rethrown so the pipeline does not silently duplicate content by appending plain text after partial converted-block work +- `ReferencedMarkdownDocumentImporter` now keeps the chat UX aligned with reply-document behavior: + - success link messages are sent before any admin or placement warning + - folder-admin grants are attempted lazily after the first successful reuse or import instead of before all candidates + - direct in-folder import failures can fall back to default-directory import, then send a placement warning while keeping the generated link + - per-candidate warning-send failures are isolated so later Markdown candidates still continue +- `FeishuCardKitClient` now supports default-directory Markdown import primitives: + - `UploadCloudFileAsync` accepts `folderToken = null` and resolves the root folder automatically + - `ImportMarkdownFileAsCloudDocumentAsync` accepts `folderToken = null` and omits the import `point` payload + - import-task polling now has a 30-second deadline and throws a dedicated Chinese timeout message for Markdown import +- Formatter verification should assert against real emitted structure instead of assuming appendix flattening: + - command placeholders are appended as separate command sections with blank-line separators + - file placeholders are not recursively expanded inside extracted command bodies diff --git a/docs/agent-notes/2026-06-10.md b/docs/agent-notes/2026-06-10.md new file mode 100644 index 0000000..79589be --- /dev/null +++ b/docs/agent-notes/2026-06-10.md @@ -0,0 +1,31 @@ +# 2026-06-10 Agent Notes + +## Feishu reply-document markdown flow should keep success links even when folder placement degrades + +- Referenced Markdown import should not fail the user-visible success path just because the target shared folder is unavailable. +- A workable fallback is: + - reuse or import the online document first + - send the success link to chat immediately + - then emit a separate placement warning if the file had to remain in the default directory +- This preserves access to the generated online document while still surfacing the folder-placement problem. + +## Reply-document markdown rendering should not fall back to plain text after converted blocks partially append + +- Conversion-time fallback is safe because no document content has been written yet. +- Append-time fallback is unsafe because partial converted blocks may already exist in the target document. +- The renderer should therefore: + - fall back to plain text only when conversion fails or returns no usable blocks + - rethrow append failures after logging so the pipeline avoids duplicated content + +## Feishu streaming footer can place a fixed Worktree action beside Goal execution + +- The `Goal执行` footer button is routed through the `/goal` quick-action path and should stay goal-specific. +- A sibling `完成Worktree` button can share the same footer row while still going through the superpowers fixed-prompt path. +- This keeps the CLI payload exact when the requested action is not a `/goal` command. + +## Goal execution prompt can reuse markdown-reference resolution for plan documents + +- The `Goal执行` quick action should inspect the latest completed assistant reply before building the CLI prompt. +- The existing markdown-reference extractor is sufficient when paired with the current session workspace root. +- Only plan-like markdown targets should influence the generated goal prompt, such as files under `plans/` or names containing `plan`. +- If no plan markdown is detected, the quick action must keep the previous fixed fallback prompt unchanged. diff --git a/docs/agent-notes/2026-06-11.md b/docs/agent-notes/2026-06-11.md new file mode 100644 index 0000000..2a4d7ad --- /dev/null +++ b/docs/agent-notes/2026-06-11.md @@ -0,0 +1,33 @@ +# 2026-06-11 Agent Notes + +## Bare plan markdown filenames in assistant replies should resolve inside the workspace before document import or goal execution + +- Reply-document referenced-markdown import and `Goal执行` prompt generation both reuse `MarkdownReferenceExtractor`. +- The original extractor only recognized markdown candidates when the text included a path segment such as `docs/x.md` or `./x.md`. +- Real assistant replies can mention a plan document by bare filename only, for example `2026-06-11-mmis-ai-first-operation-wave-2-implementation-plan.md`, without a visible folder prefix or markdown link. +- The extractor therefore needs two capabilities together: + - detect bare `*.md` filenames in normal prose + - resolve those bare filenames back to a real workspace file using safe, workspace-scoped lookup +- Safe lookup should prefer documentation-oriented directories first, especially `plans/`, `specs/`, and `docs/`, before falling back to a broader workspace search. +- This keeps the behavior deterministic for plan execution and referenced markdown online-document import while avoiding remote URLs or outside-workspace paths. + +## Feishu docx children append must batch converted blocks at 50 items per request + +- The markdown-to-docx convert API can return more than 50 first-level blocks for long replies. +- Appending those converted blocks to `/open-apis/docx/v1/documents/{documentId}/blocks/{blockId}/children` fails with Feishu validation error `children: the max len is 50` if the client sends them in one request. +- The fix belongs in `FeishuCardKitClient.AppendCloudDocumentBlocksAsync(...)`, not in the markdown renderer, because the raw docx children endpoint itself enforces the batch limit. +- Safe batching should keep the original block order by sending consecutive slices with increasing `index` values such as `0`, `50`, `100`, instead of repeatedly prepending at `index = 0`. + +## Reply-document markdown rendering should fall back to plain text when docx block append fails + +- Feishu markdown block conversion can succeed while the later docx `children` append still fails with schema-level errors such as `schema mismatch`. +- In that failure mode, dropping the entire reply-document generation is a poor user experience because the document body is already available as plain markdown text. +- `ReplyDocumentMarkdownRenderer.RenderAsync(...)` should therefore keep the existing convert-first path, but if `AppendCloudDocumentBlocksAsync(...)` throws, it should append the original markdown string through `AppendCloudDocumentTextAsync(...)` instead of rethrowing. +- This keeps reply-document delivery reliable even when Feishu rejects a specific converted block shape, while preserving rich formatting whenever the block append path is accepted. + +## Referenced Markdown import should inspect both full reply text and final-only reply text + +- The original referenced-Markdown import path inside `ReplyDocumentOrchestrator` chose `fullReplyContent` whenever it was non-empty and only fell back to `finalReplyContent` when the full reply text was blank. +- Real streamed completions can mention a local `.md` file only in the final conclusion section, for example `已创建并提交文档:2026-06-11-mmis-product-v1-total-delivery-roadmap-design.md`, while the broader full reply text remains non-empty and does not contain the file reference. +- In that shape, the old logic silently skipped Markdown online-document import even though the current session workspace contained the referenced file. +- The importer should therefore aggregate candidates from both `Output` and `FinalAnswerOutput`, preserving first-seen order and deduplicating by normalized relative path. diff --git a/docs/agent-notes/2026-06-12.md b/docs/agent-notes/2026-06-12.md new file mode 100644 index 0000000..0dd04f9 --- /dev/null +++ b/docs/agent-notes/2026-06-12.md @@ -0,0 +1,28 @@ +# 2026-06-12 Agent Notes + +## Reply-document titles should prefer the last real user message from rollout/history, not the inbound control prompt + +- `ReplyDocumentOrchestrator` used `FeishuCompletedReplyDocumentRequest.OriginalUserQuestion` directly when building document titles. +- For quick actions such as `Goal鎵ц`, the inbound prompt is often an internal control command like `/goal ...` or a fixed superpowers execution prompt, which produces noisy reply-document titles. +- The safer title source is the latest real `user` message from `IExternalCliSessionHistoryService.GetRecentMessagesAsync(...)`, resolved with the current session tool id, cli thread id, and workspace path. +- Title composition should only fall back to `OriginalUserQuestion` when no usable rollout/history user message can be resolved. +- The normalized title order for all four reply-document variants is `<鐢ㄦ埛鍐呭> - 鍚庣紑`, including `瀹屾暣鍥炲`, `缁撹鍥炲`, `鍚畬鏁村洖澶峘, and `鍚粨璁哄洖澶峘. +- Control-style prompts such as `/goal ...`, `浣跨敤Subagent-Driven瀹屾垚plan...`, and `浣跨敤Worktree鎶€鑳藉畬鎴怶orktree...` should be ignored as title candidates even if they appear in recent history. + +## Reply-document body should place the resolved last real user message differently for normal docs vs listening docs + +- The user-content placement rule applies to the document body, not the title. +- `瀹屾暣鍥炲鏂囨。` and `缁撹鍥炲鏂囨。` should prepend the resolved last real user message as a leading section before the assistant reply body. +- `鍚畬鏁存枃妗 and `鍚粨璁烘枃妗 should append the same resolved last real user message after the listening-formatted assistant body, so the audio-first content stays upfront. +- The same rollout/history-derived last real `user` message should be reused for both title building and body placement, and control prompts must still be filtered out. + +## Referenced markdown online-doc import should use local fingerprint tracking and overwrite the same Feishu doc when the local file changes + +- Remote-body comparison is not required to detect whether a local `.md` file changed. +- The import pipeline should persist a local fingerprint keyed by `folderToken + absolutePath`, stored under local app data instead of the repository. +- A workable fingerprint is `absolutePath + file size + last write ticks + SHA256(file bytes)`. +- Runtime behavior should be: + - unchanged fingerprint: reuse the existing online-doc link without re-import + - changed fingerprint and same-title online doc exists: clear the existing root child blocks and rewrite the updated markdown so the Feishu URL stays stable + - no tracked state and no same-title doc: import a new online doc as before +- The rewrite path should stay Feishu-native: markdown-to-docx block conversion first, then block append, and only fall back to plain-text append if conversion or block append fails. diff --git a/docs/agent-notes/2026-06-13.md b/docs/agent-notes/2026-06-13.md new file mode 100644 index 0000000..62fd4ec --- /dev/null +++ b/docs/agent-notes/2026-06-13.md @@ -0,0 +1,116 @@ +# 2026-06-13 Agent Notes + +## Referenced markdown online-doc import now keeps a stable Feishu link when the local markdown file changes + +- The previous referenced-markdown import flow only supported two outcomes: + - exact-title online doc exists: reuse it + - exact-title online doc missing: import a new online doc +- That behavior could not distinguish “same local file, updated content” from “same local file, unchanged content”. +- The updated flow now persists a local import state under local app data and uses a fingerprint built from: + - absolute path + - file size + - last write time ticks + - SHA256 of file bytes +- The state is keyed by `folderToken + absolutePath`, which keeps per-session-folder behavior isolated without storing anything in the repository. +- Runtime behavior is now: + - unchanged local fingerprint: reuse the existing Markdown online-doc link without re-import + - changed local fingerprint with same-title doc still present: clear the root child blocks of that doc and rewrite the current markdown content so the Feishu URL stays unchanged + - missing online doc: keep the existing import-with-default-directory-fallback behavior + +## Feishu docx overwrite path requires child-block list/delete primitives before markdown re-render + +- Rewriting an existing Feishu online document in place is not just an append operation. +- The client needs two explicit docx primitives: + - list current root child block ids + - delete child blocks by index range +- After deletion, the content rewrite should remain Feishu-native: + - convert markdown to docx blocks first + - append converted blocks in batched children requests + - fall back to plain-text append if conversion or block append fails + +## Verification closure for markdown import overwrite support + +- Targeted tests passed for: + - referenced markdown importer reuse/update behavior + - reply markdown renderer fallback behavior + - reply markdown integration flow + - Feishu docx child-block list/delete client primitives +- Full solution build also passed: + - `dotnet build D:\VSWorkshop\WebCode\WebCodeCli.sln --no-restore -p:UseSharedCompilation=false -v minimal` +- Current build result: + - 0 errors + - existing warnings only, mainly package compatibility warnings and pre-existing nullable/unused-field warnings in `WebCodeCli` + +## WebCode GitHub release publishing requires a fresh version when the previous release tag already exists + +- The repo release script treats `Directory.Build.props` as the release version source of truth and expects the matching local tag to point at `HEAD`. +- If the same version was already released before, rerunning the script after new commits will fail before build/upload because the existing `vX.Y.Z` tag still points to the earlier commit. +- For a new code batch that should be published as a new installer, bump the patch version first, then rerun the release script so it can create and push a fresh tag for the current commit. + +## Goal-runtime release regressions need terminal goal snapshots in tests, not open-ended `active` stubs + +- Two release-blocking `CliExecutorService` tests started timing out after goal runtime changed from one-shot `/goal` handling to the persistent app-server turn loop. +- The production behavior is now: + - `/goal ...` starts or resumes goal runtime + - after each turn, WebCode calls `GetGoalAsync(...)` + - if goal status is still `active`, WebCode emits a turn boundary and starts the next turn +- Older tests were still using a stub manager that never transitioned the goal away from `active`, so the stream correctly kept running forever. +- The fix was in the tests, not production code: + - queue a terminal `complete` goal snapshot for scenarios that only want to validate first-turn behavior + - keep the sparse-output assertions focused on "no idle fallback premature completion" instead of accidentally depending on infinite runtime cancellation + +## Finish-path Feishu replacement now mirrors channel behavior: the failed old card is not finalized again after replacement creation + +- `FeishuStreamingCardSession.FinishAsync(...)` used to create a replacement handle and then also finalize the already-failed stopped handle during the finish path. +- That behavior conflicted with the intended recovery model and with channel-side tests: + - once finish on the old card has already failed, the fallback path should move completion to the replacement card + - the failed old card should not receive an additional stop-finalization write during finish-path recovery +- The updated finish-path replacement therefore creates the replacement handle with stopped-handle finalization disabled. +- Release regression tests for `FeishuCardActionService` had to be aligned to the same semantics already used by `FeishuChannelService`: + - old handle `FinishAttemptCount` remains `1` + - old handle final content remains `null` + - replacement handle owns the final completed content + +## Reply-document titles should preserve the thread id, and full/final bodies need a hard separator before assistant content + +- The previous reply-document title flow truncated the entire final title string, so a long last-user question could consume the whole limit and hide the trailing `threadId`. +- `ReplyDocumentOrchestrator` now builds each title variant with suffix-aware budgeting: + - reserve space for ` + suffix` + - truncate only the normalized user-question prefix when necessary + - fall back to `` if the suffix budget leaves no safe room for the question +- This keeps the existing normalized order unchanged: + - ` - 完整回复/结论回复/听完整回复/听结论回复` +- The previous full/final body composition only prepended `## 用户内容`, then appended the assistant reply directly. +- That caused rendered markdown documents to visually blur the inserted user-content section into the assistant body for structured replies. +- The body format is now: + - full/final docs: `## 用户内容` + resolved last real user message + `---` + original assistant body in original order + - listening docs: keep the existing audio-first layout and append the user-content section at the end +- The user-content block now uses normalized document-body newlines instead of title-style line folding, so multiline user questions remain readable at the top of full/final documents. + +## Reply-document folder names now keep the session title and append the cli thread id, while document titles switch to question-plus-timestamp + +- The folder identity and the document title now serve different purposes: + - folder name = stable conversation/archive grouping + - document title = readable per-reply label +- The reply-document folder rule is now: + - named session title: ` []` + - unnamed or blank title: `` + - missing thread id: fall back to `sessionId` +- The document title rule is now: + - ` ` + - no document-type suffix in the title + - no `threadId` in the title +- The same rollout/history-derived last real `user` message is still preferred over control prompts such as `/goal ...` when choosing the title question. +- Millisecond timestamps keep the four reply-document variants from colliding even when they are generated from the same completed turn at nearly the same time. + +## Reply-document bodies should use direct plain-text append instead of markdown-to-docx conversion when reply order fidelity matters + +- The reply-document body string can already be composed in the correct order inside `ReplyDocumentOrchestrator`: + - full/final docs: user-content block, separator, then assistant body + - listening docs: listening-formatted body first, then user-content block +- However, preserving that string order is not enough if the body is later sent through the Feishu markdown-to-docx convert path. +- Real structured replies containing headings, bullet lists, separator lines, and normal paragraphs can render out of order after conversion to docx blocks, even though the original markdown string order is correct. +- The safer rule is: + - reply-document bodies use direct `AppendCloudDocumentTextAsync(...)` + - referenced local `.md` online-document import keeps its richer markdown/docx conversion pipeline +- This isolates the fidelity-sensitive “reply transcript” use case from the richer “import standalone markdown doc” use case and prevents reordered paragraphs in archived reply documents. diff --git a/docs/superpowers/plans/2026-05-20-feishu-streaming-card-recovery-plan.md b/docs/superpowers/plans/2026-05-20-feishu-streaming-card-recovery-plan.md new file mode 100644 index 0000000..5824482 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-feishu-streaming-card-recovery-plan.md @@ -0,0 +1,669 @@ +# Feishu Streaming Card Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep Feishu streaming output alive by treating timeout-plus-sequence-conflict as same-card recovery and falling back to at most one replacement card only when the active card is truly no longer writable. + +**Architecture:** Narrow the first fix into `FeishuCardKitClient` so retry-after-timeout `300317` is treated as a likely successful prior write. Then add a shared resilient streaming-card session abstraction above `FeishuStreamingHandle` and route both `FeishuChannelService` and `FeishuCardActionService` through it so replacement-card behavior, finish semantics, and disconnect fallback stay consistent. + +**Tech Stack:** C#, xUnit, existing Feishu CardKit client/services, in-repo streaming test doubles. + +--- + +### Task 1: Lock down same-card recovery in CardKit client + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` + +- [ ] **Step 1: Write the failing test for timeout-then-300317 same-card recovery** + +Add a new test in `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` next to `CreateStreamingHandleAsync_RetriesTimedOutUpdateOnceWithSameSequenceAndUuid`: + +```csharp +[Fact] +public async Task CreateStreamingHandleAsync_TreatsTimeoutThenSequenceConflictAsSuccessfulPriorWrite() +{ + var handler = new TimeoutThenSequenceConflictCardUpdateHandler(); + var client = CreateClient(handler, new FeishuOptions + { + AppId = "app-id", + AppSecret = "app-secret", + HttpTimeoutSeconds = 1, + StreamingThrottleMs = 0 + }); + + var handle = await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "initial", + "AI 助手", + TestContext.Current.CancellationToken); + + await handle.UpdateAsync("first update"); + await handle.UpdateAsync("second update"); + + Assert.False(handle.AreCardUpdatesStopped); + Assert.Equal(2, handler.SuccessfulLogicalUpdates); +} +``` + +- [ ] **Step 2: Add the HTTP handler test double that reproduces the real failure sequence** + +In `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs`, add a dedicated handler below `TimeoutOnFirstCardUpdateHandler`: + +```csharp +private sealed class TimeoutThenSequenceConflictCardUpdateHandler : HttpMessageHandler +{ + private int _updateCount; + + public int SuccessfulLogicalUpdates { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (string.Equals(path, "/open-apis/auth/v3/tenant_access_token/internal", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/im/v1/messages", StringComparison.Ordinal)) + { + return CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}"""); + } + + if (request.Method == HttpMethod.Put && + string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + { + _updateCount++; + if (_updateCount == 1) + { + await Task.Delay(TimeSpan.FromMilliseconds(1500), cancellationToken); + } + + if (_updateCount == 2) + { + SuccessfulLogicalUpdates++; + return CreateJsonResponse("""{"code":300317,"msg":"sequence number compare failed"}"""); + } + + SuccessfulLogicalUpdates++; + return CreateJsonResponse("""{"code":0}"""); + } + + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } +} +``` + +- [ ] **Step 3: Write the failing test for plain 300317 without a prior timeout** + +Add a second test to prove we do not over-broaden recovery: + +```csharp +[Fact] +public async Task CreateStreamingHandleAsync_TreatsPlainSequenceConflictAsCardFailure() +{ + var handler = new PlainSequenceConflictCardUpdateHandler(); + var client = CreateClient(handler, new FeishuOptions + { + AppId = "app-id", + AppSecret = "app-secret", + HttpTimeoutSeconds = 1, + StreamingThrottleMs = 0 + }); + + var handle = await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "initial", + "AI 助手", + TestContext.Current.CancellationToken); + + await handle.UpdateAsync("first update"); + + Assert.True(handle.AreCardUpdatesStopped); +} +``` + +- [ ] **Step 4: Add the plain conflict test double** + +In the same test file, add: + +```csharp +private sealed class PlainSequenceConflictCardUpdateHandler : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (string.Equals(path, "/open-apis/auth/v3/tenant_access_token/internal", StringComparison.Ordinal)) + { + return Task.FromResult(CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}""")); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/cardkit/v1/cards", StringComparison.Ordinal)) + { + return Task.FromResult(CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}""")); + } + + if (request.Method == HttpMethod.Post && + string.Equals(path, "/open-apis/im/v1/messages", StringComparison.Ordinal)) + { + return Task.FromResult(CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}""")); + } + + if (request.Method == HttpMethod.Put && + string.Equals(path, "/open-apis/cardkit/v1/cards/card_123", StringComparison.Ordinal)) + { + return Task.FromResult(CreateJsonResponse("""{"code":300317,"msg":"sequence number compare failed"}""")); + } + + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } +} +``` + +- [ ] **Step 5: Run the focused CardKit client tests and verify they fail** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "FeishuCardKitClientTests" +``` + +Expected: + +- the new timeout-then-300317 test fails because `AreCardUpdatesStopped` becomes `true` +- the new plain-300317 test either passes already or confirms current failure classification + +- [ ] **Step 6: Implement narrow same-card recovery in `FeishuCardKitClient`** + +Update `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` inside `UpdateCardCoreAsync(...)` so the retry loop remembers whether a timeout already happened for the current `sequence`: + +```csharp +var sawRecoverableTimeout = false; + +for (var attempt = 1; attempt <= CardUpdateMaxAttempts; attempt++) +{ + try + { + var response = await PutAsync(...); + var result = await ParseResponseAsync(response, cancellationToken); + + if (result.TryGetProperty("code", out var codeProp)) + { + var code = codeProp.GetInt32(); + if (code == 0) + { + return true; + } + + if (code == 300317 && sawRecoverableTimeout) + { + _logger.LogWarning( + "Update card retry hit sequence conflict after timeout; assuming previous write succeeded (cardId={CardId}, seq={Sequence}, uuid={Uuid})", + cardId, + sequence, + updateUuid); + return true; + } + } + + EnsureBusinessSuccess(result, "Update CardKit card"); + return false; + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + sawRecoverableTimeout = true; + ... + } +} +``` + +- [ ] **Step 7: Run the focused CardKit client tests and verify they pass** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "FeishuCardKitClientTests" +``` + +Expected: + +- the timeout-then-300317 test passes +- the plain-300317 test still shows `AreCardUpdatesStopped == true` + +- [ ] **Step 8: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs +git commit -m "fix: recover feishu card updates after timed out retry" +``` + +### Task 2: Introduce a shared resilient streaming-card session + +**Files:** +- Create: `WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs` +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingHandle.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +- [ ] **Step 1: Write the failing channel-stream test for one-time replacement-card recovery** + +Add a new test in `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` using `StreamingRecordingFeishuCardKitClient`: + +```csharp +[Fact] +public async Task HandleIncomingMessageAsync_ReplacesBrokenStreamingCardOnceAndFinishesOnReplacement() +{ + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient + { + FailUpdateOnAttempt = 1 + }; + var chatSessionService = new RecordingChatSessionService(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-recovery-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + var cliExecutor = new PromptCapturingCliExecutor(workspacePath) + { + StreamChunks = + [ + new StreamOutputChunk { Content = "first", IsCompleted = false }, + new StreamOutputChunk { Content = "second", IsCompleted = true } + ] + }; + 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); + + try + { + service.CreateNewSession(new FeishuIncomingMessage + { + ChatId = "oc_recovery_chat", + SenderName = "luhaiyan" + }, workspacePath, "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_recovery_chat", + SenderName = "luhaiyan", + MessageId = "msg-recovery", + Content = "继续输出" + }); + + Assert.Equal(2, cardKit.Handles.Count); + Assert.Equal("firstsecond", cardKit.Handles[1].FinalContent); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } +} +``` + +- [ ] **Step 2: Extend the test double so one handle can fail and the next handle can continue** + +Modify `StreamingRecordingFeishuCardKitClient` in `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` so failures can be configured per created handle: + +```csharp +public Queue FailUpdateAttemptSequence { get; } = new(); + +... +var failUpdateOnAttempt = FailUpdateAttemptSequence.Count > 0 + ? FailUpdateAttemptSequence.Dequeue() + : FailUpdateOnAttempt; + +... +if (failUpdateOnAttempt.HasValue && record.UpdateAttemptCount >= failUpdateOnAttempt.Value) +{ + return Task.FromResult(false); +} +``` + +- [ ] **Step 3: Write the failing card-action stream test for replacement-card recovery** + +Add a matching test in `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` that drives the card-action path and asserts: + +```csharp +Assert.Equal(2, cardKit.Handles.Count); +Assert.Equal("已经处理完了。", cardKit.Handles[1].FinalContent); +Assert.Null(cardKit.Handles[0].FinalContent); +``` + +- [ ] **Step 4: Run the focused streaming tests and verify they fail** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "HandleIncomingMessageAsync_ReplacesBrokenStreamingCardOnceAndFinishesOnReplacement|ExecuteCommand" +``` + +Expected: + +- the new channel-stream recovery test fails because the first stopped card ends the stream +- the new card-action recovery test fails for the same reason + +- [ ] **Step 5: Create the shared resilient streaming-card session** + +Create `WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs` with a focused abstraction: + +```csharp +namespace WebCodeCli.Domain.Domain.Service.Channels; + +internal sealed class FeishuStreamingCardSession +{ + private readonly Func> _replacementFactory; + private readonly SemaphoreSlim _lock = new(1, 1); + private int _replacementCount; + + public FeishuStreamingCardSession( + FeishuStreamingHandle initialHandle, + Func> replacementFactory) + { + CurrentHandle = initialHandle; + _replacementFactory = replacementFactory; + } + + public FeishuStreamingHandle CurrentHandle { get; private set; } + + public async Task UpdateAsync(string content, CancellationToken cancellationToken) + { + await _lock.WaitAsync(cancellationToken); + try + { + await CurrentHandle.UpdateAsync(content); + if (!CurrentHandle.AreCardUpdatesStopped) + { + return true; + } + + if (_replacementCount >= 1) + { + return false; + } + + var replacement = await _replacementFactory(CurrentHandle, content, cancellationToken); + if (replacement == null) + { + return false; + } + + _replacementCount++; + CurrentHandle = replacement; + return true; + } + finally + { + _lock.Release(); + } + } + + public Task FinishAsync(string finalContent) => CurrentHandle.FinishAsync(finalContent); +} +``` + +- [ ] **Step 6: Remove stop-callback coupling from `FeishuStreamingHandle` if it is no longer needed** + +If the only new change in `WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingHandle.cs` is the `onCardUpdatesStopped` callback experiment, revert that portion and keep the handle single-purpose: + +```csharp +public FeishuStreamingHandle( + string cardId, + string messageId, + Func> updateAsync, + Func> finishAsync, + int throttleMs = 500, + int quietWindowAfterUpdateMs = 0) +``` + +and: + +```csharp +public void StopCardUpdates() +{ + Interlocked.Exchange(ref _cardUpdatesStopped, 1); +} +``` + +- [ ] **Step 7: Add a small replacement-card factory helper in `FeishuChannelService`** + +In `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs`, add a helper near `SendStreamingMessageAsync`: + +```csharp +private async Task TryCreateReplacementStreamingHandleAsync( + string chatId, + string? replyMessageId, + string latestRenderedContent, + FeishuStreamingCardChrome chrome, + FeishuOptions effectiveOptions, + CancellationToken cancellationToken) +{ + try + { + return await _cardKit.CreateStreamingHandleAsync( + chatId, + replyMessageId, + latestRenderedContent, + effectiveOptions.DefaultCardTitle, + cancellationToken, + effectiveOptions, + chrome); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create replacement Feishu streaming card for chat {ChatId}", chatId); + return null; + } +} +``` + +- [ ] **Step 8: Route the channel streaming path through the session wrapper** + +In `FeishuChannelService`, replace direct `handle.UpdateAsync(...)` / `handle.FinishAsync(...)` usage in `ExecuteCliAndStreamAsync(...)`, the pulse task, and external history backfill with the wrapper: + +```csharp +var cardSession = new FeishuStreamingCardSession( + handle, + (stoppedHandle, latestContent, token) => TryCreateReplacementStreamingHandleAsync( + chatId, + null, + latestContent, + activeExecution.Chrome, + effectiveOptions, + token)); + +... +var updateSucceeded = await cardSession.UpdateAsync(displayContent, cancellationToken); +handle = cardSession.CurrentHandle; +if (!updateSucceeded) +{ + var disconnectedContent = await TryHandleStreamingCardDisconnectAsync(...); + ... +} +... +await cardSession.FinishAsync(finalOutput); +handle = cardSession.CurrentHandle; +``` + +- [ ] **Step 9: Add the same replacement-card factory helper in `FeishuCardActionService`** + +In `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs`, add a corresponding helper that preserves the same chat placement rule used by the original card-action flow: + +```csharp +private async Task TryCreateReplacementStreamingHandleAsync( + string chatId, + string latestRenderedContent, + FeishuStreamingCardChrome chrome, + FeishuOptions effectiveOptions, + CancellationToken cancellationToken) +{ + try + { + return await _cardKit.CreateStreamingHandleAsync( + chatId, + null, + latestRenderedContent, + effectiveOptions.DefaultCardTitle, + cancellationToken, + effectiveOptions, + chrome); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create replacement Feishu card-action streaming card for chat {ChatId}", chatId); + return null; + } +} +``` + +- [ ] **Step 10: Route both card-action streaming methods through the shared session wrapper** + +Update both `ExecuteCliAndStreamAsync(...)` and `ExecuteLowInterruptionContinueAndStreamAsync(...)` in `FeishuCardActionService.cs` to use the wrapper in the same pattern as the channel service: + +```csharp +var cardSession = new FeishuStreamingCardSession( + handle, + (stoppedHandle, latestContent, token) => TryCreateReplacementStreamingHandleAsync( + chatId, + latestContent, + streamingChrome, + effectiveOptions, + token)); + +... +var updateSucceeded = await cardSession.UpdateAsync(displayContent, executionCancellationToken); +handle = cardSession.CurrentHandle; +``` + +- [ ] **Step 11: Run the focused streaming tests and verify they pass** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "HandleIncomingMessageAsync_ReplacesBrokenStreamingCardOnceAndFinishesOnReplacement|FeishuCardActionServiceTests" +``` + +Expected: + +- the channel-stream recovery test passes with exactly two handles +- the card-action recovery test passes with exactly two handles +- the final content appears only on the replacement card + +- [ ] **Step 12: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/FeishuStreamingCardSession.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingHandle.cs WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs +git commit -m "fix: continue feishu streams on replacement card" +``` + +### Task 3: Final disconnect fallback and agent-note update + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Modify: `docs/agent-notes/2026-05-20.md` + +- [ ] **Step 1: Write the failing test for second-card failure falling back to disconnect** + +Add one more channel-path test in `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs`: + +```csharp +[Fact] +public async Task HandleIncomingMessageAsync_WhenReplacementCardAlsoFails_AppendsDisconnectMessage() +{ + var cardKit = new StreamingRecordingFeishuCardKitClient(); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + cardKit.FailUpdateAttemptSequence.Enqueue(1); + ... + Assert.Equal(2, cardKit.Handles.Count); + Assert.Contains("飞书流式更新断连", cardKit.Handles[1].FinalContent); +} +``` + +- [ ] **Step 2: Ensure disconnect fallback still appends the existing terminal message after replacement exhaustion** + +Keep `TryHandleStreamingCardDisconnectAsync(...)` in both services as the final fallback, but only invoke it after: + +```csharp +if (!updateSucceeded) +{ + var disconnectedContent = await TryHandleStreamingCardDisconnectAsync(...); + ... +} +``` + +Do not call disconnect fallback immediately when the first handle stops if replacement still succeeded. + +- [ ] **Step 3: Run the focused recovery tests and verify replacement exhaustion works** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "Disconnect|Replacement|FeishuCardKitClientTests" +``` + +Expected: + +- same-card recovery tests pass +- one-time replacement tests pass +- second-card failure test passes with the existing disconnect message + +- [ ] **Step 4: Update the daily agent note with the confirmed working rule** + +Append a new section to `docs/agent-notes/2026-05-20.md`: + +```markdown +## Feishu streaming card recovery must first preserve the current card after timeout-plus-sequence-conflict, then fall back to at most one replacement card + +- Symptom: + - CardKit streaming could stop after a timeout followed by `300317 sequence number compare failed`, even though the prior timed-out write likely already succeeded. +- Working rule: + - Treat retry-after-timeout `300317` as a same-card success signal. + - Only create one replacement streaming card when the current handle is truly no longer writable. + - If the replacement card also fails, fall back to the existing disconnect text. +``` + +- [ ] **Step 5: Run the full targeted Feishu test suite** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "Feishu" +``` + +Expected: + +- Feishu-related tests pass +- no regression in existing card completion, quick action, or attachment tests + +- [ ] **Step 6: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs docs/agent-notes/2026-05-20.md WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs +git commit -m "docs: record feishu streaming card recovery rules" +``` diff --git a/docs/superpowers/plans/2026-05-23-feishu-streaming-finish-shutdown-plan.md b/docs/superpowers/plans/2026-05-23-feishu-streaming-finish-shutdown-plan.md new file mode 100644 index 0000000..0960a26 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-feishu-streaming-finish-shutdown-plan.md @@ -0,0 +1,74 @@ +# Feishu Streaming Finish Shutdown Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop channel-side Feishu background update loops before final completion writes without canceling the replacement handle token needed for a successful finish. + +**Architecture:** Keep the change scoped to `FeishuChannelService`. Separate background-loop cancellation from final channel cleanup so replacement handles created by background updates are still finishable during normal completion. + +**Tech Stack:** C#, xUnit, existing Feishu streaming test doubles + +--- + +### Task 1: Reproduce the completion race in a regression test + +**Files:** +- Modify: `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` + +- [ ] **Step 1: Write the failing test** + +Add a test where the second update attempt comes from the status pulse, triggers replacement-card creation, and the replacement handle finish fails if its creation token was canceled before `FinishAsync(...)`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "HandleIncomingMessageAsync_WhenBackgroundReplacementHandleUsesCanceledToken_SkipsNormalCompletion"` + +Expected: FAIL because the current channel completion path cancels background update work before the final finish. + +- [ ] **Step 3: Add minimal test double support** + +Add a focused card-kit test double that can fail the original handle on a later update attempt and can inspect the cancellation token used to create the replacement handle. + +- [ ] **Step 4: Re-run the test** + +Run: `dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "HandleIncomingMessageAsync_WhenBackgroundReplacementHandleUsesCanceledToken_SkipsNormalCompletion"` + +Expected: still FAIL, now for the intended behavior assertion. + +### Task 2: Separate background-loop shutdown from final card completion + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` + +- [ ] **Step 1: Add a dedicated background-update cancellation source** + +Keep status-pulse and external-history-backfill loop cancellation separate from the existing update-work cancellation. + +- [ ] **Step 2: Stop and await background tasks before final finish** + +Cancel the background-loop token, await both tasks, then perform the final `cardSession.FinishAsync(...)` write. + +- [ ] **Step 3: Preserve existing final cleanup** + +Keep `finally` cleanup safe and idempotent so shutdown still works on success, error, and cancellation paths. + +- [ ] **Step 4: Run the targeted regression test** + +Run: `dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "HandleIncomingMessageAsync_WhenBackgroundReplacementHandleUsesCanceledToken_SkipsNormalCompletion"` + +Expected: PASS + +### Task 3: Verify channel-side regressions + +**Files:** +- Modify: `docs/agent-notes/2026-05-23.md` + +- [ ] **Step 1: Update the daily note with the confirmed fix pattern** + +Record that channel-side normal completion now waits for background update tasks to exit before final card completion. + +- [ ] **Step 2: Run focused Feishu channel tests** + +Run: `dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "FeishuChannelServiceTests"` + +Expected: PASS diff --git a/docs/superpowers/plans/2026-05-27-feishu-final-only-reply-tts-implementation.md b/docs/superpowers/plans/2026-05-27-feishu-final-only-reply-tts-implementation.md new file mode 100644 index 0000000..148c823 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-feishu-final-only-reply-tts-implementation.md @@ -0,0 +1,865 @@ +# Feishu Final-Only Reply TTS Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a second Feishu reply-TTS mode that speaks only the current turn's structured Codex `final_answer` content while preserving the existing full-reply speech mode. + +**Architecture:** Preserve assistant message `phase` in the Codex structured streaming path, accumulate both full-reply and final-only text during Feishu streaming, and let a new `ReplyTtsMode` configuration choose which completed text the existing reply-TTS orchestrator consumes. Keep rollout re-read as an optional Codex-only fallback when the live final-answer buffer is empty. + +**Tech Stack:** ASP.NET Core, Blazor Server, SqlSugar entities, existing CLI adapter/event pipeline, Feishu streaming card services, xUnit test projects, current reply-TTS orchestration and audio delivery services. + +Depends on: +- `docs/superpowers/specs/2026-05-27-feishu-final-only-reply-tts-design.md` + +--- + +## File Map + +### Configuration and persistence + +- Modify: `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` + Replace the single reply-TTS boolean with a mode field while keeping the voice field intact. +- Modify: `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` + Normalize and persist the new mode, including legacy boolean compatibility mapping. +- Modify: `WebCodeCli/Controllers/AdminController.cs` + Read and write the new mode through the admin Feishu bot config API. +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor` + Render two mutually exclusive speech-mode choices instead of a single toggle. +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor.cs` + Carry the new mode field through modal load/save state. +- Modify: `WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs` + Adjust derived UI state for `Off`, `FullReply`, and `FinalOnly`. + +### Feishu quick toggle UI and actions + +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs` + Add a dedicated help-card action for final-only reply TTS mode. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs` + Show both `语音回复` and `结论语音回复` actions as mutually exclusive top-row buttons. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs` + Resolve the effective reply-TTS mode when building help cards. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` + Toggle between `Off`, `FullReply`, and `FinalOnly` from help-card callbacks and update toast/card text. + +### Codex structured event preservation + +- Modify: `WebCodeCli.Domain/Domain/Service/Adapters/CliOutputEvent.cs` + Add assistant phase metadata to the structured adapter event model. +- Modify: `WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs` + Preserve assistant `phase` when normalizing Codex app-server notifications to adapter-facing JSONL. +- Modify: `WebCodeCli.Domain/Domain/Service/Adapters/CodexAdapter.cs` + Parse assistant `phase` into `CliOutputEvent` while preserving current assistant-text extraction behavior. + +### Feishu completed-reply text selection + +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs` + Carry both the merged completed output and the final-answer-only output. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` + Accumulate full assistant text and final-answer-only text during normal streaming, reset both at turn boundaries, and queue both values into the completed reply TTS request. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` + Mirror the same dual-buffer behavior for card-action and low-interruption streaming paths. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs` + Choose speech text based on the new mode, silently skipping `FinalOnly` when no final-answer text exists. + +### Optional Codex rollout fallback + +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs` + Add a narrowly-scoped Codex-only fallback that can re-read the latest rollout file for `final_answer` content when the live final-answer buffer is empty. +- Modify: `WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs` + Expose or factor the rollout parsing logic needed to extract only assistant `final_answer` text without duplicating Codex rollout parsing. + +### Tests + +- Modify: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + Cover new help-card toggles, mutual exclusion, and final-only mode toasts. +- Modify: `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` + Cover full vs final-only completed-reply accumulation and turn-boundary resets. +- Modify: `WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs` + Cover `Off`, `FullReply`, `FinalOnly`, silent skip, and rollout fallback behavior. +- Add: `WebCodeCli.Domain.Tests/CodexAdapterTests.cs` + Cover assistant phase parsing from normalized JSONL. +- Add: `WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs` + Cover normalized assistant-message JSONL including `phase`. +- Add: `tests/WebCodeCli.Tests/AdminUserManagementReplyTtsModeTests.cs` + Cover admin DTO/UI state compatibility and mode persistence. + +### Explicit non-goals + +- Do not implement text slicing, regex extraction, or summarization heuristics for conclusions. +- Do not change the visible Feishu card body to final-only text. +- Do not change non-Codex providers unless their adapters already expose equivalent structured phases. +- Do not block text completion on rollout fallback success. + +--- + +## Chunk 1: Replace the Boolean Reply-TTS Switch with a Mode + +### Task 1: Add `ReplyTtsMode` to Feishu bot config and admin persistence + +**Files:** +- Modify: `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` +- Modify: `WebCodeCli/Controllers/AdminController.cs` +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor.cs` +- Modify: `WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs` +- Test: `tests/WebCodeCli.Tests/AdminUserManagementReplyTtsModeTests.cs` + +- [ ] **Step 1: Write the failing persistence and compatibility tests** + +Add tests that prove: + +- legacy `ReplyTtsEnabled = true` maps to `FullReply` +- legacy `ReplyTtsEnabled = false` maps to `Off` +- a saved `FinalOnly` mode round-trips through the admin DTO and persistence service +- voice-selection UI state still disables correctly when the mode is `Off` + +```csharp +[Fact] +public void Create_WhenModeIsOff_DisablesVoiceSelection() +{ + var state = AdminUserManagementReplyTtsUiState.Create( + replyTtsMode: "Off", + selectedVoiceId: "voice-a", + availableVoices: []); + + Assert.True(state.IsVoiceSelectorDisabled); +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~ReplyTtsMode" +``` + +Expected: + +- FAIL because the new mode field and compatibility logic do not exist yet + +- [ ] **Step 3: Add the mode field to the entity and DTO surface** + +Update the entity and admin DTOs to include a mode field while temporarily preserving boolean compatibility during migration. + +Use a simple string-backed mode: + +```csharp +public static class ReplyTtsModes +{ + public const string Off = "Off"; + public const string FullReply = "FullReply"; + public const string FinalOnly = "FinalOnly"; +} +``` + +Persist the effective mode on the entity and expose it through the admin request/response model. + +- [ ] **Step 4: Implement compatibility mapping in the config service** + +In `UserFeishuBotConfigService`, normalize incoming mode values and map legacy boolean state to: + +- `true -> FullReply` +- `false -> Off` + +Reject unknown values by normalizing them to `Off`. + +- [ ] **Step 5: Update admin modal models and UI-state helper** + +Make the modal editor, payload model, and UI-state helper all use the new mode field rather than a single boolean toggle. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~ReplyTtsMode" +``` + +Expected: + +- PASS + +- [ ] **Step 7: Commit** + +```powershell +git add WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs WebCodeCli/Controllers/AdminController.cs WebCodeCli/Components/AdminUserManagementModal.razor.cs WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs tests/WebCodeCli.Tests/AdminUserManagementReplyTtsModeTests.cs +git commit -m "feat: add feishu reply tts mode config" +``` + +### Task 2: Replace the help-card boolean toggle with explicit full/final-only actions + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +- [ ] **Step 1: Write the failing help-card action tests** + +Add tests that prove: + +- clicking `toggle_reply_tts` still toggles full-reply mode +- clicking the new final-only action sets mode to `FinalOnly` +- enabling one mode disables the other +- toast text and card labels reflect the effective mode + +```csharp +[Fact] +public async Task HandleCardActionAsync_WhenToggleFinalOnlyReplyTts_SetsFinalOnlyMode() +{ + var response = await service.HandleCardActionAsync( + """{"action":"toggle_final_only_reply_tts"}""", + chatId: "oc_final_only_tts_chat"); + + Assert.Equal("✅ 已开启结论语音回复", ExtractToastContent(response)); + Assert.Contains("结论语音回复:开", ExtractCardContentStrings(response)); + Assert.Contains("语音回复:关", ExtractCardContentStrings(response)); +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTts" +``` + +Expected: + +- FAIL because the final-only action and new card text do not exist yet + +- [ ] **Step 3: Add the new help-card action constant and card labels** + +Add: + +```csharp +public const string ToggleFinalOnlyReplyTtsAction = "toggle_final_only_reply_tts"; +``` + +Update the help card to show two explicit buttons: + +- `语音回复:开/关` +- `结论语音回复:开/关` + +Render the active one as `primary` and the inactive one as `default`. + +- [ ] **Step 4: Implement mutually exclusive action handlers** + +In `FeishuCardActionService`, make the handlers set the effective mode directly: + +- full action toggles between `FullReply` and `Off` +- final-only action toggles between `FinalOnly` and `Off` + +Do not keep a state where both are active. + +- [ ] **Step 5: Use the mode when building help cards** + +Replace boolean `replyTtsEnabled` lookups with mode-aware resolution in `FeishuMessageHandler` and any other help-card builder call sites. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTts" +``` + +Expected: + +- PASS + +- [ ] **Step 7: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs +git commit -m "feat: add feishu final-only tts toggle actions" +``` + +--- + +## Chunk 2: Preserve Codex Assistant Phase in Structured Streaming + +### Task 3: Extend the structured CLI output event model with assistant phase + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Adapters/CliOutputEvent.cs` +- Add: `WebCodeCli.Domain.Tests/CodexAdapterTests.cs` + +- [ ] **Step 1: Write the failing Codex adapter phase test** + +Add a test that proves a normalized assistant-message JSONL line with `phase = "final_answer"` is parsed into: + +- `ItemType = "agent_message"` +- `Content = "hello"` +- `AssistantPhase = "final_answer"` + +```csharp +[Fact] +public void ParseOutputLine_WhenAgentMessageIncludesPhase_PreservesAssistantPhase() +{ + var adapter = new CodexAdapter(); + var line = """{"type":"item.updated","item":{"type":"agent_message","text":"hello","phase":"final_answer"}}"""; + + var outputEvent = adapter.ParseOutputLine(line); + + Assert.NotNull(outputEvent); + Assert.Equal("agent_message", outputEvent!.ItemType); + Assert.Equal("hello", outputEvent.Content); + Assert.Equal("final_answer", outputEvent.AssistantPhase); +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~CodexAdapterTests" +``` + +Expected: + +- FAIL because `AssistantPhase` does not exist yet + +- [ ] **Step 3: Add the `AssistantPhase` property** + +Update `CliOutputEvent` with: + +```csharp +public string? AssistantPhase { get; set; } +``` + +Keep it optional and do not require non-Codex adapters to populate it. + +- [ ] **Step 4: Run the test again to confirm it still fails for parsing** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~CodexAdapterTests" +``` + +Expected: + +- FAIL because the Codex adapter still ignores phase + +- [ ] **Step 5: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Adapters/CliOutputEvent.cs WebCodeCli.Domain.Tests/CodexAdapterTests.cs +git commit -m "refactor: add assistant phase to cli output events" +``` + +### Task 4: Preserve and parse assistant phase in Codex normalized JSONL + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Adapters/CodexAdapter.cs` +- Add: `WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs` +- Modify: `WebCodeCli.Domain.Tests/CodexAdapterTests.cs` + +- [ ] **Step 1: Write the failing app-server normalization test** + +Add a test that proves `item/agentMessage/delta` with a `phase` parameter produces JSONL that still includes `phase`. + +```csharp +[Fact] +public void BuildAgentMessageDeltaPayload_WhenPhaseExists_IncludesPhase() +{ + var json = InvokeBuildCliOutputJsonl( + method: "item/agentMessage/delta", + paramsJson: """{"delta":"done","phase":"final_answer","itemId":"item-1"}"""); + + Assert.Contains(@"""phase"":""final_answer""", json); +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~CodexAppServerSessionManagerTests|FullyQualifiedName~CodexAdapterTests" +``` + +Expected: + +- FAIL because normalized JSONL and adapter parsing still drop phase + +- [ ] **Step 3: Preserve `phase` in normalized assistant-message payloads** + +Update `BuildAgentMessageDeltaPayload(...)` so the generated `item.updated.item` includes `phase` whenever the app-server notification provides it. + +- [ ] **Step 4: Parse `phase` in the Codex adapter** + +Update the `agent_message` parsing path in both `item.updated` and `item.completed` handling so `outputEvent.AssistantPhase` is set from `item.phase`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~CodexAppServerSessionManagerTests|FullyQualifiedName~CodexAdapterTests" +``` + +Expected: + +- PASS + +- [ ] **Step 6: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/CodexAppServerSessionManager.cs WebCodeCli.Domain/Domain/Service/Adapters/CodexAdapter.cs WebCodeCli.Domain.Tests/CodexAppServerSessionManagerTests.cs WebCodeCli.Domain.Tests/CodexAdapterTests.cs +git commit -m "feat: preserve codex assistant phase in streaming events" +``` + +--- + +## Chunk 3: Accumulate Final-Only Text in Feishu Streaming + +### Task 5: Extend the completed-reply TTS request with final-answer text + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs` +- Modify: `WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs` + +- [ ] **Step 1: Write the failing orchestrator mode-selection tests** + +Add tests that prove: + +- `FullReply` uses `Output` +- `FinalOnly` uses `FinalAnswerOutput` +- `FinalOnly` with empty `FinalAnswerOutput` skips audio and sends no failure notice + +```csharp +[Fact] +public async Task QueueCompletedReplyAsync_WhenModeIsFinalOnlyAndFinalAnswerMissing_SkipsAudioSilently() +{ + var request = new FeishuCompletedReplyTtsRequest + { + ChatId = "oc_final_only_skip", + Output = "commentary text", + FinalAnswerOutput = "", + Username = "alice" + }; + + await orchestrator.QueueCompletedReplyAsync(request); + + await WaitForQueueDrainAsync(); + Assert.Empty(audioMessageService.SentAudioMessages); + Assert.Empty(cardKitClient.SentTextMessages); +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests" +``` + +Expected: + +- FAIL because the request model lacks `FinalAnswerOutput` and the orchestrator only knows one text source + +- [ ] **Step 3: Extend the request contract** + +Add: + +```csharp +public string FinalAnswerOutput { get; set; } = string.Empty; +``` + +Optionally include an effective `ReplyTtsMode` property if that simplifies downstream selection and reduces extra config lookups. + +- [ ] **Step 4: Update the orchestrator selection logic** + +Choose speech text by effective mode: + +- `FullReply -> Output` +- `FinalOnly -> FinalAnswerOutput` +- `Off -> return` + +If `FinalOnly` and `FinalAnswerOutput` is empty or whitespace, return without enqueueing synthesis or failure notice work. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests" +``` + +Expected: + +- PASS + +- [ ] **Step 6: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs +git commit -m "feat: add final-only reply tts selection" +``` + +### Task 6: Add dual text buffers to Feishu normal streaming completion + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` + +- [ ] **Step 1: Write the failing Feishu normal-stream tests** + +Add tests that prove: + +- commentary text contributes to the full assistant buffer +- `final_answer` text contributes to both the full buffer and the final-only buffer +- queued TTS request includes both `Output` and `FinalAnswerOutput` +- goal-runtime turn boundary clears both buffers + +```csharp +[Fact] +public async Task ExecuteCliToolAndStreamReplyAsync_WhenCodexOutputsCommentaryAndFinalAnswer_QueuesBothBuffers() +{ + // Arrange a stream with commentary, final_answer, and completion. + // Assert queued request Output == full merged text and FinalAnswerOutput == final-only text. +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuChannelServiceTests" +``` + +Expected: + +- FAIL because the service only accumulates one assistant buffer + +- [ ] **Step 3: Add a final-only buffer to the streaming path** + +In `ProcessJsonlLine(...)`, continue appending every assistant message to the existing assistant buffer, but also append to a second buffer when: + +```csharp +outputEvent.AssistantPhase == "final_answer" +``` + +- [ ] **Step 4: Reset both buffers on turn handoff** + +At the current goal-runtime turn-boundary reset points, clear: + +- full assistant buffer +- final-only assistant buffer +- JSONL buffer if already cleared today + +- [ ] **Step 5: Queue both texts into the completed reply TTS request** + +When creating `FeishuCompletedReplyTtsRequest`, populate: + +- `Output = finalOutput` +- `FinalAnswerOutput = finalAnswerOutput` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuChannelServiceTests" +``` + +Expected: + +- PASS + +- [ ] **Step 7: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs +git commit -m "feat: accumulate final-only tts text in feishu channel" +``` + +### Task 7: Add dual text buffers to Feishu card-action and low-interruption streaming + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +- [ ] **Step 1: Write the failing card-action streaming tests** + +Add tests that prove: + +- card-action streaming queues both `Output` and `FinalAnswerOutput` +- low-interruption continue also queues both text buffers +- final-only mode still resets per goal-runtime turn + +```csharp +[Fact] +public async Task HandleCardActionAsync_LowInterruptionContinue_PreservesFinalOnlyBufferPerTurn() +{ + // Arrange Codex output with commentary + final_answer. + // Assert queued TTS request contains final-only text for the current turn only. +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuCardActionServiceTests" +``` + +Expected: + +- FAIL because the card-action streaming path still tracks only one assistant buffer + +- [ ] **Step 3: Mirror the dual-buffer logic in `FeishuCardActionService`** + +Update both the ordinary card-action streaming path and the low-interruption continue path to accumulate: + +- full assistant text +- final-only assistant text + +Use the same `AssistantPhase == "final_answer"` gate as the normal channel path. + +- [ ] **Step 4: Queue both texts into the TTS request** + +Update `TryQueueCompletedReplyTtsAsync(...)` callers so they pass both full and final-only text. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuCardActionServiceTests" +``` + +Expected: + +- PASS + +- [ ] **Step 6: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs +git commit -m "feat: accumulate final-only tts text in feishu card actions" +``` + +--- + +## Chunk 4: Add Conservative Codex Rollout Fallback + +### Task 8: Expose a Codex final-answer rollout extraction path + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs` +- Modify: `WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs` + +- [ ] **Step 1: Write the failing rollout fallback extraction tests** + +Add tests that prove a rollout stream containing: + +- assistant commentary message +- assistant final-answer message + +returns only the final-answer text for fallback purposes. + +```csharp +[Fact] +public void ExtractCodexFinalAnswerText_WhenRolloutContainsCommentaryAndFinalAnswer_ReturnsFinalOnly() +{ + var jsonl = new[] + { + """{"type":"response_item","payload":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"thinking"}]}}""", + """{"type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"answer"}]}}""" + }; + + var result = ExtractFinalAnswerText(jsonl); + + Assert.Equal("answer", result); +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests" +``` + +Expected: + +- FAIL because no final-only rollout extraction exists yet + +- [ ] **Step 3: Add a reusable Codex final-answer extraction helper** + +Factor the existing rollout parsing logic so a caller can extract only assistant `final_answer` content from Codex rollout files without duplicating parser code. + +- [ ] **Step 4: Run tests to verify extraction works** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests" +``` + +Expected: + +- PASS + +- [ ] **Step 5: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs +git commit -m "refactor: add codex final-answer rollout extraction" +``` + +### Task 9: Use rollout fallback only for Codex final-only reply TTS + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs` +- Modify: `WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs` + +- [ ] **Step 1: Write the failing fallback behavior tests** + +Add tests that prove: + +- when mode is `FinalOnly` and live `FinalAnswerOutput` is empty, the orchestrator tries Codex rollout fallback once +- when fallback returns final-answer text, audio is generated from that text +- when fallback returns empty or throws, the orchestrator skips audio silently +- full-reply mode never uses this fallback + +```csharp +[Fact] +public async Task QueueCompletedReplyAsync_WhenFinalOnlyLiveTextMissing_UsesCodexRolloutFallback() +{ + // Arrange request with empty FinalAnswerOutput and a mocked fallback extractor returning "final text". + // Assert synthesis receives "final text". +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests" +``` + +Expected: + +- FAIL because rollout fallback is not wired into the orchestrator + +- [ ] **Step 3: Implement the conservative fallback** + +In `ReplyTtsOrchestrator`, only attempt fallback when all conditions hold: + +- tool/provider context is Codex-capable +- reply-TTS mode is `FinalOnly` +- `FinalAnswerOutput` is empty +- the request completed normally +- session/thread context is sufficient to locate the rollout + +If fallback yields text, use it. +If not, return silently. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests" +``` + +Expected: + +- PASS + +- [ ] **Step 5: Commit** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs +git commit -m "feat: add codex final-only reply tts rollout fallback" +``` + +--- + +## Chunk 5: Verification and Cleanup + +### Task 10: Run the full targeted regression suite + +**Files:** +- Modify: `docs/agent-notes/2026-05-27.md` + +> Execution status on 2026-05-27: +> - Steps 1-3 were completed and verified in-session. +> - Step 4 remains intentionally unchecked because no commit was created in this run. + +- [x] **Step 1: Run the domain test targets** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTts|FullyQualifiedName~FeishuChannelServiceTests|FullyQualifiedName~FeishuCardActionServiceTests|FullyQualifiedName~CodexAdapterTests|FullyQualifiedName~CodexAppServerSessionManagerTests" +``` + +Expected: + +- PASS + +- [x] **Step 2: Run the web/admin test targets** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~ReplyTtsMode" +``` + +Expected: + +- PASS + +- [x] **Step 3: Update the current agent note with any implementation-specific findings** + +Add any confirmed constraints discovered during implementation, such as: + +- exact fallback timing behavior +- any provider-specific phase quirks +- any migration nuance for legacy boolean config + +- [ ] **Step 4: Commit** + +```powershell +git add docs/agent-notes/2026-05-27.md +git commit -m "test: verify feishu final-only reply tts changes" +``` + +--- + +## Self-Review + +### Spec coverage + +- product definition and mutual exclusivity are covered in Tasks 1-2 +- assistant phase preservation is covered in Tasks 3-4 +- dual full/final-only buffering is covered in Tasks 6-7 +- TTS mode selection and silent skip are covered in Task 5 +- rollout fallback is covered in Tasks 8-9 +- regression verification and implementation notes are covered in Task 10 + +### Placeholder scan + +- no `TODO`, `TBD`, or deferred "implement later" placeholders remain +- each test or code change task includes concrete files and commands + +### Type consistency + +- the plan consistently uses `ReplyTtsMode`, `AssistantPhase`, and `FinalAnswerOutput` +- `FinalOnly` and `FullReply` are used consistently as the canonical mode values diff --git a/docs/superpowers/plans/2026-05-28-feishu-reply-documents-implementation.md b/docs/superpowers/plans/2026-05-28-feishu-reply-documents-implementation.md new file mode 100644 index 0000000..d71c414 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-feishu-reply-documents-implementation.md @@ -0,0 +1,568 @@ +# Feishu Reply Documents Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Feishu reply-TTS feature with two independent Feishu cloud-document toggles: `完整回复文档` and `结论回复文档`, reuse the existing completed-reply turn pipeline, and remove TTS-specific runtime, UI, packaging, and tests. + +**Architecture:** Rename the completed-reply TTS orchestration pipeline into a completed-reply document pipeline, keep the existing turn-boundary accumulation of `Output` and `FinalAnswerOutput`, add document creation/writing/permission APIs to the existing Feishu client, migrate config from mode-based TTS to two independent document booleans, and delete TTS-only code paths after the new document flow passes. + +**Tech Stack:** ASP.NET Core, Blazor Server, SqlSugar entities, existing Feishu message/card client, Codex structured output pipeline, xUnit test projects, existing chat/session repository, Feishu cloud-document APIs. + +Depends on: +- `docs/superpowers/specs/2026-05-28-feishu-reply-documents-design.md` + +--- + +## File Map + +### Configuration and persistence + +- Modify: `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` + Replace reply-TTS mode/voice fields with two independent document toggles. +- Modify: `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` + Migrate old reply-TTS data into new document toggles and normalize saved config. +- Modify: `WebCodeCli/Controllers/AdminController.cs` + Replace reply-TTS DTO fields with full/final document booleans. +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor` + Replace reply-TTS mode controls with two independent document toggles. +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor.cs` + Carry the new document toggles through load/save state. +- Modify or replace: `WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs` + Remove voice/TTS-specific UI state and replace it with document-toggle UI state as needed. + +### Feishu quick toggle UI and actions + +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs` + Replace reply-TTS actions with `完整回复文档` and `结论回复文档` actions. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs` + Render two independent document toggles instead of reply-TTS mode buttons. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs` + Resolve document-toggle state when building help cards. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` + Toggle full/final document flags independently and update toast/card text. + +### Completed reply request and orchestration rename + +- Modify and rename: `WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs` + Rename to a completed reply document request model and add `CliThreadId` and `OriginalUserQuestion`. +- Modify and rename: `WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs` + Rename to a reply-document orchestrator interface. +- Modify and rename: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs` + Replace TTS processing with document generation and link-message sending. + +### Completion producers and turn context + +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` + Continue accumulating `Output` / `FinalAnswerOutput`, but enqueue completed document requests with thread/question context. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` + Mirror the same behavior for card-action completion and low-interruption flows. +- Modify as needed: session/context lookup call sites + Resolve `CliThreadId` from current session and preserve original user question through completion. + +### Feishu document client support + +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs` + Add methods for document create, document body write, and permission patch. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` + Implement Feishu cloud-document API calls while reusing tenant token retrieval. +- Reuse: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` + Use the existing text-message send path for link delivery. + +### Optional Codex final-answer fallback + +- Modify and rename call sites in the new document orchestrator + Keep the Codex-only `final_answer` rollout fallback only for `结论回复文档`. +- Modify: `WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs` + Reuse the existing structured final-answer extraction path without TTS-specific naming. + +### TTS runtime removal + +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsLocalServiceManager.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/ISherpaKokoroTtsClient.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/SherpaKokoroTtsClient.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs` +- Delete: `WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs` +- Delete: audio-transcode interfaces/implementations used only by reply TTS +- Remove TTS-related options/models/service registration paths + +### Packaging cleanup + +- Modify: `tools/build-windows-installer.ps1` + Remove reply-TTS-only payload bundling and startup-time assumptions. +- Modify related installer/package files if referenced + Remove TTS asset copy, bundled runtime, or release-note sections that only exist for reply TTS. + +### Tests + +- Modify or replace: `tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs` +- Modify or replace: `tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs` +- Modify or replace: `tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs` +- Modify or replace: `tests/WebCodeCli.Tests/AdminUserManagementReplyTtsModeTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` +- Modify or replace: `WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs` +- Add if needed: dedicated document-orchestrator tests after rename +- Modify: `WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs` +- Remove TTS-only client/platform tests that no longer apply + +### Explicit non-goals + +- Do not preserve any reply-TTS audio behavior. +- Do not keep voice selection UI or voice persistence. +- Do not inject document links into the streaming/final card body. +- Do not generate a mixed full+final single document when both toggles are enabled. +- Do not implement text heuristics for conclusion extraction. + +--- + +## Chunk 1: Replace Reply-TTS Config with Reply-Document Toggles + +### Task 1: Migrate persistence, DTOs, and admin UI from reply-TTS mode to two document booleans + +**Files:** +- Modify: `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` +- Modify: `WebCodeCli/Controllers/AdminController.cs` +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor` +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor.cs` +- Modify or replace: `WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs` +- Modify or replace tests under `tests/WebCodeCli.Tests/` + +- [ ] **Step 1: Write the failing admin/config migration tests** + +Add tests that prove: + +- old `ReplyTtsMode = FullReply` maps to `FullReplyDocEnabled = true` +- old `ReplyTtsMode = FinalOnly` maps to `FinalReplyDocEnabled = true` +- old `ReplyTtsMode = Off` maps to both false +- admin DTO round-trips the two new booleans +- no voice-selection state remains in the UI model + +- [ ] **Step 2: Run the focused admin test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~ReplyDoc|FullyQualifiedName~AdminUserManagement" +``` + +Expected: + +- FAIL because the new document fields do not exist yet + +- [ ] **Step 3: Replace entity/controller/modal fields with document booleans** + +Persist: + +- `FullReplyDocEnabled` +- `FinalReplyDocEnabled` + +Remove reply-TTS mode and voice fields from the admin surface. + +- [ ] **Step 4: Implement compatibility migration in the config service** + +Map old saved mode values into the new booleans during normalization/save. + +- [ ] **Step 5: Update admin modal copy and controls** + +Render: + +- `完整回复文档` +- `结论回复文档` + +as independent toggles rather than a mode selector. + +- [ ] **Step 6: Run focused admin tests and verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~ReplyDoc|FullyQualifiedName~AdminUserManagement" +``` + +Expected: + +- PASS + +--- + +## Chunk 2: Replace Help-Card Reply-TTS Actions with Reply-Document Actions + +### Task 2: Convert help-card quick toggles to independent document switches + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Modify tests: `WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs`, `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +- [ ] **Step 1: Write the failing help-card document-toggle tests** + +Add tests that prove: + +- `完整回复文档` and `结论回复文档` both render +- both can be on simultaneously +- toggling one does not forcibly disable the other +- toast text reflects document behavior rather than TTS behavior + +- [ ] **Step 2: Run the focused domain tests and confirm they fail** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuHelpCardBuilderTests|FullyQualifiedName~FeishuCardActionServiceTests" +``` + +Expected: + +- FAIL because the new actions and copy do not exist yet + +- [ ] **Step 3: Replace reply-TTS actions with document actions** + +Suggested action constants: + +- `toggle_full_reply_doc` +- `toggle_final_reply_doc` + +- [ ] **Step 4: Implement independent toggle handlers** + +Persist and toast the two booleans independently. + +- [ ] **Step 5: Update all help-card builder paths** + +Include filtered-card and alternate builder paths so the document toggle state stays consistent everywhere. + +- [ ] **Step 6: Run focused domain tests and verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuHelpCardBuilderTests|FullyQualifiedName~FeishuCardActionServiceTests" +``` + +Expected: + +- PASS + +--- + +## Chunk 3: Rename the Completed Reply TTS Pipeline to a Reply-Document Pipeline + +### Task 3: Rename the completed reply request and orchestrator surface + +**Files:** +- Modify and rename: `WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs` +- Modify and rename: `WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs` +- Modify and rename: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs` +- Modify dependent call sites + +- [ ] **Step 1: Write failing orchestrator API-shape tests** + +Add tests that prove the orchestrator now reasons in terms of: + +- full reply document +- final reply document +- `CliThreadId` +- `OriginalUserQuestion` + +- [ ] **Step 2: Run the focused orchestrator tests and confirm they fail** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests" +``` + +Expected: + +- FAIL because the renamed document request/orchestrator surface does not exist yet + +- [ ] **Step 3: Rename request/orchestrator types and queue methods** + +Keep the asynchronous per-chat serialization behavior intact. + +- [ ] **Step 4: Expand the completed reply request payload** + +Add: + +- `CliThreadId` +- `OriginalUserQuestion` + +- [ ] **Step 5: Update all compile references** + +Rename and rebuild all call sites before changing behavior. + +- [ ] **Step 6: Run focused orchestrator tests and verify they compile to the new surface** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyDoc|FullyQualifiedName~Orchestrator" +``` + +Expected: + +- PASS or at least reach the new behavior-level failures for the next task + +--- + +## Chunk 4: Carry Thread ID and Original Question Through Completion + +### Task 4: Enqueue completed document requests with full/final output plus title context + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Modify tests: `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs`, `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +- [ ] **Step 1: Write failing completion-payload tests** + +Add tests that prove: + +- normal streaming completion enqueues `Output`, `FinalAnswerOutput`, `OriginalUserQuestion`, and `CliThreadId` +- card-action completion does the same +- goal-runtime turn-boundary still queues before clearing buffers + +- [ ] **Step 2: Run the focused completion tests and confirm they fail** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuChannelServiceTests|FullyQualifiedName~FeishuCardActionServiceTests" +``` + +Expected: + +- FAIL because title-context fields are not populated yet + +- [ ] **Step 3: Thread the original user question through completion** + +Use the current turn's original user message, not normalized CLI prompt text. + +- [ ] **Step 4: Resolve and attach `CliThreadId`** + +Prefer current session `CliThreadId`; allow orchestrator fallback to `SessionId` if absent. + +- [ ] **Step 5: Preserve turn-boundary queue-before-clear ordering** + +Do not regress the current goal-runtime boundary behavior. + +- [ ] **Step 6: Run focused completion tests and verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuChannelServiceTests|FullyQualifiedName~FeishuCardActionServiceTests" +``` + +Expected: + +- PASS + +--- + +## Chunk 5: Add Feishu Cloud-Document Client Support + +### Task 5: Extend the existing Feishu client with document create/write/permission APIs + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` +- Add/modify tests around Feishu client request construction if present + +- [ ] **Step 1: Add failing client request-shape tests** + +Prove that the client sends the correct requests for: + +- document create +- body write +- tenant-readable permission patch + +- [ ] **Step 2: Run the focused client tests and confirm they fail** + +Run the relevant Feishu client test subset if available. + +- [ ] **Step 3: Implement document API methods in the existing client** + +Reuse: + +- tenant token resolution +- options override resolution +- business-success checking + +- [ ] **Step 4: Add URL construction helper** + +Return a document URL suitable for chat-link delivery. + +- [ ] **Step 5: Run focused client tests and verify they pass** + +Expected: + +- PASS + +--- + +## Chunk 6: Replace TTS Processing with Document Processing + +### Task 6: Implement full/final document generation in the renamed orchestrator + +**Files:** +- Modify renamed orchestrator and request tests +- Modify: `WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs` if fallback naming needs cleanup +- Modify or replace orchestrator tests + +- [ ] **Step 1: Write failing document-behavior tests** + +Add tests that prove: + +- both toggles off => no document work +- only full document on => one full document + one link message +- only final document on => one final document + one link message +- both toggles on => two documents + two link messages +- empty final content => final document skipped silently +- missing `CliThreadId` => title falls back to `SessionId` +- title suffixes are distinct +- partial failure in one branch does not block the other branch + +- [ ] **Step 2: Run the focused orchestrator tests and confirm they fail** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyDoc|FullyQualifiedName~Orchestrator" +``` + +Expected: + +- FAIL because document behavior is not implemented yet + +- [ ] **Step 3: Remove all TTS chunk/audio processing from the orchestrator** + +Delete: + +- normalization/chunking/audio/temp-file logic +- TTS retry/failure-notice behavior + +- [ ] **Step 4: Implement document generation branches** + +For each enabled branch: + +1. resolve body text +2. create document +3. write body +4. set tenant-readable permission +5. send text link message + +- [ ] **Step 5: Keep Codex-only final-answer fallback for final document** + +Reuse the current non-heuristic `final_answer` extraction path. + +- [ ] **Step 6: Run focused orchestrator tests and verify they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyDoc|FullyQualifiedName~Orchestrator|FullyQualifiedName~ExternalCliSessionHistoryServiceTests" +``` + +Expected: + +- PASS + +--- + +## Chunk 7: Delete TTS-Only Runtime and Packaging Code + +### Task 7: Remove TTS-only services, options, and installer bundling + +**Files:** +- Delete TTS-only runtime files under `WebCodeCli.Domain/Domain/Service/Channels/` +- Modify package/installer scripts such as `tools/build-windows-installer.ps1` +- Remove TTS-only tests + +- [ ] **Step 1: Identify all remaining compile/runtime references to deleted TTS code** + +Use ripgrep before deleting to avoid orphaned registrations or options usage. + +- [ ] **Step 2: Delete TTS-only runtime files and DI registrations** + +Remove code that no longer has any document-era consumer. + +- [ ] **Step 3: Remove installer/package TTS bundling** + +Delete asset copy, bundled runtime, and TTS-specific release-note assumptions. + +- [ ] **Step 4: Remove or rewrite TTS-only tests** + +Keep only tests that still validate reused completed-reply document behavior. + +- [ ] **Step 5: Run solution build to catch dangling references** + +Run: + +```powershell +dotnet build D:\VSWorkshop\WebCode\WebCodeCli.sln -c Debug +``` + +Expected: + +- PASS + +--- + +## Chunk 8: Final Verification + +### Task 8: Run focused regression suites and final build + +**Files:** +- No new files; verification only + +- [ ] **Step 1: Run focused domain document suites** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuChannelServiceTests|FullyQualifiedName~FeishuCardActionServiceTests|FullyQualifiedName~ReplyDoc|FullyQualifiedName~Orchestrator|FullyQualifiedName~ExternalCliSessionHistoryServiceTests|FullyQualifiedName~FeishuHelpCardBuilderTests" +``` + +Expected: + +- PASS + +- [ ] **Step 2: Run focused web/admin suites** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~AdminController|FullyQualifiedName~AdminUserManagement" +``` + +Expected: + +- PASS + +- [ ] **Step 3: Run final solution build** + +Run: + +```powershell +dotnet build D:\VSWorkshop\WebCode\WebCodeCli.sln -c Debug +``` + +Expected: + +- PASS + +- [ ] **Step 4: Update agent notes with notable implementation findings** + +Record any confirmed Feishu document permission, title, or fallback constraints in: + +- `docs/agent-notes/2026-05-28.md` + diff --git a/docs/superpowers/plans/2026-05-29-feishu-listening-reply-documents.md b/docs/superpowers/plans/2026-05-29-feishu-listening-reply-documents.md new file mode 100644 index 0000000..b2815b4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-feishu-listening-reply-documents.md @@ -0,0 +1,419 @@ +# Feishu Listening Reply Documents Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two independent Feishu reply-document variants, `听完整文档` and `听结论文档`, that coexist with the existing full/final reply documents and rewrite file-like references into `文件内容N` placeholders with an appendix mapping for listening. + +**Architecture:** Keep the existing full/final reply-document outputs unchanged. Extend the config, help-card, admin UI, and orchestrator with two additional document variants, and isolate the listening-only body rewrite behind a dedicated formatter helper used only by the orchestrator. + +**Tech Stack:** C#, ASP.NET Core / Blazor, xUnit, existing Feishu reply-document pipeline. + +--- + +## File Map + +### Existing files to modify + +- `WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs` + - add two new help-card action ids for toggling listening full/final reply documents +- `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` + - add two new persisted booleans for listening full/final reply documents +- `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` + - persist and backfill the two new booleans through existing config save paths +- `WebCodeCli/Controllers/AdminController.cs` + - expose the two new booleans on `UserFeishuBotConfigDto` and save/load mappings +- `WebCodeCli/Components/AdminUserManagementModal.razor` + - render two new checkboxes in the reply-document section +- `WebCodeCli/Components/AdminUserManagementModal.razor.cs` + - extend the editor/view models and custom-config detection logic +- `WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs` + - render four reply-document buttons instead of two in the top action area +- `WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs` + - return four reply-document flags into the card builder +- `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` + - add two new toggle handlers and pass four flags into card rendering +- `WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs` + - expand hardcoded full/final branches into four document variants and apply the formatter only to listening variants +- `docs/agent-notes/2026-05-29.md` + - record the confirmed listening-document replacement rule and the “four toggles are independent” constraint + +### New files to create + +- `WebCodeCli.Domain/Domain/Service/Channels/ListeningReplyDocumentFormatter.cs` + - pure helper/service that rewrites file-like references into `文件内容N` placeholders and appends the mapping appendix +- `WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs` + - focused tests for replacement behavior and appendix generation + +### Existing tests to modify + +- `WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs` +- `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs` +- `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` +- `tests/WebCodeCli.Tests/AdminControllerReplyDocumentTests.cs` +- `tests/WebCodeCli.Tests/AdminUserManagementReplyDocumentModeTests.cs` +- `tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs` + +--- + +### Task 1: Add config and action surface for the two listening document toggles + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs` +- Modify: `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` +- Modify: `WebCodeCli/Controllers/AdminController.cs` +- Test: `tests/WebCodeCli.Tests/AdminControllerReplyDocumentTests.cs` +- Test: `tests/WebCodeCli.Tests/AdminUserManagementReplyDocumentModeTests.cs` + +- [x] **Step 1: Write the failing config DTO tests** + +```csharp +[Fact] +public async Task GetFeishuBotConfig_ReturnsListeningReplyDocumentFields() +{ + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService + { + ConfigsByUsername = + { + ["alice"] = new UserFeishuBotConfigEntity + { + Username = "alice", + AudioFullReplyDocEnabled = true, + AudioFinalReplyDocEnabled = false + } + } + }; + + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + var result = await controller.GetFeishuBotConfig("alice"); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.AudioFullReplyDocEnabled); + Assert.False(dto.AudioFinalReplyDocEnabled); +} +``` + +- [x] **Step 2: Run the config DTO test to verify it fails** + +Run: + +```powershell +dotnet test tests/WebCodeCli.Tests/WebCodeCli.Tests.csproj --filter "GetFeishuBotConfig_ReturnsListeningReplyDocumentFields" +``` + +Expected: + +- FAIL because `UserFeishuBotConfigEntity` / `UserFeishuBotConfigDto` do not yet contain the new properties + +- [x] **Step 3: Add the two persisted booleans and DTO mappings** + +Implement: + +- `AudioFullReplyDocEnabled` +- `AudioFinalReplyDocEnabled` + +in: + +- `UserFeishuBotConfigEntity` +- `UserFeishuBotConfigDto` +- admin controller save/load mapping +- `UserFeishuBotConfigService` + +Keep legacy reply-document compatibility behavior unchanged for the old full/final flags. + +- [x] **Step 4: Run the config/document-mode tests to verify they pass** + +Run: + +```powershell +dotnet test tests/WebCodeCli.Tests/WebCodeCli.Tests.csproj --filter "AdminControllerReplyDocumentTests|AdminUserManagementReplyDocumentModeTests" +``` + +Expected: + +- PASS for existing reply-document compatibility tests +- PASS for the new listening-field coverage tests + +--- + +### Task 2: Add four-toggle admin UI and four-button help-card rendering + +**Files:** +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor` +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` +- Test: `tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs` + +- [x] **Step 1: Write the failing help-card button test** + +```csharp +[Fact] +public void BuildCommandListCard_IncludesListeningReplyDocumentButtons_WhenListeningDocumentsEnabled() +{ + var cardJson = _builder.BuildCommandListCard( + CreateCategories(), + fullReplyDocEnabled: false, + finalReplyDocEnabled: false, + audioFullReplyDocEnabled: true, + audioFinalReplyDocEnabled: true); + + using var document = JsonDocument.Parse(cardJson); + var elements = document.RootElement.GetProperty("body").GetProperty("elements"); + + Assert.True(ContainsStringValue(elements, "听完整文档:开")); + Assert.True(ContainsStringValue(elements, "听结论文档:开")); + Assert.True(ContainsAction(elements, FeishuHelpCardAction.ToggleAudioFullReplyDocAction)); + Assert.True(ContainsAction(elements, FeishuHelpCardAction.ToggleAudioFinalReplyDocAction)); +} +``` + +- [x] **Step 2: Run the help-card test to verify it fails** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "BuildCommandListCard_IncludesListeningReplyDocumentButtons_WhenListeningDocumentsEnabled" +``` + +Expected: + +- FAIL because builder method signatures and action ids do not yet support the listening variants + +- [x] **Step 3: Extend UI and card plumbing to four independent toggles** + +Implement: + +- two new action ids in `FeishuHelpCardAction` +- four-button rendering in all three help-card builder entrypoints +- four-flag setting tuple in `FeishuMessageHandler` +- two new toggle handlers in `FeishuCardActionService` +- admin modal checkboxes and editor model fields + +Use labels: + +- `听完整文档` +- `听结论文档` + +- [x] **Step 4: Run focused UI/card tests** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "FeishuHelpCardBuilderTests|FeishuCardActionServiceTests" +dotnet test tests/WebCodeCli.Tests/WebCodeCli.Tests.csproj --filter "AdminUserManagementModalStateTests" +``` + +Expected: + +- PASS with the two new listening toggles present and independent from the existing full/final toggles + +--- + +### Task 3: Implement the listening reply document formatter with TDD + +**Files:** +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ListeningReplyDocumentFormatter.cs` +- Create: `WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs` + +- [x] **Step 1: Write the failing formatter tests** + +```csharp +[Fact] +public void Format_ReplacesDistinctFileReferencesWithSequentialPlaceholders() +{ + var input = "构建过了。包括 /D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor:812 和 /D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedSession.razor:241。"; + + var output = ListeningReplyDocumentFormatter.Format(input); + + Assert.Contains("文件内容1", output, StringComparison.Ordinal); + Assert.Contains("文件内容2", output, StringComparison.Ordinal); + Assert.Contains("文件内容1:/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor:812", output, StringComparison.Ordinal); + Assert.Contains("文件内容2:/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedSession.razor:241", output, StringComparison.Ordinal); +} +``` + +```csharp +[Fact] +public void Format_ReusesPlaceholderForRepeatedFileReference() +{ + var input = "先看 /D:/repo/a.cs:1,再看 /D:/repo/a.cs:1。"; + + var output = ListeningReplyDocumentFormatter.Format(input); + + Assert.Equal(2, Regex.Matches(output, "文件内容1", RegexOptions.CultureInvariant).Count); + Assert.DoesNotContain("文件内容2", output, StringComparison.Ordinal); +} +``` + +- [x] **Step 2: Run the formatter test file to verify it fails** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "ListeningReplyDocumentFormatterTests" +``` + +Expected: + +- FAIL because the formatter file does not exist yet + +- [x] **Step 3: Implement the minimal formatter** + +Implementation requirements: + +- preserve untouched text when no file-like references are found +- replace distinct matched references in first-appearance order +- append the mapping section only when at least one replacement occurred +- preserve the exact original matched value in the appendix + +- [x] **Step 4: Run the formatter tests to verify they pass** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "ListeningReplyDocumentFormatterTests" +``` + +Expected: + +- PASS + +--- + +### Task 4: Expand the reply-document orchestrator into four independent variants + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs` +- Modify: `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs` + +- [x] **Step 1: Write the failing orchestrator tests for listening variants** + +```csharp +[Fact] +public async Task QueueCompletedReplyAsync_WhenListeningFullReplyDocumentEnabled_CreatesFormattedListeningDocument() +{ + using var harness = new ReplyDocumentOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + AudioFullReplyDocEnabled = true + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyDocumentRequest + { + ChatId = "oc-audio-full-chat", + SessionId = "session-1", + CliThreadId = "thread-1", + OriginalUserQuestion = "question", + Username = "luhaiyan", + Output = "见 /D:/repo/a.cs:1" + }); + + await WaitUntilAsync(() => harness.CardKit.CreatedDocuments.Count == 1); + + Assert.Contains("听完整回复", harness.CardKit.CreatedDocuments.Single().Title, StringComparison.Ordinal); + Assert.Contains("文件内容1", harness.CardKit.AppendedTexts.Single().Text, StringComparison.Ordinal); +} +``` + +- [x] **Step 2: Run the orchestrator listening test to verify it fails** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "QueueCompletedReplyAsync_WhenListeningFullReplyDocumentEnabled_CreatesFormattedListeningDocument" +``` + +Expected: + +- FAIL because the orchestrator only knows the existing full/final variants + +- [x] **Step 3: Refactor orchestrator to evaluate four variants independently** + +Implement: + +- full reply raw variant +- final reply raw variant +- listening full reply transformed variant +- listening final reply transformed variant + +For listening variants only: + +- run the source body through `ListeningReplyDocumentFormatter` + +Keep: + +- current title prefix logic +- per-chat serialization +- full/final fallback behavior +- one variant failing must not block remaining variants + +- [x] **Step 4: Run the orchestrator tests** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "ReplyDocumentOrchestratorTests" +``` + +Expected: + +- PASS for the previous full/final document tests +- PASS for the new listening-variant tests + +--- + +### Task 5: Verification and documentation + +**Files:** +- Modify: `docs/agent-notes/2026-05-29.md` + +- [x] **Step 1: Record the implementation note** + +Append a note covering: + +- listening full/final documents are new independent variants +- full/final raw documents remain unchanged +- listening formatter rewrites file-like references into `文件内容N` and appends a mapping section + +- [x] **Step 2: Run the focused verification suite** + +Run: + +```powershell +dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter "ListeningReplyDocumentFormatterTests|ReplyDocumentOrchestratorTests|FeishuHelpCardBuilderTests|FeishuCardActionServiceTests" +dotnet test tests/WebCodeCli.Tests/WebCodeCli.Tests.csproj --filter "AdminControllerReplyDocumentTests|AdminUserManagementReplyDocumentModeTests|AdminUserManagementModalStateTests" +``` + +Expected: + +- PASS + +- [x] **Step 3: Run the full solution build** + +Run: + +```powershell +dotnet build D:\VSWorkshop\WebCode\WebCodeCli.sln +``` + +Expected: + +- SUCCESS with no new build errors + +- [x] **Step 4: Review git diff for touched files only** + +Run: + +```powershell +git diff -- WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs WebCodeCli/Controllers/AdminController.cs WebCodeCli/Components/AdminUserManagementModal.razor WebCodeCli/Components/AdminUserManagementModal.razor.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs WebCodeCli.Domain/Domain/Service/Channels/ListeningReplyDocumentFormatter.cs WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs tests/WebCodeCli.Tests/AdminControllerReplyDocumentTests.cs tests/WebCodeCli.Tests/AdminUserManagementReplyDocumentModeTests.cs tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs docs/agent-notes/2026-05-29.md +``` + +Expected: + +- only the planned listening-document files and notes appear in the reviewed diff diff --git a/docs/superpowers/plans/2026-06-02-feishu-reply-document-folder-implementation.md b/docs/superpowers/plans/2026-06-02-feishu-reply-document-folder-implementation.md new file mode 100644 index 0000000..b9ec741 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-feishu-reply-document-folder-implementation.md @@ -0,0 +1,178 @@ +# Feishu Reply Document Folder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Place generated Feishu reply documents into a folder named from the current session title, falling back to CLI thread id and then session id. + +**Architecture:** Extend the reply-document orchestration flow so it resolves a folder name from session metadata, asks the Feishu client to ensure that folder exists, then moves the newly created document into it. Keep document title generation unchanged and isolate Feishu Drive API details inside the Feishu client abstraction. + +**Tech Stack:** C#, xUnit, existing `ReplyDocumentOrchestrator`, `IFeishuCardKitClient`, Feishu Open API HTTP client. + +--- + +### Task 1: Add orchestrator regression coverage for folder naming and placement + +**Files:** +- Modify: `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs` + +- [ ] **Step 1: Write the failing test for session-title folder placement** + +Add a test that queues one full-reply document with: + +- `SessionId = "session-folder-title"` +- `CliThreadId = "thread-folder-title"` +- session repository title = `"事务边界"` + +Assert: + +- one document is created, +- one folder ensure call is recorded with `"事务边界"`, +- one document move call is recorded for the created document into the ensured folder. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test WebCodeCli.Domain.Tests --filter "QueueCompletedReplyAsync_WhenSessionTitlePresent_UsesSessionTitleAsFolderName"` + +Expected: FAIL because the stub Feishu client has no folder-placement behavior yet. + +- [ ] **Step 3: Write the failing test for unnamed-title fallback** + +Add a test with session title `"未命名"` and `CliThreadId = "thread-fallback-folder"`. + +Assert the ensured folder name is `"thread-fallback-folder"`. + +- [ ] **Step 4: Run test to verify it fails** + +Run: `dotnet test WebCodeCli.Domain.Tests --filter "QueueCompletedReplyAsync_WhenSessionTitleIsUnnamed_FallsBackToCliThreadIdForFolder"` + +Expected: FAIL because fallback naming is not implemented yet. + +- [ ] **Step 5: Write the failing test for missing-thread fallback** + +Add a test with blank session title and blank `CliThreadId`. + +Assert the ensured folder name is the `SessionId`. + +- [ ] **Step 6: Run test to verify it fails** + +Run: `dotnet test WebCodeCli.Domain.Tests --filter "QueueCompletedReplyAsync_WhenTitleAndThreadMissing_FallsBackToSessionIdForFolder"` + +Expected: FAIL because session-id fallback is not implemented yet. + +### Task 2: Extend the Feishu client abstraction for folder placement + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` +- Modify: `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` + +- [ ] **Step 1: Write the failing client-level tests** + +Add focused `FeishuCardKitClientTests` for: + +- ensuring a folder by name returns a folder token/id when the API responds successfully, +- moving a document into a folder posts the expected request and succeeds. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test WebCodeCli.Domain.Tests --filter "EnsureCloudFolderAsync|MoveCloudDocumentToFolderAsync"` + +Expected: FAIL because the interface and implementation do not exist yet. + +- [ ] **Step 3: Add minimal interface methods** + +Extend `IFeishuCardKitClient` with methods equivalent to: + +```csharp +Task EnsureCloudFolderAsync( + string folderName, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null); + +Task MoveCloudDocumentToFolderAsync( + string documentId, + string folderToken, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null); +``` + +- [ ] **Step 4: Implement the Feishu client methods** + +In `FeishuCardKitClient.cs`, add the minimal HTTP flow needed to: + +- search or create a folder by name, +- move the document into that folder. + +Keep all HTTP payloads inside the client implementation and reuse existing token/error helpers. + +- [ ] **Step 5: Update test stubs to satisfy the interface** + +Add no-op or recording implementations for the new methods in the test stubs used by reply-document tests. + +- [ ] **Step 6: Run the focused client tests** + +Run: `dotnet test WebCodeCli.Domain.Tests --filter "EnsureCloudFolderAsync|MoveCloudDocumentToFolderAsync"` + +Expected: PASS + +### Task 3: Implement folder-name resolution in ReplyDocumentOrchestrator + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs` +- Modify: `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs` + +- [ ] **Step 1: Add helper methods for folder-name resolution** + +In `ReplyDocumentOrchestrator.cs`, add focused helpers to: + +- load the session when available, +- resolve the preferred folder source, +- sanitize the folder name, +- detect unnamed titles such as `未命名`. + +- [ ] **Step 2: Add minimal orchestration changes** + +Update `TryCreateAndSendDocumentAsync(...)` so it: + +- resolves the folder name before or during document creation, +- creates the document, +- ensures the folder when the folder name is non-blank, +- moves the document into that folder, +- continues with append/permission/send steps. + +- [ ] **Step 3: Keep document title logic unchanged** + +Do not modify `BuildTitlePrefix(...)`, `TruncateTitle(...)`, or the full/final/audio suffix logic. + +- [ ] **Step 4: Run the reply-document naming tests** + +Run: `dotnet test WebCodeCli.Domain.Tests --filter "QueueCompletedReplyAsync_WhenSessionTitlePresent_UsesSessionTitleAsFolderName|QueueCompletedReplyAsync_WhenSessionTitleIsUnnamed_FallsBackToCliThreadIdForFolder|QueueCompletedReplyAsync_WhenTitleAndThreadMissing_FallsBackToSessionIdForFolder"` + +Expected: PASS + +### Task 4: Verify existing reply-document behavior stays intact + +**Files:** +- Modify: `docs/agent-notes/2026-06-02.md` + +- [ ] **Step 1: Run existing focused reply-document regression coverage** + +Run: `dotnet test WebCodeCli.Domain.Tests --filter "QueueCompletedReplyAsync_WhenFullReplyDocumentEnabled_CreatesOneDocumentAndSendsLink|QueueCompletedReplyAsync_WhenFinalReplyDocumentEnabled_UsesLiveFinalAnswerOnly|QueueCompletedReplyAsync_WhenBothReplyDocumentsEnabled_CreatesTwoDocuments"` + +Expected: PASS + +- [ ] **Step 2: Record the implementation note** + +Add a note to `docs/agent-notes/2026-06-02.md` covering: + +- folder naming precedence, +- `未命名` fallback behavior, +- where folder placement now happens. + +- [ ] **Step 3: Commit** + +```bash +git add WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs docs/agent-notes/2026-06-02.md docs/superpowers/specs/2026-06-02-feishu-reply-document-folder-design.md docs/superpowers/plans/2026-06-02-feishu-reply-document-folder-implementation.md +git commit -m "feat: place Feishu reply documents into session folders" +``` diff --git a/docs/superpowers/plans/2026-06-09-feishu-markdown-doc-import-and-rendering-implementation.md b/docs/superpowers/plans/2026-06-09-feishu-markdown-doc-import-and-rendering-implementation.md new file mode 100644 index 0000000..a617aee --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-feishu-markdown-doc-import-and-rendering-implementation.md @@ -0,0 +1,234 @@ +# Feishu Markdown Doc Import And Rendering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Completed checklist items in this file are intentionally closed as `- [x]`. + +**Goal:** Upgrade Feishu reply documents from plain-text append behavior to Feishu-native Markdown rendering and add an independent toggle that imports referenced local Markdown files as Feishu online documents after a completed reply. + +**Architecture:** Reuse the existing completed-reply orchestration pipeline instead of building a second completion subsystem. Keep Feishu HTTP details in `IFeishuCardKitClient` and `FeishuCardKitClient`, isolate reply-body Markdown rendering in a focused renderer, isolate Markdown reference reuse/import behavior in a dedicated importer, and preserve the existing non-fatal warning flow for folder and permission failures. + +**Tech Stack:** C#, xUnit, existing Feishu reply-document pipeline, Feishu Docx convert API, Feishu Drive upload/import APIs. + +--- + +### Task 1: Persist the independent Markdown-import toggle across admin UI, help cards, and runtime settings + +**Files:** +- Modify: `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` +- Modify: `WebCodeCli/Controllers/AdminController.cs` +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor` +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor.cs` +- Modify: `WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs` +- Modify: `tests/WebCodeCli.Tests/AdminControllerReplyDocumentTests.cs` +- Modify: `tests/WebCodeCli.Tests/AdminControllerReplyDocumentTestsAccessor.cs` +- Modify: `tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs` +- Modify: `tests/WebCodeCli.Tests/AdminUserManagementReplyDocumentModeTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +- [x] **Step 1: Add `ReferencedMarkdownDocImportEnabled` to the persisted bot config model** + +The user config entity and config service now preserve an independent `ReferencedMarkdownDocImportEnabled` boolean so the Markdown-import mode does not piggyback on any legacy reply-document flag. + +- [x] **Step 2: Add the toggle to the admin DTO and modal round-trip** + +The admin controller request/response DTO mapping and the admin user-management modal state now expose `ReferencedMarkdownDocImportEnabled`, with a dedicated Markdown online-document import label in the Feishu bot settings UI. + +- [x] **Step 3: Add the help-card action and toggle handling** + +The Feishu help-card and card-action layer now expose: + +- action: `toggle_referenced_markdown_doc_import` +- button text for the enabled and disabled Markdown online-document import states +- toast behavior consistent with the existing reply-document toggles + +- [x] **Step 4: Thread the toggle into reply-document runtime settings** + +`FeishuMessageHandler` and `FeishuCardActionService` now include the Markdown-import flag in the reply-document settings tuple so completed replies can decide whether to scan or import referenced Markdown files. + +- [x] **Step 5: Verify toggle persistence and help-card behavior** + +Evidence: + +- `tests/WebCodeCli.Tests/AdminControllerReplyDocumentTests.cs` +- `tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs` +- `tests/WebCodeCli.Tests/AdminUserManagementReplyDocumentModeTests.cs` +- `WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs` +- `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +### Task 2: Add Feishu client primitives for Markdown document rendering and Markdown file import + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` + +- [x] **Step 1: Extend the client contract for Markdown convert and import flows** + +`IFeishuCardKitClient` now exposes the primitives needed by higher layers: + +- `ConvertMarkdownToCloudDocumentBlocksAsync(...)` +- `AppendCloudDocumentBlocksAsync(...)` +- `FindCloudDocumentInFolderByTitleAsync(...)` +- `UploadCloudFileAsync(...)` +- `ImportMarkdownFileAsCloudDocumentAsync(...)` + +- [x] **Step 2: Implement reply-document Markdown conversion primitives** + +`FeishuCardKitClient` now posts reply Markdown to Feishu's official document-convert endpoint and appends returned block payloads through the existing docx children endpoint without adding business logic to the raw client. + +- [x] **Step 3: Implement Markdown upload and import primitives with shared-folder and default-directory support** + +`FeishuCardKitClient` now supports: + +- Drive upload through `upload_all` +- import-task creation and polling +- exact-title lookup inside a shared folder +- `folderToken = null` fallback that resolves the root folder for upload and omits the import `point` payload +- a 30-second import polling deadline that throws a dedicated Chinese timeout message for Markdown import + +- [x] **Step 4: Keep stage-aware error information inside the existing HTTP and business helper path** + +The client continues to surface permission and validation failures through the shared response parser so higher layers can reuse the friendly Chinese warning summarization already used by reply documents. + +- [x] **Step 5: Verify request shape and terminal-state behavior** + +Evidence: + +- `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` +- coverage includes convert requests, block append requests, direct import, failed import, and null-folder import + +### Task 3: Render reply documents through Markdown conversion and import referenced local Markdown files after completion + +**Files:** +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentMarkdownRenderer.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/MarkdownReferenceExtractor.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReferencedMarkdownDocumentImporter.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/ReplyDocumentOrchestrator.cs` +- Modify: `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs` +- Create: `WebCodeCli.Domain.Tests/ReplyDocumentMarkdownRendererTests.cs` +- Create: `WebCodeCli.Domain.Tests/MarkdownReferenceExtractorTests.cs` +- Create: `WebCodeCli.Domain.Tests/ReferencedMarkdownDocumentImporterTests.cs` +- Create: `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorMarkdownIntegrationTests.cs` +- Modify: `WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs` + +- [x] **Step 1: Add a convert-first renderer for reply-document bodies** + +`ReplyDocumentMarkdownRenderer` now keeps reply-document behavior within three explicit branches: + +- convert succeeds and returns non-empty `blocks` -> append converted blocks +- convert throws -> fall back to plain-text append for the same document +- convert succeeds but returns no usable `blocks` -> fall back to plain-text append + +Block-append failures are logged and rethrown instead of downgrading to plain text, so one partially converted document cannot silently duplicate content. + +- [x] **Step 2: Keep listening-document formatting behavior intact before rendering** + +`ReplyDocumentOrchestrator` still formats the two listening variants through `ListeningReplyDocumentFormatter.Format(...)` before those bodies enter the Markdown rendering path. + +- [x] **Step 3: Add safe local Markdown reference extraction** + +`MarkdownReferenceExtractor` now: + +- detects Markdown links whose target ends in `.md` +- detects bare local-looking `.md` paths +- rejects remote URLs, query or anchor suffixed matches, and paths that escape the workspace root +- resolves candidates under the current workspace +- deduplicates by normalized relative path while preserving source order + +- [x] **Step 4: Add reusable referenced-Markdown import behavior** + +`ReferencedMarkdownDocumentImporter` now: + +- reuses an existing shared-folder online document when its title exactly matches the normalized relative path +- otherwise imports the local `.md` file as a Feishu online document +- sends a separate chat link for each reused or generated document +- attempts folder-admin grant lazily after the first successful reuse or import +- degrades to default-directory import if direct shared-folder placement fails +- keeps warning-send failures isolated so later Markdown candidates still continue + +- [x] **Step 5: Integrate the importer into the existing completed-reply pipeline** + +`ReplyDocumentOrchestrator` now: + +- renders all four reply-document variants through the Markdown renderer +- preserves existing document title, folder, and link behavior +- triggers Markdown reference import only when `ReferencedMarkdownDocImportEnabled` is enabled +- scans the completed full reply first, then falls back to final-only text when needed +- keeps the entire Markdown-import path non-fatal to normal reply-document generation + +- [x] **Step 6: Verify renderer, extractor, importer, orchestrator, and formatter behavior** + +Evidence: + +- `WebCodeCli.Domain.Tests/ReplyDocumentMarkdownRendererTests.cs` +- `WebCodeCli.Domain.Tests/MarkdownReferenceExtractorTests.cs` +- `WebCodeCli.Domain.Tests/ReferencedMarkdownDocumentImporterTests.cs` +- `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorMarkdownIntegrationTests.cs` +- `WebCodeCli.Domain.Tests/ReplyDocumentOrchestratorTests.cs` +- `WebCodeCli.Domain.Tests/ListeningReplyDocumentFormatterTests.cs` + +### Task 4: Close the notes and verification loop for the completed implementation + +**Files:** +- Modify: `docs/agent-notes/2026-06-09.md` +- Create: `docs/superpowers/plans/2026-06-09-feishu-markdown-doc-import-and-rendering-implementation.md` + +- [x] **Step 1: Record the implementation findings** + +`docs/agent-notes/2026-06-09.md` now records: + +- the convert-first reply-document renderer behavior +- the referenced-Markdown import workflow and warning ordering +- the null-folder import fallback and 30-second import deadline +- the final toggle and warning behavior at the documentation level + +- [x] **Step 2: Re-run targeted domain verification mapped to this feature** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\.worktrees\feat-feishu-markdown-docs\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyDocument|FullyQualifiedName~MarkdownReference|FullyQualifiedName~FeishuCardKitClientTests|FullyQualifiedName~FeishuHelpCardBuilderTests|FullyQualifiedName~FeishuCardActionServiceTests" --no-restore -p:UseSharedCompilation=false -p:RunAnalyzers=false -v minimal +``` + +Observed result on 2026-06-09: + +- `251` passed +- `0` failed + +- [x] **Step 3: Re-run targeted web and admin verification mapped to this feature** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\.worktrees\feat-feishu-markdown-docs\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~ReplyDocument|FullyQualifiedName~ReferencedMarkdownDocImport" --no-restore -p:UseSharedCompilation=false -p:RunAnalyzers=false -v minimal +``` + +Observed result on 2026-06-09: + +- `18` passed +- `0` failed + +- [x] **Step 4: Re-run the solution build as the final compile gate** + +Run: + +```powershell +dotnet build D:\VSWorkshop\WebCode\.worktrees\feat-feishu-markdown-docs\WebCodeCli.sln --no-restore -p:UseSharedCompilation=false -v minimal /m:1 +``` + +Observed result on 2026-06-09: + +- `0` errors +- existing repository warnings remain + +- [x] **Step 5: Confirm the implementation file exists and all checklist items are closed** + +This implementation plan now exists at: + +- `docs/superpowers/plans/2026-06-09-feishu-markdown-doc-import-and-rendering-implementation.md` + +All steps in this document are intentionally closed because the branch and worktree state, together with the verification evidence above, already satisfy the implementation scope described in the paired design spec. diff --git a/docs/superpowers/specs/2026-05-20-feishu-streaming-card-recovery-design.md b/docs/superpowers/specs/2026-05-20-feishu-streaming-card-recovery-design.md new file mode 100644 index 0000000..e2a8a85 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-feishu-streaming-card-recovery-design.md @@ -0,0 +1,176 @@ +# Feishu Streaming Card Recovery Design + +## Context + +WebCode currently streams CLI output into Feishu CardKit cards through two separate service paths: + +- `FeishuChannelService` for ordinary chat-driven streaming +- `FeishuCardActionService` for card-triggered streaming actions + +Both paths currently assume a single `FeishuStreamingHandle` remains valid for the full lifetime of the stream. When card updates fail, the handle flips into `AreCardUpdatesStopped`, and the services treat that as a terminal disconnect. The result is that the CLI stream can still be running, but Feishu users stop receiving card updates. + +The observed production failure pattern is: + +1. a card update times out +2. the client retries the same `sequence` +3. Feishu returns `300317 sequence number compare failed` +4. WebCode treats that as a hard failure and stops the card stream + +This pattern strongly suggests the first timed-out update may already have been accepted by Feishu, and the retry is colliding with an already-applied sequence. + +## Goal + +Make Feishu streaming card delivery more resilient by: + +- preserving the current card when the timeout-plus-sequence-conflict pattern indicates the previous update probably succeeded +- falling back to a replacement card only when the current card is truly no longer writable +- applying the same behavior to both streaming entry points + +## Non-Goals + +- redesigning CardKit payload structure +- changing completion-state semantics beyond what is required for replacement-card continuity +- allowing unlimited replacement cards +- refactoring unrelated Goal runtime logic + +## User-Facing Behavior + +### Normal recovery on likely-successful timeout + +If a card update times out and the immediate retry with the same `sequence` returns `300317 sequence number compare failed`, WebCode should treat that retry result as recoverable, not terminal. The stream should continue on the same card. + +User-visible effect: + +- no replacement card is created +- the stream continues on the original card +- no disconnect error is appended + +### Replacement-card fallback + +If the current streaming card is genuinely no longer writable, WebCode should create a single replacement streaming card and continue sending output there. + +User-visible effect: + +- the latest full rendered content appears in the replacement card +- subsequent chunks and final completion land on the replacement card +- the old card may optionally receive a best-effort notice that output has moved, but failure to write that notice must not block recovery + +### Replacement-card limit + +Each logical stream may create at most one replacement card. + +If the replacement card also becomes unwritable, WebCode should stop card streaming and fall back to the existing disconnect behavior. + +## Design + +### 1. CardKit update classification + +`FeishuCardKitClient.UpdateCardCoreAsync(...)` should classify update failures into three groups: + +- `success` +- `recoverable same-card result` +- `terminal current-card failure` + +The specific same-card recovery rule is: + +- if attempt `n` timed out without caller cancellation +- and the retry for the same `cardId` and `sequence` returns business code `300317` +- then treat the update as effectively successful, because the previous attempt likely already advanced the sequence on Feishu + +This rule must be narrowly scoped to the retry-after-timeout case. A plain `300317` without a preceding timeout should still be considered a current-card write failure, because it may indicate real concurrent writers or sequence drift. + +### 2. Streaming-card session wrapper + +Introduce a higher-level runtime object that owns the logical stream's active card handle rather than assuming the original handle never changes. + +Required responsibilities: + +- hold the current `FeishuStreamingHandle` +- hold the latest fully rendered content +- hold the immutable stream context needed to create a replacement card +- expose unified `UpdateAsync(...)`, `FinishAsync(...)`, and `CurrentMessageId` +- create at most one replacement card + +This wrapper belongs above `FeishuStreamingHandle`, not inside it. `FeishuStreamingHandle` should remain a single-card transport abstraction. + +### 3. Replacement-card creation rules + +When the active handle becomes stopped, the wrapper should: + +1. check whether replacement is still allowed +2. create a new streaming card using the same chat target and effective Feishu options +3. seed it with the latest full rendered content, not a delta +4. continue subsequent updates and finish operations on the new handle + +The replacement card should preserve: + +- the same title +- the same Chrome layout and action affordances appropriate for the stream type +- the latest status markdown, normalized back into a writable running state before continuing + +### 4. Reply target behavior + +For ordinary channel-driven streaming, replacement cards should continue to be posted to the chat as new streaming cards, matching the existing initial-card behavior. + +For card-action-driven streaming, replacement cards should follow the same placement rule as the original streaming card created by that flow. If the original flow started as a fresh card in the chat, the replacement should do the same. Do not try to retrofit a different reply threading rule only for recovery. + +### 5. Shared implementation path + +Both `FeishuChannelService` and `FeishuCardActionService` currently duplicate most of the disconnect-handling flow. The replacement-card behavior should be implemented through shared logic so that both services follow the same rules for: + +- same-card recovery +- replacement-card creation +- replacement-card limit +- final disconnect fallback + +The services may still keep their own surrounding orchestration, but the card-recovery decision path should not diverge. + +## Error Handling + +### Recoverable same-card case + +- continue the stream +- do not append disconnect text +- do not increment replacement-card count + +### Replacement-card case + +- create one replacement card +- continue using the latest full rendered content +- do not send duplicate completion notifications + +### Final disconnect case + +If replacement creation fails, or the replacement card also stops updates, fall back to the existing disconnect behavior: + +- stop further card updates +- append the disconnect message +- allow the CLI execution itself to finish independently + +## Testing + +### CardKit client tests + +Add unit coverage for: + +- timed-out update followed by `300317` on the same `sequence` being treated as success +- plain `300317` without a preceding timeout remaining a failure + +### Channel streaming tests + +Add unit coverage for: + +- original card failure causing exactly one replacement card +- replacement card receiving subsequent streamed content and the final completion content +- second card failure falling back to disconnect behavior + +### Card-action streaming tests + +Add the same coverage pattern to the card-action path so both services are verified against the same recovery expectations. + +## Implementation Notes + +- Keep replacement attempts bounded to one per logical stream. +- Do not move replacement logic into `FeishuStreamingHandle` itself. +- Do not let replacement-card recovery alter the existing explicit completion text notification behavior. +- Update `docs/agent-notes/2026-05-20.md` with the final working rule once implementation is confirmed. diff --git a/docs/superpowers/specs/2026-05-23-feishu-streaming-finish-shutdown-design.md b/docs/superpowers/specs/2026-05-23-feishu-streaming-finish-shutdown-design.md new file mode 100644 index 0000000..c98c0d2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-feishu-streaming-finish-shutdown-design.md @@ -0,0 +1,30 @@ +# Feishu Streaming Finish Shutdown Design + +## Goal + +Reduce Feishu streaming-card tail-race cancellations during normal completion by stopping background update loops first, waiting for them to exit, and only then performing the final card completion write. + +## Scope + +This change is limited to the normal channel submission path in [FeishuChannelService.cs](/D:/VSWorkshop/WebCode/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs:1). It does not change `FeishuCardActionService` behavior in this pass. + +## Design + +The current channel flow cancels update work before the final `FinishAsync(...)` write. Background status-pulse and external-history-backfill tasks use the same cancellation source for loop control and replacement-handle creation, so a replacement handle created by a background task can inherit a token that is canceled immediately before final completion. + +The fix is: + +1. Introduce a dedicated background-update cancellation source for the channel submission path. +2. Run status-pulse and external-history-backfill loops off that background token instead of the execution-wide update token. +3. On normal completion, cancel and await those background tasks first. +4. After they exit, perform the final completion write. +5. Keep the existing final cleanup path to cancel remaining update work and dispose execution state. + +## Testing + +Add a regression in `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` that: + +- lets the main stream produce one successful update +- forces a later background status-pulse update to stop the original card and create a replacement handle +- makes the replacement handle fail its finish if the creation token has already been canceled +- verifies the channel path still completes the replacement card and sends the normal completion notification diff --git a/docs/superpowers/specs/2026-05-27-feishu-final-only-reply-tts-design.md b/docs/superpowers/specs/2026-05-27-feishu-final-only-reply-tts-design.md new file mode 100644 index 0000000..c440967 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-feishu-final-only-reply-tts-design.md @@ -0,0 +1,286 @@ +# Feishu Final-Only Reply TTS Design + +Date: 2026-05-27 + +## Goal + +Add a second Feishu reply-TTS mode that sends speech only for the assistant's structured final answer content for the current turn. + +The new mode must: + +- use the current turn boundary as the speech scope +- synthesize only assistant content marked as `final_answer` +- send no audio when the turn has no structured final-answer content +- keep the existing full-reply speech mode intact + +## Why + +The current Feishu reply-TTS pipeline always speaks the merged completed assistant text. That merged text is suitable for "full reply" playback, but it is not suitable for a "conclusion-only" mode because: + +- commentary and progress text can be mixed into the final visible answer +- goal runtime now rotates per app-server turn, so the desired speech unit is one turn, not a multi-turn aggregate +- Codex rollout records already distinguish assistant message phases such as `commentary` and `final_answer` + +The feature should therefore consume structured final-answer events instead of trying to guess the conclusion from plain text. + +## Product Definition + +### User-facing behavior + +Add a second toggle next to the existing Feishu `语音回复` action: + +- `语音回复` + - speaks the full completed assistant reply for the current turn +- `结论语音回复` + - speaks only the structured `final_answer` assistant text for the current turn + +These modes are mutually exclusive: + +- `Off` +- `FullReply` +- `FinalOnly` + +If `FinalOnly` is selected and the turn has no `final_answer` text, the system must not send any audio and must not send a failure notice. + +### Scope + +Included: + +- Feishu normal streaming replies +- Feishu card-action initiated streaming replies +- one-time CLI execution paths that produce structured Codex assistant message phases +- goal-runtime app-server turns, one turn at a time + +Not included: + +- text summarization or heuristic conclusion extraction +- cross-turn speech aggregation +- changing the visible Feishu card body to show only final-answer text +- adding new TTS engines or delivery channels + +## Existing Constraints + +### Turn boundary + +Goal-runtime Feishu flows already treat each app-server turn as one card lifecycle. This is the correct speech boundary for the new mode. + +### Current information loss + +The current WebCode Codex streaming pipeline keeps assistant text but discards the assistant message `phase` before the Feishu streaming layer builds the completed text. That means the TTS layer currently cannot distinguish: + +- commentary text +- final-answer text + +The design must preserve that structure earlier in the pipeline. + +### Rollout evidence + +Observed local Codex rollout files contain assistant `message` items with structured `phase` values including: + +- `commentary` +- `final_answer` + +This confirms that the model already emits the distinction needed by the feature. + +## Recommended Architecture + +Use a three-layer approach: + +1. Preserve assistant phase information in the Codex streaming event model +2. Build both full-reply and final-only text buffers in Feishu streaming consumers +3. Let reply-TTS mode selection choose which buffer to speak + +This keeps conclusion selection out of the TTS engine and out of ad-hoc text post-processing. + +## Data Model Changes + +### Feishu bot config + +Replace the single boolean reply-TTS switch with a mode field. + +Suggested shape: + +- `ReplyTtsMode = Off | FullReply | FinalOnly` +- keep `ReplyTtsVoiceId` + +Backward compatibility: + +- legacy `ReplyTtsEnabled = true` maps to `FullReply` +- legacy `ReplyTtsEnabled = false` maps to `Off` + +### Reply TTS request + +Extend the completed-reply TTS request payload to carry both texts: + +- `Output` +- `FinalAnswerOutput` + +Optional: + +- resolved `ReplyTtsMode` + +The orchestrator should not need to reconstruct speech text from raw events. + +### CLI output event + +Add an assistant-phase field to the structured CLI output event model. + +Suggested field: + +- `AssistantPhase` + +Keep it as a string for now so that the pipeline can pass through provider-specific values without overfitting to Codex-only enums. + +## Streaming Pipeline Changes + +### Codex app-server normalization + +In the Codex app-server session manager, preserve assistant phase when converting notifications into adapter-facing JSONL. + +Current normalized assistant message payload includes only: + +- item type +- text + +The new normalized payload must also include: + +- `phase` + +### Codex adapter parsing + +The Codex adapter must parse the assistant message phase and store it on the structured output event. + +The adapter should continue to expose assistant message text through the existing extraction API so current UI rendering behavior remains unchanged. + +### Feishu streaming consumers + +Both Feishu streaming consumers must maintain two assistant-text buffers per turn: + +- full assistant buffer +- final-answer-only buffer + +Suggested behavior: + +- append every assistant message text to the full buffer +- append assistant message text to the final-only buffer only when `AssistantPhase == "final_answer"` + +Reset both buffers: + +- when a turn ends +- when a goal-runtime turn boundary hands off to a fresh card + +### Completed turn handling + +At the end of a turn: + +- `finalOutput` comes from the full assistant buffer as it does today +- `finalAnswerOutput` comes from the final-only buffer + +The Feishu card still finishes with the full visible answer, not the final-only answer. + +## TTS Selection Rules + +When reply TTS is enabled: + +- `FullReply` + - speak `Output` +- `FinalOnly` + - speak `FinalAnswerOutput` + +If mode is `FinalOnly` and `FinalAnswerOutput` is empty or whitespace: + +- do not synthesize +- do not upload audio +- do not send a failure notice +- do not fall back to `Output` + +This silent skip is expected behavior, not an error. + +## Rollout Fallback + +The primary path must use the live structured stream, not disk re-read. + +An optional conservative fallback may be used only when: + +- tool is Codex +- mode is `FinalOnly` +- live `FinalAnswerOutput` is empty +- the turn completed normally +- thread/session context is available + +Fallback behavior: + +- read the newest matching rollout file +- extract only assistant `message` records with `phase = "final_answer"` +- use that text if available + +If the rollout file is not flushed yet, missing, or malformed: + +- skip audio silently + +Do not use rollout fallback to guess or synthesize a conclusion from commentary text. + +## Failure Handling + +### Expected non-audio case + +`FinalOnly` with no `final_answer` is not a failure. + +No failure card update or TTS failure notice should be emitted. + +### Actual TTS failure + +If final-answer text exists but later steps fail: + +- synthesis failure +- transcode failure +- upload failure +- Feishu audio send failure + +then use the existing reply-TTS failure path. + +### Unknown phase values + +If assistant phase is present but not recognized: + +- treat it as non-final +- keep full visible text behavior unchanged + +## Testing + +Add or update tests for the following: + +- Codex app-server normalized assistant-message JSONL includes `phase` +- Codex adapter parses `AssistantPhase` +- Feishu normal streaming accumulates: + - full assistant text + - final-only assistant text +- Feishu card-action streaming accumulates: + - full assistant text + - final-only assistant text +- goal-runtime turn boundary clears both buffers +- `FullReply` still speaks the complete merged assistant reply +- `FinalOnly` speaks only `final_answer` +- `FinalOnly` with no `final_answer` sends no audio and no failure notice +- rollout fallback can recover `final_answer` when available +- rollout fallback failure remains silent + +## Migration Notes + +Admin and per-user Feishu settings UI must present two mutually exclusive choices without allowing conflicting stored state. + +A simple compatibility path is: + +- keep loading old boolean data +- map it to `FullReply` during read/update +- persist only the new mode value going forward + +## Acceptance Criteria + +The feature is complete when: + +1. Users can choose between full-reply speech and final-only speech +2. Codex final-answer phase survives from structured stream to Feishu completion handling +3. `FinalOnly` mode never speaks commentary text +4. turns with no structured final answer produce no audio and no false failure warning +5. existing full-reply TTS behavior remains unchanged diff --git a/docs/superpowers/specs/2026-05-27-feishu-goal-runtime-turn-per-card-and-replacement-limit-design.md b/docs/superpowers/specs/2026-05-27-feishu-goal-runtime-turn-per-card-and-replacement-limit-design.md new file mode 100644 index 0000000..4f9a28c --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-feishu-goal-runtime-turn-per-card-and-replacement-limit-design.md @@ -0,0 +1,189 @@ +# Feishu Goal Runtime Turn-Per-Card And Replacement Limit Design + +## Context + +WebCode currently keeps one Feishu streaming card alive across multiple Codex app-server goal-runtime turns. + +That behavior was intentional when `CliExecutorService.StreamGoalRuntimeTurnsWhileActiveAsync(...)` was introduced: a completed inner app-server `turn` did not end the outer logical Feishu stream as long as the goal snapshot remained `active`. + +That same-card-across-turns model now conflicts with the desired user-facing behavior: + +- every app-server `turn` should have its own streaming card +- ordinary one-shot streaming should recover more than once when Feishu disconnects +- goal-runtime quick actions such as `/goal`, `/goal pause`, `/goal clear`, and `/goal resume` must remain valid on the current active card + +Recent production logs also showed that when a later card update disconnected after the single allowed replacement had already been consumed, app-server mode stopped with the existing fallback text: + +- `错误:飞书流式更新断连,已停止继续推送卡片` + +The CLI/app-server execution often continued, but Feishu users no longer received updated cards for later turns. + +## Goal + +Change Feishu streaming behavior so that: + +- each goal-runtime app-server `turn` always writes to a new streaming card +- old turn cards are explicitly closed instead of remaining in a running state +- goal-runtime quick actions remain attached to the current active card +- replacement-card recovery is increased from one attempt to ten attempts per logical stream +- the same ten-attempt recovery rule also applies to ordinary one-shot streaming flows + +## Non-Goals + +- redesigning Goal runtime semantics, thread reuse, or app-server session reuse +- changing `/goal` command meaning or button payloads +- allowing unlimited replacement cards +- changing non-Feishu channels +- redesigning CardKit payload structure beyond what is needed for card lifecycle correctness + +## User-Facing Behavior + +### Goal runtime + +For goal-runtime sessions: + +1. starting a turn creates a fresh streaming card +2. that card receives only the output for the current turn +3. when the turn ends but the goal remains `active`, the current card is finalized and the next turn starts on a new card +4. the newest card is the only card that should expose live goal-runtime controls + +Expected visible effect: + +- the user sees a sequence of per-turn cards instead of one endlessly reused card +- old cards clearly show that this round ended or output has moved on +- the latest card still has working `/goal`, `/goal pause`, `/goal clear`, `/goal resume`, and temporary-exit actions when applicable + +### Ordinary one-shot streaming + +Ordinary non-goal streaming still behaves as a single logical stream, but if the current card becomes unwritable, WebCode may create up to ten replacement cards before giving up. + +Expected visible effect: + +- transient Feishu write failures no longer stop after the first replacement +- once all ten replacement attempts are exhausted, WebCode still falls back to the existing disconnect text + +## Design + +### 1. Separate logical goal stream from Feishu card lifetime + +`CliExecutorService` should keep the existing goal-runtime session, thread, and turn orchestration rules, but it must surface an explicit outer-stream signal when one app-server `turn` ends and the goal is still `active`. + +That signal is not a terminal stream completion. It is a boundary event that means: + +- the just-finished turn card should be finalized +- Feishu consumers should start a fresh card for the next turn +- the same goal-runtime thread/session continues underneath + +This keeps the current app-server reuse model intact while allowing Feishu to switch cards deterministically at turn boundaries. + +### 2. Feishu consumers must rotate cards at goal turn boundaries + +Both `FeishuChannelService` and `FeishuCardActionService` currently consume one outer stream and assume one active streaming card per outer stream. + +They must be updated so that, when they observe the goal-turn-boundary signal: + +1. finish the current active card with turn-finished semantics +2. create a fresh streaming card using the same chat target and current chrome/actions +3. rebind the active execution to the new handle +4. continue streaming later chunks only to that new card + +The old card must not remain in a running state after the next turn starts. + +### 3. Goal buttons belong only to the current active card + +The quick-action payloads for `/goal`, `/goal pause`, `/goal clear`, `/goal resume`, and temporary exit should remain session-based, not card-instance-based. + +That means the implementation must preserve: + +- the same `session_id` +- the same `tool_id` +- the same `chat_key` + +But each newly created turn card must rebuild the current quick-action chrome so that users always interact with the latest card. + +Old turn cards should no longer be treated as the active control surface after rotation. + +### 4. Increase replacement-card limit from 1 to 10 + +`FeishuStreamingCardSession` currently hard-limits replacement to one card per logical stream. + +That limit should move to an explicit constant and be raised to ten for: + +- ordinary Feishu channel streaming +- card-action streaming +- goal-runtime per-turn streaming cards + +The recovery rule remains bounded: + +- each logical stream/card-session may create at most ten replacement cards +- after the limit is exhausted, the existing disconnect fallback still applies + +This change is intentionally independent from goal turn rotation. Turn-per-card is a normal lifecycle transition, not a replacement-card recovery event. + +### 5. Old-card terminal semantics + +There are now two distinct reasons a card stops being current: + +- normal goal turn handoff +- failure-driven replacement-card recovery + +The implementation should keep these distinct: + +- normal turn handoff should produce a normal completed-or-turn-finished state +- failure-driven replacement should keep the existing transferred/stopped semantics that indicate output moved because the prior card became unwritable + +That distinction matters for user comprehension and for preserving the existing recovery behavior. + +## Error Handling + +### Goal turn handoff + +If finishing the previous turn card fails during normal turn rotation: + +- log the failure +- continue creating the next turn card +- do not abort the underlying goal-runtime execution solely because the previous card could not be finalized + +### Replacement-card recovery + +If a card becomes unwritable: + +- use the existing replacement-card path +- allow up to ten replacement creations for that logical stream +- if replacement creation or later writes still fail after the limit is exhausted, append the existing disconnect message and stop Feishu streaming updates + +### Goal controls + +Button payload validity must not depend on reusing the same Feishu `cardId`. + +The controls should continue to work because they target the reused session/thread/runtime state, not a prior card instance. + +## Testing + +Add regression coverage for: + +### CliExecutorService + +- goal-runtime outer stream emits a turn-boundary signal when one turn ends and the goal remains `active` +- final outer completion still happens only after the goal leaves `active` + +### Feishu channel path + +- goal-runtime channel streaming creates a new card for the second turn instead of continuing on the first card +- the first turn card is finalized before the second turn card becomes active +- the latest turn card still contains goal quick actions +- ordinary one-shot replacement-card recovery allows ten replacements before disconnect fallback + +### Feishu card-action path + +- goal-runtime card-action streaming rotates to a new card per turn +- the active handle and chrome are rebound to the newest turn card +- the latest turn card still contains goal quick actions +- replacement-card recovery also allows ten replacements before disconnect fallback + +## Implementation Notes + +- Keep goal-runtime session reuse, thread reuse, and `/goal` control semantics unchanged. +- Do not implement turn-per-card by starting a fresh goal session or fresh thread for each turn. +- Treat turn rotation as a normal lifecycle transition, not as a replacement-card failure. +- Update `docs/agent-notes/2026-05-27.md` with the final working rule after implementation is confirmed. diff --git a/docs/superpowers/specs/2026-05-28-feishu-reply-documents-design.md b/docs/superpowers/specs/2026-05-28-feishu-reply-documents-design.md new file mode 100644 index 0000000..7c0e0d6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-feishu-reply-documents-design.md @@ -0,0 +1,346 @@ +# Feishu Reply Documents Design + +Date: 2026-05-28 + +## Goal + +Replace the Feishu reply-TTS feature with Feishu cloud-document delivery. + +The new feature must let users automatically generate Feishu cloud documents from completed assistant replies and send the document links back into the current Feishu chat. + +This change replaces reply TTS as a product capability. The existing turn-boundary completion pipeline, full-reply text accumulation, and structured `final_answer` accumulation should be reused where possible. + +## Product Definition + +### User-facing behavior + +Replace the old reply-TTS controls with two independent Feishu document toggles: + +- `完整回复文档` + - when enabled, generate a new Feishu cloud document for the current turn's full completed assistant reply +- `结论回复文档` + - when enabled, generate a new Feishu cloud document for the current turn's structured `final_answer` reply + +These toggles are independent: + +- both off +- only `完整回复文档` on +- only `结论回复文档` on +- both on + +If both toggles are on for the same turn: + +- generate two separate documents +- send two separate link messages + +### Turn boundary + +The document scope is one completed assistant turn: + +- one normal streaming completion +- one card-action completion +- one goal-runtime app-server turn boundary + +Do not aggregate across turns. + +### Document content + +`完整回复文档`: + +- document body contains only the current turn's full completed assistant reply text + +`结论回复文档`: + +- document body contains only the current turn's structured `final_answer` text +- no summarization, slicing, regex extraction, or heuristics are allowed + +### Document title + +Document titles are based on: + +- `thread id` +- current turn's original user question +- document type suffix + +Title format: + +- ` - 完整回复` +- ` - 结论回复` + +ID resolution rule: + +1. prefer native CLI thread id (`CliThreadId`) +2. if unavailable, fall back to `SessionId` + +Question source rule: + +- use the current turn's original user message text +- do not use normalized CLI prompt text + +Question normalization rule: + +- collapse line breaks to spaces +- trim outer whitespace +- if Feishu title length limits require truncation, truncate only the title string, never the document body + +### Chat delivery + +After a document is created successfully, send a separate plain text Feishu message containing the document link back into the same chat. + +Recommended text: + +- `已生成完整回复文档:[标题](链接)` +- `已生成结论回复文档:[标题](链接)` + +Do not inject the link into the streaming card body or final card body. + +### Permissions + +Every generated document must be updated to allow tenant-internal link-based reading. + +Target behavior: + +- chat participants in the same Feishu tenant can open the link without additional manual permission changes + +## Non-goals + +- keep any reply-TTS audio capability +- preserve TTS engines, voice selection, audio upload, or audio sending +- emit failure notices into chat for document-generation failures +- change visible streaming card/body content to document-only output +- generate one shared document that mixes full reply and conclusion sections +- aggregate conclusions across turns + +## Existing Constraints + +### Reusable completion pipeline + +The current Feishu completion pipeline already provides the right completion boundaries and text sources: + +- current turn full assistant output +- current turn `final_answer`-only output + +These should continue to be the sole sources for document bodies. + +### Reusable Codex fallback + +The existing Codex-only fallback for `final_answer` extraction from rollout files is acceptable to reuse for `结论回复文档`, as long as it remains narrowly scoped: + +- only for Codex +- only for final-answer document generation +- only when live `FinalAnswerOutput` is empty +- only by extracting assistant `message` items with `phase = "final_answer"` + +If fallback yields no text, skip the conclusion document silently. + +### Current config shape is obsolete + +The existing reply-TTS config is mode-based: + +- `Off` +- `FullReply` +- `FinalOnly` + +That shape no longer matches the new product requirement because full-reply and final-reply documents can now both be enabled simultaneously. + +## Recommended Architecture + +Use a direct replacement architecture: + +1. Replace reply-TTS config with two independent document toggles +2. Rename the completed-reply orchestration pipeline from reply-TTS semantics to reply-document semantics +3. Reuse the existing completion boundaries and `Output` / `FinalAnswerOutput` payloads +4. Extend the Feishu API client with cloud-document creation, writing, and permission methods +5. Remove TTS-specific services, models, options, packaging, and tests + +## Data Model Changes + +### User Feishu bot config + +Remove: + +- `ReplyTtsEnabled` +- `ReplyTtsMode` +- `ReplyTtsVoiceId` + +Add: + +- `FullReplyDocEnabled` +- `FinalReplyDocEnabled` + +Migration rules from old data: + +- `FullReply` maps to `FullReplyDocEnabled = true`, `FinalReplyDocEnabled = false` +- `FinalOnly` maps to `FullReplyDocEnabled = false`, `FinalReplyDocEnabled = true` +- `Off` maps to both false + +No voice-related compatibility behavior is needed after migration. + +### Completed reply request payload + +Rename the completed reply request model from reply-TTS semantics to reply-document semantics. + +Suggested shape: + +- `ChatId` +- `SessionId` +- `CliThreadId` +- `Output` +- `FinalAnswerOutput` +- `OriginalUserQuestion` +- `Username` +- `AppId` + +This payload should carry all information needed to create and announce documents without reconstructing turn context later. + +## Pipeline Changes + +### Completion producers + +Both completion producers must enqueue completed reply document requests: + +- `FeishuChannelService` +- `FeishuCardActionService` + +They must continue to preserve: + +- full completed assistant text +- final-answer-only assistant text + +They must additionally carry: + +- the original user question for the current turn +- the session's `CliThreadId` if available + +Goal-runtime turn-boundary ordering must remain: + +1. queue completed side effects for the current turn +2. rotate card / clear turn-local buffers + +### Orchestrator replacement + +Rename: + +- `IReplyTtsOrchestrator` -> reply-document equivalent +- `ReplyTtsOrchestrator` -> reply-document equivalent +- queue/process methods to reply-document names + +Retain: + +- per-chat serialization lock +- asynchronous background queue behavior + +Remove: + +- text normalization for speech +- chunk splitting +- TTS synthesis +- audio transcode +- audio upload +- audio message sending +- failure notice text specific to TTS +- temp audio storage + +New orchestrator behavior: + +1. resolve the user's document toggles +2. if full-reply document is enabled and full output is not empty, create full document and send link message +3. if final-reply document is enabled and final output is not empty, create final document and send link message +4. if both are enabled, perform both independently +5. if one fails, log and continue attempting the other + +### Feishu client additions + +Extend the existing Feishu client that already owns tenant-token retrieval and message sending. + +Add document-oriented methods for: + +- create document +- append/write document body text +- set tenant-readable link permission +- build/open document URL + +Reuse the same tenant token flow already used for Feishu message APIs. + +## Error Handling + +### Expected skip cases + +These are not failures: + +- full document enabled but full output is empty +- final document enabled but final output is empty +- Codex final fallback yields no text + +Handling: + +- skip silently +- do not send failure messages + +### Document operation failures + +These are operational failures: + +- create document API failure +- write body API failure +- permission update failure +- send-link-message failure + +Handling: + +- log warnings/errors +- do not emit an extra chat failure message by default + +### Partial success + +If both documents are enabled and one succeeds while the other fails: + +- keep the success +- send the successful link +- log the failed branch + +## Testing + +Add or update tests for: + +- config migration from old reply-TTS mode values into two new document booleans +- admin DTO/UI handling for two independent document toggles +- help-card rendering for two independent document toggles +- help-card action handling without mutual exclusion +- completed reply request payload includes: + - `Output` + - `FinalAnswerOutput` + - `OriginalUserQuestion` + - `SessionId` + - `CliThreadId` +- normal streaming completion queues full/final document requests correctly +- card-action completion queues full/final document requests correctly +- goal-runtime turn-boundary still queues before clearing buffers +- orchestrator behavior for: + - both toggles off + - only full document on + - only final document on + - both on + - missing `CliThreadId` fallback to `SessionId` + - silent skip on empty final content + - optional Codex final fallback +- Feishu API client request construction for: + - document create + - body write + - permission patch + - link message send + +## Code Removal Scope + +Remove all TTS-specific runtime, model, option, packaging, and testing code that is no longer used, including: + +- reply-TTS storage/temp-audio helpers +- voice/platform/health models +- Kokoro / sherpa TTS client interfaces and implementations +- audio transcode services +- audio message services +- local reply-TTS service manager +- voice-selection admin UI and related tests +- installer/package logic that exists only to bundle reply-TTS runtime assets + +Retain and rename the reusable completed-reply orchestration pipeline instead of rebuilding that flow from scratch. diff --git a/docs/superpowers/specs/2026-05-29-feishu-listening-reply-documents-design.md b/docs/superpowers/specs/2026-05-29-feishu-listening-reply-documents-design.md new file mode 100644 index 0000000..2c4637a --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-feishu-listening-reply-documents-design.md @@ -0,0 +1,334 @@ +# Feishu Listening Reply Documents Design + +Date: 2026-05-29 + +## Goal + +Extend the existing Feishu reply-document feature with two additional document variants intended for in-app document listening on mobile Feishu: + +- `听完整文档` +- `听结论文档` + +These new variants must reuse the existing reply-document delivery pipeline, but their document bodies must be transformed into a listening-friendly form before upload. + +The existing document variants must remain unchanged: + +- `完整回复文档` +- `结论回复文档` + +All four variants must be independently configurable and may all be enabled at the same time. + +## Product Definition + +### User-facing behavior + +The product exposes four independent reply-document toggles: + +- `完整回复文档` +- `结论回复文档` +- `听完整文档` +- `听结论文档` + +Behavior rules: + +- each toggle is independent +- any subset of the four toggles may be enabled +- each enabled toggle generates its own Feishu cloud document +- each generated document sends its own plain-text link message back into the same Feishu chat + +The two new listening variants are not aliases of the existing variants. They are additional outputs. + +### Document families + +`完整回复文档` + +- body contains the current turn full completed assistant reply text without listening-specific rewriting + +`结论回复文档` + +- body contains the current turn final-only reply text without listening-specific rewriting + +`听完整文档` + +- body starts from the current turn full completed assistant reply text +- body is transformed by the listening-document formatter before upload + +`听结论文档` + +- body starts from the current turn final-only reply text +- body is transformed by the listening-document formatter before upload + +### Turn boundary + +All four document variants remain turn-scoped. + +They must keep the same completion boundaries already used by the current reply-document system: + +- one normal streaming completion +- one card-action completion +- one goal-runtime app-server turn boundary + +Do not aggregate across turns. + +## Listening Document Formatting + +### Purpose + +Listening documents are intended to be read aloud by Feishu's built-in document voice playback, so raw file paths and filename-heavy output should be rewritten into stable spoken placeholders. + +### Scope + +The formatter applies only to: + +- `听完整文档` +- `听结论文档` + +The formatter must not modify: + +- `完整回复文档` +- `结论回复文档` + +### Replacement rule + +When the listening formatter detects a file reference that contains an English filename with a suffix, it replaces each distinct matched reference with a sequential placeholder: + +- `文件内容1` +- `文件内容2` +- `文件内容3` + +The placeholder numbering is assigned in first-appearance order. + +If the same matched reference appears again later in the same body, reuse the same placeholder instead of creating a new one. + +### Target patterns + +The formatter should cover the repository-style file references that commonly appear in assistant replies, including: + +- full local paths such as `D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor` +- full local paths with line suffixes such as `D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor:812` +- slash-prefixed local paths such as `/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedSession.razor:241` +- plain filename references with extensions when they are part of a file-like path or file mention + +The formatter should not try to rewrite arbitrary English prose just because it contains a period. + +### Mapping appendix + +After replacing in-body references, append a mapping section at the end of the listening document body. + +Example shape: + +`文件内容1:/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor:812` + +`文件内容2:/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedSession.razor:241` + +Rules: + +- append only when at least one replacement occurred +- preserve first-appearance ordering +- append once per distinct matched reference +- keep the original matched text in the appendix value + +### Example + +Input: + +`构建过了。当前主要是仓库里原有警告,还包括 /D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor:812、/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedSession.razor:241。` + +Listening output: + +`构建过了。当前主要是仓库里原有警告,还包括 文件内容1、文件内容2。` + +`文件内容1:/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedWorkspace.razor:812` + +`文件内容2:/D:/VSWorkshop/WebCode/WebCodeCli/Pages/SharedSession.razor:241` + +## Titles + +The new variants need distinct document suffixes so users can tell them apart from the existing variants. + +Recommended title format: + +- ` - 完整回复` +- ` - 结论回复` +- ` - 听完整回复` +- ` - 听结论回复` + +Reuse the current title-prefix logic: + +1. prefer `CliThreadId` +2. otherwise fall back to `SessionId` +3. normalize the original user question by collapsing line breaks to spaces +4. truncate only the title string when required + +## Recommended Architecture + +Use a document-variant expansion of the current reply-document pipeline. + +### Recommendation + +Add two new document variants while keeping the current two variants unchanged. + +This is the recommended approach because: + +- it preserves the existing outputs exactly +- it lets all four toggles run independently +- it keeps the new listening-only transformation isolated to new variants +- it avoids contaminating the current full/final document semantics + +### Rejected alternatives + +Do not overload the existing two toggles with a "listening mode" flag. + +That would make the existing feature ambiguous and would not satisfy the requirement that four buttons may all be enabled simultaneously. + +Do not replace raw file references in the common completion buffers. + +That would leak listening-specific output into the normal full/final document paths and any future reuse of the same buffers. + +## Data Model Changes + +### User Feishu bot config + +Add two new independent boolean fields: + +- `AudioFullReplyDocEnabled` +- `AudioFinalReplyDocEnabled` + +The existing fields remain: + +- `FullReplyDocEnabled` +- `FinalReplyDocEnabled` + +Legacy reply-TTS compatibility behavior should remain unchanged and continue to derive only from the existing full/final document fields unless there is an explicit later requirement to expose the listening variants through legacy compatibility fields. + +### Card action constants + +Add two new Feishu help-card actions: + +- toggle listening full reply document +- toggle listening final reply document + +They must not reuse the action ids of the existing full/final toggles. + +## Pipeline Changes + +### Help card and filtered card buttons + +Update the Feishu help-card builders so the top action area includes four buttons instead of two: + +- `完整回复文档:开/关` +- `结论回复文档:开/关` +- `听完整文档:开/关` +- `听结论文档:开/关` + +The button state rendering remains simple toggle state display with independent `primary` styling when enabled. + +### Card action handling + +Extend `FeishuCardActionService` with two new toggle handlers that mirror the existing full/final document toggle flow: + +- resolve current chat user config +- toggle the relevant boolean field +- persist config +- rebuild the help card +- return success/failure toast and card update + +### Admin UI + +Extend the admin user management modal reply-document section to show four independent checkboxes: + +- full reply document +- conclusion reply document +- listening full reply document +- listening conclusion reply document + +Extend admin DTOs and save/load paths accordingly. + +### Reply document orchestrator + +Refactor the orchestrator core from two hardcoded branches into a small list of document variants. + +Each variant definition should include: + +- enabled predicate +- title suffix +- link prefix +- content source selector +- optional content transform + +Recommended variants: + +- full reply +- final reply +- listening full reply +- listening final reply + +Processing rules: + +- evaluate all four variants independently +- skip variants whose source body is blank +- if one variant fails, log and continue attempting the remaining variants + +## Formatter Design + +Introduce a dedicated listening-document formatter service or helper used only by the orchestrator. + +Responsibilities: + +- scan the chosen body text +- replace distinct file-like references with `文件内容N` +- produce the final transformed body with a mapping appendix + +Non-responsibilities: + +- no cloud-document upload logic +- no title generation +- no chat message sending +- no mutation of the original reply buffers + +## Testing + +### Formatter tests + +Add focused tests for: + +- single file path replacement +- repeated file path reuse of the same placeholder +- multiple file paths assigned in first-appearance order +- `:line` suffix preservation in the appendix +- no appendix when no replacements occur +- no replacement of unrelated English prose + +### Orchestrator tests + +Add tests proving: + +- existing full reply document stays raw +- existing final reply document stays raw +- listening full reply document uses transformed content +- listening final reply document uses transformed content +- all four enabled generates four independent document requests +- one failed variant does not prevent the remaining variants from being attempted + +### Card and config tests + +Extend existing help-card, admin-controller, admin-modal, and card-action tests to cover: + +- four toggle states +- save/load of the two new booleans +- new button labels and action ids + +## Non-goals + +- replace or remove the existing full/final reply document outputs +- change the current final-answer fallback rules +- rewrite normal chat messages or streaming card content into listening form +- change old document titles retroactively +- add actual audio synthesis or audio uploads +- translate the appendix values into human summaries + +## Implementation Notes + +Keep the listening formatter isolated behind a single helper so later product changes can adjust the replacement grammar without touching the Feishu delivery pipeline. + +Do not modify the shared `Output` or `FinalAnswerOutput` buffers in-place. Always transform a local copy for the two listening variants only. diff --git a/docs/superpowers/specs/2026-06-02-feishu-reply-document-folder-design.md b/docs/superpowers/specs/2026-06-02-feishu-reply-document-folder-design.md new file mode 100644 index 0000000..26f297e --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-feishu-reply-document-folder-design.md @@ -0,0 +1,111 @@ +# Feishu Reply Document Folder Design + +## Context + +Feishu completed-reply delivery already creates cloud documents and sends plain-text links back into chat. Today the pipeline controls document titles, but not the folder where those documents are stored. Users want reply documents grouped under a folder derived from the current session title so a session's documents stay organized in Feishu Drive. + +The requested naming rule is: + +- Prefer the current session title as the folder name. +- If the session title is blank or effectively unnamed, fall back to the CLI thread id. +- If the CLI thread id is also missing, fall back to the session id. + +## Goals + +- Place each generated Feishu reply document into a deterministic folder instead of leaving it in the default location. +- Reuse the same folder for later documents from the same session naming source. +- Use the session title as the first-choice folder name. +- Treat `未命名` as unnamed and fall back to thread id. +- Keep existing reply-document titles unchanged. + +## Non-Goals + +- No change to the visible plain-text link message format. +- No change to the full/final/audio document title suffix rules. +- No attempt to mirror local workspace directory structure into Feishu Drive. +- No user-configurable custom folder root in this change. + +## Constraints + +- The current `CreateCloudDocumentAsync(...)` flow only creates a document by title; folder placement is not modeled in the existing client abstraction. +- Folder lookup and creation must be safe for repeated runs. The pipeline should not create duplicate folders every time if an appropriate folder already exists. +- The reply-document pipeline already has access to `SessionId`, optional `CliThreadId`, and can resolve `ChatSessionEntity` through the repository. + +## Recommended Approach + +Use a two-phase document placement flow: + +1. Resolve the target folder name from session metadata. +2. Ensure a matching Feishu folder exists. +3. Create the cloud document normally. +4. Move the created document into the ensured folder. + +This is preferred over forcing folder data into the existing create-document call because the current code is already structured around a plain document-create operation and a separate follow-up write/permission pipeline. + +## Folder Naming Rules + +Given one completed reply request: + +1. Load the chat session when `SessionId` is present. +2. Inspect `ChatSessionEntity.Title`. +3. Normalize the title by trimming whitespace and collapsing obvious empty values. +4. Treat the following as unnamed: + - null + - empty or whitespace-only + - `未命名` +5. If the normalized title is named, use it as the folder name. +6. Otherwise, use `CliThreadId` when present. +7. Otherwise, use `SessionId`. +8. If none of the above exist, skip folder placement rather than throwing. + +## Folder Name Sanitization + +Before sending the folder name to Feishu: + +- Trim leading and trailing whitespace. +- Replace filesystem-style reserved separators and unsafe punctuation such as `/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|` with spaces or a safe separator. +- Collapse repeated internal whitespace. +- Truncate to a reasonable maximum length to avoid downstream API issues. + +This sanitization is only for the folder name. Document titles remain on the existing title path. + +## Service Boundaries + +### ReplyDocumentOrchestrator + +Add orchestration logic that: + +- resolves the effective folder name from the current session/request, +- asks the Feishu client to ensure the folder exists, +- asks the Feishu client to move the newly created document into that folder. + +The orchestrator remains the place where session-aware naming decisions live. + +### IFeishuCardKitClient / FeishuCardKitClient + +Extend the Feishu client abstraction with the minimal operations needed by the orchestrator: + +- ensure a cloud folder exists by name, +- move a document into a folder. + +The concrete HTTP details stay inside the Feishu client layer rather than leaking into the orchestrator. + +## Error Handling + +- If folder-name resolution fails because the session cannot be loaded, continue with the fallback chain instead of failing document generation. +- If folder creation or folder move fails, treat it as a document-generation failure for that document and surface the same failure-notification path already used by reply-document creation failures. +- If folder placement is skipped because no usable title/thread/session id exists, continue creating the document in the default location. + +## Testing Strategy + +Add focused coverage for: + +- session title folder name wins over thread id, +- `未命名` title falls back to thread id, +- missing thread id falls back to session id, +- orchestrator asks the Feishu client to ensure a folder and then move the created document, +- blank naming inputs skip folder placement without blocking document creation. + +## Design Summary + +The change should keep the existing reply-document pipeline shape intact while adding a deterministic folder-placement step between document creation and link delivery. Naming policy belongs in `ReplyDocumentOrchestrator`, Feishu API details belong in `IFeishuCardKitClient`/`FeishuCardKitClient`, and existing document title behavior should remain untouched. diff --git a/docs/superpowers/specs/2026-06-09-feishu-markdown-doc-import-and-rendering-design.md b/docs/superpowers/specs/2026-06-09-feishu-markdown-doc-import-and-rendering-design.md new file mode 100644 index 0000000..9965a3a --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-feishu-markdown-doc-import-and-rendering-design.md @@ -0,0 +1,482 @@ +# Feishu Markdown Reply Rendering and Markdown Import Design + +Date: 2026-06-09 + +## Goal + +Extend the existing Feishu reply-document system in two coordinated ways: + +1. Upgrade reply-document body generation from plain text append behavior to Feishu-native Markdown rendering through Feishu's official document-convert capability. +2. Add a new help-card toggle that automatically imports referenced local Markdown files as Feishu online documents after a streaming reply completes. + +The system must continue to send document links back into the same Feishu chat and must preserve the existing session-scoped shared-folder workflow where possible. + +## Product Definition + +### Existing document outputs remain + +The current four reply-document outputs remain valid and independent: + +- `完整回复文档` +- `结论回复文档` +- `听完整文档` +- `听结论文档` + +All four toggles may still be enabled together. Each enabled output produces its own Feishu document and its own link message in chat. + +### New help-card toggle + +Add a fifth independent toggle: + +- `MD转在线文档` + +When enabled, the system inspects the completed reply content after a streaming reply finishes. If the reply references one or more local `.md` files, the system imports those files into Feishu online documents and sends the online-document links back into the current chat. + +This toggle is independent from the four reply-document toggles. Any combination of the five toggles is allowed. + +### Completion boundary + +All outputs remain scoped to one completed assistant turn: + +- one normal streaming completion +- one card-action completion when that path already produces completed-reply side effects +- one goal-runtime app-server turn boundary when that path already produces completed-reply side effects + +No output aggregates across multiple turns. + +### Reply-document body behavior + +The visible product behavior for the four reply-document variants stays the same at the content-source level: + +- `完整回复文档` uses the current turn full completed assistant reply +- `结论回复文档` uses the current turn final-only assistant reply +- `听完整文档` starts from the full completed assistant reply +- `听结论文档` starts from the final-only assistant reply + +The change is only in how the document body is written: + +- prefer Feishu official Markdown-to-doc conversion instead of plain text append +- keep a safe fallback to plain text append when Feishu conversion fails + +### Listening-document formatting rule remains + +`听完整文档` and `听结论文档` must continue to run the existing listening formatter before document generation. + +That formatter remains responsible for: + +- replacing file-like references with `文件内容N` +- replacing command-like content with `命令内容N` +- appending the mapping appendix at the end + +After formatting completes, the resulting Markdown text is passed into the reply-document rendering pipeline. + +### Markdown file import behavior + +When `MD转在线文档` is enabled: + +1. inspect the completed reply text +2. detect referenced local `.md` files +3. normalize and deduplicate the references +4. check whether the session shared folder already contains an online document with the normalized relative path as its title +5. if it exists, reuse it and send the existing link +6. if it does not exist, upload and import the Markdown file into a Feishu online document +7. send the resulting online-document link back into the same chat + +This feature must not modify the main reply body or the streaming card body. + +## Non-Goals + +- build or maintain a custom Markdown AST renderer for Feishu documents +- replace the current reply-document toggle family with a single combined mode +- import remote `http://` or `https://` Markdown links +- import non-Markdown local files through the new toggle +- change the current reply-document title scheme outside the new Markdown-import document titles +- block main assistant completion when document generation or Markdown import fails +- upload and render native images in the first version of the new rendering path + +## Existing Constraints + +### Existing completion pipeline should be reused + +The current system already has a completed-reply pipeline with: + +- current turn full output +- current turn final-only output +- per-chat serialization for reply-document side effects +- chat-link delivery after document creation + +The new design should attach to that pipeline rather than create a separate completion subsystem. + +### Existing shared-folder workflow should be reused + +The current reply-document flow already resolves a session-scoped shared folder and prefers direct in-folder document creation when possible. + +The new design should reuse the same shared-folder resolution policy for: + +- reply documents +- imported Markdown online documents + +### Existing failure behavior should be preserved + +Reply-document generation currently avoids breaking the main reply when Feishu document operations fail. Folder-placement failures can already degrade into: + +- document still created +- link still sent +- warning sent about placement failure + +The new design should preserve this non-fatal behavior. + +## Recommended Architecture + +Use one unified completed-reply document pipeline with two Feishu-native writing strategies. + +### Strategy A: reply documents + +For the four reply-document variants: + +1. select the variant content +2. apply optional listening formatting +3. create a Feishu `docx` document +4. try to convert the Markdown body through Feishu's official document-convert capability +5. if conversion succeeds, insert the returned blocks into the document +6. if conversion fails, fall back to the current plain-text append path +7. set access and send the link back to chat + +### Strategy B: Markdown file import + +For the new `MD转在线文档` toggle: + +1. inspect the completed reply text for referenced local `.md` files +2. resolve them against the current session workspace +3. normalize to relative paths +4. deduplicate by normalized relative path +5. reuse an existing shared-folder online document with the same title when available +6. otherwise upload the source `.md` file and create a Feishu import task that turns it into an online document +7. send the resulting link back to chat + +This architecture is preferred because it delegates Markdown interpretation to Feishu's supported platform capabilities instead of duplicating Markdown parsing rules in application code. + +## Alternatives Considered + +### Alternative 1: custom Markdown parser and Feishu block renderer + +Rejected. + +This would create a large, fragile maintenance surface for headings, lists, task lists, code blocks, links, tables, and future Markdown edge cases. It is unnecessary because Feishu already provides official conversion capabilities for reply-document text and official import capabilities for source `.md` files. + +### Alternative 2: reuse plain text for reply documents and only add Markdown import + +Rejected. + +This would leave reply-document quality behind the new imported-document quality and would preserve the current low-fidelity plain-text rendering even when the reply already contains well-structured Markdown. + +### Alternative 3: treat reply content as pseudo-files and route everything through Markdown file import + +Rejected. + +Reply text is not naturally persisted as a local `.md` file at the right abstraction boundary, and forcing that shape would complicate title handling, fallback behavior, and folder placement for no product gain. + +## Data Model Changes + +### User Feishu bot config + +Add one new independent boolean field: + +- `ReferencedMarkdownDocImportEnabled` + +This field belongs next to the existing reply-document toggles in the same user config model, DTOs, admin endpoints, and help-card rendering paths. + +### Help-card actions + +Add one new help-card action constant for toggling Markdown import mode. + +The toggle behavior mirrors the existing document toggles: + +- load current config +- invert the target field +- persist the config +- rebuild the card +- return success feedback + +## Reply-Document Rendering Design + +### Content selection + +Continue using the existing sources: + +- full reply document uses the completed turn full output +- final reply document uses the completed turn final-only output +- listening full reply document uses listening-formatted full output +- listening final reply document uses listening-formatted final-only output + +Existing Codex-specific final-answer fallback rules remain unchanged for final-only content resolution. + +### Rendering path + +The reply-document writer should support this sequence: + +1. create the target Feishu document +2. transform the chosen content into a Markdown string +3. call Feishu's official document-convert capability with that Markdown string +4. if conversion returns renderable blocks, append those blocks into the document +5. if conversion fails or returns an unusable payload, fall back to plain text append + +### First-version rendering scope + +The first version should target good fidelity for common reply content: + +- headings +- paragraphs +- bold and italic +- inline code +- blockquotes +- ordered and unordered lists +- nested lists +- task lists +- fenced code blocks +- divider +- links +- tables when Feishu conversion accepts them + +Images do not need first-version native rendering support. When images appear in the Markdown body, the system may degrade them into plain text, link text, or the plain-text append fallback. + +### Fallback rule + +If official Markdown conversion fails for a reply document: + +- do not fail the whole completed-reply pipeline +- write the same body through the current plain-text append path +- still send the document link +- log the Markdown-rendering downgrade for diagnostics, but do not send an extra chat warning for this fallback alone + +The fallback must be per-document, not global. One failed variant must not block the remaining enabled variants. + +## Markdown Reference Detection Design + +### Detection sources + +The Markdown import feature inspects reply text in this order: + +1. completed full reply content +2. if full reply content is empty, final-only reply content + +It does not inspect user prompts, rollout files directly, or unrelated session history. + +### Detection rule + +Use the approved broad detection rule: + +- detect bare local `.md` paths in prose +- detect Markdown links whose target path ends in `.md` + +Examples that should be detected: + +- `docs/agent-notes/2026-06-09.md` +- `MMIS-Server/docs/plan.md` +- `[设计文档](docs/superpowers/specs/2026-06-09-example.md)` +- `D:\Work\Repo\docs\spec.md` + +Examples that should not be imported: + +- remote `https://.../readme.md` +- malformed paths that do not resolve to a local file +- non-Markdown extensions + +### Path normalization + +For each detected candidate: + +1. trim surrounding punctuation and wrapper syntax +2. normalize separators to `/` +3. remove leading `./` segments +4. resolve against the session workspace root when the path is relative +5. keep absolute local paths only if they are under the current workspace root +6. reject paths that escape outside the workspace root +7. convert accepted paths into normalized relative paths from the workspace root + +The normalized relative path becomes both: + +- the uniqueness key +- the preferred Feishu online-document title + +Example: + +- local path `D:\VSWorkshop\WebCode\docs\agent-notes\2026-06-09.md` +- normalized relative title `docs/agent-notes/2026-06-09.md` + +### Deduplication + +If the same normalized relative path appears multiple times in one reply: + +- import it only once +- send only one link for that document in that completed-reply cycle + +## Shared-Folder Reuse and Import Design + +### Folder target + +Markdown-imported online documents should target the same session-scoped shared folder used by reply documents. + +The folder naming and resolution rules stay owned by the existing reply-document folder policy. + +### Existing-document reuse + +Before importing a detected Markdown file: + +1. list the current shared-folder document entries that are relevant for online documents +2. look for an existing entry whose title exactly matches the normalized relative path +3. if found, reuse that document instead of creating a duplicate + +When an existing document is reused, the system should still send the link back into chat so the user can open it immediately. + +### Import path + +When no matching shared-folder online document exists: + +1. upload the source `.md` file to Feishu Drive +2. create a Feishu import task that converts the uploaded Markdown file into a Feishu online document +3. place the result into the target shared folder directly when supported +4. if direct placement is not supported or fails after creation, preserve the existing fallback pattern: + - keep the created resource + - send the document link + - send a placement warning when needed + +## Title and Link Message Rules + +### Existing reply-document titles remain unchanged + +This design does not change the title-prefix logic or the suffix rules for: + +- `完整回复文档` +- `结论回复文档` +- `听完整文档` +- `听结论文档` + +### Imported Markdown document titles + +For Markdown imports, use the normalized relative path as the Feishu online-document title. + +Examples: + +- `docs/agent-notes/2026-06-09.md` +- `docs/superpowers/specs/2026-06-09-feishu-markdown-doc-import-and-rendering-design.md` + +### Link messages + +Each produced or reused Markdown online document sends a separate plain-text link message into the same chat. + +Recommended message shape: + +- `已生成Markdown在线文档:[title](url)` +- `已复用Markdown在线文档:[title](url)` + +Reply-document link messages keep their existing message family and do not merge with Markdown-import link messages. + +## Error Handling + +### General rule + +No reply-document rendering failure or Markdown import failure may break the main assistant reply completion flow. + +### Reply-document rendering failures + +If Markdown conversion fails: + +- log the failure with enough context to distinguish convert failure from append or permission failure +- fall back to plain-text append for that same document +- continue processing the remaining enabled outputs + +If later Feishu document operations fail after document creation: + +- preserve the existing stage-aware warning behavior +- still send the created link whenever the document resource exists + +### Markdown import failures + +If Markdown detection finds a candidate that cannot be resolved safely: + +- skip that candidate silently + +If upload or import fails for a valid candidate: + +- send a Chinese warning message to chat for that candidate +- continue processing the remaining Markdown candidates +- continue processing the four normal reply-document outputs + +If shared-folder lookup fails after an online document already exists: + +- preserve the created or existing document when possible +- send the link when available +- send a folder-placement warning consistent with the current reply-document warning style + +## Permission Model + +### Reply-document rendering permissions + +Reply documents continue to require the existing Feishu document capabilities already used for: + +- document creation +- document writing +- permission updates + +### Markdown import permissions + +Markdown import additionally depends on Feishu Drive capabilities for: + +- file upload +- import task creation +- shared-folder listing or lookup + +The application should not hardcode operator-facing scope lists in design logic. Instead it should preserve and extend the existing friendly error summarization path so Feishu's returned missing-scope information is surfaced back to chat in Chinese when permission errors occur. + +## Service Boundaries + +### ReplyDocumentOrchestrator + +The orchestrator remains the completed-reply coordination entry point. + +Its responsibilities expand to: + +- resolve enabled reply-document outputs +- render reply documents through the new Markdown-convert-first path +- trigger Markdown reference scanning when the new toggle is enabled +- coordinate existing-document reuse or import-task creation for Markdown files +- continue sending link messages and placement warnings + +### FeishuCardKitClient + +The Feishu client abstraction should own the HTTP details for: + +- document creation +- Markdown-to-document conversion calls +- block append operations +- file upload +- import-task creation and polling +- shared-folder listing or lookup +- permission updates +- move or placement fallback operations + +Business rules such as workspace path normalization, deduplication, variant selection, and title policy remain outside the raw client layer. + +## Testing Strategy + +Add focused automated coverage for the following cases: + +- help-card rendering shows the new `MD转在线文档` toggle state +- card action handling toggles `ReferencedMarkdownDocImportEnabled` and persists it +- reply-document rendering prefers official Markdown conversion when it succeeds +- reply-document rendering falls back to plain text append when conversion fails +- listening document variants still run the existing formatter before rendering +- bare local `.md` paths are detected correctly +- Markdown links that target `.md` files are detected correctly +- duplicate references to the same normalized relative path are imported only once +- paths outside the workspace root are rejected +- an existing shared-folder online document with the same normalized relative path title is reused instead of re-imported +- a missing shared-folder document triggers upload and import +- one Markdown import failure does not block other Markdown imports +- Markdown import failures do not block the four normal reply-document outputs +- stage-aware folder-placement warnings still work for created resources + +## Design Summary + +The system should evolve from plain-text-only reply documents into a Feishu-native Markdown document flow while adding a separate Markdown-file import capability for referenced local `.md` files. The design deliberately avoids a custom Markdown renderer and instead relies on Feishu's official document-convert and import-task capabilities. Existing reply-document toggles, listening formatting, shared-folder placement, and non-fatal failure behavior remain in place, while one new independent `MD转在线文档` toggle extends the completed-reply pipeline with reusable imported online documents. diff --git a/installer/windows/WebCode.iss b/installer/windows/WebCode.iss index 1a25315..fab81dd 100644 --- a/installer/windows/WebCode.iss +++ b/installer/windows/WebCode.iss @@ -6,10 +6,6 @@ #error PublishDir must be provided. #endif -#ifndef TtsBundleDir - #error TtsBundleDir must be provided. -#endif - #ifndef OutputDir #define OutputDir "." #endif @@ -60,15 +56,10 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; Flags: unchecked Name: "{app}\data" Name: "{app}\logs" Name: "{app}\workspaces" -Name: "{code:GetReplyTtsInstallRoot}\cache" -Name: "{code:GetReplyTtsInstallRoot}\logs" -Name: "{code:GetReplyTtsInstallRoot}\service" -Name: "{code:GetReplyTtsInstallRoot}\temp" [Files] Source: "{#PublishDir}\*"; DestDir: "{app}"; Excludes: "appsettings.json"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#PublishDir}\appsettings.json"; DestDir: "{app}"; Flags: onlyifdoesntexist ignoreversion -Source: "{#TtsBundleDir}\*"; DestDir: "{code:GetReplyTtsInstallRoot}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppSourceExe}" @@ -76,245 +67,3 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppSourceExe}"; Tasks: [Run] Filename: "{app}\{#MyAppSourceExe}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent - -[Code] -const - DRIVE_FIXED = 3; - -var - ReplyTtsDirPage: TInputDirWizardPage; - -function GetDriveType(lpRootPathName: string): Integer; - external 'GetDriveTypeW@kernel32.dll stdcall'; - -function NormalizePathSeparators(Value: string): string; -begin - Result := Trim(Value); - if Result <> '' then - StringChangeEx(Result, '/', '\', True); -end; - -function NormalizeDriveRoot(Value: string): string; -var - Candidate: string; -begin - Candidate := NormalizePathSeparators(Value); - Result := ExtractFileDrive(Candidate); - - if (Result = '') and (Length(Candidate) >= 2) and (Candidate[2] = ':') then - Result := Copy(Candidate, 1, 2); - - if Result <> '' then - Result := Uppercase(Result) + '\'; -end; - -function NormalizeReplyTtsInstallRoot(Value: string): string; -begin - Result := NormalizePathSeparators(Value); - while (Length(Result) > 3) and (Result[Length(Result)] = '\') do - Delete(Result, Length(Result), 1); -end; - -function GetSystemDriveRoot: string; -begin - Result := NormalizeDriveRoot(ExpandConstant('{sys}')); - if Result = '' then - Result := 'C:\'; -end; - -function IsSystemDrivePath(Value: string): Boolean; -begin - Result := NormalizeDriveRoot(Value) = GetSystemDriveRoot; -end; - -function CanWriteToDrive(DriveRoot: string): Boolean; -var - ProbeDir: string; - ProbeFile: string; -begin - Result := False; - ProbeDir := AddBackslash(NormalizeDriveRoot(DriveRoot)) + - '.webcode-kokoro-probe-' + GetDateTimeString('yyyymmddhhnnsszzz', #0, #0); - ProbeFile := AddBackslash(ProbeDir) + 'probe.tmp'; - - if not ForceDirectories(ProbeDir) then - Exit; - - if not SaveStringToFile(ProbeFile, 'probe', False) then begin - RemoveDir(ProbeDir); - Exit; - end; - - Result := True; - DeleteFile(ProbeFile); - RemoveDir(ProbeDir); -end; - -function IsWritableFixedNonSystemDriveRoot(Value: string): Boolean; -var - DriveRoot: string; -begin - DriveRoot := NormalizeDriveRoot(Value); - Result := - (DriveRoot <> '') and - DirExists(DriveRoot) and - (GetDriveType(DriveRoot) = DRIVE_FIXED) and - (not IsSystemDrivePath(DriveRoot)) and - CanWriteToDrive(DriveRoot); -end; - -function FindExistingReplyTtsInstallRoot: string; -var - DriveIndex: Integer; - DriveRoot: string; - CandidateRoot: string; -begin - Result := ''; - - for DriveIndex := Ord('A') to Ord('Z') do begin - DriveRoot := Chr(DriveIndex) + ':\'; - if not IsWritableFixedNonSystemDriveRoot(DriveRoot) then - Continue; - - CandidateRoot := NormalizeReplyTtsInstallRoot(DriveRoot + 'WebCodeData\Kokoro'); - if DirExists(CandidateRoot) then begin - Result := CandidateRoot; - Exit; - end; - end; -end; - -function GetFirstWritableNonSystemDriveRoot: string; -var - DriveIndex: Integer; - DriveRoot: string; -begin - Result := ''; - - for DriveIndex := Ord('A') to Ord('Z') do begin - DriveRoot := Chr(DriveIndex) + ':\'; - if IsWritableFixedNonSystemDriveRoot(DriveRoot) then begin - Result := DriveRoot; - Exit; - end; - end; -end; - -function GetDefaultReplyTtsInstallRoot: string; -var - PreviousRoot: string; - ExistingRoot: string; - DriveRoot: string; -begin - PreviousRoot := NormalizeReplyTtsInstallRoot(GetPreviousData('ReplyTtsInstallRoot', '')); - if (PreviousRoot <> '') and IsWritableFixedNonSystemDriveRoot(PreviousRoot) then begin - Result := PreviousRoot; - Exit; - end; - - ExistingRoot := FindExistingReplyTtsInstallRoot; - if ExistingRoot <> '' then begin - Result := ExistingRoot; - Exit; - end; - - DriveRoot := GetFirstWritableNonSystemDriveRoot; - if DriveRoot <> '' then begin - Result := NormalizeReplyTtsInstallRoot(DriveRoot + 'WebCodeData\Kokoro'); - Exit; - end; - - Result := ''; -end; - -function InitializeSetup(): Boolean; -begin - Result := GetFirstWritableNonSystemDriveRoot <> ''; - if not Result then - MsgBox( - 'WebCode cannot install the bundled Reply TTS payload because this Windows machine has no writable non-system fixed drive. ' + - 'Attach or map a writable data drive, then run Setup again.', - mbCriticalError, - MB_OK); -end; - -procedure InitializeWizard; -begin - ReplyTtsDirPage := CreateInputDirPage( - wpSelectDir, - 'Reply TTS Storage', - 'Where should the bundled Reply TTS payload be installed?', - 'Setup installs the Kokoro/sherpa-onnx model, ffmpeg, Python runtime, and dependencies to a writable non-system drive.', - False, - SetupMessage(msgNewFolderName)); - - ReplyTtsDirPage.Add('Reply TTS storage root:'); - ReplyTtsDirPage.Values[0] := GetDefaultReplyTtsInstallRoot; -end; - -function NextButtonClick(CurPageID: Integer): Boolean; -var - CandidateRoot: string; - DriveRoot: string; -begin - Result := True; - - if (ReplyTtsDirPage <> nil) and (CurPageID = ReplyTtsDirPage.ID) then begin - CandidateRoot := NormalizeReplyTtsInstallRoot(ReplyTtsDirPage.Values[0]); - DriveRoot := NormalizeDriveRoot(CandidateRoot); - - if CandidateRoot = '' then begin - MsgBox('Choose a Reply TTS storage root on a writable non-system drive.', mbError, MB_OK); - Result := False; - Exit; - end; - - if DriveRoot = '' then begin - MsgBox('Reply TTS storage root must be an absolute Windows path such as E:\WebCodeData\Kokoro.', mbError, MB_OK); - Result := False; - Exit; - end; - - if CandidateRoot = DriveRoot then - CandidateRoot := NormalizeReplyTtsInstallRoot(DriveRoot + 'WebCodeData\Kokoro'); - - if IsSystemDrivePath(CandidateRoot) then begin - MsgBox('Reply TTS storage root must be on a non-system drive. Do not use the Windows system drive.', mbError, MB_OK); - Result := False; - Exit; - end; - - if GetDriveType(DriveRoot) <> DRIVE_FIXED then begin - MsgBox('Reply TTS storage root must be on a fixed local drive.', mbError, MB_OK); - Result := False; - Exit; - end; - - if not DirExists(DriveRoot) then begin - MsgBox('The selected Reply TTS drive is not available.', mbError, MB_OK); - Result := False; - Exit; - end; - - if not CanWriteToDrive(DriveRoot) then begin - MsgBox('The selected Reply TTS drive is not writable.', mbError, MB_OK); - Result := False; - Exit; - end; - - ReplyTtsDirPage.Values[0] := CandidateRoot; - end; -end; - -function GetReplyTtsInstallRoot(Param: string): string; -begin - if ReplyTtsDirPage <> nil then - Result := NormalizeReplyTtsInstallRoot(ReplyTtsDirPage.Values[0]) - else - Result := GetDefaultReplyTtsInstallRoot; -end; - -procedure RegisterPreviousData(PreviousDataKey: Integer); -begin - SetPreviousData(PreviousDataKey, 'ReplyTtsInstallRoot', GetReplyTtsInstallRoot('')); -end; diff --git a/tests/README.md b/tests/README.md index 0d74e05..e218210 100644 --- a/tests/README.md +++ b/tests/README.md @@ -58,7 +58,7 @@ npx playwright test - **测试环境**: localhost:5000 - **用户名**: luhaiyan -- **密码**: Lusile@0680 +- **密码**: 通过环境变量 `WEBCODE_TEST_PASSWORD` 提供;未设置时示例值为 `CHANGEME_TEST_PASSWORD` - **测试目录**: D:\\VSWorkshop\\TestWebCode - **超时时间**: 120秒 - **浏览器**: Chromium(带UI界面) @@ -91,4 +91,4 @@ npx playwright test 1. 使用 `--headed` 参数查看浏览器界面 2. 使用 `--debug` 模式进入调试模式 3. 检查应用日志 -4. 手动执行相同的测试步骤 \ No newline at end of file +4. 手动执行相同的测试步骤 diff --git a/tests/WebCodeCli.Tests/AdminControllerReplyDocumentTests.cs b/tests/WebCodeCli.Tests/AdminControllerReplyDocumentTests.cs new file mode 100644 index 0000000..ed77ece --- /dev/null +++ b/tests/WebCodeCli.Tests/AdminControllerReplyDocumentTests.cs @@ -0,0 +1,353 @@ +using Microsoft.AspNetCore.Mvc; +using WebCodeCli.Controllers; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class AdminControllerReplyDocumentTests +{ + [Fact] + public async Task GetFeishuBotConfig_ReturnsReplyDocumentFields() + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService + { + ConfigsByUsername = + { + ["alice"] = new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + FullReplyDocEnabled = true, + FinalReplyDocEnabled = false, + AudioFullReplyDocEnabled = true, + AudioFinalReplyDocEnabled = false + } + } + }; + + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.GetFeishuBotConfig("alice"); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.FullReplyDocEnabled); + Assert.False(dto.FinalReplyDocEnabled); + Assert.True(dto.AudioFullReplyDocEnabled); + Assert.False(dto.AudioFinalReplyDocEnabled); + } + + [Fact] + public async Task GetFeishuBotConfig_ReturnsDocumentAdminOpenId() + { + var config = new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true + }; + SetStringProperty(config, "DocumentAdminOpenId", "ou_admin_alice"); + + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService + { + ConfigsByUsername = + { + ["alice"] = config + } + }; + + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.GetFeishuBotConfig("alice"); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.Equal("ou_admin_alice", GetStringProperty(dto, "DocumentAdminOpenId")); + } + + [Fact] + public async Task GetFeishuBotConfig_ReturnsReferencedMarkdownDocImportEnabled() + { + var configService = new DirectStubUserFeishuBotConfigService(); + var config = new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true + }; + SetBooleanProperty(config, "ReferencedMarkdownDocImportEnabled", true); + configService.ConfigsByUsername["alice"] = config; + + var controller = CreateDirectController(configService); + + var result = await controller.GetFeishuBotConfig("alice"); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(GetBooleanProperty(dto, "ReferencedMarkdownDocImportEnabled")); + } + + [Fact] + public async Task GetFeishuBotConfig_WithoutConfig_ReturnsDefaultReplyDocumentFields() + { + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(); + + var result = await controller.GetFeishuBotConfig("bob"); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.Equal("bob", dto.Username); + Assert.False(dto.FullReplyDocEnabled); + Assert.False(dto.FinalReplyDocEnabled); + Assert.False(dto.AudioFullReplyDocEnabled); + Assert.False(dto.AudioFinalReplyDocEnabled); + } + + [Fact] + public async Task SaveFeishuBotConfig_ForwardsReplyDocumentFieldsIntoEntity() + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService(); + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + FullReplyDocEnabled = true, + FinalReplyDocEnabled = true, + AudioFullReplyDocEnabled = true, + AudioFinalReplyDocEnabled = true + }); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.Equal("alice", configService.LastSavedConfig!.Username); + Assert.True(configService.LastSavedConfig.FullReplyDocEnabled); + Assert.True(configService.LastSavedConfig.FinalReplyDocEnabled); + Assert.True(configService.LastSavedConfig.AudioFullReplyDocEnabled); + Assert.True(configService.LastSavedConfig.AudioFinalReplyDocEnabled); + } + + [Fact] + public async Task SaveFeishuBotConfig_ForwardsReferencedMarkdownDocImportEnabledIntoEntity() + { + var configService = new DirectStubUserFeishuBotConfigService(); + var controller = CreateDirectController(configService); + var request = new UserFeishuBotConfigDto + { + IsEnabled = true + }; + SetBooleanProperty(request, "ReferencedMarkdownDocImportEnabled", true); + + var result = await controller.SaveFeishuBotConfig("alice", request); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.True(GetBooleanProperty(configService.LastSavedConfig!, "ReferencedMarkdownDocImportEnabled")); + } + + [Fact] + public async Task SaveFeishuBotConfig_ForwardsDocumentAdminOpenIdIntoEntity() + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService(); + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + var request = new UserFeishuBotConfigDto + { + IsEnabled = true + }; + SetStringProperty(request, "DocumentAdminOpenId", "ou_admin_alice"); + + var result = await controller.SaveFeishuBotConfig("alice", request); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.Equal("ou_admin_alice", GetStringProperty(configService.LastSavedConfig!, "DocumentAdminOpenId")); + } + + [Fact] + public async Task SaveFeishuBotConfig_WhenLegacyModeIsFullReply_MapsToFullReplyDocument() + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService(); + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsMode = ReplyTtsModes.FullReply + }); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.True(configService.LastSavedConfig!.FullReplyDocEnabled); + Assert.False(configService.LastSavedConfig.FinalReplyDocEnabled); + } + + [Fact] + public async Task SaveFeishuBotConfig_WhenLegacyModeIsFinalOnly_MapsToFinalReplyDocument() + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService(); + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsMode = ReplyTtsModes.FinalOnly + }); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.False(configService.LastSavedConfig!.FullReplyDocEnabled); + Assert.True(configService.LastSavedConfig.FinalReplyDocEnabled); + } + + [Fact] + public async Task SaveFeishuBotConfig_WhenLegacyReplyTtsEnabled_MapsToFullReplyDocument() + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService(); + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsEnabled = true + }); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.True(configService.LastSavedConfig!.FullReplyDocEnabled); + Assert.False(configService.LastSavedConfig.FinalReplyDocEnabled); + } + + private static string? GetStringProperty(object target, string propertyName) + { + return target + .GetType() + .GetProperty(propertyName)? + .GetValue(target) as string; + } + + private static bool GetBooleanProperty(object target, string propertyName) + { + return target + .GetType() + .GetProperty(propertyName)? + .GetValue(target) as bool? + ?? false; + } + + private static void SetStringProperty(object target, string propertyName, string value) + { + target.GetType().GetProperty(propertyName)?.SetValue(target, value); + } + + private static void SetBooleanProperty(object target, string propertyName, bool value) + { + target.GetType().GetProperty(propertyName)?.SetValue(target, value); + } + + private static AdminController CreateDirectController(DirectStubUserFeishuBotConfigService configService) + { + return new AdminController( + new AdminControllerReplyDocumentTestsAccessor.StubUserAccountService(), + new AdminControllerReplyDocumentTestsAccessor.StubUserToolPolicyService(), + new AdminControllerReplyDocumentTestsAccessor.StubUserWorkspacePolicyService(), + configService, + new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotRuntimeService(), + new AdminControllerReplyDocumentTestsAccessor.StubCliExecutorService(), + new AdminControllerReplyDocumentTestsAccessor.StubFeishuDocumentAdminGrantService()); + } + + private sealed class DirectStubUserFeishuBotConfigService : IUserFeishuBotConfigService + { + public Dictionary ConfigsByUsername { get; } = new(StringComparer.OrdinalIgnoreCase); + + public UserFeishuBotConfigEntity? LastSavedConfig { get; private set; } + + public Task GetByUsernameAsync(string username) + { + return Task.FromResult( + ConfigsByUsername.TryGetValue(username, out var config) + ? Clone(config) + : null); + } + + public Task GetByAppIdAsync(string appId) + { + throw new NotSupportedException(); + } + + public Task SaveAsync(UserFeishuBotConfigEntity config) + { + LastSavedConfig = Clone(config); + ConfigsByUsername[config.Username] = Clone(config); + return Task.FromResult(UserFeishuBotConfigSaveResult.Saved()); + } + + public Task DeleteAsync(string username) + { + throw new NotSupportedException(); + } + + public Task FindConflictingUsernameByAppIdAsync(string username, string? appId) + { + throw new NotSupportedException(); + } + + public Task> GetAutoStartCandidatesAsync() + { + throw new NotSupportedException(); + } + + public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) + { + throw new NotSupportedException(); + } + + public FeishuOptions GetSharedDefaults() + { + throw new NotSupportedException(); + } + + public Task GetEffectiveOptionsAsync(string? username) + { + throw new NotSupportedException(); + } + + public Task GetEffectiveOptionsByAppIdAsync(string? appId) + { + throw new NotSupportedException(); + } + + private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity entity) + { + var clone = new UserFeishuBotConfigEntity + { + Id = entity.Id, + Username = entity.Username, + IsEnabled = entity.IsEnabled, + AutoStartEnabled = entity.AutoStartEnabled, + AppId = entity.AppId, + AppSecret = entity.AppSecret, + EncryptKey = entity.EncryptKey, + VerificationToken = entity.VerificationToken, + DefaultCardTitle = entity.DefaultCardTitle, + ThinkingMessage = entity.ThinkingMessage, + HttpTimeoutSeconds = entity.HttpTimeoutSeconds, + StreamingThrottleMs = entity.StreamingThrottleMs, + FullReplyDocEnabled = entity.FullReplyDocEnabled, + FinalReplyDocEnabled = entity.FinalReplyDocEnabled, + AudioFullReplyDocEnabled = entity.AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = entity.AudioFinalReplyDocEnabled, + DocumentAdminOpenId = entity.DocumentAdminOpenId, + LastStartedAt = entity.LastStartedAt, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; + + var referencedMarkdownProperty = typeof(UserFeishuBotConfigEntity).GetProperty("ReferencedMarkdownDocImportEnabled"); + referencedMarkdownProperty?.SetValue(clone, referencedMarkdownProperty.GetValue(entity)); + return clone; + } + } +} diff --git a/tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs b/tests/WebCodeCli.Tests/AdminControllerReplyDocumentTestsAccessor.cs similarity index 56% rename from tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs rename to tests/WebCodeCli.Tests/AdminControllerReplyDocumentTestsAccessor.cs index c80c41c..6a32cbb 100644 --- a/tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs +++ b/tests/WebCodeCli.Tests/AdminControllerReplyDocumentTestsAccessor.cs @@ -1,204 +1,18 @@ -using Microsoft.AspNetCore.Mvc; using WebCodeCli.Controllers; using WebCodeCli.Domain.Common.Options; using WebCodeCli.Domain.Domain.Model; -using WebCodeCli.Domain.Domain.Model.Channels; using WebCodeCli.Domain.Domain.Service; using WebCodeCli.Domain.Domain.Service.Adapters; -using WebCodeCli.Domain.Domain.Service.Channels; using WebCodeCli.Domain.Repositories.Base.UserAccount; using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; -using Xunit; namespace WebCodeCli.Tests; -public sealed class AdminControllerReplyTtsTests +internal static class AdminControllerReplyDocumentTestsAccessor { - [Fact] - public async Task GetFeishuBotConfig_ReturnsReplyTtsFields() - { - var configService = new StubUserFeishuBotConfigService - { - ConfigsByUsername = - { - ["alice"] = new UserFeishuBotConfigEntity - { - Username = "alice", - IsEnabled = true, - ReplyTtsEnabled = true, - ReplyTtsVoiceId = "voice-1" - } - } - }; - - var controller = CreateController(configService: configService); - - var result = await controller.GetFeishuBotConfig("alice"); - - var ok = Assert.IsType(result.Result); - var dto = Assert.IsType(ok.Value); - Assert.True(dto.ReplyTtsEnabled); - Assert.Equal("voice-1", dto.ReplyTtsVoiceId); - } - - [Fact] - public async Task GetFeishuBotConfig_WithoutConfig_ReturnsDefaultReplyTtsFields() - { - var configService = new StubUserFeishuBotConfigService - { - ConfigsByUsername = - { - ["alice"] = new UserFeishuBotConfigEntity - { - Username = "alice", - IsEnabled = true, - ReplyTtsEnabled = true, - ReplyTtsVoiceId = "voice-1" - } - } - }; - - var controller = CreateController(configService: configService); - - var result = await controller.GetFeishuBotConfig("bob"); - - var ok = Assert.IsType(result.Result); - var dto = Assert.IsType(ok.Value); - Assert.Equal("bob", dto.Username); - Assert.False(dto.ReplyTtsEnabled); - Assert.Null(dto.ReplyTtsVoiceId); - } - - [Fact] - public async Task SaveFeishuBotConfig_ForwardsReplyTtsFieldsIntoEntity() - { - var configService = new StubUserFeishuBotConfigService(); - var controller = CreateController(configService: configService); - - var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto - { - IsEnabled = true, - ReplyTtsEnabled = true, - ReplyTtsVoiceId = "voice-9" - }); - - Assert.IsType(result); - Assert.NotNull(configService.LastSavedConfig); - Assert.Equal("alice", configService.LastSavedConfig!.Username); - Assert.True(configService.LastSavedConfig.ReplyTtsEnabled); - Assert.Equal("voice-9", configService.LastSavedConfig.ReplyTtsVoiceId); - } - - [Fact] - public async Task SaveFeishuBotConfig_WhenReplyTtsEnabled_EnsuresTtsServiceStarted() - { - var platformService = new StubFeishuReplyTtsPlatformService(); - var controller = CreateController(platformService: platformService); - - var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto - { - IsEnabled = true, - ReplyTtsEnabled = true, - ReplyTtsVoiceId = "voice-9" - }); - - Assert.IsType(result); - Assert.Equal(1, platformService.EnsureStartedCallCount); - } - - [Fact] - public async Task SaveFeishuBotConfig_WhenReplyTtsDisabled_DoesNotStartTtsService() - { - var platformService = new StubFeishuReplyTtsPlatformService(); - var controller = CreateController(platformService: platformService); - - var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto - { - IsEnabled = true, - ReplyTtsEnabled = false - }); - - Assert.IsType(result); - Assert.Equal(0, platformService.EnsureStartedCallCount); - } - - [Fact] - public async Task GetFeishuTtsHealth_ReturnsPlatformHealthDto() - { - var platformService = new StubFeishuReplyTtsPlatformService - { - HealthResult = new FeishuReplyTtsHealthStatus - { - IsAvailable = true, - StorageRoot = @"D:\reply-tts", - ServiceStatus = "ok", - Device = "cpu", - DefaultVoiceId = "voice-default" - } - }; - var controller = CreateController(platformService: platformService); - - var result = await controller.GetFeishuTtsHealth(); - - var ok = Assert.IsType(result.Result); - var dto = Assert.IsType(ok.Value); - Assert.True(dto.IsAvailable); - Assert.Equal(@"D:\reply-tts", dto.StorageRoot); - Assert.Equal("ok", dto.ServiceStatus); - Assert.Equal("cpu", dto.Device); - Assert.Equal("voice-default", dto.DefaultVoiceId); - } - - [Fact] - public async Task GetFeishuTtsVoices_ReturnsPlatformVoiceDtos() - { - var platformService = new StubFeishuReplyTtsPlatformService - { - VoicesResult = - [ - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-a", - DisplayName = "Voice A" - }, - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-b", - DisplayName = "Voice B" - } - ] - }; - var controller = CreateController(platformService: platformService); - - var result = await controller.GetFeishuTtsVoices(); - - var ok = Assert.IsType(result.Result); - var dto = Assert.IsType>(ok.Value); - Assert.Collection( - dto, - voice => Assert.Equal("voice-a", voice.VoiceId), - voice => Assert.Equal("voice-b", voice.VoiceId)); - } - - [Fact] - public async Task GetFeishuTtsVoices_WhenPlatformReturnsEmptyList_ReturnsOkEmptyList() - { - var controller = CreateController(platformService: new StubFeishuReplyTtsPlatformService - { - VoicesResult = [] - }); - - var result = await controller.GetFeishuTtsVoices(); - - var ok = Assert.IsType(result.Result); - var dto = Assert.IsType>(ok.Value); - Assert.Empty(dto); - } - - private static AdminController CreateController( + internal static AdminController CreateController( StubUserFeishuBotConfigService? configService = null, - StubUserFeishuBotRuntimeService? runtimeService = null, - StubFeishuReplyTtsPlatformService? platformService = null) + StubUserFeishuBotRuntimeService? runtimeService = null) { return new AdminController( new StubUserAccountService(), @@ -207,10 +21,10 @@ private static AdminController CreateController( configService ?? new StubUserFeishuBotConfigService(), runtimeService ?? new StubUserFeishuBotRuntimeService(), new StubCliExecutorService(), - platformService ?? new StubFeishuReplyTtsPlatformService()); + new StubFeishuDocumentAdminGrantService()); } - private sealed class StubUserFeishuBotConfigService : IUserFeishuBotConfigService + internal sealed class StubUserFeishuBotConfigService : IUserFeishuBotConfigService { public Dictionary ConfigsByUsername { get; } = new(StringComparer.OrdinalIgnoreCase); public UserFeishuBotConfigEntity? LastSavedConfig { get; private set; } @@ -271,7 +85,7 @@ public Task GetEffectiveOptionsAsync(string? username) private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity entity) { - return new UserFeishuBotConfigEntity + var clone = new UserFeishuBotConfigEntity { Id = entity.Id, Username = entity.Username, @@ -285,16 +99,23 @@ private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity entity) ThinkingMessage = entity.ThinkingMessage, HttpTimeoutSeconds = entity.HttpTimeoutSeconds, StreamingThrottleMs = entity.StreamingThrottleMs, - ReplyTtsEnabled = entity.ReplyTtsEnabled, - ReplyTtsVoiceId = entity.ReplyTtsVoiceId, + FullReplyDocEnabled = entity.FullReplyDocEnabled, + FinalReplyDocEnabled = entity.FinalReplyDocEnabled, + AudioFullReplyDocEnabled = entity.AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = entity.AudioFinalReplyDocEnabled, + ReferencedMarkdownDocImportEnabled = entity.ReferencedMarkdownDocImportEnabled, LastStartedAt = entity.LastStartedAt, CreatedAt = entity.CreatedAt, UpdatedAt = entity.UpdatedAt }; + + var documentAdminProperty = typeof(UserFeishuBotConfigEntity).GetProperty("DocumentAdminOpenId"); + documentAdminProperty?.SetValue(clone, documentAdminProperty.GetValue(entity)); + return clone; } } - private sealed class StubUserFeishuBotRuntimeService : IUserFeishuBotRuntimeService + internal sealed class StubUserFeishuBotRuntimeService : IUserFeishuBotRuntimeService { public Task GetStatusAsync(string username, CancellationToken cancellationToken = default) { @@ -315,9 +136,14 @@ public Task StopAsync(string username, CancellationT UpdatedAt = DateTime.Now }); } + + public Task RecoverAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } } - private sealed class StubUserAccountService : IUserAccountService + internal sealed class StubUserAccountService : IUserAccountService { public Task EnsureSeedDataAsync() => throw new NotSupportedException(); public Task> GetAllAsync() => throw new NotSupportedException(); @@ -331,7 +157,7 @@ private sealed class StubUserAccountService : IUserAccountService public Task UpdateLastLoginAsync(string username, DateTime? lastLoginAt = null) => throw new NotSupportedException(); } - private sealed class StubUserToolPolicyService : IUserToolPolicyService + internal sealed class StubUserToolPolicyService : IUserToolPolicyService { public Task IsToolAllowedAsync(string username, string toolId) => throw new NotSupportedException(); public Task> GetAllowedToolIdsAsync(string username, IEnumerable allToolIds) => throw new NotSupportedException(); @@ -339,14 +165,14 @@ private sealed class StubUserToolPolicyService : IUserToolPolicyService public Task SaveAllowedToolsAsync(string username, IEnumerable allowedToolIds, IEnumerable allToolIds) => throw new NotSupportedException(); } - private sealed class StubUserWorkspacePolicyService : IUserWorkspacePolicyService + internal sealed class StubUserWorkspacePolicyService : IUserWorkspacePolicyService { public Task> GetAllowedDirectoriesAsync(string username) => throw new NotSupportedException(); public Task IsPathAllowedAsync(string username, string directoryPath) => throw new NotSupportedException(); public Task SaveAllowedDirectoriesAsync(string username, IEnumerable allowedDirectories) => throw new NotSupportedException(); } - private sealed class StubCliExecutorService : ICliExecutorService + internal sealed class StubCliExecutorService : ICliExecutorService { public ICliToolAdapter? GetAdapter(CliToolConfig tool) => throw new NotSupportedException(); public ICliToolAdapter? GetAdapterById(string toolId) => throw new NotSupportedException(); @@ -382,33 +208,12 @@ private sealed class StubCliExecutorService : ICliExecutorService public void RefreshWorkspaceRootCache() => throw new NotSupportedException(); } - private sealed class StubFeishuReplyTtsPlatformService : IFeishuReplyTtsPlatformService + internal sealed class StubFeishuDocumentAdminGrantService : IFeishuDocumentAdminGrantService { - public FeishuReplyTtsHealthStatus HealthResult { get; set; } = new(); - - public IReadOnlyList VoicesResult { get; set; } = []; - - public int EnsureStartedCallCount { get; private set; } + public Task GrantConfiguredAdminAsync(string username, string documentId) + => throw new NotSupportedException(); - public Task GetHealthAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(HealthResult); - } - - public Task> GetVoicesAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(VoicesResult); - } - - public Task ResolveVoiceOrFallbackAsync(string? savedVoiceId, CancellationToken cancellationToken = default) - { - throw new NotSupportedException(); - } - - public Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default) - { - EnsureStartedCallCount++; - return Task.FromResult(HealthResult); - } + public Task GrantConfiguredAdminBatchAsync(string username, IEnumerable documentIds) + => throw new NotSupportedException(); } } diff --git a/tests/WebCodeCli.Tests/AdminUserManagementFormHelperTests.cs b/tests/WebCodeCli.Tests/AdminUserManagementFormHelperTests.cs index ffa4b74..c086290 100644 --- a/tests/WebCodeCli.Tests/AdminUserManagementFormHelperTests.cs +++ b/tests/WebCodeCli.Tests/AdminUserManagementFormHelperTests.cs @@ -51,10 +51,11 @@ public void GetAllowedToolIds_ReturnsOnlyEnabledToolIds() } [Theory] - [InlineData(true, null, null, null, null, null, null, null, null, true)] - [InlineData(false, "app-id", null, null, null, null, null, null, null, true)] - [InlineData(false, null, null, null, null, null, null, 30, null, true)] - [InlineData(false, null, null, null, null, null, null, null, null, false)] + [InlineData(true, null, null, null, null, null, null, null, null, null, true)] + [InlineData(false, "app-id", null, null, null, null, null, null, null, null, true)] + [InlineData(false, null, null, null, null, null, null, "ou_admin", null, null, true)] + [InlineData(false, null, null, null, null, null, null, null, 30, null, true)] + [InlineData(false, null, null, null, null, null, null, null, null, null, false)] public void HasCustomFeishuConfig_DetectsWhetherOverrideExists( bool isEnabled, string? appId, @@ -63,6 +64,7 @@ public void HasCustomFeishuConfig_DetectsWhetherOverrideExists( string? verificationToken, string? defaultCardTitle, string? thinkingMessage, + string? documentAdminOpenId, int? httpTimeoutSeconds, int? streamingThrottleMs, bool expected) @@ -75,6 +77,7 @@ public void HasCustomFeishuConfig_DetectsWhetherOverrideExists( verificationToken, defaultCardTitle, thinkingMessage, + documentAdminOpenId, httpTimeoutSeconds, streamingThrottleMs); diff --git a/tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs b/tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs index 6ef6130..9540ac0 100644 --- a/tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs +++ b/tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs @@ -1,7 +1,6 @@ using System.Reflection; using WebCodeCli.Components; using WebCodeCli.Domain.Domain.Model; -using WebCodeCli.Domain.Domain.Model.Channels; using Xunit; namespace WebCodeCli.Tests; @@ -29,8 +28,10 @@ public void CreateDetailEditorSeed_PreservesCurrentDetailSections_WhenReloadingS var currentFeishu = Activator.CreateInstance(feishuType, nonPublic: true)!; SetProperty(currentFeishu, "AppId", "app-123"); - SetProperty(currentFeishu, "ReplyTtsEnabled", true); - SetProperty(currentFeishu, "ReplyTtsVoiceId", "voice-a"); + SetProperty(currentFeishu, "FullReplyDocEnabled", true); + SetProperty(currentFeishu, "FinalReplyDocEnabled", false); + SetProperty(currentFeishu, "AudioFullReplyDocEnabled", true); + SetProperty(currentFeishu, "AudioFinalReplyDocEnabled", true); SetProperty(currentEditor, "FeishuBot", currentFeishu); var selectedUser = Activator.CreateInstance(summaryType, nonPublic: true)!; @@ -55,45 +56,52 @@ public void CreateDetailEditorSeed_PreservesCurrentDetailSections_WhenReloadingS Assert.NotSame(GetProperty>(currentEditor, "AllowedToolIds"), seededTools); Assert.Equal(["git", "shell"], seededTools.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToArray()); Assert.Equal("app-123", GetProperty(seededFeishu, "AppId")); - Assert.Equal("voice-a", GetProperty(seededFeishu, "ReplyTtsVoiceId")); - Assert.True(GetProperty(seededFeishu, "ReplyTtsEnabled")); + Assert.True(GetProperty(seededFeishu, "FullReplyDocEnabled")); + Assert.False(GetProperty(seededFeishu, "FinalReplyDocEnabled")); + Assert.True(GetProperty(seededFeishu, "AudioFullReplyDocEnabled")); + Assert.True(GetProperty(seededFeishu, "AudioFinalReplyDocEnabled")); } [Fact] - public void MergeReplyTtsPlatformState_PreservesVoiceCatalog_WhenVoiceRefreshFails() + public void CreateDetailEditorSeed_PreservesReferencedMarkdownDocImportEnabled_WhenReloadingSameUser() { - var method = GetStaticMethod("MergeReplyTtsPlatformState"); - var currentHealth = new FeishuReplyTtsHealthStatus - { - IsAvailable = true, - Message = "Healthy", - DefaultVoiceId = "voice-a" - }; - IReadOnlyList currentVoices = - [ - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-a", - DisplayName = "Voice A" - } - ]; - var refreshedHealth = new FeishuReplyTtsHealthStatus - { - IsAvailable = true, - Message = "Healthy", - DefaultVoiceId = "voice-a" - }; - - var merged = method.Invoke(null, [currentHealth, currentVoices, refreshedHealth, null, null, "voice endpoint timed out"])!; - var mergedType = merged.GetType(); - var mergedHealth = (FeishuReplyTtsHealthStatus)mergedType.GetField("Item1")!.GetValue(merged)!; - var mergedVoices = (IReadOnlyList)mergedType.GetField("Item2")!.GetValue(merged)!; - - Assert.True(mergedHealth.IsAvailable); - Assert.Contains("voice endpoint timed out", mergedHealth.Message, StringComparison.OrdinalIgnoreCase); - Assert.Same(currentVoices, mergedVoices); - Assert.Single(mergedVoices); - Assert.Equal("voice-a", mergedVoices[0].VoiceId); + var editorType = GetNestedType("EditableUserModel"); + var feishuType = GetNestedType("EditableFeishuBotConfigModel"); + var summaryType = GetNestedType("UserSummaryDto"); + var method = GetStaticMethod("CreateDetailEditorSeed"); + + var currentEditor = Activator.CreateInstance(editorType, nonPublic: true)!; + SetProperty(currentEditor, "Username", "alice"); + + var currentFeishu = Activator.CreateInstance(feishuType, nonPublic: true)!; + SetOptionalProperty(currentFeishu, "ReferencedMarkdownDocImportEnabled", true); + SetProperty(currentEditor, "FeishuBot", currentFeishu); + + var selectedUser = Activator.CreateInstance(summaryType, nonPublic: true)!; + SetProperty(selectedUser, "Username", "alice"); + SetProperty(selectedUser, "DisplayName", "Alice"); + SetProperty(selectedUser, "Role", UserAccessConstants.UserRole); + SetProperty(selectedUser, "Status", UserAccessConstants.EnabledStatus); + SetProperty(selectedUser, "CreatedAt", new DateTime(2026, 6, 9, 9, 0, 0, DateTimeKind.Utc)); + + var seededEditor = method.Invoke(null, [selectedUser, currentEditor])!; + var seededFeishu = GetProperty(seededEditor, "FeishuBot"); + + Assert.True(GetOptionalBooleanProperty(seededFeishu, "ReferencedMarkdownDocImportEnabled")); + } + + [Fact] + public void HasCustomFeishuConfig_ReturnsTrue_WhenOnlyReferencedMarkdownDocImportEnabled() + { + var feishuType = GetNestedType("EditableFeishuBotConfigModel"); + var method = GetStaticMethod("HasCustomFeishuConfig"); + var config = Activator.CreateInstance(feishuType, nonPublic: true)!; + + SetOptionalProperty(config, "ReferencedMarkdownDocImportEnabled", true); + + var result = method.Invoke(null, [config]); + + Assert.Equal(true, result); } private static Type GetNestedType(string name) @@ -119,6 +127,19 @@ private static void SetProperty(object instance, string propertyName, object? va GetPropertyInfo(instance.GetType(), propertyName).SetValue(instance, value); } + private static void SetOptionalProperty(object instance, string propertyName, object? value) + { + instance.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.SetValue(instance, value); + } + + private static bool GetOptionalBooleanProperty(object instance, string propertyName) + { + return instance.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(instance) as bool? + ?? false; + } + private static PropertyInfo GetPropertyInfo(Type type, string propertyName) { return type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) diff --git a/tests/WebCodeCli.Tests/AdminUserManagementReplyDocumentModeTests.cs b/tests/WebCodeCli.Tests/AdminUserManagementReplyDocumentModeTests.cs new file mode 100644 index 0000000..8ec9d54 --- /dev/null +++ b/tests/WebCodeCli.Tests/AdminUserManagementReplyDocumentModeTests.cs @@ -0,0 +1,219 @@ +using Microsoft.AspNetCore.Mvc; +using WebCodeCli.Controllers; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class AdminUserManagementReplyDocumentModeTests +{ + [Fact] + public async Task GetFeishuBotConfig_WhenReplyDocumentsAreEnabled_ReturnsDocumentFlags() + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService + { + ConfigsByUsername = + { + ["alice"] = new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + FullReplyDocEnabled = true, + FinalReplyDocEnabled = true, + AudioFullReplyDocEnabled = true, + AudioFinalReplyDocEnabled = false + } + } + }; + + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.GetFeishuBotConfig("alice"); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.FullReplyDocEnabled); + Assert.True(dto.FinalReplyDocEnabled); + Assert.True(dto.AudioFullReplyDocEnabled); + Assert.False(dto.AudioFinalReplyDocEnabled); + } + + [Theory] + [InlineData(ReplyTtsModes.FullReply, true, false)] + [InlineData(ReplyTtsModes.FinalOnly, false, true)] + [InlineData(ReplyTtsModes.Off, false, false)] + public async Task SaveFeishuBotConfig_WhenLegacyModeProvided_MapsToReplyDocumentFlags( + string mode, + bool expectedFullReplyDocEnabled, + bool expectedFinalReplyDocEnabled) + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService(); + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsMode = mode + }); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.Equal(expectedFullReplyDocEnabled, configService.LastSavedConfig!.FullReplyDocEnabled); + Assert.Equal(expectedFinalReplyDocEnabled, configService.LastSavedConfig.FinalReplyDocEnabled); + } + + [Fact] + public async Task SaveFeishuBotConfig_WhenLegacyReplyTtsEnabled_PrefersFullReplyDocumentCompatibility() + { + var configService = new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotConfigService(); + var controller = AdminControllerReplyDocumentTestsAccessor.CreateController(configService: configService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsEnabled = true + }); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.True(configService.LastSavedConfig!.FullReplyDocEnabled); + Assert.False(configService.LastSavedConfig.FinalReplyDocEnabled); + } + + [Fact] + public async Task SaveFeishuBotConfig_WhenLegacyModeProvided_KeepsReferencedMarkdownImportIndependent() + { + var configService = new DirectStubUserFeishuBotConfigService(); + var controller = CreateDirectController(configService); + var request = new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsMode = ReplyTtsModes.FinalOnly + }; + SetBooleanProperty(request, "ReferencedMarkdownDocImportEnabled", true); + + var result = await controller.SaveFeishuBotConfig("alice", request); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.False(configService.LastSavedConfig!.FullReplyDocEnabled); + Assert.True(configService.LastSavedConfig.FinalReplyDocEnabled); + Assert.True(GetBooleanProperty(configService.LastSavedConfig, "ReferencedMarkdownDocImportEnabled")); + } + + private static bool GetBooleanProperty(object target, string propertyName) + { + return target + .GetType() + .GetProperty(propertyName)? + .GetValue(target) as bool? + ?? false; + } + + private static void SetBooleanProperty(object target, string propertyName, bool value) + { + target.GetType().GetProperty(propertyName)?.SetValue(target, value); + } + + private static AdminController CreateDirectController(DirectStubUserFeishuBotConfigService configService) + { + return new AdminController( + new AdminControllerReplyDocumentTestsAccessor.StubUserAccountService(), + new AdminControllerReplyDocumentTestsAccessor.StubUserToolPolicyService(), + new AdminControllerReplyDocumentTestsAccessor.StubUserWorkspacePolicyService(), + configService, + new AdminControllerReplyDocumentTestsAccessor.StubUserFeishuBotRuntimeService(), + new AdminControllerReplyDocumentTestsAccessor.StubCliExecutorService(), + new AdminControllerReplyDocumentTestsAccessor.StubFeishuDocumentAdminGrantService()); + } + + private sealed class DirectStubUserFeishuBotConfigService : IUserFeishuBotConfigService + { + public UserFeishuBotConfigEntity? LastSavedConfig { get; private set; } + + public Task GetByUsernameAsync(string username) + { + throw new NotSupportedException(); + } + + public Task GetByAppIdAsync(string appId) + { + throw new NotSupportedException(); + } + + public Task SaveAsync(UserFeishuBotConfigEntity config) + { + LastSavedConfig = Clone(config); + return Task.FromResult(UserFeishuBotConfigSaveResult.Saved()); + } + + public Task DeleteAsync(string username) + { + throw new NotSupportedException(); + } + + public Task FindConflictingUsernameByAppIdAsync(string username, string? appId) + { + throw new NotSupportedException(); + } + + public Task> GetAutoStartCandidatesAsync() + { + throw new NotSupportedException(); + } + + public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) + { + throw new NotSupportedException(); + } + + public FeishuOptions GetSharedDefaults() + { + throw new NotSupportedException(); + } + + public Task GetEffectiveOptionsAsync(string? username) + { + throw new NotSupportedException(); + } + + public Task GetEffectiveOptionsByAppIdAsync(string? appId) + { + throw new NotSupportedException(); + } + + private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity entity) + { + var clone = new UserFeishuBotConfigEntity + { + Id = entity.Id, + Username = entity.Username, + IsEnabled = entity.IsEnabled, + AutoStartEnabled = entity.AutoStartEnabled, + AppId = entity.AppId, + AppSecret = entity.AppSecret, + EncryptKey = entity.EncryptKey, + VerificationToken = entity.VerificationToken, + DefaultCardTitle = entity.DefaultCardTitle, + ThinkingMessage = entity.ThinkingMessage, + HttpTimeoutSeconds = entity.HttpTimeoutSeconds, + StreamingThrottleMs = entity.StreamingThrottleMs, + FullReplyDocEnabled = entity.FullReplyDocEnabled, + FinalReplyDocEnabled = entity.FinalReplyDocEnabled, + AudioFullReplyDocEnabled = entity.AudioFullReplyDocEnabled, + AudioFinalReplyDocEnabled = entity.AudioFinalReplyDocEnabled, + DocumentAdminOpenId = entity.DocumentAdminOpenId, + LastStartedAt = entity.LastStartedAt, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; + + var referencedMarkdownProperty = typeof(UserFeishuBotConfigEntity).GetProperty("ReferencedMarkdownDocImportEnabled"); + referencedMarkdownProperty?.SetValue(clone, referencedMarkdownProperty.GetValue(entity)); + return clone; + } + } +} diff --git a/tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs b/tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs deleted file mode 100644 index aafb5da..0000000 --- a/tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -using WebCodeCli.Domain.Domain.Model.Channels; -using WebCodeCli.Helpers; -using Xunit; - -namespace WebCodeCli.Tests; - -public sealed class AdminUserManagementReplyTtsUiStateTests -{ - [Fact] - public void Create_DisablesVoiceSelector_WhenReplyTtsIsOff() - { - var result = AdminUserManagementReplyTtsUiState.Create( - replyTtsEnabled: false, - savedVoiceId: "voice-a", - availableVoices: - [ - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-a", - DisplayName = "Voice A" - } - ], - platformIsAvailable: true, - platformMessage: null); - - Assert.True(result.IsVoiceSelectorDisabled); - } - - [Fact] - public void Create_DisablesVoiceSelector_WhenPlatformHealthIsUnavailable() - { - var result = AdminUserManagementReplyTtsUiState.Create( - replyTtsEnabled: true, - savedVoiceId: "voice-a", - availableVoices: - [ - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-a", - DisplayName = "Voice A" - } - ], - platformIsAvailable: false, - platformMessage: "Platform unavailable"); - - Assert.True(result.IsVoiceSelectorDisabled); - } - - [Fact] - public void Create_ReturnsFallbackWarning_WhenSavedVoiceIsMissing() - { - var result = AdminUserManagementReplyTtsUiState.Create( - replyTtsEnabled: true, - savedVoiceId: "voice-missing", - availableVoices: - [ - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-a", - DisplayName = "Voice A" - } - ], - platformIsAvailable: true, - platformMessage: null); - - Assert.Equal("Saved Feishu reply TTS voice 'voice-missing' is unavailable. Select a different voice before saving.", result.WarningMessage); - } - - [Fact] - public void Create_KeepsMissingSavedVoiceVisibleAsSyntheticOption() - { - var result = AdminUserManagementReplyTtsUiState.Create( - replyTtsEnabled: true, - savedVoiceId: "voice-missing", - availableVoices: - [ - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-a", - DisplayName = "Voice A" - } - ], - platformIsAvailable: true, - platformMessage: null); - - Assert.Collection( - result.VoiceOptions, - voice => - { - Assert.Equal("voice-missing", voice.VoiceId); - Assert.Equal("voice-missing (saved)", voice.DisplayName); - }, - voice => - { - Assert.Equal("voice-a", voice.VoiceId); - Assert.Equal("Voice A", voice.DisplayName); - }); - } - - [Fact] - public void Create_DoesNotWarnAboutMissingSavedVoice_WhenReplyTtsIsDisabled() - { - var result = AdminUserManagementReplyTtsUiState.Create( - replyTtsEnabled: false, - savedVoiceId: "voice-missing", - availableVoices: - [ - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-a", - DisplayName = "Voice A" - } - ], - platformIsAvailable: true, - platformMessage: null); - - Assert.True(result.IsVoiceSelectorDisabled); - Assert.Null(result.WarningMessage); - Assert.Equal("voice-missing", result.VoiceOptions[0].VoiceId); - } - - [Fact] - public void Create_ReturnsNoWarning_WhenPlatformIsHealthyAndVoicesExist() - { - var result = AdminUserManagementReplyTtsUiState.Create( - replyTtsEnabled: true, - savedVoiceId: "voice-a", - availableVoices: - [ - new FeishuReplyTtsVoiceOption - { - VoiceId = "voice-a", - DisplayName = "Voice A" - } - ], - platformIsAvailable: true, - platformMessage: "Healthy"); - - Assert.Null(result.WarningMessage); - } -} diff --git a/tests/WebCodeCli.Tests/PublishOutputConfigurationTests.cs b/tests/WebCodeCli.Tests/PublishOutputConfigurationTests.cs new file mode 100644 index 0000000..27f0757 --- /dev/null +++ b/tests/WebCodeCli.Tests/PublishOutputConfigurationTests.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using System.Text.Json; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class PublishOutputConfigurationTests : IDisposable +{ + private readonly string _testRoot = Path.Combine(Path.GetTempPath(), "PublishOutputConfigurationTests", Guid.NewGuid().ToString("N")); + + [Fact] + public void PublishOutput_DoesNotIncludeDevelopmentSettingsFile() + { + var repoRoot = ResolveRepoRoot(); + var projectPath = Path.Combine(repoRoot, "WebCodeCli", "WebCodeCli.csproj"); + var publishDirectory = Path.Combine(_testRoot, "publish"); + + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = repoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + startInfo.ArgumentList.Add("publish"); + startInfo.ArgumentList.Add(projectPath); + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add("Release"); + startInfo.ArgumentList.Add("-o"); + startInfo.ArgumentList.Add(publishDirectory); + startInfo.ArgumentList.Add("--no-restore"); + startInfo.ArgumentList.Add("/p:UseSharedCompilation=false"); + startInfo.ArgumentList.Add("/p:RunAnalyzers=false"); + + using var process = Process.Start(startInfo); + Assert.NotNull(process); + process!.WaitForExit(); + + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + + Assert.True( + process.ExitCode == 0, + $"dotnet publish failed with exit code {process.ExitCode}.{Environment.NewLine}STDOUT:{Environment.NewLine}{standardOutput}{Environment.NewLine}STDERR:{Environment.NewLine}{standardError}"); + + Assert.False(File.Exists(Path.Combine(publishDirectory, "appsettings.Development.json"))); + + var appsettingsPath = Path.Combine(publishDirectory, "appsettings.json"); + Assert.True(File.Exists(appsettingsPath)); + + using var document = JsonDocument.Parse(File.ReadAllText(appsettingsPath)); + Assert.True(document.RootElement.TryGetProperty("DBConnection", out var dbConnection)); + Assert.Equal("Sqlite", dbConnection.GetProperty("DbType").GetString()); + Assert.Equal("Data Source=WebCodeCli.db", dbConnection.GetProperty("ConnectionStrings").GetString()); + + Assert.False(document.RootElement.TryGetProperty("FeishuReplyTts", out _)); + } + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + private static string ResolveRepoRoot() + { + var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); + while (currentDirectory != null) + { + if (File.Exists(Path.Combine(currentDirectory.FullName, "Directory.Build.props")) && + File.Exists(Path.Combine(currentDirectory.FullName, "WebCodeCli", "WebCodeCli.csproj"))) + { + return currentDirectory.FullName; + } + + currentDirectory = currentDirectory.Parent; + } + + throw new InvalidOperationException("Could not resolve the repository root from the current test base directory."); + } +} diff --git a/tests/WebCodeCli.Tests/RuntimeEnvironmentResolverTests.cs b/tests/WebCodeCli.Tests/RuntimeEnvironmentResolverTests.cs new file mode 100644 index 0000000..6053374 --- /dev/null +++ b/tests/WebCodeCli.Tests/RuntimeEnvironmentResolverTests.cs @@ -0,0 +1,57 @@ +using WebCodeCli.Helpers; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class RuntimeEnvironmentResolverTests +{ + [Fact] + public void ResolveDefaultEnvironmentName_ReturnsProduction_WhenNoEnvironmentVariablesAreSet() + { + const string aspnetCoreEnvironment = "ASPNETCORE_ENVIRONMENT"; + const string dotnetEnvironment = "DOTNET_ENVIRONMENT"; + + var originalAspNetCoreEnvironment = Environment.GetEnvironmentVariable(aspnetCoreEnvironment); + var originalDotnetEnvironment = Environment.GetEnvironmentVariable(dotnetEnvironment); + + try + { + Environment.SetEnvironmentVariable(aspnetCoreEnvironment, null); + Environment.SetEnvironmentVariable(dotnetEnvironment, null); + + var resolved = RuntimeEnvironmentResolver.ResolveDefaultEnvironmentName(); + + Assert.Equal("Production", resolved); + } + finally + { + Environment.SetEnvironmentVariable(aspnetCoreEnvironment, originalAspNetCoreEnvironment); + Environment.SetEnvironmentVariable(dotnetEnvironment, originalDotnetEnvironment); + } + } + + [Fact] + public void ResolveDefaultEnvironmentName_PrefersExplicitAspNetCoreEnvironment() + { + const string aspnetCoreEnvironment = "ASPNETCORE_ENVIRONMENT"; + const string dotnetEnvironment = "DOTNET_ENVIRONMENT"; + + var originalAspNetCoreEnvironment = Environment.GetEnvironmentVariable(aspnetCoreEnvironment); + var originalDotnetEnvironment = Environment.GetEnvironmentVariable(dotnetEnvironment); + + try + { + Environment.SetEnvironmentVariable(aspnetCoreEnvironment, "Staging"); + Environment.SetEnvironmentVariable(dotnetEnvironment, "Production"); + + var resolved = RuntimeEnvironmentResolver.ResolveDefaultEnvironmentName(); + + Assert.Equal("Staging", resolved); + } + finally + { + Environment.SetEnvironmentVariable(aspnetCoreEnvironment, originalAspNetCoreEnvironment); + Environment.SetEnvironmentVariable(dotnetEnvironment, originalDotnetEnvironment); + } + } +} diff --git a/tests/WebCodeCli.Tests/SuperpowersQuickActionSubmissionHelperTests.cs b/tests/WebCodeCli.Tests/SuperpowersQuickActionSubmissionHelperTests.cs index 82e9234..8fa3e4c 100644 --- a/tests/WebCodeCli.Tests/SuperpowersQuickActionSubmissionHelperTests.cs +++ b/tests/WebCodeCli.Tests/SuperpowersQuickActionSubmissionHelperTests.cs @@ -36,10 +36,20 @@ public void BuildMessage_ReturnsExecuteSubagentPlanPrompt_ForExecuteSubagentPlan Assert.Equal(SuperpowersQuickActionDefaults.ExecuteSubagentPlanPrompt, result); } + [Fact] + public void BuildMessage_ReturnsCompleteWorktreePrompt_ForCompleteWorktreeAction() + { + var result = SuperpowersQuickActionSubmissionHelper.BuildMessage( + SuperpowersQuickActionRequestType.ExecuteCompleteWorktree, + quickInput: null); + + Assert.Equal(SuperpowersQuickActionDefaults.CompleteWorktreePrompt, result); + } + [Theory] - [InlineData("整理这个 plan", "$using-superpowers ,使用superpowers技能,整理这个 plan\n\nReply to the user in Chinese. Write documentation and code comments in English only. Keep exception and error messages in Chinese.")] - [InlineData("$superpowers ,使用superpowers技能,整理这个 plan", "$using-superpowers ,使用superpowers技能,整理这个 plan\n\nReply to the user in Chinese. Write documentation and code comments in English only. Keep exception and error messages in Chinese.")] - [InlineData(" 整理这个 plan ", "$using-superpowers ,使用superpowers技能,整理这个 plan\n\nReply to the user in Chinese. Write documentation and code comments in English only. Keep exception and error messages in Chinese.")] + [InlineData("整理这个 plan", "$using-superpowers ,使用superpowers技能,整理这个 plan\n\nReply to the user in Chinese. Write documentation in English only. 代码注释需要使用中英文双语。 Keep exception and error messages in Chinese.")] + [InlineData("$superpowers ,使用superpowers技能,整理这个 plan", "$using-superpowers ,使用superpowers技能,整理这个 plan\n\nReply to the user in Chinese. Write documentation in English only. 代码注释需要使用中英文双语。 Keep exception and error messages in Chinese.")] + [InlineData(" 整理这个 plan ", "$using-superpowers ,使用superpowers技能,整理这个 plan\n\nReply to the user in Chinese. Write documentation in English only. 代码注释需要使用中英文双语。 Keep exception and error messages in Chinese.")] public void BuildMessage_AppliesQuickInputPrefixRules(string input, string expected) { var result = SuperpowersQuickActionSubmissionHelper.BuildMessage( diff --git a/tests/git-import-final-working.spec.ts b/tests/git-import-final-working.spec.ts index 380d276..565ac33 100644 --- a/tests/git-import-final-working.spec.ts +++ b/tests/git-import-final-working.spec.ts @@ -1,5 +1,7 @@ import { test, expect } from '@playwright/test'; +const PASSWORD = process.env.WEBCODE_TEST_PASSWORD ?? 'CHANGEME_TEST_PASSWORD'; + // 设置全局超时时间为2分钟,足够Git克隆完成(手动克隆仅需20秒) test.setTimeout(120000); @@ -13,7 +15,7 @@ test('新建会话时导入Git项目完全测试', async ({ page }) => { // 输入用户名密码 await page.locator('input[placeholder="Enter username"]').fill('luhaiyan'); - await page.locator('input[placeholder="Enter password"]').fill('Lusile@0680'); + await page.locator('input[placeholder="Enter password"]').fill(PASSWORD); // 点击登录并等待跳转 await Promise.all([ diff --git a/tests/git-import-final.spec.ts b/tests/git-import-final.spec.ts index 550ad2a..fd0a5cd 100644 --- a/tests/git-import-final.spec.ts +++ b/tests/git-import-final.spec.ts @@ -1,5 +1,7 @@ import { test, expect } from '@playwright/test'; +const PASSWORD = process.env.WEBCODE_TEST_PASSWORD ?? 'CHANGEME_TEST_PASSWORD'; + // 设置全局超时时间为5分钟 test.setTimeout(300000); @@ -22,7 +24,7 @@ test.describe('Git项目导入功能测试', () => { const passwordInput = page.locator('input[placeholder="Enter password"]'); await passwordInput.waitFor({ state: 'visible' }); - await passwordInput.fill('Lusile@0680'); + await passwordInput.fill(PASSWORD); console.log('✅ 输入密码'); // 3. 点击登录按钮,等待导航完成 diff --git a/tests/git-import-simple.spec.ts b/tests/git-import-simple.spec.ts index 3c7a72f..67a7a88 100644 --- a/tests/git-import-simple.spec.ts +++ b/tests/git-import-simple.spec.ts @@ -1,5 +1,7 @@ import { test, expect } from '@playwright/test'; +const PASSWORD = process.env.WEBCODE_TEST_PASSWORD ?? 'CHANGEME_TEST_PASSWORD'; + // 设置全局超时时间为5分钟 test.setTimeout(300000); @@ -8,7 +10,7 @@ test('新建会话时导入Git项目成功', async ({ page }) => { // 1. 登录 await page.goto('http://localhost:5000', { waitUntil: 'networkidle' }); await page.locator('input[placeholder="Enter username"]').fill('luhaiyan'); - await page.locator('input[placeholder="Enter password"]').fill('Lusile@0680'); + await page.locator('input[placeholder="Enter password"]').fill(PASSWORD); await Promise.all([ page.waitForNavigation({ waitUntil: 'networkidle' }), page.locator('button:has-text("Login")').click() diff --git a/tests/git-import.spec.ts b/tests/git-import.spec.ts index 120cb30..f025478 100644 --- a/tests/git-import.spec.ts +++ b/tests/git-import.spec.ts @@ -1,5 +1,7 @@ import { test, expect } from '@playwright/test'; +const PASSWORD = process.env.WEBCODE_TEST_PASSWORD ?? 'CHANGEME_TEST_PASSWORD'; + // 设置全局超时时间为3分钟 test.setTimeout(180000); @@ -22,7 +24,7 @@ test.describe('Git项目导入功能测试', () => { const passwordInput = page.locator('input[placeholder="Enter password"]'); await passwordInput.waitFor({ state: 'visible' }); - await passwordInput.fill('Lusile@0680'); + await passwordInput.fill(PASSWORD); console.log('✅ 输入密码'); // 3. 点击登录按钮,等待导航完成 diff --git a/tests/web-workspace-management-unified.spec.ts b/tests/web-workspace-management-unified.spec.ts index ca4f9f6..4b29b72 100644 --- a/tests/web-workspace-management-unified.spec.ts +++ b/tests/web-workspace-management-unified.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'; // 测试配置 const BASE_URL = 'http://localhost:5000'; const USERNAME = 'luhaiyan'; -const PASSWORD = 'Lusile@0680'; +const PASSWORD = process.env.WEBCODE_TEST_PASSWORD ?? 'CHANGEME_TEST_PASSWORD'; const SCREEN_SIZE = { width: 1200, height: 800 }; const TEST_DIRECTORY = 'D:\\\\VSWorkshop\\\\TestWebCode'; // 测试用的工作目录 @@ -285,4 +285,4 @@ test.describe('统一工作区功能测试', () => { console.log('ℹ️ 会话中可能还有其他元素'); } }); -}); \ No newline at end of file +}); diff --git a/tests/web-workspace-management.spec.ts b/tests/web-workspace-management.spec.ts index 5143ad4..7646a77 100644 --- a/tests/web-workspace-management.spec.ts +++ b/tests/web-workspace-management.spec.ts @@ -5,6 +5,7 @@ test.describe.configure({ timeout: 120000 }); test.describe('统一工作区管理功能测试', () => { // 测试配置 const BASE_URL = 'http://localhost:5000'; + const PASSWORD = process.env.WEBCODE_TEST_PASSWORD ?? 'CHANGEME_TEST_PASSWORD'; const SCREEN_SIZE = { width: 1200, height: 800 }; const TEST_DIRECTORY = 'D:\\\\VSWorkshop\\\\TestWebCode'; // 测试用的工作目录 @@ -24,7 +25,7 @@ test.describe('统一工作区管理功能测试', () => { const loginButton = page.getByRole('button').first(); await usernameInput.fill('luhaiyan'); - await passwordInput.fill('Lusile@0680'); + await passwordInput.fill(PASSWORD); await loginButton.click(); // 等待登录请求完成 diff --git a/tests/workspace-authorization.spec.ts b/tests/workspace-authorization.spec.ts index 0d2e413..1ab4234 100644 --- a/tests/workspace-authorization.spec.ts +++ b/tests/workspace-authorization.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'; // 测试配置 const BASE_URL = 'http://localhost:5000'; const USERNAME = 'luhaiyan'; -const PASSWORD = 'Lusile@0680'; +const PASSWORD = process.env.WEBCODE_TEST_PASSWORD ?? 'CHANGEME_TEST_PASSWORD'; const SCREEN_SIZE = { width: 1200, height: 800 }; const TEST_DIRECTORY = 'D:\\\\VSWorkshop\\\\TestWebCode'; const TEST_USER_2 = 'testuser2'; @@ -150,4 +150,4 @@ test.describe('目录授权功能测试', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/tests/workspace-management-full.spec.ts b/tests/workspace-management-full.spec.ts index 6f1d226..0f7ff3a 100644 --- a/tests/workspace-management-full.spec.ts +++ b/tests/workspace-management-full.spec.ts @@ -4,7 +4,7 @@ import { test, expect } from '@playwright/test'; const BASE_URL = 'http://localhost:5000'; const USERNAME = 'luhaiyan'; -const PASSWORD = 'Lusile@0680'; +const PASSWORD = process.env.WEBCODE_TEST_PASSWORD ?? 'CHANGEME_TEST_PASSWORD'; // 测试目录路径 const TEST_DIRECTORY = 'D:\\\\VSWorkshop\\\\TestWebCode'; diff --git a/tools/build-windows-installer.ps1 b/tools/build-windows-installer.ps1 index 3a63d16..bf94fad 100644 --- a/tools/build-windows-installer.ps1 +++ b/tools/build-windows-installer.ps1 @@ -4,9 +4,7 @@ param( [string]$Configuration = "Release", [string]$RuntimeIdentifier = "win-x64", [string]$ProjectPath, - [string]$OutputRoot, - [string]$ReplyTtsSourceRoot, - [string]$ReplyTtsFfmpegExecutablePath + [string]$OutputRoot ) $ErrorActionPreference = "Stop" @@ -84,12 +82,19 @@ function Update-PublishAppSettings { } } + $dbConnection = $null if ($settings.ContainsKey("DBConnection")) { $dbConnection = $settings["DBConnection"] - if ($dbConnection) { - $dbConnection["ConnectionStrings"] = "Data Source=data/WebCodeCli.db" - $dbConnection["VectorConnection"] = "data/WebCodeCliMem.db" - } + } + else { + $dbConnection = @{} + $settings["DBConnection"] = $dbConnection + } + + if ($dbConnection) { + $dbConnection["DbType"] = "Sqlite" + $dbConnection["ConnectionStrings"] = "Data Source=data/WebCodeCli.db" + $dbConnection["VectorConnection"] = "data/WebCodeCliMem.db" } if ($settings.ContainsKey("CliTools")) { @@ -110,268 +115,6 @@ function Update-PublishAppSettings { Write-JsonObject -Path $settingsPath -Value $settings } -function Copy-ReplyTtsServiceAssets { - param( - [string]$RepoRoot, - [string]$PublishDirectory - ) - - $sourceRoot = Join-Path $RepoRoot "tools\sherpa-kokoro-service" - if (-not (Test-Path $sourceRoot)) { - throw "Reply TTS service assets were not found at $sourceRoot" - } - - $destinationRoot = Join-Path $PublishDirectory "tools\sherpa-kokoro-service" - if (Test-Path $destinationRoot) { - Remove-Item -Recurse -Force $destinationRoot - } - - New-Item -ItemType Directory -Force -Path $destinationRoot | Out-Null - - foreach ($relativePath in @( - "README.md", - "requirements.txt", - "app.py", - "start.ps1", - "start.sh")) { - $sourcePath = Join-Path $sourceRoot $relativePath - if (-not (Test-Path $sourcePath)) { - throw "Required Reply TTS service asset was not found at $sourcePath" - } - - $destinationPath = Join-Path $destinationRoot $relativePath - $destinationParent = Split-Path -Parent $destinationPath - if (-not (Test-Path $destinationParent)) { - New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null - } - - Copy-Item -Path $sourcePath -Destination $destinationPath -Force - } -} - -function Get-WindowsSystemDriveRoot { - $systemRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::System) - if (-not [string]::IsNullOrWhiteSpace($systemRoot)) { - return [System.IO.Path]::GetPathRoot($systemRoot) - } - - $systemDrive = [Environment]::GetEnvironmentVariable("SystemDrive") - if ([string]::IsNullOrWhiteSpace($systemDrive)) { - return "C:\" - } - - return "$($systemDrive.TrimEnd('\'))\" -} - -function Test-IsSameWindowsDrive { - param( - [string]$Left, - [string]$Right - ) - - if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) { - return $false - } - - $leftRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Left)) - $rightRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Right)) - - return $leftRoot.TrimEnd('\') -ieq $rightRoot.TrimEnd('\') -} - -function Resolve-ReplyTtsSourceRoot { - param([string]$RequestedSourceRoot) - - if (-not [string]::IsNullOrWhiteSpace($RequestedSourceRoot)) { - if (-not (Test-Path $RequestedSourceRoot)) { - throw "Reply TTS bundle source root was not found at $RequestedSourceRoot" - } - - return (Resolve-Path $RequestedSourceRoot).Path - } - - $systemDriveRoot = Get-WindowsSystemDriveRoot - $candidateRoots = [System.IO.DriveInfo]::GetDrives() | - Where-Object { - $_.DriveType -eq [System.IO.DriveType]::Fixed -and - $_.IsReady -and - -not (Test-IsSameWindowsDrive $_.RootDirectory.FullName $systemDriveRoot) - } | - Sort-Object Name | - ForEach-Object { Join-Path $_.RootDirectory.FullName "WebCodeData\Kokoro" } - - foreach ($candidateRoot in $candidateRoots) { - if ( - (Test-Path $candidateRoot) -and - (Test-Path (Join-Path $candidateRoot "models\kokoro-int8-multi-lang-v1_1")) -and - (Test-Path (Join-Path $candidateRoot "venv\Scripts\python.exe")) -and - (Test-Path (Join-Path $candidateRoot "python")) - ) { - return (Resolve-Path $candidateRoot).Path - } - } - - throw "Reply TTS bundle source root was not found on any writable non-system fixed drive. Pass -ReplyTtsSourceRoot explicitly." -} - -function Resolve-ReplyTtsFfmpegExecutablePath { - param( - [string]$RequestedExecutablePath, - [string]$SourceRoot - ) - - $candidatePaths = New-Object System.Collections.Generic.List[string] - - if (-not [string]::IsNullOrWhiteSpace($RequestedExecutablePath)) { - $candidatePaths.Add($RequestedExecutablePath) - } - - $candidatePaths.Add((Join-Path $SourceRoot "ffmpeg\bin\ffmpeg.exe")) - - $ffmpegCommand = Get-Command ffmpeg.exe -CommandType Application -ErrorAction SilentlyContinue - if ($ffmpegCommand) { - $candidatePaths.Add($ffmpegCommand.Source) - } - - $candidatePaths.Add("C:\Program Files\ImageMagick-7.1.0-Q16\ffmpeg.exe") - - foreach ($candidatePath in $candidatePaths | Select-Object -Unique) { - if (-not [string]::IsNullOrWhiteSpace($candidatePath) -and (Test-Path $candidatePath)) { - return (Resolve-Path $candidatePath).Path - } - } - - throw "Reply TTS ffmpeg executable was not found. Pass -ReplyTtsFfmpegExecutablePath explicitly." -} - -function Get-ReplyTtsBundledPythonHome { - param([string]$PythonRoot) - - if (-not (Test-Path $PythonRoot)) { - throw "Reply TTS python root was not found at $PythonRoot" - } - - $bundledPythonHome = Get-ChildItem -Path $PythonRoot -Directory | - Sort-Object Name | - Where-Object { Test-Path (Join-Path $_.FullName "python.exe") } | - Select-Object -First 1 - - if ($null -eq $bundledPythonHome) { - throw "Reply TTS python root at $PythonRoot does not contain a bundled python.exe" - } - - return $bundledPythonHome.FullName -} - -function Get-ReplyTtsBundleSourceLayout { - param( - [string]$RequestedSourceRoot, - [string]$RequestedFfmpegExecutablePath - ) - - $sourceRoot = Resolve-ReplyTtsSourceRoot -RequestedSourceRoot $RequestedSourceRoot - $modelRoot = Join-Path $sourceRoot "models\kokoro-int8-multi-lang-v1_1" - $venvRoot = Join-Path $sourceRoot "venv" - $pythonRoot = Join-Path $sourceRoot "python" - $venvPythonPath = Join-Path $venvRoot "Scripts\python.exe" - $venvConfigPath = Join-Path $venvRoot "pyvenv.cfg" - - if (-not (Test-Path $modelRoot)) { - throw "Reply TTS model directory was not found at $modelRoot" - } - - foreach ($requiredRelativePath in @( - "model.int8.onnx", - "voices.bin", - "tokens.txt", - "lexicon-us-en.txt", - "lexicon-zh.txt", - "date-zh.fst", - "phone-zh.fst", - "number-zh.fst", - "espeak-ng-data")) { - $requiredPath = Join-Path $modelRoot $requiredRelativePath - if (-not (Test-Path $requiredPath)) { - throw "Reply TTS model directory is incomplete. Missing $requiredPath" - } - } - - if (-not (Test-Path $venvPythonPath)) { - throw "Reply TTS bundled venv python was not found at $venvPythonPath" - } - - if (-not (Test-Path $venvConfigPath)) { - throw "Reply TTS bundled venv config was not found at $venvConfigPath" - } - - $bundledPythonHome = Get-ReplyTtsBundledPythonHome -PythonRoot $pythonRoot - $ffmpegExecutablePath = Resolve-ReplyTtsFfmpegExecutablePath ` - -RequestedExecutablePath $RequestedFfmpegExecutablePath ` - -SourceRoot $sourceRoot - - return [pscustomobject]@{ - SourceRoot = $sourceRoot - ModelRoot = $modelRoot - VenvRoot = $venvRoot - PythonRoot = $pythonRoot - BundledPythonHome = $bundledPythonHome - FfmpegExecutablePath = $ffmpegExecutablePath - } -} - -function Copy-DirectoryTree { - param( - [string]$Source, - [string]$Destination - ) - - if (-not (Test-Path $Source)) { - throw "Directory copy source was not found at $Source" - } - - $parentPath = Split-Path -Parent $Destination - if (-not [string]::IsNullOrWhiteSpace($parentPath) -and -not (Test-Path $parentPath)) { - New-Item -ItemType Directory -Force -Path $parentPath | Out-Null - } - - if (Test-Path $Destination) { - Remove-Item -Recurse -Force $Destination - } - - Copy-Item -Path $Source -Destination $Destination -Recurse -Force -} - -function Copy-ReplyTtsBundleAssets { - param( - [psobject]$SourceLayout, - [string]$BundleDirectory - ) - - if (Test-Path $BundleDirectory) { - Remove-Item -Recurse -Force $BundleDirectory - } - - New-Item -ItemType Directory -Force -Path $BundleDirectory | Out-Null - - Copy-DirectoryTree ` - -Source $SourceLayout.ModelRoot ` - -Destination (Join-Path $BundleDirectory "models\kokoro-int8-multi-lang-v1_1") - Copy-DirectoryTree ` - -Source $SourceLayout.PythonRoot ` - -Destination (Join-Path $BundleDirectory "python") - Copy-DirectoryTree ` - -Source $SourceLayout.VenvRoot ` - -Destination (Join-Path $BundleDirectory "venv") - - $ffmpegBinDirectory = Join-Path $BundleDirectory "ffmpeg\bin" - New-Item -ItemType Directory -Force -Path $ffmpegBinDirectory | Out-Null - Copy-Item -Path $SourceLayout.FfmpegExecutablePath -Destination (Join-Path $ffmpegBinDirectory "ffmpeg.exe") -Force - - foreach ($relativeDirectory in @("cache", "logs", "service", "temp")) { - New-Item -ItemType Directory -Force -Path (Join-Path $BundleDirectory $relativeDirectory) | Out-Null - } -} - function Get-LaunchUrlForReleaseNotes { param([string]$PublishDirectory) @@ -414,9 +157,6 @@ $repoRoot = Get-RepoRoot -ScriptRoot $PSScriptRoot $projectFullPath = (Resolve-Path $ProjectPath).Path $resolvedVersion = Get-BuildVersion -RequestedVersion $Version -RepoRoot $repoRoot $assemblyVersion = "$resolvedVersion.0" -$replyTtsSourceLayout = Get-ReplyTtsBundleSourceLayout ` - -RequestedSourceRoot $ReplyTtsSourceRoot ` - -RequestedFfmpegExecutablePath $ReplyTtsFfmpegExecutablePath if ($resolvedVersion -notmatch '^\d+\.\d+\.\d+$') { throw "Resolved version '$resolvedVersion' is not in major.minor.patch format." @@ -429,13 +169,13 @@ if ([System.IO.Path]::IsPathRooted($OutputRoot)) { else { $outputRootFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputRoot)) } + $releaseRoot = Join-Path $outputRootFullPath $versionTag $publishDirectory = Join-Path $releaseRoot "publish" $portableDirectoryName = "WebCode-$versionTag-$RuntimeIdentifier-portable" $portableStageDirectory = Join-Path $releaseRoot $portableDirectoryName $portableZipPath = Join-Path $releaseRoot "$portableDirectoryName.zip" $installerOutputDirectory = Join-Path $releaseRoot "installer" -$ttsBundleDirectory = Join-Path $releaseRoot "tts-bundle" $installerBaseFileName = "WebCode-Setup-$versionTag-$RuntimeIdentifier" $installerPath = Join-Path $installerOutputDirectory "$installerBaseFileName.exe" $checksumsPath = Join-Path $releaseRoot "SHA256SUMS.txt" @@ -449,8 +189,6 @@ New-Item -ItemType Directory -Force -Path $publishDirectory | Out-Null New-Item -ItemType Directory -Force -Path $installerOutputDirectory | Out-Null Write-Host "Publishing WebCode $resolvedVersion for $RuntimeIdentifier ..." -# Release packaging does not need analyzers or debug symbols, and forcing a -# single-node publish avoids Roslyn OOMs on large Windows builds. dotnet publish $projectFullPath ` -c $Configuration ` -r $RuntimeIdentifier ` @@ -480,8 +218,6 @@ if (-not (Test-Path $publishedExePath)) { } Update-PublishAppSettings -PublishDirectory $publishDirectory -Copy-ReplyTtsServiceAssets -RepoRoot $repoRoot -PublishDirectory $publishDirectory -Copy-ReplyTtsBundleAssets -SourceLayout $replyTtsSourceLayout -BundleDirectory $ttsBundleDirectory $launchUrl = Get-LaunchUrlForReleaseNotes -PublishDirectory $publishDirectory if (Test-Path $portableStageDirectory) { @@ -502,7 +238,6 @@ Write-Host "Compiling Windows installer with Inno Setup ..." & $isccPath ` "/DMyAppVersion=$resolvedVersion" ` "/DPublishDir=$publishDirectory" ` - "/DTtsBundleDir=$ttsBundleDirectory" ` "/DOutputDir=$installerOutputDirectory" ` "/DMyAppInstallerFileName=$installerBaseFileName" ` "/DMyAppSourceExe=WebCodeCli.exe" ` @@ -537,10 +272,12 @@ $releaseNotes = @" - The installer keeps an existing appsettings.json on upgrade - Default install path is %LOCALAPPDATA%\Programs\WebCode - Default runtime data paths are data/ and workspaces/ under the install directory -- Includes the local Kokoro/sherpa-onnx Reply TTS wrapper under tools/sherpa-kokoro-service -- The Windows installer deploys the bundled Reply TTS model, ffmpeg, Python runtime, and venv to a writable non-system drive such as E:\WebCodeData\Kokoro -- The Windows installer stops with an error if only the Windows system drive is writable - After launch, open $launchUrl in the browser + +## Feishu reply delivery +- Reply TTS has been removed from WebCode. +- Feishu full-reply and conclusion-reply outputs are stored as cloud documents. +- Users can listen to the generated reply through Feishu document audio. "@ [System.IO.File]::WriteAllText($releaseNotesPath, $releaseNotes.Trim() + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) @@ -550,4 +287,3 @@ Write-Host "Installer: $installerPath" Write-Host "Portable ZIP: $portableZipPath" Write-Host "Checksums: $checksumsPath" Write-Host "Release notes: $releaseNotesPath" -Write-Host "Reply TTS source root: $($replyTtsSourceLayout.SourceRoot)"