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