Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/abstractions/abstractions/Abstractions.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<InternalsVisibleTo Include="JoinCode.Integration.Tests" />
<InternalsVisibleTo Include="JoinCode.Plugins.Infrastructure" />
<InternalsVisibleTo Include="JoinCode.Abs.Tests" />
<InternalsVisibleTo Include="JoinCode.Plugins.Tests" />
</ItemGroup>

<ItemGroup>
Expand Down
186 changes: 186 additions & 0 deletions modules/apps/cli/core/interaction/QuestionTextSanitizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
namespace JoinCode.Cli.Interaction;

/// <summary>
/// 问题文本规整器 — AI 生成文本(question/options/description)入终端前的统一清洗与折行:
/// ①<see cref="Sanitize"/>:行尾归一(CRLF/裸 CR/病态 \r 段 → 单 \n,AI 输出混入 CR 是超长换行根因)、
/// ESC 剥除(防终端二次解释 ANSI)、NUL 等控制字符剥除、TAB 转空格(对齐稳定);
/// ②<see cref="Wrap"/>:按显示宽度词边界折行(CJK 宽字符 2 列计)、悬挂缩进对齐、超宽词硬切防单行超屏。
/// 纯函数零状态 — 终端交互与 TUI 选项渲染共用同一口径。
/// </summary>
public static class QuestionTextSanitizer {
/// <summary>
/// 清洗文本 — 行尾归一(\r 段折叠单 \n)+ 剥控制字符(ESC/NUL 等 &lt;0x20 除 \n)+ TAB 转空格。
/// 顺序扫描单遍完成(\r 段贪吃 + 段后无 \n 自补一个 — CR 语义即换行)。
/// </summary>
/// <param name="text">原始文本(AI 生成,可能混入 CR/控制字符)。</param>
/// <returns>干净文本(行尾统一 \n,无 ESC/控制字符)。</returns>
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();
}

/// <summary>
/// 按显示宽度折行 — 段落(\n)保留独立折行;空格分词词边界换行;CJK 宽字符按 2 列计;
/// 续行悬挂缩进 indent 空格(可用宽度 = width - indent);超宽词按可用宽度硬切。
/// 宽度钳制:width ≤ indent 或 ≤0 返回单行原文(防退化)。
/// </summary>
/// <param name="text">已清洗文本(\n 行尾)。</param>
/// <param name="width">目标显示宽度(显示列)。</param>
/// <param name="indent">续行悬挂缩进列数。</param>
/// <returns>折行结果(每元素 ≤ width 显示列;空文本单空行)。</returns>
public static IReadOnlyList<string> Wrap(string text, int width, int indent) {
if (width <= indent || width <= 0) {
return [text];
}

var lines = new List<string>();
foreach (var segment in text.Split('\n')) {
WrapSegment(segment, width, indent, lines);
}

return lines.Count > 0 ? lines : [string.Empty];
}

/// <summary>单段折行 — 词序列逐词装行(首行与续行同内容宽 available,续行前置 indent 悬挂缩进);
/// 超宽词硬切后并入装行流程;段恰装满时尾空行不输出(空段保留单空行 — 段落边界语义)。</summary>
private static void WrapSegment(string segment, int width, int indent, List<string> 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;
}
}
}

/// <summary>超宽词硬切 — 按可用宽度逐片切(首片前缀对齐前文形态;宽字符不跨片边界:
/// 片尾恰逢宽字符占满则该片提前收口)。</summary>
private static void AppendHardChunks(string word, int available, int indent, List<string> 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;
}
}

/// <summary>空格分词(多空格折叠单分隔 — 连续空格语义等价单空格)。</summary>
private static IEnumerable<string> SplitWords(string segment) =>
segment.Split(' ', StringSplitOptions.RemoveEmptyEntries);

/// <summary>文本显示宽度(逐字符宽度和 — CJK 宽字符 2 列)。</summary>
private static int DisplayWidth(string text) {
var width = 0;
foreach (var ch in text) {
width += CharWidth(ch);
}

return width;
}

/// <summary>单字符显示宽 — ASCII/半角 1 列、CJK 全角 2 列(通用表意文字/扩展/全角符号/韩文范围)。</summary>
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;
}
52 changes: 45 additions & 7 deletions modules/apps/cli/core/interaction/TerminalInteractiveService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public Task<AskUserQuestionResult> 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)
Expand Down Expand Up @@ -80,15 +80,15 @@ public Task<AskUserQuestionResult> 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();
Expand Down Expand Up @@ -117,11 +117,11 @@ public Task<AskUserQuestionResult> AskUserQuestionsAsync(
/// </summary>
private static List<string>? DisplayQuestion(string question, List<string> 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();
Expand Down Expand Up @@ -185,4 +185,42 @@ private static List<int> ReadMultiSelection(int maxOption, CancellationToken ct)
TerminalHelper.WriteRaw($"无效输入,请输入 1-{maxOption} (逗号分隔) 或 0 取消: ");
}
}

/// <summary>清洗 — AI 生成文本行尾归一(CR 段折叠)+ 控制字符剥除(见 <see cref="QuestionTextSanitizer.Sanitize"/>)。</summary>
private static string Sanitize(string text) => QuestionTextSanitizer.Sanitize(text);

/// <summary>按终端宽度折行输出 — 首行 bold 全宽、续行 dim 悬挂缩进 2 列(ANSI 样式按行独立包夹,折行后转义序列不被终端在任意列截断)。</summary>
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}");
}
}

/// <summary>选项标签输出 — ` N. label` 形态,超宽折行悬挂对齐标签内容列(可用宽 = 终端宽 - 缩进)。</summary>
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]);
}
}

/// <summary>选项描述输出 — 5 列缩进 dim,超宽折行悬挂对齐。</summary>
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}");
}
}

/// <summary>选项行缩进列(` N. ` = 5 列 — 续行对齐标签内容列)。</summary>
private const int OptionIndent = 5;

/// <summary>描述行缩进列。</summary>
private const int DescriptionIndent = 5;
}
17 changes: 13 additions & 4 deletions modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,18 @@ public async Task InputCount_UsesChannelCount_NotInterlocked() {
/// </summary>
[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<BackpressureSendFailedEventArgs<string>>();
actor.SendFailed += (_, e) => sendFailedEvents.Add(e);

actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry<string>("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<string>("occupier1", 1));
actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry<string>("occupier2", 1));

var result = actor.TryRequeueRetryEntry(new RetryEntry<string>("D", 1));

Expand Down Expand Up @@ -205,14 +210,18 @@ public async Task RetryWriteBack_MaxRetriesExhausted_TriggersSendFailed() {
/// </summary>
[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<string>("occupier", 1));
// 竞态根治同 RetryWriteBack_Failed_TriggersSendFailed:占位 2 条 + 10s 背压信号
// 锁住 RunLoop 首条消费(断言前占位被抢跑吃掉则 TryWrite 成功不触发 SendFailed 假阴)
actor.ReceiveBackpressureSignal(TimeSpan.FromSeconds(10));
actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry<string>("occupier1", 1));
actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry<string>("occupier2", 1));
actor.TryRequeueRetryEntry(new RetryEntry<string>("D", 1));

handler2Called.Should().BeTrue();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectId>();
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);

Expand Down
12 changes: 6 additions & 6 deletions test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] } }
]
}
}
Expand Down
Loading