- Code ??? ?? ???? ?? ??? ??? ? ?? ?? ???? ???? no-progress ??? ??? - ??? ?? ??? 1~2? ????? ????? ToolCall/ToolResult ?? ??? ?? ????? ????? ??? - ??? Thinking/LLM ?? ??? ??? ???? ??? ?? ?? ??? ??? ??? ????? ???? - Cowork/Code ??? ??? ?? ??? ???? ??? ??? ??? ?? ???? ? - README.md, docs/DEVELOPMENT.md ??? 2026-04-15 18:30 (KST) ???? ??? ?? - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_agent_ui_logs\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_agent_ui_logs_tests\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs_tests\\
508 lines
22 KiB
C#
508 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
internal sealed record AgentStatusNarrative(
|
|
string Message,
|
|
string? Detail,
|
|
string Category,
|
|
string? Meta = null);
|
|
|
|
internal static class AgentStatusNarrativeCatalog
|
|
{
|
|
public static AgentStatusNarrative BuildInitial(string? runTab)
|
|
=> IsCodeTab(runTab)
|
|
? new AgentStatusNarrative(
|
|
"작업 범위와 관련 파일을 먼저 파악하고 있습니다...",
|
|
"필요한 코드, 로그, 테스트 범위를 정리한 뒤 바로 수정 단계로 이어갑니다.",
|
|
"initialize")
|
|
: new AgentStatusNarrative(
|
|
"요청 목적과 필요한 자료를 정리하고 있습니다...",
|
|
"관련 문서와 작업 범위를 확인한 뒤 바로 다음 단계로 이어갑니다.",
|
|
"initialize");
|
|
|
|
public static AgentStatusNarrative BuildFromEvent(AgentEvent evt, string? runTab)
|
|
{
|
|
var itemDisplayName = evt.Type == AgentEventType.SkillCall
|
|
? AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName, slashPrefix: true)
|
|
: AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName);
|
|
var transcriptBadgeLabel = AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt);
|
|
var row = AgentTranscriptDisplayCatalog.ResolveRowPresentation(evt, itemDisplayName, transcriptBadgeLabel);
|
|
var category = ResolveCategory(evt, row);
|
|
var message = BuildEventMessage(evt, runTab, row, category, itemDisplayName);
|
|
var detail = BuildEventDetail(evt, row, category, message);
|
|
|
|
return new AgentStatusNarrative(
|
|
SanitizeSingleLine(message),
|
|
SanitizeDetail(detail),
|
|
category,
|
|
BuildProgressPhaseMeta(evt));
|
|
}
|
|
|
|
public static AgentStatusNarrative BuildIdle(
|
|
AgentEvent? lastProgressEvent,
|
|
string? runTab,
|
|
TimeSpan idle,
|
|
TimeSpan elapsed,
|
|
bool pendingPostCompaction)
|
|
{
|
|
if (pendingPostCompaction)
|
|
{
|
|
return new AgentStatusNarrative(
|
|
"긴 대화를 이어가기 위해 컨텍스트를 정리하고 있습니다...",
|
|
"최근 작업 결과를 압축해 다음 단계에 필요한 정보만 남기고 있습니다.",
|
|
"compact");
|
|
}
|
|
|
|
var category = lastProgressEvent == null
|
|
? (IsCodeTab(runTab) ? "read" : "plan")
|
|
: ResolveCategory(
|
|
lastProgressEvent,
|
|
AgentTranscriptDisplayCatalog.ResolveRowPresentation(
|
|
lastProgressEvent,
|
|
lastProgressEvent.Type == AgentEventType.SkillCall
|
|
? AgentTranscriptDisplayCatalog.GetDisplayName(lastProgressEvent.ToolName, slashPrefix: true)
|
|
: AgentTranscriptDisplayCatalog.GetDisplayName(lastProgressEvent.ToolName),
|
|
AgentTranscriptDisplayCatalog.GetEventBadgeLabel(lastProgressEvent)));
|
|
|
|
if (idle >= TimeSpan.FromSeconds(90))
|
|
{
|
|
return new AgentStatusNarrative(
|
|
"현재까지 확인한 내용과 다음 단계를 차분히 정리하고 있습니다...",
|
|
BuildIdleDetail(category),
|
|
category);
|
|
}
|
|
|
|
if (idle >= TimeSpan.FromSeconds(30))
|
|
{
|
|
return new AgentStatusNarrative(
|
|
BuildIdleMessage(category, runTab, longWait: true),
|
|
BuildIdleDetail(category),
|
|
category);
|
|
}
|
|
|
|
if (idle >= TimeSpan.FromSeconds(12))
|
|
{
|
|
return new AgentStatusNarrative(
|
|
BuildIdleMessage(category, runTab, longWait: false),
|
|
BuildIdleDetail(category),
|
|
category);
|
|
}
|
|
|
|
if (idle >= TimeSpan.FromSeconds(5))
|
|
{
|
|
return new AgentStatusNarrative(
|
|
"다음 단계를 정리하고 있습니다...",
|
|
BuildIdleDetail(category),
|
|
category);
|
|
}
|
|
|
|
if (elapsed >= TimeSpan.FromSeconds(4))
|
|
{
|
|
return new AgentStatusNarrative(
|
|
"작업을 이어가고 있습니다...",
|
|
BuildIdleDetail(category),
|
|
category);
|
|
}
|
|
|
|
return BuildInitial(runTab);
|
|
}
|
|
|
|
public static string BuildProgressStepLabel(AgentEvent evt, string transcriptBadgeLabel, string itemDisplayName)
|
|
{
|
|
var row = AgentTranscriptDisplayCatalog.ResolveRowPresentation(evt, itemDisplayName, transcriptBadgeLabel);
|
|
var category = ResolveCategory(evt, row);
|
|
|
|
if (evt.Type == AgentEventType.ToolCall)
|
|
{
|
|
return category switch
|
|
{
|
|
"read" => "관련 코드와 파일을 확인하는 중",
|
|
"edit" => "변경을 적용하는 중",
|
|
"execute" => "실행 결과를 확인하는 중",
|
|
"document" => "문서 내용을 구성하는 중",
|
|
"git" => "변경 범위를 확인하는 중",
|
|
"web" => "필요한 정보를 정리하는 중",
|
|
_ => row.Title,
|
|
};
|
|
}
|
|
|
|
if (evt.Type == AgentEventType.ToolResult)
|
|
{
|
|
return category switch
|
|
{
|
|
"read" => "확인한 내용을 정리하는 중",
|
|
"edit" => "적용한 변경을 정리하는 중",
|
|
"execute" => "실행 결과를 분석하는 중",
|
|
"document" => "생성 결과를 검토하는 중",
|
|
_ => row.Title,
|
|
};
|
|
}
|
|
|
|
return row.Title;
|
|
}
|
|
|
|
public static string? BuildProgressPhaseLabel(AgentEvent evt)
|
|
{
|
|
var category = ResolveCategory(
|
|
evt,
|
|
AgentTranscriptDisplayCatalog.ResolveRowPresentation(
|
|
evt,
|
|
evt.Type == AgentEventType.SkillCall
|
|
? AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName, slashPrefix: true)
|
|
: AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName),
|
|
AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt)));
|
|
|
|
if (category == "compact")
|
|
return "컨텍스트를 정리하는 중...";
|
|
|
|
if (evt.Type == AgentEventType.Thinking && string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase))
|
|
return "다음 응답을 준비하는 중...";
|
|
|
|
return evt.Type switch
|
|
{
|
|
AgentEventType.Planning => "작업 순서를 정리하는 중...",
|
|
AgentEventType.StepStart when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행 중...",
|
|
AgentEventType.StepDone when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 정리 중...",
|
|
AgentEventType.PermissionRequest => "권한 확인을 기다리는 중...",
|
|
AgentEventType.PermissionDenied => "대체 경로를 검토하는 중...",
|
|
AgentEventType.ToolCall => category switch
|
|
{
|
|
"read" => "관련 파일을 확인하는 중...",
|
|
"edit" => "변경을 적용하는 중...",
|
|
"execute" => "실행 결과를 확인하는 중...",
|
|
"document" => "문서를 구성하는 중...",
|
|
"git" => "변경 범위를 정리하는 중...",
|
|
"web" => "필요한 정보를 찾는 중...",
|
|
_ => null,
|
|
},
|
|
AgentEventType.ToolResult => category switch
|
|
{
|
|
"read" => "읽은 내용을 정리하는 중...",
|
|
"edit" => "수정 내용을 정리하는 중...",
|
|
"execute" => "실행 결과를 분석하는 중...",
|
|
"document" => "생성 결과를 검토하는 중...",
|
|
_ => null,
|
|
},
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
public static string? BuildProgressPhaseMeta(AgentEvent evt)
|
|
{
|
|
var itemDisplayName = evt.Type == AgentEventType.SkillCall
|
|
? AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName, slashPrefix: true)
|
|
: AgentTranscriptDisplayCatalog.GetDisplayName(evt.ToolName);
|
|
var row = AgentTranscriptDisplayCatalog.ResolveRowPresentation(
|
|
evt,
|
|
itemDisplayName,
|
|
AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt));
|
|
var category = ResolveCategory(evt, row);
|
|
|
|
return evt.Type switch
|
|
{
|
|
AgentEventType.Planning => "계획",
|
|
AgentEventType.StepStart or AgentEventType.StepDone => "단계",
|
|
AgentEventType.PermissionRequest => "권한",
|
|
AgentEventType.PermissionGranted => "권한 확인",
|
|
AgentEventType.PermissionDenied => "권한 거부",
|
|
AgentEventType.ToolCall => category switch
|
|
{
|
|
"read" => "탐색",
|
|
"edit" => "수정",
|
|
"execute" => "실행",
|
|
"document" => "문서",
|
|
"git" => "Git",
|
|
"web" => "자료",
|
|
_ => "도구",
|
|
},
|
|
AgentEventType.ToolResult => category switch
|
|
{
|
|
"read" => "읽기 완료",
|
|
"edit" => "수정 완료",
|
|
"execute" => "실행 결과",
|
|
"document" => "생성 결과",
|
|
_ => "결과",
|
|
},
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
private static string BuildEventMessage(
|
|
AgentEvent evt,
|
|
string? runTab,
|
|
AgentTranscriptRowPresentation row,
|
|
string category,
|
|
string itemDisplayName)
|
|
{
|
|
return evt.Type switch
|
|
{
|
|
AgentEventType.Planning => IsCodeTab(runTab)
|
|
? "수정 순서와 검증 단계를 정리하고 있습니다..."
|
|
: "작업 순서와 결과물 구성을 정리하고 있습니다...",
|
|
AgentEventType.StepStart when evt.StepTotal > 0
|
|
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계 작업을 진행하고 있습니다...",
|
|
AgentEventType.StepDone when evt.StepTotal > 0
|
|
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계를 마무리하고 다음 단계로 이어가고 있습니다...",
|
|
AgentEventType.PermissionRequest => "실행 전에 필요한 권한 확인을 기다리고 있습니다...",
|
|
AgentEventType.PermissionGranted => "권한이 확인되어 작업을 이어가고 있습니다...",
|
|
AgentEventType.PermissionDenied => "권한이 거부되어 다른 진행 경로를 검토하고 있습니다...",
|
|
AgentEventType.SkillCall => "적절한 스킬이나 작업 흐름을 적용하고 있습니다...",
|
|
AgentEventType.ToolCall => category switch
|
|
{
|
|
"read" => IsCodeTab(runTab)
|
|
? "관련 코드와 파일을 확인하고 있습니다..."
|
|
: "관련 자료와 문서를 확인하고 있습니다...",
|
|
"edit" => IsCodeTab(runTab)
|
|
? "코드 변경을 적용하고 있습니다..."
|
|
: "초안 내용을 다듬고 있습니다...",
|
|
"execute" => "실행 결과와 로그를 확인하고 있습니다...",
|
|
"document" => "문서 결과물을 구성하고 있습니다...",
|
|
"git" => "변경 범위와 저장소 상태를 확인하고 있습니다...",
|
|
"web" => "필요한 외부 정보를 정리하고 있습니다...",
|
|
_ => string.IsNullOrWhiteSpace(itemDisplayName)
|
|
? "필요한 작업을 진행하고 있습니다..."
|
|
: $"{itemDisplayName} 작업을 진행하고 있습니다...",
|
|
},
|
|
AgentEventType.ToolResult => category switch
|
|
{
|
|
"read" => "확인한 내용을 정리하고 다음 단계로 이어가고 있습니다...",
|
|
"edit" => "적용한 변경을 정리하고 다음 검증으로 이어가고 있습니다...",
|
|
"execute" => "실행 결과를 분석하고 후속 조치를 판단하고 있습니다...",
|
|
"document" => "생성한 결과를 검토하고 다듬고 있습니다...",
|
|
"git" => "변경 내용을 정리하고 다음 작업을 준비하고 있습니다...",
|
|
"web" => "수집한 정보를 정리하고 답변에 반영하고 있습니다...",
|
|
_ => string.IsNullOrWhiteSpace(row.Title)
|
|
? "결과를 정리하고 있습니다..."
|
|
: EnsureSentence(row.Title),
|
|
},
|
|
AgentEventType.Thinking when string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)
|
|
=> "긴 대화를 이어가기 위해 컨텍스트를 압축하고 있습니다...",
|
|
AgentEventType.Thinking when string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|
|
=> "현재까지 진행한 내용을 정리하고 있습니다...",
|
|
AgentEventType.Thinking when category == "document"
|
|
=> "문서 흐름과 필요한 내용을 정리하고 있습니다...",
|
|
AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("검증", StringComparison.OrdinalIgnoreCase)
|
|
=> "결과를 검토하고 빠진 부분이 없는지 확인하고 있습니다...",
|
|
AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("재시도", StringComparison.OrdinalIgnoreCase)
|
|
=> "다시 시도할 경로를 정리하고 있습니다...",
|
|
AgentEventType.Complete => "작업이 완료되었습니다.",
|
|
AgentEventType.Error => "오류를 정리하고 복구 경로를 검토하고 있습니다...",
|
|
AgentEventType.Decision => "다음 진행을 위해 사용자 확인을 기다리고 있습니다...",
|
|
_ => string.IsNullOrWhiteSpace(row.Title)
|
|
? "작업을 진행하고 있습니다..."
|
|
: EnsureSentence(row.Title),
|
|
};
|
|
}
|
|
|
|
private static string? BuildEventDetail(
|
|
AgentEvent evt,
|
|
AgentTranscriptRowPresentation row,
|
|
string category,
|
|
string message)
|
|
{
|
|
var parts = new List<string>();
|
|
var baseDescription = SanitizeDetail(row.Description);
|
|
if (!string.IsNullOrWhiteSpace(baseDescription) &&
|
|
!string.Equals(baseDescription, message, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
parts.Add(baseDescription);
|
|
}
|
|
|
|
if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 })
|
|
parts.Add($"총 {evt.Steps.Count}단계 계획을 기준으로 진행합니다.");
|
|
|
|
var targetHint = ExtractTargetHint(evt);
|
|
if (!string.IsNullOrWhiteSpace(targetHint))
|
|
parts.Add($"대상: {targetHint}");
|
|
|
|
if (category == "compact" && evt.ElapsedMs > 0)
|
|
parts.Add("이전 대화와 최근 작업 결과를 이어서 사용할 수 있게 정리하고 있습니다.");
|
|
|
|
return parts.Count == 0 ? null : string.Join(" · ", parts);
|
|
}
|
|
|
|
private static string BuildIdleMessage(string category, string? runTab, bool longWait)
|
|
=> category switch
|
|
{
|
|
"read" => IsCodeTab(runTab)
|
|
? longWait
|
|
? "읽은 코드와 검색 결과를 종합해 다음 수정 지점을 정리하고 있습니다..."
|
|
: "읽은 코드와 검색 결과를 정리하고 있습니다..."
|
|
: longWait
|
|
? "확인한 자료를 종합해 답변 구조를 정리하고 있습니다..."
|
|
: "확인한 자료와 문서를 정리하고 있습니다...",
|
|
"edit" => longWait
|
|
? "적용한 변경 내용을 다시 검토하고 검증 포인트를 정리하고 있습니다..."
|
|
: "적용한 변경 내용을 다시 점검하고 있습니다...",
|
|
"execute" => longWait
|
|
? "실행 로그를 종합해 원인과 다음 조치를 정리하고 있습니다..."
|
|
: "실행 결과와 로그를 정리하고 있습니다...",
|
|
"document" => longWait
|
|
? "초안 구조와 빠진 근거를 다시 맞추고 있습니다..."
|
|
: "초안과 결과물 구성을 다듬고 있습니다...",
|
|
"git" => "변경 범위와 저장소 상태를 다시 정리하고 있습니다...",
|
|
"web" => "수집한 정보를 정리해 답변에 연결하고 있습니다...",
|
|
"permission" => "권한 확인 결과를 기다리며 다음 단계를 준비하고 있습니다...",
|
|
_ => longWait
|
|
? "현재까지의 진행 내용을 종합하고 다음 단계를 정리하고 있습니다..."
|
|
: "다음 단계를 정리하고 있습니다...",
|
|
};
|
|
|
|
private static string BuildIdleDetail(string category)
|
|
=> category switch
|
|
{
|
|
"read" => "읽은 파일과 검색 결과를 묶어 다음 단계로 연결하고 있습니다.",
|
|
"edit" => "수정 범위와 영향 파일을 다시 확인하고 있습니다.",
|
|
"execute" => "빌드와 테스트 로그에서 원인과 후속 조치를 추리고 있습니다.",
|
|
"document" => "초안 구조, 빠진 근거, 연결 흐름을 다시 맞추고 있습니다.",
|
|
"git" => "변경 범위와 저장소 상태를 한 번 더 확인하고 있습니다.",
|
|
"web" => "수집한 정보를 질문 흐름에 맞게 추리고 있습니다.",
|
|
"permission" => "권한 결과가 정리되면 같은 작업 흐름으로 바로 이어집니다.",
|
|
_ => "현재까지의 진행 내용을 정리해 다음 작업으로 연결하고 있습니다.",
|
|
};
|
|
|
|
private static string ResolveCategory(AgentEvent evt, AgentTranscriptRowPresentation row)
|
|
{
|
|
var groupKey = row.GroupKey ?? string.Empty;
|
|
if (groupKey.StartsWith("activity:", StringComparison.OrdinalIgnoreCase))
|
|
return groupKey["activity:".Length..];
|
|
if (groupKey.StartsWith("permission:", StringComparison.OrdinalIgnoreCase))
|
|
return "permission";
|
|
if (groupKey.StartsWith("compact:", StringComparison.OrdinalIgnoreCase))
|
|
return "compact";
|
|
if (groupKey.StartsWith("waiting:", StringComparison.OrdinalIgnoreCase))
|
|
return "wait";
|
|
if (groupKey.StartsWith("planning", StringComparison.OrdinalIgnoreCase))
|
|
return "plan";
|
|
if (groupKey.StartsWith("thinking:", StringComparison.OrdinalIgnoreCase))
|
|
return groupKey["thinking:".Length..];
|
|
if (groupKey.StartsWith("step:", StringComparison.OrdinalIgnoreCase))
|
|
return "step";
|
|
|
|
return evt.Type switch
|
|
{
|
|
AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied => "permission",
|
|
AgentEventType.Planning => "plan",
|
|
AgentEventType.StepStart or AgentEventType.StepDone => "step",
|
|
AgentEventType.Complete => "complete",
|
|
AgentEventType.Error => "error",
|
|
_ => "general",
|
|
};
|
|
}
|
|
|
|
private static string? ExtractTargetHint(AgentEvent evt)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(evt.FilePath))
|
|
return Path.GetFileName(evt.FilePath);
|
|
|
|
if (string.IsNullOrWhiteSpace(evt.ToolInput))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(evt.ToolInput);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
|
return null;
|
|
|
|
foreach (var key in new[]
|
|
{
|
|
"path", "file_path", "file", "file_name", "title", "query",
|
|
"pattern", "url", "command", "sheet_name", "dashboard_sheet_name"
|
|
})
|
|
{
|
|
if (!doc.RootElement.TryGetProperty(key, out var value))
|
|
continue;
|
|
|
|
var normalized = NormalizeTargetValue(key, value);
|
|
if (!string.IsNullOrWhiteSpace(normalized))
|
|
return normalized;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore malformed tool_input
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? NormalizeTargetValue(string key, JsonElement value)
|
|
{
|
|
if (value.ValueKind == JsonValueKind.String)
|
|
{
|
|
var text = value.GetString()?.Trim();
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return null;
|
|
|
|
if (key is "path" or "file_path" or "file" or "file_name")
|
|
return Path.GetFileName(text);
|
|
|
|
if (key == "command")
|
|
return text.Length > 60 ? text[..60] + "..." : text;
|
|
|
|
return text.Length > 70 ? text[..70] + "..." : text;
|
|
}
|
|
|
|
if (value.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in value.EnumerateArray())
|
|
{
|
|
var normalized = NormalizeTargetValue(key, item);
|
|
if (!string.IsNullOrWhiteSpace(normalized))
|
|
return normalized;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool IsCodeTab(string? runTab)
|
|
=> string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static string EnsureSentence(string text)
|
|
{
|
|
var normalized = SanitizeSingleLine(text);
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
return "작업을 진행하고 있습니다...";
|
|
if (normalized.EndsWith("...", StringComparison.Ordinal))
|
|
return normalized;
|
|
if (normalized.EndsWith("중", StringComparison.Ordinal))
|
|
return normalized + "...";
|
|
return normalized;
|
|
}
|
|
|
|
private static string SanitizeSingleLine(string text)
|
|
{
|
|
var normalized = AgentTranscriptDisplayCatalog.StripNonBmpCharacters(text ?? string.Empty)
|
|
.Replace("\r", " ")
|
|
.Replace("\n", " ")
|
|
.Trim();
|
|
while (normalized.Contains(" ", StringComparison.Ordinal))
|
|
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
|
return normalized;
|
|
}
|
|
|
|
private static string? SanitizeDetail(string? text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return null;
|
|
|
|
var normalized = AgentTranscriptDisplayCatalog.StripNonBmpCharacters(text)
|
|
.Replace("\r", " ")
|
|
.Replace("\n", " ")
|
|
.Trim();
|
|
|
|
while (normalized.Contains(" ", StringComparison.Ordinal))
|
|
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
|
|
|
if (normalized.Length > 160)
|
|
normalized = normalized[..160].TrimEnd() + "...";
|
|
|
|
return normalized;
|
|
}
|
|
}
|