返回 DeepSeek-Reasonix
subagent_report_test.go
根目录 / internal / agent / subagent_report_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/event"
12 "reasonix/internal/evidence"
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 )
16
17 func intPtr(v int) *int { return &v }
18
19 func TestHostReceiptsAttestChangesAndVerifications(t *testing.T) {
20 summary := evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{
21 {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{`src\parser.go`}},
22 {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"parser_test.go"}},
23 {ToolName: "bash", Success: true, Command: "go test ./parser", ExitCode: intPtr(0), Verification: evidence.VerificationPassed},
24 {ToolName: "bash", Success: true, Command: "ls -la", ExitCode: intPtr(0), Verification: evidence.VerificationNotVerification},
25 }}
26
27 got := formatHostReceipts(summary, WritePathSet{})
28 for _, want := range []string{"changed: parser_test.go, src/parser.go", "go test ./parser (verification passed, exit 0)"} {
29 if !strings.Contains(got, want) {
30 t.Fatalf("receipts block %q missing %q", got, want)
31 }
32 }
33 // A plain read command is not a claim the parent must adjudicate, so it
34 // never spends parent context.
35 if strings.Contains(got, "ls -la") {
36 t.Fatalf("receipts block must not list non-verification commands: %q", got)
37 }
38 }
39
40 func TestHostReceiptsStaySilentForReadOnlyChildren(t *testing.T) {
41 summary := evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{
42 {ToolName: "read_file", Success: true, Read: true, Paths: []string{"parser.go"}},
43 {ToolName: "grep", Success: true, Read: true},
44 }}
45 if got := formatHostReceipts(summary, WritePathSet{}); got != "" {
46 t.Fatalf("read-only child produced a receipts block: %q", got)
47 }
48 if got := appendHostReceipts("just prose", summary, WritePathSet{}); got != "just prose" {
49 t.Fatalf("answer = %q, want it unchanged", got)
50 }
51 }
52
53 func TestHostReceiptsRecordFailedCommands(t *testing.T) {
54 summary := evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{
55 {ToolName: "bash", Success: true, Command: "go build ./...", ExitCode: intPtr(2)},
56 {ToolName: "bash", Success: true, Command: "go test ./parser", ExitCode: intPtr(1), Verification: evidence.VerificationFailed},
57 }}
58 got := formatHostReceipts(summary, WritePathSet{})
59 for _, want := range []string{"go build ./... (exit 2)", "go test ./parser (verification failed, exit 1)"} {
60 if !strings.Contains(got, want) {
61 t.Fatalf("receipts block %q missing %q", got, want)
62 }
63 }
64 }
65
66 func TestDecorateExecutionReceiptCarriesHostObservedOutcome(t *testing.T) {
67 rec := evidence.Receipt{ToolName: "bash", Success: true}
68 decorateExecutionReceipt(&rec, " out ", &tool.ShellExecution{
69 ExitCode: tool.IntPtr(3),
70 Verification: tool.ShellVerificationFailed,
71 })
72 if rec.ExitCode == nil || *rec.ExitCode != 3 {
73 t.Fatalf("ExitCode = %v, want 3", rec.ExitCode)
74 }
75 if rec.Verification != evidence.VerificationFailed {
76 t.Fatalf("Verification = %q, want %q", rec.Verification, evidence.VerificationFailed)
77 }
78 if rec.OutputBytes != len("out") {
79 t.Fatalf("OutputBytes = %d, want %d", rec.OutputBytes, len("out"))
80 }
81 // A tool that ran no process must not gain a fabricated exit status.
82 plain := evidence.Receipt{ToolName: "read_file", Success: true}
83 decorateExecutionReceipt(&plain, "body", nil)
84 if plain.ExitCode != nil {
85 t.Fatalf("non-shell receipt gained ExitCode %v", plain.ExitCode)
86 }
87 }
88
89 type fakeWriteFileTool struct{}
90
91 func (fakeWriteFileTool) Name() string { return "write_file" }
92 func (fakeWriteFileTool) Description() string { return "Write a file." }
93 func (fakeWriteFileTool) Schema() json.RawMessage {
94 return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`)
95 }
96 func (fakeWriteFileTool) ReadOnly() bool { return false }
97 func (fakeWriteFileTool) Execute(context.Context, json.RawMessage) (string, error) {
98 return "written", nil
99 }
100
101 // The parent must learn what the child actually changed even when the child's
102 // own prose says nothing about it.
103 func TestSubAgentAnswerCarriesHostReceipts(t *testing.T) {
104 reg := tool.NewRegistry()
105 reg.Add(fakeWriteFileTool{})
106 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
107 {toolCallChunk("1", "write_file", `{"path":"parser.go"}`), {Type: provider.ChunkDone}},
108 {{Type: provider.ChunkText, Text: "all done"}, {Type: provider.ChunkDone}},
109 }}
110
111 answer, err := RunSubAgentWithSession(withNoClosedLoop(context.Background()), prov, reg, NewSession("sys"),
112 "fix the parser", Options{}, event.Discard)
113 if err != nil {
114 t.Fatalf("RunSubAgentWithSession: %v", err)
115 }
116 if !strings.Contains(answer, "all done") {
117 t.Fatalf("answer lost the child's own summary: %q", answer)
118 }
119 if !strings.Contains(answer, hostReceiptsHeader) || !strings.Contains(answer, "parser.go") {
120 t.Fatalf("answer missing host receipts for the write it performed: %q", answer)
121 }
122 }
123
124 func TestSplitHostReceiptsSeparatesProseFromAttestation(t *testing.T) {
125 answer := appendHostReceipts("did the thing",
126 evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{
127 {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"a.go"}},
128 }}, WritePathSet{})
129 prose, receipts := splitHostReceipts(answer)
130 if prose != "did the thing" {
131 t.Fatalf("prose = %q", prose)
132 }
133 if !strings.HasPrefix(receipts, hostReceiptsHeader) || !strings.Contains(receipts, "a.go") {
134 t.Fatalf("receipts = %q", receipts)
135 }
136 if p, r := splitHostReceipts("plain answer"); p != "plain answer" || r != "" {
137 t.Fatalf("plain answer split to %q / %q", p, r)
138 }
139 }
140
141 // A verbose child must not be able to push the host's attestation out of a
142 // fleet aggregate by writing a long answer.
143 func TestAggregateReservesHostReceiptsAgainstLongProse(t *testing.T) {
144 answer := appendHostReceipts(strings.Repeat("chatter. ", 8000),
145 evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{
146 {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{"payments.go"}},
147 {ToolName: "bash", Success: true, Command: "go test ./pay", ExitCode: intPtr(1), Verification: evidence.VerificationFailed},
148 }}, WritePathSet{})
149
150 out := formatBoundedSubagentAggregate("fleet:\n", []subagentAggregateItem{
151 {header: "1. writer\n", status: "completed\n", answer: answer, ref: "sa_1"},
152 })
153 if !strings.Contains(out, "preview truncated") {
154 t.Fatal("expected the prose to be truncated in this fixture")
155 }
156 for _, want := range []string{hostReceiptsHeader, "payments.go", "go test ./pay (verification failed, exit 1)"} {
157 if !strings.Contains(out, want) {
158 t.Fatalf("aggregate dropped %q from the host attestation:\n%s", want, out)
159 }
160 }
161 }
162
163 // When attestations alone would starve the budget they lose detail, never the
164 // fact that a write escaped the declared claim.
165 func TestAggregateDegradesReceiptsButKeepsViolations(t *testing.T) {
166 root := t.TempDir()
167 claim, err := NormalizeWritePaths(root, []string{"auth"})
168 if err != nil {
169 t.Fatal(err)
170 }
171 items := make([]subagentAggregateItem, 0, 64)
172 for i := range 64 {
173 summary := evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{
174 {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{filepath.Join(root, "auth", strings.Repeat("deep/", 20)+"f.go")}},
175 {ToolName: "write_file", Success: true, Mutation: true, Paths: []string{filepath.Join(root, strings.Repeat("out/", 20)+"escaped.go")}},
176 }}
177 items = append(items, subagentAggregateItem{
178 header: fmt.Sprintf("%d. writer\n", i+1),
179 status: "completed\n",
180 answer: appendHostReceipts("done", summary, claim),
181 })
182 }
183
184 out := formatBoundedSubagentAggregate("fleet:\n", items)
185 if n := strings.Count(out, hostReceiptsViolationLabel); n != len(items) {
186 t.Fatalf("violation lines = %d, want %d — a claim escape was dropped to save space", n, len(items))
187 }
188 if len(out) > 32*1024 {
189 t.Fatalf("aggregate = %d bytes, over the tool output budget", len(out))
190 }
191 }
192
192 lines GO