返回 DeepSeek-Reasonix
read_result_envelope_test.go
根目录 / internal / agent / read_result_envelope_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "testing"
8
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 "reasonix/internal/tool"
12 )
13
14 // envelopeReader is a stand-in reader that reports a fixed delivery, so the
15 // wiring under test is the host's identity stamping and transport clipping.
16 type envelopeReader struct{ env tool.ReadResultEnvelope }
17
18 func (envelopeReader) Name() string { return "read_file" }
19 func (envelopeReader) Description() string { return "fake reader" }
20 func (envelopeReader) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
21 func (envelopeReader) ReadOnly() bool { return true }
22 func (envelopeReader) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
23 func (r envelopeReader) ReadEnvelope(context.Context, json.RawMessage, string) (tool.ReadResultEnvelope, bool) {
24 return r.env, true
25 }
26
27 type plainReader struct{}
28
29 func (plainReader) Name() string { return "read_file" }
30 func (plainReader) Description() string { return "plain reader" }
31 func (plainReader) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
32 func (plainReader) ReadOnly() bool { return true }
33 func (plainReader) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
34
35 func newEnvelopeTestAgent(t *testing.T, reader tool.Tool) (*Agent, *Session) {
36 t.Helper()
37 reg := tool.NewRegistry()
38 reg.Add(reader)
39 sess := NewSession("system")
40 a := New(&userInputCaptureProvider{}, reg, sess, Options{WorkspaceID: "ws-1"}, event.Discard)
41 a.reads.tasks = newReadTasks("test-session", 1)
42 return a, sess
43 }
44
45 func storedEnvelope(t *testing.T, sess *Session) tool.ReadResultEnvelope {
46 t.Helper()
47 stored := sess.Snapshot()
48 if len(stored) == 0 {
49 t.Fatal("no stored messages")
50 }
51 last := stored[len(stored)-1]
52 if len(last.ReadResult) == 0 {
53 t.Fatal("tool message carries no read envelope")
54 }
55 var env tool.ReadResultEnvelope
56 if err := json.Unmarshal(last.ReadResult, &env); err != nil {
57 t.Fatalf("envelope is not valid JSON: %v", err)
58 }
59 return env
60 }
61
62 func TestStoreBatchToolResultStampsReaderEnvelope(t *testing.T) {
63 reader := envelopeReader{env: tool.ReadResultEnvelope{
64 ProtocolVersion: tool.ReadResultProtocolVersion,
65 Source: tool.ReadResultSource{CanonicalPath: "/w/a.go", Snapshot: "ss2:abc"},
66 Intent: tool.ReadIntentInspect,
67 DeliveredRanges: []tool.ReadRange{{Start: 0, End: 2}},
68 EOF: true,
69 }}
70 a, sess := newEnvelopeTestAgent(t, reader)
71 sess.Add(a.buildBatchToolResult(context.Background(), provider.ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"a.go"}`}, toolOutcome{output: " 1→a\n 2→b\n"}))
72
73 env := storedEnvelope(t, sess)
74 if env.ReadID == "" || env.ResultRef == "" {
75 t.Fatalf("host must stamp read identity: %+v", env)
76 }
77 if env.Source.WorkspaceID != "ws-1" {
78 t.Fatalf("WorkspaceID = %q, want ws-1", env.Source.WorkspaceID)
79 }
80 if env.ProtocolVersion != tool.ReadResultProtocolVersion || env.TransportCut != tool.ReadCutNone {
81 t.Fatalf("untruncated delivery must stay uncut: %+v", env)
82 }
83 }
84
85 func TestStoreBatchToolResultClipsEnvelopeToVisibleBytes(t *testing.T) {
86 reader := envelopeReader{env: tool.ReadResultEnvelope{
87 ProtocolVersion: tool.ReadResultProtocolVersion,
88 Source: tool.ReadResultSource{CanonicalPath: "/w/a.go", Snapshot: "ss2:abc"},
89 DeliveredRanges: []tool.ReadRange{{Start: 0, End: 2}},
90 EOF: true,
91 }}
92 a, sess := newEnvelopeTestAgent(t, reader)
93 sess.Add(a.buildBatchToolResult(context.Background(),
94 provider.ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"a.go"}`},
95 toolOutcome{output: " 1→a\n", rawOutput: " 1→a\n 2→b\n", truncated: true},
96 ))
97
98 env := storedEnvelope(t, sess)
99 if env.TransportCut != tool.ReadCutToolOutput {
100 t.Fatalf("TransportCut = %q, want %q", env.TransportCut, tool.ReadCutToolOutput)
101 }
102 if got := env.DeliveredRanges; len(got) != 1 || got[0] != (tool.ReadRange{Start: 0, End: 1}) {
103 t.Fatalf("DeliveredRanges = %+v, want only the visible line", got)
104 }
105 if !env.HasMore || env.EOF {
106 t.Fatalf("clipped delivery must be resumable: has_more=%v eof=%v", env.HasMore, env.EOF)
107 }
108 }
109
110 func TestStoreBatchToolResultOmitsEnvelopeForPlainReaders(t *testing.T) {
111 a, sess := newEnvelopeTestAgent(t, plainReader{})
112 sess.Add(a.buildBatchToolResult(context.Background(), provider.ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"a.go"}`}, toolOutcome{output: " 1→a\n"}))
113
114 stored := sess.Snapshot()
115 if len(stored) == 0 {
116 t.Fatal("no stored messages")
117 }
118 if len(stored[len(stored)-1].ReadResult) != 0 {
119 t.Fatal("a reader that cannot describe its delivery must not get a fabricated envelope")
120 }
121 }
122
123 func TestReadContinuationCursorJoinsTheLogicalRead(t *testing.T) {
124 a, _ := newEnvelopeTestAgent(t, envelopeReader{})
125 path := filepath.Join(t.TempDir(), "a.go")
126 a.reads.tasks.remember("ir-1", tool.ReadResultEnvelope{
127 Source: tool.ReadResultSource{CanonicalPath: path, Snapshot: "ss2:abc"},
128 })
129 cursor := tool.EncodeReadCursor(tool.ReadCursor{
130 Path: path, ReadID: "ir-1", Snapshot: "ss2:abc",
131 NextStart: 5, RequestEnd: 10, SessionID: "test-session", RunGen: 1,
132 Binding: a.reads.tasks.binding,
133 })
134 a.reads.tasks.remember("ir-1", tool.ReadResultEnvelope{Source: tool.ReadResultSource{CanonicalPath: path, Snapshot: "ss2:abc"}, NextCursor: cursor})
135 argsJSON, err := json.Marshal(map[string]string{"path": path, "cursor": cursor})
136 if err != nil {
137 t.Fatal(err)
138 }
139 plan := &toolCallPlan{execArgs: argsJSON}
140
141 if out, blocked := a.resolveReadCursor(plan); blocked {
142 t.Fatalf("a valid continuation cursor was rejected: %+v", out)
143 }
144 if plan.readTaskID != "ir-1" {
145 t.Fatalf("readTaskID = %q, want the continued read ir-1", plan.readTaskID)
146 }
147 var args map[string]any
148 if err := json.Unmarshal(plan.execArgs, &args); err != nil {
149 t.Fatal(err)
150 }
151 if _, present := args["cursor"]; present {
152 t.Fatal("the cursor must be consumed, not forwarded to the reader")
153 }
154 if args["offset"] != float64(5) || args["limit"] != float64(5) {
155 t.Fatalf("rewritten args = %v, want offset 5 limit 5", args)
156 }
157 }
158
159 func TestReadContinuationCursorRejections(t *testing.T) {
160 cases := []struct {
161 name string
162 change func(*tool.ReadCursor)
163 }{
164 {"malformed", nil},
165 {"unknown read task", func(c *tool.ReadCursor) { c.ReadID = "ir-other" }},
166 {"other session", func(c *tool.ReadCursor) { c.SessionID = "someone-else" }},
167 {"earlier run", func(c *tool.ReadCursor) { c.RunGen = 99 }},
168 {"other file", func(c *tool.ReadCursor) { c.Path = filepath.Join(filepath.Dir(c.Path), "b.go") }},
169 {"changed content", func(c *tool.ReadCursor) { c.Snapshot = "ss2:zzz" }},
170 }
171 for _, tc := range cases {
172 t.Run(tc.name, func(t *testing.T) {
173 a, _ := newEnvelopeTestAgent(t, envelopeReader{})
174 path := filepath.Join(t.TempDir(), "a.go")
175 cursor := tool.ReadCursor{Path: path, ReadID: "ir-1", Snapshot: "ss2:abc", NextStart: 5, RequestEnd: 10, SessionID: "test-session", RunGen: 1, Binding: a.reads.tasks.binding}
176 a.reads.tasks.remember("ir-1", tool.ReadResultEnvelope{
177 Source: tool.ReadResultSource{CanonicalPath: path, Snapshot: "ss2:abc"}, NextCursor: tool.EncodeReadCursor(cursor),
178 })
179 newPlan := func(token string) *toolCallPlan {
180 args, err := json.Marshal(map[string]string{"path": path, "cursor": token})
181 if err != nil {
182 t.Fatal(err)
183 }
184 return &toolCallPlan{execArgs: args}
185 }
186 if out, blocked := a.resolveReadCursor(newPlan(tool.EncodeReadCursor(cursor))); blocked {
187 t.Fatalf("unmodified cursor must be accepted before testing rejection: %+v", out)
188 }
189 token := "rc2:!!!"
190 if tc.change != nil {
191 tc.change(&cursor)
192 token = tool.EncodeReadCursor(cursor)
193 }
194 plan := newPlan(token)
195 out, blocked := a.resolveReadCursor(plan)
196 if !blocked || !out.blocked {
197 t.Fatalf("cursor %q must be rejected, got %+v (blocked=%v)", token, out, blocked)
198 }
199 if plan.readTaskID != "" {
200 t.Fatalf("a rejected cursor must not select a read task, got %q", plan.readTaskID)
201 }
202 })
203 }
204 }
205
206 func TestReadContinuationCursorAbsentIsNotABlock(t *testing.T) {
207 a, _ := newEnvelopeTestAgent(t, envelopeReader{})
208 plan := &toolCallPlan{execArgs: json.RawMessage(`{"path":"/w/a.go"}`)}
209 if _, blocked := a.resolveReadCursor(plan); blocked {
210 t.Fatal("a plain read must not be treated as a continuation")
211 }
212 }
213
214 // TestModelInputMessagesStripsReadResult guards the single provider boundary:
215 // every request path goes through modelInputMessages, so no host envelope may
216 // survive it.
217 func TestModelInputMessagesStripsReadResult(t *testing.T) {
218 msgs := []provider.Message{
219 {Role: provider.RoleUser, Content: "look"},
220 {Role: provider.RoleTool, ToolCallID: "c1", Name: "read_file", Content: " 1→a\n",
221 ReadResult: json.RawMessage(`{"protocol_version":2,"read_id":"ir-1"}`)},
222 }
223 out := modelInputMessages(msgs)
224 for i, msg := range out {
225 if len(msg.ReadResult) != 0 {
226 t.Fatalf("message %d leaked the read envelope into provider input: %s", i, msg.ReadResult)
227 }
228 }
229 }
230
230 lines GO