mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
* fix(agent): harden Cursor terminal failures Co-authored-by: multica-agent <github@multica.ai> * fix(agent): preserve UTF-8 stderr tails Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
242 lines
7.9 KiB
Go
242 lines
7.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestFinalizeStreamResultEmptySuccessWithoutAssistantUsesSafeNotice(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
status, output, errMsg := finalizeStreamResult(
|
|
"claude",
|
|
time.Second,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
"session-1",
|
|
streamTerminalState{sawResult: true},
|
|
"",
|
|
)
|
|
if status != "completed" || output != emptySuccessfulStreamResult || errMsg != "" {
|
|
t.Fatalf("finalizeStreamResult() = (%q, %q, %q), want completed safe notice", status, output, errMsg)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeStreamResultPreservesErrorResultWhenContextEnds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, runErr := range []error{context.DeadlineExceeded, context.Canceled} {
|
|
status, output, errMsg := finalizeStreamResult(
|
|
"claude",
|
|
time.Second,
|
|
runErr,
|
|
nil,
|
|
nil,
|
|
"session-1",
|
|
streamTerminalState{
|
|
finalResultText: "provider rejected the request",
|
|
sawResult: true,
|
|
resultIsError: true,
|
|
},
|
|
"",
|
|
)
|
|
if status != "failed" || output != "" || errMsg != "provider rejected the request" {
|
|
t.Errorf("runErr=%v: finalizeStreamResult() = (%q, %q, %q), want failed provider error", runErr, status, output, errMsg)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStreamProtocolObservationDoesNotLogContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
assistantSecret = "FIRST-TURN PRIVATE NARRATION"
|
|
resultSecret = "FINAL PRIVATE RESULT"
|
|
baseURLSecret = "https://provider.example/private"
|
|
)
|
|
var buf bytes.Buffer
|
|
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
|
logStreamProtocolObservation(logger, streamProtocolObservation{
|
|
provider: "claude",
|
|
cliVersion: "2.1.5",
|
|
model: "glm-4.6",
|
|
exitCode: 0,
|
|
eventCount: 7,
|
|
invalidEventCount: 1,
|
|
assistantEventCount: 2,
|
|
toolUseCount: 1,
|
|
sawResult: true,
|
|
resultBytes: len(resultSecret),
|
|
lastAssistantBytes: len(assistantSecret),
|
|
lastEventType: "result",
|
|
anthropicBaseURLConfigured: true,
|
|
})
|
|
|
|
got := buf.String()
|
|
for _, required := range []string{
|
|
"provider=claude",
|
|
"cli_version=2.1.5",
|
|
"model=glm-4.6",
|
|
"exit_code=0",
|
|
"event_count=7",
|
|
"saw_result=true",
|
|
"result_bytes=20",
|
|
"last_event_type=result",
|
|
"anthropic_base_url_configured=true",
|
|
} {
|
|
if !strings.Contains(got, required) {
|
|
t.Errorf("observation log %q does not contain %q", got, required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{assistantSecret, resultSecret, baseURLSecret} {
|
|
if strings.Contains(got, forbidden) {
|
|
t.Errorf("observation log leaked %q: %q", forbidden, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStreamJSONBackendsFinalOutputBoundaries(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell-script fixtures are POSIX-only")
|
|
}
|
|
|
|
const multiTurnStream = `printf '%s\n' '{"type":"system","session_id":"sess-boundary"}'
|
|
printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"test-model","content":[{"type":"text","text":"FIRST-TURN NARRATION"}]}}'
|
|
printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"test-model","content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"path":"README.md"}}]}}'
|
|
printf '%s\n' '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-1","content":"TOOL TRACE"}]}}'
|
|
printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"test-model","content":[{"type":"text","text":"LAST ASSISTANT ANSWER"}]}}'
|
|
`
|
|
const toolLastStream = `printf '%s\n' '{"type":"system","session_id":"sess-boundary"}'
|
|
printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"test-model","content":[{"type":"text","text":"PRE-TOOL NARRATION"}]}}'
|
|
printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"test-model","content":[{"type":"text","text":"I WILL USE A TOOL"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"path":"README.md"}}]}}'
|
|
printf '%s\n' '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-1","content":"TOOL TRACE"}]}}'
|
|
`
|
|
|
|
tests := []struct {
|
|
name string
|
|
scriptBody string
|
|
wantStatus string
|
|
wantOutput string
|
|
wantError string
|
|
forbiddenOutput []string
|
|
}{
|
|
{
|
|
name: "non-empty result is authoritative",
|
|
scriptBody: multiTurnStream + `printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-boundary","result":"AUTHORITATIVE RESULT"}'` + "\n",
|
|
wantStatus: "completed",
|
|
wantOutput: "AUTHORITATIVE RESULT",
|
|
forbiddenOutput: []string{
|
|
"FIRST-TURN NARRATION",
|
|
"TOOL TRACE",
|
|
"LAST ASSISTANT ANSWER",
|
|
},
|
|
},
|
|
{
|
|
name: "empty successful result uses only last assistant message",
|
|
scriptBody: multiTurnStream + `printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-boundary","result":""}'` + "\n",
|
|
wantStatus: "completed",
|
|
wantOutput: "LAST ASSISTANT ANSWER",
|
|
forbiddenOutput: []string{
|
|
"FIRST-TURN NARRATION",
|
|
"TOOL TRACE",
|
|
},
|
|
},
|
|
{
|
|
name: "empty successful result after tool-using turn uses safe notice",
|
|
scriptBody: toolLastStream + `printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-boundary","result":""}'` + "\n",
|
|
wantStatus: "completed",
|
|
wantOutput: emptySuccessfulStreamResult,
|
|
forbiddenOutput: []string{
|
|
"PRE-TOOL NARRATION",
|
|
"I WILL USE A TOOL",
|
|
"TOOL TRACE",
|
|
},
|
|
},
|
|
{
|
|
name: "clean exit without result fails closed",
|
|
scriptBody: multiTurnStream,
|
|
wantStatus: "failed",
|
|
wantOutput: "",
|
|
wantError: "stream ended without terminal result",
|
|
forbiddenOutput: []string{
|
|
"FIRST-TURN NARRATION",
|
|
"TOOL TRACE",
|
|
"LAST ASSISTANT ANSWER",
|
|
},
|
|
},
|
|
{
|
|
name: "scanner error fails closed",
|
|
// The production scanner caps a single event at 10 MiB. A larger
|
|
// token produces bufio.ErrTooLong while the child still exits 0.
|
|
scriptBody: `dd if=/dev/zero bs=1048576 count=11 2>/dev/null | tr '\000' x; printf '\n'` + "\n",
|
|
wantStatus: "failed",
|
|
wantOutput: "",
|
|
wantError: "stdout read error",
|
|
},
|
|
}
|
|
|
|
for _, provider := range []string{"claude", "codebuddy"} {
|
|
provider := provider
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(provider+"/"+tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fakePath := filepath.Join(t.TempDir(), provider)
|
|
script := "#!/bin/sh\nIFS= read -r _\n" + tt.scriptBody
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
backend, err := New(provider, Config{
|
|
ExecutablePath: fakePath,
|
|
Env: map[string]string{"IS_SANDBOX": "1"},
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New(%s): %v", provider, err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
session, err := backend.Execute(ctx, "test prompt", ExecOptions{Timeout: 10 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("Execute(%s): %v", provider, err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result, ok := <-session.Result:
|
|
if !ok {
|
|
t.Fatal("result channel closed without a value")
|
|
}
|
|
if result.Status != tt.wantStatus {
|
|
t.Fatalf("status = %q, want %q (error=%q, output=%q)", result.Status, tt.wantStatus, result.Error, result.Output)
|
|
}
|
|
if result.Output != tt.wantOutput {
|
|
t.Fatalf("output = %q, want %q", result.Output, tt.wantOutput)
|
|
}
|
|
if tt.wantError != "" && !strings.Contains(result.Error, tt.wantError) {
|
|
t.Fatalf("error = %q, want substring %q", result.Error, tt.wantError)
|
|
}
|
|
for _, forbidden := range tt.forbiddenOutput {
|
|
if strings.Contains(result.Output, forbidden) {
|
|
t.Fatalf("output leaked %q: %q", forbidden, result.Output)
|
|
}
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatalf("timed out waiting for %s result: %v", provider, ctx.Err())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|