diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ad825d54..abeeb9966 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,10 @@ observed upstream server failures as compatibility behavior. See Report validation failures with official evidence through the typed field error, which emits `invalid_request_error` with the observed param and message; keep -other local codes until their official fields are sampled. Agent configuration +other local codes until their official fields are sampled. Every 409 has type +`conflict_error`. Session input conflicts and changed tool results also use code +`conflict_error`; documented Core-only conflicts, such as Idempotency-Key reuse, +sandbox administration and Environment input states, keep their local codes. Agent configuration (saved create/update and the inline Session agent) uses one path-tracking validator of the pinned shapes before its parsers and harness admission, which keep their local codes; do not grow it into a JSON Schema engine. A malformed path @@ -2295,7 +2298,9 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Turn/call identity selects an existing call; admission never creates a Turn for a result. Save the complete result and its input retry record in the same Session transaction. Any invalid target, conflicting result or later batch error rolls - back the whole request. Identical saved results remain retryable after termination + back the whole request. Resolve targets only after the tenant Session lookup: + an unknown call or a call of another Turn is 400 `invalid_request_error`, and + missing or foreign Sessions keep one 404. Identical saved results remain retryable after termination without applying them again. The execution input cursor skips function results; their separate native receipts still determine application. Public result events validate variant-specific fields and required values before admission; retain diff --git a/apps/web/src/features/sessions/actions/session-actions.test.ts b/apps/web/src/features/sessions/actions/session-actions.test.ts index 34edaee80..387c758dc 100644 --- a/apps/web/src/features/sessions/actions/session-actions.test.ts +++ b/apps/web/src/features/sessions/actions/session-actions.test.ts @@ -352,7 +352,10 @@ describe("Session deletion", () => { await requestSessionCancelBeforeDelete(core, "session-1", "cancel-key"); expect(cancelTurn).toHaveBeenCalledWith("session-1", "cancel-key"); + // Current Cores report a rejected cancellation with the official conflict + // fields; earlier Cores used turn_conflict. Neither is a deletion conflict. for (const [error, kind, text] of [ + [new AgentCoreError("Earlier input to this Session is still pending.", 409, "conflict_error", null, "conflict_error"), "request_failed", "rejected the cancellation (409): Earlier input to this Session is still pending."], [new AgentCoreError("pending input", 409, "turn_conflict"), "request_failed", "rejected the cancellation (409)"], [new TypeError("connection lost"), "request_failed", "result is unknown"], [new AgentCoreError("missing", 404), "not_found", "not found"], @@ -362,7 +365,7 @@ describe("Session deletion", () => { expect(failure).toMatchObject({ kind }); expect((failure as Error).message).toContain(text); } - expect(cancelTurn).toHaveBeenCalledTimes(4); + expect(cancelTurn).toHaveBeenCalledTimes(5); expect(deleteSession).not.toHaveBeenCalled(); }); diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 7a0f7e292..cc67571fc 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -23,7 +23,7 @@ and Environment Plugin MCP have separate inventories and qualification. | 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. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | | 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). | An explicit non-object root type is a protocol error for every harness ([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, 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. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). 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 an explicit object root. Local nonblank/512-byte name and 64-definition Session 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. | +| 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 and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). 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_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | | Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | | Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | diff --git a/contracts/agents-api/history-events-usage.md b/contracts/agents-api/history-events-usage.md index 6a30c5507..527cb493c 100644 --- a/contracts/agents-api/history-events-usage.md +++ b/contracts/agents-api/history-events-usage.md @@ -202,8 +202,8 @@ Deferred, with evidence retained in `findings.json`: them under native history ownership. - EVT-09 and EVT-10: null-valued `output_index`, `phase` and `error` fields and the initial assistant content belong to a separate serialization batch. -- EVT-11 and EVT-12: unknown call/Turn result and conflict error codes belong to - ERROR-PROTOCOL-001. +- EVT-11 and EVT-12: unknown call/Turn result and conflict error codes were + since aligned by the [input conflict batch](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23). - EVT-13: official Session usage became null when any root Turn usage was unknown; Core sums the known Turns. The in-progress case is unverified. - EVT-19: the Core terminal sequence for a Turn cancelled mid-text is recorded by diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index 3dbe589ba..c6e9b5289 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -520,3 +520,80 @@ test creates Sessions and submits events over HTTP, reads back the exact user Item text, and proves the W4 rejections write nothing; the pinned-SDK initial input script asserts the same. Real Core, daemon and model acceptance is recorded separately by the coordinator. + +## Session input conflicts and result targets — September 23 + +This batch gives every 409 the official conflict type and aligns the conflict and +tool result target errors of `events.create`, from Core main `0035435a`. Evidence +comes from the campaign scans recorded privately in +`~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` (ERR-22 and +ERR-27, raw records in `official/results.json`: `sessB-message-while-running`, +`sessA-delete-while-waiting`) and +`~/.parsar/remediation/20260923/campaign-scan-2/events-tools/findings.json` +(EVT-11, EVT-12 and EVT-14, raw records in `official/calls-s2.json`: +`s2-result-unknown-call`, `s2-result-unknown-turn`, +`s2-result-duplicate-after-terminal`, `s2-result-changed-after-terminal` and +`s2-result-after-cancel`). Every observed official 409 has type and code +`conflict_error` and a null param. + +| Row | Case | Core behavior | +| --- | --- | --- | +| CF1 | Every 409 response (ERR-27) | Type `conflict_error`. The code stays specific to the case (CF2–CF5). | +| CF2 | `events.create` input that the Session cannot accept in its current state: a tool result after its Turn was cancelled, or ended without a saved result (EVT-12), and any other Turn conflict on this route; a batch while earlier input still waits for admission, such as the reserved initial input of a provisioning hosted Session or of a self-hosted Session awaiting its connection (ERR-22) | 409 with code `conflict_error` and a null param. Core keeps its message "The Turn cannot accept this input in its current state."; pending input reports "Earlier input to this Session is still pending." (official: "session initial input is still pending"). | +| CF3 | A tool result that differs from the call's saved result, before or after its Turn ends (EVT-12) | 409 `conflict_error`, "The tool call already has a different result." | +| CF4 | Idempotency-Key reuse with a different body on Session creation or `events.create` | Unchanged local code `idempotency_conflict` and message, with type `conflict_error`. Request idempotency is a documented Core extension of these operations. | +| CF5 | Other Core-only conflicts: `sandbox_deployment_conflict`, `runtime_node_in_use`, `runtime_local_node_configured`, `environment_unavailable`, `environment_input_expired`, `environment_input_cancelled`, `runtime_history_unsupported`, and `turn_conflict` from an Environment file write while Session work or input is active | Codes and messages unchanged, with type `conflict_error`. | +| CF6 | A tool result whose `call_id` names no function call of the caller's own Session, with any `turn_id` (EVT-11) | 400 with type and code `invalid_request_error`, param null, "Unknown pending tool call." Nothing is written and the pending action is unchanged. | +| CF7 | A tool result for a call of the Session whose `turn_id` names another Turn, an unknown UUID or no UUID at all (EVT-11) | 400 `invalid_request_error`, param null, "The tool call belongs to a different Turn." Nothing is written. | +| CF8 | Any input to a missing, malformed or foreign Session | Unchanged: the byte-identical 404 `not_found_error`, whatever the result target. | +| CF9 | An identical tool result repeated before or after its Turn ends (EVT-14) | Unchanged: 202 without another application or event. The official repeated `item.added` is not copied. | + +Decisions: + +- The error writer selects type `conflict_error` from the 409 status, so later + conflicts cannot drift. The Session input writer maps Turn conflicts to code + `conflict_error`; other routes keep `turn_conflict`, because official conflicts + there, such as an Environment file write during work, are unsampled. +- Result targets are resolved under the tenant Session lock after the Session + lookup. A well-formed Turn ID is looked up in that Session and must own the call; + otherwise the Session's own calls decide between CF6 and CF7. The `turn_id` is + therefore no longer rejected as a malformed UUID before the Session lookup, and + malformed, missing and foreign Sessions keep one 404. The decision reads only + the caller's Session, so it reveals nothing about other Sessions or tenants. +- The official messages name the call or internal executor IDs ("Unknown pending + tool call: ", "function call exec-... belongs to a different managed + agent turn"). Core's messages are fixed and repeat neither caller input nor + internal identifiers. +- Checks keep their order: request validation, the Session lookup, the retry + lookup (CF4), for batches with a message the Environment file-write gate (a + Turn conflict, also CF2 with the Turn message), the pending input gate (CF2), + then each event in batch order. A + batch sent while input is pending therefore returns the CF2 409 even when its + result target is unknown; the official order between these errors is + unobserved. An empty `turn_id` or a + blank `call_id` remains the generic 400 `invalid_request`. +- The official pending-input sample is the asynchronous admission window of + `none` initial input (ERR-22). Core admits `none` input synchronously and does + not emulate that window; the same fields apply to Core's reserved hosted and + self-hosted input, initial or later. +- The TypeScript client and Core Web did not branch on the old codes. The client + documents that `isSessionDeletionConflict` classifies only a `deleteSession` + failure, since input conflicts now share its code. Cores before this batch + returned 409 `turn_conflict` or `idempotency_conflict` with type + `invalid_request_error`, and 404 for unknown result targets; clients that span + both should treat any 409 as a conflict, and a 400 on new Cores or a 404 on + older Cores as an unknown result target. + +Unchanged: the ERR-22 asynchronous admission window (an architectural difference), +Idempotency-Key semantics, Session-level 404 isolation, admission timing and the +schema. The pinned SDK still retries a 409 by default; the status did not change. + +Go API tests pin the exact CF2–CF9 bodies and the conflict type of every Core-only +409 code. A real-PostgreSQL HTTP test replays CF2–CF4 and CF6–CF9 with tenant B +requests, missing and malformed Sessions, a rolled-back mixed batch, a +whole-database digest and Session reads proving that every rejection writes +nothing and keeps the pending action. Store tests cover target classification and +rollback. The pinned-SDK scripts `official_function_inputs.py`, +`official_pending_actions_native.py` and `official_session_creators.py` assert the +new fields, and TypeScript client and Core Web unit tests cover them. Real Core, +daemon and model acceptance is recorded separately by the coordinator. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index c62d852b3..b8889f667 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -4550,20 +4550,27 @@ paths: 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. + errors; exact hosted failure mapping is unverified. Input the Session cannot + accept in its current state, such as a result after cancellation or a batch + while earlier input is pending, and a result that differs from the call's + saved result return 409 with type and code conflict_error; reusing an Idempotency-Key + with a different batch returns the local 409 idempotency_conflict. Inside + an owned Session, a result for an unknown call or for a call of another Turn + returns 400 invalid_request_error and changes nothing; missing and foreign + Sessions return 404. 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. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 906743974..04c20b710 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -1,6 +1,6 @@ # Pinned operation evidence inventory — 2026-09-23 -Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, and the list cursor error batch (CE) updates unresolved `after` cursor errors on every list; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. +Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, the list cursor error batch (CE) updates unresolved `after` cursor errors on every list, and the input conflict batch (CF) gives every 409 type `conflict_error` and aligns Session input conflicts and tool result target errors; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. Baseline: `contracts/agents-api/upstream.json`, SDK **3.13.0**, upstream commit **d7c41efee1b0802b79f3f88a678ef2052b06e9ce**, `OpenAI-Beta: agents=v1`. AGENTS.md and relevant CONTRIBUTING.md compatibility, ownership and evidence rules govern this inventory. @@ -46,6 +46,7 @@ Repository paths below are relative to the inspected worktree; private evidence | Y | [Artifact capture and listing](official-semantics-alignment.md#artifact-capture-and-listing--september-23); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-50..62 with raw records under `official/` (labels `al01`–`al09`, `ar01`–`ar04`, `ac01`–`ac05`, `ad01`/`ad02`): three owned Sessions and two tiny Turns, all deleted; first official Artifact observations. Rows A1–A4 of that section: symlink skip, republication, list envelope and malformed filter. Rust link tests, real-PostgreSQL store/HTTP and pinned-SDK tests without a model; live acceptance is recorded with the batch. | | CE | [List cursor errors](list-query-semantics.md#list-cursor-errors--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-01..06 (ERR-07..13 and ERR-17 match or record upstream failures) with raw records labelled `cur-*` in `official/results.json`, plus SAT-04 (campaign scan 3) and HE-57 (campaign scan 2): owned Agent, Session, Turn, Item, Subagent, Artifact, Template, Vault, Credential, File, Skill and Skill version cursors without a model. Rows C1–C5 and K1–K3 of that section. Go store, real-PostgreSQL HTTP tenant A/B and pinned-SDK tests; independent acceptance is recorded with the batch. | | Z | [Session deletion lifecycle](official-semantics-alignment.md#session-deletion-lifecycle--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-29/30 with raw records under `official/` (`q5-delete-repeat`, `q5b-delete-while-in-progress`, `q5-delete-never-existed`, `q5-delete-while-running`, `q5-get-after-delete`). Rows D1–D5 of that section. Handler, real-PostgreSQL HTTP/store/lock-race, Worker, pinned-SDK, TypeScript client and Web tests without a model; live acceptance is recorded with the batch. | +| CF | [Session input conflicts and result targets](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-22/27 (raw `official/results.json`: `sessB-message-while-running`, `sessA-delete-while-waiting`) and `~/.parsar/remediation/20260923/campaign-scan-2/events-tools/findings.json` EVT-11/12/14 (raw `official/calls-s2.json`: `s2-result-unknown-call`, `s2-result-unknown-turn`, `s2-result-duplicate-after-terminal`, `s2-result-changed-after-terminal`, `s2-result-after-cancel`). Rows CF1–CF9 of that section. Go API bodies, real-PostgreSQL HTTP replay with tenant B and a no-write digest, pinned-SDK scripts; live acceptance is recorded with the batch. | ## Per-operation evidence matrix @@ -63,7 +64,7 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | 8 | beta.agents.sessions.update | P: metadata-only replacement/clear; metadata errors use official code and `metadata`/`metadata.` param | S `metadata-replace/null/empty/omit/invalid-value.json`; `update-agent.json` uses newer unpinned field | N Live completed Session metadata rejection/clear/isolation; recorded active controlled metadata coverage | Session admission batch changes empty update to observed official 400; Session agent update belongs to baseline upgrade, not fixed-pin operation gap | | 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging; unknown keys ignored, limit 0/above 100 clamp; any unresolved cursor, malformed included, is the missing 404 | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples; L SES-10/11/15/16/17; CE ERR-01 `cur-sessions-malformed`, ERR-13/14 | C Live order/cursors/empty/tenant checks; L DB tenant A/B; CE DB cursor matrix tenant A/B | Core page capacity 100; official cap unknown. Eventual visibility sample is not a required delay | | 10 | beta.agents.sessions.delete | P: deletion only of a durably idle or failed Session without required actions or pending input; a busy root Turn or pending reservation gives 409 `conflict_error` with no change (subagent child Turns and pending Environment file writes are not checked); owner repeat returns the same 200; owned managed cleanup, user compute retained | S cleanup files 200/deleted; retry-session active cleanup initially 409; Z SES-29 `q5-delete-repeat` 200, SES-30 `q5b-delete-while-in-progress` 409, `q5-delete-never-existed` 404, `q5-delete-while-running` 200 right after an `events.create` 202 | D recorded real cleanup; C cleanup separately recorded; Z DB matrix D1–D4 with no-write digest, admission lock race and pinned-SDK script | Core returns 409 right after an `events.create` 202 because it admits Turns synchronously (official 200); input awaiting its Environment cannot be cancelled publicly and is unobserved officially; physical purge/retention may end repeat idempotency; caller compute ownership preserved | -| 11 | beta.agents.sessions.events.create | P: 202/empty body, empty-array authenticated no-op, text/cancel/function admission; whitespace-only text is admitted and stored verbatim, empty text/content/input keep the local 400 | S `second-turn-create.json`, `events-empty/null.json`; H second-input; P SES-04 two whitespace-only messages 202, SES-06/07 empty text/content/input 400 | C Live real continuation/no-op; T qualified message/result/cancel workflows; P DB verbatim whitespace Items and no-write empty-input rejection | Mixed prepared-environment batches, native receipt vs durable acceptance, cancel-before-result-publication timing; unqualified content/tools; empty-input error code/message differ; `["", text]` unobserved officially (SES-08); Claude SDK and MiniMax Code reject whitespace-only messages at admission as a declared native limitation (W6) | +| 11 | beta.agents.sessions.events.create | P: 202/empty body, empty-array authenticated no-op, text/cancel/function admission; whitespace-only text is admitted and stored verbatim, empty text/content/input keep the local 400; input the Session cannot accept (result after cancellation, batch while input is pending) and changed results give 409 `conflict_error`/`conflict_error`; in an owned Session an unknown call or a call of another Turn gives 400 `invalid_request_error` without writes; missing/foreign Sessions stay 404; key reuse keeps local 409 `idempotency_conflict` with type `conflict_error` | S `second-turn-create.json`, `events-empty/null.json`; H second-input; P SES-04 two whitespace-only messages 202, SES-06/07 empty text/content/input 400; CF EVT-11 `s2-result-unknown-call`/`s2-result-unknown-turn` 400, EVT-12 `s2-result-changed-after-terminal`/`s2-result-after-cancel` 409, EVT-14 duplicate 202, ERR-22 `sessB-message-while-running` 409 | C Live real continuation/no-op; T qualified message/result/cancel workflows; P DB verbatim whitespace Items and no-write empty-input rejection; CF DB rows CF2–CF4 and CF6–CF9 with tenant B, no-write digest and unchanged pending action, pinned-SDK result script | Mixed prepared-environment batches, native receipt vs durable acceptance, cancel-before-result-publication timing; unqualified content/tools; empty-input error code/message differ; `["", text]` unobserved officially (SES-08); Claude SDK and MiniMax Code reject whitespace-only messages at admission as a declared native limitation (W6); the official asynchronous pending-input window (ERR-22) is not emulated; Core messages omit call/executor IDs; official order between pending-input and target errors unobserved | | 12 | beta.agents.sessions.events.stream | P: live-only SSE, typed persisted projections; terminal Turn events carry top-level Turn usage (null when unknown); new Turns start `turn.created`, user `item.added`, `session.in_progress`, `turn.in_progress` | H create/reconnect frames; no historical frames in sampled idle interval; J GET streams never ended, terminal `usage` present | C Live; H recorded three-harness disconnect/recovery; T pending actions; J DB order/usage | Full SSE/Item variants/order (EVT-05..10 deferred); child Turns and Items are not streamed on the Session (U, SAT-09) and are read through the Subagent routes; no replay guarantee or observer-disconnect proof for every state | | 13 | beta.agents.sessions.turns.retrieve | P: persisted root Turn identity; malformed and child Turn IDs equal missing | H/S contain Turn list payloads; no isolated positive retrieve raw request identified in this set; X SES-28 official `turn_` 404; U SAT-07 `C04` child Turn ID 404 | Recorded H/B scoped Turn recovery (B under the earlier mixed root/child contract); C history uses list; U DB child-ID 404 equal to missing, tenant B | Distinguish list-shape evidence from retrieve wire qualification; full lifecycle/usage; official 404 message text differs | | 14 | beta.agents.sessions.turns.list | P: full envelope, ordered root Turns only; a child Turn, malformed or other unresolved cursor equals a missing one; limit outside 1–100 rejects with the Beta code | S `turns-final/empty-page/limit-high/order-empty.json`; H both directions; L SES-14/15/16/17; X SES-28; U SAT-07 `R06` root-only list beside a child Turn; CE ERR-01 `cur-turns-malformed`, ERR-13 | C Live paging; H/B real child/root identity recorded under the earlier mixed contract; L DB tenant A/B; U DB root-only list and cursor; CE DB cursor matrix tenant A/B | All interleavings, same-timestamp paging, interim/failed usage | diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index d4ae8e523..312c709fc 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -653,7 +653,7 @@ describe("OpenAIAgentsClient", () => { fetch: recordingFetch(jsonResponse({ error: { code: "idempotency_conflict", message: "Creation key conflicts with another request.", - type: "invalid_request_error", + type: "conflict_error", } }, 409), calls), }); @@ -664,7 +664,7 @@ describe("OpenAIAgentsClient", () => { )).rejects.toMatchObject({ status: 409, code: "idempotency_conflict", - errorType: "invalid_request_error", + errorType: "conflict_error", }); expect(calls).toHaveLength(1); expect(onOpen).not.toHaveBeenCalled(); @@ -2222,6 +2222,25 @@ describe("OpenAIAgentsClient", () => { expect(calls).toHaveLength(0); }); + it("surfaces function result conflicts and target errors with their official fields", async () => { + const input = { callId: "call", turnId: "turn", success: true, output: "value" }; + for (const [status, type, code, message] of [ + [409, "conflict_error", "conflict_error", "The tool call already has a different result."], + [409, "conflict_error", "conflict_error", "The Turn cannot accept this input in its current state."], + [400, "invalid_request_error", "invalid_request_error", "Unknown pending tool call."], + [400, "invalid_request_error", "invalid_request_error", "The tool call belongs to a different Turn."], + ] as const) { + const calls: FetchCall[] = []; + const client = new OpenAIAgentsClient({ + fetch: recordingFetch(jsonResponse({ error: { type, code, message, param: null } }, status), calls), + }); + const error = await client.submitFunctionResult("session", input, "result-key").catch((value: unknown) => value); + expect(error).toBeInstanceOf(AgentCoreError); + expect(error).toMatchObject({ status, code, errorType: type, param: null, message }); + expect(calls).toHaveLength(1); + } + }); + 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: 202 }), calls) }); diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index fb2137fdc..a1b2e638d 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -125,7 +125,8 @@ export class CreationStreamRetryError extends AgentCoreError { * Core deletes only a durably idle or failed Session without required actions * or pending input. Any other Session is rejected with HTTP 409 and code * `conflict_error` and left unchanged: cancel its work, wait until it is idle, - * then delete it. + * then delete it. Apply this only to a `deleteSession` failure: Session input + * conflicts use the same status and code. */ export function isSessionDeletionConflict(error: unknown): error is AgentCoreError { return error instanceof AgentCoreError && error.status === 409 && error.code === "conflict_error"; @@ -2571,6 +2572,15 @@ export class OpenAIAgentsClient implements AgentCore { return this.submitEvents(sessionId, [{ type: "agent.session.input.cancel" }], idempotencyKey); } + /** + * Submits one function result. Core rejects a result the Session cannot accept, + * such as one after cancellation or one that differs from the saved result, + * with HTTP 409 `conflict_error`. A call that is unknown or belongs to another + * Turn of the Session is HTTP 400 `invalid_request_error`; nothing changes. + * Cores before these codes used 409 `turn_conflict`/`idempotency_conflict` + * and 404 for unknown targets: treat any 409 as a conflict, and 400 (new) or + * 404 (older, within an owned Session) as an unknown target. + */ submitFunctionResult(sessionId: string, input: FunctionResultInput, idempotencyKey: string): Promise { const event: SessionToolResultInputEvent = { type: "agent.session.input.tool_result", diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index d30cdc523..99197dea7 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -19,13 +19,17 @@ func writeJSON(w http.ResponseWriter, status int, value any) { _ = json.NewEncoder(w).Encode(value) } +// writeError reports every 409 with type conflict_error, as every observed +// official conflict does (ERR-27); Core-only conflicts keep their own code. func writeError(w http.ResponseWriter, status int, code, message string, param ...string) { kind := "invalid_request_error" if status >= 500 { kind = "server_error" } else if status == http.StatusUnauthorized { kind = "authentication_error" - } else if code == "not_found_error" || code == "invalid_beta" || code == "conflict_error" { + } else if status == http.StatusConflict { + kind = "conflict_error" + } else if code == "not_found_error" || code == "invalid_beta" { kind = code } var errorCode *string @@ -39,6 +43,20 @@ func writeError(w http.ResponseWriter, status int, code, message string, param . writeJSON(w, status, v1.ErrorResponse{Error: v1.APIError{Message: message, Type: kind, Code: errorCode, Param: errorParam}}) } +// writeInputError reports Session input admission failures. Input that the +// Session cannot accept in its current state is the official conflict_error; +// Idempotency-Key reuse keeps Core's local idempotency_conflict code. +func writeInputError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, store.ErrSessionInputPending): + writeError(w, http.StatusConflict, "conflict_error", "Earlier input to this Session is still pending.") + case errors.Is(err, store.ErrTurnConflict): + writeError(w, http.StatusConflict, "conflict_error", "The Turn cannot accept this input in its current state.") + default: + writeStoreError(w, r, err) + } +} + const unstorableTextMessage = "Request text contains characters this service cannot store or compare, such as U+0000 or invalid UTF-8." // fieldError is a request validation failure reported with the official @@ -114,6 +132,12 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound case errors.Is(err, store.ErrSessionNotIdle): // Observed official status, type, code, null param and message. writeError(w, http.StatusConflict, "conflict_error", "session must be durably idle or failed without required actions before deletion") + case errors.Is(err, store.ErrUnknownFunctionCall): + writeError(w, http.StatusBadRequest, "invalid_request_error", "Unknown pending tool call.") + case errors.Is(err, store.ErrFunctionCallTurnMismatch): + writeError(w, http.StatusBadRequest, "invalid_request_error", "The tool call belongs to a different Turn.") + case errors.Is(err, store.ErrFunctionResultConflict): + writeError(w, http.StatusConflict, "conflict_error", "The tool call already has a different result.") case errors.Is(err, store.ErrTurnConflict): writeError(w, http.StatusConflict, "turn_conflict", "The Turn cannot accept this input in its current state.") case errors.Is(err, store.ErrIdempotencyConflict): diff --git a/services/agents-api/internal/api/errors_test.go b/services/agents-api/internal/api/errors_test.go index 7de682ed4..4c91496e4 100644 --- a/services/agents-api/internal/api/errors_test.go +++ b/services/agents-api/internal/api/errors_test.go @@ -5,9 +5,11 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -96,3 +98,38 @@ func TestSessionDeletionConflictError(t *testing.T) { t.Fatalf("response = %d %s", response.Code, response.Body) } } + +// Every 409 has type conflict_error (ERR-27). Core-only conflicts keep their +// documented local code; outside Session input, a Turn conflict keeps +// turn_conflict because the official behavior there is unobserved. +func TestConflictErrorsUseConflictType(t *testing.T) { + for err, code := range map[error]string{ + store.ErrSandboxDeploymentConflict: "sandbox_deployment_conflict", + store.ErrRuntimeNodeInUse: "runtime_node_in_use", + store.ErrRuntimeLocalNodeConfigured: "runtime_local_node_configured", + store.ErrEnvironmentUnavailable: "environment_unavailable", + execution.ErrEnvironmentInputExpired: "environment_input_expired", + execution.ErrEnvironmentInputCancelled: "environment_input_cancelled", + store.ErrSessionNotIdle: "conflict_error", + store.ErrFunctionResultConflict: "conflict_error", + store.ErrIdempotencyConflict: "idempotency_conflict", + store.ErrTurnConflict: "turn_conflict", + store.ErrSessionInputPending: "turn_conflict", + } { + t.Run(code, func(t *testing.T) { + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/v1/agents/environments/environment/files", nil) + writeStoreError(response, request, fmt.Errorf("operation: %w", err)) + var body v1.ErrorResponse + if response.Code != http.StatusConflict || json.Unmarshal(response.Body.Bytes(), &body) != nil || + body.Error.Type != "conflict_error" || body.Error.Code == nil || *body.Error.Code != code || body.Error.Param != nil { + t.Fatalf("%v: %d %s", err, response.Code, response.Body) + } + }) + } + response := httptest.NewRecorder() + writeError(response, http.StatusConflict, "runtime_history_unsupported", "Runtime history is not supported for this Session.") + if response.Code != http.StatusConflict || !strings.Contains(response.Body.String(), `"type":"conflict_error","code":"runtime_history_unsupported"`) { + t.Fatal(response.Body) + } +} diff --git a/services/agents-api/internal/api/function_inputs_test.go b/services/agents-api/internal/api/function_inputs_test.go index ba8291d57..5d7a6db82 100644 --- a/services/agents-api/internal/api/function_inputs_test.go +++ b/services/agents-api/internal/api/function_inputs_test.go @@ -2,11 +2,13 @@ package api import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -72,9 +74,40 @@ func TestPublicFunctionResultsRejectMalformedVariantsBeforeAdmission(t *testing. } } -func TestPublicFunctionTurnConflictIsNotServerFailure(t *testing.T) { - w, _ := submitResultRequest(t, `{"events":[{"type":"agent.session.input.tool_result","turn_id":"turn","call_id":"call","success":true}]}`, store.ErrTurnConflict) - if w.Code != 409 || !strings.Contains(w.Body.String(), `"code":"turn_conflict"`) { - t.Fatal(w.Code, w.Body) +// Session input admission errors on events.create (EVT-11, EVT-12, ERR-22, +// ERR-27): input conflicts use the official conflict_error fields, result +// targets inside an owned Session are request errors, a missing or foreign +// Session stays not found and Idempotency-Key reuse keeps Core's local code. +func TestPublicInputAdmissionErrorFields(t *testing.T) { + body := `{"events":[{"type":"agent.session.input.tool_result","turn_id":"turn","call_id":"call","success":true}]}` + for _, test := range []struct { + name string + err error + status int + want string + }{ + {"cancelled_turn", fmt.Errorf("submit turn inputs: %w", store.ErrTurnConflict), 409, + `{"error":{"message":"The Turn cannot accept this input in its current state.","type":"conflict_error","code":"conflict_error","param":null}}`}, + {"pending_input", fmt.Errorf("submit turn inputs: %w", store.ErrSessionInputPending), 409, + `{"error":{"message":"Earlier input to this Session is still pending.","type":"conflict_error","code":"conflict_error","param":null}}`}, + {"changed_result", fmt.Errorf("submit turn inputs: %w", store.ErrFunctionResultConflict), 409, + `{"error":{"message":"The tool call already has a different result.","type":"conflict_error","code":"conflict_error","param":null}}`}, + {"unknown_call", fmt.Errorf("submit turn inputs: %w", store.ErrUnknownFunctionCall), 400, + `{"error":{"message":"Unknown pending tool call.","type":"invalid_request_error","code":"invalid_request_error","param":null}}`}, + {"other_turn", fmt.Errorf("submit turn inputs: %w", store.ErrFunctionCallTurnMismatch), 400, + `{"error":{"message":"The tool call belongs to a different Turn.","type":"invalid_request_error","code":"invalid_request_error","param":null}}`}, + {"missing_session", fmt.Errorf("submit turn inputs: %w", store.ErrNotFound), 404, + `{"error":{"message":"Resource not found.","type":"not_found_error","code":"not_found_error","param":null}}`}, + {"key_reuse", fmt.Errorf("submit turn inputs: %w", store.ErrIdempotencyConflict), 409, + `{"error":{"message":"This idempotency key was used with different input.","type":"conflict_error","code":"idempotency_conflict","param":null}}`}, + {"environment_input_expired", execution.ErrEnvironmentInputExpired, 409, + `{"error":{"message":"The environment input deadline elapsed before admission.","type":"conflict_error","code":"environment_input_expired","param":null}}`}, + } { + t.Run(test.name, func(t *testing.T) { + w, _ := submitResultRequest(t, body, test.err) + if w.Code != test.status || w.Body.String() != test.want+"\n" { + t.Fatal(w.Code, w.Body) + } + }) } } diff --git a/services/agents-api/internal/api/inputs.go b/services/agents-api/internal/api/inputs.go index 8cd708fb8..25e2f82f2 100644 --- a/services/agents-api/internal/api/inputs.go +++ b/services/agents-api/internal/api/inputs.go @@ -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 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. +// @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. Input the Session cannot accept in its current state, such as a result after cancellation or a batch while earlier input is pending, and a result that differs from the call's saved result return 409 with type and code conflict_error; reusing an Idempotency-Key with a different batch returns the local 409 idempotency_conflict. Inside an owned Session, a result for an unknown call or for a call of another Turn returns 400 invalid_request_error and changes nothing; missing and foreign Sessions return 404. 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 @@ -88,7 +88,7 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { return } if _, err := h.inputs.SubmitInputs(r.Context(), tenantID(r), sessionID, key, inputs); err != nil { - writeStoreError(w, r, err) + writeInputError(w, r, err) return } w.Header().Set("Cache-Control", "no-store") diff --git a/services/agents-api/internal/db/queries/functions.sql b/services/agents-api/internal/db/queries/functions.sql index b3d9efa13..81a78b857 100644 --- a/services/agents-api/internal/db/queries/functions.sql +++ b/services/agents-api/internal/db/queries/functions.sql @@ -18,6 +18,11 @@ WHERE f.session_id = $1 AND f.turn_id = $2 AND NOT f.applied AND t.status IN ('in_progress', 'waiting') AND t.cancel_requested_at IS NULL ORDER BY f.created_at, f.call_id; +-- name: SessionHasFunctionCall :one +SELECT EXISTS ( + SELECT 1 FROM function_calls WHERE session_id = sqlc.arg(session_id) AND call_id = sqlc.arg(call_id) +)::boolean AS found; + -- name: MatchFunctionResult :one SELECT (result IS NOT NULL)::boolean AS submitted, COALESCE(result = sqlc.arg(result)::jsonb, false)::boolean AS matches FROM function_calls diff --git a/services/agents-api/internal/db/sqlc/functions.sql.go b/services/agents-api/internal/db/sqlc/functions.sql.go index 377cc0429..4594004cb 100644 --- a/services/agents-api/internal/db/sqlc/functions.sql.go +++ b/services/agents-api/internal/db/sqlc/functions.sql.go @@ -209,6 +209,24 @@ func (q *Queries) MatchFunctionResult(ctx context.Context, arg MatchFunctionResu return i, err } +const sessionHasFunctionCall = `-- name: SessionHasFunctionCall :one +SELECT EXISTS ( + SELECT 1 FROM function_calls WHERE session_id = $1 AND call_id = $2 +)::boolean AS found +` + +type SessionHasFunctionCallParams struct { + SessionID pgtype.UUID `json:"session_id"` + CallID string `json:"call_id"` +} + +func (q *Queries) SessionHasFunctionCall(ctx context.Context, arg SessionHasFunctionCallParams) (bool, error) { + row := q.db.QueryRow(ctx, sessionHasFunctionCall, arg.SessionID, arg.CallID) + var found bool + err := row.Scan(&found) + return found, err +} + const submitFunctionResult = `-- name: SubmitFunctionResult :exec UPDATE function_calls SET result = $4 WHERE session_id = $1 AND turn_id = $2 AND call_id = $3 ` diff --git a/services/agents-api/internal/store/environment_inputs.go b/services/agents-api/internal/store/environment_inputs.go index ec95cfd89..28966e1d1 100644 --- a/services/agents-api/internal/store/environment_inputs.go +++ b/services/agents-api/internal/store/environment_inputs.go @@ -22,6 +22,10 @@ const ( EnvironmentInputFailed = "failed" ) +// ErrSessionInputPending rejects a new input batch while earlier Session input +// still waits for admission. It remains a Turn conflict for internal callers. +var ErrSessionInputPending = fmt.Errorf("%w: session input is still pending", ErrTurnConflict) + // EnvironmentInputReservation is private admission state, not a public Session projection. type EnvironmentInputReservation struct { ID string @@ -278,7 +282,7 @@ func checkEnvironmentInputGate(ctx context.Context, q *sqlc.Queries, session pgt return ErrIdempotencyConflict } if gate.Blocked { - return ErrTurnConflict + return ErrSessionInputPending } return nil } diff --git a/services/agents-api/internal/store/function_calls_test.go b/services/agents-api/internal/store/function_calls_test.go index af64a0977..6249282ee 100644 --- a/services/agents-api/internal/store/function_calls_test.go +++ b/services/agents-api/internal/store/function_calls_test.go @@ -72,7 +72,7 @@ func TestFunctionCallsPersistCompleteResultsAndReceipts(t *testing.T) { if err := reopened.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, id, json.RawMessage(expected)); err != nil { t.Fatal(err) } - if err := reopened.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, id, json.RawMessage(`{"success":false,"error":"changed"}`)); !errors.Is(err, ErrIdempotencyConflict) { + if err := reopened.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, id, json.RawMessage(`{"success":false,"error":"changed"}`)); !errors.Is(err, ErrFunctionResultConflict) { t.Fatal(err) } for range 2 { @@ -233,7 +233,7 @@ func TestFunctionResultConcurrentSubmissionsChooseOneValue(t *testing.T) { for err := range outcomes { if err == nil { winners++ - } else if errors.Is(err, ErrIdempotencyConflict) { + } else if errors.Is(err, ErrFunctionResultConflict) { conflicts++ } else { t.Fatal(err) diff --git a/services/agents-api/internal/store/function_inputs.go b/services/agents-api/internal/store/function_inputs.go index e0e079117..62737f068 100644 --- a/services/agents-api/internal/store/function_inputs.go +++ b/services/agents-api/internal/store/function_inputs.go @@ -6,13 +6,24 @@ import ( "errors" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) +// Result targets are resolved only inside a tenant-owned Session, after its +// lookup, so a missing or foreign Session still returns ErrNotFound (EVT-11). +var ( + // ErrUnknownFunctionCall rejects a result whose call_id names no function + // call in the Session. + ErrUnknownFunctionCall = errors.New("unknown pending tool call") + // ErrFunctionCallTurnMismatch rejects a result whose call exists in the + // Session but not in the named Turn, including a malformed or unknown Turn. + ErrFunctionCallTurnMismatch = errors.New("tool call belongs to a different turn") +) + // FunctionResultInput identifies a persisted call; Result is validated by the API. -// It is an internal command, not an upstream input event. +// It is an internal command, not an upstream input event. TurnID is the caller's +// value and is resolved within the Session at admission. type FunctionResultInput struct { TurnID string `json:"turn_id"` CallID string `json:"call_id"` @@ -21,10 +32,7 @@ type FunctionResultInput struct { func functionInput(raw json.RawMessage) (FunctionResultInput, error) { var input FunctionResultInput - if json.Unmarshal(raw, &input) != nil || !validFunctionIdentity(input.CallID) || len(input.Result) == 0 { - return input, ErrInvalidInput - } - if _, err := uuid.Parse(input.TurnID); err != nil { + if json.Unmarshal(raw, &input) != nil || input.TurnID == "" || !validFunctionIdentity(input.CallID) || len(input.Result) == 0 { return input, ErrInvalidInput } result, err := canonicalJSONObject(input.Result) @@ -40,19 +48,31 @@ func admitFunctionResult(ctx context.Context, q *sqlc.Queries, tenantID string, if err != nil { return InputReceipt{}, err } - lookup, err := turnLookup(tenantID, uuid.UUID(session.Bytes).String(), result.TurnID) + tenant, err := parseID(tenantID) if err != nil { return InputReceipt{}, err } - turn, err := q.GetTurn(ctx, lookup) - if errors.Is(err, pgx.ErrNoRows) { - return InputReceipt{}, ErrNotFound + var turn sqlc.Turn + found := false + // A malformed Turn ID cannot name a Turn; it is classified like an unknown one. + if id, err := parseID(result.TurnID); err == nil { + turn, err = q.GetTurn(ctx, sqlc.GetTurnParams{TenantID: tenant, SessionID: session, ID: id}) + if err == nil { + found = true + } else if !errors.Is(err, pgx.ErrNoRows) { + return InputReceipt{}, err + } } - if err != nil { - return InputReceipt{}, err + if found { + err = storeFunctionResult(ctx, q, turn, result.CallID, result.Result) + if errors.Is(err, ErrNotFound) { + found = false + } else if err != nil { + return InputReceipt{}, err + } } - if err := storeFunctionResult(ctx, q, turn, result.CallID, result.Result); err != nil { - return InputReceipt{}, err + if !found { + return InputReceipt{}, unknownFunctionResultTarget(ctx, q, session, result.CallID) } sequence, err := q.CreateTurnInput(ctx, sqlc.CreateTurnInputParams{ SessionID: session, TurnID: turn.ID, IdempotencyKey: key, Kind: input.Kind, Payload: input.Payload, BatchPosition: position, @@ -62,3 +82,16 @@ func admitFunctionResult(ctx context.Context, q *sqlc.Queries, tenantID string, } return inputReceipt(sequence, turn.ID, false), nil } + +// unknownFunctionResultTarget classifies a result whose Turn does not own the +// call. The answer depends only on this Session's own calls. +func unknownFunctionResultTarget(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, callID string) error { + elsewhere, err := q.SessionHasFunctionCall(ctx, sqlc.SessionHasFunctionCallParams{SessionID: session, CallID: callID}) + if err != nil { + return err + } + if elsewhere { + return ErrFunctionCallTurnMismatch + } + return ErrUnknownFunctionCall +} diff --git a/services/agents-api/internal/store/function_inputs_test.go b/services/agents-api/internal/store/function_inputs_test.go index a81544798..8a97c3ef4 100644 --- a/services/agents-api/internal/store/function_inputs_test.go +++ b/services/agents-api/internal/store/function_inputs_test.go @@ -106,7 +106,7 @@ func TestFunctionInputBatchFailureRollsBackEveryWrite(t *testing.T) { cancel := Input{Kind: "cancel", Payload: json.RawMessage(`{}`)} first := resultInput(t, turn, "a", `{"success":true}`) batch := []Input{message, first, cancel} - expected := ErrNotFound + expected := ErrUnknownFunctionCall switch mode { case "missing-call": batch = append(batch, resultInput(t, turn, "missing", `{"success":true}`)) @@ -125,12 +125,13 @@ func TestFunctionInputBatchFailureRollsBackEveryWrite(t *testing.T) { t.Fatal(err) } batch = append(batch, resultInput(t, otherTurn, "a", `{"success":true}`)) + expected = ErrFunctionCallTurnMismatch case "cancel-first": batch = []Input{message, cancel, first} expected = ErrTurnConflict case "changed-result": batch = append(batch, resultInput(t, turn, "a", `{"success":false}`)) - expected = ErrIdempotencyConflict + expected = ErrFunctionResultConflict } if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "failed-batch", batch); !errors.Is(err, expected) { t.Fatal(err) @@ -175,7 +176,7 @@ func TestFunctionInputConcurrentBatchesSelectOneResult(t *testing.T) { for err := range results { if err == nil { wins++ - } else if errors.Is(err, ErrIdempotencyConflict) { + } else if errors.Is(err, ErrFunctionResultConflict) { conflicts++ } else { t.Fatal(err) @@ -193,20 +194,33 @@ func TestFunctionInputConcurrentBatchesSelectOneResult(t *testing.T) { func TestFunctionInputsRejectInvalidTargetsAndStorageObjects(t *testing.T) { s, _ := testStore(t) tenant, session, turn := functionInputFixture(t, s) - for _, raw := range []string{`{}`, `{"turn_id":"bad","call_id":"a","result":{}}`, fmt.Sprintf(`{"turn_id":%q,"call_id":"a"}`, turn), fmt.Sprintf(`{"turn_id":%q,"call_id":"a","result":null}`, turn), fmt.Sprintf(`{"turn_id":%q,"call_id":"a","result":[]}`, turn)} { + for _, raw := range []string{`{}`, `{"turn_id":"","call_id":"a","result":{}}`, fmt.Sprintf(`{"turn_id":%q,"call_id":" ","result":{}}`, turn), fmt.Sprintf(`{"turn_id":%q,"call_id":"a"}`, turn), fmt.Sprintf(`{"turn_id":%q,"call_id":"a","result":null}`, turn), fmt.Sprintf(`{"turn_id":%q,"call_id":"a","result":[]}`, turn)} { _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "invalid", []Input{{Kind: "tool_result", Payload: json.RawMessage(raw)}}) if !errors.Is(err, ErrInvalidInput) { t.Fatal(err) } } input := resultInput(t, turn, "a", `{"success":true}`) - for _, scope := range []struct{ tenant, session string }{{uuid.NewString(), session.ID}, {tenant, uuid.NewString()}} { - if _, err := s.SubmitInputs(t.Context(), scope.tenant, scope.session, "foreign", []Input{input}); !errors.Is(err, ErrNotFound) { - t.Fatal(err) + // Missing, foreign and malformed Sessions are not found, whatever the target. + for _, scope := range []struct{ tenant, session string }{{uuid.NewString(), session.ID}, {tenant, uuid.NewString()}, {tenant, "sess_malformed"}} { + for _, target := range []Input{input, resultInput(t, "bad", "missing", `{"success":true}`)} { + if _, err := s.SubmitInputs(t.Context(), scope.tenant, scope.session, "foreign", []Input{target}); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } } } - if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "missing-turn", []Input{resultInput(t, uuid.NewString(), "a", `{"success":true}`)}); !errors.Is(err, ErrNotFound) { - t.Fatal(err) + // Inside the owned Session, the call decides the error: a call of another, + // unknown or malformed Turn differs from an unknown call. + for _, target := range []struct { + turn, call string + want error + }{ + {uuid.NewString(), "a", ErrFunctionCallTurnMismatch}, {"bad", "a", ErrFunctionCallTurnMismatch}, + {turn, "missing", ErrUnknownFunctionCall}, {uuid.NewString(), "missing", ErrUnknownFunctionCall}, {"bad", "missing", ErrUnknownFunctionCall}, + } { + if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "target", []Input{resultInput(t, target.turn, target.call, `{"success":true}`)}); !errors.Is(err, target.want) { + t.Fatal(target, err) + } } transition(t, s, tenant, session.ID, turn, TurnWaiting, TurnFailed) if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "late", []Input{input}); !errors.Is(err, ErrTurnConflict) { diff --git a/services/agents-api/internal/store/function_results.go b/services/agents-api/internal/store/function_results.go index d92ac070d..21c5ce7b6 100644 --- a/services/agents-api/internal/store/function_results.go +++ b/services/agents-api/internal/store/function_results.go @@ -10,6 +10,10 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +// ErrFunctionResultConflict rejects a result that differs from the one already +// saved for its call, including after the Turn ended (EVT-12). +var ErrFunctionResultConflict = errors.New("tool call already has a different result") + // SubmitFunctionResult stores a caller-validated result object; its wire schema belongs to the API. func (s *Store) SubmitFunctionResult(ctx context.Context, tenantID, sessionID, turnID, callID string, result json.RawMessage) error { if len(result) == 0 || len(result) > 512*1024 { @@ -77,7 +81,7 @@ func storeFunctionResult(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn, c } if match.Submitted { if !match.Matches { - return ErrIdempotencyConflict + return ErrFunctionResultConflict } return nil } diff --git a/services/agents-api/internal/store/input_conflicts_public_test.go b/services/agents-api/internal/store/input_conflicts_public_test.go new file mode 100644 index 000000000..d48023c58 --- /dev/null +++ b/services/agents-api/internal/store/input_conflicts_public_test.go @@ -0,0 +1,294 @@ +package store_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +const conflictAgent = `"agent":{"id":"agent_conflicts","model":"fixture","tools":[],"multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"}}` + +// Official error bodies for Session input rejections (EVT-11, EVT-12, ERR-22, +// ERR-27). Every param is null and no message repeats caller input. +const ( + missingSessionBody = `{"error":{"message":"Resource not found.","type":"not_found_error","code":"not_found_error","param":null}}` + "\n" + unknownCallBody = `{"error":{"message":"Unknown pending tool call.","type":"invalid_request_error","code":"invalid_request_error","param":null}}` + "\n" + otherTurnBody = `{"error":{"message":"The tool call belongs to a different Turn.","type":"invalid_request_error","code":"invalid_request_error","param":null}}` + "\n" + turnConflictBody = `{"error":{"message":"The Turn cannot accept this input in its current state.","type":"conflict_error","code":"conflict_error","param":null}}` + "\n" + pendingInputBody = `{"error":{"message":"Earlier input to this Session is still pending.","type":"conflict_error","code":"conflict_error","param":null}}` + "\n" + changedResultBody = `{"error":{"message":"The tool call already has a different result.","type":"conflict_error","code":"conflict_error","param":null}}` + "\n" + eventKeyReuseBody = `{"error":{"message":"This idempotency key was used with different input.","type":"conflict_error","code":"idempotency_conflict","param":null}}` + "\n" +) + +// TestSessionInputConflictsAndResultTargetsPostgres replays the Session input +// error rows CF2–CF4 and CF6–CF9 of the official semantics alignment over HTTP +// and PostgreSQL. Inside an owned Session, an unknown call +// or a call of another Turn is a 400 request error; missing, malformed and +// foreign Sessions stay the byte-identical 404. Conflicts use conflict_error, +// and every rejection leaves the database and the pending action unchanged. +func TestSessionInputConflictsAndResultTargetsPostgres(t *testing.T) { + // An isolated database keeps the no-write digest independent of other tests. + s, pool := store.NewManagedTestStore(t) + ctx := t.Context() + tenant, owner, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "conflict-owner", TokenSHA256: device.HashCredential(owner), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "conflict-foreign", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s), api.WithEnvironmentRemoteURL("https://executor.example")) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + client := pathIDClient{t: t, server: server} + + create := func(environment string, initial bool) string { + t.Helper() + input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{` + conflictAgent + `,"environment":` + environment + `}`)} + if initial { + input.InitialInputs = []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"reserved"}`)}} + } + session, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + return session.ID + } + // waiting starts a Turn that waits for one function result. + waiting := func(session, key, call string) store.InputReceipt { + t.Helper() + receipt, err := s.SubmitMessage(ctx, tenant, session, key, json.RawMessage(`{"text":"work"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := s.TransitionTurn(ctx, tenant, session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + if err := s.RecordFunctionCall(ctx, tenant, session, receipt.TurnID, store.FunctionCall{CallID: call, ExecutorCallID: "native-" + call, Name: "lookup", Arguments: json.RawMessage(`{}`)}); err != nil { + t.Fatal(err) + } + return receipt + } + complete := func(session string, receipt store.InputReceipt, call string) { + t.Helper() + if err := s.ConfirmFunctionResult(ctx, tenant, session, receipt.TurnID, call); err != nil { + t.Fatal(err) + } + if _, err := s.CompleteExecution(ctx, tenant, session, receipt.TurnID, store.TurnCompleted, nil, "", receipt.Sequence); err != nil { + t.Fatal(err) + } + } + result := func(turn, call, output string) string { + return `{"type":"agent.session.input.tool_result","turn_id":"` + turn + `","call_id":"` + call + `","success":true,"output":"` + output + `"}` + } + message := `{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"must roll back"}]}]}` + submit := func(token, session, key string, events ...string) (int, string) { + t.Helper() + request, err := http.NewRequest(http.MethodPost, server.URL+"/v1/agents/sessions/"+session+"/events", strings.NewReader(`{"events":[`+strings.Join(events, ",")+`]}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Idempotency-Key", key) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, string(raw) + } + read := func(session string) string { + t.Helper() + status, raw := client.do(owner, http.MethodGet, "/v1/agents/sessions/"+session, "", nil) + if status != http.StatusOK { + t.Fatal(status, raw) + } + return raw + } + // A rejection uses its own new Idempotency-Key unless key names one. + type rejection struct { + name, token, session string + events []string + status int + body, key string + } + // reject checks each response body and that no rejection wrote anything. + reject := func(cases []rejection, watched ...string) { + t.Helper() + digest := databaseDigest(t, pool) + before := make([]string, len(watched)) + for i, session := range watched { + before[i] = read(session) + } + for _, tc := range cases { + key := tc.key + if key == "" { + key = "rejected-" + tc.name + } + if status, body := submit(tc.token, tc.session, key, tc.events...); status != tc.status || body != tc.body { + t.Errorf("%s: %d %s", tc.name, status, body) + } + } + if after := databaseDigest(t, pool); !reflect.DeepEqual(after, digest) { + t.Error("rejected input changed the database") + } + for i, session := range watched { + if after := read(session); after != before[i] { + t.Errorf("rejected input changed Session %s: %s != %s", session, before[i], after) + } + } + } + + none := `{"type":"none"}` + session := create(none, false) + first := waiting(session, "first", "first-call") + if err := s.SubmitFunctionResult(ctx, tenant, session, first.TurnID, "first-call", json.RawMessage(`{"success":true,"output":"one"}`)); err != nil { + t.Fatal(err) + } + complete(session, first, "first-call") + current := waiting(session, "second", "pending-call") + other := create(none, false) + otherTurn := waiting(other, "other", "other-call").TurnID + var pending struct { + Status string `json:"status"` + RequiredActions []struct { + CallID string `json:"call_id"` + TurnID string `json:"turn_id"` + } `json:"required_actions"` + } + if err := json.Unmarshal([]byte(read(session)), &pending); err != nil || pending.Status != "requires_action" || + len(pending.RequiredActions) != 1 || pending.RequiredActions[0].CallID != "pending-call" || pending.RequiredActions[0].TurnID != current.TurnID { + t.Fatal("fixture pending action", pending, err) + } + + // CF6–CF8: result targets in owned, other owned, foreign and missing Sessions. + valid := result(current.TurnID, "pending-call", "value") + reject([]rejection{ + {"unknown-call", owner, session, []string{result(current.TurnID, "absent-call", "x")}, 400, unknownCallBody, ""}, + {"unknown-call-any-turn", owner, session, []string{result(uuid.NewString(), "absent-call", "x")}, 400, unknownCallBody, ""}, + {"unknown-call-malformed-turn", owner, session, []string{result("turn_malformed", "absent-call", "x")}, 400, unknownCallBody, ""}, + {"call-of-other-session", owner, other, []string{valid}, 400, unknownCallBody, ""}, + {"rollback-batch", owner, session, []string{message, valid, result(current.TurnID, "absent-call", "x")}, 400, unknownCallBody, ""}, + {"completed-turn", owner, session, []string{result(first.TurnID, "pending-call", "x")}, 400, otherTurnBody, ""}, + {"call-of-completed-turn", owner, session, []string{result(current.TurnID, "first-call", "one")}, 400, otherTurnBody, ""}, + {"unknown-turn", owner, session, []string{result(uuid.NewString(), "pending-call", "x")}, 400, otherTurnBody, ""}, + {"malformed-turn", owner, session, []string{message, result("turn_malformed", "pending-call", "x")}, 400, otherTurnBody, ""}, + {"other-session-turn", owner, session, []string{result(otherTurn, "pending-call", "x")}, 400, otherTurnBody, ""}, + {"foreign", foreign, session, []string{valid}, 404, missingSessionBody, ""}, + {"foreign-unknown-call", foreign, session, []string{result(current.TurnID, "absent-call", "x")}, 404, missingSessionBody, ""}, + {"foreign-malformed-turn", foreign, session, []string{result("turn_malformed", "pending-call", "x")}, 404, missingSessionBody, ""}, + {"foreign-cancel", foreign, session, []string{`{"type":"agent.session.input.cancel"}`}, 404, missingSessionBody, ""}, + {"missing", owner, uuid.NewString(), []string{valid}, 404, missingSessionBody, ""}, + {"malformed-session", owner, "sess_malformed", []string{result("turn_malformed", "absent-call", "x")}, 404, missingSessionBody, ""}, + }, session, other) + + // CF2: input while earlier Session input waits for admission. + awaiting := create(`{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[]}`, true) + reject([]rejection{ + {"pending-cancel", owner, awaiting, []string{`{"type":"agent.session.input.cancel"}`}, 409, pendingInputBody, ""}, + {"pending-message", owner, awaiting, []string{message}, 409, pendingInputBody, ""}, + {"pending-result", owner, awaiting, []string{valid}, 409, pendingInputBody, ""}, + {"pending-foreign", foreign, awaiting, []string{`{"type":"agent.session.input.cancel"}`}, 404, missingSessionBody, ""}, + }, awaiting) + + // CF9: the result and its identical retries are accepted. + for _, key := range []string{"result", "result", "same-result"} { + if status, body := submit(owner, session, key, valid); status != http.StatusAccepted || body != "" { + t.Fatalf("%s: %d %s", key, status, body) + } + } + // CF3 and CF4: a changed result conflicts; reusing a key with another batch + // keeps Core's local Idempotency-Key code. + changed := result(current.TurnID, "pending-call", "changed") + reject([]rejection{ + {"changed-result", owner, session, []string{changed}, 409, changedResultBody, ""}, + {"changed-result-batch", owner, session, []string{message, changed}, 409, changedResultBody, ""}, + {"key-reuse", owner, session, []string{changed}, 409, eventKeyReuseBody, "result"}, + {"changed-foreign", foreign, session, []string{changed}, 404, missingSessionBody, ""}, + }, session) + complete(session, current, "pending-call") + reject([]rejection{ + {"changed-after-completion", owner, session, []string{changed}, 409, changedResultBody, ""}, + {"changed-after-completion-foreign", foreign, session, []string{changed}, 404, missingSessionBody, ""}, + }, session) + if status, body := submit(owner, session, "same-after-completion", valid); status != http.StatusAccepted || body != "" { + t.Fatalf("identical result after completion: %d %s", status, body) + } + if call, err := s.GetFunctionCall(ctx, tenant, session, current.TurnID, "pending-call"); err != nil || !bytes.Contains(call.Result, []byte(`"value"`)) { + t.Fatal("saved result changed", call, err) + } + + // CF2: a result after its Turn was cancelled. + cancelled := waiting(session, "third", "late-call") + if status, body := submit(owner, session, "cancel", `{"type":"agent.session.input.cancel"}`); status != http.StatusAccepted { + t.Fatalf("cancel: %d %s", status, body) + } + late := result(cancelled.TurnID, "late-call", "late") + reject([]rejection{ + {"after-cancel", owner, session, []string{late}, 409, turnConflictBody, ""}, + {"after-cancel-foreign", foreign, session, []string{late}, 404, missingSessionBody, ""}, + }, session) + if call, err := s.GetFunctionCall(ctx, tenant, session, cancelled.TurnID, "late-call"); err != nil || call.Result != nil { + t.Fatal("late result was saved", call, err) + } + + // CF4: Session creation key reuse keeps its local code with the conflict type. + createSession := func(input string) (int, string) { + t.Helper() + request, err := http.NewRequest(http.MethodPost, server.URL+"/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"conflict-model"},"environment":{"type":"none"},"input":"`+input+`"}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer "+owner) + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Idempotency-Key", "creation") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, _ := io.ReadAll(response.Body) + return response.StatusCode, string(raw) + } + if status, body := createSession("first"); status != http.StatusCreated { + t.Fatal(status, body) + } + digest := databaseDigest(t, pool) + status, body := createSession("changed") + var creation struct { + Error struct { + Type string `json:"type"` + Code string `json:"code"` + Param *string `json:"param"` + } `json:"error"` + } + if status != http.StatusConflict || json.Unmarshal([]byte(body), &creation) != nil || + creation.Error.Type != "conflict_error" || creation.Error.Code != "idempotency_conflict" || creation.Error.Param != nil { + t.Fatalf("creation key reuse: %d %s", status, body) + } + if after := databaseDigest(t, pool); !reflect.DeepEqual(after, digest) { + t.Error("creation key reuse changed the database") + } +} diff --git a/services/agents-api/tests/official_function_inputs.py b/services/agents-api/tests/official_function_inputs.py index 33aa9e721..0cb520648 100644 --- a/services/agents-api/tests/official_function_inputs.py +++ b/services/agents-api/tests/official_function_inputs.py @@ -4,6 +4,7 @@ import json from pathlib import Path import sys +import uuid import httpx2 from openai import APIStatusError, OpenAI @@ -13,20 +14,32 @@ source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json") or "{}") assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] +# Expected (type, code, message) by status; None skips the message check. +MISSING = ("not_found_error", "not_found_error", "Resource not found.") +UNKNOWN_CALL = ("invalid_request_error", "invalid_request_error", "Unknown pending tool call.") +OTHER_TURN = ("invalid_request_error", "invalid_request_error", "The tool call belongs to a different Turn.") +INVALID = ("invalid_request_error", "invalid_request", None) +KEY_REUSE = ("conflict_error", "idempotency_conflict", None) +TURN_CONFLICT = ("conflict_error", "conflict_error", "The Turn cannot accept this input in its current state.") +CHANGED = ("conflict_error", "conflict_error", "The tool call already has a different result.") -def result(call, **values): - return {"type": "agent.session.input.tool_result", "turn_id": turn, "call_id": call, **values} +def result(call, target_turn=None, **values): + return {"type": "agent.session.input.tool_result", "turn_id": target_turn or turn, "call_id": call, **values} -def submit(api, events, key, expected=202, target=session): + +def submit(api, events, key, expected=202, error=None, 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 == 202 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_error", "turn_conflict", "idempotency_conflict", "invalid_request"} + except APIStatusError as failure: + assert failure.status_code == expected, (failure.status_code, expected, failure.message) + kind, code, message = error + body = failure.body + assert (body["type"], body["code"], body["param"]) == (kind, code, None), body + assert message is None or body["message"] == message, body with OpenAI(api_key=token, base_url=base+"/v1", max_retries=0, @@ -34,16 +47,21 @@ def submit(api, events, key, expected=202, target=session): output = [{"type":"input_text","text":""}, {"type":"input_image","image_url":"data:image/png;base64,AA=="}, {"type":"input_text","text":"last"}] message = {"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"follow up"}]}]} batch = [result("a", success=False, error="failure", output=output), result("b", success=True, output=None, error=None), result("c", success=True), message, {"type":"agent.session.input.cancel"}] - submit(api, [message, result("rollback", success=True), result("missing", success=True)], "rollback", 404) - submit(api, [result("a", success=True)], "other-session", 404, other) - submit(api, [message, result("rollback", success=None)], "malformed", 400) + submit(api, [message, result("rollback", success=True), result("missing", success=True)], "rollback", 400, UNKNOWN_CALL) + submit(api, [result("a", str(uuid.uuid4()), success=True)], "other-turn", 400, OTHER_TURN) + submit(api, [message, result("rollback", "turn_malformed", success=True)], "malformed-turn", 400, OTHER_TURN) + submit(api, [result("a", success=True)], "other-session", 400, UNKNOWN_CALL, other) + submit(api, [message, result("rollback", success=None)], "malformed", 400, INVALID) stranger = api.with_options(api_key=foreign) - submit(stranger, [result("a", success=True)], "foreign", 404) + submit(stranger, [result("a", success=True)], "foreign", 404, MISSING) + submit(stranger, [result("missing", "turn_malformed", success=True)], "foreign-malformed", 404, MISSING) + submit(api, [result("a", success=True)], "missing-session", 404, MISSING, str(uuid.uuid4())) submit(api, batch, "batch") submit(api, batch, "batch") print("saved", flush=True) assert sys.stdin.readline() == "terminal\n" submit(api, batch, "batch") - submit(api, list(reversed(batch)), "batch", 409) - submit(api, [result("late", success=True)], "late", 409) - submit(api, [result("b", success=True)], "changed-null", 409) + submit(api, list(reversed(batch)), "batch", 409, KEY_REUSE) + submit(api, [result("late", success=True)], "late", 409, TURN_CONFLICT) + submit(api, [result("b", success=True)], "changed-null", 409, CHANGED) + submit(api, [result("b", success=True, output=None, error=None)], "same-after-terminal") diff --git a/services/agents-api/tests/official_pending_actions_native.py b/services/agents-api/tests/official_pending_actions_native.py index e6eb54700..017801329 100644 --- a/services/agents-api/tests/official_pending_actions_native.py +++ b/services/agents-api/tests/official_pending_actions_native.py @@ -40,13 +40,20 @@ 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=202, auth=None): + # Expected (type, code) of each public rejection; every error param is null. + missing = ("not_found_error", "not_found_error") + bad_target = ("invalid_request_error", "invalid_request_error") + conflict = ("conflict_error", "conflict_error") + key_reuse = ("conflict_error", "idempotency_conflict") + + def submit(sid, event, key, expected=202, auth=None, error=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 == 202: assert response.content == b"" else: - assert response.json()["error"]["code"] in {"not_found_error", "turn_conflict", "idempotency_conflict"} + body = response.json()["error"] + assert (body["type"], body["code"], body["param"]) == (*error, None), (key, body) return {"request": key, "status": response.status_code} try: @@ -92,13 +99,16 @@ def pending_unchanged(): assert sessions.retrieve(other.id).status == "idle" assert sessions.turns.list(other.id).data == [] and items(other.id) == [] - for label, target, value, auth in ( - ("wrong-tenant", session.id, event, foreign_headers), - ("wrong-session", other.id, event, headers), - ("wrong-turn", session.id, {**event, "turn_id": proof["rounds"][index - 1]["recovered_turn"]["id"] if index else str(uuid.uuid4())}, headers), - ("wrong-call", session.id, {**event, "call_id": "absent-" + uuid.uuid4().hex}, headers), + # A foreign Session is not found; inside an owned Session an unknown + # call or a call of another Turn is a request error. + for label, target, value, auth, status, error in ( + ("wrong-tenant", session.id, event, foreign_headers, 404, missing), + ("wrong-session", other.id, event, headers, 400, bad_target), + ("wrong-turn", session.id, {**event, "turn_id": proof["rounds"][index - 1]["recovered_turn"]["id"] if index else str(uuid.uuid4())}, headers, 400, bad_target), + ("malformed-turn", session.id, {**event, "turn_id": "turn_" + uuid.uuid4().hex}, headers, 400, bad_target), + ("wrong-call", session.id, {**event, "call_id": "absent-" + uuid.uuid4().hex}, headers, 400, bad_target), ): - current["refusals"].append(submit(target, value, label + "-" + mode, 404, auth)) + current["refusals"].append(submit(target, value, label + "-" + mode, status, auth, error)) pending_unchanged() for path in ("/" + session.id, turn_path, "/" + session.id + "/items"): assert http.get(endpoint + path, headers=foreign_headers).status_code == 404 @@ -112,8 +122,8 @@ def pending_unchanged(): sessions.events.create(session.id, events=[submitted], idempotency_key=key) current["retry"] = submit(session.id, submitted, key) if mode != "cancel": - current["conflict"] = submit(session.id, {**event, "output": "changed"}, key, 409) - current["new_identity_conflict"] = submit(session.id, {**event, "output": "changed"}, key + "-changed", 409) + current["conflict"] = submit(session.id, {**event, "output": "changed"}, key, 409, error=key_reuse) + current["new_identity_conflict"] = submit(session.id, {**event, "output": "changed"}, key + "-changed", 409, error=conflict) for received in stream: current["after_reconnect"].append(received.to_dict()) if received.type == "agent.session.idle": @@ -135,7 +145,7 @@ def pending_unchanged(): assert terminal_events[0]["type"] == "agent.session.turn." + terminal.status current["terminal_retry"] = submit(session.id, submitted, key) if mode == "cancel": - current["late_result"] = submit(session.id, event, key + "-late-result", 409) + current["late_result"] = submit(session.id, event, key + "-late-result", 409, error=conflict) else: current["same_result_new_identity"] = submit(session.id, event, key + "-same-result") diff --git a/services/agents-api/tests/official_self_hosted_cancel.py b/services/agents-api/tests/official_self_hosted_cancel.py index 8f7c92a87..d5c000a1e 100644 --- a/services/agents-api/tests/official_self_hosted_cancel.py +++ b/services/agents-api/tests/official_self_hosted_cancel.py @@ -121,8 +121,13 @@ def current(session_id): raw_submit(session_id, str(uuid.uuid4()), events, 400) missing_function = [{"type": "agent.session.input.tool_result", "turn_id": turn_id, "call_id": "unknown-function", "success": True, "output": "not admitted"}] - sdk_submit(session_id, str(uuid.uuid4()), missing_function, 404) - raw_submit(session_id, str(uuid.uuid4()), missing_function, 404) + # An unknown call in the caller's own Session is a request error. + sdk_submit(session_id, str(uuid.uuid4()), missing_function, 400) + raw_submit(session_id, str(uuid.uuid4()), missing_function, 400) + unknown = raw.post(endpoint + session_id + "/events", json={"events": missing_function}, + headers={"Idempotency-Key": str(uuid.uuid4()), "Authorization": "Bearer " + token}) + assert unknown.json() == {"error": {"type": "invalid_request_error", "code": "invalid_request_error", + "param": None, "message": "Unknown pending tool call."}} raw_submit(session_id, "foreign-cancel", expected=404, key_token=settings["foreign_token"]) for missing in (result["deleted_id"], str(uuid.uuid4())): sdk_submit(missing, "missing-cancel", expected=404) diff --git a/services/agents-api/tests/official_session_creators.py b/services/agents-api/tests/official_session_creators.py index 47c1dc65f..07b89f661 100644 --- a/services/agents-api/tests/official_session_creators.py +++ b/services/agents-api/tests/official_session_creators.py @@ -40,6 +40,7 @@ def check_retries(request, key, current): assert response.headers["content-type"].split(";")[0] == "application/json" response.read() assert response.json()["error"]["code"] == "idempotency_conflict" + assert response.json()["error"]["type"] == "conflict_error" assert_no_creator_fields(response.json()["error"]) response = raw.post(endpoint, headers=auth | key, json=request) assert response.status_code == 201 and response.json() == current.to_dict()