mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* fix(comments): cap completion-fallback comment synthesized from raw output (#5455) On long, tool-heavy runs some runtimes emit the entire raw execution stream (text deltas + a `tool call` line per tool_use) as the task's final Output. When the agent left no comment of its own, CompleteTask synthesizes a fallback comment from that Output and posted it verbatim, dumping 190-264KB raw streams onto the issue thread (author_type=agent, type=comment, source_task_id=NULL). Cap the synthesized body: redact then truncate to a head that comfortably fits a real final message, appending a marker naming the omitted length instead of the multi-hundred-KB tail. Normal short outputs are unchanged. * test(comments): pin completion-fallback comment cap (#5455) Covers passthrough for real short messages, exact-cap boundary, rune-based (not byte) counting for multibyte content, and the reporter's fingerprint — a narration head followed by hundreds of `tool call` lines — asserting the stored body is bounded, the head preserved, and a truncation marker appended. * fix(comments): replace oversized fallback output with safe notice Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
85 lines
3.3 KiB
Go
85 lines
3.3 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// TestTruncateFallbackCommentBody pins the completion-fallback comment cap that
|
|
// keeps a runaway raw-stream Output off the issue thread (GH #5455).
|
|
func TestTruncateFallbackCommentBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("short body passes through unchanged", func(t *testing.T) {
|
|
t.Parallel()
|
|
// A real final message — multi-line, well under the cap — must be stored
|
|
// verbatim, newlines intact (unlike the summary flattening path).
|
|
body := "I fixed the bug in the parser.\n\n- root cause: off-by-one\n- added a regression test"
|
|
if got := truncateFallbackCommentBody(body, maxSynthesizedFallbackCommentRunes); got != body {
|
|
t.Fatalf("short body was altered:\n got: %q\nwant: %q", got, body)
|
|
}
|
|
})
|
|
|
|
t.Run("body exactly at the cap is untouched", func(t *testing.T) {
|
|
t.Parallel()
|
|
body := strings.Repeat("x", maxSynthesizedFallbackCommentRunes)
|
|
if got := truncateFallbackCommentBody(body, maxSynthesizedFallbackCommentRunes); got != body {
|
|
t.Fatalf("body at cap was truncated: len(got)=%d", utf8.RuneCountInString(got))
|
|
}
|
|
})
|
|
|
|
t.Run("raw execution-stream dump is replaced with a safe notice", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Reproduce the reporter's fingerprint: first-turn narration followed by
|
|
// hundreds of repeated `tool call` lines and a tail answer — the shape a
|
|
// 200KB+ dump takes. None of that untrusted output may reach the comment.
|
|
var b strings.Builder
|
|
b.WriteString("I'll start by reading the issue context and the relevant files.\n")
|
|
for i := 0; i < 40000; i++ {
|
|
b.WriteString("tool call\n")
|
|
}
|
|
b.WriteString("FINAL ANSWER THAT MUST NOT BE MISTAKEN FOR TRUSTED OUTPUT")
|
|
dump := b.String()
|
|
if utf8.RuneCountInString(dump) < 200_000 {
|
|
t.Fatalf("test fixture too small: %d runes", utf8.RuneCountInString(dump))
|
|
}
|
|
|
|
got := truncateFallbackCommentBody(dump, maxSynthesizedFallbackCommentRunes)
|
|
|
|
if n := utf8.RuneCountInString(got); n > 256 {
|
|
t.Fatalf("safe notice is unexpectedly large: %d runes", n)
|
|
}
|
|
for _, leaked := range []string{"I'll start", "tool call", "FINAL ANSWER"} {
|
|
if strings.Contains(got, leaked) {
|
|
t.Fatalf("safe notice leaked raw output %q: %q", leaked, got)
|
|
}
|
|
}
|
|
if !strings.Contains(got, "not posted") {
|
|
t.Fatalf("safe notice does not explain that output was withheld: %q", got)
|
|
}
|
|
if !strings.Contains(got, "Execution log") {
|
|
t.Fatalf("safe notice does not direct the user to the task run: %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("cap counts runes not bytes for multibyte content", func(t *testing.T) {
|
|
t.Parallel()
|
|
// A body of maxRunes multibyte runes is > maxRunes bytes but == maxRunes
|
|
// runes, so it must NOT be truncated — the boundary is rune-based.
|
|
body := strings.Repeat("你", maxSynthesizedFallbackCommentRunes)
|
|
if got := truncateFallbackCommentBody(body, maxSynthesizedFallbackCommentRunes); got != body {
|
|
t.Fatalf("multibyte body at rune cap was wrongly truncated")
|
|
}
|
|
// One rune over the cap is replaced rather than leaking a raw excerpt.
|
|
over := strings.Repeat("你", maxSynthesizedFallbackCommentRunes+10)
|
|
got := truncateFallbackCommentBody(over, maxSynthesizedFallbackCommentRunes)
|
|
if !utf8.ValidString(got) {
|
|
t.Fatalf("safe notice is invalid UTF-8")
|
|
}
|
|
if strings.Contains(got, "你") {
|
|
t.Fatalf("safe notice leaked a multibyte raw-output excerpt")
|
|
}
|
|
})
|
|
}
|