| 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 |