返回 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 TestLegacyDependentSameBatchEditRefreshesPreviewBeforeExecution(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("read_file", "edit_file") {
39 reg.Add(tl)
40 }
41 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
42 {
43 toolCallChunk("r1", "read_file", `{"path":"task.txt","limit":1}`),
44 toolCallChunk("c1", "edit_file", `{"path":"task.txt","old_string":"draft","new_string":"ready"}`),
45 toolCallChunk("c2", "edit_file", `{"path":"task.txt","old_string":"ready","new_string":"done"}`),
46 {Type: provider.ChunkDone},
47 },
48 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
49 }}
50 var events []event.Event
51 a := New(prov, reg, NewSession(""), Options{}, event.FuncSink(func(e event.Event) {
52 events = append(events, e)
53 }))
54 if err := a.Run(withNoClosedLoop(context.Background()), "advance status twice"); err != nil {
55 t.Fatal(err)
56 }
57 data, err := os.ReadFile(path)
58 if err != nil {
59 t.Fatal(err)
60 }
61 if string(data) != "status=\"done\"\n" {
62 t.Fatalf("final file = %q", data)
63 }
64
65 var fullDispatches []event.Event
66 lastUpdatedDispatch := -1
67 secondResult := -1
68 for i, e := range events {
69 switch {
70 case e.Kind == event.ToolDispatch && !e.Tool.Partial && e.Tool.ID == "c2":
71 fullDispatches = append(fullDispatches, e)
72 if strings.Contains(e.Tool.Diff, `-status="ready"`) && strings.Contains(e.Tool.Diff, `+status="done"`) {
73 lastUpdatedDispatch = i
74 }
75 case e.Kind == event.ToolResult && e.Tool.ID == "c2":
76 secondResult = i
77 }
78 }
79 if len(fullDispatches) != 2 {
80 t.Fatalf("second edit full dispatches = %d, want initial plus refreshed", len(fullDispatches))
81 }
82 if fullDispatches[0].Tool.Diff != "" {
83 t.Fatalf("dependent edit should not be previewable against the batch's initial state:\n%s", fullDispatches[0].Tool.Diff)
84 }
85 if lastUpdatedDispatch < 0 {
86 t.Fatal("second edit never emitted a preview refreshed against the first edit")
87 }
88 if !fullDispatches[1].Tool.Refreshed {
89 t.Fatal("updated preview dispatch must be marked refreshed for append-only sinks")
90 }
91 if secondResult < 0 || lastUpdatedDispatch >= secondResult {
92 t.Fatalf("updated dispatch index %d must precede result index %d", lastUpdatedDispatch, secondResult)
93 }
94 if got := lastToolResult(a.sess.conversation, "edit_file"); !strings.Contains(got, "-ready") || !strings.Contains(got, "+done") {
95 t.Fatalf("second edit result did not ground the actual replacement:\n%s", got)
96 }
97 var archived provider.ToolCall
98 for _, msg := range a.sess.conversation.Snapshot() {
99 for _, call := range msg.ToolCalls {
100 if call.ID == "c2" {
101 archived = call
102 }
103 }
104 }
105 if !strings.Contains(archived.Diff, `-status="ready"`) || !strings.Contains(archived.Diff, `+status="done"`) {
106 t.Fatalf("session archived stale dependent preview:\n%s", archived.Diff)
107 }
108 if !a.sess.conversation.NeedsRewriteSave() {
109 t.Fatal("refreshing an already-appended assistant call must require a rewrite-safe snapshot")
110 }
111 }
112
113 func TestFailedWriterDoesNotSkipLaterReadAndEdit(t *testing.T) {
114 dir := t.TempDir()
115 path := filepath.Join(dir, "task.txt")
116 if err := os.WriteFile(path, []byte("status=\"draft\"\n"), 0o600); err != nil {
117 t.Fatal(err)
118 }
119 reg := tool.NewRegistry()
120 reg.Add(mutateThenFailTool{path: path})
121 for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file", "edit_file") {
122 reg.Add(tl)
123 }
124 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
125 {
126 toolCallChunk("c1", "mutate_then_fail", `{}`),
127 toolCallChunk("r1", "read_file", `{"path":"task.txt","limit":1}`),
128 toolCallChunk("c2", "edit_file", `{"path":"task.txt","old_string":"ready","new_string":"done"}`),
129 {Type: provider.ChunkDone},
130 },
131 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
132 }}
133 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
134 if err := a.Run(withNoClosedLoop(context.Background()), "run dependent edit after a partial failure"); err != nil {
135 t.Fatal(err)
136 }
137 data, err := os.ReadFile(path)
138 if err != nil {
139 t.Fatal(err)
140 }
141 if string(data) != "status=\"done\"\n" {
142 t.Fatalf("final file = %q, want later edit applied", data)
143 }
144 if got := toolResultByID(a.sess.conversation, "c2"); strings.Contains(got, "earlier modification") || !strings.Contains(got, "edited") {
145 t.Fatalf("second edit result = %q, want normal execution", got)
146 }
147 }
148
148 lines GO