| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "unicode/utf8" |
| 13 | |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/provider" |
| 16 | "reasonix/internal/tool" |
| 17 | ) |
| 18 | |
| 19 | type toolResultPageHeader struct { |
| 20 | ResultRef string `json:"result_ref"` |
| 21 | Offset int `json:"offset"` |
| 22 | NextOffset int `json:"next_offset"` |
| 23 | TotalBytes int `json:"total_bytes"` |
| 24 | SHA256 string `json:"sha256"` |
| 25 | Complete bool `json:"complete"` |
| 26 | } |
| 27 | |
| 28 | func executeToolResultPage(t *testing.T, proxy tool.Tool, callID, ref string, offset, limit int) (toolResultPageHeader, string, error) { |
| 29 | t.Helper() |
| 30 | args, _ := json.Marshal(map[string]any{ |
| 31 | "action": "call", "capability_id": sessionToolResultCapabilityID, |
| 32 | "arguments": map[string]any{"tool_call_id": callID, "result_ref": ref, "offset": offset, "limit": limit}, |
| 33 | }) |
| 34 | out, err := proxy.Execute(context.Background(), args) |
| 35 | if err != nil { |
| 36 | return toolResultPageHeader{}, "", err |
| 37 | } |
| 38 | headerText, body, ok := strings.Cut(out, "\n") |
| 39 | if !ok { |
| 40 | t.Fatalf("page result has no metadata header: %.200q", out) |
| 41 | } |
| 42 | var header toolResultPageHeader |
| 43 | if err := json.Unmarshal([]byte(headerText), &header); err != nil { |
| 44 | t.Fatalf("decode page header %q: %v", headerText, err) |
| 45 | } |
| 46 | return header, body, nil |
| 47 | } |
| 48 | |
| 49 | func newToolResultCapabilityAgent(t *testing.T, session *Session) (*Agent, *UseCapabilityTool) { |
| 50 | t.Helper() |
| 51 | reg := tool.NewRegistry() |
| 52 | proxy := NewUseCapabilityTool(context.Background(), nil, nil, reg, nil, nil, nil) |
| 53 | reg.Add(proxy) |
| 54 | a := New(nil, reg, session, Options{}, event.Discard) |
| 55 | return a, proxy |
| 56 | } |
| 57 | |
| 58 | func TestSessionToolResultPagesReconstructCompleteUTF8Output(t *testing.T) { |
| 59 | full := strings.Repeat("ASCII-界-🧪\n", 6000) |
| 60 | bounded, notice := truncateToolOutputFor(full, "bash", "call-1") |
| 61 | if notice == "" || len(bounded) > maxToolOutputBytes { |
| 62 | t.Fatalf("fixture was not bounded: bytes=%d notice=%q", len(bounded), notice) |
| 63 | } |
| 64 | session := &Session{Messages: []provider.Message{{ |
| 65 | Role: provider.RoleTool, Name: "bash", ToolCallID: "call-1", Content: bounded, RawContent: full, |
| 66 | }}} |
| 67 | _, proxy := newToolResultCapabilityAgent(t, session) |
| 68 | ref := toolResultRef("call-1", full) |
| 69 | if _, _, err := executeToolResultPage(t, proxy, "call-1", "", 0, 128); err == nil || !strings.Contains(err.Error(), "result_ref is required") { |
| 70 | t.Fatalf("new truncated result accepted no result_ref: %v", err) |
| 71 | } |
| 72 | |
| 73 | var rebuilt strings.Builder |
| 74 | offset := 0 |
| 75 | for pages := 0; ; pages++ { |
| 76 | if pages > 20 { |
| 77 | t.Fatal("pagination did not terminate") |
| 78 | } |
| 79 | header, page, err := executeToolResultPage(t, proxy, "call-1", ref, offset, 0) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | if header.Offset != offset || header.ResultRef != ref || header.TotalBytes != len(full) || header.SHA256 == "" { |
| 84 | t.Fatalf("unexpected page header: %+v", header) |
| 85 | } |
| 86 | if len(page) > toolResultPageDefaultBytes || len(page)+len(toolResultMustJSON(t, header))+1 > maxToolOutputBytes { |
| 87 | t.Fatalf("page exceeded bounded output: page=%d", len(page)) |
| 88 | } |
| 89 | rebuilt.WriteString(page) |
| 90 | offset = header.NextOffset |
| 91 | if header.Complete { |
| 92 | break |
| 93 | } |
| 94 | } |
| 95 | if got := rebuilt.String(); got != full { |
| 96 | t.Fatalf("rebuilt result differs: got=%d want=%d", len(got), len(full)) |
| 97 | } |
| 98 | |
| 99 | header, page, err := executeToolResultPage(t, proxy, "call-1", ref, len(full), 1024) |
| 100 | if err != nil || !header.Complete || page != "" || header.NextOffset != len(full) { |
| 101 | t.Fatalf("terminal page = header=%+v page=%q err=%v", header, page, err) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestToolResultProviderBoundaryAndRecoveryMarker(t *testing.T) { |
| 106 | tests := []struct { |
| 107 | name string |
| 108 | body string |
| 109 | }{ |
| 110 | {name: "below", body: strings.Repeat("a", maxToolOutputBytes-1)}, |
| 111 | {name: "equal", body: strings.Repeat("a", maxToolOutputBytes)}, |
| 112 | {name: "ascii", body: strings.Repeat("a", maxToolOutputBytes+1)}, |
| 113 | {name: "chinese", body: strings.Repeat("界", maxToolOutputBytes)}, |
| 114 | {name: "emoji", body: strings.Repeat("🧪", maxToolOutputBytes)}, |
| 115 | {name: "error-tail", body: strings.Repeat("trace\n", 9000) + "fatal: unique error tail"}, |
| 116 | } |
| 117 | for _, tc := range tests { |
| 118 | t.Run(tc.name, func(t *testing.T) { |
| 119 | callID := strings.Repeat("调用-🧪", 100) |
| 120 | got, notice := truncateToolOutputFor(tc.body, strings.Repeat("tool-界", 100), callID) |
| 121 | if len(tc.body) <= maxToolOutputBytes { |
| 122 | if got != tc.body || notice != "" { |
| 123 | t.Fatalf("under-bound result changed: bytes=%d notice=%q", len(got), notice) |
| 124 | } |
| 125 | return |
| 126 | } |
| 127 | if len(got) > maxToolOutputBytes || !utf8.ValidString(got) { |
| 128 | t.Fatalf("bounded result invalid: bytes=%d utf8=%v", len(got), utf8.ValidString(got)) |
| 129 | } |
| 130 | ref := toolResultRef(callID, tc.body) |
| 131 | for _, want := range []string{"result_ref=" + ref, "original_bytes=", "kept_bytes=", "session:tool_result", "use_capability", "narrower arguments"} { |
| 132 | if !strings.Contains(got, want) { |
| 133 | t.Fatalf("recovery marker missing %q: %.1000s", want, got) |
| 134 | } |
| 135 | } |
| 136 | if tc.name == "error-tail" && !strings.Contains(got, "fatal: unique error tail") { |
| 137 | t.Fatal("error-oriented truncation lost the diagnostic tail") |
| 138 | } |
| 139 | }) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func toolResultMustJSON(t *testing.T, value any) []byte { |
| 144 | t.Helper() |
| 145 | b, err := json.Marshal(value) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | return b |
| 150 | } |
| 151 | |
| 152 | func TestSessionToolResultRequiresExactRefForDuplicateCallID(t *testing.T) { |
| 153 | first := strings.Repeat("first", 8000) |
| 154 | second := strings.Repeat("second", 8000) |
| 155 | session := &Session{Messages: []provider.Message{ |
| 156 | {Role: provider.RoleTool, Name: "bash", ToolCallID: "repeat", Content: "bounded-1", RawContent: first}, |
| 157 | {Role: provider.RoleTool, Name: "bash", ToolCallID: "repeat", Content: "bounded-2", RawContent: second}, |
| 158 | }} |
| 159 | _, proxy := newToolResultCapabilityAgent(t, session) |
| 160 | |
| 161 | if _, _, err := executeToolResultPage(t, proxy, "repeat", "", 0, 128); err == nil || !strings.Contains(err.Error(), "ambiguous") || !strings.Contains(err.Error(), toolResultRef("repeat", first)) { |
| 162 | t.Fatalf("missing deterministic ambiguity error: %v", err) |
| 163 | } |
| 164 | _, page, err := executeToolResultPage(t, proxy, "repeat", toolResultRef("repeat", first), 0, 128) |
| 165 | if err != nil || page != first[:128] { |
| 166 | t.Fatalf("exact ref selected wrong duplicate: page=%q err=%v", page, err) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestSessionToolResultValidatesLegacyAndPagingErrors(t *testing.T) { |
| 171 | fullLost := strings.Repeat("lost-new-result", 3000) |
| 172 | boundedLost, notice := truncateToolOutputFor(fullLost, "read_file", "lost-new") |
| 173 | if notice == "" { |
| 174 | t.Fatal("new lost-result fixture was not truncated") |
| 175 | } |
| 176 | session := &Session{Messages: []provider.Message{ |
| 177 | {Role: provider.RoleTool, Name: "read_file", ToolCallID: "complete", Content: "完整结果"}, |
| 178 | {Role: provider.RoleTool, Name: "read_file", ToolCallID: "lost", Content: "…[truncated tool=read_file call_id=lost]…"}, |
| 179 | {Role: provider.RoleTool, Name: "read_file", ToolCallID: "lost-new", Content: boundedLost}, |
| 180 | }} |
| 181 | _, proxy := newToolResultCapabilityAgent(t, session) |
| 182 | |
| 183 | _, page, err := executeToolResultPage(t, proxy, "complete", "", 0, 1024) |
| 184 | if err != nil || page != "完整结果" { |
| 185 | t.Fatalf("legacy complete result = %q err=%v", page, err) |
| 186 | } |
| 187 | if _, _, err := executeToolResultPage(t, proxy, "lost", "", 0, 1024); err == nil || !strings.Contains(err.Error(), "full result is unavailable") { |
| 188 | t.Fatalf("legacy truncated result error = %v", err) |
| 189 | } |
| 190 | fullLostRef := toolResultRef("lost-new", fullLost) |
| 191 | if _, _, err := executeToolResultPage(t, proxy, "lost-new", "", 0, 1024); err == nil || !strings.Contains(err.Error(), fullLostRef) { |
| 192 | t.Fatalf("new truncated result did not request its marker ref: %v", err) |
| 193 | } |
| 194 | if _, _, err := executeToolResultPage(t, proxy, "lost-new", fullLostRef, 0, 1024); err == nil || !strings.Contains(err.Error(), "full result is unavailable") { |
| 195 | t.Fatalf("new truncated result with missing RawContent error = %v", err) |
| 196 | } |
| 197 | if _, _, err := executeToolResultPage(t, proxy, "complete", "", 1, 1024); err == nil || !strings.Contains(err.Error(), "UTF-8 character boundary") { |
| 198 | t.Fatalf("invalid UTF-8 offset error = %v", err) |
| 199 | } |
| 200 | if _, _, err := executeToolResultPage(t, proxy, "complete", "", 0, toolResultPageMaxBytes+1); err == nil || !strings.Contains(err.Error(), "limit must be") { |
| 201 | t.Fatalf("invalid limit error = %v", err) |
| 202 | } |
| 203 | if _, _, err := executeToolResultPage(t, proxy, "missing", "", 0, 10); err == nil || !strings.Contains(err.Error(), "was not found") { |
| 204 | t.Fatalf("missing result error = %v", err) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestSessionToolResultRejectsSubagentReferences(t *testing.T) { |
| 209 | _, proxy := newToolResultCapabilityAgent(t, &Session{}) |
| 210 | if _, _, err := executeToolResultPage(t, proxy, "sa_child", "", 0, 32); err == nil || !strings.Contains(err.Error(), "read_subagent_result") { |
| 211 | t.Fatalf("subagent tool call id error = %v, want dedicated reader guidance", err) |
| 212 | } |
| 213 | if _, _, err := executeToolResultPage(t, proxy, "tool-call", "sa_child", 0, 32); err == nil || !strings.Contains(err.Error(), "read_subagent_result") { |
| 214 | t.Fatalf("subagent result ref error = %v, want dedicated reader guidance", err) |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | func TestSessionToolResultBindingTracksSetSessionAndCloneIsIsolated(t *testing.T) { |
| 219 | parent := &Session{Messages: []provider.Message{{Role: provider.RoleTool, ToolCallID: "call", Name: "bash", Content: "parent"}}} |
| 220 | a, proxy := newToolResultCapabilityAgent(t, parent) |
| 221 | clone := proxy.CloneForAgent(nil, nil) |
| 222 | if clone.currentToolResultTarget() != nil { |
| 223 | t.Fatal("CloneForAgent inherited the parent session reader") |
| 224 | } |
| 225 | childRegistry := tool.NewRegistry() |
| 226 | childRegistry.Add(clone) |
| 227 | _ = New(nil, childRegistry, &Session{Messages: []provider.Message{{Role: provider.RoleTool, ToolCallID: "call", Name: "bash", Content: "child"}}}, Options{}, event.Discard) |
| 228 | |
| 229 | _, page, err := executeToolResultPage(t, proxy, "call", "", 0, 32) |
| 230 | if err != nil || page != "parent" { |
| 231 | t.Fatalf("parent page=%q err=%v", page, err) |
| 232 | } |
| 233 | a.SetSession(&Session{Messages: []provider.Message{{Role: provider.RoleTool, ToolCallID: "call", Name: "bash", Content: "replacement"}}}) |
| 234 | _, page, err = executeToolResultPage(t, proxy, "call", "", 0, 32) |
| 235 | if err != nil || page != "replacement" { |
| 236 | t.Fatalf("replacement page=%q err=%v", page, err) |
| 237 | } |
| 238 | _, page, err = executeToolResultPage(t, clone, "call", "", 0, 32) |
| 239 | if err != nil || page != "child" { |
| 240 | t.Fatalf("child page=%q err=%v", page, err) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | func TestSessionToolResultBindingDoesNotChangeUseCapabilitySchema(t *testing.T) { |
| 245 | reg := tool.NewRegistry() |
| 246 | proxy := NewUseCapabilityTool(context.Background(), nil, nil, reg, nil, nil, nil) |
| 247 | reg.Add(proxy) |
| 248 | beforeName, beforeDescription := proxy.Name(), proxy.Description() |
| 249 | beforeSchema := append([]byte(nil), proxy.Schema()...) |
| 250 | beforeProviderSchemas := reg.Schemas() |
| 251 | |
| 252 | _ = New(nil, reg, NewSession("system"), Options{}, event.Discard) |
| 253 | if proxy.Name() != beforeName || proxy.Description() != beforeDescription || !reflect.DeepEqual(proxy.Schema(), json.RawMessage(beforeSchema)) { |
| 254 | t.Fatal("session reader binding changed the stable use_capability contract") |
| 255 | } |
| 256 | if !reflect.DeepEqual(reg.Schemas(), beforeProviderSchemas) { |
| 257 | t.Fatal("session reader binding changed provider tool schemas") |
| 258 | } |
| 259 | list, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"list"}`)) |
| 260 | if err != nil || !strings.Contains(list, sessionToolResultCapabilityID) { |
| 261 | t.Fatalf("bound capability missing from list: err=%v list=%s", err, list) |
| 262 | } |
| 263 | inspectArgs := json.RawMessage(`{"action":"inspect","capability_id":"session:tool_result"}`) |
| 264 | inspect, err := proxy.Execute(context.Background(), inspectArgs) |
| 265 | if err != nil || !strings.Contains(inspect, `"limit_max": 24576`) { |
| 266 | t.Fatalf("inspect result: err=%v out=%s", err, inspect) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | func TestRestrictedCapabilityProxyListsAndReadsOnlyOwnToolResults(t *testing.T) { |
| 271 | inner := NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), nil, nil, nil) |
| 272 | resolver := tool.CallResolver(inner) |
| 273 | proxy := &restrictedCapabilityProxy{ |
| 274 | Tool: inner, resolver: resolver, |
| 275 | allowed: map[string]bool{"mcp-server:allowed": true}, servers: map[string]bool{"allowed": true}, |
| 276 | } |
| 277 | reg := tool.NewRegistry() |
| 278 | reg.Add(proxy) |
| 279 | _ = New(nil, reg, &Session{Messages: []provider.Message{{Role: provider.RoleTool, Name: "read", ToolCallID: "own", Content: "own result"}}}, Options{}, event.Discard) |
| 280 | |
| 281 | list, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"list"}`)) |
| 282 | if err != nil || !strings.Contains(list, sessionToolResultCapabilityID) { |
| 283 | t.Fatalf("restricted list omitted session capability: err=%v list=%s", err, list) |
| 284 | } |
| 285 | args := json.RawMessage(`{"action":"call","capability_id":"session:tool_result","arguments":{"tool_call_id":"own"}}`) |
| 286 | out, err := proxy.Execute(context.Background(), args) |
| 287 | if err != nil || !strings.HasSuffix(out, "\nown result") { |
| 288 | t.Fatalf("restricted self read: err=%v out=%q", err, out) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func TestRestrictedCapabilityFrontendCloneKeepsAgentSessionsIsolated(t *testing.T) { |
| 293 | parentInner := NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), nil, nil, nil) |
| 294 | parentProxy := &restrictedCapabilityProxy{ |
| 295 | Tool: parentInner, resolver: parentInner, |
| 296 | allowed: map[string]bool{"mcp-server:allowed": true}, servers: map[string]bool{"allowed": true}, |
| 297 | } |
| 298 | parentRegistry := tool.NewRegistry() |
| 299 | parentRegistry.Add(parentProxy) |
| 300 | _ = New(nil, parentRegistry, &Session{Messages: []provider.Message{{ |
| 301 | Role: provider.RoleTool, ToolCallID: "call", Name: "read", Content: "parent", |
| 302 | }}}, Options{}, event.Discard) |
| 303 | |
| 304 | childTool := newSubagentCapabilityFrontend(parentRegistry, nil) |
| 305 | childProxy, ok := childTool.(*restrictedCapabilityProxy) |
| 306 | if !ok { |
| 307 | t.Fatalf("child frontend type = %T, want *restrictedCapabilityProxy", childTool) |
| 308 | } |
| 309 | if childProxy == parentProxy || childProxy.Tool == parentProxy.Tool { |
| 310 | t.Fatal("child restricted frontend shares the parent's session-bindable proxy") |
| 311 | } |
| 312 | childRegistry := tool.NewRegistry() |
| 313 | childRegistry.Add(childProxy) |
| 314 | _ = New(nil, childRegistry, &Session{Messages: []provider.Message{{ |
| 315 | Role: provider.RoleTool, ToolCallID: "call", Name: "read", Content: "child", |
| 316 | }}}, Options{}, event.Discard) |
| 317 | |
| 318 | _, parentPage, parentErr := executeToolResultPage(t, parentProxy, "call", "", 0, 32) |
| 319 | _, childPage, childErr := executeToolResultPage(t, childProxy, "call", "", 0, 32) |
| 320 | if parentErr != nil || parentPage != "parent" { |
| 321 | t.Fatalf("parent page=%q err=%v", parentPage, parentErr) |
| 322 | } |
| 323 | if childErr != nil || childPage != "child" { |
| 324 | t.Fatalf("child page=%q err=%v", childPage, childErr) |
| 325 | } |
| 326 | childProxy.allowed["mcp-server:child-only"] = true |
| 327 | if parentProxy.allowed["mcp-server:child-only"] { |
| 328 | t.Fatal("child clone shares the parent's mutable capability allowlist") |
| 329 | } |
| 330 | beforeRegistry := tool.NewRegistry() |
| 331 | beforeRegistry.Add(parentProxy) |
| 332 | afterRegistry := tool.NewRegistry() |
| 333 | afterRegistry.Add(childProxy) |
| 334 | if parentProxy.Name() != childProxy.Name() || parentProxy.Description() != childProxy.Description() || |
| 335 | !reflect.DeepEqual(parentProxy.Schema(), childProxy.Schema()) || |
| 336 | !reflect.DeepEqual(beforeRegistry.Schemas(), afterRegistry.Schemas()) { |
| 337 | t.Fatal("cloning or binding changed provider-visible use_capability bytes") |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | func TestPathBoundCapabilityFrontendBindsAndClonesAgentSessions(t *testing.T) { |
| 342 | for _, restricted := range []bool{false, true} { |
| 343 | name := "unrestricted" |
| 344 | if restricted { |
| 345 | name = "restricted" |
| 346 | } |
| 347 | t.Run(name, func(t *testing.T) { |
| 348 | root := t.TempDir() |
| 349 | claim, err := NormalizeWritePaths(root, []string{"frontend"}) |
| 350 | if err != nil { |
| 351 | t.Fatal(err) |
| 352 | } |
| 353 | baseRegistry := tool.NewRegistry() |
| 354 | inner := NewUseCapabilityTool(context.Background(), nil, nil, baseRegistry, nil, nil, nil) |
| 355 | var frontend tool.Tool = inner |
| 356 | if restricted { |
| 357 | frontend = &restrictedCapabilityProxy{ |
| 358 | Tool: inner, resolver: inner, |
| 359 | allowed: map[string]bool{"mcp-server:allowed": true}, servers: map[string]bool{"allowed": true}, |
| 360 | } |
| 361 | } |
| 362 | baseRegistry.Add(frontend) |
| 363 | parentRegistry, removed := BindWritePaths(baseRegistry, claim, root, false) |
| 364 | if len(removed) != 0 { |
| 365 | t.Fatalf("path-bound registry removed tools: %v", removed) |
| 366 | } |
| 367 | parentTool, ok := parentRegistry.Get("use_capability") |
| 368 | if !ok { |
| 369 | t.Fatal("path-bound registry missing use_capability") |
| 370 | } |
| 371 | _ = New(nil, parentRegistry, &Session{Messages: []provider.Message{{ |
| 372 | Role: provider.RoleTool, ToolCallID: "call", Name: "read", Content: "parent", |
| 373 | }}}, Options{}, event.Discard) |
| 374 | |
| 375 | childTool := newSubagentCapabilityFrontend(parentRegistry, nil) |
| 376 | if childTool == nil { |
| 377 | t.Fatal("path-bound child frontend was dropped during clone") |
| 378 | } |
| 379 | childRegistry := tool.NewRegistry() |
| 380 | childRegistry.Add(childTool) |
| 381 | _ = New(nil, childRegistry, &Session{Messages: []provider.Message{{ |
| 382 | Role: provider.RoleTool, ToolCallID: "call", Name: "read", Content: "child", |
| 383 | }}}, Options{}, event.Discard) |
| 384 | |
| 385 | _, parentPage, parentErr := executeToolResultPage(t, parentTool, "call", "", 0, 32) |
| 386 | _, childPage, childErr := executeToolResultPage(t, childTool, "call", "", 0, 32) |
| 387 | if parentErr != nil || parentPage != "parent" { |
| 388 | t.Fatalf("path-bound parent page=%q err=%v", parentPage, parentErr) |
| 389 | } |
| 390 | if childErr != nil || childPage != "child" { |
| 391 | t.Fatalf("path-bound child page=%q err=%v", childPage, childErr) |
| 392 | } |
| 393 | if parentTool.Name() != childTool.Name() || parentTool.Description() != childTool.Description() || |
| 394 | !reflect.DeepEqual(parentTool.Schema(), childTool.Schema()) || |
| 395 | !reflect.DeepEqual(parentRegistry.Schemas(), childRegistry.Schemas()) { |
| 396 | t.Fatal("path-bound cloning or binding changed provider-visible use_capability bytes") |
| 397 | } |
| 398 | }) |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | func TestPlannerRestrictedCapabilityFrontendCloneKeepsExecutorSession(t *testing.T) { |
| 403 | inner := NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), nil, nil, nil) |
| 404 | executorProxy := &restrictedCapabilityProxy{ |
| 405 | Tool: inner, resolver: inner, |
| 406 | allowed: map[string]bool{"mcp-server:allowed": true}, servers: map[string]bool{"allowed": true}, |
| 407 | } |
| 408 | parent := tool.NewRegistry() |
| 409 | parent.Add(executorProxy) |
| 410 | _ = New(nil, parent, &Session{Messages: []provider.Message{{ |
| 411 | Role: provider.RoleTool, ToolCallID: "call", Name: "read", Content: "executor", |
| 412 | }}}, Options{}, event.Discard) |
| 413 | |
| 414 | plannerRegistry := PlannerToolRegistry(parent) |
| 415 | plannerTool, ok := plannerRegistry.Get("use_capability") |
| 416 | if !ok { |
| 417 | t.Fatal("planner missing use_capability") |
| 418 | } |
| 419 | plannerProxy, ok := plannerTool.(*restrictedCapabilityProxy) |
| 420 | if !ok || plannerProxy == executorProxy || plannerProxy.Tool == executorProxy.Tool { |
| 421 | t.Fatalf("planner frontend was not deeply cloned: %T", plannerTool) |
| 422 | } |
| 423 | _ = New(nil, plannerRegistry, &Session{Messages: []provider.Message{{ |
| 424 | Role: provider.RoleTool, ToolCallID: "call", Name: "read", Content: "planner", |
| 425 | }}}, Options{}, event.Discard) |
| 426 | |
| 427 | _, executorPage, executorErr := executeToolResultPage(t, executorProxy, "call", "", 0, 32) |
| 428 | _, plannerPage, plannerErr := executeToolResultPage(t, plannerProxy, "call", "", 0, 32) |
| 429 | if executorErr != nil || executorPage != "executor" { |
| 430 | t.Fatalf("executor page=%q err=%v", executorPage, executorErr) |
| 431 | } |
| 432 | if plannerErr != nil || plannerPage != "planner" { |
| 433 | t.Fatalf("planner page=%q err=%v", plannerPage, plannerErr) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | func TestProviderRequestsNeverUploadToolRawContent(t *testing.T) { |
| 438 | const rawSentinel = "RAW-SENTINEL-MUST-STAY-LOCAL" |
| 439 | msgs := []provider.Message{ |
| 440 | {Role: provider.RoleSystem, Content: "system"}, |
| 441 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read", Arguments: `{}`}}}, |
| 442 | {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read", Content: "bounded", RawContent: rawSentinel}, |
| 443 | } |
| 444 | a := New(nil, tool.NewRegistry(), &Session{Messages: msgs}, Options{}, event.Discard) |
| 445 | req := a.summaryRequest(msgs, "") |
| 446 | b, err := json.Marshal(req) |
| 447 | if err != nil { |
| 448 | t.Fatal(err) |
| 449 | } |
| 450 | if strings.Contains(string(b), rawSentinel) || !strings.Contains(string(b), "bounded") { |
| 451 | t.Fatalf("summary request leaked RawContent: %s", b) |
| 452 | } |
| 453 | projected := buildVisibleCompressionProjection(msgs, visibleCompressionPlan{foldMask: make([]bool, len(msgs)), firstFold: -1}, "") |
| 454 | b, _ = json.Marshal(projected) |
| 455 | if strings.Contains(string(b), rawSentinel) || !strings.Contains(string(b), "bounded") { |
| 456 | t.Fatalf("projection leaked RawContent: %s", b) |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | func TestSessionToolResultRejectsPageThatSplitsUTF8Rune(t *testing.T) { |
| 461 | session := &Session{Messages: []provider.Message{{Role: provider.RoleTool, Name: "read", ToolCallID: "utf8", Content: "界"}}} |
| 462 | _, proxy := newToolResultCapabilityAgent(t, session) |
| 463 | if _, _, err := executeToolResultPage(t, proxy, "utf8", "", 0, 1); err == nil || !strings.Contains(err.Error(), "increase limit") { |
| 464 | t.Fatalf("split-rune limit error = %v", err) |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | func TestSessionToolResultSHA256MatchesCompleteBody(t *testing.T) { |
| 469 | body := strings.Repeat("hash-界", 100) |
| 470 | session := &Session{Messages: []provider.Message{{Role: provider.RoleTool, Name: "read", ToolCallID: "hash", Content: body}}} |
| 471 | _, proxy := newToolResultCapabilityAgent(t, session) |
| 472 | header, page, err := executeToolResultPage(t, proxy, "hash", "", 0, toolResultPageMaxBytes) |
| 473 | if err != nil || page != body { |
| 474 | t.Fatalf("page=%q err=%v", page, err) |
| 475 | } |
| 476 | digest := sha256.Sum256([]byte(body)) |
| 477 | if !bytes.Equal(mustDecodeHex(t, header.SHA256), digest[:]) { |
| 478 | t.Fatalf("sha256=%q does not match complete body", header.SHA256) |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | func mustDecodeHex(t *testing.T, value string) []byte { |
| 483 | t.Helper() |
| 484 | b, err := hex.DecodeString(value) |
| 485 | if err != nil { |
| 486 | t.Fatal(err) |
| 487 | } |
| 488 | return b |
| 489 | } |
| 490 |