返回 DeepSeek-Reasonix
workspace_mutation_test.go
根目录 / internal / agent / workspace_mutation_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/checkpoint"
13 "reasonix/internal/event"
14 "reasonix/internal/evidence"
15 "reasonix/internal/extension"
16 "reasonix/internal/extension/dispatch"
17 "reasonix/internal/extension/protocol"
18 "reasonix/internal/provider"
19 "reasonix/internal/tool"
20 )
21
22 func TestObservedFileChangePromotesRepositoryOnlyReceiptToContentMutation(t *testing.T) {
23 root := t.TempDir()
24 path := root + "/tracked.txt"
25 if err := os.WriteFile(path, []byte("before"), 0o600); err != nil {
26 t.Fatal(err)
27 }
28 store := checkpoint.New("", root)
29 store.Begin(1, "commit", 0)
30 observer := checkpoint.NewMutationObserver(checkpoint.ObserverOptions{Store: store})
31 observer.BeforeMutation(path, "bash", checkpoint.CaptureBeforeMutation)
32
33 reg := tool.NewRegistry()
34 bash := fakeTool{name: "bash", readOnly: false}
35 reg.Add(bash)
36 a := New(nil, reg, NewSession(""), Options{MutationObserver: observer}, event.Discard)
37 args := json.RawMessage(`{"command":"git commit -m checkpoint"}`)
38 plan := &toolCallPlan{
39 call: provider.ToolCall{ID: "commit", Name: "bash", Arguments: string(args)},
40 tool: bash,
41 evidenceName: "bash",
42 evidenceArgs: args,
43 effects: evidence.ClassifyToolCall("bash", args, false),
44 mutationPath: path,
45 }
46 if plan.effects.ContentMutation {
47 t.Fatal("pure commit should begin as repository-only")
48 }
49 if err := os.WriteFile(path, []byte("changed by hook"), 0o600); err != nil {
50 t.Fatal(err)
51 }
52 if !a.observeAfterMutation(plan) || !plan.effects.ContentMutation {
53 t.Fatalf("observed effect was not promoted: %+v", plan.effects)
54 }
55 a.recordToolReceipts(plan, "", nil, nil)
56 if _, ok := a.task.ledger.LatestSuccessfulMutationIndex(); !ok {
57 t.Fatal("promoted receipt was not recorded as a content mutation")
58 }
59 }
60
61 func TestToolBeforeWorkspaceMutationUsesExecutedReplacement(t *testing.T) {
62 t.Run("reader replaced by writer", func(t *testing.T) {
63 client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
64 if ev == protocol.EventToolBefore {
65 return replaceWith(t, dispatch.ToolBeforePayload{Name: "write_file", Arguments: `{"path":"effective.go","content":"x"}`}), nil
66 }
67 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
68 }}
69 reg := tool.NewRegistry()
70 reg.Add(&recordingTool{name: "read_file", readOnly: true})
71 reg.Add(&recordingTool{name: "write_file", readOnly: false})
72 sink := newWorkspaceSignalSink()
73 a := New(nil, reg, NewSession(""), Options{Extensions: newExtDispatcher(client, true, nil, extension.PointToolBefore)}, sink)
74 a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{ID: "call", Name: "read_file", Arguments: `{"path":"original.go"}`}})
75
76 select {
77 case mutation := <-sink.mutations:
78 if mutation.ToolName != "write_file" || len(mutation.Paths) != 1 || mutation.Paths[0] != "effective.go" {
79 t.Fatalf("replacement workspace mutation = %+v", mutation)
80 }
81 default:
82 t.Fatal("executed writer replacement did not publish a workspace mutation")
83 }
84 results := sink.kinds(event.ToolResult)
85 if len(results) != 1 || !results[0].Tool.WorkspaceMutation || len(results[0].Tool.WorkspacePaths) != 1 || results[0].Tool.WorkspacePaths[0] != "effective.go" {
86 t.Fatalf("replacement ToolResult metadata = %+v", results)
87 }
88 })
89
90 t.Run("writer replaced by reader", func(t *testing.T) {
91 client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
92 if ev == protocol.EventToolBefore {
93 return replaceWith(t, dispatch.ToolBeforePayload{Name: "read_file", Arguments: `{"path":"effective.go"}`}), nil
94 }
95 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
96 }}
97 reg := tool.NewRegistry()
98 reg.Add(&recordingTool{name: "write_file", readOnly: false})
99 reg.Add(&recordingTool{name: "read_file", readOnly: true})
100 sink := newWorkspaceSignalSink()
101 a := New(nil, reg, NewSession(""), Options{Extensions: newExtDispatcher(client, true, nil, extension.PointToolBefore)}, sink)
102 a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{ID: "call", Name: "write_file", Arguments: `{"path":"original.go","content":"x"}`}})
103
104 select {
105 case mutation := <-sink.mutations:
106 t.Fatalf("reader replacement published a false workspace mutation: %+v", mutation)
107 default:
108 }
109 results := sink.kinds(event.ToolResult)
110 if len(results) != 1 || results[0].Tool.WorkspaceMutation {
111 t.Fatalf("reader replacement ToolResult metadata = %+v", results)
112 }
113 })
114 }
115
116 func TestToolBeforeWriterReplacementSignalsBeforeParallelPeerCompletes(t *testing.T) {
117 client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
118 if ev != protocol.EventToolBefore {
119 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
120 }
121 var call dispatch.ToolBeforePayload
122 if err := json.Unmarshal(payload, &call); err != nil {
123 return protocol.InterceptResult{}, err
124 }
125 if call.Name == "read_file" {
126 return replaceWith(t, dispatch.ToolBeforePayload{Name: "write_file", Arguments: `{"path":"effective.go","content":"x"}`}), nil
127 }
128 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
129 }}
130 started := make(chan struct{})
131 release := make(chan struct{})
132 reg := tool.NewRegistry()
133 reg.Add(&recordingTool{name: "read_file", readOnly: true})
134 reg.Add(&recordingTool{name: "write_file", readOnly: false})
135 reg.Add(blockingTool{name: "slow_read", started: started, release: release})
136 sink := newWorkspaceSignalSink()
137 a := New(nil, reg, NewSession(""), Options{Extensions: newExtDispatcher(client, true, nil, extension.PointToolBefore)}, sink)
138 done := make(chan struct{})
139 go func() {
140 defer close(done)
141 a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
142 {ID: "writer", Name: "read_file", Arguments: `{"path":"original.go"}`},
143 {ID: "reader", Name: "slow_read", Arguments: `{}`},
144 })
145 }()
146
147 select {
148 case <-started:
149 case <-time.After(2 * time.Second):
150 t.Fatal("parallel read peer did not start")
151 }
152 select {
153 case mutation := <-sink.mutations:
154 if mutation.ToolName != "write_file" || len(mutation.Paths) != 1 || mutation.Paths[0] != "effective.go" {
155 t.Fatalf("replacement workspace mutation = %+v", mutation)
156 }
157 case <-time.After(2 * time.Second):
158 t.Fatal("writer replacement waited for its parallel peer")
159 }
160 close(release)
161 select {
162 case <-done:
163 case <-time.After(2 * time.Second):
164 t.Fatal("parallel batch did not finish after releasing the peer")
165 }
166 }
167
168 func TestToolBeforeFailedWriterReplacementDoesNotBlockNextCall(t *testing.T) {
169 client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
170 if ev != protocol.EventToolBefore {
171 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
172 }
173 var call dispatch.ToolBeforePayload
174 if err := json.Unmarshal(payload, &call); err != nil {
175 return protocol.InterceptResult{}, err
176 }
177 if call.Name == "read_file" {
178 return replaceWith(t, dispatch.ToolBeforePayload{Name: "write_one", Arguments: `{"path":"first.go"}`}), nil
179 }
180 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
181 }}
182 var secondCalls int32
183 reg := tool.NewRegistry()
184 reg.Add(fakeTool{name: "read_file", readOnly: true})
185 reg.Add(fakeTool{name: "write_one", err: errors.New("partial write")})
186 reg.Add(fakeTool{name: "write_two", calls: &secondCalls})
187 a := New(nil, reg, NewSession(""), Options{Extensions: newExtDispatcher(client, true, nil, extension.PointToolBefore)}, event.Discard)
188 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
189 {ID: "first", Name: "read_file", Arguments: `{"path":"original.go"}`},
190 {ID: "second", Name: "write_two", Arguments: `{"path":"second.go"}`},
191 })
192 if secondCalls != 1 {
193 t.Fatalf("later writer executed %d times, want once", secondCalls)
194 }
195 if len(batch.results) != 2 || strings.Contains(batch.results[1], "skipped because an earlier modification") {
196 t.Fatalf("later call was dependency-blocked: %+v", batch.results)
197 }
198 }
199
200 func TestSubSinkForwardsWorkspaceMutationToParent(t *testing.T) {
201 parent := newWorkspaceSignalSink()
202 event.RecordWorkspaceMutation(subSinkFor("task_1", parent), event.WorkspaceMutation{
203 ToolID: "write", ToolName: "write_file", Paths: []string{"child.go"}, Content: true,
204 })
205 select {
206 case mutation := <-parent.mutations:
207 if mutation.ToolName != "write_file" || len(mutation.Paths) != 1 || mutation.Paths[0] != "child.go" {
208 t.Fatalf("forwarded workspace mutation = %+v", mutation)
209 }
210 default:
211 t.Fatal("sub-agent workspace mutation was not forwarded")
212 }
213 }
214
214 lines GO