Truncate execution.log patches and keep members aligned with patch bodies so verbose MiniMax-M3 runs no longer exceed the Go 8MB fail-closed limit. Surface body-too-large vs typed-reject reasons and map contract failures to fatal restart instead of misleading resumable TASK_INTERRUPTED.
371 lines
11 KiB
Go
371 lines
11 KiB
Go
package agent_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/agent"
|
|
"chat-agent-system/internal/config"
|
|
)
|
|
|
|
func TestStubBackendExecution(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfg := &config.Config{AgentBackend: "stub"}
|
|
|
|
backend := agent.NewBackend("stub", cfg)
|
|
if backend.Name() != "stub" {
|
|
t.Errorf("expected stub backend, got %s", backend.Name())
|
|
}
|
|
|
|
req := agent.Request{
|
|
WorkDir: tmpDir,
|
|
Prompt: "请编写用户初始需求收集表",
|
|
Targets: []string{"output.md"},
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
|
|
res, err := backend.Execute(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("stub execute failed: %v", err)
|
|
}
|
|
if !res.Success {
|
|
t.Fatalf("expected success")
|
|
}
|
|
|
|
outputPath := filepath.Join(tmpDir, "output.md")
|
|
if _, err := os.Stat(outputPath); err != nil {
|
|
t.Fatalf("expected output.md to exist: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPiAgentRemoteRunner(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/run" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
|
|
|
body := "# Remote Generated Deliverable\nValidated from Container Runner"
|
|
runID := filepath.Base(tmpDir)
|
|
rv := agent.BuildResultV1(runID, "success", false, "", []struct {
|
|
RelPath string
|
|
Content []byte
|
|
}{{"output.md", []byte(body)}})
|
|
resp := map[string]any{
|
|
"ok": true,
|
|
"run_id": runID,
|
|
"runner_protocol": "typed-v1",
|
|
"capabilities": map[string]any{"typed_result": true},
|
|
"patches": []map[string]string{{"rel_path": "output.md", "content": body}},
|
|
"result_v1": rv,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer ts.Close()
|
|
cfg := &config.Config{
|
|
AgentBackend: "pi-agent",
|
|
PiAgentRunnerURL: ts.URL,
|
|
PiAgentModel: "MiniMax-M3",
|
|
PiAgentProvider: "minimax",
|
|
AgentTimeout: 5 * time.Second,
|
|
}
|
|
|
|
backend := agent.NewBackend("pi-agent", cfg)
|
|
res, err := backend.Execute(context.Background(), agent.Request{
|
|
WorkDir: tmpDir,
|
|
Prompt: "Generate remote test document",
|
|
Targets: []string{"output.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("remote execute returned unexpected error: %v", err)
|
|
}
|
|
if !res.Success {
|
|
t.Fatalf("expected success, got error: %s", res.ErrorReason)
|
|
}
|
|
if string(res.Patches["output.md"]) != "# Remote Generated Deliverable\nValidated from Container Runner" {
|
|
t.Errorf("unexpected content: %s", string(res.Patches["output.md"]))
|
|
}
|
|
|
|
// Verify local file written
|
|
localContent, err := os.ReadFile(filepath.Join(tmpDir, "output.md"))
|
|
if err != nil {
|
|
t.Fatalf("expected local file written: %v", err)
|
|
}
|
|
if string(localContent) != "# Remote Generated Deliverable\nValidated from Container Runner" {
|
|
t.Errorf("unexpected local file content: %s", string(localContent))
|
|
}
|
|
}
|
|
|
|
func TestPiAgentRemoteRunner_PathAliasesAndMissing(t *testing.T) {
|
|
run := func(mode, want string, expectOK bool) {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), "tasks", "01_draft")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/run" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
var patches []map[string]string
|
|
switch mode {
|
|
case "abs":
|
|
patches = []map[string]string{{
|
|
"rel_path": filepath.Join(dir, "output.md"),
|
|
"content": "# from absolute rel_path\n",
|
|
}}
|
|
case "nested":
|
|
patches = []map[string]string{{
|
|
"rel_path": "tasks/01_draft/output.md",
|
|
"content": "# from nested rel_path\n",
|
|
}}
|
|
case "dot":
|
|
patches = []map[string]string{{
|
|
"rel_path": "./output.md",
|
|
"content": "# from dot rel_path\n",
|
|
}}
|
|
default:
|
|
patches = []map[string]string{{
|
|
"rel_path": "execution.log",
|
|
"content": "Outcome: SUCCESS\n",
|
|
}}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"ok": true, "runner_protocol": "legacy", "patches": patches,
|
|
})
|
|
}))
|
|
defer ts.Close()
|
|
|
|
cfg := &config.Config{
|
|
AgentBackend: "pi-agent",
|
|
PiAgentRunnerURL: ts.URL,
|
|
PiAgentModel: "MiniMax-M3",
|
|
PiAgentProvider: "minimax",
|
|
AgentTimeout: 5 * time.Second,
|
|
}
|
|
res, err := agent.NewBackend("pi-agent", cfg).Execute(context.Background(), agent.Request{
|
|
WorkDir: dir,
|
|
Prompt: "draft",
|
|
Targets: []string{"output.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("mode %s unexpected err: %v", mode, err)
|
|
}
|
|
if expectOK {
|
|
if !res.Success {
|
|
t.Fatalf("mode %s want success, got %s", mode, res.ErrorReason)
|
|
}
|
|
got, rerr := os.ReadFile(filepath.Join(dir, "output.md"))
|
|
if rerr != nil {
|
|
t.Fatalf("mode %s missing file: %v", mode, rerr)
|
|
}
|
|
if string(got) != want {
|
|
t.Fatalf("mode %s content %q", mode, got)
|
|
}
|
|
return
|
|
}
|
|
if res.Success {
|
|
t.Fatalf("mode %s must fail closed", mode)
|
|
}
|
|
if !strings.Contains(res.ErrorReason, "missing target deliverable output.md") {
|
|
t.Fatalf("mode %s reason %q", mode, res.ErrorReason)
|
|
}
|
|
if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil {
|
|
t.Fatalf("mode %s must not invent output.md", mode)
|
|
}
|
|
}
|
|
|
|
run("abs", "# from absolute rel_path\n", true)
|
|
run("nested", "# from nested rel_path\n", true)
|
|
run("dot", "# from dot rel_path\n", true)
|
|
run("missing", "", false)
|
|
}
|
|
|
|
func TestPiAgentRemoteRunner_PropagatesRetryable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"ok": false,
|
|
"retryable": true,
|
|
"fallback_reason": "Connection error.",
|
|
"runner_protocol": "legacy",
|
|
"patches": []any{},
|
|
})
|
|
}))
|
|
defer ts.Close()
|
|
res, err := agent.NewBackend("pi-agent", &config.Config{
|
|
AgentBackend: "pi-agent",
|
|
PiAgentRunnerURL: ts.URL,
|
|
PiAgentModel: "MiniMax-M3",
|
|
PiAgentProvider: "minimax",
|
|
AgentTimeout: 5 * time.Second,
|
|
}).Execute(context.Background(), agent.Request{
|
|
WorkDir: dir,
|
|
Prompt: "draft",
|
|
Targets: []string{"output.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Success || !res.Retryable {
|
|
t.Fatalf("want retryable failure, success=%v retryable=%v reason=%s", res.Success, res.Retryable, res.ErrorReason)
|
|
}
|
|
if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil {
|
|
t.Fatal("must not invent output.md")
|
|
}
|
|
}
|
|
|
|
func TestPiAgentRemoteRunner_OldSettleLieIsRetryable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := "Outcome: SUCCESS\n" +
|
|
`{"type":"message_end","message":{"stopReason":"error","errorMessage":"terminated"}}` + "\n" +
|
|
`{"type":"auto_retry_end","success":false,"attempt":3,"finalError":"Connection error."}` + "\n" +
|
|
`{"type":"agent_settled"}` + "\n"
|
|
if err := os.WriteFile(filepath.Join(dir, "execution.log"), []byte(log), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"ok": true,
|
|
"runner_protocol": "legacy",
|
|
"patches": []map[string]string{{
|
|
"rel_path": "execution.log",
|
|
"content": "Outcome: SUCCESS\n",
|
|
}},
|
|
})
|
|
}))
|
|
defer ts.Close()
|
|
res, err := agent.NewBackend("pi-agent", &config.Config{
|
|
AgentBackend: "pi-agent",
|
|
PiAgentRunnerURL: ts.URL,
|
|
PiAgentModel: "MiniMax-M3",
|
|
PiAgentProvider: "minimax",
|
|
AgentTimeout: 5 * time.Second,
|
|
}).Execute(context.Background(), agent.Request{
|
|
WorkDir: dir,
|
|
Prompt: "draft",
|
|
Targets: []string{"output.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Success {
|
|
t.Fatal("process-0 settle without output.md must not succeed")
|
|
}
|
|
if !res.Retryable {
|
|
t.Fatalf("old runner lie + provider trace must be retryable, reason=%s", res.ErrorReason)
|
|
}
|
|
if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil {
|
|
t.Fatal("must not reconstruct output.md from truncated write args")
|
|
}
|
|
}
|
|
|
|
func TestPiAgentRemoteRunner_TypedTamperDoesNotWrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
runID := filepath.Base(dir)
|
|
rv := agent.BuildResultV1(runID, "success", false, "", []struct {
|
|
RelPath string
|
|
Content []byte
|
|
}{{"output.md", []byte("hello")}})
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"ok": true, "run_id": runID, "runner_protocol": "typed-v1",
|
|
"patches": []map[string]string{{"rel_path": "output.md", "content": "TAMPER"}},
|
|
"result_v1": rv,
|
|
})
|
|
}))
|
|
defer ts.Close()
|
|
res, err := agent.NewBackend("pi-agent", &config.Config{
|
|
AgentBackend: "pi-agent",
|
|
PiAgentRunnerURL: ts.URL,
|
|
PiAgentModel: "MiniMax-M3",
|
|
PiAgentProvider: "minimax",
|
|
AgentTimeout: 5 * time.Second,
|
|
}).Execute(context.Background(), agent.Request{
|
|
WorkDir: dir, Prompt: "draft", Targets: []string{"output.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Success || !strings.HasPrefix(res.ErrorReason, "typed result rejected") {
|
|
t.Fatalf("tamper must fail closed, got success=%v reason=%q", res.Success, res.ErrorReason)
|
|
}
|
|
if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil {
|
|
t.Fatal("must not materialize after typed reject")
|
|
}
|
|
}
|
|
|
|
func TestPiAgentRemoteRunner_ClientTimeout(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(2 * time.Second)
|
|
}))
|
|
defer ts.Close()
|
|
res, err := agent.NewBackend("pi-agent", &config.Config{
|
|
AgentBackend: "pi-agent",
|
|
PiAgentRunnerURL: ts.URL,
|
|
PiAgentModel: "MiniMax-M3",
|
|
PiAgentProvider: "minimax",
|
|
AgentTimeout: 50 * time.Millisecond,
|
|
}).Execute(context.Background(), agent.Request{
|
|
WorkDir: dir, Prompt: "draft", Targets: []string{"output.md"}, Timeout: 50 * time.Millisecond,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Success || !res.Retryable {
|
|
t.Fatalf("timeout must be retryable failure, got %+v", res)
|
|
}
|
|
if strings.Contains(strings.ToLower(res.ErrorReason), "stderr") {
|
|
t.Fatalf("timeout reason leaked stderr: %q", res.ErrorReason)
|
|
}
|
|
}
|
|
|
|
func TestPiAgentRemoteRunner_BodyTooLargeDoesNotWrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// JSON body > MaxResultBodyBytes (8MiB). Content-Length alone is not enough;
|
|
// LimitReader stops after MaxResultBodyBytes+1 of payload bytes.
|
|
payload := make([]byte, agent.MaxResultBodyBytes+1024)
|
|
for i := range payload {
|
|
payload[i] = 'A'
|
|
}
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(payload)
|
|
}))
|
|
defer ts.Close()
|
|
res, err := agent.NewBackend("pi-agent", &config.Config{
|
|
AgentBackend: "pi-agent",
|
|
PiAgentRunnerURL: ts.URL,
|
|
PiAgentModel: "MiniMax-M3",
|
|
PiAgentProvider: "minimax",
|
|
AgentTimeout: 5 * time.Second,
|
|
}).Execute(context.Background(), agent.Request{
|
|
WorkDir: dir, Prompt: "draft", Targets: []string{"output.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Success || res.ErrorReason != "runner result body too large" {
|
|
t.Fatalf("oversized body must fail closed, got success=%v reason=%q", res.Success, res.ErrorReason)
|
|
}
|
|
if res.Retryable {
|
|
t.Fatal("body-too-large must not be retryable")
|
|
}
|
|
if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil {
|
|
t.Fatal("must not materialize after body-too-large reject")
|
|
}
|
|
}
|