From 2d4b0b0d638c59587293f17de9f4753e4a2c227e Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 22:53:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20ask=20=E5=B7=A5=E5=85=B7=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E6=B8=B2=E6=9F=93=E6=B8=85=E7=90=86=E4=B8=8E=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E8=87=AA=E9=80=82=E5=BA=94=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: AI 生成文本混入 CR 段(\r\r\n 病态行尾)直接写终端导致超长换行错乱;长问题/选项裸写靠终端原生 wrap(ANSI 转义序列在任意列截断、中文宽字符不计数) 修复: - 新建 QuestionTextSanitizer 纯函数类:Sanitize(CR 段贪吃折叠单换行、\n\r 防双换行、ESC/NUL 剥除防终端二次解释、TAB 转空格)+ Wrap(显示宽度词边界折行、CJK 宽字符 2 列计、悬挂缩进对齐、超宽词硬切、段落保留) - TerminalInteractiveService 全渲染点接入:question/header 按终端宽折行、选项标签悬挂对齐标签内容列、描述 5 列缩进折行;ANSI 样式改为按行独立包夹(折行后转义不被截断) - 18 单测锁定清理与折行语义(含 \x00 变长转义陷阱用 \u 定长改写) 验证: host.tests 1433 全绿(含既有 AskClarify 命令测试回归) --- .../core/interaction/QuestionTextSanitizer.cs | 186 ++++++++++++++++++ .../interaction/TerminalInteractiveService.cs | 52 ++++- .../interaction/QuestionTextSanitizerTests.cs | 86 ++++++++ 3 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 modules/apps/cli/core/interaction/QuestionTextSanitizer.cs create mode 100644 test/unit/host.tests/interaction/QuestionTextSanitizerTests.cs diff --git a/modules/apps/cli/core/interaction/QuestionTextSanitizer.cs b/modules/apps/cli/core/interaction/QuestionTextSanitizer.cs new file mode 100644 index 0000000000..7af707c5de --- /dev/null +++ b/modules/apps/cli/core/interaction/QuestionTextSanitizer.cs @@ -0,0 +1,186 @@ +namespace JoinCode.Cli.Interaction; + +/// +/// 问题文本规整器 — AI 生成文本(question/options/description)入终端前的统一清洗与折行: +/// ①:行尾归一(CRLF/裸 CR/病态 \r 段 → 单 \n,AI 输出混入 CR 是超长换行根因)、 +/// ESC 剥除(防终端二次解释 ANSI)、NUL 等控制字符剥除、TAB 转空格(对齐稳定); +/// ②:按显示宽度词边界折行(CJK 宽字符 2 列计)、悬挂缩进对齐、超宽词硬切防单行超屏。 +/// 纯函数零状态 — 终端交互与 TUI 选项渲染共用同一口径。 +/// +public static class QuestionTextSanitizer { + /// + /// 清洗文本 — 行尾归一(\r 段折叠单 \n)+ 剥控制字符(ESC/NUL 等 <0x20 除 \n)+ TAB 转空格。 + /// 顺序扫描单遍完成(\r 段贪吃 + 段后无 \n 自补一个 — CR 语义即换行)。 + /// + /// 原始文本(AI 生成,可能混入 CR/控制字符)。 + /// 干净文本(行尾统一 \n,无 ESC/控制字符)。 + public static string Sanitize(string text) { + if (text.Length == 0) + return text; + + var builder = new StringBuilder(text.Length); + var index = 0; + while (index < text.Length) { + var ch = text[index]; + if (ch == '\r') { + // \r 段贪吃(\r\r\n 等病态混合折叠为单换行;\n\r 混合防双换行 — 尾已是 \n 不补) + while (index < text.Length && text[index] == '\r') { + index++; + } + + if (index < text.Length && text[index] == '\n') { + index++; + } + + if (builder.Length == 0 || builder[^1] != '\n') { + builder.Append('\n'); + } + + continue; + } + + if (ch == '\n') { + builder.Append('\n'); + index++; + continue; + } + + if (ch == '\t') { + builder.Append(' '); + index++; + continue; + } + + if (ch < ' ') { + // C0 控制字符剥除(ESC 防终端二次解释 ANSI、NUL 等) + index++; + continue; + } + + builder.Append(ch); + index++; + } + + return builder.ToString(); + } + + /// + /// 按显示宽度折行 — 段落(\n)保留独立折行;空格分词词边界换行;CJK 宽字符按 2 列计; + /// 续行悬挂缩进 indent 空格(可用宽度 = width - indent);超宽词按可用宽度硬切。 + /// 宽度钳制:width ≤ indent 或 ≤0 返回单行原文(防退化)。 + /// + /// 已清洗文本(\n 行尾)。 + /// 目标显示宽度(显示列)。 + /// 续行悬挂缩进列数。 + /// 折行结果(每元素 ≤ width 显示列;空文本单空行)。 + public static IReadOnlyList Wrap(string text, int width, int indent) { + if (width <= indent || width <= 0) { + return [text]; + } + + var lines = new List(); + foreach (var segment in text.Split('\n')) { + WrapSegment(segment, width, indent, lines); + } + + return lines.Count > 0 ? lines : [string.Empty]; + } + + /// 单段折行 — 词序列逐词装行(首行与续行同内容宽 available,续行前置 indent 悬挂缩进); + /// 超宽词硬切后并入装行流程;段恰装满时尾空行不输出(空段保留单空行 — 段落边界语义)。 + private static void WrapSegment(string segment, int width, int indent, List lines) { + var available = width - indent; + var current = new StringBuilder(); + var currentWidth = 0; + var pendingIndent = false; + foreach (var word in SplitWords(segment)) { + if (DisplayWidth(word) > available) { + FlushCurrent(); + AppendHardChunks(word, available, indent, lines, pendingIndent ? indent : 0); + pendingIndent = true; + continue; + } + + var separatorWidth = currentWidth == 0 ? 0 : 1; + if (currentWidth + separatorWidth + DisplayWidth(word) > available) { + FlushCurrent(); + } + + if (currentWidth == 0 && pendingIndent) { + current.Append(' ', indent); + } + + if (currentWidth > 0) { + current.Append(' '); + currentWidth++; + } + + current.Append(word); + currentWidth += DisplayWidth(word); + } + + if (current.Length > 0 || lines.Count == 0) { + lines.Add(current.ToString()); + } + + void FlushCurrent() { + if (current.Length > 0) { + lines.Add(current.ToString()); + current.Clear(); + currentWidth = 0; + pendingIndent = true; + } + } + } + + /// 超宽词硬切 — 按可用宽度逐片切(首片前缀对齐前文形态;宽字符不跨片边界: + /// 片尾恰逢宽字符占满则该片提前收口)。 + private static void AppendHardChunks(string word, int available, int indent, List lines, int firstPrefix) { + var index = 0; + var prefix = new string(' ', firstPrefix); + var continuationPrefix = new string(' ', indent); + while (index < word.Length) { + var chunkWidth = 0; + var end = index; + while (end < word.Length && chunkWidth + CharWidth(word[end]) <= available) { + chunkWidth += CharWidth(word[end]); + end++; + } + + if (end == index) { + end = index + 1; // 单字符超宽防御(不可能路径 — 字符宽 ≤2 且 available ≥1) + } + + lines.Add(prefix + word[index..end]); + index = end; + prefix = continuationPrefix; + } + } + + /// 空格分词(多空格折叠单分隔 — 连续空格语义等价单空格)。 + private static IEnumerable SplitWords(string segment) => + segment.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + /// 文本显示宽度(逐字符宽度和 — CJK 宽字符 2 列)。 + private static int DisplayWidth(string text) { + var width = 0; + foreach (var ch in text) { + width += CharWidth(ch); + } + + return width; + } + + /// 单字符显示宽 — ASCII/半角 1 列、CJK 全角 2 列(通用表意文字/扩展/全角符号/韩文范围)。 + private static int CharWidth(char ch) => + ch >= 0x1100 && (ch <= 0x115F // Hangul Jamo + || ch >= 0x2E80 && ch <= 0xA4CF // CJK 部首/康熙/注音/中日韩符号/平假名/片假名/兼容 + || ch >= 0xAC00 && ch <= 0xD7A3 // Hangul 音节 + || ch >= 0xF900 && ch <= 0xFAFF // CJK 兼容表意 + || ch >= 0xFE30 && ch <= 0xFE4F // CJK 兼容形式 + || ch >= 0xFF00 && ch <= 0xFF60 // 全角形式 + || ch >= 0xFFE0 && ch <= 0xFFE6 // 全角符号 + || ch >= 0x4E00 && ch <= 0x9FFF // CJK 统一表意文字 + || ch >= 0x3000 && ch <= 0x303F) // CJK 标点 + ? 2 : 1; +} diff --git a/modules/apps/cli/core/interaction/TerminalInteractiveService.cs b/modules/apps/cli/core/interaction/TerminalInteractiveService.cs index 98cb03882b..3efde2dba4 100644 --- a/modules/apps/cli/core/interaction/TerminalInteractiveService.cs +++ b/modules/apps/cli/core/interaction/TerminalInteractiveService.cs @@ -32,7 +32,7 @@ public Task AskUserQuestionAsync( if (options is null || options.Count == 0) { TerminalHelper.WriteLine(); - TerminalHelper.WriteLine($"{AnsiStyleEnumConstants.Bold}{question}{AnsiStyleEnumConstants.Reset}"); + WriteWrapped(Sanitize(question), bold: true, indent: 0); TerminalHelper.WriteRaw("> "); var freeInput = TerminalHelper.ReadLine(); return Task.FromResult(string.IsNullOrWhiteSpace(freeInput) @@ -80,15 +80,15 @@ public Task AskUserQuestionsAsync( return Task.FromResult(AskUserQuestionResult.FailureResult("Non-interactive mode: cannot prompt for user input")); TerminalHelper.WriteLine(); - TerminalHelper.WriteLine($"{AnsiStyleEnumConstants.Bold}{q.Header}{AnsiStyleEnumConstants.Reset}"); - TerminalHelper.WriteLine(q.Question); + WriteWrapped(Sanitize(q.Header), bold: true, indent: 0); + WriteWrapped(Sanitize(q.Question), bold: false, indent: 0); TerminalHelper.WriteLine(); for (var i = 0; i < q.Options.Count; i++) { var opt = q.Options[i]; - TerminalHelper.WriteLine($" {AnsiStyleEnumConstants.Bold}{i + 1}.{AnsiStyleEnumConstants.Reset} {opt.Label}"); + WriteOptionLabel(i + 1, opt.Label); if (!string.IsNullOrWhiteSpace(opt.Description)) - TerminalHelper.WriteLine($" {AnsiStyleEnumConstants.Dim}{opt.Description}{AnsiStyleEnumConstants.Reset}"); + WriteOptionDescription(opt.Description); } TerminalHelper.WriteLine(); @@ -117,11 +117,11 @@ public Task AskUserQuestionsAsync( /// private static List? DisplayQuestion(string question, List options, bool multiSelect, CancellationToken ct) { TerminalHelper.WriteLine(); - TerminalHelper.WriteLine($"{AnsiStyleEnumConstants.Bold}{question}{AnsiStyleEnumConstants.Reset}"); + WriteWrapped(Sanitize(question), bold: true, indent: 0); TerminalHelper.WriteLine(); for (var i = 0; i < options.Count; i++) { - TerminalHelper.WriteLine($" {AnsiStyleEnumConstants.Bold}{i + 1}.{AnsiStyleEnumConstants.Reset} {options[i]}"); + WriteOptionLabel(i + 1, options[i]); } TerminalHelper.WriteLine(); @@ -185,4 +185,42 @@ private static List ReadMultiSelection(int maxOption, CancellationToken ct) TerminalHelper.WriteRaw($"无效输入,请输入 1-{maxOption} (逗号分隔) 或 0 取消: "); } } + + /// 清洗 — AI 生成文本行尾归一(CR 段折叠)+ 控制字符剥除(见 )。 + private static string Sanitize(string text) => QuestionTextSanitizer.Sanitize(text); + + /// 按终端宽度折行输出 — 首行 bold 全宽、续行 dim 悬挂缩进 2 列(ANSI 样式按行独立包夹,折行后转义序列不被终端在任意列截断)。 + private static void WriteWrapped(string text, bool bold, int indent) { + var width = Math.Max(20, TerminalHelper.GetWidth()); + foreach (var line in QuestionTextSanitizer.Wrap(text, width, indent: 2)) { + var style = bold ? AnsiStyleEnumConstants.Bold : AnsiStyleEnumConstants.Reset; + TerminalHelper.WriteLine($"{style}{line}{AnsiStyleEnumConstants.Reset}"); + } + } + + /// 选项标签输出 — ` N. label` 形态,超宽折行悬挂对齐标签内容列(可用宽 = 终端宽 - 缩进)。 + private static void WriteOptionLabel(int number, string label) { + var width = Math.Max(20, TerminalHelper.GetWidth()); + var lines = QuestionTextSanitizer.Wrap(Sanitize(label), width, indent: OptionIndent); + for (var i = 0; i < lines.Count; i++) { + var prefix = i == 0 + ? $" {AnsiStyleEnumConstants.Bold}{number}.{AnsiStyleEnumConstants.Reset} " + : new string(' ', OptionIndent); + TerminalHelper.WriteLine(prefix + lines[i]); + } + } + + /// 选项描述输出 — 5 列缩进 dim,超宽折行悬挂对齐。 + private static void WriteOptionDescription(string description) { + var width = Math.Max(20, TerminalHelper.GetWidth()); + foreach (var line in QuestionTextSanitizer.Wrap(Sanitize(description), width, indent: DescriptionIndent)) { + TerminalHelper.WriteLine($"{new string(' ', DescriptionIndent)}{AnsiStyleEnumConstants.Dim}{line}{AnsiStyleEnumConstants.Reset}"); + } + } + + /// 选项行缩进列(` N. ` = 5 列 — 续行对齐标签内容列)。 + private const int OptionIndent = 5; + + /// 描述行缩进列。 + private const int DescriptionIndent = 5; } \ No newline at end of file diff --git a/test/unit/host.tests/interaction/QuestionTextSanitizerTests.cs b/test/unit/host.tests/interaction/QuestionTextSanitizerTests.cs new file mode 100644 index 0000000000..fe51f663e7 --- /dev/null +++ b/test/unit/host.tests/interaction/QuestionTextSanitizerTests.cs @@ -0,0 +1,86 @@ +namespace Host.Tests.Interaction; + +/// +/// 问题文本规整测试 — AI 生成的 question/options/description 输入清理与宽度换行: +/// ①清理:\r\n 归一 \n、裸 \r 归一 \n(Windows/混合行尾)、剥离其余 C0 控制字符(\t 保留转空格、ESC 剥除防终端解释); +/// ②换行:按显示宽度词边界折行(CJK 宽字符按 2 列计)、悬挂缩进对齐、宽度钳制(超宽词硬切)。 +/// +public sealed class QuestionTextSanitizerTests { + [Theory] + [InlineData("a\r\nb", "a\nb")] // CRLF → LF + [InlineData("a\rb", "a\nb")] // 裸 CR → LF(AI 混入) + [InlineData("a\r\r\nb", "a\nb")] // \r\r\n 病态混合(question 工具实测)→ 单换行 + [InlineData("a\n\rb", "a\nb")] // \n\r 混合 → 单换行 + [InlineData("a\x1b[31mb", "a[31mb")] // ESC 剥除(防终端解释 ANSI)—— 方括号字面保留 + [InlineData("a\u0000b", "ab")] // NUL 剥除(\u 定长转义 — \x00 变长陷阱:\x00b 会被吃成单字符 0x0B) + [InlineData("a\tb", "a b")] // TAB → 单空格(对齐语义稳定) + [InlineData("正常文本", "正常文本")] // 干净文本零变化 + [InlineData("", "")] // 空串零变化 + public void Sanitize_Normalizes_And_Strips(string input, string expected) { + Assert.Equal(expected, QuestionTextSanitizer.Sanitize(input)); + } + + [Fact] + public void Sanitize_CrLfRuns_Collapse() { + // 连续 \r 段(AI 生成的超长换行根因)→ 单 \n + Assert.Equal("行1\n行2", QuestionTextSanitizer.Sanitize("行1\r\r\r\r\n行2")); + } + + [Fact] + public void Wrap_ShortLine_Unchanged() { + Assert.Equal(["短文本"], QuestionTextSanitizer.Wrap("短文本", width: 20, indent: 0)); + } + + [Fact] + public void Wrap_Words_Break_At_Width() { + // 10 ASCII 词,宽 11(容纳 2 词/行)→ 词边界折行 + var text = "one two three four five six seven eight nine ten"; + var lines = QuestionTextSanitizer.Wrap(text, width: 11, indent: 0); + Assert.Equal("one two", lines[0]); + Assert.Equal("three four", lines[1]); + Assert.Equal("five six", lines[2]); + } + + [Fact] + public void Wrap_Cjk_Counted_As_Two_Columns() { + // 8 个汉字 = 16 列,宽 8 → 每行 4 字 + var lines = QuestionTextSanitizer.Wrap("一二三四五六七八", width: 8, indent: 0); + Assert.Equal(["一二三四", "五六七八"], lines); + } + + [Fact] + public void Wrap_Indent_Hangs_Aligned() { + // 悬挂缩进:续行前留 indent 空格,可用宽度 = width - indent + var lines2 = QuestionTextSanitizer.Wrap("aaaa bbbb cccc", width: 9, indent: 5); + Assert.Equal("aaaa", lines2[0]); + Assert.Equal(" bbbb", lines2[1]); // 5 空格缩进 + 词 + } + + [Fact] + public void Wrap_OverlongWord_Hard_Splits() { + // 超宽词(无空格长串)按可用宽度硬切防单行超屏 + var lines = QuestionTextSanitizer.Wrap("abcdefghij", width: 4, indent: 0); + Assert.Equal(["abcd", "efgh", "ij"], lines); + } + + [Fact] + public void Wrap_MultiParagraph_Kept() { + // 段落(\n)保留 — 每段独立折行 + var lines = QuestionTextSanitizer.Wrap("one two\nthree four", width: 8, indent: 0); + Assert.Equal(["one two", "three", "four"], lines); + } + + [Fact] + public void Wrap_ZeroWidth_Returns_Unfolded_Single() { + // 不可用宽度防退化 — 原文单行返回(信息不丢) + Assert.Equal(["any"], QuestionTextSanitizer.Wrap("any", width: 0, indent: 0)); + } + + [Fact] + public void SanitizeAndWrap_Pipeline() { + // 组合:清理后折行 — AI 实测形态(\r 混入 + 长中文) + var sanitized = QuestionTextSanitizer.Sanitize("问题一\r\r\r回答一"); + var lines = QuestionTextSanitizer.Wrap(sanitized, width: 6, indent: 0); + Assert.Equal(["问题一", "回答一"], lines); + } +} From 1f5d346793bb8cacd7a0988740a37372118c13b1 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 23:25:04 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20CI=20=E5=81=87=E7=BA=A2=20RecordPlug?= =?UTF-8?q?inResourceIds=20=E8=BF=9E=E7=BB=AD=E5=8C=BA=E9=97=B4=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E6=B5=8B=E8=AF=95=E4=B8=8E=E5=85=A8=E5=B1=80=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E5=99=A8=E8=A7=A3=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: 测试用 new ObjectId(type) 构造 10 个连续 ID,而 SequenceId 是进程级静态原子计数器(按 ObjectType 全局共享)— xUnit 并行时其他测试同类型 new 交错插入序号断链,LongRangeSet 压缩出 2 个区间(CI Linux 并行度高才暴露,本地单跑 9/9 过) 修复: - 测试改用 internal 查找键构造 ObjectId(type, seq) 指定连续 seq(1000-1009,确定性、与全局计数器零耦合) - Abstractions.csproj 增 InternalsVisibleTo JoinCode.Plugins.Tests(先例 Integration.Tests/Plugins.Infrastructure/Abs.Tests 同款) 验证: 修改域 20/20 + 全量并行 3×189 全绿(过滤本地环境差异 E2E 五项 — NativePluginHost 进程类,CI Linux 全过) --- modules/abstractions/abstractions/Abstractions.csproj | 1 + .../plugins.tests/unit/PluginManagerTestHooksTest.cs | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/abstractions/abstractions/Abstractions.csproj b/modules/abstractions/abstractions/Abstractions.csproj index 3365d51fb5..c33ee7e78e 100644 --- a/modules/abstractions/abstractions/Abstractions.csproj +++ b/modules/abstractions/abstractions/Abstractions.csproj @@ -10,6 +10,7 @@ + diff --git a/modules/plugins/plugins.tests/unit/PluginManagerTestHooksTest.cs b/modules/plugins/plugins.tests/unit/PluginManagerTestHooksTest.cs index 68e9971824..0d3dc364e8 100644 --- a/modules/plugins/plugins.tests/unit/PluginManagerTestHooksTest.cs +++ b/modules/plugins/plugins.tests/unit/PluginManagerTestHooksTest.cs @@ -85,10 +85,11 @@ public async Task GetPluginResourceIdsForTest_NotRecorded_ReturnsEmpty() { [Fact] public async Task RecordPluginResourceIds_ContinuousIds_CompressesIntoSingleRange() { await using var manager = CreateManager(); - // 连续的 SequenceId 应压缩为单区间 + // 连续的 SequenceId 应压缩为单区间 — internal 查找键构造(确定性 seq,与进程级全局 + // 原子计数器解耦:xUnit 并行时其他测试同类 new 交错会断链 — CI 实测 RangeCount=2 假红) var ids = new List(); - for (var i = 0; i < 10; i++) - ids.Add(new ObjectId(ObjectType.Resource)); + for (var seq = 1000L; seq < 1010; seq++) + ids.Add(new ObjectId(ObjectType.Resource, seq)); manager.RecordPluginResourceIds("p", ids); From a151eabb5d0914b4d1351d331ac95ec039002076 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Fri, 23 Oct 2026 00:25:31 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20CI=20=E5=8F=8C=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88Actor=20=E7=AB=9E=E6=80=81=E5=81=87?= =?UTF-8?q?=E7=BA=A2=20+=20E2E=20contextWindow=20=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E8=B6=85=E6=97=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因一(async_primitives 假红): RetryWriteBack_Failed_TriggersSendFailed 与 SendFailed_Multicast 依赖单条占位塞满重试队列,但 RunLoop await foreach 取出条目后卡 Task.Delay(200ms) — 取出即队列空,CI 慢机断言前被抢跑消费则 TryWrite 成功假阳(本地 16/16 过、CI Linux 偶发;占位改 2 条后本地稳定复现可调试) 修复: 占位 2 条 + ReceiveBackpressureSignal(10s) 拉长 RunLoop 首条消费延迟(串行循环卡 Delay 期间第二条稳占队列,满判定确定性成立;10s Delay 由 Dispose 取消令牌打断不拖慢测试) 根因二(MCP+Sync 超时元凶): E2eSettingsJsonHelper 生成的 vendor 模型条目缺 contextWindow 字段,引擎 ValidateOrThrow 每回合抛 ConfigurationException 并被 CrashStore 反复快照(重 IO)— Sync 2m11s 增至 6m27s、job 顶到 timeout-minutes 10 被强杀 修复: 六个模型条目补真实 contextWindow(gpt-4o 128k / claude-sonnet-4 200k / deepseek 128k / agnes 128k / sensenova 128k) 验证: async_lock.tests 全量 5×600 全绿(每轮 31s — 10s Delay 取消传播正常);E2E 本地缺 MockServer 构件无法跑,CI 验证 --- .../async_lock.tests/actor/ActorBaseTest.cs | 17 +++++++++++++---- .../core/E2eSettingsJsonHelper.cs | 12 ++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs b/modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs index 4488e4919b..6e4b1a51ec 100644 --- a/modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs +++ b/modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs @@ -168,13 +168,18 @@ public async Task InputCount_UsesChannelCount_NotInterlocked() { /// [Fact] public async Task RetryWriteBack_Failed_TriggersSendFailed() { - var bp = new ActorBackpressure(Capacity: 1, RetryQueueCapacity: 1); + var bp = new ActorBackpressure(Capacity: 1, RetryQueueCapacity: 2); await using var actor = new TestActor(bp); var sendFailedEvents = new List>(); actor.SendFailed += (_, e) => sendFailedEvents.Add(e); - actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier", 1)); + // 竞态根治(CI 慢机假红):RunLoop await foreach 取出首条后卡 Task.Delay — 取出即队列空, + // 断言前被抢跑则 TryWrite 成功假阳。占位 2 条 + 10s 背压信号拉长首条消费延迟 + // (RunLoop 串行:首条卡 Delay 期间第二条稳占队列,TryRequeue 满判定确定性成立) + actor.ReceiveBackpressureSignal(TimeSpan.FromSeconds(10)); + actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier1", 1)); + actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier2", 1)); var result = actor.TryRequeueRetryEntry(new RetryEntry("D", 1)); @@ -205,14 +210,18 @@ public async Task RetryWriteBack_MaxRetriesExhausted_TriggersSendFailed() { /// [Fact] public async Task SendFailed_Multicast_FirstHandlerThrows_SecondStillCalled() { - var bp = new ActorBackpressure(Capacity: 1, RetryQueueCapacity: 1); + var bp = new ActorBackpressure(Capacity: 1, RetryQueueCapacity: 2); await using var actor = new TestActor(bp); var handler2Called = false; actor.SendFailed += (_, _) => throw new InvalidOperationException("handler1 crash"); actor.SendFailed += (_, _) => handler2Called = true; - actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier", 1)); + // 竞态根治同 RetryWriteBack_Failed_TriggersSendFailed:占位 2 条 + 10s 背压信号 + // 锁住 RunLoop 首条消费(断言前占位被抢跑吃掉则 TryWrite 成功不触发 SendFailed 假阴) + actor.ReceiveBackpressureSignal(TimeSpan.FromSeconds(10)); + actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier1", 1)); + actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier2", 1)); actor.TryRequeueRetryEntry(new RetryEntry("D", 1)); handler2Called.Should().BeTrue(); diff --git a/test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs b/test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs index c16b92cc90..84a9ca1278 100644 --- a/test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs +++ b/test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs @@ -18,36 +18,36 @@ public static async Task WriteSettingsJsonToStateDirAsync(string stateDir) { "protocol": "openai-compatible", "apiKeyEnvVar": "OPENAI_API_KEY", "models": [ - { "id": "gpt-4o", "displayName": "GPT-4o", "capabilities": { "modalities": ["text"] } }, - { "id": "gpt-4o-mini", "displayName": "GPT-4o Mini", "capabilities": { "modalities": ["text"] } } + { "id": "gpt-4o", "displayName": "GPT-4o", "contextWindow": 128000, "capabilities": { "modalities": ["text"] } }, + { "id": "gpt-4o-mini", "displayName": "GPT-4o Mini", "contextWindow": 128000, "capabilities": { "modalities": ["text"] } } ] }, "anthropic": { "protocol": "anthropic", "apiKeyEnvVar": "ANTHROPIC_API_KEY", "models": [ - { "id": "claude-sonnet-4-20250514", "displayName": "Claude Sonnet 4", "capabilities": { "modalities": ["text"] } } + { "id": "claude-sonnet-4-20250514", "displayName": "Claude Sonnet 4", "contextWindow": 200000, "capabilities": { "modalities": ["text"] } } ] }, "deepseek": { "protocol": "openai-compatible", "apiKeyEnvVar": "DEEPSEEK_API_KEY", "models": [ - { "id": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", "capabilities": { "modalities": ["text"] } } + { "id": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", "contextWindow": 128000, "capabilities": { "modalities": ["text"] } } ] }, "agnes": { "protocol": "openai-compatible", "apiKeyEnvVar": "AGNES_API_KEY", "models": [ - { "id": "agnes-image-2.0-flash", "displayName": "Agnes Image 2.0 Flash", "capabilities": { "modalities": ["text", "readImage"] } } + { "id": "agnes-image-2.0-flash", "displayName": "Agnes Image 2.0 Flash", "contextWindow": 128000, "capabilities": { "modalities": ["text", "readImage"] } } ] }, "sensenova": { "protocol": "openai-compatible", "apiKeyEnvVar": "SENSENOVA_API_KEY", "models": [ - { "id": "sensenova-6.8-flash-lite", "displayName": "SenseNova 6.8 Flash Lite", "capabilities": { "modalities": ["text"] } } + { "id": "sensenova-6.8-flash-lite", "displayName": "SenseNova 6.8 Flash Lite", "contextWindow": 128000, "capabilities": { "modalities": ["text"] } } ] } }