Files
multica/server/pkg/agent/stream_scanner_test.go
小七 5063fc9079 MUL-7869: fix(agent): fail kimi runs whose wire log records a failed turn (#9057)
* fix(agent): fail kimi runs whose wire log records a failed turn

kimi's ACP adapter can answer session/prompt successfully while its own
turn failed: the session wire log carries a
{"type":"agent.turn.ended","outcome":"failed",...} record (e.g. a bare
"OAuthConnectionError: OAuth request to https://auth.kimi.com/api/oauth/
token failed" that matches none of the stderr sniffer's terminal
signatures). The run then kept status=completed and published the
intermediate narration as a successfully delivered result, so the daemon
sent no failure signal and retry/continuation policy never saw the
failure (#9054).

Fix: after the run, scan the session wire logs - the same source,
turn-boundary rules, and tolerant-line policy as the usage scan
(scanKimiSessionUsage) - for the main agent's failed agent.turn.ended
record, and promote completed->failed carrying kimi's sanitized
diagnostic. Only the structured outcome verdict counts, so conversation
text echoing failure words cannot flip a healthy run; a delegated
agent's failed turn stays that agent's own task path; timeout, abort,
and already-failed runs keep their own terminal semantics.

Complementary to #8573's zero-output guard: that incident shape had 26
tool calls and nonempty narration, which a zero-output guard
deliberately preserves.

Fixes #9054

Generated-by: GLM-5.3-Flash (ZCode)

* fix(kimi): sanitize the wire diagnostic and scan past oversized records

Review fixes for #9057 (CHANGES_REQUESTED, both repros offline with a
fake ACP; no live provider requests):

P1: kimiWireTurnFailure copied the record's errorMessage into the run's
diagnostic unsanitized. The failure path persists Result.Error, and the
shared redact.Text does not redact a quoted JSON credential shape
(request={"api_key":...}) — the sanitized diagnostic the PR described
was not actually provided. The diagnostic now goes through the same
sanitizeAgentDiagnostic pass as every child-process diagnostic before
it reaches Result.Error.

P2: the scan loop used newAgentStreamScanner, whose bufio.Scanner ends
its scan for good at a line beyond agentStreamMaxLineBytes, and the
loop never checked scanner.Err() — a valid but oversized context.append
record ahead of the terminal event hid the failed outcome and kept the
run completed. kimiWireTurnFailure now reads through
readAgentStreamLine, which consumes and discards an over-bound record
whole and keeps the later records readable, and ends the walk
explicitly on any other read failure (keeping what was read, the same
best-effort policy as a truncated tail).

Regressions, all red before the fix and green after:
- TestScanKimiMainTurnFailureSanitizesWireDiagnostic (P1)
- TestScanKimiMainTurnFailureReadsPastOversizedRecord (P2, scan level)
- TestKimiBackendWireTurnFailurePastOversizedRecord (P2, backend e2e
  with the fake ACP: red as got "completed" (error=""), the review's
  exact repro shape)
- TestReadAgentStreamLineSkipsOversizedAndContinues /
  TestReadAgentStreamLineReturnsUnterminatedTail (helper contract; the
  first caught a boundary miss where the cap was crossed only on the
  final buffer chunk)

Generated-by: GLM-5.3-Flash (ZCode)
2026-10-08 10:13:54 -07:00

96 lines
3.5 KiB
Go

package agent
import (
"bufio"
"errors"
"io"
"strings"
"testing"
)
// TestAgentStreamScannerReadsPastOldTenMiBCap is the regression for MUL-5722 /
// GH#4520: Codex serializes a whole thread into the single `thread/resume`
// response line, and the previous 10 MiB bound turned any thread past that
// size into a permanent "bufio.Scanner: token too long" resume failure.
func TestAgentStreamScannerReadsPastOldTenMiBCap(t *testing.T) {
t.Parallel()
const oldCap = 10 * 1024 * 1024
if agentStreamMaxLineBytes <= oldCap {
t.Fatalf("agentStreamMaxLineBytes must exceed the old %d-byte cap, got %d",
oldCap, agentStreamMaxLineBytes)
}
line := strings.Repeat("x", oldCap+1024*1024)
scanner := newAgentStreamScanner(strings.NewReader(line + "\n"))
if !scanner.Scan() {
t.Fatalf("expected a %d-byte line to scan, got err=%v", len(line), scanner.Err())
}
if got := len(scanner.Bytes()); got != len(line) {
t.Fatalf("expected %d bytes, got %d", len(line), got)
}
if err := scanner.Err(); err != nil {
t.Fatalf("unexpected scanner error: %v", err)
}
}
// TestAgentStreamScannerStillFailsClosedAboveCap keeps the bound a bound: the
// backends' overflow handling (fail the turn rather than silently truncate an
// event) depends on Scan reporting bufio.ErrTooLong, so raising the cap must
// not mean removing it.
func TestAgentStreamScannerStillFailsClosedAboveCap(t *testing.T) {
t.Parallel()
line := strings.Repeat("x", agentStreamMaxLineBytes+1)
scanner := newAgentStreamScanner(strings.NewReader(line + "\n"))
if scanner.Scan() {
t.Fatalf("expected an over-cap line to fail, scanned %d bytes", len(scanner.Bytes()))
}
if err := scanner.Err(); !errors.Is(err, bufio.ErrTooLong) {
t.Fatalf("expected bufio.ErrTooLong, got %v", err)
}
}
// TestReadAgentStreamLineSkipsOversizedAndContinues covers the review on
// #9057: the log-walking companion to newAgentStreamScanner must do what
// the Scanner cannot — discard a record beyond agentStreamMaxLineBytes and
// keep the later records readable.
func TestReadAgentStreamLineSkipsOversizedAndContinues(t *testing.T) {
t.Parallel()
oversized := strings.Repeat("x", agentStreamMaxLineBytes+16)
r := bufio.NewReaderSize(strings.NewReader(oversized+"\nafter\n"), agentStreamInitialBufferBytes)
line, err := readAgentStreamLine(r)
if line != nil || !errors.Is(err, bufio.ErrTooLong) {
t.Fatalf("expected ErrTooLong with no line, got %d bytes err=%v", len(line), err)
}
line, err = readAgentStreamLine(r)
if err != nil || string(line) != "after" {
t.Fatalf("expected the record after the oversized one, got %q err=%v", line, err)
}
if _, err := readAgentStreamLine(r); !errors.Is(err, io.EOF) {
t.Fatalf("expected io.EOF after the last record, got %v", err)
}
}
// TestReadAgentStreamLineReturnsUnterminatedTail: a live log can end
// mid-write; the final unterminated line is still returned, matching
// bufio.Scanner's behavior, and an exhausted reader reports io.EOF.
func TestReadAgentStreamLineReturnsUnterminatedTail(t *testing.T) {
t.Parallel()
r := bufio.NewReaderSize(strings.NewReader("first\npartial-tail"), agentStreamInitialBufferBytes)
if line, err := readAgentStreamLine(r); err != nil || string(line) != "first" {
t.Fatalf("expected the first line, got %q err=%v", line, err)
}
if line, err := readAgentStreamLine(r); err != nil || string(line) != "partial-tail" {
t.Fatalf("expected the unterminated tail, got %q err=%v", line, err)
}
if _, err := readAgentStreamLine(r); !errors.Is(err, io.EOF) {
t.Fatalf("expected io.EOF, got %v", err)
}
}