返回 DeepSeek-Reasonix
dependent_writer_preview_test.go
根目录 / internal / agent / dependent_writer_preview_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 "reasonix/internal/tool/builtin"
16 )
17
18 type mutateThenFailTool struct{ path string }
19
20 func (m mutateThenFailTool) Name() string { return "mutate_then_fail" }
21 func (m mutateThenFailTool) Description() string { return "test writer that mutates before failing" }
22 func (m mutateThenFailTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
23 func (m mutateThenFailTool) ReadOnly() bool { return false }
24 func (m mutateThenFailTool) Execute(context.Context, json.RawMessage) (string, error) {
25 if err := os.WriteFile(m.path, []byte("status=\"ready\"\n"), 0o600); err != nil {
26 return "", err
27 }
28 return "", errors.New("simulated failure after write")
29 }
30
31 func TestDependentSameBatchEditRefreshesPreviewBeforeExecution(t *testing.T) {
32 dir := t.TempDir()
33 path := filepath.Join(dir, "task.txt")
34 if err := os.WriteFile(path, []byte("status=\"draft\"\n"), 0o600); err != nil {
35 t.Fatal(err)
36 }
37 reg := tool.NewRegistry()
38 for _, tl := range (builtin.Workspace{Dir: dir}).Tools("edit_file") {
39 reg.Add(tl)
40 }
41 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
42 {
43 toolCallChunk("c1", "edit_file", `{"path":"task.txt","old_string":"draft","new_string":"ready"}`),
44 toolCallChunk("c2", "edit_file", `{"path":"task.txt","old_string":"ready","new_string":"done"}`),
45 {Type: provider.ChunkDone},
46 },
47 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
48 }}
49 var events []event.Event
50 a := New(prov, reg, NewSession(""), Options{}, event.FuncSink(func(e event.Event) {
51 events = append(events, e)
52 }))
53 if err := a.Run(context.Background(), "advance status twice"); err != nil {
54 t.Fatal(err)
55 }
56 data, err := os.ReadFile(path)
57 if err != nil {
58 t.Fatal(err)
59 }
60 if string(data) != "status=\"done\"\n" {
61 t.Fatalf("final file = %q", data)
62 }
63
64 var fullDispatches []event.Event
65 lastUpdatedDispatch := -1
66 secondResult := -1
67 for i, e := range events {
68 switch {
69 case e.Kind == event.ToolDispatch && !e.Tool.Partial && e.Tool.ID == "c2":
70 fullDispatches = append(fullDispatches, e)
71 if strings.Contains(e.Tool.Diff, `-status="ready"`) && strings.Contains(e.Tool.Diff, `+status="done"`) {
72 lastUpdatedDispatch = i
73 }
74 case e.Kind == event.ToolResult && e.Tool.ID == "c2":
75 secondResult = i
76 }
77 }
78 if len(fullDispatches) != 2 {
79 t.Fatalf("second edit full dispatches = %d, want initial plus refreshed", len(fullDispatches))
80 }
81 if fullDispatches[0].Tool.Diff != "" {
82 t.Fatalf("dependent edit should not be previewable against the batch's initial state:\n%s", fullDispatches[0].Tool.Diff)
83 }
84 if lastUpdatedDispatch < 0 {
85 t.Fatal("second edit never emitted a preview refreshed against the first edit")
86 }
87 if !fullDispatches[1].Tool.Refreshed {
88 t.Fatal("updated preview dispatch must be marked refreshed for append-only sinks")
89 }
90 if secondResult < 0 || lastUpdatedDispatch >= secondResult {
91 t.Fatalf("updated dispatch index %d must precede result index %d", lastUpdatedDispatch, secondResult)
92 }
93 if got := lastToolResult(a.session, "edit_file"); !strings.Contains(got, "-ready") || !strings.Contains(got, "+done") {
94 t.Fatalf("second edit result did not ground the actual replacement:\n%s", got)
95 }
96 var archived provider.ToolCall
97 for _, msg := range a.session.Snapshot() {
98 for _, call := range msg.ToolCalls {
99 if call.ID == "c2" {
100 archived = call
101 }
102 }
103 }
104 if !strings.Contains(archived.Diff, `-status="ready"`) || !strings.Contains(archived.Diff, `+status="done"`) {
105 t.Fatalf("session archived stale dependent preview:\n%s", archived.Diff)
106 }
107 if !a.session.NeedsRewriteSave() {
108 t.Fatal("refreshing an already-appended assistant call must require a rewrite-safe snapshot")
109 }
110 }
111
112 func TestDependentMutationSkippedAfterFailedWriterInBatch(t *testing.T) {
113 // Shell execution contract: after any mutating call fails or is blocked,
114 // later mutations (and verifications) in the same provider batch are not
115 // executed. The first tool may still have written to disk; the second must
116 // return not_run/dependency rather than apply a follow-up edit.
117 dir := t.TempDir()
118 path := filepath.Join(dir, "task.txt")
119 if err := os.WriteFile(path, []byte("status=\"draft\"\n"), 0o600); err != nil {
120 t.Fatal(err)
121 }
122 reg := tool.NewRegistry()
123 reg.Add(mutateThenFailTool{path: path})
124 for _, tl := range (builtin.Workspace{Dir: dir}).Tools("edit_file") {
125 reg.Add(tl)
126 }
127 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
128 {
129 toolCallChunk("c1", "mutate_then_fail", `{}`),
130 toolCallChunk("c2", "edit_file", `{"path":"task.txt","old_string":"ready","new_string":"done"}`),
131 {Type: provider.ChunkDone},
132 },
133 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
134 }}
135 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
136 if err := a.Run(context.Background(), "run dependent edit after a partial failure"); err != nil {
137 t.Fatal(err)
138 }
139 data, err := os.ReadFile(path)
140 if err != nil {
141 t.Fatal(err)
142 }
143 // First tool wrote "ready" then failed; second edit must not run.
144 if string(data) != "status=\"ready\"\n" {
145 t.Fatalf("final file = %q, want partial first write preserved", data)
146 }
147 if got := toolResultByID(a.session, "c2"); !strings.Contains(got, "earlier modification") {
148 t.Fatalf("second edit result = %q, want dependency skip", got)
149 }
150 }
151
151 lines GO