From 9ffa8ff7f7c79887bf7c01dd3b8e6ce2f65d78f4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:16:27 +0800 Subject: [PATCH 01/15] Align observed beta and ordinary resource error categories --- apps/web/src/lib/core-probe.test.ts | 2 +- apps/web/src/lib/core-probe.ts | 2 +- contracts/agents-api/v1/sessions.go | 2 +- services/agents-api/internal/api/auth.go | 2 +- .../internal/api/environment_creation_test.go | 4 +- services/agents-api/internal/api/errors.go | 16 ++++- .../agents-api/internal/api/errors_test.go | 68 +++++++++++++++++++ .../agents-api/internal/api/handler_test.go | 2 +- .../internal/api/session_request_test.go | 2 +- 9 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 services/agents-api/internal/api/errors_test.go diff --git a/apps/web/src/lib/core-probe.test.ts b/apps/web/src/lib/core-probe.test.ts index 1e9c5932f..c5eef9767 100644 --- a/apps/web/src/lib/core-probe.test.ts +++ b/apps/web/src/lib/core-probe.test.ts @@ -208,7 +208,7 @@ describe("Core connection probe", () => { }); it.each([ - [jsonResponse({ error: { code: "invalid_beta_header" } }, 400), 400], + [jsonResponse({ error: { code: "invalid_beta" } }, 400), 400], [jsonResponse({ error: { code: "not_found" } }, 404), 404], [jsonResponse({ error: { code: "method_not_allowed" } }, 405), 405], [jsonResponse(canonicalPage(), 201), 201], diff --git a/apps/web/src/lib/core-probe.ts b/apps/web/src/lib/core-probe.ts index ffce4d4c5..82de58b5a 100644 --- a/apps/web/src/lib/core-probe.ts +++ b/apps/web/src/lib/core-probe.ts @@ -204,7 +204,7 @@ export async function probeCore(options: CoreProbeOptions): Promise Date: Tue, 22 Sep 2026 23:12:31 +0800 Subject: [PATCH 02/15] Align resource creation, empty updates and credential validation --- contracts/agents-api/v1/credentials.go | 8 +-- services/agents-api/internal/api/agents.go | 10 +++- .../agents-api/internal/api/agents_update.go | 2 +- .../agents-api/internal/api/credentials.go | 10 ++-- .../internal/api/credentials_oauth.go | 9 ++- .../internal/api/credentials_oauth_test.go | 21 ++++--- .../internal/api/credentials_test.go | 3 +- .../internal/api/credentials_update.go | 6 +- .../internal/api/credentials_update_test.go | 4 +- .../internal/api/environment_templates.go | 6 +- .../internal/api/resource_creation_test.go | 40 +++++++++++++ services/agents-api/internal/api/vaults.go | 4 +- .../agents-api/internal/api/vaults_test.go | 2 +- .../internal/store/agents_update.go | 3 - .../internal/store/agents_update_test.go | 8 ++- .../internal/store/environment_templates.go | 3 - .../store/environment_templates_noop_test.go | 56 +++++++++++++++++++ .../agents-api/tests/official_agent_update.py | 9 ++- services/agents-api/tests/official_agents.py | 2 + .../tests/official_credential_rotation.py | 3 +- .../agents-api/tests/official_credentials.py | 7 ++- .../tests/official_environment_templates.py | 6 +- .../tests/official_oauth_credentials.py | 15 ++--- services/agents-api/tests/official_vaults.py | 4 +- 24 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 services/agents-api/internal/api/resource_creation_test.go create mode 100644 services/agents-api/internal/store/environment_templates_noop_test.go diff --git a/contracts/agents-api/v1/credentials.go b/contracts/agents-api/v1/credentials.go index 4ef51b135..e5360ad68 100644 --- a/contracts/agents-api/v1/credentials.go +++ b/contracts/agents-api/v1/credentials.go @@ -7,8 +7,8 @@ import "encoding/json" type CredentialAuthInput struct { Type string `json:"type" binding:"required" enums:"static_bearer,mcp_oauth"` MCPServerURL *string `json:"mcp_server_url" binding:"required"` - Token *string `json:"token,omitempty"` - AccessToken *string `json:"access_token,omitempty"` + Token *string `json:"token,omitempty" minLength:"1"` + AccessToken *string `json:"access_token,omitempty" minLength:"1"` ExpiresAt *string `json:"expires_at,omitempty" extensions:"x-nullable"` Refresh *OAuthCredentialRefreshInput `json:"refresh,omitempty" extensions:"x-nullable"` } @@ -39,8 +39,8 @@ type UpdateCredentialRequest struct { // Raw nullable fields retain omission separately from explicit null. type CredentialAuthReplacement struct { Type string `json:"type" binding:"required" enums:"static_bearer,mcp_oauth"` - Token *string `json:"token,omitempty"` - AccessToken *string `json:"access_token,omitempty" extensions:"x-nullable"` + Token *string `json:"token,omitempty" minLength:"1"` + AccessToken *string `json:"access_token,omitempty" extensions:"x-nullable" minLength:"1"` ExpiresAt json.RawMessage `json:"expires_at,omitempty" swaggertype:"string" extensions:"x-nullable"` Refresh *OAuthCredentialRefreshReplacement `json:"refresh,omitempty" extensions:"x-nullable"` } diff --git a/services/agents-api/internal/api/agents.go b/services/agents-api/internal/api/agents.go index 0144aeec8..ae118b149 100644 --- a/services/agents-api/internal/api/agents.go +++ b/services/agents-api/internal/api/agents.go @@ -27,7 +27,7 @@ type AgentStore interface { // @Security BearerAuth // @Param OpenAI-Beta header string true "agents=v1" // @Param body body v1.CreateAgentRequest true "Reusable Agent configuration" -// @Success 200 {object} v1.SavedAgent +// @Success 201 {object} v1.SavedAgent // @Failure 400,401,413,500 {object} v1.ErrorResponse // @Router /agents [post] func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { @@ -54,7 +54,7 @@ func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - h.respondAgent(w, r, agent) + h.respondAgentStatus(w, r, agent, http.StatusCreated) } // @Summary Retrieve a reusable Agent @@ -81,12 +81,16 @@ func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) { } func (h *Handler) respondAgent(w http.ResponseWriter, r *http.Request, agent store.SavedAgent) { + h.respondAgentStatus(w, r, agent, http.StatusOK) +} + +func (h *Handler) respondAgentStatus(w http.ResponseWriter, r *http.Request, agent store.SavedAgent, status int) { response, err := agentResponse(agent) if err != nil { writeStoreError(w, r, err) return } - writeJSON(w, http.StatusOK, response) + writeJSON(w, status, response) } func agentResponse(agent store.SavedAgent) (v1.SavedAgent, error) { diff --git a/services/agents-api/internal/api/agents_update.go b/services/agents-api/internal/api/agents_update.go index ee9e27ffe..5041fdfd1 100644 --- a/services/agents-api/internal/api/agents_update.go +++ b/services/agents-api/internal/api/agents_update.go @@ -11,7 +11,7 @@ import ( ) // @Summary Update a reusable Agent -// @Description Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty metadata clears all pairs. Existing Session snapshots are unchanged. Nested replacement/null defaults, model-derived reasoning and exact hosted error/no-op timestamp behavior remain incompletely verified. +// @Description Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty metadata clears all pairs. Existing Session snapshots are unchanged. Empty updates advance updated_at without changing saved fields. Nested replacement/null defaults, model-derived reasoning and exact hosted error behavior remain incompletely verified. // @Tags Agents // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/credentials.go b/services/agents-api/internal/api/credentials.go index 7fca2843c..c1cf7a765 100644 --- a/services/agents-api/internal/api/credentials.go +++ b/services/agents-api/internal/api/credentials.go @@ -22,7 +22,7 @@ type CredentialStore interface { } // @Summary Create a Vault Credential -// @Description Stores static_bearer or mcp_oauth secrets as execution-owned authenticated ciphertext without contacting any endpoint. OAuth accepts a required access token, nullable RFC3339 expiry and optional refresh configuration with none, client_secret_basic or client_secret_post authentication. Required name is trimmed to 1–256 UTF-8 bytes. Credential and token endpoints require HTTPS without userinfo or fragments. Responses contain safe metadata only, including explicit nullable OAuth expiry, refresh, resource and scope. Missing encryption configuration returns local 503. External authorization and provider revocation remain caller responsibilities; exact hosted error/default semantics remain unverified. +// @Description Stores static_bearer or mcp_oauth secrets as execution-owned authenticated ciphertext without contacting any endpoint. Static bearer and OAuth access tokens must be nonempty strings; their bytes are preserved. OAuth accepts a required access token, nullable RFC3339 expiry and optional refresh configuration with none, client_secret_basic or client_secret_post authentication. Required name is trimmed to 1–256 UTF-8 bytes. Credential and token endpoints require HTTPS without userinfo or fragments. Responses contain safe metadata only, including explicit nullable OAuth expiry, refresh, resource and scope. Missing encryption configuration returns local 503. External authorization and provider revocation remain caller responsibilities; exact hosted error/default semantics remain unverified. // @Tags Credentials // @Accept json // @Produce json @@ -30,7 +30,7 @@ type CredentialStore interface { // @Param OpenAI-Beta header string true "agents=v1" // @Param vault_id path string true "Vault ID" // @Param body body v1.CreateCredentialRequest true "Write-only credential authentication union" -// @Success 200 {object} v1.Credential +// @Success 201 {object} v1.Credential // @Failure 400,401,404,413,500,503 {object} v1.ErrorResponse // @Router /vaults/{vault_id}/credentials [post] func (h *Handler) createCredential(w http.ResponseWriter, r *http.Request) { @@ -63,8 +63,8 @@ func (h *Handler) createCredential(w http.ResponseWriter, r *http.Request) { switch credentialAuthType(request.Auth) { case "static_bearer": var auth v1.CredentialAuthInput - if decodeInputObject(request.Auth, &auth, "type", "mcp_server_url", "token") != nil || auth.Token == nil || !credentialHTTPSURL(auth.MCPServerURL) { - writeError(w, http.StatusBadRequest, "invalid_request", "static_bearer requires string token and an absolute HTTPS mcp_server_url without userinfo or a fragment.") + if decodeInputObject(request.Auth, &auth, "type", "mcp_server_url", "token") != nil || auth.Token == nil || *auth.Token == "" || !credentialHTTPSURL(auth.MCPServerURL) { + writeError(w, http.StatusBadRequest, "invalid_request", "static_bearer requires a nonempty string token and an absolute HTTPS mcp_server_url without userinfo or a fragment.") return } credential, err = h.store.CreateStaticCredential(r.Context(), tenantID(r), vaultID, store.CreateStaticCredentialInput{Name: name, MCPServerURL: *auth.MCPServerURL, Token: *auth.Token}) @@ -83,7 +83,7 @@ func (h *Handler) createCredential(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - writeJSON(w, http.StatusOK, credentialResponse(credential)) + writeJSON(w, http.StatusCreated, credentialResponse(credential)) } // @Summary Retrieve safe Vault Credential metadata diff --git a/services/agents-api/internal/api/credentials_oauth.go b/services/agents-api/internal/api/credentials_oauth.go index d377a39ac..d7b7d1734 100644 --- a/services/agents-api/internal/api/credentials_oauth.go +++ b/services/agents-api/internal/api/credentials_oauth.go @@ -44,7 +44,7 @@ func credentialAuthType(raw json.RawMessage) string { func oauthCredentialCreate(raw json.RawMessage, name string) (store.CreateOAuthCredentialInput, error) { var auth v1.CredentialAuthInput input := store.CreateOAuthCredentialInput{Name: name} - if decodeInputObject(raw, &auth, "type", "mcp_server_url", "access_token", "expires_at", "refresh") != nil || auth.Type != "mcp_oauth" || auth.AccessToken == nil || !credentialHTTPSURL(auth.MCPServerURL) || !credentialExpiry(auth.ExpiresAt) { + if decodeInputObject(raw, &auth, "type", "mcp_server_url", "access_token", "expires_at", "refresh") != nil || auth.Type != "mcp_oauth" || auth.AccessToken == nil || *auth.AccessToken == "" || !credentialHTTPSURL(auth.MCPServerURL) || !credentialExpiry(auth.ExpiresAt) { return input, store.ErrInvalidInput } input.MCPServerURL, input.AccessToken = *auth.MCPServerURL, *auth.AccessToken @@ -88,6 +88,9 @@ func oauthCredentialUpdate(raw json.RawMessage) (store.UpdateOAuthCredentialInpu return input, store.ErrInvalidInput } input.AccessToken = auth.AccessToken + if input.AccessToken != nil && *input.AccessToken == "" { + return input, store.ErrInvalidInput + } if len(auth.ExpiresAt) > 0 { input.ExpiresAtSet = true if json.Unmarshal(auth.ExpiresAt, &input.ExpiresAt) != nil || !credentialExpiry(input.ExpiresAt) { @@ -109,5 +112,9 @@ func oauthCredentialUpdate(raw json.RawMessage) (store.UpdateOAuthCredentialInpu input.Refresh.TokenEndpointAuthType, input.Refresh.ClientSecret = a.Type, a.ClientSecret } } + if input.AccessToken == nil && !input.ExpiresAtSet && (input.Refresh == nil || + (input.Refresh.RefreshToken == nil && input.Refresh.ClientSecret == nil && !input.Refresh.ScopeSet)) { + return input, store.ErrInvalidInput + } return input, nil } diff --git a/services/agents-api/internal/api/credentials_oauth_test.go b/services/agents-api/internal/api/credentials_oauth_test.go index 444025aeb..3f61ee10a 100644 --- a/services/agents-api/internal/api/credentials_oauth_test.go +++ b/services/agents-api/internal/api/credentials_oauth_test.go @@ -48,7 +48,7 @@ func TestOAuthCredentialVariantsAndSafeResourceReads(t *testing.T) { path := "/v1/vaults/" + f.credential.VaultID + "/credentials" w := credentialRequest(h, "POST", path, oauthCreateBody(t, auth)) var body map[string]any - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &body) != nil || !reflect.DeepEqual(body["auth"], wantAuth) || strings.Contains(w.Body.String(), "canary") { + if w.Code != 201 || json.Unmarshal(w.Body.Bytes(), &body) != nil || !reflect.DeepEqual(body["auth"], wantAuth) || strings.Contains(w.Body.String(), "canary") { t.Fatal("OAuth resource must expose only exact safe metadata", w.Code) } if f.tenant != tenant || f.vault != f.credential.VaultID || f.oauthInput.Name != "OAuth credential" || f.oauthInput.AccessToken != "access-canary" { @@ -82,6 +82,7 @@ func TestOAuthCreateRejectsInvalidFieldsBeforeStorage(t *testing.T) { for _, auth := range []string{ `{"type":"mcp_oauth","mcp_server_url":"https://mcp.example"}`, `{"type":"mcp_oauth","mcp_server_url":"https://mcp.example","access_token":null}`, + `{"type":"mcp_oauth","mcp_server_url":"https://mcp.example","access_token":""}`, `{"type":"mcp_oauth","mcp_server_url":"http://mcp.example","access_token":"access-canary"}`, base + `,"token":"cross-variant"}`, base + `,"expires_at":3}`, base + `,"expires_at":"tomorrow"}`, base + `,"refresh":{}}`, base + `,"refresh":[]}`, base + `,"refresh":{"client_id":null}}`, @@ -107,16 +108,11 @@ func TestOAuthUpdateRetainsPresenceAndSecretPointers(t *testing.T) { auth string want store.UpdateOAuthCredentialInput }{ - {`{"type":"mcp_oauth"}`, store.UpdateOAuthCredentialInput{}}, - {`{"type":"mcp_oauth","access_token":null,"refresh":null}`, store.UpdateOAuthCredentialInput{}}, - {`{"type":"mcp_oauth","access_token":""}`, store.UpdateOAuthCredentialInput{AccessToken: text("")}}, + {`{"type":"mcp_oauth","access_token":" \t"}`, store.UpdateOAuthCredentialInput{AccessToken: text(" \t")}}, {`{"type":"mcp_oauth","expires_at":null}`, store.UpdateOAuthCredentialInput{ExpiresAtSet: true}}, {`{"type":"mcp_oauth","expires_at":"2026-09-22T12:30:00.123+08:00"}`, store.UpdateOAuthCredentialInput{ExpiresAtSet: true, ExpiresAt: text("2026-09-22T12:30:00.123+08:00")}}, - {`{"type":"mcp_oauth","refresh":{}}`, store.UpdateOAuthCredentialInput{Refresh: &store.OAuthRefreshUpdate{}}}, - {`{"type":"mcp_oauth","refresh":{"refresh_token":null,"token_endpoint_auth":null}}`, store.UpdateOAuthCredentialInput{Refresh: &store.OAuthRefreshUpdate{}}}, {`{"type":"mcp_oauth","refresh":{"scope":null}}`, store.UpdateOAuthCredentialInput{Refresh: &store.OAuthRefreshUpdate{ScopeSet: true}}}, {`{"type":"mcp_oauth","refresh":{"scope":"","refresh_token":"refresh-canary","token_endpoint_auth":{"type":"client_secret_post","client_secret":"client-canary"}}}`, store.UpdateOAuthCredentialInput{Refresh: &store.OAuthRefreshUpdate{Scope: text(""), ScopeSet: true, RefreshToken: text("refresh-canary"), TokenEndpointAuthType: "client_secret_post", ClientSecret: text("client-canary")}}}, - {`{"type":"mcp_oauth","refresh":{"token_endpoint_auth":{"type":"client_secret_basic","client_secret":null}}}`, store.UpdateOAuthCredentialInput{Refresh: &store.OAuthRefreshUpdate{TokenEndpointAuthType: "client_secret_basic"}}}, } { h, f, tenant := credentialHandler(t) f.credential.AuthType, f.credential.OAuth = "mcp_oauth", &store.OAuthMetadata{} @@ -129,6 +125,9 @@ func TestOAuthUpdateRetainsPresenceAndSecretPointers(t *testing.T) { func TestOAuthUpdateRejectsInvalidPatchesBeforeStorage(t *testing.T) { for _, patch := range []string{ + `"access_token":""`, `"access_token":null`, `"refresh":{}`, + `"refresh":{"refresh_token":null,"token_endpoint_auth":null}`, + `"refresh":{"token_endpoint_auth":{"type":"client_secret_basic","client_secret":null}}`, `"token":"cross-variant"`, `"access_token":3`, `"expires_at":[]`, `"expires_at":"not a timestamp"`, `"expires_at":"2026-09-22T1:02:03Z"`, `"expires_at":"2026-09-22T01:02:03+24:00"`, `"refresh":{"client_id":"other"}`, `"refresh":{"token_endpoint":"https://other.example"}`, `"refresh":{"resource":null}`, `"refresh":{"scope":3}`, `"refresh":{"refresh_token":false}`, @@ -164,3 +163,11 @@ func TestOAuthCredentialStoreFailuresUseSafeExistingErrors(t *testing.T) { } } } + +func TestOAuthTypeOnlyUpdateRejectsBeforeStorage(t *testing.T) { + h, f, _ := credentialHandler(t) + w := credentialRequest(h, "POST", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+f.credential.ID, `{"auth":{"type":"mcp_oauth"}}`) + if w.Code != 400 || f.calls != 0 { + t.Fatal("empty OAuth update reached storage", w.Code) + } +} diff --git a/services/agents-api/internal/api/credentials_test.go b/services/agents-api/internal/api/credentials_test.go index 39eb0dd09..6f3017cfa 100644 --- a/services/agents-api/internal/api/credentials_test.go +++ b/services/agents-api/internal/api/credentials_test.go @@ -64,7 +64,7 @@ func TestCredentialSafeProjectionAndOpaqueInput(t *testing.T) { path := "/v1/vaults/" + f.credential.VaultID + "/credentials" w := credentialRequest(h, "POST", path, `{"name":" Vault credential \n","auth":{"type":"static_bearer","mcp_server_url":"https://example.invalid/mcp?q=x","token":" \tcredential-canary\n雪 "}}`) var got map[string]any - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil { + if w.Code != 201 || json.Unmarshal(w.Body.Bytes(), &got) != nil { t.Fatal(w.Code, w.Body) } want := map[string]any{"id": f.credential.ID, "vault_id": f.credential.VaultID, "name": "Vault credential", "object": "vault.credential", "created_at": float64(1700000000), "updated_at": float64(1700000000), "auth": map[string]any{"type": "static_bearer", "mcp_server_url": "https://example.invalid/mcp?q=x"}} @@ -81,6 +81,7 @@ func TestCredentialInvalidRequestsNeverReachStorage(t *testing.T) { for _, body := range []string{ `null`, `[]`, `{} {}`, `{}`, `{"name":null,"auth":{}}`, `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://example.invalid","token":null}}`, + `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://example.invalid","token":""}}`, `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://credential-canary@example.invalid","token":"credential-canary"}}`, `{"name":"n","auth":{"type":"mcp_oauth","access_token":"credential-canary"}}`, `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"http://example.invalid","token":"credential-canary"}}`, diff --git a/services/agents-api/internal/api/credentials_update.go b/services/agents-api/internal/api/credentials_update.go index b89bc8f91..dd20aa2c5 100644 --- a/services/agents-api/internal/api/credentials_update.go +++ b/services/agents-api/internal/api/credentials_update.go @@ -9,7 +9,7 @@ import ( ) // @Summary Replace Vault Credential authentication secrets -// @Description Updates the existing static_bearer or mcp_oauth authentication method without network requests. OAuth access_token omission/null retains the token; a new token clears omitted expiry, explicit null clears expiry, and other omitted fields remain unchanged. OAuth refresh patches cannot add configuration or change client, endpoint, resource or authentication method; nullable token/client-secret values retain stored secrets while explicit null scope clears scope. Whole-null refresh and token_endpoint_auth retain existing configuration under local policy. Identity, destination, creation time and Session bindings remain unchanged. Responses expose safe metadata only. Already-dispatched work is not revoked; provider revocation, storage-key rotation and exact hosted concurrent-update/error semantics remain separate. +// @Description Explicitly empty static bearer or OAuth access tokens and OAuth patches without a mutable field are rejected before storage. Omitted OAuth access tokens preserve the existing grant when expiry or refresh fields change. Updates the existing static_bearer or mcp_oauth authentication method without network requests. OAuth access_token omission/null retains the token; a new token clears omitted expiry, explicit null clears expiry, and other omitted fields remain unchanged. OAuth refresh patches cannot add configuration or change client, endpoint, resource or authentication method; nullable token/client-secret values retain stored secrets while explicit null scope clears scope. Whole-null refresh and token_endpoint_auth retain existing configuration under local policy. Identity, destination, creation time and Session bindings remain unchanged. Responses expose safe metadata only. Already-dispatched work is not revoked; provider revocation, storage-key rotation and exact hosted concurrent-update/error semantics remain separate. // @Tags Credentials // @Accept json // @Produce json @@ -50,8 +50,8 @@ func (h *Handler) updateCredential(w http.ResponseWriter, r *http.Request) { switch credentialAuthType(request.Auth) { case "static_bearer": var auth v1.CredentialAuthReplacement - if decodeInputObject(request.Auth, &auth, "type", "token") != nil || auth.Token == nil { - writeError(w, http.StatusBadRequest, "invalid_request", "static_bearer auth requires a string token.") + if decodeInputObject(request.Auth, &auth, "type", "token") != nil || auth.Token == nil || *auth.Token == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "static_bearer auth requires a nonempty string token.") return } credential, err = h.store.UpdateStaticCredential(r.Context(), tenantID(r), vaultID, id, store.UpdateStaticCredentialInput{Token: *auth.Token}) diff --git a/services/agents-api/internal/api/credentials_update_test.go b/services/agents-api/internal/api/credentials_update_test.go index 87a947da5..cf537d3c3 100644 --- a/services/agents-api/internal/api/credentials_update_test.go +++ b/services/agents-api/internal/api/credentials_update_test.go @@ -20,7 +20,7 @@ func (f *credentialFixture) UpdateStaticCredential(_ context.Context, tenant, va } func TestCredentialUpdatePreservesOpaqueInputAndSafeProjection(t *testing.T) { - for _, token := range []string{"", " \tcredential-canary\n雪 ", "credential-canary"} { + for _, token := range []string{" ", " \tcredential-canary\n雪 ", "credential-canary"} { h, f, tenant := credentialHandler(t) f.credential.Name, f.credential.MCPServerURL = "Retained name", "https://example.invalid/mcp?q=x" body, _ := json.Marshal(map[string]any{"auth": map[string]string{"type": "static_bearer", "token": token}}) @@ -43,7 +43,7 @@ func TestCredentialUpdateRejectsInvalidBodiesBeforeStorage(t *testing.T) { `null`, `[]`, `{} {}`, `{}`, `{"auth":null}`, `{"auth":[]}`, `{"auth":{}}`, `{"auth":{"type":"static_bearer"}}`, `{"auth":{"token":"credential-canary"}}`, `{"auth":{"type":null,"token":"credential-canary"}}`, `{"auth":{"type":3,"token":"credential-canary"}}`, - `{"auth":{"type":"static_bearer","token":null}}`, `{"auth":{"type":"static_bearer","token":3}}`, + `{"auth":{"type":"static_bearer","token":""}}`, `{"auth":{"type":"static_bearer","token":null}}`, `{"auth":{"type":"static_bearer","token":3}}`, `{"auth":{"type":"static_bearer","token":{}}}`, `{"auth":{"type":"static_bearer","token":[]}}`, `{"auth":{"type":"mcp_oauth","access_token":3}}`, `{"auth":{"type":"static_bearer","token":"credential-canary","mcp_server_url":"https://other.invalid"}}`, diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index d2e47c0e8..32a1cce1c 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -101,7 +101,7 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.Environmen // @Security BearerAuth // @Param OpenAI-Beta header string true "agents=v1" // @Param body body v1.EnvironmentTemplateRequest true "Reusable configuration" -// @Success 200 {object} v1.EnvironmentTemplate +// @Success 201 {object} v1.EnvironmentTemplate // @Failure 400,401,413,500 {object} v1.ErrorResponse // @Router /agents/environments/templates [post] func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { @@ -114,7 +114,7 @@ func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Reque writeStoreError(w, r, err) return } - writeJSON(w, http.StatusOK, templateResponse(value)) + writeJSON(w, http.StatusCreated, templateResponse(value)) } // @Summary Retrieve an Environment Template @@ -140,7 +140,7 @@ func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) } // @Summary Update an Environment Template -// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Skills replace as a list; null/empty clears. Skill archives are encrypted separately and omitted from responses. Plugins and capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted after setup. Environment MCP execution requires a qualified native transport and runtime network policy. Exact hosted no-op timestamp behavior remains unverified. +// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Skills replace as a list; null/empty clears. Skill archives are encrypted separately and omitted from responses. Plugins and capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted after setup. Environment MCP execution requires a qualified native transport and runtime network policy. Empty updates advance updated_at without changing saved fields or confidential contents. // @Tags Environment Templates // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/resource_creation_test.go b/services/agents-api/internal/api/resource_creation_test.go new file mode 100644 index 000000000..25275b946 --- /dev/null +++ b/services/agents-api/internal/api/resource_creation_test.go @@ -0,0 +1,40 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type resourceCreationStore struct { + ResourceStore +} + +func (*resourceCreationStore) CreateAgent(_ context.Context, tenant string, input store.CreateAgentInput) (store.SavedAgent, error) { + return store.SavedAgent{ID: uuid.NewString(), TenantID: tenant, Configuration: input.Configuration, Metadata: input.Metadata}, nil +} + +func (*resourceCreationStore) CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) { + return store.EnvironmentTemplate{ID: uuid.NewString(), NetworkAccess: "enabled"}, nil +} + +func TestAgentAndTemplateCreationStatus(t *testing.T) { + h, recording, _ := testHandler(t) + recording.ResourceStore = &resourceCreationStore{} + for _, tc := range []struct{ path, body, object string }{ + {"/v1/agents", `{"model":"resource-model"}`, "agent"}, + {"/v1/agents/environments/templates", `{}`, "agent.environment.template"}, + } { + t.Run(tc.object, func(t *testing.T) { + w := credentialRequest(h, http.MethodPost, tc.path, tc.body) + var body struct{ ID, Object string } + if w.Code != http.StatusCreated || json.Unmarshal(w.Body.Bytes(), &body) != nil || body.ID == "" || body.Object != tc.object { + t.Fatal("creation did not return the created resource", w.Code, w.Body) + } + }) + } +} diff --git a/services/agents-api/internal/api/vaults.go b/services/agents-api/internal/api/vaults.go index c472b6a32..c96464724 100644 --- a/services/agents-api/internal/api/vaults.go +++ b/services/agents-api/internal/api/vaults.go @@ -28,7 +28,7 @@ type VaultStore interface { // @Security BearerAuth // @Param OpenAI-Beta header string true "agents=v1" // @Param body body v1.CreateVaultRequest true "Vault name and metadata" -// @Success 200 {object} v1.Vault +// @Success 201 {object} v1.Vault // @Failure 400,401,413,500 {object} v1.ErrorResponse // @Router /vaults [post] func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { @@ -73,7 +73,7 @@ func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - writeJSON(w, http.StatusOK, vaultResponse(vault)) + writeJSON(w, http.StatusCreated, vaultResponse(vault)) } // @Summary Retrieve a Vault diff --git a/services/agents-api/internal/api/vaults_test.go b/services/agents-api/internal/api/vaults_test.go index 147d2c5f9..9b8d2e644 100644 --- a/services/agents-api/internal/api/vaults_test.go +++ b/services/agents-api/internal/api/vaults_test.go @@ -76,7 +76,7 @@ func TestVaultResourceProjectionWithoutExecution(t *testing.T) { h, f := vaultResourceHandler(t) w := vaultRequest(h, "POST", "/v1/vaults", test.body) var got map[string]any - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil { + if w.Code != 201 || json.Unmarshal(w.Body.Bytes(), &got) != nil { t.Fatal(w.Code, w.Body) } want := map[string]any{"id": f.vault.ID, "object": "vault", "created_at": float64(1700000000), "name": test.name, "metadata": test.metadata} diff --git a/services/agents-api/internal/store/agents_update.go b/services/agents-api/internal/store/agents_update.go index 892d8a685..9f4a4c449 100644 --- a/services/agents-api/internal/store/agents_update.go +++ b/services/agents-api/internal/store/agents_update.go @@ -48,9 +48,6 @@ func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input return SavedAgent{}, err } } - if len(patch) == 0 && input.Metadata == nil { - return s.GetAgent(ctx, tenantID, agentID) - } var updated SavedAgent err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { q := s.queries.WithTx(tx) diff --git a/services/agents-api/internal/store/agents_update_test.go b/services/agents-api/internal/store/agents_update_test.go index 55ee09628..f60c4b73a 100644 --- a/services/agents-api/internal/store/agents_update_test.go +++ b/services/agents-api/internal/store/agents_update_test.go @@ -57,7 +57,11 @@ func TestAgentUpdateRollbackAndCompleteSizeBound(t *testing.T) { t.Fatal("resource timestamps changed incorrectly") } unchanged, err := s.UpdateAgent(ctx, tenant, original.ID, UpdateAgentInput{}) - if err != nil || !reflect.DeepEqual(unchanged, updated) { - t.Fatalf("empty update: %v", err) + if err != nil || !unchanged.UpdatedAt.After(updated.UpdatedAt) { + t.Fatalf("empty update did not advance timestamp: %v", err) + } + updated.UpdatedAt = unchanged.UpdatedAt + if !reflect.DeepEqual(unchanged, updated) { + t.Fatal("empty update changed saved configuration") } } diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index 69b3106c4..08d363b0a 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -134,9 +134,6 @@ func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templat if err != nil { return EnvironmentTemplate{}, ErrNotFound } - if !in.SetName && !in.SetNetwork && !in.SetFiles && !in.SetEnv && !in.SetSetup && !in.SetPackages && !in.SetSkills && !in.SetPlugins && !in.SetDirectories { - return s.GetEnvironmentTemplate(ctx, tenantID, templateID) - } var name pgtype.Text if in.Name != nil { name = pgtype.Text{String: *in.Name, Valid: true} diff --git a/services/agents-api/internal/store/environment_templates_noop_test.go b/services/agents-api/internal/store/environment_templates_noop_test.go new file mode 100644 index 000000000..b90dce123 --- /dev/null +++ b/services/agents-api/internal/store/environment_templates_noop_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "bytes" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestTemplateEmptyUpdateTouchesTimeWithoutDecryptingOrChangingContents(t *testing.T) { + keyless, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{37}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant, name := uuid.NewString(), "Retained template" + original, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{ + Name: &name, + Files: []InitialFile{{Type: "inline", Path: "/workspace/input.txt", Data: []byte("file-canary")}}, + Initialization: EnvironmentSetup{ + Env: map[string]string{"PRIVATE_SETUP": "env-canary"}, + Commands: []SetupCommand{{Command: "echo setup-canary"}}, + }, + }) + if err != nil { + t.Fatal(err) + } + readContents := func() []byte { + t.Helper() + var contents []byte + if err := pool.QueryRow(t.Context(), "SELECT to_jsonb(t) - 'updated_at' FROM environment_templates t WHERE id=$1", original.ID).Scan(&contents); err != nil { + t.Fatal(err) + } + return contents + } + before := readContents() + if _, err := keyless.UpdateEnvironmentTemplate(t.Context(), uuid.NewString(), original.ID, EnvironmentTemplateInput{}); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign empty update was admitted", err) + } + updated, err := keyless.UpdateEnvironmentTemplate(t.Context(), tenant, original.ID, EnvironmentTemplateInput{}) + if err != nil || !updated.UpdatedAt.After(original.UpdatedAt) { + t.Fatal("empty update did not advance timestamp without a key", err) + } + original.UpdatedAt = updated.UpdatedAt + if !reflect.DeepEqual(updated, original) || !bytes.Equal(before, readContents()) { + t.Fatal("empty update changed template metadata, ciphertext or ownership") + } + retained, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, original.ID) + if err != nil || retained.Initialization.Env["PRIVATE_SETUP"] != "env-canary" || len(retained.Initialization.Commands) != 1 { + t.Fatal("empty update invalidated confidential setup", err) + } +} diff --git a/services/agents-api/tests/official_agent_update.py b/services/agents-api/tests/official_agent_update.py index fb62e09dd..69e0c1613 100644 --- a/services/agents-api/tests/official_agent_update.py +++ b/services/agents-api/tests/official_agent_update.py @@ -39,8 +39,13 @@ def main(): assert fresh.agent.model == updated.model assert [t.to_dict() for t in fresh.agent.tools] == [t.to_dict() for t in updated.tools] assert fresh.metadata == {} and old.agent.instructions == original.instructions - # A request without fields is a local no-op, including its update timestamp. - assert agents.update(original.id) == updated + # An empty update touches time while retaining configuration and Session snapshots. + touched = agents.update(original.id) + assert touched.updated_at >= updated.updated_at + assert {k: v for k, v in touched.to_dict().items() if k != "updated_at"} == { + k: v for k, v in updated.to_dict().items() if k != "updated_at"} + assert sessions.retrieve(old.id) == old + updated = touched for body in (None, [], {"model": None}, {"model": 3}, {"name": "x" * 129}, {"metadata": {"bad": None}}, {"text": {"unexpected": True}}, {"metadata": {"replace": "no"}, "instructions": False}, diff --git a/services/agents-api/tests/official_agents.py b/services/agents-api/tests/official_agents.py index 99acba024..cce0320ec 100644 --- a/services/agents-api/tests/official_agents.py +++ b/services/agents-api/tests/official_agents.py @@ -19,6 +19,7 @@ def verify_agents(client, other, invalid, expect_error): "multi_agent": None, "reasoning": None, "service_tier": None, "text": None, "tools": None}): response = agents.with_raw_response.create(model=" caller-model ", **values) + assert response.status_code == 201 body, agent = response.http_response.json(), response.parse() assert set(body) == {"id", "object", "created_at", "updated_at", "metadata", "model", "name", "instructions", "multi_agent", "reasoning", "service_tier", "text", "tools"} @@ -40,6 +41,7 @@ def verify_agents(client, other, invalid, expect_error): "text": {"format": {"type": "json_schema", "schema": schema}, "verbosity": "high"}, "tools": tools} response = agents.with_raw_response.create(**request) + assert response.status_code == 201 body, agent = response.http_response.json(), response.parse() for field in ("model", "name", "instructions", "metadata", "reasoning", "service_tier", "text"): assert body[field] == request[field], field diff --git a/services/agents-api/tests/official_credential_rotation.py b/services/agents-api/tests/official_credential_rotation.py index 7a7f07e92..3ce85d448 100644 --- a/services/agents-api/tests/official_credential_rotation.py +++ b/services/agents-api/tests/official_credential_rotation.py @@ -50,7 +50,7 @@ def metadata(response, previous): assert response.parse() == current # The public boundary accepts opaque values; private Store tests verify # their bytes. The final value also supplies a log-scan canary. - for token in ("", " \t" + canary + "\n雪 ", canary + "final"): + for token in (" ", " \t" + canary + "\n雪 ", canary + "final"): response = raw.post(endpoint, headers=headers, json={"auth": {"type": "static_bearer", "token": token}}) current = metadata(response, current) response = peer.beta.agents.vaults.credentials.with_raw_response.update( @@ -63,6 +63,7 @@ def metadata(response, previous): {"auth": {"type": "static_bearer"}}, {"auth": {"token": canary}}, {"auth": {"type": None, "token": canary}}, {"auth": {"type": 3, "token": canary}}, + {"auth": {"type": "static_bearer", "token": ""}}, {"auth": {"type": "static_bearer", "token": None}}, {"auth": {"type": "static_bearer", "token": 3}}, {"auth": {"type": "mcp_oauth", "access_token": canary}}, diff --git a/services/agents-api/tests/official_credentials.py b/services/agents-api/tests/official_credentials.py index 6d8779514..e9f8033b5 100644 --- a/services/agents-api/tests/official_credentials.py +++ b/services/agents-api/tests/official_credentials.py @@ -36,17 +36,17 @@ def safe_body(response, status): with httpx2.Client(trust_env=False, timeout=10) as raw: response = credentials.with_raw_response.create(vault.id, **request) - body, value = safe_body(response.http_response, 200), response.parse() + body, value = safe_body(response.http_response, 201), response.parse() verify_credential(body, vault.id, "Credential 資源", destination) assert value.to_dict() == body and abs(value.created_at - time.time()) < 10 saved.append(value) # These successful writes exercise opaque strings, not a public token # round-trip. Byte preservation is verified by private Store tests. - for name, token in (("🧪" * 64, canary + "x" * 1024), ("Empty opaque token", "")): + for name, token in (("🧪" * 64, canary + "x" * 1024), ("Whitespace opaque token", " ")): response = raw.post(endpoint, headers=headers, json={"name": " " + name + "\n", "auth": {**auth, "token": token}}) - body = safe_body(response, 200) + body = safe_body(response, 201) verify_credential(body, vault.id, name, destination) saved.append(credentials.retrieve(body["id"], vault_id=vault.id)) assert saved[-1].to_dict() == body @@ -70,6 +70,7 @@ def safe_body(response, status): {**request, "name": " " + "🧪" * 64 + "a "}, {**request, "auth": None}, {**request, "auth": []}, {**request, "auth": {**auth, "token": 3}}, + {**request, "auth": {**auth, "token": ""}}, {**request, "auth": {"type": "mcp_oauth", "mcp_server_url": destination, "access_token": None}}, {**request, "metadata": {"unexpected": "field"}}, ] diff --git a/services/agents-api/tests/official_environment_templates.py b/services/agents-api/tests/official_environment_templates.py index 21b44338f..7b59e1063 100644 --- a/services/agents-api/tests/official_environment_templates.py +++ b/services/agents-api/tests/official_environment_templates.py @@ -23,6 +23,7 @@ def verify_environment_templates(client, foreign, http): {'name': ' preserved ', 'network': {'access': 'disabled'}, 'files': [], 'plugins': [], 'skills': [], 'packages': {'python': [], 'npm': None}}): response = api.with_raw_response.create(**values) + assert response.status_code == 201 body, template = response.http_response.json(), response.parse() owned.append(template.id) assert set(body) == {'id', 'object', 'created_at', 'updated_at', 'name', 'network', @@ -43,7 +44,10 @@ def verify_environment_templates(client, foreign, http): assert updated.created_at == before.created_at updated = api.update(owned[-1], name=None, network=None) assert updated.name is None and updated.network.access == 'enabled' - assert api.update(owned[-1]).to_dict() == updated.to_dict() + touched = api.update(owned[-1]) + assert touched.updated_at >= updated.updated_at + assert {k: v for k, v in touched.to_dict().items() if k != "updated_at"} == { + k: v for k, v in updated.to_dict().items() if k != "updated_at"} assert [v.id for v in api.list(order='asc', limit=1)] == owned assert [v.id for v in api.list(order='desc', limit=2)] == owned[::-1] page = api.list(order='asc', limit=2) diff --git a/services/agents-api/tests/official_oauth_credentials.py b/services/agents-api/tests/official_oauth_credentials.py index 593d053fc..56dd72682 100644 --- a/services/agents-api/tests/official_oauth_credentials.py +++ b/services/agents-api/tests/official_oauth_credentials.py @@ -93,11 +93,9 @@ def update(vault, previous, patch, expected_auth, sdk=True): "token_endpoint_auth": {"type": method}, "resource": auth["refresh"].get("resource"), "scope": auth["refresh"].get("scope")}) response = credentials.with_raw_response.create(vault.id, name=" OAuth " + str(index) + " ", auth=auth) - body = check_resource(safe(response.http_response), vault.id, expected_auth) + body = check_resource(safe(response.http_response, 201), vault.id, expected_auth) assert response.parse().to_dict() == body and body["name"] == "OAuth " + str(index) current = retrieve(vault.id, body["id"], expected_auth) - current = update(vault.id, current, {}, expected_auth) - current = update(vault.id, current, {"access_token": None, "refresh": None}, expected_auth, sdk=False) changed = deepcopy(expected_auth) changed["expires_at"] = None current = update(vault.id, current, {"access_token": secrets[3]}, changed) @@ -107,7 +105,6 @@ def update(vault, previous, patch, expected_auth, sdk=True): current = update(vault.id, current, {"expires_at": None}, changed) current = update(vault.id, current, {"access_token": secrets[0], "expires_at": expiry}, expected_auth) if method is not None: - current = update(vault.id, current, {"refresh": {"refresh_token": None, "token_endpoint_auth": None}}, expected_auth) current = update(vault.id, current, {"refresh": {"refresh_token": secrets[3]}}, expected_auth, sdk=False) changed = deepcopy(expected_auth) changed["refresh"]["scope"] = None @@ -118,7 +115,7 @@ def update(vault, previous, patch, expected_auth, sdk=True): current = update(vault.id, current, {"refresh": {"scope": ""}}, changed, sdk=False) expected_auth = changed if method != "none": - for secret_patch in ({}, {"client_secret": None}, {"client_secret": secrets[3]}): + for secret_patch in ({"client_secret": secrets[3]},): current = update(vault.id, current, {"refresh": {"token_endpoint_auth": {"type": method, **secret_patch}}}, expected_auth) invalid_patches = [ {"refresh": {"token_endpoint_auth": {"type": "client_secret_post" if method != "client_secret_post" else "client_secret_basic"}}}, @@ -126,7 +123,10 @@ def update(vault, previous, patch, expected_auth, sdk=True): {"refresh": {"resource": None}}, {"refresh": {"token_endpoint_auth": {"type": "none"}}}] else: invalid_patches = [{"refresh": {}}, {"refresh": {"scope": "added"}}] - invalid_patches += [{"access_token": 3}, {"expires_at": "tomorrow"}, {"token": secrets[0]}, + invalid_patches += [{}, {"access_token": ""}, {"access_token": None, "refresh": None}, + {"refresh": {}}, {"refresh": {"refresh_token": None, "token_endpoint_auth": None}}, + {"refresh": {"token_endpoint_auth": {"type": "client_secret_basic", "client_secret": None}}}, + {"access_token": 3}, {"expires_at": "tomorrow"}, {"token": secrets[0]}, {"refresh": {"scope": 3}}, {"refresh": {"refresh_token": 3}}] for patch in invalid_patches: safe(raw.post(endpoint(vault.id, current["id"]), headers=headers(), json={"auth": {"type": "mcp_oauth", **patch}}), 400) @@ -137,7 +137,7 @@ def update(vault, previous, patch, expected_auth, sdk=True): # Explicit null creation must have the same public nullable fields as omission. null_auth = {"type": "mcp_oauth", "mcp_server_url": destination, "expires_at": None, "refresh": None} null_body = safe(raw.post(endpoint(vault.id), headers=headers(), json={ - "name": "Raw nulls", "auth": {**null_auth, "access_token": secrets[0]}})) + "name": "Raw nulls", "auth": {**null_auth, "access_token": secrets[0]}}), 201) check_resource(null_body, vault.id, null_auth) assert retrieve(vault.id, null_body["id"], null_auth) == null_body expected[null_body["id"]] = null_body @@ -149,6 +149,7 @@ def update(vault, previous, patch, expected_auth, sdk=True): invalid_create = [ {"type": "mcp_oauth", "mcp_server_url": destination}, {"type": "mcp_oauth", "mcp_server_url": destination, "access_token": None}, + {"type": "mcp_oauth", "mcp_server_url": destination, "access_token": ""}, {"type": "mcp_oauth", "mcp_server_url": "http://issuer.example", "access_token": secrets[0]}, {"type": "mcp_oauth", "mcp_server_url": destination, "access_token": secrets[0], "refresh": {}}, {"type": "mcp_oauth", "mcp_server_url": destination, "access_token": secrets[0], "expires_at": 3}, diff --git a/services/agents-api/tests/official_vaults.py b/services/agents-api/tests/official_vaults.py index 13c9a4ff8..115a580c6 100644 --- a/services/agents-api/tests/official_vaults.py +++ b/services/agents-api/tests/official_vaults.py @@ -30,7 +30,7 @@ def verify_vaults(client, other, invalid, peer, binding, expect_error): for request, name, expected_metadata in cases: response = vaults.with_raw_response.create(**request) body, value = response.http_response.json(), response.parse() - assert response.status_code == 200 + assert response.status_code == 201 verify_vault(body, name, expected_metadata) assert value.to_dict() == body and abs(value.created_at - time.time()) < 10 assert vaults.retrieve(value.id) == value @@ -38,7 +38,7 @@ def verify_vaults(client, other, invalid, peer, binding, expect_error): saved.append(value) response = raw.post(base, headers=headers, json={"name": "\nRaw Vault\t", "metadata": {}}) - assert response.status_code == 200 + assert response.status_code == 201 verify_vault(response.json(), "Raw Vault", {}) saved.append(vaults.retrieve(response.json()["id"])) assert saved[-1].to_dict() == response.json() From c680d7f7a0a4ddd6a9cc98935b19ba00098af2b0 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:19:06 +0800 Subject: [PATCH 03/15] Record observed official semantics and retained baseline differences --- CONTRIBUTING.md | 6 +- contracts/agents-api/README.md | 13 ++++- .../official-semantics-alignment.md | 57 +++++++++++++++++++ services/agents-api/credentials.md | 6 +- services/agents-api/oauth-credentials.md | 5 ++ 5 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 contracts/agents-api/official-semantics-alignment.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e1e64259..51548ebfd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,7 +113,11 @@ databases, credentials and migrations. The product uses Core exclusively; it has with a synthetic model does not constitute live model validation. Keep provider credentials in private test configuration, outside source, logs and task records. Record unspecified or unverified behavior explicitly; never invent official - semantics. Track partial + semantics. When current documentation adds operations or fields absent from the + fixed baseline, queue a protocol upgrade instead of silently implementing a new + version. Owned-resource live probes can qualify status codes and wire details + left unspecified by the SDK; retain request evidence and distinguish observations + from guaranteed or fully covered behavior. Track partial coverage in `contracts/agents-api/README.md` until the complete target is verified. Reconcile current coverage summaries with merged routes and recorded acceptance; distinguish accepted profiles, partial implementation, missing operations and diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 9cfad97fe..80b8c40ff 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -180,11 +180,16 @@ including further deployment qualification; this inventory describes merged beha ## Public semantics +The [September 22 wire comparison](official-semantics-alignment.md) records the +bounded official-service observations, aligned responses and remaining differences. +It supplements the fixed SDK baseline; current documentation does not silently +upgrade the protocol. + - Credentials use `POST /vaults/{vault_id}/credentials` and `GET /vaults/{vault_id}/credentials/{credential_id}`. The static profile accepts `static_bearer` with required string token and HTTPS destination, plus a - required name trimmed to 1–256 UTF-8 bytes. Tokens remain opaque, including empty - strings; exact hosted token validation is unverified. The local URL profile + required name trimmed to 1–256 UTF-8 bytes. Tokens remain opaque and nonempty; explicitly empty tokens are rejected before + mutation, following the sampled official create/update behavior. The local URL profile excludes userinfo/fragments and preserves queries without normalization or network contact. Public metadata contains identity, owning Vault, name, timestamps and auth type/destination; it never returns tokens or ciphertext and can be read @@ -563,7 +568,9 @@ historical native transport evidence. `POST /v1/agents/sessions/{session_id}/events` accepts `agent.session.input.message` with ordered user `input_text` content and [qualified image content](message-input.md), `agent.session.input.cancel` and `agent.session.input.tool_result`. Successful atomic -admission returns 204, as consumed by the official `events.create` method. A retry +admission returns 202 with no body, as observed from the official service. +An empty event array is an authenticated no-op: it creates no Turn or Item and +does not reserve an execution retry key. A retry key identifies the entire ordered request; conflict does not partially admit it. Messages start queued work or steer the active Turn. Individual input messages remain distinct Items even when their text shares one native prompt. diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md new file mode 100644 index 000000000..dbe2c1ec9 --- /dev/null +++ b/contracts/agents-api/official-semantics-alignment.md @@ -0,0 +1,57 @@ +# Official wire semantics: September 22, 2026 + +This batch compares owned-resource requests to the official Agents API with Core +main `692e32daafb19521e0919915c7685eef78b813fa`. The protocol remains Python SDK +3.13.0, upstream `d7c41efee1b0802b79f3f88a678ef2052b06e9ce`, `agents=v1`. +The current [Session reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/sessions) +and [overview](https://developers.openai.com/api/docs/guides/agents-api/overview) +were consulted alongside the pinned source. Current documentation is not a +replacement baseline. A successful SDK parse alone is not conformance evidence. + +## Selected common behavior + +| Operation | Official observation and Core behavior in this batch | +| --- | --- | +| Create Agent, Vault, Credential, EnvironmentTemplate or Session | HTTP 201. Session JSON and live SSE creation use the same status. Non-creation successes retain their operation-specific status. | +| Submit Session events | HTTP 202 with an empty response body. An empty array is an authenticated no-op; null is invalid. No-op requests do not create a Turn, Item or execution retry reservation. | +| Session, Turn and Item lists | `object: list`, `data`, `has_more`, `first_id` and `last_id`. Empty pages contain null first/last IDs. | +| Empty Agent/Template update | Advance `updated_at` through the existing atomic update, preserving IDs, content, ownership and frozen Session snapshots. Timestamp precision is seconds; immediate updates may have the same serialized timestamp. | +| Static bearer create/replacement | Reject an explicitly empty token before mutation. Preserve valid opaque token bytes without trimming. | +| OAuth grant create/replacement | Reject an explicitly empty access token and a replacement without mutable grant fields. Preserve previously qualified refresh/expiry/null handling. | +| Missing beta resource | HTTP 404 with `type` and `code` equal to `not_found_error`. Missing and foreign resources remain indistinguishable. | +| Missing required Beta header | HTTP 400 with `type` and `code` equal to `invalid_beta`, after authentication. | +| Missing non-beta File or Skill | HTTP 404 with `type: invalid_request_error`, `code: null`. Exact message, File `param` and additional detail payload remain outside this batch. | + +The resource comparison made 40 raw requests over six newly owned resources +(one Agent, two Vaults, two Credentials and one Template), including actual +rejected replacements and post-delete reads. All six resources were deleted. +Session probes used real `gpt-6-astra` executions and compared event admission, +query envelopes and accumulated usage; their owned Sessions and Agent were also +deleted. Separate missing File/Skill probes used randomly generated IDs. +Private request/status/body evidence and cleanup results are retained under +`~/.parsar/remediation/20260922/official-semantics-alignment/`; no API keys or +credential values belong in the repository or task board. + +## Explicit remaining differences + +- Current documentation supports Session Agent configuration updates; the fixed + `SessionUpdateParams` exposes only metadata. New fields and newer Environment + status/configuration shapes are a queued baseline upgrade, as approved by the user. +- Official `none` creation rejected omitted, null and empty initial input. Core + still permits idle `none` Sessions. Changing this requires a coordinated client + and acceptance-flow migration and is separately queued. +- Two otherwise identical official creates with the same `Idempotency-Key` + returned 201 and distinct Session IDs. Core retains its durable creation retry + guarantee. This is a local behavior, not evidence of official idempotency parity. +- An empty Session update body, generic validation codes/field `param`, malformed + queries, page limits and overlapping mutation behavior need separate qualification. + The error mapping above must not be extrapolated to every status or resource. +- Template references with inline installation overrides, optional Skill version + semantics and the other active board entries remain outstanding. +- Native model defaults, tool combinations and unavailable usage counters retain + their documented multi-harness differences. Core does not reconstruct model + output, guess counters or introduce a second tool loop to manufacture equality. + +These observations establish a bounded comparison, not complete official protocol +compatibility. Core regression and real-model validation are recorded with the +implementation acceptance before merge. diff --git a/services/agents-api/credentials.md b/services/agents-api/credentials.md index 8a2292389..64bc4d437 100644 --- a/services/agents-api/credentials.md +++ b/services/agents-api/credentials.md @@ -73,10 +73,12 @@ and exact hosted query/concurrent-page semantics remain separate gaps. Required name is trimmed to 1–256 UTF-8 bytes. Required `auth` accepts `static_bearer` or the [OAuth variant](oauth-credentials.md). Static auth requires an HTTPS `mcp_server_url` and a string `token`. The token is -preserved as opaque data, including whitespace or an empty string. This does not +preserved as opaque, nonempty data; whitespace is not trimmed. An explicitly +empty token is rejected before storage or replacement. This does not verify that it will authenticate to a destination. The local URL profile excludes userinfo and fragments, preserves queries and performs no DNS or HTTP request. -Exact hosted empty-token and URL normalization rules remain unverified. +Official empty-token create/update rejection was observed directly. Other hosted +URL normalization rules remain unverified. The response contains `id`, `vault_id`, `name`, `object: vault.credential`, `created_at`, `updated_at` and `auth`. Static auth contains only `type` and diff --git a/services/agents-api/oauth-credentials.md b/services/agents-api/oauth-credentials.md index 755540ac9..03eaf910b 100644 --- a/services/agents-api/oauth-credentials.md +++ b/services/agents-api/oauth-credentials.md @@ -78,6 +78,11 @@ client.beta.agents.vaults.credentials.update( ) ``` +Explicitly empty access tokens are rejected at creation and replacement. A +replacement must include a mutable grant field; a type-only or otherwise empty +patch is rejected before reading or changing secret material. Existing optional +null semantics below remain qualified separately. + Identity, name, auth type, destination, refresh endpoint/client ID/resource and endpoint authentication method remain unchanged. A refresh configuration cannot be added to a Credential that was created without one. From c60ade0229c6a5c035b64cfa554be1a4205a384d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:19:59 +0800 Subject: [PATCH 04/15] Refresh official-client error assertions and event response docs --- contracts/agents-api/execution-tools.md | 2 +- services/agents-api/README.md | 2 +- services/agents-api/tests/official_credential_delete.py | 4 ++-- services/agents-api/tests/official_credential_list.py | 8 ++++---- services/agents-api/tests/official_credential_rotation.py | 4 ++-- services/agents-api/tests/official_credentials.py | 6 +++--- .../agents-api/tests/official_environment_retrieve.py | 8 ++++---- services/agents-api/tests/official_function_inputs.py | 2 +- .../agents-api/tests/official_pending_actions_native.py | 2 +- services/agents-api/tests/official_source_file_list.py | 4 ++-- services/agents-api/tests/official_vault_delete.py | 6 +++--- services/agents-api/tests/official_vault_list.py | 4 ++-- services/agents-api/tests/official_vaults.py | 4 ++-- 13 files changed, 28 insertions(+), 28 deletions(-) diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index f10d305fb..272ce56d6 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -20,7 +20,7 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | | Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](message-input.md), [initial parser](../../services/agents-api/internal/api/session_initial_input.go). | Empty-message behavior and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 204 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](../../services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](../../services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. | | Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged. [Contract](structured-output.md), [profile](../../services/agents-api/internal/engine/claude.go). | Codex/MiniMax, other schema roots, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | | Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](../../services/agents-api/internal/api/function_configuration.go), [saved tools](../../services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires object-root schemas. Local unique/nonblank name and 64-definition bounds are compatibility gaps. Saving configuration alone does not qualify execution. | | Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity. Same result retries are accepted; changed results conflict, including after terminal state. F1/F2/M2 plus [controlled SDK/raw checks](../../services/agents-api/tests/official_function_inputs.py). [Parser](../../services/agents-api/internal/api/function_inputs.go), [Store](../../services/agents-api/internal/store/function_results.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Exact hosted errors, defaults and publication timing remain unverified. | diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 08842a859..dae8ddfa8 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -527,7 +527,7 @@ Neither disconnect nor deletion promises immediate native process quiescence or reclaims user-owned E2B/local compute. The user must stop and destroy it explicitly. Pending input retains its durable identity/deadline through HTTP disconnects. Later -idle input returns 204 after preparation/admission, not after model completion; +idle input returns 202 after preparation/admission, not after model completion; use client/proxy timeouts above five minutes and recover progress through events and reads. Exact upstream failure/error timing remains unverified. diff --git a/services/agents-api/tests/official_credential_delete.py b/services/agents-api/tests/official_credential_delete.py index 857275e65..c80fdcc88 100644 --- a/services/agents-api/tests/official_credential_delete.py +++ b/services/agents-api/tests/official_credential_delete.py @@ -25,7 +25,7 @@ def verify_credential_deletion(client, other, invalid, peer, canary, expect_erro url = endpoint + "/" + target.id assert raw.delete(url).status_code == 401 response = raw.delete(url, headers={"Authorization": headers["Authorization"]}) - assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta_header" + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta" for kwargs in [{"params": {"include": "token"}}, {"content": b"{}"}]: assert raw.request("DELETE", url, headers=headers, **kwargs).status_code == 400 assert credentials.retrieve(target.id, vault_id=vault.id) == target @@ -41,7 +41,7 @@ def verify_credential_deletion(client, other, invalid, peer, canary, expect_erro url = endpoint + "/" + value.id for method in ["GET", "DELETE"]: response = raw.request(method, url, headers=headers) - assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found_error" expect_error(NotFoundError, lambda: credentials.update(value.id, vault_id=vault.id, auth={"type": "static_bearer", "token": canary})) for status in [None, ["active"], ["active", "archived"]]: diff --git a/services/agents-api/tests/official_credential_list.py b/services/agents-api/tests/official_credential_list.py index c993393d5..a1485c260 100644 --- a/services/agents-api/tests/official_credential_list.py +++ b/services/agents-api/tests/official_credential_list.py @@ -101,9 +101,9 @@ def safe_error(response, status): value.auth.mcp_server_url)) body = response.json()["error"] if status == 404: - assert body["code"] == "not_found" + assert body["code"] == body["type"] == "not_found_error" elif status == 400: - assert body["type"] == "invalid_request_error" + assert body["type"] == ("invalid_beta" if body["code"] == "invalid_beta" else "invalid_request_error") return body with httpx2.Client(trust_env=False, timeout=10) as raw: @@ -150,7 +150,7 @@ def safe_error(response, status): auth = {"Authorization": headers["Authorization"]} if beta is not None: auth["OpenAI-Beta"] = beta - assert safe_error(raw.get(endpoint, headers=auth), 400)["code"] == "invalid_beta_header" + assert safe_error(raw.get(endpoint, headers=auth), 400)["code"] == "invalid_beta" for scope in ({"OpenAI-Organization": "wrong-org"}, {"OpenAI-Project": "wrong-project"}): safe_error(raw.get(endpoint, headers=headers | scope), 401) expect_error(AuthenticationError, lambda: credentials.list(vault.id, extra_headers=scope)) @@ -179,6 +179,6 @@ def verify_credential_list_recovery(client, other, peer, saved, canary, phase="A ({"status[]": "archived", "order": "asc", "limit": "100"}, archived, False)): verify_page(raw.get(endpoint, headers=headers, params=params), vault.id, values, has_more, canary) response = raw.get(endpoint, headers=headers | {"Authorization": f"Bearer {other.api_key}"}) - assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found_error" assert canary not in response.text and expected[0].id not in response.text print(f"Credential list: exact metadata, stored status, discovery/retrieval and project isolation survived {phase}.") diff --git a/services/agents-api/tests/official_credential_rotation.py b/services/agents-api/tests/official_credential_rotation.py index 3ce85d448..7789f23c6 100644 --- a/services/agents-api/tests/official_credential_rotation.py +++ b/services/agents-api/tests/official_credential_rotation.py @@ -86,7 +86,7 @@ def metadata(response, previous): (vault.id, str(uuid.UUID(int=0))), ("invalid", original.id), (str(uuid.UUID(int=0)), original.id)): response = raw.post(base + owner + "/credentials/" + credential_id, headers=headers, json=replacement) - assert safe(response, 404)["error"]["code"] == "not_found" + assert safe(response, 404)["error"]["code"] == "not_found_error" assert original.id not in response.text and foreign.id not in response.text error = expect_error(NotFoundError, lambda: credentials.update(credential_id, vault_id=owner, **replacement)) safe(error.response, 404) @@ -96,7 +96,7 @@ def metadata(response, previous): original.id, vault_id=vault.id, **replacement)) safe(raw.post(endpoint, json=replacement), 401) assert safe(raw.post(endpoint, headers={"Authorization": headers["Authorization"]}, - json=replacement), 400)["error"]["code"] == "invalid_beta_header" + json=replacement), 400)["error"]["code"] == "invalid_beta" for scope in ({"OpenAI-Project": "other-project"}, {"OpenAI-Organization": "other-organization"}): expect_error(AuthenticationError, lambda: credentials.update( original.id, vault_id=vault.id, **replacement, extra_headers=scope)) diff --git a/services/agents-api/tests/official_credentials.py b/services/agents-api/tests/official_credentials.py index e9f8033b5..96e70fda6 100644 --- a/services/agents-api/tests/official_credentials.py +++ b/services/agents-api/tests/official_credentials.py @@ -94,13 +94,13 @@ def safe_body(response, status): (vault.id, str(uuid.UUID(int=0))), ("invalid", saved[0].id), (str(uuid.UUID(int=0)), saved[0].id)): response = raw.get(base + owner + "/credentials/" + credential_id, headers=headers) - assert safe_body(response, 404)["error"]["code"] == "not_found" + assert safe_body(response, 404)["error"]["code"] == "not_found_error" assert saved[0].id not in response.text and foreign.id not in response.text error = expect_error(NotFoundError, lambda: credentials.retrieve(credential_id, vault_id=owner)) safe_body(error.response, 404) for owner in (foreign_vault.id, str(uuid.uuid4()), "invalid", str(uuid.UUID(int=0))): response = raw.post(base + owner + "/credentials", headers=headers, json=request) - assert safe_body(response, 404)["error"]["code"] == "not_found" + assert safe_body(response, 404)["error"]["code"] == "not_found_error" expect_error(NotFoundError, lambda: other.beta.agents.vaults.credentials.retrieve(saved[0].id, vault_id=vault.id)) expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.credentials.create(vault.id, **request)) expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.credentials.retrieve(saved[0].id, vault_id=vault.id)) @@ -111,7 +111,7 @@ def safe_body(response, status): body = {"json": request} if method == "POST" else {} safe_body(raw.request(method, url, **body), 401) response = raw.request(method, url, headers={"Authorization": headers["Authorization"]}, **body) - assert safe_body(response, 400)["error"]["code"] == "invalid_beta_header" + assert safe_body(response, 400)["error"]["code"] == "invalid_beta" safe_body(raw.post(endpoint, headers=headers, params={"tenant_id": "other"}, json=request), 400) safe_body(raw.get(endpoint + "/" + saved[0].id, headers=headers, params={"include": "token"}), 400) diff --git a/services/agents-api/tests/official_environment_retrieve.py b/services/agents-api/tests/official_environment_retrieve.py index 83908f1d4..3d38286a7 100644 --- a/services/agents-api/tests/official_environment_retrieve.py +++ b/services/agents-api/tests/official_environment_retrieve.py @@ -70,12 +70,12 @@ def rejected(url, status, code, request_headers=headers, method="GET"): assert response.status_code == status body = response.json() assert set(body) == {"error"} and body["error"]["code"] == code - assert body["error"]["type"] == ("authentication_error" if status == 401 else "invalid_request_error") + assert body["error"]["type"] == ("authentication_error" if status == 401 else code if code in {"not_found_error", "invalid_beta"} else "invalid_request_error") for private in (token, settings["peer_token"], settings["foreign_token"], settings["executor_token"], environment_id): assert private not in response.text for missing in (result["deleted_environment_id"], result["foreign_environment_id"], str(uuid.uuid4())): - rejected(base + "/v1/agents/environments/" + missing, 404, "not_found") + rejected(base + "/v1/agents/environments/" + missing, 404, "not_found_error") try: api.beta.agents.environments.retrieve(missing) except NotFoundError: @@ -84,7 +84,7 @@ def rejected(url, status, code, request_headers=headers, method="GET"): raise AssertionError("absent Environment was exposed through the SDK") for malformed in ("invalid", str(uuid.UUID(int=0))): rejected(base + "/v1/agents/environments/" + malformed, 400, "invalid_request") - rejected(endpoint, 404, "not_found", headers | {"Authorization": "Bearer " + settings["foreign_token"]}) + rejected(endpoint, 404, "not_found_error", headers | {"Authorization": "Bearer " + settings["foreign_token"]}) for authorization in (None, "Bearer invalid", "Bearer " + settings["executor_token"]): request_headers = {"OpenAI-Beta": "agents=v1"} if authorization is not None: @@ -94,7 +94,7 @@ def rejected(url, status, code, request_headers=headers, method="GET"): request_headers = {"Authorization": "Bearer " + token} if beta is not None: request_headers["OpenAI-Beta"] = beta - rejected(endpoint, 400, "invalid_beta_header", request_headers) + rejected(endpoint, 400, "invalid_beta", request_headers) rejected(endpoint + "?include=files", 400, "unsupported_parameter") for method in ("POST", "PATCH", "DELETE"): rejected(endpoint, 405, "unsupported_operation", method=method) diff --git a/services/agents-api/tests/official_function_inputs.py b/services/agents-api/tests/official_function_inputs.py index 35f2deb00..0effe32ab 100644 --- a/services/agents-api/tests/official_function_inputs.py +++ b/services/agents-api/tests/official_function_inputs.py @@ -26,7 +26,7 @@ def submit(api, events, key, expected=204, target=session): assert response.content == b"" except APIStatusError as error: assert error.status_code == expected, (error.status_code, expected, error.message) - assert error.body["code"] in {"not_found", "turn_conflict", "idempotency_conflict", "invalid_request"} + assert error.body["code"] in {"not_found_error", "turn_conflict", "idempotency_conflict", "invalid_request"} with OpenAI(api_key=token, base_url=base+"/v1", max_retries=0, diff --git a/services/agents-api/tests/official_pending_actions_native.py b/services/agents-api/tests/official_pending_actions_native.py index 3c5788781..f5d04b581 100644 --- a/services/agents-api/tests/official_pending_actions_native.py +++ b/services/agents-api/tests/official_pending_actions_native.py @@ -44,7 +44,7 @@ def submit(sid, event, key, expected=204, auth=None): if expected == 204: assert response.content == b"" else: - assert response.json()["error"]["code"] in {"not_found", "turn_conflict", "idempotency_conflict"} + assert response.json()["error"]["code"] in {"not_found_error", "turn_conflict", "idempotency_conflict"} return {"request": key, "status": response.status_code} try: diff --git a/services/agents-api/tests/official_source_file_list.py b/services/agents-api/tests/official_source_file_list.py index 7155493b8..6393dccc7 100644 --- a/services/agents-api/tests/official_source_file_list.py +++ b/services/agents-api/tests/official_source_file_list.py @@ -56,7 +56,7 @@ def sdk_page(values, has_more, query, **request): verify_page(raw.get(endpoint, headers=headers, params={"order": "asc", "limit": "100", "after": first.json()["last_id"]}), expected[100:], False) verify_page(raw.get(endpoint, headers=headers, params={"purpose": "batch"}), [], False) response = raw.get(endpoint, headers=headers, params={"after": foreign.id}) - assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert response.status_code == 404 and response.json()["error"]["code"] is None assert foreign.id not in response.text for query in ("limit=0", "limit=10001", "limit=null", "order=invalid", "after=a&after=b", "purpose=a&purpose=b", "unknown=x"): response = raw.get(endpoint + "?" + query, headers=headers) @@ -64,7 +64,7 @@ def sdk_page(values, has_more, query, **request): assert response.json()["error"]["type"] == "invalid_request_error" response = raw.get(endpoint, headers=headers, params={"after": "not-a-file"}) assert response.status_code == 404 - assert response.json()["error"]["code"] == "not_found" + assert response.json()["error"]["code"] is None assert response.json()["error"]["type"] == "invalid_request_error" assert raw.get(endpoint).status_code == 401 for scope in ({"OpenAI-Organization": "wrong-org"}, {"OpenAI-Project": "wrong-project"}): diff --git a/services/agents-api/tests/official_vault_delete.py b/services/agents-api/tests/official_vault_delete.py index e19744572..5266ffb51 100644 --- a/services/agents-api/tests/official_vault_delete.py +++ b/services/agents-api/tests/official_vault_delete.py @@ -28,7 +28,7 @@ def verify_vault_deletion(client, other, invalid, peer, canary, expect_error): with httpx2.Client(trust_env=False, timeout=10) as raw: assert raw.delete(endpoint + target.id).status_code == 401 response = raw.delete(endpoint + target.id, headers={"Authorization": auth_headers["Authorization"]}) - assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta_header" + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta" for kwargs in [{"params": {"include": "credentials"}}, {"content": b"{}"}]: assert raw.request("DELETE", endpoint + target.id, headers=auth_headers, **kwargs).status_code == 400 assert vaults.retrieve(target.id) == target @@ -43,9 +43,9 @@ def verify_vault_deletion(client, other, invalid, peer, canary, expect_error): for value in values[:2]: for method in ["GET", "DELETE"]: response = raw.request(method, endpoint + value.id, headers=auth_headers) - assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found_error" response = raw.get(endpoint + value.id + "/credentials", headers=auth_headers) - assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found_error" response = raw.post(endpoint + value.id + "/credentials", headers=auth_headers, json={"name": "late", "auth": auth}) assert response.status_code == 404 and canary not in response.text expect_error(NotFoundError, lambda: vaults.credentials.list(value.id)) diff --git a/services/agents-api/tests/official_vault_list.py b/services/agents-api/tests/official_vault_list.py index 4009bd108..9929077d2 100644 --- a/services/agents-api/tests/official_vault_list.py +++ b/services/agents-api/tests/official_vault_list.py @@ -100,7 +100,7 @@ def sdk_page(values, has_more, query, **request): foreign_headers = headers | {"Authorization": f"Bearer {other.api_key}"} verify_page(raw.get(endpoint, headers=foreign_headers, params={"status": "archived"}), [], False) response = raw.get(endpoint, headers=headers, params={"after": foreign.id}) - assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found_error" assert foreign.id not in response.text for params in ({"after": "invalid-vault"}, {"status": "unknown"}, {"limit": "null"}): response = raw.get(endpoint, headers=headers, params=params) @@ -112,7 +112,7 @@ def sdk_page(values, has_more, query, **request): if beta is not None: auth["OpenAI-Beta"] = beta response = raw.get(endpoint, headers=auth) - assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta_header" + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta" for scope in ({"OpenAI-Organization": "wrong-org"}, {"OpenAI-Project": "wrong-project"}): assert raw.get(endpoint, headers=headers | scope).status_code == 401 expect_error(AuthenticationError, lambda: vaults.list(extra_headers=scope)) diff --git a/services/agents-api/tests/official_vaults.py b/services/agents-api/tests/official_vaults.py index 115a580c6..d3b289d6d 100644 --- a/services/agents-api/tests/official_vaults.py +++ b/services/agents-api/tests/official_vaults.py @@ -77,7 +77,7 @@ def verify_vaults(client, other, invalid, peer, binding, expect_error): for resource_id in (str(uuid.uuid4()), "invalid-vault", str(uuid.UUID(int=0)), foreign.id): response = raw.get(base + "/" + resource_id, headers=headers) assert response.status_code == 404 - assert response.json()["error"]["code"] == "not_found" + assert response.json()["error"]["code"] == "not_found_error" assert resource_id not in response.text expect_error(NotFoundError, lambda: vaults.retrieve(resource_id)) for scope in ({"OpenAI-Organization": "wrong-org"}, {"OpenAI-Project": "wrong-project"}): @@ -95,7 +95,7 @@ def verify_vaults(client, other, invalid, peer, binding, expect_error): request_headers["OpenAI-Beta"] = beta response = raw.request(method, base + suffix, headers=request_headers, json={} if method == "POST" else None) - assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta_header" + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta" assert raw.post(base, headers=headers, params={"tenant_id": "other"}, json={}).status_code == 400 assert raw.get(base + "/" + saved[0].id, headers=headers, params={"include": "credentials"}).status_code == 400 alias = str(client.base_url).rstrip("/") + "/agents/vaults/" + saved[0].id From cc11495f591b974ebcc3f817ae0dbdfe27ee43d4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:23:01 +0800 Subject: [PATCH 05/15] Align Session transport status and history envelopes --- contracts/agents-api/v1/items.go | 7 +- contracts/agents-api/v1/sessions.go | 3 + contracts/agents-api/v1/turns.go | 7 +- .../internal/api/claude_admission_test.go | 6 +- .../internal/api/claude_mcp_test.go | 2 +- .../internal/api/environment_creation_test.go | 2 +- .../internal/api/environment_input_test.go | 4 +- .../api/function_configuration_test.go | 2 +- .../internal/api/function_inputs.go | 7 + .../internal/api/function_inputs_test.go | 2 +- services/agents-api/internal/api/handler.go | 12 +- .../agents-api/internal/api/handler_test.go | 2 +- .../agents-api/internal/api/harness_test.go | 8 +- .../agents-api/internal/api/history_pages.go | 35 +++++ services/agents-api/internal/api/inputs.go | 39 +++-- .../agents-api/internal/api/inputs_test.go | 5 +- services/agents-api/internal/api/items.go | 3 +- .../agents-api/internal/api/items_test.go | 2 +- .../internal/api/session_creation_identity.go | 2 +- .../internal/api/session_creation_stream.go | 2 +- .../api/session_initial_input_test.go | 4 +- .../internal/api/session_request_test.go | 12 +- .../internal/api/session_semantics_test.go | 136 ++++++++++++++++++ services/agents-api/internal/api/stream.go | 5 +- .../agents-api/internal/api/subagent_turns.go | 11 +- services/agents-api/internal/api/subagents.go | 5 +- .../agents-api/internal/api/subagents_test.go | 6 +- .../internal/api/text_configuration_test.go | 2 +- services/agents-api/internal/api/turns.go | 2 +- .../internal/store/environment_inputs.go | 2 +- .../agents-api/internal/store/turn_inputs.go | 5 +- .../tests/official_agent_reference_retry.py | 2 +- .../tests/official_function_images.py | 2 +- .../tests/official_function_inputs.py | 4 +- .../tests/official_pending_actions_native.py | 4 +- .../tests/official_self_hosted_cancel.py | 10 +- .../tests/official_session_agent_filter.py | 5 +- .../tests/official_session_creation_stream.py | 2 +- .../tests/official_session_creators.py | 4 +- .../tests/official_session_initial_input.py | 19 ++- .../tests/official_session_requests.py | 2 +- .../tests/official_workspace_images_native.py | 4 +- 42 files changed, 310 insertions(+), 90 deletions(-) create mode 100644 services/agents-api/internal/api/history_pages.go create mode 100644 services/agents-api/internal/api/session_semantics_test.go diff --git a/contracts/agents-api/v1/items.go b/contracts/agents-api/v1/items.go index 83071f156..e1a17c9c3 100644 --- a/contracts/agents-api/v1/items.go +++ b/contracts/agents-api/v1/items.go @@ -50,8 +50,11 @@ type WebSearchAction struct { } type ItemList struct { - Data []Item `json:"data" binding:"required"` - HasMore bool `json:"has_more" binding:"required"` + Object string `json:"object" enums:"list" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` + Data []Item `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` } // UnmarshalJSON preserves integer precision in tool arguments and structured results. diff --git a/contracts/agents-api/v1/sessions.go b/contracts/agents-api/v1/sessions.go index 1f963d749..8d5f21e24 100644 --- a/contracts/agents-api/v1/sessions.go +++ b/contracts/agents-api/v1/sessions.go @@ -104,6 +104,9 @@ type Session struct { } type SessionList struct { + Object string `json:"object" enums:"list" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` Data []Session `json:"data" binding:"required"` HasMore bool `json:"has_more" binding:"required"` } diff --git a/contracts/agents-api/v1/turns.go b/contracts/agents-api/v1/turns.go index ea112a260..745953deb 100644 --- a/contracts/agents-api/v1/turns.go +++ b/contracts/agents-api/v1/turns.go @@ -20,6 +20,9 @@ type TurnError struct { } type TurnList struct { - Data []Turn `json:"data" binding:"required"` - HasMore bool `json:"has_more" binding:"required"` + Object string `json:"object" enums:"list" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` + Data []Turn `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` } diff --git a/services/agents-api/internal/api/claude_admission_test.go b/services/agents-api/internal/api/claude_admission_test.go index 318887457..adb1e7344 100644 --- a/services/agents-api/internal/api/claude_admission_test.go +++ b/services/agents-api/internal/api/claude_admission_test.go @@ -64,7 +64,7 @@ func TestClaudeSessionConfigurationAdmission(t *testing.T) { handler.ServeHTTP(response, request) want := http.StatusBadRequest if test.accepted { - want = http.StatusOK + want = http.StatusCreated if stream || initial { want = http.StatusServiceUnavailable } @@ -72,10 +72,10 @@ func TestClaudeSessionConfigurationAdmission(t *testing.T) { if response.Code != want { t.Fatalf("status %d, expected %d: %s", response.Code, want, response.Body) } - if want != http.StatusOK && saved.tenant != "" { + if want != http.StatusCreated && saved.tenant != "" { t.Fatal("rejected request persisted a Session") } - if want == http.StatusOK && saved.input.Engine != "claude_sdk" { + if want == http.StatusCreated && saved.input.Engine != "claude_sdk" { t.Fatal("wrong engine persisted") } }) diff --git a/services/agents-api/internal/api/claude_mcp_test.go b/services/agents-api/internal/api/claude_mcp_test.go index c0760c9db..d413cb78d 100644 --- a/services/agents-api/internal/api/claude_mcp_test.go +++ b/services/agents-api/internal/api/claude_mcp_test.go @@ -46,7 +46,7 @@ func TestClaudeMCPAdmitsResolvedCredentials(t *testing.T) { } body := fmt.Sprintf(`{"agent":{"model":"model","tools":[%s]},"environment":{"type":"none"},"vault_ids":[%q]}`, tool, vault) response := credentialRequest(h, "POST", "/v1/agents/sessions", body) - if response.Code != 200 || s.calls != 1 || s.tenant == "" { + if response.Code != 201 || s.calls != 1 || s.tenant == "" { t.Fatal("credential selection or admission failed", response.Code, response.Body, s.calls) } }) diff --git a/services/agents-api/internal/api/environment_creation_test.go b/services/agents-api/internal/api/environment_creation_test.go index 74827ce79..84e42faa8 100644 --- a/services/agents-api/internal/api/environment_creation_test.go +++ b/services/agents-api/internal/api/environment_creation_test.go @@ -92,7 +92,7 @@ func TestSelfHostedEmptyCreationAndStream(t *testing.T) { t.Fatal(err) } defer response.Body.Close() - if response.StatusCode != http.StatusOK { + if response.StatusCode != http.StatusCreated { t.Fatal("empty creation rejected", response.StatusCode) } var session v1.Session diff --git a/services/agents-api/internal/api/environment_input_test.go b/services/agents-api/internal/api/environment_input_test.go index f54446388..6e4468dd6 100644 --- a/services/agents-api/internal/api/environment_input_test.go +++ b/services/agents-api/internal/api/environment_input_test.go @@ -73,7 +73,7 @@ func TestPreparedEnvironmentInputWaitExtendsOnlyItsResponseDeadline(t *testing.T create.Header.Set("OpenAI-Beta", "agents=v1") created := httptest.NewRecorder() handler.ServeHTTP(created, create) - if created.Code != http.StatusOK { + if created.Code != http.StatusCreated { t.Fatal("fixture creation failed", created.Code, created.Body.String()) } if environment == "openai_hosted" { @@ -121,7 +121,7 @@ func TestPreparedEnvironmentInputWaitExtendsOnlyItsResponseDeadline(t *testing.T defer outcome.response.Body.Close() } if environment != "none" { - if outcome.err != nil || outcome.response.StatusCode != http.StatusNoContent { + if outcome.err != nil || outcome.response.StatusCode != http.StatusAccepted { t.Fatal("prepared Environment wait lost its response to the ordinary timeout", outcome.err) } } else if outcome.err == nil { diff --git a/services/agents-api/internal/api/function_configuration_test.go b/services/agents-api/internal/api/function_configuration_test.go index 02a684e06..6b17cee6f 100644 --- a/services/agents-api/internal/api/function_configuration_test.go +++ b/services/agents-api/internal/api/function_configuration_test.go @@ -19,7 +19,7 @@ func TestPublicFunctionConfiguration(t *testing.T) { req.Header.Set("OpenAI-Beta", "agents=v1") w := httptest.NewRecorder() h.ServeHTTP(w, req) - if w.Code != 200 { + if w.Code != 201 { t.Fatal(suffix, w.Code, w.Body) } var response v1.Session diff --git a/services/agents-api/internal/api/function_inputs.go b/services/agents-api/internal/api/function_inputs.go index 3d2749932..5cbe20840 100644 --- a/services/agents-api/internal/api/function_inputs.go +++ b/services/agents-api/internal/api/function_inputs.go @@ -25,6 +25,7 @@ func decodeInputEvent(raw json.RawMessage) (decodedInputEvent, error) { fields = append(fields, "input") var messages struct { Input []struct { + Type json.RawMessage `json:"type"` Content json.RawMessage `json:"content"` } `json:"input"` } @@ -32,6 +33,12 @@ func decodeInputEvent(raw json.RawMessage) (decodedInputEvent, error) { return event, store.ErrInvalidInput } for _, message := range messages.Input { + if len(message.Type) > 0 { + var kind string + if json.Unmarshal(message.Type, &kind) != nil || kind != "message" { + return event, store.ErrInvalidInput + } + } if err := validateInputContent(message.Content); err != nil { return event, err } diff --git a/services/agents-api/internal/api/function_inputs_test.go b/services/agents-api/internal/api/function_inputs_test.go index 99042a2b1..ba8291d57 100644 --- a/services/agents-api/internal/api/function_inputs_test.go +++ b/services/agents-api/internal/api/function_inputs_test.go @@ -30,7 +30,7 @@ func TestPublicFunctionResultsPreserveOptionalValues(t *testing.T) { } { body := `{"events":[{"type":"agent.session.input.tool_result","turn_id":"turn","call_id":"call",` + fields + `}]}` w, recorder := submitResultRequest(t, body, nil) - if w.Code != 204 || w.Body.Len() != 0 || len(recorder.inputs) != 1 { + if w.Code != 202 || w.Body.Len() != 0 || len(recorder.inputs) != 1 { t.Fatal(w.Code, w.Body, recorder.inputs) } var input store.FunctionResultInput diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index bcbb51fc9..09dca54e3 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -132,7 +132,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // @Param OpenAI-Beta header string true "agents=v1" // @Param Idempotency-Key header string false "Creation retry key, up to 128 bytes" // @Param body body v1.CreateSessionRequest true "Session configuration" -// @Success 200 {object} v1.Session +// @Success 201 {object} v1.Session // @Failure 400,401,404,409,413,500,503 {object} v1.ErrorResponse // @Router /agents/sessions [post] func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { @@ -266,7 +266,7 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - h.respondSession(w, r, session) + h.respondSessionStatus(w, r, session, http.StatusCreated) } // @Summary Retrieve an execution Session @@ -293,12 +293,16 @@ func (h *Handler) getSession(w http.ResponseWriter, r *http.Request) { } func (h *Handler) respondSession(w http.ResponseWriter, r *http.Request, session store.Session) { + h.respondSessionStatus(w, r, session, http.StatusOK) +} + +func (h *Handler) respondSessionStatus(w http.ResponseWriter, r *http.Request, session store.Session, status int) { response, err := sessionResponse(session, h.executorURL) if err != nil { writeStoreError(w, r, err) return } - writeJSON(w, http.StatusOK, response) + writeJSON(w, status, response) } // @Summary List execution Sessions @@ -337,5 +341,5 @@ func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) { } response.Data = append(response.Data, item) } - writeJSON(w, http.StatusOK, response) + writeJSON(w, http.StatusOK, sessionListResponse(response.Data, response.HasMore)) } diff --git a/services/agents-api/internal/api/handler_test.go b/services/agents-api/internal/api/handler_test.go index 4c84d2c93..da5ec885c 100644 --- a/services/agents-api/internal/api/handler_test.go +++ b/services/agents-api/internal/api/handler_test.go @@ -65,7 +65,7 @@ func TestHTTPConfigurationAndTenantIdentity(t *testing.T) { request.Header.Set("X-Tenant-ID", "untrusted-tenant") w := httptest.NewRecorder() h.ServeHTTP(w, request) - if w.Code != http.StatusOK || s.tenant != tenant || s.input.Engine != "codex" || s.input.IdempotencyKey != "retry-key" { + if w.Code != http.StatusCreated || s.tenant != tenant || s.input.Engine != "codex" || s.input.IdempotencyKey != "retry-key" { t.Fatalf("request = %d %s; tenant=%s, engine=%s", w.Code, w.Body, s.tenant, s.input.Engine) } var response v1.Session diff --git a/services/agents-api/internal/api/harness_test.go b/services/agents-api/internal/api/harness_test.go index 3798e28ac..fffd2f46b 100644 --- a/services/agents-api/internal/api/harness_test.go +++ b/services/agents-api/internal/api/harness_test.go @@ -17,10 +17,10 @@ func TestSessionHarnessAdmission(t *testing.T) { enabled bool status int }{ - {"default", "", "", `{"type":"none"}`, "codex", false, 200}, - {"explicit default", `,"x_agents_core":{"harness":"codex"}`, "", `{"type":"none"}`, "codex", false, 200}, - {"claude", `,"x_agents_core":{"harness":"claude_sdk"}`, "", `{"type":"none"}`, "claude_sdk", true, 200}, - {"mcode", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"none"}`, "mcode", true, 200}, + {"default", "", "", `{"type":"none"}`, "codex", false, 201}, + {"explicit default", `,"x_agents_core":{"harness":"codex"}`, "", `{"type":"none"}`, "codex", false, 201}, + {"claude", `,"x_agents_core":{"harness":"claude_sdk"}`, "", `{"type":"none"}`, "claude_sdk", true, 201}, + {"mcode", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"none"}`, "mcode", true, 201}, {"unavailable", `,"x_agents_core":{"harness":"claude_sdk"}`, "", `{"type":"none"}`, "", false, 400}, {"unknown", `,"x_agents_core":{"harness":"other"}`, "", `{"type":"none"}`, "", true, 400}, {"empty", `,"x_agents_core":{}`, "", `{"type":"none"}`, "", true, 400}, diff --git a/services/agents-api/internal/api/history_pages.go b/services/agents-api/internal/api/history_pages.go new file mode 100644 index 000000000..f88c643b9 --- /dev/null +++ b/services/agents-api/internal/api/history_pages.go @@ -0,0 +1,35 @@ +package api + +import v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + +func listBounds[T any](data []T, id func(T) string) (*string, *string) { + if len(data) == 0 { + return nil, nil + } + first, last := id(data[0]), id(data[len(data)-1]) + return &first, &last +} + +func sessionListResponse(data []v1.Session, more bool) v1.SessionList { + if data == nil { + data = []v1.Session{} + } + first, last := listBounds(data, func(value v1.Session) string { return value.ID }) + return v1.SessionList{Object: "list", Data: data, HasMore: more, FirstID: first, LastID: last} +} + +func turnListResponse(data []v1.Turn, more bool) v1.TurnList { + if data == nil { + data = []v1.Turn{} + } + first, last := listBounds(data, func(value v1.Turn) string { return value.ID }) + return v1.TurnList{Object: "list", Data: data, HasMore: more, FirstID: first, LastID: last} +} + +func itemListResponse(data []v1.Item, more bool) v1.ItemList { + if data == nil { + data = []v1.Item{} + } + first, last := listBounds(data, func(value v1.Item) string { return value.ID }) + return v1.ItemList{Object: "list", Data: data, HasMore: more, FirstID: first, LastID: last} +} diff --git a/services/agents-api/internal/api/inputs.go b/services/agents-api/internal/api/inputs.go index 7ce8ecb7b..f54c776ab 100644 --- a/services/agents-api/internal/api/inputs.go +++ b/services/agents-api/internal/api/inputs.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "io" "net/http" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -24,7 +24,7 @@ type Option func(*Handler) func WithExecution(s InputSubmitter) Option { return func(h *Handler) { h.inputs = s } } // @Summary Submit Session input events -// @Description For environment none, atomically accepts text messages, cancellation and function results. Messages steer active work or start a queued Turn. The supported self_hosted profile accepts text-only messages; qualified Codex and Claude SDK openai_hosted profiles also accept inline PNG/JPEG. Under the Session lock, matching retries retain their original target; new active messages append to the current Turn, while idle messages reserve work and wait up to the original five-minute connection/admission deadline. Return 204 only after durable admission, without claiming native application; active messages create no Turn or reservation. Cancellation-only prepared-environment batches use existing durable cancellation admission and return 204 without waiting for native exit; a new cancellation conflicts while a pre-Turn reservation is pending. Homogeneous tool_result-only prepared-environment batches reuse existing scoped result admission and application receipts without creating a Turn or bypassing a pending reservation. Mixed prepared-environment batches remain unsupported. HTTP expiry/cancellation use local 409 environment_input_expired/environment_input_cancelled errors; exact hosted failure mapping is unverified. Losing execution ownership returns 503. The response write deadline accommodates the admission window for either prepared Environment, independently of new-hosted-admission and executor URL settings. Disconnecting the waiting HTTP request does not cancel retained work or restart its deadline. Retry keys identify the whole ordered batch. Function output accepts text or ordered text/image parts subject to engine support; Claude SDK accepts text results and, on none and qualified openai_hosted, successful inline PNG/JPEG results, preserving ordered content; error images and remote references reject before admission. Native image resizing may change bytes. Runtime image-result support is checked only for image-bearing delivery. Codex and Claude SDK on none and qualified openai_hosted accept ordered inline PNG/JPEG image messages. Self-hosted profiles and other engines remain text-only; remote image URLs are unsupported. Image references are retained unchanged without service-side downloads. +// @Description An empty events array is a resource-authorized no-op; it creates no execution retry identity, Turn, Item or input receipt. For environment none, atomically accepts text messages, cancellation and function results. Messages steer active work or start a queued Turn. The supported self_hosted profile accepts text-only messages; qualified Codex and Claude SDK openai_hosted profiles also accept inline PNG/JPEG. Under the Session lock, matching retries retain their original target; new active messages append to the current Turn, while idle messages reserve work and wait up to the original five-minute connection/admission deadline. Return 202 only after durable admission, without claiming native application; active messages create no Turn or reservation. Cancellation-only prepared-environment batches use existing durable cancellation admission and return 202 without waiting for native exit; a new cancellation conflicts while a pre-Turn reservation is pending. Homogeneous tool_result-only prepared-environment batches reuse existing scoped result admission and application receipts without creating a Turn or bypassing a pending reservation. Mixed prepared-environment batches remain unsupported. HTTP expiry/cancellation use local 409 environment_input_expired/environment_input_cancelled errors; exact hosted failure mapping is unverified. Losing execution ownership returns 503. The response write deadline accommodates the admission window for either prepared Environment, independently of new-hosted-admission and executor URL settings. Disconnecting the waiting HTTP request does not cancel retained work or restart its deadline. Retry keys identify the whole ordered batch. Function output accepts text or ordered text/image parts subject to engine support; Claude SDK accepts text results and, on none and qualified openai_hosted, successful inline PNG/JPEG results, preserving ordered content; error images and remote references reject before admission. Native image resizing may change bytes. Runtime image-result support is checked only for image-bearing delivery. Codex and Claude SDK on none and qualified openai_hosted accept ordered inline PNG/JPEG image messages. Self-hosted profiles and other engines remain text-only; remote image URLs are unsupported. Image references are retained unchanged without service-side downloads. // @Tags Sessions // @Accept json // @Security BearerAuth @@ -32,14 +32,10 @@ func WithExecution(s InputSubmitter) Option { return func(h *Handler) { h.inputs // @Param Idempotency-Key header string false "Retry key, up to 128 bytes" // @Param session_id path string true "Session ID" // @Param body body v1.CreateEventsRequest true "Ordered input events" -// @Success 204 +// @Success 202 // @Failure 400,401,404,409,413,500,503 {object} v1.ErrorResponse // @Router /agents/sessions/{session_id}/events [post] func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { - if h.inputs == nil { - writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not enabled on this service.") - return - } if len(r.URL.Query()) != 0 { writeError(w, http.StatusBadRequest, "unsupported_parameter", "Event submission does not accept query parameters.") return @@ -62,15 +58,34 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request", "Request must contain exactly one JSON object.") return } + key := r.Header.Get("Idempotency-Key") + if key == "" { + key = uuid.NewString() + } + if err := store.ValidateInputKey(key); err != nil { + writeStoreError(w, r, err) + return + } + if request.Events != nil && len(request.Events) == 0 { + // Empty batches have no execution identity to reserve or replay. + // Authorize the resource even when no executor is configured. + if _, err := h.store.GetSession(r.Context(), tenantID(r), chi.URLParam(r, "session_id")); err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusAccepted) + return + } + if h.inputs == nil { + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not enabled on this service.") + return + } inputs, err := executionInputs(request.Events) if err != nil { writeStoreError(w, r, err) return } - key := r.Header.Get("Idempotency-Key") - if key == "" { - key = uuid.NewString() - } sessionID := chi.URLParam(r, "session_id") if err := h.setEnvironmentInputWriteDeadline(w, r, sessionID); err != nil { writeStoreError(w, r, err) @@ -81,7 +96,7 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusNoContent) + w.WriteHeader(http.StatusAccepted) } func executionInputs(events []json.RawMessage) ([]store.Input, error) { diff --git a/services/agents-api/internal/api/inputs_test.go b/services/agents-api/internal/api/inputs_test.go index d860cacb5..bb75160b8 100644 --- a/services/agents-api/internal/api/inputs_test.go +++ b/services/agents-api/internal/api/inputs_test.go @@ -34,7 +34,7 @@ func TestPublicInputAdmission(t *testing.T) { r.Header.Set("X-Tenant-ID", "forged") w := httptest.NewRecorder() h.ServeHTTP(w, r) - if w.Code != 204 || w.Body.Len() != 0 || recorder.tenant != tenant || recorder.session != "session-id" || recorder.key != "batch-key" { + if w.Code != 202 || w.Body.Len() != 0 || recorder.tenant != tenant || recorder.session != "session-id" || recorder.key != "batch-key" { t.Fatalf("response=%d %s recorder=%+v", w.Code, w.Body, recorder) } if len(recorder.inputs) != 2 || recorder.inputs[0].Kind != "message" || recorder.inputs[1].Kind != "cancel" { @@ -51,7 +51,8 @@ func TestPublicInputAdmission(t *testing.T) { func TestPublicInputRejectsUnsupportedOrMalformedBatch(t *testing.T) { for _, body := range []string{ - `null`, `{}`, `{"events":[]}`, `{"events":[{"type":"agent.session.input.tool_result","call_id":"x"}]}`, + `{"events":[{"type":"agent.session.input.message","input":[{"type":null,"role":"user","content":[{"type":"input_text","text":"x"}]}]}]}`, + `null`, `{}`, `{"events":null}`, `{"events":[{"type":"agent.session.input.tool_result","call_id":"x"}]}`, `{"events":[{"type":"agent.session.input.message","input":[{"role":"assistant","content":[{"type":"input_text","text":"x"}]}]}]}`, `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_image","image_url":"https://example.com/a.png"}]}]}]}`, `{"events":[{"type":"agent.session.input.cancel","input":[]}]}`, diff --git a/services/agents-api/internal/api/items.go b/services/agents-api/internal/api/items.go index 8bd6c23e7..43b7500fd 100644 --- a/services/agents-api/internal/api/items.go +++ b/services/agents-api/internal/api/items.go @@ -1,7 +1,6 @@ package api import ( - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/go-chi/chi/v5" "net/http" ) @@ -29,5 +28,5 @@ func (h *Handler) listItems(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - writeJSON(w, http.StatusOK, v1.ItemList{Data: page.Items, HasMore: page.HasMore}) + writeJSON(w, http.StatusOK, itemListResponse(page.Items, page.HasMore)) } diff --git a/services/agents-api/internal/api/items_test.go b/services/agents-api/internal/api/items_test.go index 0c95e6e24..f77a650ab 100644 --- a/services/agents-api/internal/api/items_test.go +++ b/services/agents-api/internal/api/items_test.go @@ -37,7 +37,7 @@ func TestItemRouteUsesAuthenticationAndSharedPagination(t *testing.T) { if w := request("?after=last&limit=2&order=asc", "test-api-key"); w.Code != 200 || s.tenant != tenant || s.session != "session" || s.cursor != "last" || s.limit != 2 || !s.ascending { t.Fatal(w.Code, w.Body, s) } - if w := request("", "test-api-key"); w.Code != 200 || s.limit != 20 || s.ascending || w.Body.String() != "{\"data\":[],\"has_more\":false}\n" { + if w := request("", "test-api-key"); w.Code != 200 || s.limit != 20 || s.ascending || w.Body.String() != "{\"object\":\"list\",\"first_id\":null,\"last_id\":null,\"data\":[],\"has_more\":false}\n" { t.Fatal(w.Code, w.Body, s) } for _, q := range []string{"?limit=0", "?limit=101", "?order=bad", "?limit=2&limit=3", "?tenant_id=other"} { diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index 5e2561952..592ecff2e 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -60,7 +60,7 @@ func (h *Handler) recoverSessionCreation(w http.ResponseWriter, r *http.Request, if err != nil { writeStoreError(w, r, err) } else { - h.respondSession(w, r, session) + h.respondSessionStatus(w, r, session, http.StatusCreated) } } return true diff --git a/services/agents-api/internal/api/session_creation_stream.go b/services/agents-api/internal/api/session_creation_stream.go index f03851b77..b5a8db8ee 100644 --- a/services/agents-api/internal/api/session_creation_stream.go +++ b/services/agents-api/internal/api/session_creation_stream.go @@ -56,5 +56,5 @@ func (h *Handler) respondSessionCreationStream(w http.ResponseWriter, r *http.Re if result.Created { initial = &v1.SessionEvent{Type: "agent.session.created", EventID: uuid.NewString(), Session: &response} } - h.serveSessionEvents(w, r, events, result.Session, result.Cursor, initial) + h.serveSessionEvents(w, r, events, result.Session, result.Cursor, initial, http.StatusCreated) } diff --git a/services/agents-api/internal/api/session_initial_input_test.go b/services/agents-api/internal/api/session_initial_input_test.go index 8b892135e..fa2f8c0e3 100644 --- a/services/agents-api/internal/api/session_initial_input_test.go +++ b/services/agents-api/internal/api/session_initial_input_test.go @@ -19,7 +19,7 @@ func TestInitialInputUsesExecutionAdmissionAndSharedMessageValidation(t *testing r.Header.Set("Idempotency-Key", "create-key") w := httptest.NewRecorder() h.ServeHTTP(w, r) - if w.Code != 200 || idle.tenant != "" || execution.tenant != tenant || execution.input.IdempotencyKey != "create-key" || len(execution.input.InitialInputs) != 1 || recorder.inputs != nil { + if w.Code != 201 || idle.tenant != "" || execution.tenant != tenant || execution.input.IdempotencyKey != "create-key" || len(execution.input.InitialInputs) != 1 || recorder.inputs != nil { t.Fatal(w.Code, w.Body, execution.input) } expected, err := executionInputs([]json.RawMessage{json.RawMessage(`{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"First"}]}]}`)}) @@ -27,7 +27,7 @@ func TestInitialInputUsesExecutionAdmissionAndSharedMessageValidation(t *testing t.Fatal(execution.input, err) } } - for _, value := range []string{`0`, `true`, `{}`, `[]`, `" "`, `[{"role":"user","unknown":true,"content":[{"type":"input_text","text":"x"}]}]`, `[{"role":"user","content":[{"type":"input_text","text":"x","unknown":true}]}]`, `[{"role":"assistant","content":[{"type":"input_text","text":"x"}]}]`} { + for _, value := range []string{`[{"type":null,"role":"user","content":[{"type":"input_text","text":"x"}]}]`, `[{"type":"","role":"user","content":[{"type":"input_text","text":"x"}]}]`, `0`, `true`, `{}`, `[]`, `" "`, `[{"role":"user","unknown":true,"content":[{"type":"input_text","text":"x"}]}]`, `[{"role":"user","content":[{"type":"input_text","text":"x","unknown":true}]}]`, `[{"role":"assistant","content":[{"type":"input_text","text":"x"}]}]`} { if _, err := initialSessionInputs(json.RawMessage(value)); err == nil { t.Fatal("invalid initial input accepted", value) } diff --git a/services/agents-api/internal/api/session_request_test.go b/services/agents-api/internal/api/session_request_test.go index 5d48b263b..143f92f8e 100644 --- a/services/agents-api/internal/api/session_request_test.go +++ b/services/agents-api/internal/api/session_request_test.go @@ -19,17 +19,17 @@ func TestSessionCreateFieldPresence(t *testing.T) { status int metadata map[string]string }{ - {"omitted", ``, 200, nil}, - {"false stream", `,"stream":false`, 200, nil}, + {"omitted", ``, 201, nil}, + {"false stream", `,"stream":false`, 201, nil}, {"null stream", `,"stream":null`, 400, nil}, {"whitespace null stream", `,"stream": null `, 400, nil}, {"string stream", `,"stream":"false"`, 400, nil}, {"numeric stream", `,"stream":0`, 400, nil}, {"null agent ID", `,"agent_id":null`, 400, nil}, {"numeric agent ID", `,"agent_id":0`, 400, nil}, - {"null metadata", `,"metadata":null`, 200, nil}, - {"empty metadata", `,"metadata":{}`, 200, nil}, - {"string metadata", `,"metadata":{"empty":"","label":"中文🧪"}`, 200, map[string]string{"empty": "", "label": "中文🧪"}}, + {"null metadata", `,"metadata":null`, 201, nil}, + {"empty metadata", `,"metadata":{}`, 201, nil}, + {"string metadata", `,"metadata":{"empty":"","label":"中文🧪"}`, 201, map[string]string{"empty": "", "label": "中文🧪"}}, {"null metadata value", `,"metadata":{"label":null}`, 400, nil}, {"mixed metadata values", `,"metadata":{"empty":"","label":null}`, 400, nil}, {"numeric metadata value", `,"metadata":{"label":0}`, 400, nil}, @@ -46,7 +46,7 @@ func TestSessionCreateFieldPresence(t *testing.T) { if response.Code != tc.status { t.Fatalf("status = %d, want %d: %s", response.Code, tc.status, response.Body) } - if tc.status != http.StatusOK { + if tc.status != http.StatusCreated { if saved.tenant != "" { t.Fatal("invalid request reached persistence") } diff --git a/services/agents-api/internal/api/session_semantics_test.go b/services/agents-api/internal/api/session_semantics_test.go new file mode 100644 index 000000000..e82810070 --- /dev/null +++ b/services/agents-api/internal/api/session_semantics_test.go @@ -0,0 +1,136 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type emptyEventSessionStore struct { + ResourceStore + tenant, id string + reads int +} + +func (s *emptyEventSessionStore) GetSession(_ context.Context, tenant, id string) (store.Session, error) { + s.tenant, s.id = tenant, id + s.reads++ + if id != "owned" { + return store.Session{}, store.ErrNotFound + } + return store.Session{ID: id, TenantID: tenant, Configuration: json.RawMessage(`{"environment":{"type":"none"}}`)}, nil +} + +func TestEmptyEventBatchAuthorizesWithoutExecutionEffects(t *testing.T) { + for _, executor := range []bool{false, true} { + t.Run(map[bool]string{false: "without executor", true: "with executor"}[executor], func(t *testing.T) { + recorder := &inputRecorder{} + var options []Option + if executor { + options = append(options, WithExecution(recorder)) + } + h, base, tenant := testHandler(t, options...) + sessions := &emptyEventSessionStore{} + base.ResourceStore = sessions + request := func(id, token, key string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions/"+id+"/events", strings.NewReader(`{"events":[]}`)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Idempotency-Key", key) + r.Header.Set("X-Tenant-ID", "forged") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + for range 2 { + w := request("owned", "test-api-key", "same-key") + if w.Code != http.StatusAccepted || w.Body.Len() != 0 || w.Header().Get("Cache-Control") != "no-store" || sessions.tenant != tenant || sessions.id != "owned" || recorder.inputs != nil || recorder.key != "" { + t.Fatalf("no-op reached execution or lost scope: %d %s %+v %+v", w.Code, w.Body, sessions, recorder) + } + } + reads := sessions.reads + for _, key := range []string{" ", strings.Repeat("x", 129)} { + if w := request("owned", "test-api-key", key); w.Code != http.StatusBadRequest || sessions.reads != reads { + t.Fatalf("invalid identity reached resource: %d %s", w.Code, w.Body) + } + } + if w := request("owned", "wrong", "same-key"); w.Code != http.StatusUnauthorized || sessions.reads != reads { + t.Fatalf("unauthorized read: %d", w.Code) + } + if w := request("foreign", "test-api-key", "same-key"); w.Code != http.StatusNotFound || recorder.inputs != nil { + t.Fatalf("unknown resource accepted: %d", w.Code) + } + if executor { + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions/owned/events", strings.NewReader(`{"events":[{"type":"agent.session.input.cancel"}]}`)) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Idempotency-Key", "same-key") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusAccepted || recorder.key != "same-key" || len(recorder.inputs) != 1 { + t.Fatalf("no-op consumed subsequent execution identity: %d %s", w.Code, w.Body) + } + } + }) + } +} + +func TestHistoryListWireEnvelopesPreserveReturnedOrder(t *testing.T) { + for _, ids := range [][]string{nil, {"only"}, {"newest", "oldest"}} { + sessions := make([]v1.Session, len(ids)) + turns := make([]v1.Turn, len(ids)) + items := make([]v1.Item, len(ids)) + for i, id := range ids { + sessions[i].ID = id + turns[i].ID = id + items[i].ID = id + } + for name, page := range map[string]any{ + "sessions": sessionListResponse(sessions, len(ids) > 1), + "turns": turnListResponse(turns, len(ids) > 1), + "items": itemListResponse(items, len(ids) > 1), + } { + t.Run(name+"/"+strings.Join(ids, ","), func(t *testing.T) { + raw, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + var got map[string]json.RawMessage + if json.Unmarshal(raw, &got) != nil { + t.Fatal(string(raw)) + } + if len(got) != 5 || string(got["object"]) != `"list"` || string(got["data"]) == "null" { + t.Fatalf("incomplete envelope: %s", raw) + } + var first, last *string + _ = json.Unmarshal(got["first_id"], &first) + _ = json.Unmarshal(got["last_id"], &last) + if len(ids) == 0 { + if first != nil || last != nil || string(got["has_more"]) != "false" { + t.Fatal(string(raw)) + } + return + } + if first == nil || last == nil || *first != ids[0] || *last != ids[len(ids)-1] { + t.Fatalf("cursors disagree with ordered data: %s", raw) + } + var values []struct{ ID string } + _ = json.Unmarshal(got["data"], &values) + actual := make([]string, len(values)) + for i, value := range values { + actual[i] = value.ID + } + if !reflect.DeepEqual(actual, ids) { + t.Fatalf("reordered page: %v", actual) + } + }) + } + } +} diff --git a/services/agents-api/internal/api/stream.go b/services/agents-api/internal/api/stream.go index 3817a814b..0bb337039 100644 --- a/services/agents-api/internal/api/stream.go +++ b/services/agents-api/internal/api/stream.go @@ -55,14 +55,15 @@ func (h *Handler) streamEvents(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - h.serveSessionEvents(w, r, events, session, cursor, nil) + h.serveSessionEvents(w, r, events, session, cursor, nil, http.StatusOK) } -func (h *Handler) serveSessionEvents(w http.ResponseWriter, r *http.Request, events eventStore, session store.Session, cursor int64, initial *v1.SessionEvent) { +func (h *Handler) serveSessionEvents(w http.ResponseWriter, r *http.Request, events eventStore, session store.Session, cursor int64, initial *v1.SessionEvent, status int) { id, tenant := session.ID, tenantID(r) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(status) controller := http.NewResponseController(w) write := func(data []byte) error { if err := controller.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { diff --git a/services/agents-api/internal/api/subagent_turns.go b/services/agents-api/internal/api/subagent_turns.go index 23f0433be..0f9afbc67 100644 --- a/services/agents-api/internal/api/subagent_turns.go +++ b/services/agents-api/internal/api/subagent_turns.go @@ -3,7 +3,6 @@ package api import ( "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/go-chi/chi/v5" ) @@ -59,10 +58,7 @@ func (h *Handler) listSubagentTurns(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - if page.Data == nil { - page.Data = []v1.Turn{} - } - writeJSON(w, http.StatusOK, page) + writeJSON(w, http.StatusOK, turnListResponse(page.Data, page.HasMore)) } // @Summary List a Subagent Turn's Items @@ -90,8 +86,5 @@ func (h *Handler) listSubagentTurnItems(w http.ResponseWriter, r *http.Request) writeStoreError(w, r, err) return } - if page.Data == nil { - page.Data = []v1.Item{} - } - writeJSON(w, http.StatusOK, page) + writeJSON(w, http.StatusOK, itemListResponse(page.Data, page.HasMore)) } diff --git a/services/agents-api/internal/api/subagents.go b/services/agents-api/internal/api/subagents.go index 9721d20dd..93e1b307b 100644 --- a/services/agents-api/internal/api/subagents.go +++ b/services/agents-api/internal/api/subagents.go @@ -119,8 +119,5 @@ func (h *Handler) listSubagentItems(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } - if page.Data == nil { - page.Data = []v1.Item{} - } - writeJSON(w, http.StatusOK, page) + writeJSON(w, http.StatusOK, itemListResponse(page.Data, page.HasMore)) } diff --git a/services/agents-api/internal/api/subagents_test.go b/services/agents-api/internal/api/subagents_test.go index 6378d7d25..08eb22312 100644 --- a/services/agents-api/internal/api/subagents_test.go +++ b/services/agents-api/internal/api/subagents_test.go @@ -205,7 +205,11 @@ func TestSubagentRoutesDistinguishEmptyFromUnavailable(t *testing.T) { } if route.list { w = requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") - if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"data":[],"has_more":false}` { + expected := `{"object":"list","first_id":null,"last_id":null,"data":[],"has_more":false}` + if route.method == "list" { + expected = `{"data":[],"has_more":false}` + } + if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != expected { t.Fatalf("empty page: %d %s", w.Code, w.Body) } } diff --git a/services/agents-api/internal/api/text_configuration_test.go b/services/agents-api/internal/api/text_configuration_test.go index 32b75849c..22ff4a484 100644 --- a/services/agents-api/internal/api/text_configuration_test.go +++ b/services/agents-api/internal/api/text_configuration_test.go @@ -25,7 +25,7 @@ func TestTextConfigurationHTTP(t *testing.T) { req.Header.Set("OpenAI-Beta", "agents=v1") response := httptest.NewRecorder() h.ServeHTTP(response, req) - if response.Code != 200 { + if response.Code != 201 { t.Fatal(response.Code, response.Body) } var got v1.Session diff --git a/services/agents-api/internal/api/turns.go b/services/agents-api/internal/api/turns.go index 8f2227c18..cd5226e23 100644 --- a/services/agents-api/internal/api/turns.go +++ b/services/agents-api/internal/api/turns.go @@ -83,7 +83,7 @@ func (h *Handler) listTurns(w http.ResponseWriter, r *http.Request) { } response.Data = append(response.Data, item) } - writeJSON(w, http.StatusOK, response) + writeJSON(w, http.StatusOK, turnListResponse(response.Data, response.HasMore)) } func turnResponse(session store.Session, turn store.Turn) (v1.Turn, error) { diff --git a/services/agents-api/internal/store/environment_inputs.go b/services/agents-api/internal/store/environment_inputs.go index e1edcedb7..ec95cfd89 100644 --- a/services/agents-api/internal/store/environment_inputs.go +++ b/services/agents-api/internal/store/environment_inputs.go @@ -38,7 +38,7 @@ type EnvironmentInputReservation struct { // ReserveEnvironmentInput appends to active work or reserves an idle message batch. // The Session lock decides both paths; only promotion can create a new Turn. func (s *Store) ReserveEnvironmentInput(ctx context.Context, tenantID, sessionID, key string, inputs []Input) (EnvironmentInputReservation, error) { - if err := validateInputKey(key); err != nil { + if err := ValidateInputKey(key); err != nil { return EnvironmentInputReservation{}, err } batch, encoded, err := validateInitialInputs(inputs) diff --git a/services/agents-api/internal/store/turn_inputs.go b/services/agents-api/internal/store/turn_inputs.go index d52bb4f61..ba9741446 100644 --- a/services/agents-api/internal/store/turn_inputs.go +++ b/services/agents-api/internal/store/turn_inputs.go @@ -57,7 +57,7 @@ func (s *Store) submitOne(ctx context.Context, tenantID, sessionID, key string, // batch is the retry identity; replay never re-evaluates a cancellation target. // Internal receipts are not the response body of the public events endpoint. func (s *Store) SubmitInputs(ctx context.Context, tenantID, sessionID, key string, inputs []Input) ([]InputReceipt, error) { - if err := validateInputKey(key); err != nil { + if err := ValidateInputKey(key); err != nil { return nil, err } batch, encoded, err := validateInputs(inputs) @@ -97,7 +97,8 @@ func (s *Store) SubmitInputs(ctx context.Context, tenantID, sessionID, key strin return receipts, nil } -func validateInputKey(key string) error { +// ValidateInputKey enforces the shared request identity limit, including no-op requests. +func ValidateInputKey(key string) error { if strings.TrimSpace(key) == "" || len(key) > 128 { return fmt.Errorf("%w: idempotency key is required and limited to 128 bytes", ErrInvalidInput) } diff --git a/services/agents-api/tests/official_agent_reference_retry.py b/services/agents-api/tests/official_agent_reference_retry.py index 1e998debd..f99a076bc 100644 --- a/services/agents-api/tests/official_agent_reference_retry.py +++ b/services/agents-api/tests/official_agent_reference_retry.py @@ -33,7 +33,7 @@ def main(): def mutate(**values): response = transport.post(control, json={"id":agent.id, **values}) - assert response.status_code == 204 + assert response.status_code == 202 mutate(patch={"model":"changed-model", "instructions":"changed"}) assert sessions.create(**spec, extra_headers=headers) == first diff --git a/services/agents-api/tests/official_function_images.py b/services/agents-api/tests/official_function_images.py index c07945bf4..116ecbc6a 100644 --- a/services/agents-api/tests/official_function_images.py +++ b/services/agents-api/tests/official_function_images.py @@ -89,7 +89,7 @@ def run(sid, output=None, expected=None, cancel=False, failed_text=False, valida assert post(sid, [{**result, "call_id": "unknown-call"}]).status_code in {400, 404, 409} assert items(sid) == before assert sessions.events.create(sid, events=[result], idempotency_key=key) is None - assert post(sid, [result], key).status_code == 204 + assert post(sid, [result], key).status_code == 202 assert post(sid, [{**result, "output": "different"}], key).status_code == 409 proof["calls"].append({"turn": action.turn_id, "call": action.call_id, "output": output, "success": not failed_text}) assert event.type not in {"agent.session.failed", "agent.session.turn.failed"}, event.to_dict() diff --git a/services/agents-api/tests/official_function_inputs.py b/services/agents-api/tests/official_function_inputs.py index 0effe32ab..33aa9e721 100644 --- a/services/agents-api/tests/official_function_inputs.py +++ b/services/agents-api/tests/official_function_inputs.py @@ -18,11 +18,11 @@ def result(call, **values): return {"type": "agent.session.input.tool_result", "turn_id": turn, "call_id": call, **values} -def submit(api, events, key, expected=204, target=session): +def submit(api, events, key, expected=202, target=session): try: response = api.beta.agents.sessions.events.with_raw_response.create( target, events=events, extra_headers={"Idempotency-Key": key}) - assert response.status_code == expected == 204 + assert response.status_code == expected == 202 assert response.content == b"" except APIStatusError as error: assert error.status_code == expected, (error.status_code, expected, error.message) diff --git a/services/agents-api/tests/official_pending_actions_native.py b/services/agents-api/tests/official_pending_actions_native.py index f5d04b581..8acfef084 100644 --- a/services/agents-api/tests/official_pending_actions_native.py +++ b/services/agents-api/tests/official_pending_actions_native.py @@ -38,10 +38,10 @@ def items(sid): assert raw == [item.to_dict() for item in sessions.items.list(sid, limit=100, order="asc").data] return raw - def submit(sid, event, key, expected=204, auth=None): + def submit(sid, event, key, expected=202, auth=None): response = http.post(endpoint + "/" + sid + "/events", headers={**(auth or headers), "Idempotency-Key": key}, json={"events": [event]}) assert response.status_code == expected, (key, response.status_code, response.text) - if expected == 204: + if expected == 202: assert response.content == b"" else: assert response.json()["error"]["code"] in {"not_found_error", "turn_conflict", "idempotency_conflict"} diff --git a/services/agents-api/tests/official_self_hosted_cancel.py b/services/agents-api/tests/official_self_hosted_cancel.py index 94f57416e..8f7c92a87 100644 --- a/services/agents-api/tests/official_self_hosted_cancel.py +++ b/services/agents-api/tests/official_self_hosted_cancel.py @@ -35,21 +35,21 @@ def client(): sessions = api.beta.agents.sessions endpoint = base + "/v1/agents/sessions/" - def sdk_submit(session_id, key, events=batch, expected=204): + def sdk_submit(session_id, key, events=batch, expected=202): with client() as caller: try: response = caller.beta.agents.sessions.events.with_raw_response.create( session_id, events=events, idempotency_key=key) - assert response.status_code == expected == 204 and response.content == b"" + assert response.status_code == expected == 202 and response.content == b"" assert response.parse() is None except APIStatusError as error: - assert error.status_code == expected and expected != 204 + assert error.status_code == expected and expected != 202 - def raw_submit(session_id, key, events=batch, expected=204, key_token=token): + def raw_submit(session_id, key, events=batch, expected=202, key_token=token): response = raw.post(endpoint + session_id + "/events", json={"events": events}, headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) assert response.status_code == expected, (response.status_code, expected) - if expected == 204: + if expected == 202: assert response.content == b"" and response.headers["cache-control"] == "no-store" def concurrent(session_id, key): diff --git a/services/agents-api/tests/official_session_agent_filter.py b/services/agents-api/tests/official_session_agent_filter.py index 3678e9fdb..0bb3f654c 100644 --- a/services/agents-api/tests/official_session_agent_filter.py +++ b/services/agents-api/tests/official_session_agent_filter.py @@ -53,12 +53,15 @@ def main(): first = http.get(endpoint, headers=headers, params={"agent_id": root.id, "order": "asc", "limit": 2}) assert first.status_code == 200 and first.json()["has_more"] is True assert [s["id"] for s in first.json()["data"]] == [s.id for s in selected[:2]] + assert first.json()["object"] == "list" + assert (first.json()["first_id"], first.json()["last_id"]) == (selected[0].id, selected[1].id) tail = http.get(endpoint, headers=headers, params={"agent_id": root.id, "order": "asc", "limit": 2, "after": selected[1].id}) assert tail.status_code == 200 and tail.json()["has_more"] is False assert [s["id"] for s in tail.json()["data"]] == [s.id for s in selected[2:]] + assert (tail.json()["first_id"], tail.json()["last_id"]) == (selected[2].id, selected[-1].id) for value in ("", "unknown", root.id + " ", "' OR true --"): reply = http.get(endpoint, headers=headers, params={"agent_id": value}) - assert reply.status_code == 200 and reply.json() == {"data": [], "has_more": False} + assert reply.status_code == 200 and reply.json() == {"object": "list", "data": [], "has_more": False, "first_id": None, "last_id": None} for query in ([("agent_id", root.id), ("agent_id", peer.id)], {"agent_id": root.id, "tenant_id": "other"}): assert http.get(endpoint, headers=headers, params=query).status_code == 400 assert http.get(endpoint, headers=headers, params={"agent_id": root.id, "after": foreign_session.id}).status_code == 404 diff --git a/services/agents-api/tests/official_session_creation_stream.py b/services/agents-api/tests/official_session_creation_stream.py index 86a65aab3..74169e835 100644 --- a/services/agents-api/tests/official_session_creation_stream.py +++ b/services/agents-api/tests/official_session_creation_stream.py @@ -52,7 +52,7 @@ def verify_creation_streams(client, raw, base, headers, foreign, unsupported): # A creation retry observes from the upsert cursor, with no old created/Turn/Item replay. with raw.stream("POST", base + "/v1/agents/sessions", headers={**headers, **key, "Last-Event-ID": first.event_id}, json={**request, "stream": True}) as response: - assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + assert response.status_code == 201 and response.headers["content-type"] == "text/event-stream" lines = response.iter_lines() assert next(lines) == ": connected" previous_turns = {turn.id for turn in sessions.turns.list(session.id)} diff --git a/services/agents-api/tests/official_session_creators.py b/services/agents-api/tests/official_session_creators.py index 0e52a9f6d..88c21e6cc 100644 --- a/services/agents-api/tests/official_session_creators.py +++ b/services/agents-api/tests/official_session_creators.py @@ -42,7 +42,7 @@ def check_retries(request, key, current): assert response.json()["error"]["code"] == "idempotency_conflict" assert_no_creator_fields(response.json()["error"]) response = raw.post(endpoint, headers=auth | key, json=request) - assert response.status_code == 200 and response.json() == current.to_dict() + assert response.status_code == 201 and response.json() == current.to_dict() assert_no_creator_fields(response.json()) # Inline requests reach the authoritative creation upsert. Untrusted @@ -102,7 +102,7 @@ def check_retries(request, key, current): key = {"Idempotency-Key": str(uuid.uuid4())} headers = auth | key | forged | {"Authorization": "Bearer " + typed_peer.api_key} with raw.stream("POST", endpoint, headers=headers, json=spec | {"stream": True}) as response: - assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + assert response.status_code == 201 and response.headers["content-type"] == "text/event-stream" created = event_data(response.iter_lines()) assert created["type"] == "agent.session.created" assert_no_creator_fields(created["session"]) diff --git a/services/agents-api/tests/official_session_initial_input.py b/services/agents-api/tests/official_session_initial_input.py index cd247aa67..d2c711610 100644 --- a/services/agents-api/tests/official_session_initial_input.py +++ b/services/agents-api/tests/official_session_initial_input.py @@ -22,6 +22,21 @@ def main(): idle = sessions.create(**spec, extra_headers=idle_key) assert sessions.create(**spec, input=None, extra_headers=idle_key) == idle assert list(sessions.turns.list(idle.id)) == [] + empty_headers = {**headers, "Idempotency-Key": str(uuid.uuid4())} + empty_endpoint = base + "/v1/agents/sessions/" + idle.id + "/events" + for _ in range(2): + response = raw.post(empty_endpoint, headers=empty_headers, json={"events": []}) + assert response.status_code == 202 and response.content == b"" + assert sessions.retrieve(idle.id) == idle + assert list(sessions.turns.list(idle.id)) == [] and list(sessions.items.list(idle.id)) == [] + foreign_empty = raw.post(empty_endpoint, headers={**empty_headers, "Authorization": "Bearer " + foreign}, json={"events": []}) + assert foreign_empty.status_code == 404 + # Empty requests do not consume a nonempty batch's retry identity. + response = raw.post(empty_endpoint, headers=empty_headers, json={"events": [{"type": "agent.session.input.cancel"}]}) + assert response.status_code == 202 and response.content == b"" + response = raw.post(empty_endpoint, headers=empty_headers, json={"events": []}) + assert response.status_code == 202 and response.content == b"" + assert sessions.retrieve(idle.id) == idle forms = ["First", [{"role": "user", "content": [{"type": "input_text", "text": "First"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Second"}]}]] for i, initial in enumerate(forms): @@ -36,7 +51,7 @@ def main(): assert [item.content[0].text for item in items] == (["First"] if i == 0 else ["First", "Second"]) reply = raw.post(base + "/v1/agents/sessions", headers={**headers, **key}, json={**configuration, "input": initial}) - assert reply.status_code == 200 and reply.json()["id"] == session.id + assert reply.status_code == 201 and reply.json()["id"] == session.id assert [turn.id for turn in sessions.turns.list(session.id)] == [turns[0].id] denied = raw.get(base + "/v1/agents/sessions/" + session.id, headers={**headers, "Authorization": "Bearer " + foreign}) @@ -51,7 +66,7 @@ def main(): verify_creation_streams(client, raw, base, headers, foreign, unsupported) before = {session.id for session in sessions.list()} - for fields in [{"input": 0}, {"input": {}}, {"input": []}, {"input": " "}, + for fields in [{"input": [{"type": None, "role": "user", "content": [{"type": "input_text", "text": "x"}]}]}, {"input": 0}, {"input": {}}, {"input": []}, {"input": " "}, {"input": [{"role": "assistant", "content": [{"type": "input_text", "text": "x"}]}]}, {"input": [{"role": "user", "content": [{"type": "input_image", "image_url": "https://example.com/x.png"}]}]}]: reply = raw.post(base + "/v1/agents/sessions", headers=headers, json={**spec, **fields}) diff --git a/services/agents-api/tests/official_session_requests.py b/services/agents-api/tests/official_session_requests.py index b9c9c4aad..49dba8721 100644 --- a/services/agents-api/tests/official_session_requests.py +++ b/services/agents-api/tests/official_session_requests.py @@ -41,7 +41,7 @@ def verify_session_create_requests(client, spec): assert sessions.create(**spec, extra_body=fields, extra_headers=key) == first response = raw.post(str(client.base_url).rstrip("/") + "/agents/sessions", headers={**headers, **key}, json={**spec, **fields}) - assert response.status_code == 200 + assert response.status_code == 201 assert response.json()["id"] == first.id and response.json()["metadata"] == {} metadata = {"empty": "", "label": "中文🧪"} preserved = sessions.create(**spec, metadata=metadata) diff --git a/services/agents-api/tests/official_workspace_images_native.py b/services/agents-api/tests/official_workspace_images_native.py index 6744b60d6..a1afbe7e9 100644 --- a/services/agents-api/tests/official_workspace_images_native.py +++ b/services/agents-api/tests/official_workspace_images_native.py @@ -143,7 +143,7 @@ def submit(action, output, validate=False): assert post([{**result, "call_id": "unknown-call"}]).status_code in {400, 404, 409} assert items() == before sessions.events.create(sid, events=[result], idempotency_key=key) - assert post([result], key).status_code == 204 + assert post([result], key).status_code == 202 assert post([{**result, "output": "conflict"}], key).status_code == 409 proof["calls"].append(result) @@ -184,7 +184,7 @@ def active(action): batch = [event(incoming)] sessions.events.create(sid, events=batch, idempotency_key="active-image") expected_messages.extend(incoming) - assert post(batch, "active-image").status_code == 204 + assert post(batch, "active-image").status_code == 202 assert post([event(messages([text("conflict")]))], "active-image").status_code == 409 submit(action, "The user supplied an image. Follow its instructions, then stop.") turn = run(messages([text("Call get_visual once and wait. Then follow the incoming image instructions.")]), active) From a3d02ea22f678bf324652cd49aa61d0e45003bf4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:24:17 +0800 Subject: [PATCH 06/15] Update hosted creation retry and harness onboarding assertions --- .../internal/store/harness_onboarding_test.go | 10 +++++----- .../internal/store/self_hosted_cancel_public_test.go | 2 +- .../agents-api/tests/official_self_hosted_initial.py | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/services/agents-api/internal/store/harness_onboarding_test.go b/services/agents-api/internal/store/harness_onboarding_test.go index 340a82043..f817d728d 100644 --- a/services/agents-api/internal/store/harness_onboarding_test.go +++ b/services/agents-api/internal/store/harness_onboarding_test.go @@ -83,7 +83,7 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { for _, fields := range []string{`,"text":{"verbosity":"high"}`, `,"tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]`} { request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"`+fields+`},"environment":{"type":"none"}}`, 400) } - res := request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"none"},"input":"hold"}`, 200) + res := request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"none"},"input":"hold"}`, 201) var created struct { ID string `json:"id"` } @@ -98,7 +98,7 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { if first.AgentKind != "fixture_harness" || first.AgentSessionID != "" { t.Fatal(first) } - request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"finish"}]}]}]}`, 204) + request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"finish"}]}]}]}`, 202) waitTurn(t, h, first.RunID, store.TurnCompleted) turn, err := h.s.GetTurn(ctx, h.tenant, created.ID, first.RunID) if err != nil { @@ -116,12 +116,12 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { if err != nil || bound.NativeSessionID == "" { t.Fatal(bound, err) } - request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"hold"}]}]}]}`, 204) + request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"hold"}]}]}]}`, 202) next := awaitOnboardingPrompt(t, started) if next.RunID == first.RunID || next.AgentSessionID != bound.NativeSessionID || !next.StrictResume { t.Fatal(next) } - request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.cancel"}]}`, 204) + request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.cancel"}]}`, 202) waitTurn(t, h, next.RunID, store.TurnCancelled) // A missing mandatory receipt capability must prevent claiming queued work. peer, _ := h.registry.LookupDevice(h.device.ID) @@ -147,7 +147,7 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { } time.Sleep(10 * time.Millisecond) } - res = request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"none"},"input":"hold"}`, 200) + res = request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"none"},"input":"hold"}`, 201) if err = json.Unmarshal(res.Body.Bytes(), &created); err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/self_hosted_cancel_public_test.go b/services/agents-api/internal/store/self_hosted_cancel_public_test.go index 6a659690e..17c5ab728 100644 --- a/services/agents-api/internal/store/self_hosted_cancel_public_test.go +++ b/services/agents-api/internal/store/self_hosted_cancel_public_test.go @@ -160,7 +160,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { activeReceipts := receipts(created.ActiveKey, first) turn, err := s.GetTurn(t.Context(), tenant, created.ID, first) if err != nil || turn.Status != store.TurnInProgress || turn.CancelRequestedAt.IsZero() || !turn.CompletedAt.IsZero() { - t.Fatal("204 must admit cancellation without fabricating native completion", err) + t.Fatal("202 must admit cancellation without fabricating native completion", err) } itemsAfter, err := s.ListItems(t.Context(), tenant, created.ID, "", 100, true) if err != nil || !reflect.DeepEqual(itemsBefore, itemsAfter) { diff --git a/services/agents-api/tests/official_self_hosted_initial.py b/services/agents-api/tests/official_self_hosted_initial.py index 9760ca502..fe3630abb 100644 --- a/services/agents-api/tests/official_self_hosted_initial.py +++ b/services/agents-api/tests/official_self_hosted_initial.py @@ -58,7 +58,7 @@ def retry(case, status="requires_action"): value = current(case, status) assert sessions.create(**case["request"], extra_headers={"Idempotency-Key": case["key"]}).to_dict() == value response = post(case["request"], case["key"]) - assert response.status_code == 200 and response.json() == value + assert response.status_code == 201 and response.json() == value return value phase = settings.get("phase", "create") @@ -80,7 +80,7 @@ def create_once(index): with client() as creator: return creator.beta.agents.sessions.create(**request, extra_headers={"Idempotency-Key": key}).to_dict() reply = post(request, key) - assert reply.status_code == 200 + assert reply.status_code == 201 return reply.json() with ThreadPoolExecutor(max_workers=4) as workers: @@ -100,7 +100,7 @@ def create_once(index): assert created["session"]["created_at"] == created["session"]["last_active_at"] elif mode == "raw_disconnect": with raw.stream("POST", endpoint, json={**request, "stream": True}, headers={"Idempotency-Key": key}) as response: - assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + assert response.status_code == 201 and response.headers["content-type"] == "text/event-stream" created = next(json.loads(line[6:]) for line in response.iter_lines() if line.startswith("data: ")) assert created["type"] == "agent.session.created" check(created["session"], "idle") @@ -124,7 +124,7 @@ def create_once(index): for suffix in ("", "/events", "/turns", "/items"): assert raw.get(endpoint + "/" + case["id"] + suffix, headers={"Authorization": "Bearer " + settings["foreign_token"]}).status_code == 404 foreign = post(cases[0]["request"], cases[0]["key"], key_token=settings["foreign_token"]) - assert foreign.status_code == 200 and foreign.json()["id"] != cases[0]["id"] + assert foreign.status_code == 201 and foreign.json()["id"] != cases[0]["id"] assert raw.get(endpoint + "/" + foreign.json()["id"]).status_code == 404 result = {"cases": cases} else: @@ -184,7 +184,7 @@ def observe(name): response = post({**cases[0]["request"], "stream": streaming}, str(uuid.uuid4()), url=target) assert response.status_code == 503 and response.json()["error"]["code"] == "execution_unavailable" response = post(cases[1]["request"], cases[1]["key"], url=target) - assert response.status_code == 200 and response.json() == cases[1]["snapshot"] + assert response.status_code == 201 and response.json() == cases[1]["snapshot"] assert {session.id for session in sessions.list()} == before else: raise AssertionError("unknown test phase") From 0ae34d0d18f29e25dec4cbb4f720f4fca89c5cd0 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:23:41 +0800 Subject: [PATCH 07/15] Match client and web fixtures to official creation and event statuses --- apps/web/e2e/fixture-core.mjs | 22 ++++---- packages/agents-client/src/client.test.ts | 54 +++++++++++-------- packages/agents-client/src/client.ts | 17 +++--- .../src/environment-templates.test.ts | 4 +- .../src/vault-credentials-oauth.test.ts | 6 +-- 5 files changed, 58 insertions(+), 45 deletions(-) diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index b950ef6b6..218be1efa 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -399,7 +399,7 @@ function initialState() { updateStatus: 200, deleteDelayMs: 0, deleteStatus: 200, - sendStatus: 204, + sendStatus: 202, sendResponseLoss: 0, itemsScenario: 0, turnsScenario: 0, @@ -894,7 +894,7 @@ const server = http.createServer(async (request, response) => { metadata: body.metadata ?? {}, }; state.vaults.unshift(vault); - return sendJson(response, vault); + return sendJson(response, vault, 201); } } @@ -924,7 +924,7 @@ const server = http.createServer(async (request, response) => { }; state.credentials.unshift(credential); state.credentialTokens.add(credential.id); - return sendJson(response, credential); + return sendJson(response, credential, 201); } } @@ -1023,7 +1023,7 @@ const server = http.createServer(async (request, response) => { return sendError(response, 409, "Fixture idempotency key was reused with a different Session request."); } if (body.stream === true) { - response.writeHead(200, { + response.writeHead(201, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", @@ -1032,7 +1032,7 @@ const server = http.createServer(async (request, response) => { setTimeout(() => response.end(), state.controls.sessionCreateStreamCloseDelayMs); return; } - return sendJson(response, receipt.session, 200); + return sendJson(response, receipt.session, 201); } const control = consumeControl("sessionCreate", 201); @@ -1097,7 +1097,7 @@ const server = http.createServer(async (request, response) => { } if (body.stream === true) { const createdSnapshot = structuredClone(created); - response.writeHead(200, { + response.writeHead(201, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", @@ -1358,7 +1358,7 @@ const server = http.createServer(async (request, response) => { updated_at: created, }; state.environmentTemplates.push(template); - return sendJson(response, template); + return sendJson(response, template, 201); } return sendError(response, 405, "This API method is not supported.", "unsupported_operation"); } @@ -1557,6 +1557,8 @@ const server = http.createServer(async (request, response) => { object: "list", data, has_more: start + data.length < sessionTurns.length, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, }); } @@ -1564,14 +1566,14 @@ const server = http.createServer(async (request, response) => { if (request.method === "POST" && eventsMatch) { const status = state.controls.sendStatus; const responseLoss = state.controls.sendResponseLoss; - state.controls.sendStatus = 204; + state.controls.sendStatus = 202; state.controls.sendResponseLoss = 0; if (responseLoss) { response.destroy(); return; } - if (status !== 204) return sendError(response, status, "Fixture send failed."); - response.writeHead(204); + if (status !== 202) return sendError(response, status, "Fixture send failed."); + response.writeHead(202); response.end(); return; } diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index 13c0a237d..927bb3b33 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -30,14 +30,14 @@ function recordingFetch(response: Response, calls: FetchCall[]): typeof fetch { }) as typeof fetch; } -function streamResponse(chunks: string[]): Response { +function streamResponse(chunks: string[], status = 200): Response { const encoder = new TextEncoder(); return new Response(new ReadableStream({ start(controller) { chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk))); controller.close(); }, - }), { headers: { "Content-Type": "text/event-stream" } }); + }), { status, headers: { "Content-Type": "text/event-stream" } }); } function ephemeralBearer(): string { @@ -281,7 +281,7 @@ describe("OpenAIAgentsClient", () => { fetch: recordingFetch(streamResponse([ ": connected\n\nevent: agent.session.cre", `ated\ndata: ${created}\n\nevent: agent.session.future_event\ndata: ${later}\n\ndata: [DONE]\n\n`, - ]), calls), + ], 201), calls), }); await client.createSessionStream( @@ -1675,7 +1675,7 @@ describe("OpenAIAgentsClient", () => { it("submits typed function-result parts with an explicit idempotency key", async () => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); await client.submitFunctionResult( "session", @@ -1713,7 +1713,7 @@ describe("OpenAIAgentsClient", () => { const calls: FetchCall[] = []; const client = new OpenAIAgentsClient({ baseUrl: "https://core.example.test/v1/", - fetch: recordingFetch(new Response(null, { status: 204 }), calls), + fetch: recordingFetch(new Response(null, { status: 202 }), calls), }); const events = eventBatchDadf64.request.events as SessionInputEvent[]; @@ -1728,7 +1728,7 @@ describe("OpenAIAgentsClient", () => { it("preserves event order when the same retry key is deliberately reused", async () => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); const message: SessionInputEvent = { type: "agent.session.input.message", input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], @@ -1750,7 +1750,7 @@ describe("OpenAIAgentsClient", () => { it("preserves Core-permitted empty Function values and ordered rich output parts", async () => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); const events: SessionInputEvent[] = [ { type: "agent.session.input.tool_result", @@ -1788,7 +1788,6 @@ describe("OpenAIAgentsClient", () => { it.each([ { label: "non-array", events: {} }, - { label: "empty", events: [] }, { label: "sparse", events: Array(1) }, { label: "65 events", events: Array.from({ length: 65 }, () => ({ type: "agent.session.input.cancel" })) }, { label: "unknown variant", events: [{ type: "agent.session.input.future" }] }, @@ -1842,7 +1841,7 @@ describe("OpenAIAgentsClient", () => { }] }, ])("rejects malformed Session event batch locally: $label", ({ events }) => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); expect(() => client.submitEvents("session", events as unknown as SessionInputEvent[], "invalid")) .toThrow(TypeError); @@ -1851,7 +1850,7 @@ describe("OpenAIAgentsClient", () => { it("enforces only Core's 1 MiB HTTP wire limit and leaves canonical internal sizing to Core", async () => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); const makeEvent = (text: string): SessionInputEvent => ({ type: "agent.session.input.message", input: [{ role: "user", content: [{ type: "input_text", text }] }], @@ -1878,7 +1877,7 @@ describe("OpenAIAgentsClient", () => { it("does not invent JavaScript-only whitespace restrictions for message text", async () => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); const event: SessionInputEvent = { type: "agent.session.input.message", input: [{ role: "user", content: [ @@ -1892,12 +1891,21 @@ describe("OpenAIAgentsClient", () => { expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ events: [event] }); }); - it("does not retry a failed public batch submission or reinterpret non-204 success", async () => { + it("sends an empty event batch once and accepts the official empty 202 response", async () => { + const calls: FetchCall[] = []; + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); + await expect(client.submitEvents("session", [], "empty-key")).resolves.toBeUndefined(); + expect(calls).toHaveLength(1); + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ events: [] }); + expect(new Headers(calls[0]?.init?.headers).get("Idempotency-Key")).toBe("empty-key"); + }); + + it("does not retry a failed public batch submission or reinterpret non-202 success", async () => { const event: SessionInputEvent = { type: "agent.session.input.cancel" }; - for (const status of [200, 202, 409, 500]) { + for (const status of [200, 204, 409, 500]) { const calls: FetchCall[] = []; const client = new OpenAIAgentsClient({ - fetch: recordingFetch(jsonResponse({ error: { message: "rejected" } }, status), calls), + fetch: recordingFetch(status === 204 ? new Response(null, { status }) : jsonResponse({ error: { message: "rejected" } }, status), calls), }); await expect(client.submitEvents("session", [event], "one-attempt")).rejects.toMatchObject({ status }); @@ -1933,7 +1941,7 @@ describe("OpenAIAgentsClient", () => { "🙂".repeat(33), ])("rejects invalid event-write idempotency key %j before fetch", (key) => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); expect(() => client.submitEvents( "session", @@ -1945,7 +1953,7 @@ describe("OpenAIAgentsClient", () => { it("keeps the three legacy single-event helpers on the public batch wire", async () => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 202 }), calls) }); await client.sendMessage("session", "hello", "message-key"); await client.cancelTurn("session", "cancel-key"); @@ -1983,19 +1991,19 @@ describe("OpenAIAgentsClient", () => { success: false, error: "safe failure", }, "event-key")], - ])("requires HTTP 204 for %s event submission without retrying", async (_label, submit) => { + ])("requires HTTP 202 for %s event submission without retrying", async (_label, submit) => { const successCalls: FetchCall[] = []; const successClient = new OpenAIAgentsClient({ - fetch: recordingFetch(new Response(null, { status: 204 }), successCalls), + fetch: recordingFetch(new Response(null, { status: 202 }), successCalls), }); await expect(submit(successClient)).resolves.toBeUndefined(); expect(successCalls).toHaveLength(1); - for (const status of [200, 202]) { + for (const status of [200, 204]) { const calls: FetchCall[] = []; const client = new OpenAIAgentsClient({ - fetch: recordingFetch(jsonResponse({ accepted: true }, status), calls), + fetch: recordingFetch(status === 204 ? new Response(null, { status }) : jsonResponse({ accepted: true }, status), calls), }); await expect(submit(client)).rejects.toMatchObject({ @@ -2035,12 +2043,12 @@ describe("OpenAIAgentsClient", () => { if (path.endsWith(`/credentials/${credentialId}`)) return jsonResponse(credential); if (path.endsWith("/credentials")) { return init?.method === "POST" - ? jsonResponse(credential) + ? jsonResponse(credential, 201) : jsonResponse({ object: "list", data: [credential], has_more: false, first_id: credentialId, last_id: credentialId }); } if (path.endsWith(`/vaults/${vaultId}`)) return jsonResponse(vault); return init?.method === "POST" - ? jsonResponse(vault) + ? jsonResponse(vault, 201) : jsonResponse({ object: "list", data: [vault], has_more: false, first_id: vaultId, last_id: vaultId }); }) as typeof fetch, }); @@ -2187,7 +2195,7 @@ describe("OpenAIAgentsClient", () => { updated_at: 2, ...change, }; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse(credential), calls) }); + const client = new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse(credential, 201), calls) }); await expect(client.createVaultCredential(vaultId, { name: "Internal MCP", diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index c491cf554..553dd5a16 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -313,8 +313,8 @@ function canonicalSessionInputEvent(value: unknown): SessionInputEvent { } function encodeSessionInputBatch(events: readonly SessionInputEvent[]): string { - if (!Array.isArray(events) || events.length === 0 || events.length > maxSessionInputEvents) { - return invalidSessionInputBatch("Session input event batch must contain 1 through 64 events."); + if (!Array.isArray(events) || events.length > maxSessionInputEvents) { + return invalidSessionInputBatch("Session input event batch must contain at most 64 events."); } const body = JSON.stringify({ events: Array.from(events, canonicalSessionInputEvent) }); // Core's HTTP handler bounds the complete wire request at 1 MiB. Its separate @@ -1586,7 +1586,7 @@ export class OpenAIAgentsClient implements AgentCore { if (!response.ok || (expectedStatus !== undefined && response.status !== expectedStatus)) { throw await this.toError(response); } - if (response.status === 204) return undefined as T; + if (response.status === 204 || expectedStatus === 202) return undefined as T; return (await response.json()) as T; } @@ -1594,6 +1594,7 @@ export class OpenAIAgentsClient implements AgentCore { path: string, body: string, safeMessage: string, + expectedStatus: 200 | 201, ): Promise { const headers = this.headers({ "Content-Type": "application/json" }); const response = await this.fetchImpl(`${this.baseUrl}${path}`, { @@ -1601,7 +1602,7 @@ export class OpenAIAgentsClient implements AgentCore { headers, body, }); - if (!response.ok || response.status !== 200) { + if (!response.ok || response.status !== expectedStatus) { // A rejected secret-bearing write may reflect attacker-controlled token // bytes in every upstream error field. Never parse or expose that body. throw new AgentCoreError(safeMessage, response.status, "credential_write_failed"); @@ -1650,7 +1651,7 @@ export class OpenAIAgentsClient implements AgentCore { ) { throw new TypeError("Vault creation accepts a trimmed name and string metadata only."); } - const value = await this.request("/vaults", { method: "POST", body: JSON.stringify(input) }, 200); + const value = await this.request("/vaults", { method: "POST", body: JSON.stringify(input) }, 201); const vault = projectVault(value); const expectedName = input.name ?? null; const expectedMetadata = input.metadata ?? {}; @@ -1696,6 +1697,7 @@ export class OpenAIAgentsClient implements AgentCore { `/vaults/${encodeURIComponent(vaultId)}/credentials`, JSON.stringify(input), "Agent Core Credential creation failed.", + 201, ); const credential = projectVaultCredential(value, vaultId); if (credential.auth.type !== "static_bearer" || credential.name !== input.name || credential.auth.mcp_server_url !== input.auth.mcp_server_url) { @@ -1739,6 +1741,7 @@ export class OpenAIAgentsClient implements AgentCore { `/vaults/${encodeURIComponent(vaultId)}/credentials/${encodeURIComponent(credentialId)}`, JSON.stringify(input), "Agent Core Credential token replacement failed.", + 200, ); const credential = projectVaultCredential(value, vaultId, credentialId); if ( @@ -1885,7 +1888,7 @@ export class OpenAIAgentsClient implements AgentCore { const value = await this.request( "/agents/environments/templates", { method: "POST", body, signal: options?.signal }, - 200, + 201, ); const template = projectEnvironmentTemplate(value); const expectedName = input.name === undefined ? null : input.name; @@ -2144,7 +2147,7 @@ export class OpenAIAgentsClient implements AgentCore { headers: { "Idempotency-Key": idempotencyKey }, body, }, - 204, + 202, ); } diff --git a/packages/agents-client/src/environment-templates.test.ts b/packages/agents-client/src/environment-templates.test.ts index 55d7541f1..e16761dc4 100644 --- a/packages/agents-client/src/environment-templates.test.ts +++ b/packages/agents-client/src/environment-templates.test.ts @@ -84,7 +84,7 @@ describe("Environment Template resource", () => { }); it("creates a Template with the supported fields only", async () => { - const { client, calls } = recordingClient(jsonResponse(template())); + const { client, calls } = recordingClient(jsonResponse(template(), 201)); const created = await client.createEnvironmentTemplate({ name: "Restricted outbound access", @@ -111,7 +111,7 @@ describe("Environment Template resource", () => { }); it("rejects a created Template whose configuration differs from the request", async () => { - const { client } = recordingClient(jsonResponse(template({ network: { access: "enabled", allowed_domains: [] } }))); + const { client } = recordingClient(jsonResponse(template({ network: { access: "enabled", allowed_domains: [] } }), 201)); await expect(client.createEnvironmentTemplate({ network: { access: "disabled" } })) .rejects.toBeInstanceOf(AgentCoreError); diff --git a/packages/agents-client/src/vault-credentials-oauth.test.ts b/packages/agents-client/src/vault-credentials-oauth.test.ts index 5a01cb8e0..d40b2b799 100644 --- a/packages/agents-client/src/vault-credentials-oauth.test.ts +++ b/packages/agents-client/src/vault-credentials-oauth.test.ts @@ -27,8 +27,8 @@ const oauthAuth: McpOAuthCredentialAuth = { }, }; const oauthCredential: VaultCredential = { ...staticCredential, auth: oauthAuth }; -const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { - status: 200, +const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, headers: { "content-type": "application/json" }, }); @@ -92,7 +92,7 @@ describe("OAuth Credential metadata", () => { }); it("rejects an OAuth response to static creation", async () => { - const client = new OpenAIAgentsClient({ fetch: (async () => jsonResponse(oauthCredential)) as typeof fetch }); + const client = new OpenAIAgentsClient({ fetch: (async () => jsonResponse(oauthCredential, 201)) as typeof fetch }); await expect(client.createVaultCredential(vaultId, { name: staticCredential.name, auth: { type: "static_bearer", mcp_server_url: staticCredential.auth.mcp_server_url, token: "creation" }, From 0079011aa34604c48c838525c9e0dabc6f442426 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:26:00 +0800 Subject: [PATCH 08/15] Regenerate public schema and finish wire regression fixtures --- .../official-semantics-alignment.md | 1 + contracts/agents-api/openapi.yaml | 149 +++++++++++------- .../tests/official_agent_reference_retry.py | 2 +- .../tests/official_agent_references.py | 2 +- .../tests/official_environment_activity.py | 2 +- .../tests/official_mcp_credentials.py | 2 +- 6 files changed, 101 insertions(+), 57 deletions(-) diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index dbe2c1ec9..a13df5a6e 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -18,6 +18,7 @@ replacement baseline. A successful SDK parse alone is not conformance evidence. | Empty Agent/Template update | Advance `updated_at` through the existing atomic update, preserving IDs, content, ownership and frozen Session snapshots. Timestamp precision is seconds; immediate updates may have the same serialized timestamp. | | Static bearer create/replacement | Reject an explicitly empty token before mutation. Preserve valid opaque token bytes without trimming. | | OAuth grant create/replacement | Reject an explicitly empty access token and a replacement without mutable grant fields. Preserve previously qualified refresh/expiry/null handling. | +| Input message discriminator | Omission remains valid; a supplied `type` must be `message`. Explicit null or empty strings reject through the shared initial/event decoder, matching the pinned literal type. | | Missing beta resource | HTTP 404 with `type` and `code` equal to `not_found_error`. Missing and foreign resources remain indistinguishable. | | Missing required Beta header | HTTP 400 with `type` and `code` equal to `invalid_beta`, after authentication. | | Missing non-beta File or Skill | HTTP 404 with `type: invalid_request_error`, `code: null`. Exact message, File `param` and additional detail payload remain outside this batch. | diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index c3a8fcfef..44ce472ad 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -4,6 +4,7 @@ definitions: properties: code: type: string + x-nullable: true message: type: string param: @@ -12,7 +13,6 @@ definitions: type: type: string required: - - code - message - type type: object @@ -254,6 +254,7 @@ definitions: v1.CredentialAuthInput: properties: access_token: + minLength: 1 type: string expires_at: type: string @@ -265,6 +266,7 @@ definitions: - $ref: '#/definitions/v1.OAuthCredentialRefreshInput' x-nullable: true token: + minLength: 1 type: string type: enum: @@ -278,6 +280,7 @@ definitions: v1.CredentialAuthReplacement: properties: access_token: + minLength: 1 type: string x-nullable: true expires_at: @@ -288,6 +291,7 @@ definitions: - $ref: '#/definitions/v1.OAuthCredentialRefreshReplacement' x-nullable: true token: + minLength: 1 type: string type: enum: @@ -869,11 +873,22 @@ definitions: items: $ref: '#/definitions/v1.Item' type: array + first_id: + type: string + x-nullable: true has_more: type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string required: - data - has_more + - object type: object v1.ModelProviderInput: properties: @@ -1409,11 +1424,22 @@ definitions: items: $ref: '#/definitions/v1.Session' type: array + first_id: + type: string + x-nullable: true has_more: type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string required: - data - has_more + - object type: object v1.Skill: properties: @@ -1828,11 +1854,22 @@ definitions: items: $ref: '#/definitions/v1.Turn' type: array + first_id: + type: string + x-nullable: true has_more: type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string required: - data - has_more + - object type: object v1.UpdateAgentRequest: properties: @@ -2075,8 +2112,8 @@ paths: produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: $ref: '#/definitions/v1.SavedAgent' "400": @@ -2196,9 +2233,10 @@ paths: - application/json description: Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty - metadata clears all pairs. Existing Session snapshots are unchanged. Nested - replacement/null defaults, model-derived reasoning and exact hosted error/no-op - timestamp behavior remain incompletely verified. + metadata clears all pairs. Existing Session snapshots are unchanged. Empty + updates advance updated_at without changing saved fields. Nested replacement/null + defaults, model-derived reasoning and exact hosted error behavior remain incompletely + verified. parameters: - description: agents=v1 in: header @@ -2530,8 +2568,8 @@ paths: produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: $ref: '#/definitions/v1.EnvironmentTemplate' "400": @@ -2652,8 +2690,8 @@ paths: capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted after setup. Environment MCP execution requires a qualified native transport - and runtime network policy. Exact hosted no-op timestamp behavior remains - unverified. + and runtime network policy. Empty updates advance updated_at without changing + saved fields or confidential contents. parameters: - description: agents=v1 in: header @@ -2871,8 +2909,8 @@ paths: - application/json - text/event-stream responses: - "200": - description: OK + "201": + description: Created schema: $ref: '#/definitions/v1.Session' "400": @@ -3340,21 +3378,22 @@ paths: post: consumes: - application/json - description: For environment none, atomically accepts text messages, cancellation - and function results. Messages steer active work or start a queued Turn. The - supported self_hosted profile accepts text-only messages; qualified Codex - and Claude SDK openai_hosted profiles also accept inline PNG/JPEG. Under the - Session lock, matching retries retain their original target; new active messages - append to the current Turn, while idle messages reserve work and wait up to - the original five-minute connection/admission deadline. Return 204 only after - durable admission, without claiming native application; active messages create - no Turn or reservation. Cancellation-only prepared-environment batches use - existing durable cancellation admission and return 204 without waiting for - native exit; a new cancellation conflicts while a pre-Turn reservation is - pending. Homogeneous tool_result-only prepared-environment batches reuse existing - scoped result admission and application receipts without creating a Turn or - bypassing a pending reservation. Mixed prepared-environment batches remain - unsupported. HTTP expiry/cancellation use local 409 environment_input_expired/environment_input_cancelled + description: An empty events array is a resource-authorized no-op; it creates + no execution retry identity, Turn, Item or input receipt. For environment + none, atomically accepts text messages, cancellation and function results. + Messages steer active work or start a queued Turn. The supported self_hosted + profile accepts text-only messages; qualified Codex and Claude SDK openai_hosted + profiles also accept inline PNG/JPEG. Under the Session lock, matching retries + retain their original target; new active messages append to the current Turn, + while idle messages reserve work and wait up to the original five-minute connection/admission + deadline. Return 202 only after durable admission, without claiming native + application; active messages create no Turn or reservation. Cancellation-only + prepared-environment batches use existing durable cancellation admission and + return 202 without waiting for native exit; a new cancellation conflicts while + a pre-Turn reservation is pending. Homogeneous tool_result-only prepared-environment + batches reuse existing scoped result admission and application receipts without + creating a Turn or bypassing a pending reservation. Mixed prepared-environment + batches remain unsupported. HTTP expiry/cancellation use local 409 environment_input_expired/environment_input_cancelled errors; exact hosted failure mapping is unverified. Losing execution ownership returns 503. The response write deadline accommodates the admission window for either prepared Environment, independently of new-hosted-admission and @@ -3391,8 +3430,8 @@ paths: schema: $ref: '#/definitions/v1.CreateEventsRequest' responses: - "204": - description: No Content + "202": + description: Accepted "400": description: Bad Request schema: @@ -4633,8 +4672,8 @@ paths: produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: $ref: '#/definitions/v1.Vault' "400": @@ -4840,15 +4879,16 @@ paths: consumes: - application/json description: Stores static_bearer or mcp_oauth secrets as execution-owned authenticated - ciphertext without contacting any endpoint. OAuth accepts a required access - token, nullable RFC3339 expiry and optional refresh configuration with none, - client_secret_basic or client_secret_post authentication. Required name is - trimmed to 1–256 UTF-8 bytes. Credential and token endpoints require HTTPS - without userinfo or fragments. Responses contain safe metadata only, including - explicit nullable OAuth expiry, refresh, resource and scope. Missing encryption - configuration returns local 503. External authorization and provider revocation - remain caller responsibilities; exact hosted error/default semantics remain - unverified. + ciphertext without contacting any endpoint. Static bearer and OAuth access + tokens must be nonempty strings; their bytes are preserved. OAuth accepts + a required access token, nullable RFC3339 expiry and optional refresh configuration + with none, client_secret_basic or client_secret_post authentication. Required + name is trimmed to 1–256 UTF-8 bytes. Credential and token endpoints require + HTTPS without userinfo or fragments. Responses contain safe metadata only, + including explicit nullable OAuth expiry, refresh, resource and scope. Missing + encryption configuration returns local 503. External authorization and provider + revocation remain caller responsibilities; exact hosted error/default semantics + remain unverified. parameters: - description: agents=v1 in: header @@ -4869,8 +4909,8 @@ paths: produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: $ref: '#/definitions/v1.Credential' "400": @@ -5013,17 +5053,20 @@ paths: post: consumes: - application/json - description: Updates the existing static_bearer or mcp_oauth authentication - method without network requests. OAuth access_token omission/null retains - the token; a new token clears omitted expiry, explicit null clears expiry, - and other omitted fields remain unchanged. OAuth refresh patches cannot add - configuration or change client, endpoint, resource or authentication method; - nullable token/client-secret values retain stored secrets while explicit null - scope clears scope. Whole-null refresh and token_endpoint_auth retain existing - configuration under local policy. Identity, destination, creation time and - Session bindings remain unchanged. Responses expose safe metadata only. Already-dispatched - work is not revoked; provider revocation, storage-key rotation and exact hosted - concurrent-update/error semantics remain separate. + description: Explicitly empty static bearer or OAuth access tokens and OAuth + patches without a mutable field are rejected before storage. Omitted OAuth + access tokens preserve the existing grant when expiry or refresh fields change. + Updates the existing static_bearer or mcp_oauth authentication method without + network requests. OAuth access_token omission/null retains the token; a new + token clears omitted expiry, explicit null clears expiry, and other omitted + fields remain unchanged. OAuth refresh patches cannot add configuration or + change client, endpoint, resource or authentication method; nullable token/client-secret + values retain stored secrets while explicit null scope clears scope. Whole-null + refresh and token_endpoint_auth retain existing configuration under local + policy. Identity, destination, creation time and Session bindings remain unchanged. + Responses expose safe metadata only. Already-dispatched work is not revoked; + provider revocation, storage-key rotation and exact hosted concurrent-update/error + semantics remain separate. parameters: - description: agents=v1 in: header diff --git a/services/agents-api/tests/official_agent_reference_retry.py b/services/agents-api/tests/official_agent_reference_retry.py index f99a076bc..44d512b4c 100644 --- a/services/agents-api/tests/official_agent_reference_retry.py +++ b/services/agents-api/tests/official_agent_reference_retry.py @@ -66,7 +66,7 @@ def mutate(**values): equivalent = spec | {"input":[{"role":"user","content":[{"type":"input_text","text":"initial"}]}], "metadata":{}, "stream":False} assert transport.post(endpoint, headers=auth, json=equivalent).json()["id"] == first.id with transport.stream("POST", endpoint, headers=auth, json=spec | {"stream":True}) as stream: - assert stream.status_code == 200 + assert stream.status_code == 201 sessions.events.create(first.id, events=[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"future-after-retry"}]}]}]) found = False deadline = time.monotonic() + 15 diff --git a/services/agents-api/tests/official_agent_references.py b/services/agents-api/tests/official_agent_references.py index a8f630e26..1d89d2409 100644 --- a/services/agents-api/tests/official_agent_references.py +++ b/services/agents-api/tests/official_agent_references.py @@ -79,7 +79,7 @@ def verify_agent_references(client, other, expect_error): auth = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} with httpx2.Client(trust_env=False, timeout=10) as raw: body = raw.post(base, headers=auth, json=spec) - assert body.status_code == 200 and body.json()["agent"] == expected + assert body.status_code == 201 and body.json()["agent"] == expected recovered.append(sessions.retrieve(body.json()["id"])) before = {item.id for item in sessions.list()} for override in (None, [], {"model": None}, {"model": 1}, {"model": ""}, diff --git a/services/agents-api/tests/official_environment_activity.py b/services/agents-api/tests/official_environment_activity.py index 9ba3070f5..88d5a399d 100644 --- a/services/agents-api/tests/official_environment_activity.py +++ b/services/agents-api/tests/official_environment_activity.py @@ -129,7 +129,7 @@ def snapshot(name, status): value = response.json() check_session(value, status) page = raw.get("/agents/sessions", params={"agent_id": agent_id, "limit": 1}) - assert page.status_code == 200 and page.json() == {"data": [value], "has_more": False} + assert page.status_code == 200 and page.json() == {"object": "list", "data": [value], "has_more": False, "first_id": value["id"], "last_id": value["id"]} proof["snapshots"][name] = value return value diff --git a/services/agents-api/tests/official_mcp_credentials.py b/services/agents-api/tests/official_mcp_credentials.py index 8c663c145..5b35d271d 100644 --- a/services/agents-api/tests/official_mcp_credentials.py +++ b/services/agents-api/tests/official_mcp_credentials.py @@ -81,7 +81,7 @@ def verify_session(value, expected_ids, expected_credential): stream_key = {"Idempotency-Key": "mcp-vault-stream-" + str(uuid.uuid4())} with raw.stream("POST", base + "/agents/sessions", headers={**headers, **stream_key}, json={**inline, "stream": True}) as response: - assert response.status_code == 200 + assert response.status_code == 201 assert response.headers["content-type"].startswith("text/event-stream") event = event_data(response.iter_lines()) assert event["type"] == "agent.session.created" From 65f277172d4ffe6c29f28b949147feeca75eed45 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:27:52 +0800 Subject: [PATCH 09/15] Allow the nullable public error code in standalone SDK acceptance --- services/agents-api/tests/official_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py index 67edc350c..3448246af 100644 --- a/services/agents-api/tests/official_client.py +++ b/services/agents-api/tests/official_client.py @@ -136,7 +136,8 @@ def expect_error(error, operation): try: operation() except error as result: - assert isinstance(result.body, dict) and result.body.get("code") + assert isinstance(result.body, dict) and "code" in result.body + assert isinstance(result.body.get("type"), str) and isinstance(result.body.get("message"), str) return result else: raise AssertionError(f"Expected {error.__name__}") From 64a99a2bee228bc9508c998fe8746e149ad252f1 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:32:35 +0800 Subject: [PATCH 10/15] Update the connection acceptance fixture to the beta error category --- apps/web/e2e/core-connection.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts index e357bcd74..fc101a463 100644 --- a/apps/web/e2e/core-connection.spec.ts +++ b/apps/web/e2e/core-connection.spec.ts @@ -293,7 +293,7 @@ test("announces loading, authenticated access, and each safe failure state from { status: 200, body: { object: "list", data: [null], has_more: false, first_id: null, last_id: null } }, { status: 401, body: { error: { code: "invalid_api_key", message: "safe fixture failure" } } }, { status: 401, body: { error: { code: "gateway_auth_required", message: "safe fixture failure" } } }, - { status: 400, body: { error: { code: "invalid_beta_header", message: "safe fixture failure" } } }, + { status: 400, body: { error: { code: "invalid_beta", message: "safe fixture failure" } } }, { status: 503, body: { error: { code: "unavailable", message: "safe fixture failure" } } }, { abort: true }, ]; From f01ed6facbc5fbd56461c94833e2fb6fca274cb4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:33:16 +0800 Subject: [PATCH 11/15] Correct database regression statuses without changing test-control responses --- services/agents-api/internal/store/initial_files_http_test.go | 2 +- .../internal/store/session_model_execution_http_test.go | 4 ++-- services/agents-api/tests/official_agent_reference_retry.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/agents-api/internal/store/initial_files_http_test.go b/services/agents-api/internal/store/initial_files_http_test.go index 56fcdff7f..bb65673fe 100644 --- a/services/agents-api/internal/store/initial_files_http_test.go +++ b/services/agents-api/internal/store/initial_files_http_test.go @@ -50,7 +50,7 @@ func TestInitialFilesHTTPInlineLimitsAndRetry(t *testing.T) { r.Header.Set("Idempotency-Key", key) w := httptest.NewRecorder() handler.ServeHTTP(w, r) - if w.Code != http.StatusOK { + if w.Code != http.StatusCreated { t.Fatalf("size %d: HTTP %d: %s", size, w.Code, w.Body.String()) } var response struct { diff --git a/services/agents-api/internal/store/session_model_execution_http_test.go b/services/agents-api/internal/store/session_model_execution_http_test.go index 78c4d2f60..f806b7578 100644 --- a/services/agents-api/internal/store/session_model_execution_http_test.go +++ b/services/agents-api/internal/store/session_model_execution_http_test.go @@ -41,7 +41,7 @@ func TestModelExecutionHTTPWriteOnlyAndStrictAdmission(t *testing.T) { body := `{"agent":{"model":"actual-model","x_agents_core":{"harness":"codex"}},"environment":{"type":"openai_hosted"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://example.com/v1","api_key":"model-http-canary"}}}` key := uuid.NewString() w := call("POST", "/v1/agents/sessions", body, key) - if w.Code != 200 { + if w.Code != 201 { t.Fatalf("create: %d %s", w.Code, w.Body) } var session struct{ ID string } @@ -51,7 +51,7 @@ func TestModelExecutionHTTPWriteOnlyAndStrictAdmission(t *testing.T) { if w := call("GET", "/v1/agents/sessions/"+session.ID, "", ""); w.Code != 200 { t.Fatal(w.Code) } - if w := call("POST", "/v1/agents/sessions", body, key); w.Code != 200 { + if w := call("POST", "/v1/agents/sessions", body, key); w.Code != 201 { t.Fatal("creation retry failed", w.Code) } if w := call("POST", "/v1/agents/sessions", strings.Replace(body, "model-http-canary", "changed-key", 1), key); w.Code != 409 { diff --git a/services/agents-api/tests/official_agent_reference_retry.py b/services/agents-api/tests/official_agent_reference_retry.py index 44d512b4c..701c388c4 100644 --- a/services/agents-api/tests/official_agent_reference_retry.py +++ b/services/agents-api/tests/official_agent_reference_retry.py @@ -33,7 +33,7 @@ def main(): def mutate(**values): response = transport.post(control, json={"id":agent.id, **values}) - assert response.status_code == 202 + assert response.status_code == 204 mutate(patch={"model":"changed-model", "instructions":"changed"}) assert sessions.create(**spec, extra_headers=headers) == first From a82f0648331f78b5c1c2c2e461de02e742b68c49 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:34:21 +0800 Subject: [PATCH 12/15] Refresh saved Agent acceptance for qualified Codex controls --- .../tests/official_agent_references.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/services/agents-api/tests/official_agent_references.py b/services/agents-api/tests/official_agent_references.py index 1d89d2409..ed42f5b21 100644 --- a/services/agents-api/tests/official_agent_references.py +++ b/services/agents-api/tests/official_agent_references.py @@ -62,11 +62,9 @@ def verify_agent_references(client, other, expect_error): ("reasoning", {"effort": "high"}, None), ("reasoning", {"summary": "auto"}, {}), ("service_tier", "fast", "auto"), - ("multi_agent", {"enabled": True}, {"enabled": False}), ("text", {"format": {"type": "json_schema", "schema": {"type": "object"}}}, {"verbosity": "medium"}), ("tools", [dict(tool, defer_loading=True)], None), ("tools", [{"type": "tool_search"}], []), - ("tools", [{"type": "programmatic_tool_calling", "enabled": False}], []), ): unsupported = agents.create(model="model", **{field: value}) reference = {"agent_id": unsupported.id, "environment": {"type": "none"}} @@ -75,6 +73,26 @@ def verify_agent_references(client, other, expect_error): expect_error(BadRequestError, lambda: sessions.create(agent={"model": "model", field: value}, environment={"type": "none"})) assert agents.retrieve(unsupported.id) == unsupported + # These controls are admitted by the current Codex profile. Both reference + # and inline requests must preserve them, and overrides replace the field. + for field, value, replacement in ( + ("multi_agent", {"enabled": True}, {"enabled": False}), + ("tools", [{"type": "programmatic_tool_calling", "enabled": False}], []), + ): + supported = agents.create(model="model", **{field: value}) + reference = {"agent_id": supported.id, "environment": {"type": "none"}} + inherited = sessions.create(**reference) + inline = sessions.create(agent={"model": "model", field: value}, environment={"type": "none"}) + expected_field = supported.to_dict(mode="json")[field] + assert inherited.agent.to_dict(mode="json")[field] == expected_field + assert inline.agent.to_dict(mode="json")[field] == expected_field + replaced = sessions.create(**reference, agent={field: replacement}) + inline_replacement = sessions.create(agent={"model": "model", field: replacement}, environment={"type": "none"}) + assert replaced.agent.to_dict(mode="json")[field] == inline_replacement.agent.to_dict(mode="json")[field] + assert replaced.agent.to_dict(mode="json")[field] != expected_field + assert agents.retrieve(supported.id) == supported + recovered.extend([inherited, inline, replaced, inline_replacement]) + base = str(client.base_url).rstrip("/") + "/agents/sessions" auth = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} with httpx2.Client(trust_env=False, timeout=10) as raw: From fe6347b37316294ceac768985c93cbbc30b26613 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:38:22 +0800 Subject: [PATCH 13/15] Represent nullable HTTP error codes in the TypeScript client --- apps/web/src/lib/pending-send.ts | 2 +- packages/agents-client/src/client.test.ts | 14 ++++++++++++++ packages/agents-client/src/client.ts | 6 +++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/pending-send.ts b/apps/web/src/lib/pending-send.ts index de260ce70..3bc40aaae 100644 --- a/apps/web/src/lib/pending-send.ts +++ b/apps/web/src/lib/pending-send.ts @@ -47,7 +47,7 @@ export function failPendingSend( ): FailedPendingSend { return { ...pending, - code: error instanceof AgentCoreError ? error.code : undefined, + code: error instanceof AgentCoreError ? error.code ?? undefined : undefined, message, uncertain: isUncertainSendFailure(error), }; diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index 927bb3b33..c5fed1092 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -529,6 +529,20 @@ describe("OpenAIAgentsClient", () => { } }); + it("preserves a nullable resource error code", async () => { + const client = new OpenAIAgentsClient({ + fetch: recordingFetch(jsonResponse({ error: { + code: null, + message: "Resource not found.", + type: "invalid_request_error", + param: null, + } }, 404), []), + }); + await expect(client.retrieveSourceFile("missing")).rejects.toMatchObject({ + status: 404, code: null, param: null, errorType: "invalid_request_error", + }); + }); + it("preserves a pre-stream Session creation API error without opening or retrying", async () => { const calls: FetchCall[] = []; const onOpen = vi.fn(); diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index 553dd5a16..58d21309c 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -64,7 +64,7 @@ export interface OpenAIAgentsClientOptions { interface APIErrorEnvelope { error?: { - code?: string; + code?: string | null; message?: string; param?: string | null; type?: string; @@ -73,14 +73,14 @@ interface APIErrorEnvelope { export class AgentCoreError extends Error { readonly status: number; - readonly code?: string; + readonly code?: string | null; readonly param?: string | null; readonly errorType?: string; constructor( message: string, status: number, - code?: string, + code?: string | null, param?: string | null, errorType?: string, ) { From b51548290a72af23b7a47fdc43666d7a14067cce Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:40:21 +0800 Subject: [PATCH 14/15] Use a valid Source File ID in nullable-error regression --- packages/agents-client/src/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index c5fed1092..908ce0839 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -538,7 +538,7 @@ describe("OpenAIAgentsClient", () => { param: null, } }, 404), []), }); - await expect(client.retrieveSourceFile("missing")).rejects.toMatchObject({ + await expect(client.retrieveSourceFile("file-123e4567-e89b-42d3-a456-426614174000")).rejects.toMatchObject({ status: 404, code: null, param: null, errorType: "invalid_request_error", }); }); From 70cd55fbb2e09841de05f9333b68fc5053da8aa1 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 23:49:16 +0800 Subject: [PATCH 15/15] Record completed semantic alignment acceptance --- .../official-semantics-alignment.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index a13df5a6e..325de68aa 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -56,3 +56,22 @@ credential values belong in the repository or task board. These observations establish a bounded comparison, not complete official protocol compatibility. Core regression and real-model validation are recorded with the implementation acceptance before merge. + +## Core acceptance + +The deployed server/runtime at `7c80d604` passed seven real-PostgreSQL resource and +safety groups, including unchanged credential-row hashes after rejected writes +and restart. Codex and Claude Code used Kimi K3; MiniMax Code used MiniMax M2.7. +Each completed three real Turns covering JSON creation, live SSE creation and +event continuation, with history paging, empty no-op requests and tenant isolation. +Four earlier attempts were interrupted by failed test-network relays and retained +as unsuccessful evidence. After end-to-end TLS checks, the controlled rerun passed. + +The server `make check` gate passed with Web checks run separately: type checks, +production build, 287 client tests, 573 Web tests and 74 fixture browser cases +(the corrected Beta-error fixture was rerun separately). Full standalone fixed +Python SDK and Go-client acceptance passed. A fresh Astra high full-diff review +found no blockers and independently ran API/contract tests. Rebase onto main +`c96ea82` preserved every batch patch; the combined tree passed API/execution and +three PostgreSQL scheduling regressions. Test resources were scoped to this batch. +E2B, OAuth provider refresh and new native capability combinations were not requalified.