diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 14931d0..a4d9aba 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" "time" + "unicode/utf8" "github.com/luoyuctl/agenttrace/internal/i18n" ) @@ -1892,6 +1893,47 @@ func TestReportOverviewTextShowsIncidentAndAuthorityCues(t *testing.T) { } } +func TestReportOverviewTextTruncatesUnicodeSafely(t *testing.T) { + longName := "x" + strings.Repeat("项目", 24) + "-异常会话" + longTool := "x" + strings.Repeat("工具", 32) + sessions := []Session{{ + Name: longName, + Health: 45, + Anomalies: []Anomaly{{Type: "hanging", Severity: SeverityHigh}}, + Metrics: Metrics{ + SourceTool: "codex_cli", + ModelUsed: "gpt-5.1", + AssistantTurns: 3, + DurationSec: 180, + GapsSec: []float64{90}, + ToolCallsOK: 1, + ToolCallsFail: 2, + ToolUsage: map[string]int{longTool: 3}, + ToolAuthority: map[string]int{ToolAuthorityTestOrBuild: 1}, + HighestAuthority: ToolAuthorityTestOrBuild, + }, + }} + + out := ReportOverview(ComputeOverview(sessions), sessions) + if !utf8.ValidString(out) { + t.Fatalf("text overview should stay valid UTF-8 after truncation:\n%x", out) + } + for _, want := range []string{ + "Incident timeline", + "Touched surface", + "Tool authority", + "Recent Anomalies", + "...", + } { + if !strings.Contains(out, want) { + t.Fatalf("text overview missing %q:\n%s", want, out) + } + } + if strings.ContainsRune(out, '\uFFFD') { + t.Fatalf("text overview contains replacement characters after truncation:\n%s", out) + } +} + func TestTextCellCollapsesWhitespaceAndTruncatesUTF8(t *testing.T) { got := textCell("命令\n执行 失败 😅", 8) if got != "命令 执行..." { diff --git a/internal/engine/incident_timeline.go b/internal/engine/incident_timeline.go index ac09f4e..3bb3e03 100644 --- a/internal/engine/incident_timeline.go +++ b/internal/engine/incident_timeline.go @@ -155,8 +155,5 @@ func incidentSafeName(value string) string { if fields := strings.Fields(value); len(fields) > 0 { value = fields[0] } - if len(value) > 48 { - value = value[:48] - } - return value + return truncateTextRunes(value, 48, "") } diff --git a/internal/engine/report.go b/internal/engine/report.go index 94902b6..f74c268 100644 --- a/internal/engine/report.go +++ b/internal/engine/report.go @@ -6,6 +6,7 @@ import ( "html" "sort" "strings" + "unicode/utf8" "github.com/luoyuctl/agenttrace/internal/i18n" ) @@ -1216,11 +1217,7 @@ func ReportOverview(ov Overview, sessions []Session) string { } for i := 0; i < limit; i++ { a := ov.AnomaliesTop[i] - name := a.Session - if len(name) > 30 { - name = name[:30] - } - wf(" ⚠️ %-30s %s", name, reportAnomalyTypeLabel(a.Type)) + wf(" ⚠️ %-30s %s", textCell(a.Session, 30), reportAnomalyTypeLabel(a.Type)) } } w("") @@ -1238,9 +1235,25 @@ func textAuthorityCounts(items []authorityCount) string { func textCell(value string, limit int) string { value = strings.Join(strings.Fields(value), " ") - runes := []rune(value) - if limit > 3 && len(runes) > limit { - return string(runes[:limit-3]) + "..." + if limit > 3 { + return truncateTextRunes(value, limit, "...") } return value } + +func truncateTextRunes(value string, limit int, suffix string) string { + if limit <= 0 { + return "" + } + if utf8.RuneCountInString(value) <= limit { + return value + } + cut := limit + if suffixRunes := utf8.RuneCountInString(suffix); suffix != "" && suffixRunes < limit { + cut = limit - suffixRunes + } else { + suffix = "" + } + runes := []rune(value) + return string(runes[:cut]) + suffix +}