webctx 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ package app
2
+
3
+ import "strings"
4
+
5
+ func researchView(view, query string) (string, error) {
6
+ switch strings.ToLower(strings.TrimSpace(view)) {
7
+ case "":
8
+ if strings.TrimSpace(query) == "" {
9
+ return "providers", nil
10
+ }
11
+ return "tools", nil
12
+ case "sources", "providers":
13
+ return "providers", nil
14
+ case "groups":
15
+ return "groups", nil
16
+ case "operations", "tools":
17
+ return "tools", nil
18
+ default:
19
+ return "", dataError("invalid_view", "Use view=sources, groups, or operations.")
20
+ }
21
+ }
22
+
23
+ func expandContract(include []string) ([]string, error) {
24
+ seen := map[string]bool{}
25
+ out := make([]string, 0, len(include))
26
+ for _, value := range include {
27
+ name := ""
28
+ switch strings.ToLower(strings.TrimSpace(value)) {
29
+ case "inputs", "options":
30
+ name = "options"
31
+ case "output", "response":
32
+ name = "response"
33
+ case "examples":
34
+ name = "examples"
35
+ default:
36
+ return nil, dataError("invalid_include", "Supported include values: inputs, output, examples.")
37
+ }
38
+ if !seen[name] {
39
+ out = append(out, name)
40
+ seen[name] = true
41
+ }
42
+ }
43
+ return out, nil
44
+ }
45
+
46
+ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string, []string, error) {
47
+ switch input.Mode {
48
+ case "", "ranked", "catalogue":
49
+ default:
50
+ return input, "", nil, dataError("invalid_mode", "Use mode=ranked or mode=catalogue.")
51
+ }
52
+ view, err := researchView(input.View, input.Query)
53
+ if err != nil {
54
+ return input, "", nil, err
55
+ }
56
+ expand, err := expandContract(input.Include)
57
+ if err != nil {
58
+ return input, "", nil, err
59
+ }
60
+ if input.Limit == 0 {
61
+ input.Limit = 10
62
+ }
63
+ if input.Limit < 1 || input.Limit > 100 {
64
+ return input, "", nil, dataError("invalid_limit", "limit must be between 1 and 100.")
65
+ }
66
+ if input.Offset < 0 || input.Offset > 100000 {
67
+ return input, "", nil, dataError("invalid_offset", "offset must be between 0 and 100000.")
68
+ }
69
+ for _, target := range input.URLs {
70
+ if err := validateSourceURL(target); err != nil {
71
+ return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
72
+ }
73
+ }
74
+ return input, view, expand, nil
75
+ }
76
+
77
+ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string) map[string]any {
78
+ o := map[string]any{"level": view, "limit": input.Limit, "offset": input.Offset}
79
+ if input.Query != "" {
80
+ o["query"] = input.Query
81
+ }
82
+ if len(input.URLs) > 0 {
83
+ o["urls"] = input.URLs
84
+ }
85
+ if len(input.Sources) > 0 {
86
+ o["providers"] = input.Sources
87
+ }
88
+ if len(input.Categories) > 0 {
89
+ o["categories"] = input.Categories
90
+ }
91
+ if len(input.Groups) > 0 {
92
+ o["groups"] = input.Groups
93
+ }
94
+ if len(input.Operations) > 0 {
95
+ var caps []string
96
+ sourceSet := map[string]bool{}
97
+ for _, source := range input.Sources {
98
+ sourceSet[source] = true
99
+ }
100
+ for _, id := range input.Operations {
101
+ provider, capability, err := parseOperationID(id)
102
+ if err == nil {
103
+ caps = append(caps, capability)
104
+ sourceSet[provider] = true
105
+ } else {
106
+ caps = append(caps, id)
107
+ }
108
+ }
109
+ o["capabilities"] = caps
110
+ if len(input.Sources) == 0 {
111
+ var providers []string
112
+ for provider := range sourceSet {
113
+ providers = append(providers, provider)
114
+ }
115
+ // Sorting keeps responses, cursors and test fixtures stable.
116
+ sortStrings(providers)
117
+ if len(providers) > 0 {
118
+ o["providers"] = providers
119
+ }
120
+ }
121
+ }
122
+ if len(expand) > 0 {
123
+ o["expand"] = expand
124
+ }
125
+ return o
126
+ }
127
+
128
+ func sortStrings(values []string) {
129
+ for i := 1; i < len(values); i++ {
130
+ for j := i; j > 0 && values[j] < values[j-1]; j-- {
131
+ values[j], values[j-1] = values[j-1], values[j]
132
+ }
133
+ }
134
+ }
135
+
136
+ // ResearchData supports ranked semantic search and the complete filterable,
137
+ // pageable catalogue. Each result includes the exact next tool/arguments.
138
+ func ResearchData(input ResearchDataInput) (map[string]any, error) {
139
+ input, view, expand, err := normalizeResearchInput(input)
140
+ if err != nil {
141
+ return nil, err
142
+ }
143
+ ctx, cancel := defaultDataContext()
144
+ defer cancel()
145
+ semantic := input.Mode != "catalogue" && input.Query != "" && view == "tools" && input.Offset == 0 &&
146
+ len(input.URLs) == 0 && len(input.Sources) == 0 && len(input.Categories) == 0 &&
147
+ len(input.Groups) == 0 && len(input.Operations) == 0
148
+ var items []any
149
+ var total any
150
+ var next any
151
+ var raw map[string]any
152
+ mode := "catalogue"
153
+ if semantic {
154
+ mode = "ranked_search"
155
+ // Ask for full metadata even when the agent requested concise output:
156
+ // compact upstream matches may omit both price and operation name.
157
+ // We still return compact result cards unless include was requested.
158
+ detail := "full"
159
+ raw, err = doDataAPI(ctx, "/search", map[string]any{
160
+ "query": input.Query, "sources": []string{"alexandria"},
161
+ "limit": input.Limit, "toolDetail": detail,
162
+ }, "")
163
+ if err != nil {
164
+ return nil, err
165
+ }
166
+ data := mapField(raw["data"])
167
+ items = listField(data["tools"])
168
+ // Ranked search has no native cursor. Expose the free, complete
169
+ // catalogue with matching semantic query as an optional expansion.
170
+ next = map[string]any{"tool": "research", "arguments": map[string]any{
171
+ "query": input.Query, "view": "operations", "mode": "catalogue", "limit": input.Limit,
172
+ }, "note": "Browse all matching operations with pagination; ranked discovery itself has no cursor."}
173
+ } else {
174
+ options := nativeBrowseOptions(input, view, expand)
175
+ var catalogue map[string]any
176
+ catalogue, raw, err = freeDataBrowse(ctx, options)
177
+ if err != nil {
178
+ return nil, err
179
+ }
180
+ items = listField(catalogue["items"])
181
+ total = catalogue["total"]
182
+ if native := mapField(catalogue["next"]); native != nil {
183
+ next = continuationFromCatalogue(native)
184
+ }
185
+ }
186
+ results := make([]map[string]any, 0, len(items))
187
+ for _, value := range items {
188
+ entry := mapField(value)
189
+ if entry == nil {
190
+ continue
191
+ }
192
+ id := stringField(entry["id"])
193
+ if id == "" && view == "tools" {
194
+ if provider := stringField(entry["provider"]); provider != "" {
195
+ id = provider + "/" + stringField(entry["capability"])
196
+ }
197
+ }
198
+ name := stringField(entry["name"])
199
+ if name == "" {
200
+ segments := strings.Split(id, "/")
201
+ name = strings.ReplaceAll(strings.ReplaceAll(segments[len(segments)-1], "_", " "), "-", " ")
202
+ }
203
+ result := map[string]any{"id": id, "name": name, "description": entry["description"]}
204
+ if provider := stringField(entry["provider"]); provider != "" {
205
+ result["source_id"] = provider
206
+ }
207
+ if cost, ok := entry["creditsCost"]; ok {
208
+ result["credits_per_call"] = cost
209
+ }
210
+ if cost, ok := entry["perRecord"]; ok {
211
+ result["per_record"] = cost
212
+ }
213
+ if count, ok := entry["toolCount"]; ok {
214
+ result["operation_count"] = count
215
+ }
216
+ if rank, ok := entry["similarity"]; ok {
217
+ result["relevance"] = rank
218
+ }
219
+ if len(expand) > 0 {
220
+ if selected := entry["options"]; selected != nil && containsString(expand, "options") {
221
+ result["inputs"] = selected
222
+ }
223
+ if selected := entry["response"]; selected != nil && containsString(expand, "response") {
224
+ result["output"] = selected
225
+ }
226
+ if selected := entry["examples"]; selected != nil && containsString(expand, "examples") {
227
+ result["examples"] = selected
228
+ }
229
+ }
230
+ if continuation := mapField(entry["next"]); continuation != nil {
231
+ result["next"] = continuationFromCatalogue(continuation)
232
+ } else if id != "" {
233
+ result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
234
+ }
235
+ results = append(results, result)
236
+ }
237
+ output := map[string]any{
238
+ "mode": mode, "view": niceDataView(view), "results": results,
239
+ "count": len(results), "cost_credits": 0,
240
+ "guidance": "Use inspect with an operation ID to see complete inputs, output, examples and the next execute call. To explore a source, inspect its source ID.",
241
+ }
242
+ if total != nil {
243
+ output["total"] = total
244
+ }
245
+ if next != nil {
246
+ output["next"] = next
247
+ }
248
+ if input.Raw {
249
+ output["raw"] = raw
250
+ }
251
+ return output, nil
252
+ }
253
+
254
+ func niceDataView(view string) string {
255
+ switch view {
256
+ case "providers":
257
+ return "sources"
258
+ case "tools":
259
+ return "operations"
260
+ default:
261
+ return view
262
+ }
263
+ }
264
+
265
+ func containsString(items []string, needle string) bool {
266
+ for _, item := range items {
267
+ if item == needle {
268
+ return true
269
+ }
270
+ }
271
+ return false
272
+ }
273
+
274
+ func continuationFromCatalogue(native map[string]any) map[string]any {
275
+ options := mapField(native["options"])
276
+ if options == nil {
277
+ return map[string]any{"native_request": native}
278
+ }
279
+ args := map[string]any{"mode": "catalogue"}
280
+ for _, field := range []string{"query", "urls", "providers", "categories", "groups", "limit", "offset"} {
281
+ if value, ok := options[field]; ok {
282
+ name := field
283
+ if field == "providers" {
284
+ name = "sources"
285
+ }
286
+ args[name] = value
287
+ }
288
+ }
289
+ if level := stringField(options["level"]); level != "" {
290
+ args["view"] = niceDataView(level)
291
+ }
292
+ if caps := listField(options["capabilities"]); len(caps) > 0 {
293
+ var ids []string
294
+ sourceNames := listField(options["providers"])
295
+ for _, capability := range caps {
296
+ cap := stringField(capability)
297
+ if cap == "" {
298
+ continue
299
+ }
300
+ if len(sourceNames) == 1 {
301
+ ids = append(ids, stringField(sourceNames[0])+"/"+cap)
302
+ } else {
303
+ ids = append(ids, cap)
304
+ }
305
+ }
306
+ args["operations"] = ids
307
+ }
308
+ if exp := listField(options["expand"]); len(exp) > 0 {
309
+ var include []string
310
+ for _, value := range exp {
311
+ switch stringField(value) {
312
+ case "options":
313
+ include = append(include, "inputs")
314
+ case "response":
315
+ include = append(include, "output")
316
+ case "examples":
317
+ include = append(include, "examples")
318
+ }
319
+ }
320
+ args["include"] = include
321
+ }
322
+ return map[string]any{"tool": "research", "arguments": args, "native_request": native}
323
+ }
@@ -0,0 +1,336 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "io"
7
+ "net/http"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+ )
12
+
13
+ func dataTestContract() map[string]any {
14
+ return map[string]any{
15
+ "id": "particle/podcasts/episodes/search",
16
+ "provider": "particle",
17
+ "capability": "podcasts/episodes/search",
18
+ "name": "Search podcast conversations",
19
+ "description": "Find episodes by what people discussed.",
20
+ "creditsCost": 15,
21
+ "perRecord": false,
22
+ "requiresOneOf": []any{[]any{"semantic_search", "keyword_search"}},
23
+ "options": []any{
24
+ map[string]any{"name": "semantic_search", "type": "string", "about": "Topic described in your own words."},
25
+ map[string]any{"name": "keyword_search", "type": "string", "about": "Exact transcript keyword."},
26
+ map[string]any{"name": "limit", "type": "number", "default": 25, "min": 1, "max": 100},
27
+ map[string]any{"name": "cursor", "type": "string", "about": "Opaque pagination token."},
28
+ },
29
+ "response": map[string]any{"key": "data", "paginated": true, "fields": []any{map[string]any{"name": "episode", "type": "object"}}},
30
+ }
31
+ }
32
+
33
+ type dataTestTransport struct {
34
+ mu sync.Mutex
35
+ executions int
36
+ lastHeaders http.Header
37
+ lastPayload map[string]any
38
+ blockPaid bool
39
+ }
40
+
41
+ func (d *dataTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
42
+ if req.URL.Host != "api.firecrawl.dev" || req.Method != http.MethodPost {
43
+ return testHTTPResponse(req, http.StatusNotFound, "{}", nil), nil
44
+ }
45
+ body, err := io.ReadAll(req.Body)
46
+ if err != nil {
47
+ return nil, err
48
+ }
49
+ var payload map[string]any
50
+ if err := json.Unmarshal(body, &payload); err != nil {
51
+ return nil, err
52
+ }
53
+ d.mu.Lock()
54
+ d.lastPayload = payload
55
+ d.lastHeaders = req.Header.Clone()
56
+ d.mu.Unlock()
57
+ write := func(status int, content map[string]any) (*http.Response, error) {
58
+ b, _ := json.Marshal(content)
59
+ return testHTTPResponse(req, status, string(b), nil), nil
60
+ }
61
+ if req.URL.Path == "/v2/search" {
62
+ return write(200, map[string]any{"success": true, "data": map[string]any{
63
+ "tools": []any{dataTestContract()},
64
+ }})
65
+ }
66
+ if req.URL.Path != "/v2/scrape" {
67
+ return write(404, map[string]any{"success": false})
68
+ }
69
+ if operation := mapField(payload["alexandria"]); operation != nil &&
70
+ operation["provider"] == "firecrawl" && operation["capability"] == "find-tools" {
71
+ opts := mapField(operation["options"])
72
+ if opts["level"] == "providers" {
73
+ return write(200, map[string]any{"success": true, "data": map[string]any{
74
+ "alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0, "data": map[string]any{
75
+ "level": "providers", "total": 3,
76
+ "items": []any{map[string]any{"id": "particle", "name": "Particle", "description": "Podcast data", "toolCount": 40}},
77
+ "next": map[string]any{"provider": "firecrawl", "capability": "find-tools", "options": map[string]any{
78
+ "level": "providers", "limit": 2, "offset": 2,
79
+ }},
80
+ }}},
81
+ }})
82
+ }
83
+ return write(200, map[string]any{"success": true, "data": map[string]any{
84
+ "alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0, "data": map[string]any{
85
+ "level": "tools", "total": 1, "items": []any{dataTestContract()},
86
+ }}},
87
+ }})
88
+ }
89
+ if calls := listField(payload["alexandria"]); len(calls) > 0 {
90
+ d.mu.Lock()
91
+ d.executions++
92
+ d.mu.Unlock()
93
+ if d.blockPaid {
94
+ return write(403, map[string]any{
95
+ "success": false, "code": "THIRD_PARTY_DATA_TERMS_REQUIRED",
96
+ "error": "Provider terms have not been accepted.",
97
+ "requiresAction": map[string]any{"url": "https://firecrawl.dev/app/settings?tab=data-sources"},
98
+ })
99
+ }
100
+ var results []any
101
+ var total int
102
+ for _, value := range calls {
103
+ call := mapField(value)
104
+ results = append(results, map[string]any{
105
+ "provider": call["provider"], "capability": call["capability"],
106
+ "creditsCost": 15, "alexandriaId": "paid-data-id-1",
107
+ "data": map[string]any{"data": []any{map[string]any{"episode": map[string]any{"id": "ep_1"}}}, "next_cursor": "after-1"},
108
+ })
109
+ total += 15
110
+ }
111
+ return write(200, map[string]any{
112
+ "success": true, "data": map[string]any{"alexandria": results, "creditsCost": total},
113
+ })
114
+ }
115
+ return write(400, map[string]any{"success": false, "error": "Bad test request."})
116
+ }
117
+
118
+ func installDataTestTransport(t *testing.T) *dataTestTransport {
119
+ t.Helper()
120
+ old := http.DefaultClient
121
+ t.Cleanup(func() { http.DefaultClient = old })
122
+ d := &dataTestTransport{}
123
+ http.DefaultClient = &http.Client{Transport: d}
124
+ t.Setenv("FIRECRAWL_API_KEY", "test-key")
125
+ t.Setenv("WEBCTX_ALEXANDRIA_PAID", "true")
126
+ t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "100")
127
+ return d
128
+ }
129
+
130
+ func TestProgressiveDataDiscoveryAndExecution(t *testing.T) {
131
+ mock := installDataTestTransport(t)
132
+ found, err := ResearchData(ResearchDataInput{Query: "podcast conversations about AI agents", Limit: 2})
133
+ if err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ if found["cost_credits"] != 0 || found["mode"] != "ranked_search" {
137
+ t.Fatalf("ranked discovery not free: %#v", found)
138
+ }
139
+ results := found["results"].([]map[string]any)
140
+ id := results[0]["id"].(string)
141
+ if id != "particle/podcasts/episodes/search" {
142
+ t.Fatalf("wrong discovery ID: %s", id)
143
+ }
144
+ next := mapField(results[0]["next"])
145
+ if next["tool"] != "inspect" || mapField(next["arguments"])["id"] != id {
146
+ t.Fatalf("discovery did not provide inspect arguments: %#v", next)
147
+ }
148
+ details, err := InspectData(InspectDataInput{ID: id})
149
+ if err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if mapField(details["price"])["credits_per_call"] != float64(15) {
153
+ t.Fatalf("missing pricing: %#v", details)
154
+ }
155
+ if len(listField(mapField(details["inputs"])["fields"])) != 4 {
156
+ t.Fatalf("missing upstream contract fields: %#v", details["inputs"])
157
+ }
158
+ continued := mapField(details["next"])
159
+ if continued["tool"] != "execute" {
160
+ t.Fatalf("no next execution: %#v", continued)
161
+ }
162
+ args := mapField(continued["arguments"])
163
+ skeleton := listField(args["calls"])
164
+ if len(skeleton) != 1 {
165
+ t.Fatalf("invalid next argument shape: %#v", args)
166
+ }
167
+ inspectedCall := mapField(skeleton[0])
168
+ if inspectedCall == nil || inspectedCall["id"] != id || mapField(inspectedCall["inputs"])["semantic_search"] != nil {
169
+ t.Fatalf("invalid template: %#v", skeleton)
170
+ }
171
+ got, err := ExecuteData(ExecuteDataInput{
172
+ Calls: []DataCall{{ID: id, Inputs: map[string]any{"semantic_search": "AI agents", "limit": float64(2)}}},
173
+ MaxCredits: 20,
174
+ })
175
+ if err != nil {
176
+ t.Fatal(err)
177
+ }
178
+ if got["credits_used"] != float64(15) {
179
+ t.Fatalf("credits missing: %#v", got)
180
+ }
181
+ executions := got["results"].([]map[string]any)
182
+ if executions[0]["data_id"] != "paid-data-id-1" {
183
+ t.Fatalf("provider provenance missing: %#v", got)
184
+ }
185
+ followup := mapField(executions[0]["next"])
186
+ if followup["tool"] != "execute" {
187
+ t.Fatalf("missing cursor continuation: %#v", followup)
188
+ }
189
+ followArgs := mapField(followup["arguments"])
190
+ nextCall := mapField(listField(followArgs["calls"])[0])
191
+ if mapField(nextCall["inputs"])["cursor"] != "after-1" {
192
+ t.Fatalf("pagination lost filters: %#v", nextCall)
193
+ }
194
+ mock.mu.Lock()
195
+ defer mock.mu.Unlock()
196
+ if mock.executions != 1 || !safeRequestID.MatchString(got["request_id"].(string)) ||
197
+ mock.lastHeaders.Get("x-request-id") != got["request_id"].(string) {
198
+ t.Fatalf("request not paid once with stable ID: executions=%d req=%s", mock.executions, got["request_id"])
199
+ }
200
+ }
201
+
202
+ func TestCatalogueBrowsingReturnsExactPaginationCall(t *testing.T) {
203
+ installDataTestTransport(t)
204
+ result, err := ResearchData(ResearchDataInput{View: "sources", Limit: 2})
205
+ if err != nil {
206
+ t.Fatal(err)
207
+ }
208
+ if result["view"] != "sources" || result["total"] != float64(3) {
209
+ t.Fatalf("bad catalogue: %#v", result)
210
+ }
211
+ next := mapField(result["next"])
212
+ args := mapField(next["arguments"])
213
+ if next["tool"] != "research" || args["offset"] != float64(2) || args["view"] != "sources" || args["mode"] != "catalogue" {
214
+ t.Fatalf("invalid pagination continuation: %#v", next)
215
+ }
216
+ fromSource, err := InspectData(InspectDataInput{ID: "particle"})
217
+ if err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ if fromSource["view"] != "operations" {
221
+ t.Fatalf("provider exploration failed: %#v", fromSource)
222
+ }
223
+ }
224
+
225
+ func TestExecutionValidationAndBudgetRejectBeforePaidCall(t *testing.T) {
226
+ mock := installDataTestTransport(t)
227
+ id := "particle/podcasts/episodes/search"
228
+ for _, testCase := range []struct {
229
+ inputs map[string]any
230
+ credits int
231
+ code string
232
+ }{
233
+ {map[string]any{}, 20, "invalid_inputs"},
234
+ {map[string]any{"semantic_search": "AI agents", "limit": 101}, 20, "invalid_inputs"},
235
+ {map[string]any{"semantic_search": "AI agents", "unknown_field": 123}, 20, "invalid_inputs"},
236
+ {map[string]any{"semantic_search": "AI agents", "limit": 5}, 5, "credit_budget_exceeded"},
237
+ } {
238
+ _, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{ID: id, Inputs: testCase.inputs}}, MaxCredits: testCase.credits})
239
+ typed, ok := err.(*DataError)
240
+ if !ok || typed.Code != testCase.code {
241
+ t.Fatalf("wanted %s, got %v", testCase.code, err)
242
+ }
243
+ }
244
+ mock.mu.Lock()
245
+ defer mock.mu.Unlock()
246
+ if mock.executions != 0 {
247
+ t.Fatalf("invalid calls charged credits: %d", mock.executions)
248
+ }
249
+ }
250
+
251
+ func TestExecuteHandlesProviderTermsWithoutAccepting(t *testing.T) {
252
+ mock := installDataTestTransport(t)
253
+ mock.blockPaid = true
254
+ _, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{
255
+ ID: "particle/podcasts/episodes/search",
256
+ Inputs: map[string]any{"semantic_search": "AI agents"},
257
+ }}})
258
+ typed, ok := err.(*DataError)
259
+ if !ok || typed.Code != "THIRD_PARTY_DATA_TERMS_REQUIRED" ||
260
+ mapField(typed.RequiresAction)["url"] != "https://firecrawl.dev/app/settings?tab=data-sources" {
261
+ t.Fatalf("terms condition lost: %+v", err)
262
+ }
263
+ if mapField(typed.Details)["request_id"] == nil {
264
+ t.Fatalf("lost retriable request identifier: %+v", typed)
265
+ }
266
+ }
267
+
268
+ func TestCLIResearchAndInspectJSON(t *testing.T) {
269
+ installDataTestTransport(t)
270
+ var out, errBuf bytes.Buffer
271
+ if code := Run([]string{"research", "podcasts about AI agents", "--limit", "2", "--raw"}, &out, &errBuf); code != 0 {
272
+ t.Fatalf("CLI research failed: %s", errBuf.String())
273
+ }
274
+ var result map[string]any
275
+ if json.Unmarshal(out.Bytes(), &result) != nil || mapField(result["raw"]) == nil {
276
+ t.Fatalf("CLI JSON or --raw missing: %s", out.String())
277
+ }
278
+ out.Reset()
279
+ if code := Run([]string{"inspect", "particle/podcasts/episodes/search"}, &out, &errBuf); code != 0 {
280
+ t.Fatalf("CLI inspect failed: %s", errBuf.String())
281
+ }
282
+ if !strings.Contains(out.String(), "semantic_search") || !strings.Contains(out.String(), "max_credits") {
283
+ t.Fatalf("CLI inspect omitted exact execute syntax: %s", out.String())
284
+ }
285
+ }
286
+
287
+ func TestProviderNextOffsetCreatesCopyReadyContinuation(t *testing.T) {
288
+ id := "fred-stlouisfed-org/economic-data/series_observations"
289
+ inputs := map[string]any{"series_id": "CPIAUCSL", "limit": 2.0, "offset": 0.0}
290
+ next := continuationForDataResult(id, inputs, map[string]any{"next_offset": 2.0, "count": 956.0}, 15)
291
+ if mapField(next)["tool"] != "execute" {
292
+ t.Fatalf("expected executable pagination: %+v", next)
293
+ }
294
+ args := mapField(mapField(next)["arguments"])
295
+ call := mapField(listField(args["calls"])[0])
296
+ fields := mapField(call["inputs"])
297
+ if fields["offset"] != 2 || fields["series_id"] != "CPIAUCSL" {
298
+ t.Fatalf("next offset dropped exact source inputs: %+v", fields)
299
+ }
300
+ if original, _ := numberField(inputs["offset"]); original != 0 {
301
+ t.Fatal("original inputs mutated")
302
+ }
303
+ }
304
+
305
+ func TestPerRecordPricingRequiresKnownBound(t *testing.T) {
306
+ contract := map[string]any{"creditsCost": float64(5), "perRecord": true, "options": []any{}}
307
+ if n, err := expectedCallCredits(contract, map[string]any{"limit": float64(3)}); err != nil || n != 15 {
308
+ t.Fatalf("bad predicted per-record charge: %d %v", n, err)
309
+ }
310
+ if _, err := expectedCallCredits(contract, map[string]any{}); err == nil {
311
+ t.Fatal("unknown record count must not trigger unbounded paid call")
312
+ }
313
+ }
314
+
315
+ func TestNumericEnumsMayArriveAsUpstreamStrings(t *testing.T) {
316
+ contract := map[string]any{"id": "example/enum", "options": []any{
317
+ map[string]any{"name": "year", "type": "number", "required": true, "oneOf": []any{"2021", "2023"}},
318
+ }}
319
+ if err := validateDataInputs(contract, map[string]any{"year": float64(2023)}); err != nil {
320
+ t.Fatalf("upstream numeric enum from strings should work: %v", err)
321
+ }
322
+ if err := validateDataInputs(contract, map[string]any{"year": float64(2026)}); err == nil {
323
+ t.Fatal("unlisted numeric value must be rejected")
324
+ }
325
+ }
326
+
327
+ func TestInspectOnlyOutputStillHasExecuteInputTemplate(t *testing.T) {
328
+ installDataTestTransport(t)
329
+ details, err := InspectData(InspectDataInput{ID: "particle/podcasts/episodes/search", Include: []string{"output"}})
330
+ if err != nil {
331
+ t.Fatal(err)
332
+ }
333
+ if mapField(details["input_template"])["semantic_search"] != nil || len(listField(mapField(details["inputs"])["fields"])) != 4 {
334
+ t.Fatalf("missing necessary next-call schema after include=output: %+v", details)
335
+ }
336
+ }