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