返回 DeepSeek-Reasonix
harness_semantics_test.go
根目录 / internal / agent / harness_semantics_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync/atomic"
11 "testing"
12
13 "reasonix/internal/event"
14 "reasonix/internal/fileops"
15 "reasonix/internal/provider"
16 "reasonix/internal/sandbox"
17 "reasonix/internal/tool"
18 "reasonix/internal/tool/builtin"
19 )
20
21 func TestSameBatchReadEditBashEditUsesExecutionOrder(t *testing.T) {
22 dir := t.TempDir()
23 path := filepath.Join(dir, "中文.txt")
24 if err := os.WriteFile(path, []byte("alpha\r\nbeta\r\ngamma\r\n"), 0o600); err != nil {
25 t.Fatal(err)
26 }
27 reg := tool.NewRegistry()
28 for _, target := range (builtin.Workspace{Dir: dir, Bash: sandbox.Spec{Mode: "off"}}).Tools("read_file", "edit_file") {
29 reg.Add(target)
30 }
31 var bashCalls int32
32 reg.Add(fakeTool{name: "bash", calls: &bashCalls})
33 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
34
35 calls := []provider.ToolCall{
36 {ID: "r", Name: "read_file", Arguments: `{"path":"中文.txt","limit":1}`},
37 {ID: "e1", Name: "edit_file", Arguments: `{"path":"中文.txt","old_string":"beta","new_string":"BETA"}`},
38 {ID: "b", Name: "bash", Arguments: `{}`},
39 {ID: "e2", Name: "edit_file", Arguments: `{"path":"中文.txt","old_string":"gamma","new_string":"GAMMA"}`},
40 }
41 a.Session().Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: calls})
42 batch := a.executeBatch(context.Background(), &a.turn, calls)
43 if batch.err != nil {
44 t.Fatal(batch.err)
45 }
46 for i, out := range batch.outcomes {
47 if out.errMsg != "" || out.blocked {
48 t.Fatalf("call %d failed: %+v", i, out)
49 }
50 }
51 if atomic.LoadInt32(&bashCalls) != 1 {
52 t.Fatalf("bash calls = %d, want 1", bashCalls)
53 }
54 got, err := os.ReadFile(path)
55 if err != nil || !strings.Contains(string(got), "BETA\r\nGAMMA") {
56 t.Fatalf("file = %q, err=%v", got, err)
57 }
58 }
59
60 func TestThreeReadEditCommandEditCyclesIncludingMoveAndDelete(t *testing.T) {
61 dir := t.TempDir()
62 reg := tool.NewRegistry()
63 for _, target := range (builtin.Workspace{Dir: dir, Bash: sandbox.Spec{Mode: "off"}}).Tools(
64 "read_file", "edit_file", "move_file", "delete_range",
65 ) {
66 reg.Add(target)
67 }
68 var bashCalls int32
69 reg.Add(fakeTool{name: "bash", calls: &bashCalls})
70 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
71
72 names := []string{"循环-一.txt", "循环-二.txt", "循环-三.txt"}
73 for i, name := range names {
74 if err := os.WriteFile(filepath.Join(dir, name), []byte("header\r\nbeta\r\ngamma\r\ntail\r\n"), 0o600); err != nil {
75 t.Fatal(err)
76 }
77 calls := []provider.ToolCall{
78 {ID: fmt.Sprintf("r%d", i), Name: "read_file", Arguments: `{"path":"` + name + `","limit":1}`},
79 {ID: fmt.Sprintf("e%da", i), Name: "edit_file", Arguments: `{"path":"` + name + `","old_string":"beta","new_string":"BETA"}`},
80 {ID: fmt.Sprintf("b%d", i), Name: "bash", Arguments: `{}`},
81 {ID: fmt.Sprintf("e%db", i), Name: "edit_file", Arguments: `{"path":"` + name + `","old_string":"gamma","new_string":"GAMMA"}`},
82 }
83 a.Session().Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: calls})
84 batch := a.executeBatch(context.Background(), &a.turn, calls)
85 if batch.err != nil {
86 t.Fatal(batch.err)
87 }
88 for n, outcome := range batch.outcomes {
89 if outcome.errMsg != "" || outcome.blocked {
90 t.Fatalf("cycle %d call %d failed: %+v", i, n, outcome)
91 }
92 }
93 }
94
95 post := []provider.ToolCall{
96 {ID: "move", Name: "move_file", Arguments: `{"source_path":"循环-一.txt","destination_path":"已移动.txt"}`},
97 {ID: "delete", Name: "delete_range", Arguments: `{"path":"循环-二.txt","start_anchor":"BETA","end_anchor":"GAMMA","inclusive":true}`},
98 }
99 a.Session().Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: post})
100 batch := a.executeBatch(context.Background(), &a.turn, post)
101 if batch.err != nil {
102 t.Fatal(batch.err)
103 }
104 for i, outcome := range batch.outcomes {
105 if outcome.errMsg != "" || outcome.blocked {
106 t.Fatalf("post call %d failed: %+v", i, outcome)
107 }
108 }
109 if atomic.LoadInt32(&bashCalls) != 3 {
110 t.Fatalf("bash calls = %d, want 3", bashCalls)
111 }
112 }
113
114 func TestObservationSurvivesOrdinaryTurnButIsAgentLocal(t *testing.T) {
115 dir := t.TempDir()
116 if err := os.WriteFile(filepath.Join(dir, "state.txt"), []byte("old\n"), 0o600); err != nil {
117 t.Fatal(err)
118 }
119 reg := tool.NewRegistry()
120 for _, target := range (builtin.Workspace{Dir: dir}).Tools("read_file", "edit_file") {
121 reg.Add(target)
122 }
123 parent := New(nil, reg, NewSession(""), Options{}, event.Discard)
124 child := New(nil, reg, NewSession(""), Options{}, event.Discard)
125
126 read := []provider.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"state.txt","limit":1}`}}
127 parent.Session().Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: read})
128 if batch := parent.executeBatch(context.Background(), &parent.turn, read); batch.err != nil || batch.outcomes[0].errMsg != "" {
129 t.Fatalf("read failed: %+v", batch)
130 }
131 parent.turn = turnRuntime{} // ordinary user turn replaces only per-turn state
132 edit := []provider.ToolCall{{ID: "edit", Name: "edit_file", Arguments: `{"path":"state.txt","old_string":"old","new_string":"parent"}`}}
133 parent.Session().Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: edit})
134 if batch := parent.executeBatch(context.Background(), &parent.turn, edit); batch.err != nil || batch.outcomes[0].errMsg != "" {
135 t.Fatalf("cross-turn edit failed: %+v", batch)
136 }
137
138 childEdit := []provider.ToolCall{{ID: "child-edit", Name: "edit_file", Arguments: `{"path":"state.txt","old_string":"parent","new_string":"child"}`}}
139 child.Session().Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: childEdit})
140 batch := child.executeBatch(context.Background(), &child.turn, childEdit)
141 if len(batch.outcomes) != 1 || !strings.Contains(batch.outcomes[0].errMsg, string(tool.FSNotObserved)) {
142 t.Fatalf("child inherited parent observation: %+v", batch)
143 }
144 }
145
146 func TestSessionReplacementClearsFileObservations(t *testing.T) {
147 a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
148 target := fileops.OverlayTarget("/workspace/a.txt")
149 a.fileObservations.ObservePresent(target, fileops.OverlayVersion("old"))
150 a.SetSession(NewSession(""))
151 if got := a.fileObservations.Get(target); got.Kind != fileops.Unseen {
152 t.Fatalf("observation crossed session replacement: %+v", got)
153 }
154 }
155
156 func TestBoundedLargeReadDoesNotGateCommandOrFinalAnswer(t *testing.T) {
157 dir := t.TempDir()
158 if err := os.WriteFile(filepath.Join(dir, "large.txt"), []byte(strings.Repeat("line\n", 100000)), 0o600); err != nil {
159 t.Fatal(err)
160 }
161 reg := tool.NewRegistry()
162 for _, target := range (builtin.Workspace{Dir: dir}).Tools("read_file") {
163 reg.Add(target)
164 }
165 var bashCalls int32
166 reg.Add(fakeTool{name: "bash", calls: &bashCalls})
167 prov := &scriptedProvider{name: "bounded-read", turns: [][]provider.Chunk{
168 {toolCallChunk("r", "read_file", `{"path":"large.txt","limit":1}`), toolCallChunk("b", "bash", `{}`), {Type: provider.ChunkDone}},
169 {{Type: provider.ChunkText, Text: "done after one useful window"}, {Type: provider.ChunkDone}},
170 }}
171 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
172 if err := a.Run(context.Background(), "inspect one line, run a command, and finish"); err != nil {
173 t.Fatal(err)
174 }
175 if prov.call != 2 || atomic.LoadInt32(&bashCalls) != 1 {
176 t.Fatalf("provider calls=%d bash calls=%d", prov.call, bashCalls)
177 }
178 }
179
180 func TestUnknownPriorEffectIsFactNotExecutionBarrier(t *testing.T) {
181 var calls int32
182 target := fakeTool{name: "external_write", calls: &calls}
183 reg := tool.NewRegistry()
184 reg.Add(target)
185 record := provider.ToolCallRecord{
186 Identity: provider.ActionIdentity{CallID: "old", AttemptID: "attempt-old", CanonicalTool: target.Name(), ArgumentDigest: recoveryDigest([]byte(`{}`)), ResourceScope: "session:fixture"},
187 State: provider.ToolRunUnknown, Arguments: json.RawMessage(`{}`),
188 }
189 sess := NewSession("")
190 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old request"})
191 sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "old", Name: target.Name(), Arguments: `{}`, Recovery: &record}}})
192 a := New(nil, reg, sess, Options{}, event.Discard)
193
194 newCalls := []provider.ToolCall{{ID: "new", Name: target.Name(), Arguments: `{}`}}
195 a.Session().Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: newCalls})
196 batch := a.executeBatch(context.Background(), &a.turn, newCalls)
197 if batch.err != nil || len(batch.outcomes) != 1 || batch.outcomes[0].errMsg != "" || batch.outcomes[0].blocked {
198 t.Fatalf("new call was blocked by historical unknown: %+v", batch)
199 }
200 if atomic.LoadInt32(&calls) != 1 {
201 t.Fatalf("new call executions = %d, want 1", calls)
202 }
203 if pending := a.PendingToolRecovery(); len(pending) != 1 || pending[0].Identity.AttemptID != "attempt-old" {
204 t.Fatalf("historical fact changed: %+v", pending)
205 }
206 }
207
208 func TestRetiredProofToolReturnsOrdinaryPairedResult(t *testing.T) {
209 a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
210 calls := []provider.ToolCall{{ID: "old", Name: "complete_step", Arguments: `{}`}}
211 a.Session().Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: calls})
212 batch := a.executeBatch(context.Background(), &a.turn, calls)
213 if batch.err != nil || len(batch.results) != 1 || !strings.Contains(batch.results[0], "tool_retired") {
214 t.Fatalf("retired result = %+v", batch)
215 }
216 if got := toolResultByID(a.Session(), "old"); !strings.Contains(got, "tool_retired") {
217 t.Fatalf("stored paired result = %q", got)
218 }
219 }
220
220 lines GO