diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b4bf4ff4..0f521af86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,14 @@ to recover a lost creation response. Session metadata updates require a supplied metadata field, with null/empty clearing it. Validate an empty update before any resource lookup, after authentication. +List order parsing distinguishes omission from an explicit empty value. Reuse the +shared parser and error serializer, preserving the observed Beta, Files and Skills +error fields rather than applying one error code to every resource. Qualification +of one query error does not authorize changing page bounds, cursor ownership or +parent lookup order. Record uncertain range/lookup behavior separately; do not +reproduce observed upstream server failures as compatibility behavior. See +`contracts/agents-api/list-query-semantics.md` for the bounded evidence. + Keep runtime state, test artifacts and build output under `~/.parsar/`. Require absolute user-supplied working directories. Keep credentials out of source and logs. Update this guide when architecture, ownership or generated contracts change. diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 7ffafabd8..c1154c3e3 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -1,6 +1,8 @@ # Agents API contract See the [58-operation evidence inventory](operation-evidence.md) for observed official behavior, local verification and remaining unknowns. +The [list-query comparison](list-query-semantics.md) distinguishes measured order +errors from unresolved range, cursor and lookup semantics. The external reference is [openai-python beta/agents](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents), pinned in `upstream.json`. Its resource methods, corresponding types, pagination and streaming helpers define the compatibility target. This directory records diff --git a/contracts/agents-api/list-query-semantics.md b/contracts/agents-api/list-query-semantics.md new file mode 100644 index 000000000..95d5d3622 --- /dev/null +++ b/contracts/agents-api/list-query-semantics.md @@ -0,0 +1,101 @@ +# List query semantics — September 23, 2026 + +The fixed target remains openai-python 3.13.0, commit +`d7c41efee1b0802b79f3f88a678ef2052b06e9ce`, with `agents=v1` on beta resources. +This batch starts from Core main `c86b5bb` and addresses list order validation and +the error fields actually observed for those requests. It does not change page +limits, cursor lookup order, execution or native harness behavior. + +## Official observations + +Exactly 35 new read-only official GET requests were made, with no retries, resource +writes or model execution. Nested requests used random missing Session/Vault IDs; +top-level requests used missing cursors or a nonexistent Agent filter. Every +successful list was empty. Evidence, request IDs, fixed type copies and the full +matrix are retained under +`~/.parsar/remediation/20260923/error-query-survey/`. Earlier owned Session/Turn +observations are separately identified in that directory, not counted as new calls. + +The nine sampled collections rejected explicit `order=`. The fixed enum permits +only `asc` or `desc`; omission and an explicitly empty string are different inputs. + +| Measured request | Status | Error type | Code | Param | +| --- | --- | --- | --- | --- | +| Empty order on Agents, Sessions, Turns, Items, Templates, Vaults, Credentials | 400 | invalid_request_error | invalid_request_error | null | +| Empty order on Files | 400 | invalid_request_error | null | null | +| Empty order on Skills | 400 | invalid_request_error | invalid_value | order | +| Invalid status on Vaults/Credentials | 400 | invalid_request_error | invalid_request_error | null | + +These are operation-specific observations. Do not apply the Skills code to all +validation errors or make every Files error share one parameter. Other endpoints +using the same order enum may share the parser, but were not independently probed +here. Authentication and ownership checks retain their existing boundaries. + +The fixed Python SDK's query serializer drops empty string values. Consequently, +`list(order="")` does not send `order=` and follows omission/default behavior. +Explicit-empty rejection requires raw HTTP evidence; nonempty invalid SDK order +values exercise the rejected path normally. Do not alter Core or the SDK to hide +that request-serialization distinction. + +## Deferred differences and uncertainty + +- Query limits require separate qualification. Missing cursors or parents can mask + a later numeric validation error. A successful filtered-empty request does not + establish useful zero-sized pagination or an actual maximum page capacity. +- An owned official Item list accepted 101 although its pinned type documents + 1–100; an owned Turn list rejected 101. Keep the fixed range until the conflict + is resolved explicitly. No general range change follows from this survey. +- Vault/Credential negative limits rejected on the official service, while the + pinned description broadly says values clamp to 1–100. Core's clamping policy + is unchanged in this batch. +- Files invalid purpose and missing cursor expose different `param` values; + purpose typing and cursor behavior need a separate bounded decision. The + verbose Files validation message and additional `detail` object are not a + reason to recreate an upstream validation framework. +- Two malformed Skills cursor observations returned 500, while a shaped missing + cursor returned 404. Retain the evidence; do not deliberately reproduce an + upstream failure or weaken safe missing-resource handling. +- Unknown/repeated query keys, whitespace cursors, numeric overflow, all status + combinations and concurrent-page mutation were not newly qualified. + +Current documentation was consulted alongside the pin, including the +[Session list reference](https://developers.openai.com/api/reference/go/resources/beta/subresources/agents/subresources/sessions/methods/list) +and [Vault list reference](https://developers.openai.com/api/reference/typescript/resources/beta/subresources/agents/subresources/vaults/methods/list). +New documentation and sampled tolerance do not silently replace the fixed SDK. + +## Acceptance boundary + +Acceptance must exercise fixed-SDK and raw HTTP requests against the actual Core +service and a dedicated PostgreSQL database: rejected queries, omitted/valid order, +scoped history and pagination, authentication, tenant isolation and no mutation +from rejected GETs. Controlled tests support the same contract. This read-only +change requires no new native-model capability qualification; existing real-model +evidence retains its original scope. Record completed checks and independent review +before merging; neither a deserializable response nor a route inventory proves +complete compatibility. + +## Completed acceptance + +The nine-family fixed-SDK/raw-HTTP regression passed against actual Core HTTP, +PostgreSQL and Worker admission with dispatch paused. It checked 35 primary +rejections, nine SDK empty-query omission cases, successful ascending/descending +and default pagination, authentication and foreign-tenant masking. Resource +snapshots and the original three cancelled Turns/three Items remained unchanged. +The baseline failed at raw empty-order admission; the implementation passed. +This is resource/query acceptance, not native or model execution. Logs, exact +SDK serializer source and hashes are retained in the survey directory's +`ACCEPTANCE.md`; its owned database was removed. + +Server `make -o check-web check` passed at `e4cb8a5`, including the dedicated +PostgreSQL regression, sqlc regeneration, service/adapter Go tests and builds, +Claude/MiniMax checks and Rust tests/format/Clippy. Web/client source and dependencies +are unchanged from PR #35: its 287 client tests, 583 Web tests and 76 browser cases +remain the applicable exact-source evidence; no new Web run is claimed. The +optional packaged MiniMax native-tools probe was skipped. This batch changes no +Runtime/provider/model behavior and does not requalify their combinations. + +A fresh independent GPT-6 Astra high reviewer found no grounded in-scope findings +after inspecting the full diff and evidence; API tests and `git diff --check` +passed independently. Server logs are retained under +`~/.parsar/remediation/20260923/list-query-alignment/`. The remaining limits, cursor, +lookup and Files verbose error differences above are not declared compatible. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 40ccff928..008de4ff4 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2050,7 +2050,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -2373,7 +2374,8 @@ paths: name: limit type: integer - default: desc - description: Case-sensitive path-component order + description: Case-sensitive path-component order; omit for descending, explicit + empty values are invalid enum: - asc - desc @@ -2509,7 +2511,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -2772,7 +2775,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -3133,7 +3137,8 @@ paths: name: limit type: integer - default: desc - description: Publication order + description: Publication order; omit for descending, explicit empty values + are invalid enum: - asc - desc @@ -3497,7 +3502,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -3559,7 +3565,8 @@ paths: name: limit type: integer - default: desc - description: Resource order + description: Resource order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -3683,7 +3690,8 @@ paths: name: limit type: integer - default: desc - description: Resource order + description: Resource order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -3754,7 +3762,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -3888,7 +3897,8 @@ paths: name: limit type: integer - default: desc - description: Resource order + description: Resource order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -3955,7 +3965,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -4056,7 +4067,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -4296,7 +4308,8 @@ paths: in: query name: limit type: integer - - description: Creation order + - description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -4452,7 +4465,8 @@ paths: in: query name: limit type: integer - - description: Version order + - description: Version order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -4601,7 +4615,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc @@ -4827,7 +4842,8 @@ paths: name: limit type: integer - default: desc - description: Creation order + description: Creation order; omit for descending, explicit empty values are + invalid enum: - asc - desc diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 321c6b261..f4b1126cd 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -25,6 +25,7 @@ Repository paths below are relative to the inspected worktree; private evidence | E | `~/.parsar/remediation/20260922/official-semantics-alignment/error-surface-probe.json`: missing non-beta File/Skill observations only. Not successful-resource or full error-surface coverage. | | C | `~/.parsar/remediation/20260922/official-semantics-alignment/live/acceptance-summary.json`, source `7c80d604ba47c578084ebc9fa99cff332bd5d5f2`. Seven resource/safety groups (DB), plus three real Turns per harness (Live): Codex/Claude Kimi K3; MiniMax M2.7. Successful runs: `live/resources/run-1790091139881592673`, `live/codex/run-1790091781322707360`, `live/claude_sdk/run-1790091781322554888`, `live/mcode/run-1790091781324544033`; each has `result.json` and `raw-evidence.json`, model runs also history/SSE evidence. Four network-interrupted attempts remain failures. No new native capability/OAuth refresh/E2B qualification. | | N | `contracts/agents-api/official-semantics-alignment.md`, September 23 admission section; private `~/.parsar/remediation/20260923/session-admission-alignment/{official,live}/`: conditional create/update official probes plus exact-source `7ccc636` Core/daemon real none execution (six Turns), write rejection, local retry, metadata and isolation. Qualified idle hosted admission and Codex self-hosted admission are not new execution profiles. | +| Q | [List query semantics](list-query-semantics.md); private `~/.parsar/remediation/20260923/error-query-survey/REPORT.md` and `request-index.md`: 35 read-only official GETs, nine empty-order rejections and family-specific error fields. Missing-parent/cursor cases do not qualify later numeric bounds. No model execution or resource creation. Core acceptance is recorded in the linked batch document when complete. | | A | `contracts/agents-api/official-semantics-alignment.md`: merged status/envelope/no-op/error/rejection changes and explicit remaining differences. `contracts/agents-api/README.md:63` supplies the current resource ledger; it is a coverage summary, not raw evidence. | | T | `contracts/agents-api/execution-tools.md`: per-operation input, function, required-action, structured-output, discovery and policy matrix; evidence register M1/M2/F1/F2/F3/S1/S2/D1/P1/E1/E2 gives exact private run paths. These are profile-specific real acceptances. The older blanket OAuth rejection in this document is superseded by O. | | D | `contracts/agents-api/README.md:115`, `contracts/agents-api/user-managed-runtime-v1.md`: recorded three-harness Docker MVP and separate user-managed Docker/E2B qualification. Historical `~/.parsar/remediation/20260920/three-harness-mvp/REPORT.md`, `acceptance-results.json` on zju_a100_2; prior Core-managed E2B qualification is retired-route evidence, not current enrollment qualification. | diff --git a/services/agents-api/internal/api/agents_list.go b/services/agents-api/internal/api/agents_list.go index 102df3e89..505211fcd 100644 --- a/services/agents-api/internal/api/agents_list.go +++ b/services/agents-api/internal/api/agents_list.go @@ -14,7 +14,7 @@ import ( // @Param OpenAI-Beta header string true "agents=v1" // @Param after query string false "Last Agent ID from the previous page" // @Param limit query int64 false "Maximum requested resources; pages contain at most 100" minimum(1) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.SavedAgentList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Router /agents [get] diff --git a/services/agents-api/internal/api/credentials_list.go b/services/agents-api/internal/api/credentials_list.go index 07fd78b92..a324d5be9 100644 --- a/services/agents-api/internal/api/credentials_list.go +++ b/services/agents-api/internal/api/credentials_list.go @@ -15,7 +15,7 @@ import ( // @Param vault_id path string true "Vault ID" // @Param after query string false "Last Credential ID from the previous page" // @Param limit query integer false "Requested page size, clamped to 1–100" default(20) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Param status query string false "Scalar status filter" Enums(active,archived) // @Param status[] query []string false "Array status filter; cannot be combined with status" collectionFormat(multi) Enums(active,archived) // @Success 200 {object} v1.CredentialList diff --git a/services/agents-api/internal/api/environment_files.go b/services/agents-api/internal/api/environment_files.go index 3be4fe99a..13f5b559a 100644 --- a/services/agents-api/internal/api/environment_files.go +++ b/services/agents-api/internal/api/environment_files.go @@ -32,7 +32,7 @@ func WithEnvironmentDirectoryReader(reader EnvironmentDirectoryReader) Option { // @Param environment_id path string true "Environment ID" // @Param path query string false "Absolute directory inside the Environment workspace" // @Param limit query int false "Maximum file count; local default 20" minimum(1) maximum(100) -// @Param order query string false "Case-sensitive path-component order" Enums(asc,desc) default(desc) +// @Param order query string false "Case-sensitive path-component order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Param page query string false "Opaque continuation token; keep path, order and limit unchanged" // @Success 200 {object} v1.EnvironmentFileList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse diff --git a/services/agents-api/internal/api/environment_files_query.go b/services/agents-api/internal/api/environment_files_query.go index a4b82cd1f..d7050b4d9 100644 --- a/services/agents-api/internal/api/environment_files_query.go +++ b/services/agents-api/internal/api/environment_files_query.go @@ -29,7 +29,7 @@ func readEnvironmentFileQuery(w http.ResponseWriter, r *http.Request, environmen return options, false } for key, values := range q { - if !slices.Contains([]string{"path", "limit", "order", "page"}, key) || len(values) != 1 || values[0] == "" { + if !slices.Contains([]string{"path", "limit", "order", "page"}, key) || len(values) != 1 || (key != "order" && values[0] == "") { writeError(w, http.StatusBadRequest, "invalid_request", "Supported list parameters are path, limit, order and page, each supplied once with a nonempty value.") return options, false } @@ -40,7 +40,7 @@ func readEnvironmentFileQuery(w http.ResponseWriter, r *http.Request, environmen pageQuery[key] = values } } - page, ok := readPageQuery(w, pageQuery, true) + page, ok := readPageQuery(w, r, pageQuery, true) if !ok { return options, false } diff --git a/services/agents-api/internal/api/environment_files_test.go b/services/agents-api/internal/api/environment_files_test.go index e6a338f45..e80683aad 100644 --- a/services/agents-api/internal/api/environment_files_test.go +++ b/services/agents-api/internal/api/environment_files_test.go @@ -209,6 +209,9 @@ func TestEnvironmentFilesRejectsInvalidRequestsBeforeRead(t *testing.T) { if w.Code != 400 || f.lookups != 1 || f.reads != 0 { t.Fatal("invalid query reached runtime", w.Code, w.Body, f) } + if query == "order=" || query == "order=ASC" { + assertListQueryError(t, w, "invalid_request_error", nil, "Failed to deserialize query string: order: unknown variant `"+strings.TrimPrefix(query, "order=")+"`, expected `asc` or `desc`") + } }) } } diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index 32a1cce1c..832bd49be 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -194,7 +194,7 @@ func (h *Handler) deleteEnvironmentTemplate(w http.ResponseWriter, r *http.Reque // @Param OpenAI-Beta header string true "agents=v1" // @Param after query string false "Previous Template ID" // @Param limit query integer false "Page size" default(20) minimum(1) maximum(100) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.EnvironmentTemplateList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Router /agents/environments/templates [get] diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index bcb02fcca..1934973f8 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -19,7 +19,7 @@ func writeJSON(w http.ResponseWriter, status int, value any) { _ = json.NewEncoder(w).Encode(value) } -func writeError(w http.ResponseWriter, status int, code, message string) { +func writeError(w http.ResponseWriter, status int, code, message string, param ...string) { kind := "invalid_request_error" if status >= 500 { kind = "server_error" @@ -32,7 +32,11 @@ func writeError(w http.ResponseWriter, status int, code, message string) { if code != "" { errorCode = &code } - writeJSON(w, status, v1.ErrorResponse{Error: v1.APIError{Message: message, Type: kind, Code: errorCode}}) + var errorParam *string + if len(param) > 0 { + errorParam = ¶m[0] + } + writeJSON(w, status, v1.ErrorResponse{Error: v1.APIError{Message: message, Type: kind, Code: errorCode, Param: errorParam}}) } func writeStoreError(w http.ResponseWriter, r *http.Request, err error) { diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index e490cee5a..d61f3e349 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -322,7 +322,7 @@ func (h *Handler) respondSessionStatus(w http.ResponseWriter, r *http.Request, s // @Param agent_id query string false "Root Agent ID whose Sessions to return" // @Param after query string false "Last Session ID from the previous page" // @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.SessionList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Router /agents/sessions [get] diff --git a/services/agents-api/internal/api/items.go b/services/agents-api/internal/api/items.go index 43b7500fd..6b0910e9b 100644 --- a/services/agents-api/internal/api/items.go +++ b/services/agents-api/internal/api/items.go @@ -14,7 +14,7 @@ import ( // @Param session_id path string true "Session ID" // @Param after query string false "Last Item ID from the previous page" // @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.ItemList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/items [get] diff --git a/services/agents-api/internal/api/pagination.go b/services/agents-api/internal/api/pagination.go index b3385e0fe..a4df0317c 100644 --- a/services/agents-api/internal/api/pagination.go +++ b/services/agents-api/internal/api/pagination.go @@ -20,14 +20,14 @@ func readPage(w http.ResponseWriter, r *http.Request, extraKeys ...string) (page } func readPageSize(w http.ResponseWriter, r *http.Request, rejectLarger bool, extraKeys ...string) (pageOptions, bool) { - return readPageQuery(w, r.URL.Query(), rejectLarger, extraKeys...) + return readPageQuery(w, r, r.URL.Query(), rejectLarger, extraKeys...) } -func readPageQuery(w http.ResponseWriter, q url.Values, rejectLarger bool, extraKeys ...string) (pageOptions, bool) { - return readPageQueryLimits(w, q, 20, 100, rejectLarger, extraKeys...) +func readPageQuery(w http.ResponseWriter, r *http.Request, q url.Values, rejectLarger bool, extraKeys ...string) (pageOptions, bool) { + return readPageQueryLimits(w, r, q, 20, 100, rejectLarger, extraKeys...) } -func readPageQueryLimits(w http.ResponseWriter, q url.Values, defaultLimit, maxLimit int, rejectLarger bool, extraKeys ...string) (pageOptions, bool) { +func readPageQueryLimits(w http.ResponseWriter, r *http.Request, q url.Values, defaultLimit, maxLimit int, rejectLarger bool, extraKeys ...string) (pageOptions, bool) { keys := append([]string{"after", "limit", "order"}, extraKeys...) for key, values := range q { if !slices.Contains(keys, key) || len(values) != 1 { @@ -50,9 +50,20 @@ func readPageQueryLimits(w http.ResponseWriter, q url.Values, defaultLimit, maxL } limit = int(min(requested, int64(maxLimit))) } - if order != "" && order != "asc" && order != "desc" { - writeError(w, http.StatusBadRequest, "invalid_request", "order must be asc or desc.") + if _, supplied := q["order"]; supplied && order != "asc" && order != "desc" { + writeListOrderError(w, r, order) return pageOptions{}, false } return pageOptions{after: strings.TrimSpace(q.Get("after")), limit: limit, ascending: order == "asc"}, true } + +func writeListOrderError(w http.ResponseWriter, r *http.Request, order string) { + switch { + case r.URL.Path == "/v1/files" || strings.HasPrefix(r.URL.Path, "/v1/files/"): + writeError(w, http.StatusBadRequest, "", "order must be asc or desc.") + case r.URL.Path == "/v1/skills" || strings.HasPrefix(r.URL.Path, "/v1/skills/"): + writeError(w, http.StatusBadRequest, "invalid_value", fmt.Sprintf("Invalid value: '%s'. Supported values are: 'asc' and 'desc'.", order), "order") + default: + writeError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("Failed to deserialize query string: order: unknown variant `%s`, expected `asc` or `desc`", order)) + } +} diff --git a/services/agents-api/internal/api/pagination_test.go b/services/agents-api/internal/api/pagination_test.go index f90e9392a..e3e135ae0 100644 --- a/services/agents-api/internal/api/pagination_test.go +++ b/services/agents-api/internal/api/pagination_test.go @@ -1,7 +1,12 @@ package api import ( + "encoding/json" + "fmt" + "net/http" "net/http/httptest" + "net/url" + "reflect" "testing" ) @@ -16,7 +21,7 @@ func TestPageSizePolicy(t *testing.T) { {"limit=9223372036854775808", false, false, 0}, {"limit=0", false, false, 0}, {"limit=-1", false, false, 0}, {"limit=1.5", false, false, 0}, {"limit=", false, false, 0}, {"limit=null", false, false, 0}, {"limit=2&limit=3", false, false, 0}, {"order=invalid", false, false, 0}, - {"tenant_id=other", false, false, 0}, + {"order=", false, false, 0}, {"tenant_id=other", false, false, 0}, } { t.Run(test.query, func(t *testing.T) { for _, strict := range []bool{true, false} { @@ -34,3 +39,95 @@ func TestPageSizePolicy(t *testing.T) { }) } } + +func TestPageOrderPolicy(t *testing.T) { + for _, path := range []string{"/v1/agents", "/v1/files", "/v1/skills", "/v1/skills/skill-example/versions"} { + for _, test := range []struct { + query string + ascending bool + }{{"", false}, {"order=asc", true}, {"order=desc", false}} { + t.Run(path+"?"+test.query, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, path+"?"+test.query, nil) + page, ok := readPage(w, r) + if !ok || page.ascending != test.ascending || page.limit != 20 || w.Body.Len() != 0 { + t.Fatalf("page=%+v ok=%t response=%s", page, ok, w.Body.String()) + } + }) + } + } +} + +func TestListOrderErrorEnvelopes(t *testing.T) { + for _, test := range []struct { + path string + code, param any + message string + }{ + {"/v1/agents", "invalid_request_error", nil, "Failed to deserialize query string: order: unknown variant `%s`, expected `asc` or `desc`"}, + {"/v1/agents/environments/environment-example/files", "invalid_request_error", nil, "Failed to deserialize query string: order: unknown variant `%s`, expected `asc` or `desc`"}, + {"/v1/files", nil, nil, "order must be asc or desc."}, + {"/v1/skills", "invalid_value", "order", "Invalid value: '%s'. Supported values are: 'asc' and 'desc'."}, + {"/v1/skills/skill-example/versions", "invalid_value", "order", "Invalid value: '%s'. Supported values are: 'asc' and 'desc'."}, + } { + for _, order := range []string{"", "invalid", "ASC", " "} { + t.Run(test.path+"/"+order, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, test.path+"?order="+url.QueryEscape(order), nil) + if _, ok := readPage(w, r); ok { + t.Fatal("invalid order accepted") + } + message := test.message + if test.code != nil { + message = fmt.Sprintf(message, order) + } + assertListQueryError(t, w, test.code, test.param, message) + }) + } + } +} + +func TestListOrderValidationPreservesOtherQueryErrors(t *testing.T) { + for _, test := range []struct{ query, code string }{ + {"order=&limit=0", "invalid_request"}, + {"order=asc&order=desc", "unsupported_parameter"}, + {"order=&tenant_id=other", "unsupported_parameter"}, + } { + t.Run(test.query, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v1/skills?"+test.query, nil) + if _, ok := readPage(w, r); ok { + t.Fatal("invalid query accepted") + } + var response struct { + Error struct { + Code string + Param *string + } + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if w.Code != http.StatusBadRequest || response.Error.Code != test.code || response.Error.Param != nil { + t.Fatalf("other query error changed: %d %s", w.Code, w.Body.String()) + } + }) + } +} + +func assertListQueryError(t *testing.T, w *httptest.ResponseRecorder, code, param any, message string) { + t.Helper() + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + want := map[string]any{"error": map[string]any{ + "type": "invalid_request_error", "code": code, "param": param, "message": message, + }} + if w.Code != http.StatusBadRequest || !reflect.DeepEqual(body, want) { + t.Fatalf("error envelope: status=%d body=%s want=%v", w.Code, w.Body.String(), want) + } + if w.Header().Get("Content-Type") != "application/json" || w.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("error headers changed: %v", w.Header()) + } +} diff --git a/services/agents-api/internal/api/session_artifacts.go b/services/agents-api/internal/api/session_artifacts.go index 32aad8332..ecadde853 100644 --- a/services/agents-api/internal/api/session_artifacts.go +++ b/services/agents-api/internal/api/session_artifacts.go @@ -44,7 +44,7 @@ func (h *Handler) artifactsReady(w http.ResponseWriter, r *http.Request, list bo // @Param environment_id query string false "Producing Environment ID" // @Param after query string false "Last immutable artifact ID" // @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Publication order" Enums(asc,desc) default(desc) +// @Param order query string false "Publication order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.SessionArtifactList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/artifacts [get] diff --git a/services/agents-api/internal/api/skills_list.go b/services/agents-api/internal/api/skills_list.go index abcc4d5f6..6692df0c4 100644 --- a/services/agents-api/internal/api/skills_list.go +++ b/services/agents-api/internal/api/skills_list.go @@ -14,7 +14,7 @@ import ( // @Security BearerAuth // @Param after query string false "Skill resource cursor" // @Param limit query integer false "Page size" -// @Param order query string false "Creation order" Enums(asc,desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) // @Success 200 {object} v1.SkillList // @Router /skills [get] func (h *Handler) listSkills(w http.ResponseWriter, r *http.Request) { @@ -49,7 +49,7 @@ func (h *Handler) listSkills(w http.ResponseWriter, r *http.Request) { // @Param skill_id path string true "Skill ID" // @Param after query string false "Version resource cursor" // @Param limit query integer false "Page size" -// @Param order query string false "Version order" Enums(asc,desc) +// @Param order query string false "Version order; omit for descending, explicit empty values are invalid" Enums(asc,desc) // @Success 200 {object} v1.SkillVersionList // @Router /skills/{skill_id}/versions [get] func (h *Handler) listSkillVersions(w http.ResponseWriter, r *http.Request) { diff --git a/services/agents-api/internal/api/source_files_list.go b/services/agents-api/internal/api/source_files_list.go index d29365089..9aa057377 100644 --- a/services/agents-api/internal/api/source_files_list.go +++ b/services/agents-api/internal/api/source_files_list.go @@ -13,7 +13,7 @@ import ( // @Security BearerAuth // @Param after query string false "Last File ID from the previous page" // @Param limit query integer false "Maximum page size, 1–10000" default(10000) minimum(1) maximum(10000) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Param purpose query string false "Only return Files with this purpose" // @Success 200 {object} v1.SourceFileList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse @@ -44,7 +44,7 @@ func (h *Handler) listSourceFiles(w http.ResponseWriter, r *http.Request) { func readSourceFilePage(w http.ResponseWriter, r *http.Request) (pageOptions, *string, bool) { q := r.URL.Query() - options, ok := readPageQueryLimits(w, q, 10000, 10000, true, "purpose") + options, ok := readPageQueryLimits(w, r, q, 10000, 10000, true, "purpose") if !ok { return pageOptions{}, nil, false } diff --git a/services/agents-api/internal/api/source_files_list_test.go b/services/agents-api/internal/api/source_files_list_test.go index e95a216fc..f665fc958 100644 --- a/services/agents-api/internal/api/source_files_list_test.go +++ b/services/agents-api/internal/api/source_files_list_test.go @@ -45,7 +45,7 @@ func TestSourceFileListParametersAndEnvelope(t *testing.T) { func TestSourceFileListRejectsInvalidQueriesBeforeStorage(t *testing.T) { for _, query := range []string{ "limit=", "limit=0", "limit=10001", "limit=1.5", "limit=1&limit=2", - "order=invalid", "after=a&after=b", "purpose=a&purpose=b", "tenant_id=foreign", + "order=", "order=invalid", "after=a&after=b", "purpose=a&purpose=b", "tenant_id=foreign", } { f := &sourceFilesFixture{} h, _ := environmentFileCreateHandler(t, WithSourceFiles(f)) diff --git a/services/agents-api/internal/api/subagent_turns.go b/services/agents-api/internal/api/subagent_turns.go index 0f9afbc67..d090f1f3e 100644 --- a/services/agents-api/internal/api/subagent_turns.go +++ b/services/agents-api/internal/api/subagent_turns.go @@ -44,7 +44,7 @@ func (h *Handler) getSubagentTurn(w http.ResponseWriter, r *http.Request) { // @Param subagent_id path string true "Subagent ID" // @Param after query string false "Last Turn ID from the previous page" // @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.TurnList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/subagents/{subagent_id}/turns [get] @@ -72,7 +72,7 @@ func (h *Handler) listSubagentTurns(w http.ResponseWriter, r *http.Request) { // @Param turn_id path string true "Turn ID" // @Param after query string false "Last Item ID from the previous page" // @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Resource order" Enums(asc,desc) default(desc) +// @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.ItemList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}/items [get] diff --git a/services/agents-api/internal/api/subagents.go b/services/agents-api/internal/api/subagents.go index 93e1b307b..a7a54306b 100644 --- a/services/agents-api/internal/api/subagents.go +++ b/services/agents-api/internal/api/subagents.go @@ -75,7 +75,7 @@ func (h *Handler) getSubagent(w http.ResponseWriter, r *http.Request) { // @Param session_id path string true "Session ID" // @Param after query string false "Last Subagent ID from the previous page" // @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Resource order" Enums(asc,desc) default(desc) +// @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.SubagentList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/subagents [get] @@ -105,7 +105,7 @@ func (h *Handler) listSubagents(w http.ResponseWriter, r *http.Request) { // @Param subagent_id path string true "Subagent ID" // @Param after query string false "Last Item ID from the previous page" // @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Resource order" Enums(asc,desc) default(desc) +// @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.ItemList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/subagents/{subagent_id}/items [get] diff --git a/services/agents-api/internal/api/turns.go b/services/agents-api/internal/api/turns.go index cd5226e23..ded7ebb91 100644 --- a/services/agents-api/internal/api/turns.go +++ b/services/agents-api/internal/api/turns.go @@ -54,7 +54,7 @@ func (h *Handler) getTurn(w http.ResponseWriter, r *http.Request) { // @Param session_id path string true "Session ID" // @Param after query string false "Last Turn ID from the previous page" // @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Success 200 {object} v1.TurnList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/turns [get] diff --git a/services/agents-api/internal/api/vault_pagination.go b/services/agents-api/internal/api/vault_pagination.go index 9290e11e3..5f9b614ee 100644 --- a/services/agents-api/internal/api/vault_pagination.go +++ b/services/agents-api/internal/api/vault_pagination.go @@ -19,7 +19,7 @@ func readVaultPage(w http.ResponseWriter, r *http.Request) (pageOptions, []strin } for _, status := range statuses { if status != "active" && status != "archived" { - writeError(w, http.StatusBadRequest, "invalid_request", "status must be active or archived.") + writeError(w, http.StatusBadRequest, "invalid_request_error", "Failed to deserialize query string: status: data did not match any variant of untagged enum VaultStatusFilterParam") return pageOptions{}, nil, false } } @@ -34,6 +34,6 @@ func readVaultPage(w http.ResponseWriter, r *http.Request) (pageOptions, []strin } q.Set("limit", strconv.FormatInt(max(1, min(requested, 100)), 10)) } - options, ok := readPageQuery(w, q, false) + options, ok := readPageQuery(w, r, q, false) return options, statuses, ok } diff --git a/services/agents-api/internal/api/vault_pagination_test.go b/services/agents-api/internal/api/vault_pagination_test.go new file mode 100644 index 000000000..1fe129480 --- /dev/null +++ b/services/agents-api/internal/api/vault_pagination_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestVaultStatusErrorEnvelopes(t *testing.T) { + for _, path := range []string{"/v1/vaults", "/v1/vaults/vault-example/credentials"} { + for _, query := range []string{"status=invalid", "status=", "status[]=active&status[]=invalid", "status[]="} { + t.Run(path+"?"+query, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, path+"?"+query, nil) + if _, _, ok := readVaultPage(w, r); ok { + t.Fatal("invalid status accepted") + } + assertListQueryError(t, w, "invalid_request_error", nil, "Failed to deserialize query string: status: data did not match any variant of untagged enum VaultStatusFilterParam") + }) + } + } +} + +func TestVaultStatusGrammarUnchanged(t *testing.T) { + for _, query := range []string{"status=active&status=archived", "status=active&status[]=archived"} { + t.Run(query, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v1/vaults?"+query, nil) + if _, _, ok := readVaultPage(w, r); ok { + t.Fatal("invalid status grammar accepted") + } + assertListQueryError(t, w, "invalid_request", nil, "Supply status once or use status[] for an array.") + }) + } +} diff --git a/services/agents-api/internal/api/vaults_list.go b/services/agents-api/internal/api/vaults_list.go index 57aa2973a..862915863 100644 --- a/services/agents-api/internal/api/vaults_list.go +++ b/services/agents-api/internal/api/vaults_list.go @@ -14,7 +14,7 @@ import ( // @Param OpenAI-Beta header string true "agents=v1" // @Param after query string false "Last Vault ID from the previous page" // @Param limit query integer false "Requested page size, clamped to 1–100" default(20) -// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) // @Param status query string false "Scalar status filter" Enums(active,archived) // @Param status[] query []string false "Array status filter; cannot be combined with status" collectionFormat(multi) Enums(active,archived) // @Success 200 {object} v1.VaultList diff --git a/services/agents-api/internal/store/list_query_public_test.go b/services/agents-api/internal/store/list_query_public_test.go new file mode 100644 index 000000000..6faeaeeda --- /dev/null +++ b/services/agents-api/internal/store/list_query_public_test.go @@ -0,0 +1,66 @@ +package store_test + +import ( + "bytes" + "context" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestListQueryOfficialClientPostgres(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{72}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "query-owner", TokenSHA256: device.HashCredential(token), TenantID: uuid.NewString()}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "query-foreign", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + // Use real admission while leaving dispatch paused. Public cancellation retains + // the queued history; this fixture does not perform native or model execution. + worker, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + stopped, cancel := context.WithCancel(context.Background()) + cancel() + if err := worker.Run(stopped); err != context.Canceled { + t.Error(err) + } + }) + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker), api.WithSkills(s), api.WithSourceFiles(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_list_query.py", server.URL, token, foreign) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("official list query acceptance: %v %s", err, output) + } + t.Log(string(output)) +} diff --git a/services/agents-api/tests/official_list_query.py b/services/agents-api/tests/official_list_query.py new file mode 100644 index 000000000..b862cba1f --- /dev/null +++ b/services/agents-api/tests/official_list_query.py @@ -0,0 +1,164 @@ +"""List query acceptance through real HTTP/PostgreSQL and the fixed official SDK. + +Owned fixtures use real Worker admission with dispatch paused. No native executor +or model runs, and initial Turn/Item history remains visible throughout the test. +""" + +import importlib.metadata +import json +import secrets +import sys +from contextlib import ExitStack +from pathlib import Path + +import httpx2 +from openai import APIStatusError, DefaultHttpxClient, OpenAI + + +def main(): + base, token, foreign = sys.argv[1:] + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + assert distribution.version == pin["sdk_version"] + assert json.loads(distribution.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] + with ExitStack() as cleanup: + raw = cleanup.enter_context(httpx2.Client(trust_env=False, timeout=10)) + client = cleanup.enter_context(OpenAI( + api_key=token, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, http_client=DefaultHttpxClient(trust_env=False), + )) + agents = client.beta.agents + sessions = agents.sessions + templates = agents.environments.templates + vaults = agents.vaults + secret = "query-credential-" + secrets.token_hex(16) + owned = {name: [] for name in ("agents", "templates", "vaults", "credentials", "sessions", "files", "skills")} + for index in range(2): + agent = agents.create(model="query-fixture-model", name=f"Query Agent {index}") + owned["agents"].append(agent.id) + cleanup.callback(agents.delete, agent.id) + template = templates.create(name=f"Query Template {index}") + owned["templates"].append(template.id) + cleanup.callback(templates.delete, template.id) + vault = vaults.create(name=f"Query Vault {index}") + owned["vaults"].append(vault.id) + cleanup.callback(vaults.delete, vault.id) + file = client.files.create(file=(f"query-{index}.txt", b"Owned list query fixture"), purpose="user_data") + owned["files"].append(file.id) + cleanup.callback(client.files.delete, file.id) + manifest = f"---\nname: query-{index}\ndescription: Owned query fixture.\n---\nList metadata only.\n" + skill = client.skills.create(files=[(f"query-{index}/SKILL.md", manifest.encode(), "text/markdown")]) + owned["skills"].append(skill.id) + cleanup.callback(client.skills.delete, skill.id) + session = sessions.create( + agent_id=agent.id, environment={"type": "none"}, + input=f"Retain query fixture initial history {index}.", + ) + owned["sessions"].append(session.id) + cleanup.callback(sessions.delete, session.id) + sessions.events.create(session.id, events=[{"type": "agent.session.input.cancel"}]) + vault_id = owned["vaults"][0] + for index in range(2): + credential = vaults.credentials.create( + vault_id, name=f"Query Credential {index}", + auth={"type": "static_bearer", "mcp_server_url": "https://query.example.invalid/mcp", "token": secret}, + ) + owned["credentials"].append(credential.id) + session_id = owned["sessions"][0] + for index in range(2): + sessions.events.create(session_id, events=[{ + "type": "agent.session.input.message", + "input": [{"role": "user", "content": [{"type": "input_text", "text": f"Retain query history {index}."}]}], + }]) + sessions.events.create(session_id, events=[{"type": "agent.session.input.cancel"}]) + turns = list(sessions.turns.list(session_id, order="asc")) + items = list(sessions.items.list(session_id, order="asc")) + assert len(turns) == len(items) == 3 + assert all(turn.status == "cancelled" for turn in turns) + owned["turns"] = [turn.id for turn in turns] + owned["items"] = [item.id for item in items] + + families = [ + ("agents", "/agents", agents.list, True, False), + ("templates", "/agents/environments/templates", templates.list, True, False), + ("sessions", "/agents/sessions", sessions.list, True, False), + ("turns", f"/agents/sessions/{session_id}/turns", lambda **q: sessions.turns.list(session_id, **q), True, True), + ("items", f"/agents/sessions/{session_id}/items", lambda **q: sessions.items.list(session_id, **q), True, True), + ("vaults", "/vaults", vaults.list, True, False), + ("credentials", f"/vaults/{vault_id}/credentials", lambda **q: vaults.credentials.list(vault_id, **q), True, True), + ("files", "/files", client.files.list, False, False), + ("skills", "/skills", client.skills.list, False, False), + ] + state_before = {name: [value.to_dict() for value in listing(order="asc", limit=1)] for name, _, listing, _, _ in families} + rejected = 0 + for name, path, listing, beta, nested in families: + headers = {"Authorization": "Bearer " + token} + if beta: + headers["OpenAI-Beta"] = "agents=v1" + url = base + "/v1" + path + before = state_before[name] + assert [value["id"] for value in before] == owned[name], name + assert [value.id for value in listing(order="desc", limit=1)] == owned[name][::-1], name + assert [value.id for value in listing(limit=1)] == owned[name][::-1], name + page = raw.get(url, headers=headers, params={"order": "asc", "limit": 1}) + assert page.status_code == 200 and page.json()["has_more"] is True, name + assert page.json()["first_id"] == page.json()["last_id"] == owned[name][0], name + tail = raw.get(url, headers=headers, params={"order": "asc", "after": owned[name][-1], "limit": 1}) + assert tail.status_code == 200 and tail.json()["data"] == [] and tail.json()["has_more"] is False, name + + expected_code = "invalid_request_error" if beta else ("invalid_value" if name == "skills" else None) + expected_param = "order" if name == "skills" else None + for order in ("", "sideways"): + response = raw.get(url, headers=headers, params={"order": order}) + assert response.status_code == 400, (name, order, response.status_code) + body = response.json()["error"] + assert body["type"] == "invalid_request_error" and body["code"] == expected_code and body["param"] == expected_param, (name, body) + assert isinstance(body["message"], str) and body["message"], name + assert secret not in response.text and all(value not in response.text for value in owned[name]), name + if order: + try: + listing(order=order) + except APIStatusError as error: + assert error.status_code == 400 and error.body == body, (name, error.body) + else: + raise AssertionError(f"SDK accepted invalid {name} order") + else: + # Fixed 3.13 drops empty query values in _qs.stringify_items. + # Only the raw request above exercises an explicit order=. + assert [value.id for value in listing(order="", limit=1)] == owned[name][::-1], name + foreign_error = raw.get(url, headers={**headers, "Authorization": "Bearer " + foreign}, params={"order": order}) + assert foreign_error.status_code == 400 and foreign_error.json() == response.json(), name + unauthorized = raw.get(url, headers={k: v for k, v in headers.items() if k != "Authorization"}, params={"order": order}) + assert unauthorized.status_code == 401 and secret not in unauthorized.text, name + rejected += 2 if order else 1 + + foreign_page = raw.get(url, headers={**headers, "Authorization": "Bearer " + foreign}, params={"order": "asc"}) + if nested: + assert foreign_page.status_code == 404, name + else: + assert foreign_page.status_code == 200 and foreign_page.json()["data"] == [], name + assert secret not in foreign_page.text and all(value not in foreign_page.text for value in owned[name]), name + if name in {"vaults", "credentials"}: + for query in ({"status": "query-invalid"}, {"status[]": "query-invalid"}): + response = raw.get(url, headers=headers, params=query) + assert response.status_code == 400, name + error = response.json()["error"] + assert (error["type"], error["code"], error["param"]) == ("invalid_request_error", "invalid_request_error", None), (name, error) + try: + listing(extra_query=query) + except APIStatusError as sdk_error: + assert sdk_error.status_code == 400 and sdk_error.body == error, name + else: + raise AssertionError(f"SDK accepted invalid {name} status") + rejected += 2 + assert [value.to_dict() for value in listing(order="asc", limit=1)] == before, name + assert {name: [value.to_dict() for value in listing(order="asc", limit=1)] for name, _, listing, _, _ in families} == state_before + assert list(sessions.turns.list(session_id, order="asc")) == turns + assert list(sessions.items.list(session_id, order="asc")) == items + print(json.dumps({"result": "passed", "families": len(families), "sdk_and_raw_rejections": rejected, + "sdk_empty_order_omitted": len(families), "retained_turns": len(turns), "retained_items": len(items), "postgres": True, + "worker_admission": True, "native_model_execution": False})) + + +if __name__ == "__main__": + main()