| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/evidence" |
| 11 | "reasonix/internal/provider" |
| 12 | "reasonix/internal/tool" |
| 13 | ) |
| 14 | |
| 15 | func submitCompleteSubtask(t *testing.T, led *evidence.Ledger, args string) string { |
| 16 | t.Helper() |
| 17 | ctx := evidence.WithLedger(context.Background(), led) |
| 18 | out, err := NewCompleteSubtaskTool().Execute(ctx, json.RawMessage(args)) |
| 19 | if err != nil { |
| 20 | t.Fatalf("complete_subtask: %v", err) |
| 21 | } |
| 22 | return out |
| 23 | } |
| 24 | |
| 25 | // The model's report is preserved; execution facts are a separate projection. |
| 26 | func TestCompleteSubtaskPreservesModelReport(t *testing.T) { |
| 27 | led := evidence.NewLedger() |
| 28 | led.Record(evidence.Receipt{ToolName: "bash", Command: "go test ./parser", Success: true, OutputBytes: 12}) |
| 29 | |
| 30 | args := `{ |
| 31 | "status":"complete", |
| 32 | "summary":"fixed the parser", |
| 33 | "acceptance_criteria":[ |
| 34 | {"id":"AC1","status":"satisfied","evidence":[{"kind":"verification","summary":"unit tests","command":"go test ./parser"}]}, |
| 35 | {"id":"AC2","status":"satisfied","evidence":[{"kind":"verification","summary":"integration suite","command":"go test ./integration"}]} |
| 36 | ]}` |
| 37 | if out := submitCompleteSubtask(t, led, args); !strings.Contains(out, "status=complete") { |
| 38 | t.Fatalf("tool result = %q, want the model's status", out) |
| 39 | } |
| 40 | |
| 41 | // The agent host, not the tool, records the call; replay that receipt so the |
| 42 | // ledger lookup the report renderer uses is covered too. |
| 43 | led.Record(evidence.ReceiptFromToolCall("complete_subtask", json.RawMessage(args), true, true)) |
| 44 | report, ok := led.LatestCompletionReport() |
| 45 | if !ok { |
| 46 | t.Fatal("a recorded complete_subtask call must be recoverable from the ledger") |
| 47 | } |
| 48 | if report.Status != evidence.CompletionComplete || report.Criteria[1].Status != evidence.CriterionSatisfied { |
| 49 | t.Fatalf("host rewrote the model report: %+v", report) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestCompleteSubtaskKeepsFullyBackedClaim(t *testing.T) { |
| 54 | led := evidence.NewLedger() |
| 55 | led.Record(evidence.Receipt{ToolName: "bash", Command: "go test ./parser", Success: true, OutputBytes: 12}) |
| 56 | led.Record(evidence.Receipt{ToolName: "write_file", Success: true, Mutation: true, Write: true, Paths: []string{"parser.go"}}) |
| 57 | |
| 58 | out := submitCompleteSubtask(t, led, `{ |
| 59 | "status":"complete", |
| 60 | "summary":"fixed the parser", |
| 61 | "acceptance_criteria":[ |
| 62 | {"id":"AC1","status":"satisfied","evidence":[{"kind":"verification","summary":"tests","command":"go test ./parser"}]}, |
| 63 | {"id":"AC2","status":"satisfied","evidence":[{"kind":"diff","summary":"the fix","paths":["parser.go"]}]} |
| 64 | ]}`) |
| 65 | if !strings.Contains(out, "status=complete") || strings.Contains(out, "lowered") { |
| 66 | t.Fatalf("tool result = %q, want an untouched complete status", out) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestCompleteSubtaskAcceptsReportWithoutHostEvidence(t *testing.T) { |
| 71 | out, err := NewCompleteSubtaskTool().Execute(context.Background(), json.RawMessage(`{ |
| 72 | "status":"complete","summary":"done", |
| 73 | "acceptance_criteria":[ |
| 74 | {"id":"AC1","status":"satisfied","evidence":[{"kind":"manual","summary":"I checked it"}]}, |
| 75 | {"id":"AC2","status":"satisfied"} |
| 76 | ]}`)) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | if !strings.Contains(out, "status=complete") || strings.Contains(out, "lowered") { |
| 81 | t.Fatalf("report was adjudicated: %s", out) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestParseCompletionReportRejectsMalformedClaims(t *testing.T) { |
| 86 | for name, args := range map[string]string{ |
| 87 | "bad status": `{"status":"done","summary":"x"}`, |
| 88 | "missing summary": `{"status":"complete"}`, |
| 89 | "verification no cmd": `{"status":"complete","summary":"x","acceptance_criteria":[{"id":"AC1","status":"satisfied","evidence":[{"kind":"verification","summary":"tests"}]}]}`, |
| 90 | "diff without paths": `{"status":"complete","summary":"x","acceptance_criteria":[{"id":"AC1","status":"satisfied","evidence":[{"kind":"diff","summary":"change"}]}]}`, |
| 91 | "unknown evidence": `{"status":"complete","summary":"x","acceptance_criteria":[{"id":"AC1","status":"satisfied","evidence":[{"kind":"vibes","summary":"trust me"}]}]}`, |
| 92 | "criterion without id": `{"status":"complete","summary":"x","acceptance_criteria":[{"status":"satisfied"}]}`, |
| 93 | } { |
| 94 | if _, err := evidence.ParseCompletionReport(json.RawMessage(args)); err == nil { |
| 95 | t.Errorf("%s: accepted a malformed report", name) |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestSubAgentAnswerSeparatesModelReportFromExecutionFacts(t *testing.T) { |
| 101 | reg := tool.NewRegistry() |
| 102 | reg.Add(fakeWriteFileTool{}) |
| 103 | AttachCompleteSubtaskTool(reg) |
| 104 | prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ |
| 105 | {toolCallChunk("1", "write_file", `{"path":"parser.go"}`), {Type: provider.ChunkDone}}, |
| 106 | {toolCallChunk("2", "complete_subtask", `{"status":"complete","summary":"fixed the parser","acceptance_criteria":[{"id":"AC1","status":"satisfied","evidence":[{"kind":"diff","summary":"the fix","paths":["parser.go"]}]},{"id":"AC2","status":"satisfied","evidence":[{"kind":"verification","summary":"suite","command":"go test ./..."}]}],"unresolved":["integration suite not executed"]}`), {Type: provider.ChunkDone}}, |
| 107 | {{Type: provider.ChunkText, Text: "all good"}, {Type: provider.ChunkDone}}, |
| 108 | }} |
| 109 | |
| 110 | answer, err := RunSubAgentWithSession(withNoClosedLoop(context.Background()), prov, reg, NewSession("sys"), |
| 111 | "fix the parser", Options{}, event.Discard) |
| 112 | if err != nil { |
| 113 | t.Fatalf("RunSubAgentWithSession: %v", err) |
| 114 | } |
| 115 | if !strings.HasPrefix(answer, "Model-reported status: complete") { |
| 116 | t.Fatalf("answer must label the model's assessment:\n%s", answer) |
| 117 | } |
| 118 | for _, want := range []string{ |
| 119 | "AC1 satisfied", |
| 120 | "AC2 satisfied", |
| 121 | "unresolved: integration suite not executed", |
| 122 | hostReceiptsHeader, |
| 123 | } { |
| 124 | if !strings.Contains(answer, want) { |
| 125 | t.Fatalf("answer missing %q:\n%s", want, answer) |
| 126 | } |
| 127 | } |
| 128 | if strings.Contains(answer, "host lowered") { |
| 129 | t.Fatalf("host adjudication survived: %s", answer) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestSubAgentMayFinishWithoutCompletionReport(t *testing.T) { |
| 134 | reg := tool.NewRegistry() |
| 135 | AttachCompleteSubtaskTool(reg) |
| 136 | prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ |
| 137 | {{Type: provider.ChunkText, Text: "Analysis complete."}, {Type: provider.ChunkDone}}, |
| 138 | }} |
| 139 | answer, err := RunSubAgentWithSession(context.Background(), prov, reg, NewSession("sys"), |
| 140 | completeSubtaskContract, Options{}, event.Discard) |
| 141 | if err != nil || answer != "Analysis complete." { |
| 142 | t.Fatalf("plain completion = %q, %v", answer, err) |
| 143 | } |
| 144 | } |
| 145 |