diff --git a/.github/workflows/agent-session-contracts.yml b/.github/workflows/agent-session-contracts.yml new file mode 100644 index 0000000..16dbbd7 --- /dev/null +++ b/.github/workflows/agent-session-contracts.yml @@ -0,0 +1,42 @@ +name: Agent Session contracts + +on: + pull_request: + paths: + - 'src/xgen_sdk/agent_session/**' + - 'tests/test_agent_session_*.py' + - 'tests/fixtures/agent-session-*.json' + - '.github/workflows/agent-session-contracts.yml' + - 'pyproject.toml' + push: + branches: + - 'feat/cross-platform-*' + paths: + - 'src/xgen_sdk/agent_session/**' + - 'tests/test_agent_session_*.py' + - 'tests/fixtures/agent-session-*.json' + - '.github/workflows/agent-session-contracts.yml' + - 'pyproject.toml' + +permissions: + contents: read + +jobs: + contracts: + name: Contracts (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install contract test dependencies + run: python -m pip install 'pydantic>=2,<3' pytest + - name: Verify isolated unreleased source contracts + env: + PYTHONPATH: src + # Approval registry fixtures are unrelated to these pure wire models. + run: python -m pytest --noconftest -q tests/test_agent_session_*.py diff --git a/docs/agent-session-broker-operations.md b/docs/agent-session-broker-operations.md new file mode 100644 index 0000000..5ac1199 --- /dev/null +++ b/docs/agent-session-broker-operations.md @@ -0,0 +1,68 @@ +# Agent Session broker 작업 클라이언트 / Agent Session broker operation clients + +## 범위 / Boundary + +`AgentSessionBrokerOperationsClient`는 소유한 Agent Session 턴에 연결된 broker 작업의 공개 참조를 나열하고, 한 작업의 공개 metadata를 읽고, 취소를 접수하는 순수 SDK 클라이언트다. 호출자가 인증된 JSON GET과 빈 본문 JSON POST callback을 주입한다. SDK는 credential, base URL, 환경변수, HTTP 인증, retry, polling, 실행, reconcile 또는 수명 연장을 소유하지 않는다. + +`AgentSessionBrokerOperationsClient` is a pure SDK client for listing public broker-operation references attached to an owned Agent Session turn, reading public metadata for one operation, and requesting cancellation. The caller injects authenticated JSON GET and empty-body JSON POST callbacks. The SDK owns no credentials, base URL, environment variables, HTTP authentication, retry, polling, execution, reconciliation, or lifetime extension. + +## 고정 API / Fixed API + +| SDK method | HTTP contract | Public response | +|---|---|---| +| `list(session_id, turn_id)` | `GET /api/agentflow/agent-sessions/{session_id}/turns/{turn_id}/operations` | bare array, maximum 128 exact references | +| `read(session_id, turn_id, reference)` | `GET .../operations/{invocation_id}` | exact seven-field metadata | +| `cancel(session_id, turn_id, reference)` | `POST .../operations/{invocation_id}/cancel`, empty body, HTTP 202 | exact cancellation acknowledgement | + +모든 경로는 query가 없다. callback은 `get(path)`와 `post(path)`처럼 경로 인자 하나만 받는다. 특히 `post`에 JSON, 빈 객체, `None`, header 또는 query를 전달하지 않는다. 호출자 transport는 현재 Platform Session/DPoP, HTTPS origin, 응답 크기와 timeout, HTTP status를 처리하고 성공 JSON만 반환해야 한다. SDK는 callback을 한 번만 호출하며 transport 예외와 task cancellation을 그대로 전달한다. + +Every path is queryless. Each callback accepts one path argument as `get(path)` or `post(path)`. In particular, the SDK passes no JSON, empty object, `None`, headers, or query to `post`. The caller transport handles the current Platform Session/DPoP, HTTPS origin, response size and timeout, and HTTP status, then returns successful JSON. The SDK calls the callback once and propagates transport errors and task cancellation unchanged. + +```python +from xgen_sdk.agent_session import AgentSessionBrokerOperationsClient + +# Both callbacks authenticate with the caller's current session. +# authenticated_empty_json_post(path) sends an HTTP request with zero body bytes. +client = AgentSessionBrokerOperationsClient( + authenticated_json_get, + authenticated_empty_json_post, +) + +references = await client.list(session_id, turn_id) +if references: + metadata = await client.read(session_id, turn_id, references[0]) + accepted = await client.cancel(session_id, turn_id, references[0]) +``` + +## 공개 DTO와 검증 / Public DTOs and validation + +세션, 턴, invocation ID는 nonzero UUID를 lower canonical 문자열로 직렬화했을 때 입력과 정확히 같아야 한다. SDK는 세션과 턴 ID를 transport 호출 전에 검사한다. 참조 목록은 정확한 기본 JSON 배열이어야 하며 최대 128개이고 invocation ID가 고유해야 한다. 반환한 Python tuple과 모든 DTO는 불변이다. 추가 필드, coercion, model subclass, `model_construct`/`model_copy` 우회 값은 parser에서 다시 검사한다. + +Session, turn, and invocation identifiers must be nonzero UUIDs whose lower canonical string exactly equals the input. The SDK validates session and turn identifiers before transport. A reference list must be an exact plain JSON array with at most 128 entries and unique invocation identifiers. The returned Python tuple and every DTO are immutable. Parsers revalidate extra fields, coercion, model subclasses, and values forged through `model_construct` or `model_copy`. + +참조는 정확히 `invocation_id`, `operation_id`만 가진다. 허용 operation은 다음 여섯 개다. + +A reference contains exactly `invocation_id` and `operation_id`. The operation allowlist is: + +- `documents.collections.list` +- `documents.collection.documents.list` +- `workflow.actor.authorize` +- `documents.attachment.index` +- `workflow.code.execute` +- `workflow.attachments.stage` + +Metadata는 정확히 `invocation_id`, `operation_id`, `state`, `cancel_requested`, `target_cleanup`, `result_available`, `expires_at`만 가진다. `read`는 응답의 invocation과 operation을 모두 호출자가 보존한 expected reference에 묶는다. `state`는 `running`, `completed`, `failed`, `cancelled`, `unknown` 중 하나다. `expires_at`은 1 이상 JavaScript safe integer 이하의 strict integer이며 bool을 거부한다. + +Metadata contains exactly `invocation_id`, `operation_id`, `state`, `cancel_requested`, `target_cleanup`, `result_available`, and `expires_at`. `read` binds both response invocation and operation to the caller-retained expected reference. `state` is one of `running`, `completed`, `failed`, `cancelled`, or `unknown`. `expires_at` is a strict integer from 1 through the JavaScript safe-integer maximum; booleans are rejected. + +`workflow.code.execute`와 `workflow.attachments.stage`만 Sandbox 작업이다. Sandbox의 `target_cleanup`은 `pending`, `complete`, `unknown` 중 하나이고 `not_applicable`은 금지된다. 다른 네 operation은 `not_applicable`이어야 한다. `result_available=true`는 Sandbox 작업이 `completed`일 때만 허용한다. 공개 metadata에는 결과 본문이 없으며 `result`, grant, lease, 입력 body 같은 private 필드를 거부한다. + +Only `workflow.code.execute` and `workflow.attachments.stage` are Sandbox operations. Their `target_cleanup` is `pending`, `complete`, or `unknown`; `not_applicable` is forbidden. The other four operations require `not_applicable`. `result_available=true` is allowed only for a completed Sandbox operation. Public metadata contains no result body and rejects private fields such as `result`, grants, leases, or input bodies. + +취소 응답은 정확히 `invocation_id`와 `cancel_requested: true`만 가진다. 이것은 HTTP 202 접수 증거다. 최종 state, process 종료, stage 삭제 또는 `target_cleanup=complete`를 뜻하지 않는다. 정리 상태는 이후 명시적인 `read` 응답에서만 판단한다. + +A cancellation response contains exactly `invocation_id` and `cancel_requested: true`. This is HTTP 202 acceptance evidence. It does not mean a final state, process termination, stage deletion, or `target_cleanup=complete`. Cleanup is interpreted only from a later explicit `read` response. + +검증 실패는 원시 payload와 exception context를 노출하지 않는 고정 `AgentSessionBrokerOperationValidationError`다. 공개 fixture `tests/fixtures/agent-session-broker-operations.json`은 SDK와 Frontend가 같은 유효·변조 벡터를 사용하도록 하며 credential, grant, lease 또는 실제 결과를 포함하지 않는다. + +Validation failures use a fixed `AgentSessionBrokerOperationValidationError` without raw payload or exception context. The public fixture at `tests/fixtures/agent-session-broker-operations.json` lets SDK and Frontend consume the same valid and tampered vectors and contains no credential, grant, lease, or real result. diff --git a/docs/canonical-attachment-reference-contract.md b/docs/canonical-attachment-reference-contract.md new file mode 100644 index 0000000..985f1d0 --- /dev/null +++ b/docs/canonical-attachment-reference-contract.md @@ -0,0 +1,57 @@ +# Canonical 첨부 참조 계약 / Canonical attachment reference contract + +## 현재 구현 범위 / Current boundary + +이 계약은 Canonical 업로드·턴 연결을 위한 **메타데이터 검증 기반**이다. 아직 업로드 API, 저장소, 저널 연결, 다운로드 또는 첨부 UI를 제공하지 않는다. 현재 Canonical 턴 API는 텍스트 전용이며 `attachments`를 거부한다. Legacy workspace 업로드를 자동으로 대신 호출하지 않는다. + +This is the metadata validation foundation for future Canonical upload and turn linking. Upload/storage/journal/download/UI integration is not implemented. Current Canonical turn requests remain text-only and reject `attachments`; no legacy workspace fallback is enabled. + +## 데이터 / Data + +Scope는 검증된 로그인과 선택 세션에서 얻는다. Receipt는 향후 서버가 저장을 완료한 첨부의 응답 형식이다. 이를 파싱했다는 사실은 발급·소유권·접근권한·실제 bytes의 checksum을 검증했다는 뜻이 아니다. 서버는 Gateway가 검증한 principal로 저장된 첨부의 계정·세션·workflow와 실제 checksum을 다시 검사해야 한다. + +Scope comes from the authenticated login and selected session. A receipt describes a future server-committed attachment. Shape validation cannot prove issuance, ownership, access or the checksum of stored bytes. The server must independently verify these against the Gateway-verified principal and stored attachment before execution. + +| 필드 / Field | 타입 / Type | 제약과 용도 / Constraint and use | +|---|---|---| +| `origin` | string | 정규 HTTPS origin, 경로·사용자정보·query·fragment 없음. 소문자 ASCII DNS (`xn--` IDN 제외), canonical IPv4/IPv6; 포트 1..65535, 기본 443 생략. / Canonical HTTPS origin; ASCII DNS excluding IDN A-labels, or canonical IP; no paths, credentials, query or fragment. | +| `user_id` | string | `1`..`9223372036854775807`의 정규 십진 문자열. 앞의 0·숫자 coercion 없음. / Positive canonical decimal string, no numeric coercion. | +| `session_id` | string | 서버 발급 Canonical 세션의 소문자 UUID, version 1..8, RFC variant. / Lowercase canonical session UUID. | +| `workflow_id` | string | 원문 Unicode / Raw Unicode, UTF-8 1..128 bytes, slash/backslash·C0/C1·방향 제어 문자 없음. / Bounded Unicode identifier; no paths or control characters. | +| `attachment_id` | string | 서버가 발급할 opaque 소문자 UUID, version 1..8, RFC variant. 클라이언트가 저장 경로를 선택하지 않는다. / Opaque server-issued UUID; no client storage path. | +| `filename` | string | 원문 Unicode / Raw Unicode basename, UTF-8 1..255 bytes; slash/backslash·C0/C1·방향 제어 문자와 `.`/`..` 금지. 저장 경로로 사용하지 않는다. / Display basename, never a storage path. | +| `size_bytes` | integer | 0..104857600, bool·문자열·소수 coercion 없음. / Strict integer, 0..100 MiB. | +| `media_type` | string | 소문자 `type/subtype` 토큰, 최대255 ASCII bytes, parameter 없음. 표시 메타데이터이며 이미지 검증 결과가 아니다. / Inert MIME hint, not verified file content. | +| `sha256` | string | 소문자 64자리 hex. 실제 파일의 서버 checksum과 일치해야 한다. / Lowercase 64-character hex; must match stored server checksum. | + +- Scope에는 첫 4개 필드만 있고 receipt에는 표의 9개 필드만 있다. 추가 필드는 거부한다. +- 미래 턴 참조에는 `attachment_id`, `sha256`만 있다. 경로·URL·base64·bytes·권한/신뢰 주장·표시 힌트를 전달하지 않는다. +- 최대10개, 파일당100 MiB, 합계100 MiB이다. 중복 ID는 checksum이 같아도 거부하며 순서는 유지한다. 이 상한은 현재 서버의 허용 업로드 정책을 선언하지 않는다. 실제 업로드 및 이미지 sniffing/decoder 제한은 서버 구현에서 별도로 적용해야 한다. +- TypeScript는 새 primitive-only 객체와 배열을 freeze한다. Python은 frozen 모델과 tuple을 반환한다. 원본 receipt 목록을 수정해도 준비된 참조는 바뀌지 않는다. +- 계정·origin·세션·workflow가 바뀌면 draft와 준비된 참조를 폐기한다. 응답 유실 후 재시도는 원래 참조의 **순서·ID·checksum**을 유지해야 하며, 후속 턴 저널 구현이 이를 idempotency hash에 포함해야 한다. + +IDN A-labels are reserved until both languages share an IDNA normalization contract. Scopes accept only their four fields; receipts accept only the nine fields above. Future turn references contain only `attachment_id` and `sha256`. Up to ten ordered, unique IDs are accepted, with 100 MiB per-file and aggregate caps. These local caps do not authorize uploads; server content sniffing, image decoder and storage policies remain separate. Results are copied and frozen (TypeScript objects/array, Python models/tuple). Scope changes must discard prepared drafts. Future journal hashing and explicit unknown-outcome retries must preserve the exact ordered ID/checksum list. + +Unicode 원문은 정규화하지 않고 code point와 UTF-8 상한을 그대로 보존한다. 서버에서 얻은 workflow와 정확한 문자열로 비교하므로 NFC/NFD 표현이 다르면 scope도 다르다. 런타임별 Unicode 정규화 테이블에 의존하지 않는다. / Unicode metadata is preserved verbatim without normalization; distinct NFC/NFD workflow strings remain distinct scope values. + +## 제공 함수 / Helpers + +| DEX protocol | Python SDK | 동작 / Behavior | +|---|---|---| +| `parseAgentAttachmentScope(value)` | `parse_attachment_scope(value)` | Scope를 엄격히 검사하고 복사한다. / Validate and copy context. | +| `parseAgentAttachmentReceipt(value, scope)` | `parse_attachment_receipt(value, scope)` | 전체 구조·범위·scope 동등성을 검사한다. / Validate receipt and exact scope match. | +| `prepareAgentAttachmentReferences(receipts, scope)` | `prepare_attachment_references(receipts, scope)` | 중복·개수·합계 제한을 검사하고 불변 참조 목록을 만든다. / Prepare immutable ordered references. | + +Python 모델은 `AgentAttachmentScope`, `AgentAttachmentReceipt`, `AgentAttachmentReference`이다. TypeScript는 같은 이름의 interface를 제공한다. helper 실패는 원시 입력을 포함하지 않는 `AgentAttachmentValidationError`를 반환한다. 현재 endpoint는 이 모델을 수용하지 않으며 SDK는 네트워크 요청을 수행하지 않는다. + +Both implementations expose the same named models/interfaces. Helper failures produce a generic `AgentAttachmentValidationError` without raw input. The current endpoint does not accept these models and the SDK performs no network requests. + +## 다음 연결 순서 / Next integration steps + +1. 서버 소유권·durable storage·checksum을 검증하는 Canonical 업로드와 정리/보존 정책. +2. 첨부 참조를 정확한 순서로 턴 request hash와 저널에 묶고 원자적으로 예약하는 계약. +3. 검증된 내부 실행 전달 및 안전한 다운로드/완료 메시지 projection. +4. Native host의 동일 인증 lock·scope 폐기·명시적 원본 재시도와 각 클라이언트 UI. +5. 실제 ACTIVE Gateway와 pod 재시작·계정 변경·cross-surface 파일 송신 검증. + +Implement server-owned durable upload and retention first, then ordered journal/hash binding, verified execution/download projection, native/UI integration and actual ACTIVE cross-surface/pod recovery checks. The pure contract tests do not replace these gates. diff --git a/docs/canonical-turn-submission-contract.md b/docs/canonical-turn-submission-contract.md new file mode 100644 index 0000000..433a8b9 --- /dev/null +++ b/docs/canonical-turn-submission-contract.md @@ -0,0 +1,35 @@ +# Canonical 턴 제출 계약 / Canonical turn submission contract + +## 범위 / Boundary + +`SubmitAgentSessionTurn`은 Canonical Agent Session에 턴을 제출할 때 쓰는 순수 SDK wire DTO다. 이 모델과 helper는 요청 모양만 검사한다. 호출자를 인증하거나, 파일을 업로드하거나, receipt/HMAC을 검증하거나, 첨부의 서버 소유권·저장 상태·checksum을 증명하지 않는다. + +`SubmitAgentSessionTurn` is the pure SDK wire DTO for a canonical Agent Session turn submission. The model and helpers validate request shape only. They do not authenticate a caller, upload content, verify a receipt or HMAC, or prove server ownership, durable storage, or the checksum of an attachment. + +## 요청 / Request + +| 필드 / Field | 타입 / Type | 제약 / Constraint | +|---|---|---| +| `input_text` | string | 1..262144자, coercion 없음 / 1..262144 characters, no coercion | +| `expected_state_version` | integer | strict integer `>= 1`; bool·문자열 거부 / bool and string rejected | +| `idempotency_key` | string | 1..128자, coercion 없음 / 1..128 characters, no coercion | +| `origin_id` | string or null | 선택, 있으면 1..128자 / optional, 1..128 characters when present | +| `attachments` | array | 선택, 기본 `[]`; 순서가 있는 고유 `AgentAttachmentReference` 최대 10개 / optional, defaults to `[]`; at most ten ordered unique references | + +모델은 추가 필드를 거부하고 불변이다. Python의 `attachments`는 내부에서 tuple이며 JSON에서는 배열이다. FastAPI가 JSON 배열을 Python `list`로 전달하는 것은 허용하지만 iterator와 list/tuple subclass는 거부한다. 각 참조는 `attachment_id`와 `sha256`만 포함하며, 이미 만들어진 Pydantic 모델도 우회 생성 여부를 신뢰하지 않고 다시 검사한다. + +The model forbids extra fields and is immutable. Python stores `attachments` as a tuple and emits a JSON array. A plain list produced by FastAPI JSON parsing is accepted; iterators and list/tuple subclasses are rejected. Every reference contains only `attachment_id` and `sha256`, and even an existing Pydantic instance is copied and fully revalidated against construction bypasses. + +## Helpers와 서버 책임 / Helpers and server responsibilities + +- `parse_attachment_references(value)`는 정확한 기본 list/tuple만 받아 순서와 ID 고유성을 검사하고 새 불변 tuple을 반환한다. +- `parse_turn_submission(value)`는 정확한 `SubmitAgentSessionTurn` 인스턴스만 받아 모든 필드를 다시 검사한 새 DTO를 반환한다. +- helper 실패는 원시 입력이나 예외 context를 보존하지 않는 일반 오류만 공개한다. + +- `parse_attachment_references(value)` accepts only an exact built-in list or tuple, checks order and unique IDs, and returns a new immutable tuple. +- `parse_turn_submission(value)` accepts only an exact `SubmitAgentSessionTurn` instance and returns a newly validated DTO. +- Helper failures expose only a generic error without raw input or exception context. + +서버 계약은 `attachments`가 생략되거나 빈 배열이면 기존 v1 text hash를 유지한다. 비어 있지 않으면 순서가 보존된 참조만 v2 HMAC 입력에 포함한다. 합계 100 MiB 제한은 클라이언트가 보낸 참조가 아니라 authoritative DB metadata로 서버가 검사해야 한다. 서버는 Gateway 인증, 세션과 workflow 소유권, ACTIVE 상태, attachment 소유권·저장 완료·checksum, idempotency 충돌과 실행 권한을 별도로 검증한다. JSON Schema에도 최대 10개 상한을 표시한다. + +The server contract retains the existing v1 text hash when `attachments` is omitted or empty. For nonempty attachments, only the ordered references enter the v2 HMAC input. The server must enforce the 100 MiB aggregate cap from authoritative database metadata rather than client references. Gateway authentication, session/workflow and attachment ownership, ACTIVE state, committed storage/checksum, idempotency conflicts, and execution authorization remain server checks. JSON Schema also advertises the ten-item ceiling. diff --git a/docs/cross-platform-session-integration.md b/docs/cross-platform-session-integration.md new file mode 100644 index 0000000..5666201 --- /dev/null +++ b/docs/cross-platform-session-integration.md @@ -0,0 +1,47 @@ +# Cross-Platform Session 통합 브랜치 + +- 기준: `main` (`561315c96bf625e8b135da9212243ece4ab8d590`) +- 상태: Draft. 서버와 클라이언트 계약 및 관련 검증이 끝날 때까지 `main`에 병합하지 않는다. + +## SDK 작업 경계 + +1. Gateway가 발급한 세션 principal의 `sid`, tenant, platform, device, trust 및 audience를 서비스가 일관되게 해석할 계약을 만든다. 호출자가 보낸 사용자 헤더만으로 신원을 확정하지 않는다. +2. Agent Session의 서버 발급 ID, 이벤트 sequence/cursor, 재연결 및 중복 제거 계약을 공통 타입으로 제공한다. +3. 개인 설정 catalog의 타입, 정책 잠금, 버전 충돌과 출처를 반환하는 클라이언트 인터페이스를 제공한다. Secret 값은 일반 설정 응답 타입에 포함하지 않는다. + +Workflow의 계정 포커스 계약이 확정되어 `xgen_sdk.agent_session`에 생성·전환 요청, 포커스·이벤트 응답, 409 충돌 응답 모델을 추가한다. `apply_event_page`는 계정 이벤트의 연속 sequence, 이전 포인터, cursor·snapshot 일관성을 검사하고 중복 응답을 무시한다. 불일치나 서버의 `ACCOUNT_CURSOR_AHEAD`/`ACCOUNT_CURSOR_GAP`에서는 `GET /api/agentflow/me/agent-state`로 스냅샷을 다시 받은 뒤 그 version부터 재조회한다. SDK 모델은 인증을 수행하지 않는다. 호출자는 Gateway에서 Platform Session·DPoP로 검증된 경로만 사용해야 한다. + +Workflow의 Agent Session 이벤트 계약도 `AgentSessionSnapshot`, `AgentSessionEventPage`, `AgentSessionEventFrame`, `SessionCursorConflictResponse`로 제공한다. `apply_session_event_page`는 HTTP `after_sequence` 또는 WebSocket `after_seq`로 요청한 순번부터 연속성을 검사하고, 겹쳐 온 페이지를 중복 적용하지 않으며, 마지막으로 검증한 이벤트의 순번과 ID를 보존한다. `SESSION_CURSOR_AHEAD`/`SESSION_CURSOR_GAP` 또는 SDK의 `AgentSessionReplayGap`에서는 이어받기를 중단하고 snapshot과 로컬 투영을 재조정해야 한다. SDK는 이 모델만 제공하며 네트워크 인증이나 자동 재시도를 수행하지 않는다. + +Workflow의 `GET /api/agentflow/agent-sessions/{id}/messages`에 맞춰 `AgentSessionMessagePage`, `AgentSessionMessageCursor`, `AgentSessionMessageConflictResponse`와 `apply_message_page`를 제공한다. 이 API는 저널에 연결된 완료 턴만 돌려주므로 메시지 sequence는 이벤트 사이에서 건너뛸 수 있고, `has_more=false`여도 `next_cursor`가 `snapshot_sequence`보다 작을 수 있다. SDK는 해당 세션의 마지막 턴 ID로 겹친 페이지를 확인하고 중복을 제외한다. cursor·턴 anchor·응답 구조가 어긋나면 `AgentSessionMessageGap`으로 중단한다. 입력·출력 텍스트가 없을 수 있으며 `content_complete`와 `source`가 그 상태와 일치해야 한다. 이 페이지를 전체 대화 snapshot으로 취급하거나 Agent Session 이벤트 cursor를 전진시키지 않는다. 인증·소유권 검사와 409 `SESSION_MESSAGE_LINK_INVALID` 처리 책임은 Gateway·Workflow 및 호출자에게 있다. + +## 턴 상태 조회 (2026-10-15) + +`AgentSessionTurnStatusClient`는 호출자가 제공한 인증된 JSON GET 함수로 고정 경로 `GET /api/agentflow/agent-sessions/{session_id}/turns/{turn_id}`를 한 번 조회한다. transport는 현재 Platform Session·DPoP, HTTPS origin, 응답 크기/시간 제한, HTTP 오류 처리를 담당한다. SDK는 인증 정보·base URL·환경변수를 새로 만들지 않는다. + +```python +from xgen_sdk.agent_session import AgentSessionTurnStatusClient + +# authenticated_json_get(path): 현재 세션/DPoP로 GET하고 JSON 객체를 반환하는 앱의 async 함수 +client = AgentSessionTurnStatusClient(authenticated_json_get) +turn = await client.read(session_id, turn_id) +if turn.result_available: + # 결과 본문은 기존 messages 조회로 읽는다. + pass +``` + +응답은 정확한9개 필드의 frozen DTO다. 정규 UUID·요청 session/turn binding·status/progress 조합·sequence 순서·terminal 결과 증거를 재검사한다. `parse_agent_session_turn_status`로 이미 받은 dict/DTO도 다시 검증할 수 있다. 검증 실패는 원시 응답을 넣지 않는 `AgentSessionTurnStatusValidationError`이며, 호출자 transport의 HTTP/취소 예외는 그대로 전달한다. 자동 polling·재시도·reconcile·실행·lease/로그인 수명 연장은 없다. + +`running_or_unknown`은 worker 생존을 증명하지 않으며 `cancelled/final`도 대상 process/stage 정리 완료를 증명하지 않는다. `awaiting_reconciliation`은 명시적인 기존 복구 경로가 필요한 상태다. SDK와 Frontend는 동일한 공개 JSON fixture로 유효·변조 응답의 해석을 대조한다. 패키지는 발행하지 않으며 상위 PR53은 Draft로 유지한다. + +The caller owns authenticated, bounded JSON GET transport. The client fixes and validates the queryless path, revalidates the exact immutable response and its requested identity, and performs no retry, execution or lease extension. A terminal journal projection is separate from target cleanup. SDK and Frontend share public fixtures; publication and full ACTIVE acceptance remain separate gates. + +나머지 항목은 서버 계약이 확정된 뒤 하위 브랜치와 별도 PR로 구현한다. + +## Canonical 첨부 참조 계약 (2026-10-06) + +[공통 첨부 계약](canonical-attachment-reference-contract.md)의 `AgentAttachmentScope`, `AgentAttachmentReceipt`, `AgentAttachmentReference`와 엄격한 parse/prepare helper를 제공한다. origin·계정·세션·workflow를 정확히 비교하고, ID/checksum 참조를 복사한 frozen 모델/tuple로 반환한다. 중복 ID·잘못된 Unicode·크기/개수/합계 초과와 경로·URL·raw bytes·권한 주장을 거부한다. helper 예외는 원시 입력을 노출하지 않는다. SDK는 업로드·인증·checksum 계산·턴 제출을 수행하지 않으며 현재 서버의 text-only 계약은 유지한다. + +DEX와 동일한 JSON 입력 벡터를 적용하며 Python3.11/3.12 계약 CI를 추가한다. CI는 source 모델 테스트만 실행하고 패키지를 발행하지 않는다. 배포 없이 Workflow의 임시 source overlay에서 실제 import와 동작을 확인한다. 서버 업로드·소유권/저장 checksum 확인·저널 hash 및 실행 전달은 후속 단계다. `main` 대상 상위 PR53은 Draft로 유지한다. + +The SDK supplies strict, scoped attachment metadata models and immutable ordered reference preparation, using the same fixtures as DEX. Parsing cannot authenticate a receipt or authorize stored bytes. Python3.11/3.12 CI validates unreleased source contracts without publishing. The local Workflow overlay checks real imports; upload, storage ownership/checksum, journal and execution integration remain subsequent work. Parent PR53 remains Draft. diff --git a/src/xgen_sdk/agent_session/__init__.py b/src/xgen_sdk/agent_session/__init__.py new file mode 100644 index 0000000..6ede0fe --- /dev/null +++ b/src/xgen_sdk/agent_session/__init__.py @@ -0,0 +1,137 @@ +"""Contracts for account focus, Agent Session events and linked messages. + +These models describe verified API responses. They do not authenticate a caller +or replace the Gateway's Platform Session and DPoP checks. +""" + +from .focus import ( + AccountEventPage, + AccountFocusEvent, + AgentSessionCreated, + CreateAgentSession, + CursorConflictResponse, + FocusConflictResponse, + FocusReplayGap, + FocusState, + SwitchFocus, + apply_event_page, +) +from .events import ( + AgentSessionEvent, + AgentSessionEventFrame, + AgentSessionEventPage, + AgentSessionReplayCursor, + AgentSessionReplayGap, + AgentSessionSnapshot, + AgentSessionTurnSummary, + SessionCursorConflictResponse, + apply_session_event_page, +) +from .messages import ( + AgentSessionMessage, + AgentSessionMessageConflictResponse, + AgentSessionMessageCursor, + AgentSessionMessageGap, + AgentSessionMessagePage, + apply_message_page, +) +from .attachments import ( + AGENT_ATTACHMENT_MAX_BYTES, + AGENT_ATTACHMENT_MAX_COUNT, + AGENT_ATTACHMENT_MAX_TOTAL_BYTES, + AgentAttachmentReceipt, + AgentAttachmentReference, + AgentAttachmentScope, + AgentAttachmentValidationError, + parse_attachment_receipt, + parse_attachment_references, + parse_attachment_scope, + prepare_attachment_references, +) +from .turns import ( + AgentSessionTurnValidationError, + SubmitAgentSessionTurn, + parse_turn_submission, +) +from .turn_status import ( + AgentSessionTurnStatus, + AgentSessionTurnStatusClient, + AgentSessionTurnStatusGetTransport, + AgentSessionTurnStatusValidationError, + parse_agent_session_turn_status, +) +from .broker_operations import ( + AGENT_SESSION_BROKER_OPERATION_MAX_COUNT, + AGENT_SESSION_BROKER_OPERATION_MAX_EXPIRY, + AGENT_SESSION_BROKER_OPERATIONS, + AgentSessionBrokerOperationCancellation, + AgentSessionBrokerOperationMetadata, + AgentSessionBrokerOperationReference, + AgentSessionBrokerOperationValidationError, + AgentSessionBrokerOperationsClient, + AgentSessionBrokerOperationsGetTransport, + AgentSessionBrokerOperationsPostTransport, + parse_agent_session_broker_operation_cancellation, + parse_agent_session_broker_operation_metadata, + parse_agent_session_broker_operation_references, +) + +__all__ = [ + "AGENT_ATTACHMENT_MAX_BYTES", + "AGENT_ATTACHMENT_MAX_COUNT", + "AGENT_ATTACHMENT_MAX_TOTAL_BYTES", + "AGENT_SESSION_BROKER_OPERATION_MAX_COUNT", + "AGENT_SESSION_BROKER_OPERATION_MAX_EXPIRY", + "AGENT_SESSION_BROKER_OPERATIONS", + "AccountEventPage", + "AccountFocusEvent", + "AgentAttachmentReceipt", + "AgentAttachmentReference", + "AgentAttachmentScope", + "AgentAttachmentValidationError", + "AgentSessionEvent", + "AgentSessionEventFrame", + "AgentSessionEventPage", + "AgentSessionMessage", + "AgentSessionMessageConflictResponse", + "AgentSessionMessageCursor", + "AgentSessionMessageGap", + "AgentSessionMessagePage", + "AgentSessionBrokerOperationCancellation", + "AgentSessionBrokerOperationMetadata", + "AgentSessionBrokerOperationReference", + "AgentSessionBrokerOperationValidationError", + "AgentSessionBrokerOperationsClient", + "AgentSessionBrokerOperationsGetTransport", + "AgentSessionBrokerOperationsPostTransport", + "AgentSessionReplayCursor", + "AgentSessionReplayGap", + "AgentSessionSnapshot", + "AgentSessionTurnSummary", + "AgentSessionTurnStatus", + "AgentSessionTurnStatusClient", + "AgentSessionTurnStatusGetTransport", + "AgentSessionTurnStatusValidationError", + "AgentSessionTurnValidationError", + "AgentSessionCreated", + "CreateAgentSession", + "CursorConflictResponse", + "FocusConflictResponse", + "FocusReplayGap", + "FocusState", + "SwitchFocus", + "SubmitAgentSessionTurn", + "SessionCursorConflictResponse", + "apply_event_page", + "apply_message_page", + "apply_session_event_page", + "parse_attachment_receipt", + "parse_attachment_references", + "parse_attachment_scope", + "parse_turn_submission", + "parse_agent_session_turn_status", + "parse_agent_session_broker_operation_cancellation", + "parse_agent_session_broker_operation_metadata", + "parse_agent_session_broker_operation_references", + "prepare_attachment_references", +] diff --git a/src/xgen_sdk/agent_session/attachments.py b/src/xgen_sdk/agent_session/attachments.py new file mode 100644 index 0000000..cbb4e48 --- /dev/null +++ b/src/xgen_sdk/agent_session/attachments.py @@ -0,0 +1,417 @@ +"""Strict local metadata contracts for canonical Agent Session attachments. + +These models validate inert metadata only. Filenames are display values and +must never be joined to a filesystem path. The models do not upload content, +authenticate a receipt, or prove server ownership of an attachment. +""" + +from __future__ import annotations + +import ipaddress +import re +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +AGENT_ATTACHMENT_MAX_BYTES = 104_857_600 +AGENT_ATTACHMENT_MAX_COUNT = 10 +AGENT_ATTACHMENT_MAX_TOTAL_BYTES = 104_857_600 + +_ERROR_MESSAGE = "Invalid canonical Agent Session attachment metadata" +_MAX_USER_ID = "9223372036854775807" +_UUID = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_MIME = re.compile( + r"^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$" +) +_DNS_LABEL = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +_NUMERIC_HOST_LABEL = re.compile(r"^(?:[0-9]+|0x[0-9a-f]*)$") +_IPV6_TEXT = re.compile(r"^[0-9a-f:]+$") +_BIDI_CONTROLS = frozenset( + (0x061C, 0x200E, 0x200F, *range(0x202A, 0x202F), *range(0x2066, 0x206A)) +) +_SCOPE_FIELDS = ("origin", "user_id", "session_id", "workflow_id") + + +class AgentAttachmentValidationError(ValueError): + """The supplied metadata is not a safe canonical attachment contract.""" + + +class _AttachmentModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + ) + + @classmethod + def model_construct( + cls, _fields_set: set[str] | None = None, **values: Any + ) -> _AttachmentModel: + declared = set(cls.model_fields) + if set(values) - declared or (_fields_set is not None and set(_fields_set) - declared): + raise AgentAttachmentValidationError(_ERROR_MESSAGE) from None + return super().model_construct(_fields_set=_fields_set, **values) + + +def _valid_port(port: str | None) -> bool: + if port is None: + return True + if ( + not port + or len(port) > 5 + or not port.isascii() + or not port.isdecimal() + or port[0] == "0" + ): + return False + number = int(port) + return 1 <= number <= 65_535 and number != 443 and str(number) == port + + +def _valid_dns_or_ipv4(host: str) -> bool: + if not host or len(host) > 253 or host.endswith("."): + return False + if all(character in "0123456789." for character in host): + try: + return str(ipaddress.IPv4Address(host)) == host + except ipaddress.AddressValueError: + return False + labels = host.split(".") + if any(not _DNS_LABEL.fullmatch(label) for label in labels): + return False + if any(label.startswith("xn--") for label in labels): + return False + # WHATWG treats a numeric final label as an IPv4 candidate rather than a DNS name. + return _NUMERIC_HOST_LABEL.fullmatch(labels[-1]) is None + + +def _whatwg_ipv6(address: ipaddress.IPv6Address) -> str: + """Serialize eight hextets without Python's version-dependent mapped form.""" + packed = address.packed + hextets = [int.from_bytes(packed[index : index + 2], "big") for index in range(0, 16, 2)] + + best_start = -1 + best_length = 0 + index = 0 + while index < len(hextets): + if hextets[index] != 0: + index += 1 + continue + end = index + while end < len(hextets) and hextets[end] == 0: + end += 1 + length = end - index + if length >= 2 and length > best_length: + best_start = index + best_length = length + index = end + + if best_start < 0: + return ":".join(format(hextet, "x") for hextet in hextets) + left = ":".join(format(hextet, "x") for hextet in hextets[:best_start]) + right = ":".join(format(hextet, "x") for hextet in hextets[best_start + best_length :]) + return f"{left}::{right}" + + +def _canonical_origin(value: str) -> bool: + if not value.isascii() or not value.startswith("https://"): + return False + if "\\" in value or any(character.isspace() for character in value): + return False + authority = value[len("https://") :] + if not authority or any(character in authority for character in "/?#@"): + return False + + port: str | None = None + if authority.startswith("["): + close = authority.find("]") + if close < 0: + return False + host = authority[1:close] + suffix = authority[close + 1 :] + if suffix: + if not suffix.startswith(":"): + return False + port = suffix[1:] + if not _valid_port(port) or not _IPV6_TEXT.fullmatch(host): + return False + try: + canonical_host = _whatwg_ipv6(ipaddress.IPv6Address(host)) + except ipaddress.AddressValueError: + return False + if canonical_host != host: + return False + canonical = f"https://[{canonical_host}]" + (f":{port}" if port else "") + return canonical == value + + if authority.count(":") > 1: + return False + if ":" in authority: + host, port = authority.rsplit(":", 1) + else: + host = authority + if not _valid_port(port) or not _valid_dns_or_ipv4(host): + return False + canonical = f"https://{host}" + (f":{port}" if port else "") + return canonical == value + + +def _valid_unicode_metadata(value: str, maximum_bytes: int, *, filename: bool = False) -> bool: + try: + encoded = value.encode("utf-8", errors="strict") + except UnicodeEncodeError: + return False + if not 1 <= len(encoded) <= maximum_bytes: + return False + if "/" in value or "\\" in value or (filename and value in {".", ".."}): + return False + return not any( + codepoint <= 0x1F + or 0x7F <= codepoint <= 0x9F + or codepoint in _BIDI_CONTROLS + for codepoint in map(ord, value) + ) + + +class AgentAttachmentScope(_AttachmentModel): + # Character caps reject oversized metadata before UTF-8 encoding or URL parsing. + origin: str = Field(min_length=1, max_length=320) + user_id: str = Field(min_length=1, max_length=19) + session_id: str = Field(min_length=36, max_length=36) + workflow_id: str = Field(min_length=1, max_length=128) + + @field_validator("origin") + @classmethod + def canonical_https_origin(cls, value: str) -> str: + if not _canonical_origin(value): + raise ValueError("origin must be canonical HTTPS") + return value + + @field_validator("user_id") + @classmethod + def canonical_user_id(cls, value: str) -> str: + if ( + not value.isascii() + or not re.fullmatch(r"[1-9][0-9]*", value) + or len(value) > len(_MAX_USER_ID) + or (len(value) == len(_MAX_USER_ID) and value > _MAX_USER_ID) + ): + raise ValueError("user_id must be canonical decimal") + return value + + @field_validator("session_id") + @classmethod + def canonical_session_id(cls, value: str) -> str: + if not _UUID.fullmatch(value): + raise ValueError("session_id must be a lowercase UUID") + return value + + @field_validator("workflow_id") + @classmethod + def canonical_workflow_id(cls, value: str) -> str: + if not _valid_unicode_metadata(value, 128): + raise ValueError("workflow_id must be valid Unicode metadata") + return value + + +class AgentAttachmentReceipt(AgentAttachmentScope): + attachment_id: str = Field(min_length=36, max_length=36) + filename: str = Field(min_length=1, max_length=255) + size_bytes: int = Field(ge=0, le=AGENT_ATTACHMENT_MAX_BYTES) + media_type: str = Field(min_length=3, max_length=255) + sha256: str = Field(min_length=64, max_length=64) + + @field_validator("attachment_id") + @classmethod + def canonical_attachment_id(cls, value: str) -> str: + if not _UUID.fullmatch(value): + raise ValueError("attachment_id must be a lowercase UUID") + return value + + @field_validator("filename") + @classmethod + def canonical_filename(cls, value: str) -> str: + if not _valid_unicode_metadata(value, 255, filename=True): + raise ValueError("filename must be valid Unicode metadata") + return value + + @field_validator("media_type") + @classmethod + def canonical_media_type(cls, value: str) -> str: + if not value.isascii() or not _MIME.fullmatch(value) or len(value.encode("ascii")) > 255: + raise ValueError("media_type must be canonical MIME metadata") + return value + + @field_validator("sha256") + @classmethod + def canonical_sha256(cls, value: str) -> str: + if not value.isascii() or not _SHA256.fullmatch(value): + raise ValueError("sha256 must be lowercase hexadecimal") + return value + + +class AgentAttachmentReference(_AttachmentModel): + attachment_id: str = Field(min_length=36, max_length=36) + sha256: str = Field(min_length=64, max_length=64) + + @field_validator("attachment_id") + @classmethod + def canonical_attachment_id(cls, value: str) -> str: + if not _UUID.fullmatch(value): + raise ValueError("attachment_id must be a lowercase UUID") + return value + + @field_validator("sha256") + @classmethod + def canonical_sha256(cls, value: str) -> str: + if not value.isascii() or not _SHA256.fullmatch(value): + raise ValueError("sha256 must be lowercase hexadecimal") + return value + + +def _model_input(value: Any, expected_model: type[_AttachmentModel]) -> Any: + if isinstance(value, BaseModel): + if type(value) is not expected_model: + raise ValueError("unexpected attachment model type") + declared = set(expected_model.model_fields) + if set(value.__dict__) != declared: + raise ValueError("attachment model fields are not exact") + extra = getattr(value, "__pydantic_extra__", None) + if extra not in (None, {}): + raise ValueError("attachment model has extra fields") + fields_set = getattr(value, "__pydantic_fields_set__", set()) + if not isinstance(fields_set, set) or not fields_set <= declared: + raise ValueError("attachment model field set is invalid") + return expected_model.model_dump( + value, mode="python", round_trip=True, warnings=False + ) + return value + + +def _validate_scope(value: Any) -> AgentAttachmentScope: + return AgentAttachmentScope.model_validate( + _model_input(value, AgentAttachmentScope), strict=True + ) + + +def _validate_receipt(value: Any) -> AgentAttachmentReceipt: + return AgentAttachmentReceipt.model_validate( + _model_input(value, AgentAttachmentReceipt), strict=True + ) + + +def _validate_reference(value: Any) -> AgentAttachmentReference: + return AgentAttachmentReference.model_validate( + _model_input(value, AgentAttachmentReference), strict=True + ) + + +def _same_scope(receipt: AgentAttachmentReceipt, scope: AgentAttachmentScope) -> bool: + return all(getattr(receipt, field) == getattr(scope, field) for field in _SCOPE_FIELDS) + + +def _invalid() -> AgentAttachmentValidationError: + return AgentAttachmentValidationError(_ERROR_MESSAGE) + + +def parse_attachment_scope(value: Any) -> AgentAttachmentScope: + """Return a new, fully validated scope without trusting model construction.""" + try: + return _validate_scope(value) + except Exception: + pass + # Raise after leaving the handler so the public exception does not retain + # a Pydantic ValidationError (including its raw .errors() input) as context. + raise _invalid() from None + + +def parse_attachment_receipt( + value: Any, expected_scope: Any +) -> AgentAttachmentReceipt: + """Return a copied receipt only when all four authenticated scope fields match.""" + try: + scope = _validate_scope(expected_scope) + receipt = _validate_receipt(value) + if not _same_scope(receipt, scope): + raise ValueError("attachment receipt scope mismatch") + return receipt + except Exception: + pass + raise _invalid() from None + + +def prepare_attachment_references( + receipts: Any, expected_scope: Any +) -> tuple[AgentAttachmentReference, ...]: + """Validate local receipts and project ordered immutable turn references. + + Shape validation does not authenticate a receipt. The caller must provide + an authenticated scope and independently enforce server ownership and the + stored checksum before dispatch. + """ + try: + scope = _validate_scope(expected_scope) + if not isinstance(receipts, (list, tuple)) or len(receipts) > AGENT_ATTACHMENT_MAX_COUNT: + raise ValueError("attachment count is invalid") + seen: set[str] = set() + total_bytes = 0 + references: list[AgentAttachmentReference] = [] + for value in receipts: + receipt = _validate_receipt(value) + if not _same_scope(receipt, scope) or receipt.attachment_id in seen: + raise ValueError("attachment receipt is not unique in the expected scope") + seen.add(receipt.attachment_id) + total_bytes += receipt.size_bytes + if total_bytes > AGENT_ATTACHMENT_MAX_TOTAL_BYTES: + raise ValueError("attachment aggregate size is invalid") + references.append( + AgentAttachmentReference( + attachment_id=receipt.attachment_id, + sha256=receipt.sha256, + ) + ) + return tuple(references) + except Exception: + pass + raise _invalid() from None + + +def parse_attachment_references( + value: Any, +) -> tuple[AgentAttachmentReference, ...]: + """Validate and copy an ordered immutable turn attachment reference list.""" + try: + if type(value) not in (list, tuple) or len(value) > AGENT_ATTACHMENT_MAX_COUNT: + raise ValueError("attachment reference count is invalid") + seen: set[str] = set() + references: list[AgentAttachmentReference] = [] + for candidate in value: + reference = _validate_reference(candidate) + if reference.attachment_id in seen: + raise ValueError("attachment reference IDs must be unique") + seen.add(reference.attachment_id) + references.append(reference) + return tuple(references) + except Exception: + pass + raise _invalid() from None + + +__all__ = [ + "AGENT_ATTACHMENT_MAX_BYTES", + "AGENT_ATTACHMENT_MAX_COUNT", + "AGENT_ATTACHMENT_MAX_TOTAL_BYTES", + "AgentAttachmentReceipt", + "AgentAttachmentReference", + "AgentAttachmentScope", + "AgentAttachmentValidationError", + "parse_attachment_receipt", + "parse_attachment_references", + "parse_attachment_scope", + "prepare_attachment_references", +] diff --git a/src/xgen_sdk/agent_session/broker_operations.py b/src/xgen_sdk/agent_session/broker_operations.py new file mode 100644 index 0000000..384c7a8 --- /dev/null +++ b/src/xgen_sdk/agent_session/broker_operations.py @@ -0,0 +1,371 @@ +"""Strict clients for canonical Agent Session broker-operation controls. + +The caller supplies authenticated JSON transports. This module validates only +credential-free references, metadata, and cancellation acceptance; it never +receives a broker grant, lease, request body, or cached result. +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Literal, Protocol +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +AGENT_SESSION_BROKER_OPERATION_MAX_COUNT = 128 +AGENT_SESSION_BROKER_OPERATION_MAX_EXPIRY = 9_007_199_254_740_991 + +_ERROR_MESSAGE = "Invalid canonical Agent Session broker operation" +AGENT_SESSION_BROKER_OPERATIONS = frozenset( + { + "documents.collections.list", + "documents.collection.documents.list", + "workflow.actor.authorize", + "documents.attachment.index", + "workflow.code.execute", + "workflow.attachments.stage", + } +) +_SANDBOX_OPERATIONS = frozenset( + {"workflow.code.execute", "workflow.attachments.stage"} +) +_REFERENCE_FIELDS = frozenset({"invocation_id", "operation_id"}) +_METADATA_FIELDS = frozenset( + { + "invocation_id", + "operation_id", + "state", + "cancel_requested", + "target_cleanup", + "result_available", + "expires_at", + } +) +_CANCELLATION_FIELDS = frozenset({"invocation_id", "cancel_requested"}) + + +class AgentSessionBrokerOperationValidationError(ValueError): + """The supplied value is not a canonical public broker-operation value.""" + + +def _canonical_uuid(value: Any) -> UUID: + if type(value) is UUID: + rendered = str(value) + elif type(value) is str: + rendered = value + else: + raise ValueError("identifier must be a UUID") + parsed = UUID(rendered) + if parsed.int == 0 or str(parsed) != rendered: + raise ValueError("identifier must be canonical and nonzero") + return parsed + + +class _ExactFrozenBrokerModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + ) + + _wire_fields: ClassVar[frozenset[str]] + + @classmethod + def model_construct( + cls, _fields_set: set[str] | None = None, **values: Any + ) -> _ExactFrozenBrokerModel: + if set(values) - cls._wire_fields or ( + _fields_set is not None and set(_fields_set) - cls._wire_fields + ): + raise AgentSessionBrokerOperationValidationError(_ERROR_MESSAGE) from None + return super().model_construct(_fields_set=_fields_set, **values) + + +class AgentSessionBrokerOperationReference(_ExactFrozenBrokerModel): + """Credential-free reference retained with an owned Agent Session turn.""" + + _wire_fields = _REFERENCE_FIELDS + + invocation_id: UUID + operation_id: Literal[ + "documents.collections.list", + "documents.collection.documents.list", + "workflow.actor.authorize", + "documents.attachment.index", + "workflow.code.execute", + "workflow.attachments.stage", + ] + + @field_validator("invocation_id", mode="before") + @classmethod + def canonical_invocation_id(cls, value: Any) -> UUID: + return _canonical_uuid(value) + + +class AgentSessionBrokerOperationMetadata(_ExactFrozenBrokerModel): + """Public point-in-time metadata with no result, grant, or lease material.""" + + _wire_fields = _METADATA_FIELDS + + invocation_id: UUID + operation_id: Literal[ + "documents.collections.list", + "documents.collection.documents.list", + "workflow.actor.authorize", + "documents.attachment.index", + "workflow.code.execute", + "workflow.attachments.stage", + ] + state: Literal["running", "completed", "failed", "cancelled", "unknown"] + cancel_requested: bool = Field(strict=True) + target_cleanup: Literal["not_applicable", "pending", "complete", "unknown"] + result_available: bool = Field(strict=True) + expires_at: int = Field( + ge=1, + le=AGENT_SESSION_BROKER_OPERATION_MAX_EXPIRY, + strict=True, + ) + + @field_validator("invocation_id", mode="before") + @classmethod + def canonical_invocation_id(cls, value: Any) -> UUID: + return _canonical_uuid(value) + + @model_validator(mode="after") + def coherent_public_metadata(self) -> AgentSessionBrokerOperationMetadata: + sandbox = self.operation_id in _SANDBOX_OPERATIONS + if sandbox == (self.target_cleanup == "not_applicable"): + raise ValueError("operation and cleanup projection are inconsistent") + if self.result_available and (not sandbox or self.state != "completed"): + raise ValueError("result availability is inconsistent") + return self + + +class AgentSessionBrokerOperationCancellation(_ExactFrozenBrokerModel): + """Cancellation acceptance; this is not final target-cleanup evidence.""" + + _wire_fields = _CANCELLATION_FIELDS + + invocation_id: UUID + cancel_requested: Literal[True] + + @field_validator("invocation_id", mode="before") + @classmethod + def canonical_invocation_id(cls, value: Any) -> UUID: + return _canonical_uuid(value) + + @field_validator("cancel_requested", mode="before") + @classmethod + def exact_cancellation_acknowledgement(cls, value: Any) -> bool: + if value is not True: + raise ValueError("cancellation acknowledgement must be true") + return True + + +class AgentSessionBrokerOperationsGetTransport(Protocol): + """Caller-owned authenticated JSON GET callback.""" + + async def __call__(self, path: str, /) -> object: ... + + +class AgentSessionBrokerOperationsPostTransport(Protocol): + """Caller-owned authenticated empty-body JSON POST callback.""" + + async def __call__(self, path: str, /) -> object: ... + + +def _identifier(value: Any) -> UUID: + try: + return _canonical_uuid(value) + except Exception: + pass + raise AgentSessionBrokerOperationValidationError(_ERROR_MESSAGE) from None + + +def _model_input( + value: Any, + *, + model_type: type[_ExactFrozenBrokerModel], + fields: frozenset[str], +) -> dict[str, Any]: + if type(value) is dict: + if set(value) != fields: + raise ValueError("response fields are not exact") + return {field: value[field] for field in fields} + if type(value) is not model_type: + raise ValueError("unexpected broker-operation model type") + if set(value.__dict__) != fields: + raise ValueError("broker-operation model fields are not exact") + extra = getattr(value, "__pydantic_extra__", None) + if extra not in (None, {}): + raise ValueError("broker-operation model has extra fields") + fields_set = getattr(value, "__pydantic_fields_set__", set()) + if not isinstance(fields_set, set) or not fields_set <= fields: + raise ValueError("broker-operation model field set is invalid") + return {field: value.__dict__[field] for field in fields} + + +def _parse_reference(value: Any) -> AgentSessionBrokerOperationReference: + return AgentSessionBrokerOperationReference.model_validate( + _model_input( + value, + model_type=AgentSessionBrokerOperationReference, + fields=_REFERENCE_FIELDS, + ), + strict=True, + ) + + +def _expected_reference(value: Any) -> AgentSessionBrokerOperationReference: + try: + return _parse_reference(value) + except Exception: + pass + raise AgentSessionBrokerOperationValidationError(_ERROR_MESSAGE) from None + + +def parse_agent_session_broker_operation_references( + value: Any, +) -> tuple[AgentSessionBrokerOperationReference, ...]: + """Revalidate one bare array of at most 128 unique public references.""" + try: + if type(value) is not list or len(value) > AGENT_SESSION_BROKER_OPERATION_MAX_COUNT: + raise ValueError("operation reference list is invalid") + parsed = tuple(_parse_reference(item) for item in value) + invocation_ids = {item.invocation_id for item in parsed} + if len(invocation_ids) != len(parsed): + raise ValueError("operation invocation identifiers must be unique") + return parsed + except Exception: + pass + raise AgentSessionBrokerOperationValidationError(_ERROR_MESSAGE) from None + + +def parse_agent_session_broker_operation_metadata( + value: Any, + *, + expected_reference: AgentSessionBrokerOperationReference, +) -> AgentSessionBrokerOperationMetadata: + """Revalidate exact metadata and bind invocation and operation identifiers.""" + try: + reference = _parse_reference(expected_reference) + parsed = AgentSessionBrokerOperationMetadata.model_validate( + _model_input( + value, + model_type=AgentSessionBrokerOperationMetadata, + fields=_METADATA_FIELDS, + ), + strict=True, + ) + if ( + parsed.invocation_id != reference.invocation_id + or parsed.operation_id != reference.operation_id + ): + raise ValueError("metadata does not match the expected reference") + return parsed + except Exception: + pass + raise AgentSessionBrokerOperationValidationError(_ERROR_MESSAGE) from None + + +def parse_agent_session_broker_operation_cancellation( + value: Any, + *, + expected_reference: AgentSessionBrokerOperationReference, +) -> AgentSessionBrokerOperationCancellation: + """Revalidate an exact cancellation acknowledgement and bind invocation.""" + try: + reference = _parse_reference(expected_reference) + parsed = AgentSessionBrokerOperationCancellation.model_validate( + _model_input( + value, + model_type=AgentSessionBrokerOperationCancellation, + fields=_CANCELLATION_FIELDS, + ), + strict=True, + ) + if parsed.invocation_id != reference.invocation_id: + raise ValueError("cancellation does not match the expected reference") + return parsed + except Exception: + pass + raise AgentSessionBrokerOperationValidationError(_ERROR_MESSAGE) from None + + +class AgentSessionBrokerOperationsClient: + """List, read, and cancel through caller-authenticated fixed-path callbacks.""" + + __slots__ = ("_get", "_post") + + def __init__( + self, + get: AgentSessionBrokerOperationsGetTransport, + post: AgentSessionBrokerOperationsPostTransport, + ) -> None: + if not callable(get): + raise TypeError("get transport must be callable") + if not callable(post): + raise TypeError("post transport must be callable") + self._get = get + self._post = post + + @staticmethod + def _base_path(session_id: Any, turn_id: Any) -> str: + session = _identifier(session_id) + turn = _identifier(turn_id) + return ( + f"/api/agentflow/agent-sessions/{session}" + f"/turns/{turn}/operations" + ) + + async def list( + self, session_id: str | UUID, turn_id: str | UUID + ) -> tuple[AgentSessionBrokerOperationReference, ...]: + path = self._base_path(session_id, turn_id) + value = await self._get(path) + return parse_agent_session_broker_operation_references(value) + + async def read( + self, + session_id: str | UUID, + turn_id: str | UUID, + reference: AgentSessionBrokerOperationReference, + ) -> AgentSessionBrokerOperationMetadata: + path = self._base_path(session_id, turn_id) + expected = _expected_reference(reference) + value = await self._get(f"{path}/{expected.invocation_id}") + return parse_agent_session_broker_operation_metadata( + value, expected_reference=expected + ) + + async def cancel( + self, + session_id: str | UUID, + turn_id: str | UUID, + reference: AgentSessionBrokerOperationReference, + ) -> AgentSessionBrokerOperationCancellation: + path = self._base_path(session_id, turn_id) + expected = _expected_reference(reference) + value = await self._post(f"{path}/{expected.invocation_id}/cancel") + return parse_agent_session_broker_operation_cancellation( + value, expected_reference=expected + ) + + +__all__ = [ + "AGENT_SESSION_BROKER_OPERATION_MAX_COUNT", + "AGENT_SESSION_BROKER_OPERATION_MAX_EXPIRY", + "AGENT_SESSION_BROKER_OPERATIONS", + "AgentSessionBrokerOperationCancellation", + "AgentSessionBrokerOperationMetadata", + "AgentSessionBrokerOperationReference", + "AgentSessionBrokerOperationValidationError", + "AgentSessionBrokerOperationsClient", + "AgentSessionBrokerOperationsGetTransport", + "AgentSessionBrokerOperationsPostTransport", + "parse_agent_session_broker_operation_cancellation", + "parse_agent_session_broker_operation_metadata", + "parse_agent_session_broker_operation_references", +] diff --git a/src/xgen_sdk/agent_session/events.py b/src/xgen_sdk/agent_session/events.py new file mode 100644 index 0000000..43aa6c5 --- /dev/null +++ b/src/xgen_sdk/agent_session/events.py @@ -0,0 +1,130 @@ +"""Wire models and ordered replay for Workflow Agent Session events. + +Authentication and ownership are enforced by Gateway and Workflow. Callers +must obtain these responses through an authorized Platform Session. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class _WireModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class AgentSessionTurnSummary(_WireModel): + id: UUID + status: str + accepted_sequence: int = Field(ge=1, strict=True) + + +class AgentSessionSnapshot(_WireModel): + id: UUID + workflow_id: str + title: str + current_sequence: int = Field(ge=0, strict=True) + state_version: int = Field(ge=1, strict=True) + latest_turn: AgentSessionTurnSummary | None + message_history_complete: bool = Field(strict=True) + + +class AgentSessionEvent(_WireModel): + event_id: UUID + sequence: int = Field(ge=1, strict=True) + event_type: str = Field(min_length=1) + turn_id: UUID | None + platform_session_id: UUID | None + device_id: UUID | None + origin_id: str | None + idempotency_key: str | None + payload_ref: str | None + created_at: datetime + + +class AgentSessionEventPage(_WireModel): + events: list[AgentSessionEvent] + next_cursor: int = Field(ge=0, strict=True) + snapshot_sequence: int = Field(ge=0, strict=True) + state_version: int = Field(ge=1, strict=True) + has_more: bool = Field(strict=True) + + +class AgentSessionEventFrame(AgentSessionEventPage): + type: Literal["agent_session.events"] + + +class _SessionCursorConflictDetail(_WireModel): + code: Literal["SESSION_CURSOR_AHEAD", "SESSION_CURSOR_GAP"] + current_sequence: int = Field(ge=0, strict=True) + state_version: int = Field(ge=1, strict=True) + + +class SessionCursorConflictResponse(_WireModel): + detail: _SessionCursorConflictDetail + + +class AgentSessionReplayCursor(_WireModel): + """Last verified event, persisted by a client for reconnection.""" + + sequence: int = Field(ge=0, strict=True) + event_id: UUID | None + state_version: int = Field(ge=1, strict=True) + + +class AgentSessionReplayGap(ValueError): + """Stop replay; fetch a fresh snapshot and reconcile local state.""" + + +def apply_session_event_page( + current: AgentSessionReplayCursor, + page: AgentSessionEventPage, + *, after_sequence: int, +) -> tuple[AgentSessionReplayCursor, tuple[AgentSessionEvent, ...]]: + """Accept only a continuous page and return events not previously applied. + + ``after_sequence`` is the cursor sent to HTTP ``after_sequence`` or WS + ``after_seq``. A delayed duplicate page is harmless. A new event must + follow the last verified sequence; on a gap or cursor conflict, callers + must stop rather than advance the cursor. + """ + if type(after_sequence) is not int or not 0 <= after_sequence <= current.sequence: + raise AgentSessionReplayGap("invalid replay cursor") + + expected = after_sequence + for event in page.events: + expected += 1 + if event.sequence != expected: + raise AgentSessionReplayGap("Agent Session event sequence gap") + if page.next_cursor != expected or page.snapshot_sequence < expected: + raise AgentSessionReplayGap("inconsistent replay cursor") + if page.has_more != (page.next_cursor < page.snapshot_sequence): + raise AgentSessionReplayGap("inconsistent replay continuation") + + cursor = current + accepted: list[AgentSessionEvent] = [] + for event in page.events: + if event.sequence < cursor.sequence: + continue + if event.sequence == cursor.sequence: + if cursor.event_id is not None and cursor.event_id != event.event_id: + raise AgentSessionReplayGap("conflicting duplicate Agent Session event") + continue + if event.sequence != cursor.sequence + 1 or page.state_version < cursor.state_version: + raise AgentSessionReplayGap("Agent Session event state diverged") + cursor = AgentSessionReplayCursor( + sequence=event.sequence, + event_id=event.event_id, + state_version=page.state_version, + ) + accepted.append(event) + + return AgentSessionReplayCursor( + sequence=cursor.sequence, + event_id=cursor.event_id, + state_version=max(cursor.state_version, page.state_version), + ), tuple(accepted) diff --git a/src/xgen_sdk/agent_session/focus.py b/src/xgen_sdk/agent_session/focus.py new file mode 100644 index 0000000..8016aa5 --- /dev/null +++ b/src/xgen_sdk/agent_session/focus.py @@ -0,0 +1,128 @@ +"""Wire contract and ordered replay for Workflow's account focus API.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class _WireModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class CreateAgentSession(_WireModel): + workflow_id: str = Field(min_length=1, max_length=256) + expected_version: int = Field(ge=0, strict=True) + title: str = Field(default="", max_length=256) + origin_id: str | None = Field(default=None, min_length=1, max_length=128) + + +class SwitchFocus(_WireModel): + active_agent_session_id: UUID | None + expected_version: int = Field(ge=0, strict=True) + origin_id: str | None = Field(default=None, min_length=1, max_length=128) + + +class FocusState(_WireModel): + active_agent_session_id: UUID | None + version: int = Field(ge=0, strict=True) + event_id: UUID | None + + +class AgentSessionCreated(_WireModel): + id: UUID + workflow_id: str + focus: FocusState + + @model_validator(mode="after") + def created_session_is_focused(self) -> AgentSessionCreated: + if self.focus.active_agent_session_id != self.id: + raise ValueError("created Agent Session must be the account focus") + return self + + +class AccountFocusEvent(_WireModel): + event_id: UUID + sequence: int = Field(ge=1, strict=True) + event_type: Literal["agent_session.focus_changed"] + previous_agent_session_id: UUID | None + active_agent_session_id: UUID | None + origin_id: str | None + created_at: datetime + + +class AccountEventPage(_WireModel): + events: list[AccountFocusEvent] + next_cursor: int = Field(ge=0, strict=True) + snapshot_version: int = Field(ge=0, strict=True) + has_more: bool = Field(strict=True) + + +class _FocusConflictDetail(_WireModel): + code: Literal["FOCUS_VERSION_CONFLICT"] + current: FocusState + + +class FocusConflictResponse(_WireModel): + detail: _FocusConflictDetail + + +class _CursorConflictDetail(_WireModel): + code: Literal["ACCOUNT_CURSOR_AHEAD", "ACCOUNT_CURSOR_GAP"] + current: FocusState + + +class CursorConflictResponse(_WireModel): + detail: _CursorConflictDetail + + +class FocusReplayGap(ValueError): + """Discard the cursor and GET /me/agent-state again before replaying.""" + + +def apply_event_page( + current: FocusState, page: AccountEventPage, *, after_sequence: int +) -> FocusState: + """Apply a replay page without accepting a missing or reordered focus event. + + ``after_sequence`` is the cursor sent to GET /me/agent-events. A duplicate + response is harmless; a new event must follow the current version and + previous session pointer exactly. The returned version is at least the page + cursor, including when a delayed duplicate page arrives after newer events. + When this raises, fetch a fresh focus snapshot rather than guessing state. + """ + if type(after_sequence) is not int or after_sequence < 0 or after_sequence > current.version: + raise FocusReplayGap("invalid replay cursor") + + expected = after_sequence + for event in page.events: + expected += 1 + if event.sequence != expected: + raise FocusReplayGap("account event sequence gap") + if page.next_cursor != expected or page.snapshot_version < expected: + raise FocusReplayGap("inconsistent replay cursor") + if page.has_more != (page.next_cursor < page.snapshot_version): + raise FocusReplayGap("inconsistent replay continuation") + + state = current + for event in page.events: + if event.sequence < state.version: + continue + if event.sequence == state.version: + if ( + (state.event_id is not None and event.event_id != state.event_id) + or event.active_agent_session_id != state.active_agent_session_id + ): + raise FocusReplayGap("conflicting duplicate account event") + continue + if event.sequence != state.version + 1 or event.previous_agent_session_id != state.active_agent_session_id: + raise FocusReplayGap("account focus diverged") + state = FocusState( + active_agent_session_id=event.active_agent_session_id, + version=event.sequence, + event_id=event.event_id, + ) + return state diff --git a/src/xgen_sdk/agent_session/messages.py b/src/xgen_sdk/agent_session/messages.py new file mode 100644 index 0000000..603aa09 --- /dev/null +++ b/src/xgen_sdk/agent_session/messages.py @@ -0,0 +1,143 @@ +"""Wire contract for Workflow's journal-linked completed message pages. + +Messages are a sparse projection of Agent Session events. A page is not a +complete conversation snapshot and must never advance the event replay cursor. +""" + +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class _WireModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class AgentSessionMessage(_WireModel): + turn_id: UUID + sequence: int = Field(ge=1, strict=True) + status: Literal["completed", "failed", "cancelled"] + input_text: str | None = Field(max_length=262144) + output_text: str | None = Field(max_length=262144) + content_complete: bool = Field(strict=True) + source: Literal["unknown", "user", "subagent_report"] + + @model_validator(mode="after") + def consistent_content_flags(self) -> AgentSessionMessage: + if self.content_complete != (self.input_text is not None and self.output_text is not None): + raise ValueError("content_complete does not match available text") + if (self.input_text is None) != (self.source == "unknown"): + raise ValueError("source does not match available input text") + return self + + +class AgentSessionMessagePage(_WireModel): + messages: list[AgentSessionMessage] = Field(max_length=20) + next_cursor: int = Field(ge=0, strict=True) + snapshot_sequence: int = Field(ge=0, strict=True) + state_version: int = Field(ge=1, strict=True) + has_more: bool = Field(strict=True) + + @model_validator(mode="after") + def ordered_page(self) -> AgentSessionMessagePage: + previous = 0 + seen: set[UUID] = set() + for message in self.messages: + if message.sequence <= previous or message.turn_id in seen: + raise ValueError("duplicate or unordered linked message") + previous = message.sequence + seen.add(message.turn_id) + if self.next_cursor > self.snapshot_sequence: + raise ValueError("message cursor exceeds snapshot sequence") + if self.messages and self.next_cursor != previous: + raise ValueError("message cursor is not the last message sequence") + if self.has_more and (not self.messages or self.next_cursor >= self.snapshot_sequence): + raise ValueError("invalid linked message continuation") + return self + + +class _MessageConflictDetail(_WireModel): + code: Literal["SESSION_CURSOR_AHEAD", "SESSION_MESSAGE_LINK_INVALID"] + current_sequence: int = Field(ge=0, strict=True) + state_version: int = Field(ge=1, strict=True) + + +class AgentSessionMessageConflictResponse(_WireModel): + detail: _MessageConflictDetail + + +class AgentSessionMessageCursor(_WireModel): + """Last verified linked message for one Agent Session.""" + + session_id: UUID + sequence: int = Field(ge=0, strict=True) + turn_id: UUID | None + state_version: int = Field(ge=1, strict=True) + + @model_validator(mode="after") + def anchored_cursor(self) -> AgentSessionMessageCursor: + if (self.sequence == 0) != (self.turn_id is None): + raise ValueError("linked message cursor requires a turn anchor") + return self + + +class AgentSessionMessageGap(ValueError): + """Stop paging; do not infer missing content or a complete history.""" + + +def apply_message_page( + current: AgentSessionMessageCursor, + page: AgentSessionMessagePage, + *, + session_id: UUID, + after_sequence: int, +) -> tuple[AgentSessionMessageCursor, tuple[AgentSessionMessage, ...]]: + """Apply a page using sparse message sequence and an exact turn anchor. + + ``after_sequence`` is the cursor sent to the messages endpoint. Delayed + overlapping pages may repeat the last verified turn. Unlike event replay, + message sequence numbers need not be adjacent, and ``has_more=false`` does + not imply ``next_cursor == snapshot_sequence``. + """ + if ( + session_id != current.session_id + or type(after_sequence) is not int + or not 0 <= after_sequence <= current.sequence + ): + raise AgentSessionMessageGap("invalid linked message request cursor") + + last = after_sequence + for message in page.messages: + if message.sequence <= last: + raise AgentSessionMessageGap("linked message sequence did not advance") + last = message.sequence + if page.next_cursor != last or page.snapshot_sequence < last: + raise AgentSessionMessageGap("inconsistent linked message cursor") + + if page.next_cursor < current.sequence: + return current, () + if after_sequence < current.sequence and not any( + message.sequence == current.sequence and message.turn_id == current.turn_id + for message in page.messages + ): + raise AgentSessionMessageGap("linked message overlap lost its turn anchor") + + cursor = current + accepted: list[AgentSessionMessage] = [] + for message in page.messages: + if message.sequence <= cursor.sequence: + continue + if page.state_version < cursor.state_version or message.turn_id == cursor.turn_id: + raise AgentSessionMessageGap("linked message state diverged") + cursor = AgentSessionMessageCursor( + session_id=session_id, sequence=message.sequence, + turn_id=message.turn_id, state_version=page.state_version, + ) + accepted.append(message) + return AgentSessionMessageCursor( + session_id=session_id, sequence=cursor.sequence, turn_id=cursor.turn_id, + state_version=max(cursor.state_version, page.state_version), + ), tuple(accepted) diff --git a/src/xgen_sdk/agent_session/turn_status.py b/src/xgen_sdk/agent_session/turn_status.py new file mode 100644 index 0000000..35dd883 --- /dev/null +++ b/src/xgen_sdk/agent_session/turn_status.py @@ -0,0 +1,216 @@ +"""Strict read contract for one canonical Agent Session turn status. + +The transport is supplied by the caller so the SDK never owns credentials or +authentication policy. A status is a point-in-time server projection: a +``running`` value does not prove that a worker is live, and ``cancelled`` does +not prove that target-side cleanup has completed. +""" + +from __future__ import annotations + +import re +from typing import Any, Literal, Protocol +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +_ERROR_MESSAGE = "Invalid canonical Agent Session turn status" +_UUID = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) +_FIELDS = frozenset( + { + "turn_id", + "agent_session_id", + "status", + "progress", + "accepted_sequence", + "started_sequence", + "terminal_sequence", + "result_available", + "state_version", + } +) +_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"}) + + +class AgentSessionTurnStatusValidationError(ValueError): + """The supplied value is not a canonical turn-status response.""" + + +def _canonical_uuid(value: Any) -> UUID: + if type(value) is UUID: + rendered = str(value) + elif type(value) is str: + rendered = value + else: + raise ValueError("identifier must be a UUID") + if not _UUID.fullmatch(rendered): + raise ValueError("identifier must be a canonical non-nil UUID") + return UUID(rendered) + + +class AgentSessionTurnStatus(BaseModel): + """Frozen, exact DTO returned by the single-turn status endpoint.""" + + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + ) + + turn_id: UUID + agent_session_id: UUID + status: Literal["accepted", "running", "completed", "failed", "cancelled"] + progress: Literal[ + "awaiting_claim", + "running_or_unknown", + "awaiting_reconciliation", + "final", + ] + accepted_sequence: int = Field(ge=1, strict=True) + started_sequence: int | None = Field(ge=1, strict=True) + terminal_sequence: int | None = Field(ge=1, strict=True) + result_available: bool = Field(strict=True) + state_version: int = Field(ge=1, strict=True) + + @field_validator("turn_id", "agent_session_id", mode="before") + @classmethod + def canonical_identifier(cls, value: Any) -> UUID: + return _canonical_uuid(value) + + @model_validator(mode="after") + def coherent_projection(self) -> AgentSessionTurnStatus: + allowed_progress = { + "accepted": {"awaiting_claim", "awaiting_reconciliation"}, + "running": {"running_or_unknown", "awaiting_reconciliation"}, + "completed": {"final"}, + "failed": {"final"}, + "cancelled": {"final"}, + } + if self.progress not in allowed_progress[self.status]: + raise ValueError("status and progress are inconsistent") + + if self.status == "accepted" and self.started_sequence is not None: + raise ValueError("an accepted turn cannot have a start sequence") + if ( + self.started_sequence is not None + and self.started_sequence <= self.accepted_sequence + ): + raise ValueError("start sequence must follow acceptance") + if self.terminal_sequence is not None: + if self.terminal_sequence <= self.accepted_sequence: + raise ValueError("terminal sequence must follow acceptance") + if ( + self.started_sequence is not None + and self.terminal_sequence <= self.started_sequence + ): + raise ValueError("terminal sequence must follow start") + if self.result_available and ( + self.status not in _TERMINAL_STATUSES + or self.terminal_sequence is None + ): + raise ValueError("available results require terminal evidence") + return self + + @classmethod + def model_construct( + cls, _fields_set: set[str] | None = None, **values: Any + ) -> AgentSessionTurnStatus: + if set(values) - _FIELDS or ( + _fields_set is not None and set(_fields_set) - _FIELDS + ): + raise AgentSessionTurnStatusValidationError(_ERROR_MESSAGE) from None + return super().model_construct(_fields_set=_fields_set, **values) + + +class AgentSessionTurnStatusGetTransport(Protocol): + """Caller-owned authenticated JSON GET callback.""" + + async def __call__(self, path: str, /) -> object: ... + + +def _identifier(value: Any) -> UUID: + try: + return _canonical_uuid(value) + except Exception: + pass + raise AgentSessionTurnStatusValidationError(_ERROR_MESSAGE) from None + + +def _model_input(value: Any) -> dict[str, Any]: + if type(value) is dict: + if set(value) != _FIELDS: + raise ValueError("response fields are not exact") + return {field: value[field] for field in _FIELDS} + if type(value) is not AgentSessionTurnStatus: + raise ValueError("unexpected turn-status model type") + if set(value.__dict__) != _FIELDS: + raise ValueError("turn-status model fields are not exact") + extra = getattr(value, "__pydantic_extra__", None) + if extra not in (None, {}): + raise ValueError("turn-status model has extra fields") + fields_set = getattr(value, "__pydantic_fields_set__", set()) + if not isinstance(fields_set, set) or not fields_set <= _FIELDS: + raise ValueError("turn-status model field set is invalid") + return {field: value.__dict__[field] for field in _FIELDS} + + +def parse_agent_session_turn_status( + value: Any, + *, + expected_session_id: str | UUID, + expected_turn_id: str | UUID, +) -> AgentSessionTurnStatus: + """Fully revalidate an exact response and bind it to the requested IDs.""" + expected_session = _identifier(expected_session_id) + expected_turn = _identifier(expected_turn_id) + try: + parsed = AgentSessionTurnStatus.model_validate(_model_input(value), strict=True) + if ( + parsed.agent_session_id != expected_session + or parsed.turn_id != expected_turn + ): + raise ValueError("response identity does not match the request") + return parsed + except Exception: + pass + raise AgentSessionTurnStatusValidationError(_ERROR_MESSAGE) from None + + +class AgentSessionTurnStatusClient: + """Read one turn through a caller-authenticated, fixed-path GET callback.""" + + __slots__ = ("_get",) + + def __init__(self, get: AgentSessionTurnStatusGetTransport) -> None: + if not callable(get): + raise TypeError("get transport must be callable") + self._get = get + + async def read( + self, session_id: str | UUID, turn_id: str | UUID + ) -> AgentSessionTurnStatus: + canonical_session = _identifier(session_id) + canonical_turn = _identifier(turn_id) + path = ( + f"/api/agentflow/agent-sessions/{canonical_session}" + f"/turns/{canonical_turn}" + ) + value = await self._get(path) + return parse_agent_session_turn_status( + value, + expected_session_id=canonical_session, + expected_turn_id=canonical_turn, + ) + + +__all__ = [ + "AgentSessionTurnStatus", + "AgentSessionTurnStatusClient", + "AgentSessionTurnStatusGetTransport", + "AgentSessionTurnStatusValidationError", + "parse_agent_session_turn_status", +] diff --git a/src/xgen_sdk/agent_session/turns.py b/src/xgen_sdk/agent_session/turns.py new file mode 100644 index 0000000..2038c33 --- /dev/null +++ b/src/xgen_sdk/agent_session/turns.py @@ -0,0 +1,86 @@ +"""Pure canonical Agent Session turn submission wire contract. + +This module validates inert request data only. It does not authenticate a +caller, upload content, verify a receipt or HMAC, or prove server ownership of +an attachment. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .attachments import AGENT_ATTACHMENT_MAX_COUNT, AgentAttachmentReference, parse_attachment_references + + +_ERROR_MESSAGE = "Invalid canonical Agent Session turn submission" + + +class AgentSessionTurnValidationError(ValueError): + """The supplied value is not a safe canonical turn submission.""" + + +class SubmitAgentSessionTurn(BaseModel): + """Strict immutable request body for one canonical Agent Session turn.""" + + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + ) + + input_text: str = Field(min_length=1, max_length=262_144) + expected_state_version: int = Field(ge=1, strict=True) + idempotency_key: str = Field(min_length=1, max_length=128) + origin_id: str | None = Field(default=None, min_length=1, max_length=128) + attachments: tuple[AgentAttachmentReference, ...] = Field(default=(), max_length=AGENT_ATTACHMENT_MAX_COUNT) + + @field_validator("attachments", mode="before") + @classmethod + def canonical_attachments(cls, value: Any) -> tuple[AgentAttachmentReference, ...]: + return parse_attachment_references(value) + + @classmethod + def model_construct( + cls, _fields_set: set[str] | None = None, **values: Any + ) -> SubmitAgentSessionTurn: + declared = set(cls.model_fields) + if set(values) - declared or (_fields_set is not None and set(_fields_set) - declared): + raise AgentSessionTurnValidationError(_ERROR_MESSAGE) from None + return super().model_construct(_fields_set=_fields_set, **values) + + +def _model_input(value: Any) -> dict[str, Any]: + if type(value) is not SubmitAgentSessionTurn: + raise ValueError("unexpected turn submission model type") + declared = set(SubmitAgentSessionTurn.model_fields) + if set(value.__dict__) != declared: + raise ValueError("turn submission model fields are not exact") + extra = getattr(value, "__pydantic_extra__", None) + if extra not in (None, {}): + raise ValueError("turn submission model has extra fields") + fields_set = getattr(value, "__pydantic_fields_set__", set()) + if not isinstance(fields_set, set) or not fields_set <= declared: + raise ValueError("turn submission model field set is invalid") + # Preserve the raw nested reference instances for the before-validator. + # Serializing here could silently discard an injected field from a forged + # reference before ``parse_attachment_references`` has a chance to reject it. + return {field: value.__dict__[field] for field in declared} + + +def parse_turn_submission(value: Any) -> SubmitAgentSessionTurn: + """Copy and fully revalidate an exact turn DTO without trusting construction.""" + try: + return SubmitAgentSessionTurn.model_validate(_model_input(value), strict=True) + except Exception: + pass + raise AgentSessionTurnValidationError(_ERROR_MESSAGE) from None + + +__all__ = [ + "AgentSessionTurnValidationError", + "SubmitAgentSessionTurn", + "parse_turn_submission", +] diff --git a/src/xgen_sdk/approval/catalog.py b/src/xgen_sdk/approval/catalog.py index bbd51d9..df025e2 100644 --- a/src/xgen_sdk/approval/catalog.py +++ b/src/xgen_sdk/approval/catalog.py @@ -96,6 +96,10 @@ def to_dict(self) -> Dict[str, object]: #: 한다(workflow 의 적용 워커가 documents 내부 API 로 넘긴다 — filestore.embed 와 같은 길). FILESTORE_UPLOAD = "filestore.upload" +#: Canonical Agent Session에서 정확한 연결·도구·인자 digest로 묶인 1회 기기 실행. +#: 승인은 권한 기록만 남기며 원격 부작용은 현재 세션/기기 검증 후 Workflow가 수행한다. +CAPABILITY_INVOKE = "capability.invoke" + CATALOG: Tuple[ActionSpec, ...] = ( # ── 자유 결재 — 게이트가 아니다 ── ActionSpec( @@ -164,6 +168,11 @@ def to_dict(self) -> Dict[str, object]: "에이전트가 만든 도구를 전체 사용자에게 게시하는 것", OWNER_WORKFLOW, DOMAIN_TOOL, ), + ActionSpec( + CAPABILITY_INVOKE, "기기 도구 실행", + "검증된 Agent Session에서 선택한 기기의 정확한 도구와 입력으로 한 번 실행하는 것", + OWNER_WORKFLOW, DOMAIN_TOOL, + ), ) _BY_TYPE: Dict[str, ActionSpec] = {s.action_type: s for s in CATALOG} diff --git a/tests/fixtures/agent-session-attachments.json b/tests/fixtures/agent-session-attachments.json new file mode 100644 index 0000000..30f38bf --- /dev/null +++ b/tests/fixtures/agent-session-attachments.json @@ -0,0 +1,1770 @@ +{ + "version": 1, + "cases": [ + { + "name": "empty", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "unicode filename and workflow", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": true, + "references": [ + { + "attachment_id": "22222222-2222-4222-8222-222222222222", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + { + "name": "ordered distinct references", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "33333333-3333-4333-8333-333333333333", + "filename": "empty.bin", + "size_bytes": 0, + "media_type": "application/octet-stream", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": true, + "references": [ + { + "attachment_id": "33333333-3333-4333-8333-333333333333", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "attachment_id": "22222222-2222-4222-8222-222222222222", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + { + "name": "scope mismatch origin", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://other.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "scope mismatch user_id", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "8", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "scope mismatch session_id", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "44444444-4444-4444-8444-444444444444", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "scope mismatch workflow_id", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "other", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "URL authority", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://user:password@xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "HTTP origin", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "http://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "origin path", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com/path", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "origin noncanonical", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://XGEN.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "user leading zero", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "07", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "user number", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": 7, + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "user overflow", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "9223372036854775808", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "workflow empty", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "workflow control", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "wf\n", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "uuid uppercase", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "ABCDEFAB-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "uuid invalid variant", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-7222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "name path", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "../secret", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "name windows path", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "C:\\secret", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "name dot", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": ".", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "name decomposed", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "e\u0301.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": true, + "references": [ + { + "attachment_id": "22222222-2222-4222-8222-222222222222", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + { + "name": "name control", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "a\u0000.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "name bidi", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "a\u202etxt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "name surrogate", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "a\ud800", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "size bool", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": true, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "size decimal", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1.2, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "size negative", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": -1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "size limit", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 104857601, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "MIME params", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain;charset=utf-8", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "MIME uppercase", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "Text/Plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "digest uppercase", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ], + "valid": false + }, + { + "name": "digest short", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "raw path", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "path": "uploads/users_7/file" + } + ], + "valid": false + }, + { + "name": "raw URL", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "url": "https://untrusted.example/a" + } + ], + "valid": false + }, + { + "name": "raw bytes", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "data_base64": "YWJj" + } + ], + "valid": false + }, + { + "name": "trust claim", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "trusted": true + } + ], + "valid": false + }, + { + "name": "receipt format version", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "version": 1 + } + ], + "valid": false + }, + { + "name": "duplicate ID", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "duplicate ID changed digest", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], + "valid": false + }, + { + "name": "too many", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000000-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000001-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000002-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000003-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000004-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000005-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000006-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000007-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000008-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "00000009-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "0000000a-1111-4111-8111-111111111111", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "aggregate limit", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 104857600, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "33333333-3333-4333-8333-333333333333", + "filename": "empty.bin", + "size_bytes": 0, + "media_type": "application/octet-stream", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "44444444-4444-4444-8444-444444444444", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "receipt missing digest", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain" + } + ], + "valid": false + }, + { + "name": "receipt null", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + null + ], + "valid": false + }, + { + "name": "scope extra", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "tenant_id": 1 + }, + "receipts": [], + "valid": false + }, + { + "name": "scope malformed", + "scope": { + "origin": "https://xgen.example.com/", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://localhost:3443", + "scope": { + "origin": "https://localhost:3443", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "origin https://127.0.0.1:3443", + "scope": { + "origin": "https://127.0.0.1:3443", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "origin https://[::1]:3443", + "scope": { + "origin": "https://[::1]:3443", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "origin https://[2001:db8::1]", + "scope": { + "origin": "https://[2001:db8::1]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "origin https://xn--bcher-kva.example", + "scope": { + "origin": "https://xn--bcher-kva.example", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://127.1", + "scope": { + "origin": "https://127.1", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://127.0.0.01", + "scope": { + "origin": "https://127.0.0.01", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://[2001:0db8::1]", + "scope": { + "origin": "https://[2001:0db8::1]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://localhost:03443", + "scope": { + "origin": "https://localhost:03443", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://localhost:443", + "scope": { + "origin": "https://localhost:443", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://localhost:0", + "scope": { + "origin": "https://localhost:0", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://localhost:65536", + "scope": { + "origin": "https://localhost:65536", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://a_b.example", + "scope": { + "origin": "https://a_b.example", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://-a.example", + "scope": { + "origin": "https://-a.example", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://example.com.", + "scope": { + "origin": "https://example.com.", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://b\u00fccher.example", + "scope": { + "origin": "https://b\u00fccher.example", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://example.com?x=1", + "scope": { + "origin": "https://example.com?x=1", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://example.com#x", + "scope": { + "origin": "https://example.com#x", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "trailing newline attachment_id", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222\n", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "trailing newline sha256", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + } + ], + "valid": false + }, + { + "name": "trailing newline media_type", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain\n", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "user trailing newline", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7\n", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://[::ffff:c000:201]", + "scope": { + "origin": "https://[::ffff:c000:201]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "origin https://[::ffff:0:0]", + "scope": { + "origin": "https://[::ffff:0:0]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "origin https://[::ffff:192.0.2.1]", + "scope": { + "origin": "https://[::ffff:192.0.2.1]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://0x7f.1", + "scope": { + "origin": "https://0x7f.1", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://2130706433", + "scope": { + "origin": "https://2130706433", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://foo.123", + "scope": { + "origin": "https://foo.123", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://foo.0x1", + "scope": { + "origin": "https://foo.0x1", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://[1:0:0:2::3:4]", + "scope": { + "origin": "https://[1:0:0:2::3:4]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://[1::2:0:0:3:4]", + "scope": { + "origin": "https://[1::2:0:0:3:4]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "scope session_id forbidden end/path", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111\n", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "scope workflow_id forbidden end/path", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "../wf" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://xn--a.example", + "scope": { + "origin": "https://xn--a.example", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://xn--0.example", + "scope": { + "origin": "https://xn--0.example", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://xn--abc.example", + "scope": { + "origin": "https://xn--abc.example", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://xn--a-ecp.example", + "scope": { + "origin": "https://xn--a-ecp.example", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://[1:0:0:2::3]", + "scope": { + "origin": "https://[1:0:0:2::3]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "origin https://[::]", + "scope": { + "origin": "https://[::]", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + }, + { + "name": "raw Unicode16 combining metadata", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "e\u0301-a\u0315\ud804\udfce" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "e\u0301-a\u0315\ud804\udfce", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "e\u0301-a\u0315\ud804\udfce.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": true, + "references": [ + { + "attachment_id": "22222222-2222-4222-8222-222222222222", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + { + "name": "exact Unicode scope differs", + "scope": { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "\u00e9" + }, + "receipts": [ + { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "e\u0301", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "filename": "\uc790\ub8cc.txt", + "size_bytes": 42, + "media_type": "text/plain", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "valid": false + }, + { + "name": "origin https://foo.0x", + "scope": { + "origin": "https://foo.0x", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://0x", + "scope": { + "origin": "https://0x", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": false + }, + { + "name": "origin https://foo.0xffg", + "scope": { + "origin": "https://foo.0xffg", + "user_id": "7", + "session_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "workflow-\ud55c\uae00" + }, + "receipts": [], + "valid": true, + "references": [] + } + ] +} diff --git a/tests/fixtures/agent-session-broker-operations.json b/tests/fixtures/agent-session-broker-operations.json new file mode 100644 index 0000000..ba885e9 --- /dev/null +++ b/tests/fixtures/agent-session-broker-operations.json @@ -0,0 +1,1018 @@ +{ + "valid": { + "identifiers": [ + { + "name": "canonical request identifiers", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222" + } + ], + "lists": [ + { + "name": "empty operation list", + "response": [] + }, + { + "name": "all public operation references", + "response": [ + { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "44444444-4444-4444-8444-444444444444", + "operation_id": "documents.collection.documents.list" + }, + { + "invocation_id": "55555555-5555-4555-8555-555555555555", + "operation_id": "workflow.actor.authorize" + }, + { + "invocation_id": "66666666-6666-4666-8666-666666666666", + "operation_id": "documents.attachment.index" + }, + { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + { + "invocation_id": "88888888-8888-4888-8888-888888888888", + "operation_id": "workflow.attachments.stage" + } + ] + } + ], + "metadata": [ + { + "name": "documents running metadata", + "reference": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list" + }, + "response": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list", + "state": "running", + "cancel_requested": false, + "target_cleanup": "not_applicable", + "result_available": false, + "expires_at": 1 + } + }, + { + "name": "sandbox completed result metadata", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "completed", + "cancel_requested": false, + "target_cleanup": "complete", + "result_available": true, + "expires_at": 9007199254740991 + } + }, + { + "name": "sandbox cancelled cleanup pending metadata", + "reference": { + "invocation_id": "88888888-8888-4888-8888-888888888888", + "operation_id": "workflow.attachments.stage" + }, + "response": { + "invocation_id": "88888888-8888-4888-8888-888888888888", + "operation_id": "workflow.attachments.stage", + "state": "cancelled", + "cancel_requested": true, + "target_cleanup": "pending", + "result_available": false, + "expires_at": 1770000030 + } + }, + { + "name": "sandbox unknown cleanup metadata", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "unknown", + "cancel_requested": true, + "target_cleanup": "unknown", + "result_available": false, + "expires_at": 1770000030 + } + } + ], + "cancellations": [ + { + "name": "cancellation accepted", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "cancel_requested": true + } + } + ] + }, + "invalid": { + "identifiers": [ + { + "name": "nil session identifier", + "field": "session_id", + "value": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "uppercase turn identifier", + "field": "turn_id", + "value": "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA" + }, + { + "name": "noncanonical invocation identifier", + "field": "invocation_id", + "value": "{77777777-7777-4777-8777-777777777777}" + }, + { + "name": "null identifier", + "field": "session_id", + "value": null + } + ], + "lists": [ + { + "name": "list response is null", + "response": null + }, + { + "name": "reference has private result field", + "response": [ + { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list", + "result": { + "private": "redacted" + } + } + ] + }, + { + "name": "reference has unknown operation", + "response": [ + { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.secret.read" + } + ] + }, + { + "name": "duplicate invocation identifiers", + "response": [ + { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "workflow.code.execute" + } + ] + }, + { + "name": "list exceeds 128 references", + "response": [ + { + "invocation_id": "90000000-0000-4000-8000-000000000001", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000002", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000003", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000004", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000005", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000006", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000007", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000008", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000009", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000010", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000011", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000012", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000013", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000014", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000015", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000016", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000017", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000018", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000019", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000020", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000021", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000022", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000023", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000024", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000025", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000026", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000027", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000028", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000029", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000030", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000031", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000032", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000033", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000034", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000035", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000036", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000037", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000038", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000039", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000040", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000041", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000042", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000043", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000044", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000045", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000046", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000047", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000048", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000049", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000050", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000051", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000052", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000053", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000054", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000055", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000056", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000057", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000058", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000059", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000060", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000061", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000062", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000063", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000064", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000065", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000066", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000067", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000068", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000069", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000070", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000071", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000072", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000073", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000074", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000075", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000076", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000077", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000078", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000079", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000080", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000081", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000082", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000083", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000084", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000085", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000086", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000087", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000088", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000089", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000090", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000091", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000092", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000093", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000094", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000095", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000096", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000097", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000098", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000099", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000100", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000101", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000102", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000103", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000104", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000105", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000106", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000107", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000108", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000109", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000110", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000111", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000112", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000113", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000114", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000115", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000116", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000117", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000118", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000119", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000120", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000121", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000122", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000123", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000124", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000125", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000126", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000127", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000128", + "operation_id": "documents.collections.list" + }, + { + "invocation_id": "90000000-0000-4000-8000-000000000129", + "operation_id": "documents.collections.list" + } + ] + } + ], + "metadata": [ + { + "name": "stale invocation metadata", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "99999999-9999-4999-8999-999999999999", + "operation_id": "workflow.code.execute", + "state": "completed", + "cancel_requested": false, + "target_cleanup": "complete", + "result_available": true, + "expires_at": 1770000030 + } + }, + { + "name": "wrong operation metadata", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.attachments.stage", + "state": "completed", + "cancel_requested": false, + "target_cleanup": "complete", + "result_available": true, + "expires_at": 1770000030 + } + }, + { + "name": "metadata exposes private result", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "completed", + "cancel_requested": false, + "target_cleanup": "complete", + "result_available": true, + "expires_at": 1770000030, + "result": { + "private": "redacted" + } + } + }, + { + "name": "metadata is null", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": null + }, + { + "name": "boolean expiry", + "reference": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list" + }, + "response": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list", + "state": "running", + "cancel_requested": false, + "target_cleanup": "not_applicable", + "result_available": false, + "expires_at": true + } + }, + { + "name": "zero expiry", + "reference": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list" + }, + "response": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list", + "state": "running", + "cancel_requested": false, + "target_cleanup": "not_applicable", + "result_available": false, + "expires_at": 0 + } + }, + { + "name": "expiry exceeds safe integer", + "reference": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list" + }, + "response": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list", + "state": "running", + "cancel_requested": false, + "target_cleanup": "not_applicable", + "result_available": false, + "expires_at": 9007199254740992 + } + }, + { + "name": "non-sandbox result availability", + "reference": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list" + }, + "response": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list", + "state": "completed", + "cancel_requested": false, + "target_cleanup": "not_applicable", + "result_available": true, + "expires_at": 1770000030 + } + }, + { + "name": "running sandbox result availability", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "running", + "cancel_requested": false, + "target_cleanup": "complete", + "result_available": true, + "expires_at": 1770000030 + } + }, + { + "name": "non-sandbox cleanup pending", + "reference": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list" + }, + "response": { + "invocation_id": "33333333-3333-4333-8333-333333333333", + "operation_id": "documents.collections.list", + "state": "running", + "cancel_requested": false, + "target_cleanup": "pending", + "result_available": false, + "expires_at": 1770000030 + } + }, + { + "name": "sandbox cleanup not applicable", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "completed", + "cancel_requested": false, + "target_cleanup": "not_applicable", + "result_available": true, + "expires_at": 1770000030 + } + }, + { + "name": "unknown state value", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "queued", + "cancel_requested": false, + "target_cleanup": "complete", + "result_available": true, + "expires_at": 1770000030 + } + }, + { + "name": "unknown cleanup value", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "completed", + "cancel_requested": false, + "target_cleanup": "removed", + "result_available": true, + "expires_at": 1770000030 + } + }, + { + "name": "numeric cancel requested", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "completed", + "cancel_requested": 1, + "target_cleanup": "complete", + "result_available": true, + "expires_at": 1770000030 + } + }, + { + "name": "numeric result available", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "completed", + "cancel_requested": false, + "target_cleanup": "complete", + "result_available": 1, + "expires_at": 1770000030 + } + }, + { + "name": "null cleanup", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute", + "state": "completed", + "cancel_requested": false, + "target_cleanup": null, + "result_available": true, + "expires_at": 1770000030 + } + } + ], + "cancellations": [ + { + "name": "stale invocation cancellation", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "99999999-9999-4999-8999-999999999999", + "cancel_requested": true + } + }, + { + "name": "cancellation not accepted", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "cancel_requested": false + } + }, + { + "name": "cancellation exposes cleanup", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "cancel_requested": true, + "target_cleanup": "complete" + } + }, + { + "name": "cancellation is null", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": null + }, + { + "name": "numeric cancellation acknowledgement", + "reference": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "operation_id": "workflow.code.execute" + }, + "response": { + "invocation_id": "77777777-7777-4777-8777-777777777777", + "cancel_requested": 1 + } + } + ] + } +} diff --git a/tests/fixtures/agent-session-turn-status.json b/tests/fixtures/agent-session-turn-status.json new file mode 100644 index 0000000..3d9eb7d --- /dev/null +++ b/tests/fixtures/agent-session-turn-status.json @@ -0,0 +1,486 @@ +{ + "valid": [ + { + "name": "accepted awaiting claim", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "accepted awaiting reconciliation with terminal evidence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_reconciliation", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": 11, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "running", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "running", + "progress": "running_or_unknown", + "accepted_sequence": 10, + "started_sequence": 11, + "terminal_sequence": null, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "running with incomplete start evidence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "running", + "progress": "running_or_unknown", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "running awaiting reconciliation", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "running", + "progress": "awaiting_reconciliation", + "accepted_sequence": 10, + "started_sequence": 11, + "terminal_sequence": 12, + "result_available": false, + "state_version": 5 + } + }, + { + "name": "completed result with full journal evidence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "completed", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": 11, + "terminal_sequence": 12, + "result_available": true, + "state_version": 5 + } + }, + { + "name": "completed result with missing start evidence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "completed", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": 11, + "result_available": true, + "state_version": 5 + } + }, + { + "name": "completed without result or journal evidence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "completed", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 5 + } + }, + { + "name": "cancelled before start", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "cancelled", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": 11, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "failed with incomplete journal evidence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "failed", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 4 + } + } + ], + "invalid": [ + { + "name": "response session identity mismatch", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "33333333-3333-4333-8333-333333333333", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "response turn identity mismatch", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "33333333-3333-4333-8333-333333333333", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "unknown status", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "queued", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "accepted with running progress", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "running_or_unknown", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "terminal status without final progress", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "failed", + "progress": "awaiting_reconciliation", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": 11, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "accepted with start sequence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": 11, + "terminal_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "start does not follow acceptance", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "running", + "progress": "running_or_unknown", + "accepted_sequence": 10, + "started_sequence": 10, + "terminal_sequence": null, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "terminal does not follow acceptance", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "cancelled", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": 10, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "terminal does not follow start", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "completed", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": 12, + "terminal_sequence": 11, + "result_available": false, + "state_version": 5 + } + }, + { + "name": "nonterminal result availability", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "running", + "progress": "awaiting_reconciliation", + "accepted_sequence": 10, + "started_sequence": 11, + "terminal_sequence": 12, + "result_available": true, + "state_version": 5 + } + }, + { + "name": "result without terminal evidence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "completed", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": true, + "state_version": 5 + } + }, + { + "name": "boolean accepted sequence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": true, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "boolean start sequence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "running", + "progress": "running_or_unknown", + "accepted_sequence": 10, + "started_sequence": true, + "terminal_sequence": null, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "boolean terminal sequence", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "completed", + "progress": "final", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": true, + "result_available": false, + "state_version": 4 + } + }, + { + "name": "numeric result availability", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": 0, + "state_version": 3 + } + }, + { + "name": "string state version", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": "3" + } + }, + { + "name": "zero state version", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 0 + } + }, + { + "name": "noncanonical uppercase response UUID", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "missing response field", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "result_available": false, + "state_version": 3 + } + }, + { + "name": "extra response field", + "session_id": "11111111-1111-4111-8111-111111111111", + "turn_id": "22222222-2222-4222-8222-222222222222", + "response": { + "turn_id": "22222222-2222-4222-8222-222222222222", + "agent_session_id": "11111111-1111-4111-8111-111111111111", + "status": "accepted", + "progress": "awaiting_claim", + "accepted_sequence": 10, + "started_sequence": null, + "terminal_sequence": null, + "result_available": false, + "state_version": 3, + "private_token": "not-a-real-token" + } + } + ] +} diff --git a/tests/test_agent_session_attachments.py b/tests/test_agent_session_attachments.py new file mode 100644 index 0000000..563e67c --- /dev/null +++ b/tests/test_agent_session_attachments.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from xgen_sdk.agent_session import ( + AGENT_ATTACHMENT_MAX_BYTES, + AGENT_ATTACHMENT_MAX_COUNT, + AGENT_ATTACHMENT_MAX_TOTAL_BYTES, + AgentAttachmentReceipt, + AgentAttachmentReference, + AgentAttachmentScope, + AgentAttachmentValidationError, + parse_attachment_receipt, + parse_attachment_scope, + prepare_attachment_references, +) + + +FIXTURE = Path(__file__).parent / "fixtures" / "agent-session-attachments.json" +SESSION_ID = "11111111-1111-4111-8111-111111111111" +ATTACHMENT_ID = "22222222-2222-4222-8222-222222222222" +DIGEST = "a" * 64 +ERROR = "Invalid canonical Agent Session attachment metadata" + + +def scope(**changes: object) -> dict[str, object]: + value: dict[str, object] = { + "origin": "https://xgen.example.com", + "user_id": "7", + "session_id": SESSION_ID, + "workflow_id": "workflow-한글", + } + value.update(changes) + return value + + +def receipt(index: int = 0, **changes: object) -> dict[str, object]: + value = { + **scope(), + "attachment_id": f"{index:08x}-1111-4111-8111-111111111111", + "filename": "자료.txt", + "size_bytes": 1, + "media_type": "text/plain", + "sha256": DIGEST, + } + value.update(changes) + return value + + +def load_cases() -> list[dict[str, object]]: + data = json.loads(FIXTURE.read_text(encoding="utf-8")) + assert data["version"] == 1 + return data["cases"] + + +@pytest.mark.parametrize("case", load_cases(), ids=lambda case: str(case["name"])) +def test_shared_cross_language_vectors(case: dict[str, object]) -> None: + operation = lambda: prepare_attachment_references(case["receipts"], case["scope"]) + if case["valid"]: + references = operation() + assert [item.model_dump() for item in references] == case["references"] + else: + with pytest.raises(AgentAttachmentValidationError, match=f"^{ERROR}$"): + operation() + + +def test_scope_and_receipt_helpers_copy_and_match_all_scope_fields() -> None: + raw_scope = scope() + parsed_scope = parse_attachment_scope(raw_scope) + copied_scope = parse_attachment_scope(parsed_scope) + assert parsed_scope == copied_scope + assert parsed_scope is not copied_scope + + raw_receipt = receipt(2) + parsed_receipt = parse_attachment_receipt(raw_receipt, parsed_scope) + copied_receipt = parse_attachment_receipt(parsed_receipt, raw_scope) + assert parsed_receipt == copied_receipt + assert parsed_receipt is not copied_receipt + + raw_scope["workflow_id"] = "mutated" + raw_receipt["filename"] = "mutated.txt" + assert parsed_scope.workflow_id == "workflow-한글" + assert parsed_receipt.filename == "자료.txt" + + for field, different in { + "origin": "https://other.example.com", + "user_id": "8", + "session_id": "33333333-3333-4333-8333-333333333333", + "workflow_id": "other", + }.items(): + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_receipt(receipt(2, **{field: different}), scope()) + + +def test_models_are_frozen_exact_and_constructed_models_are_fully_revalidated() -> None: + parsed_scope = parse_attachment_scope(scope()) + parsed_receipt = parse_attachment_receipt(receipt(2), parsed_scope) + reference = AgentAttachmentReference(attachment_id=ATTACHMENT_ID, sha256=DIGEST) + for model, field in [ + (parsed_scope, "workflow_id"), + (parsed_receipt, "filename"), + (reference, "sha256"), + ]: + with pytest.raises(ValidationError): + setattr(model, field, "mutated") + + with pytest.raises(ValidationError): + AgentAttachmentReference.model_validate( + {"attachment_id": ATTACHMENT_ID, "sha256": DIGEST, "url": "https://untrusted.example"} + ) + + forged_scope = AgentAttachmentScope.model_construct(**scope(origin="http://xgen.example.com")) + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_scope(forged_scope) + forged_receipt = AgentAttachmentReceipt.model_construct(**receipt(2, size_bytes=True)) + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_receipt(forged_receipt, scope()) + with pytest.raises(AgentAttachmentValidationError): + prepare_attachment_references([forged_receipt], scope()) + + with pytest.raises(AgentAttachmentValidationError, match=f"^{ERROR}$"): + AgentAttachmentScope.model_construct(**scope(), tenant_id="private-secret") + + injected = AgentAttachmentReceipt.model_construct(**receipt(2)) + injected.__dict__["url"] = "https://private-secret.example" + with pytest.raises(AgentAttachmentValidationError, match=f"^{ERROR}$"): + parse_attachment_receipt(injected, scope()) + + injected_extra = AgentAttachmentReceipt.model_construct(**receipt(2)) + object.__setattr__(injected_extra, "__pydantic_extra__", {"url": "private-secret"}) + with pytest.raises(AgentAttachmentValidationError, match=f"^{ERROR}$"): + prepare_attachment_references([injected_extra], scope()) + + class ScopeSubclass(AgentAttachmentScope): + def model_dump(self, *args, **kwargs): + return scope() + + with pytest.raises(AgentAttachmentValidationError, match=f"^{ERROR}$"): + parse_attachment_scope(ScopeSubclass(**scope())) + + +def test_references_preserve_order_are_immutable_and_do_not_alias_receipts() -> None: + first = receipt(2, sha256="a" * 64) + second = receipt(3, sha256="b" * 64) + references = prepare_attachment_references([first, second], scope()) + assert isinstance(references, tuple) + assert [item.attachment_id for item in references] == [ + first["attachment_id"], + second["attachment_id"], + ] + first["attachment_id"] = "44444444-4444-4444-8444-444444444444" + first["sha256"] = "c" * 64 + assert references[0].attachment_id == "00000002-1111-4111-8111-111111111111" + assert references[0].sha256 == "a" * 64 + with pytest.raises(ValidationError): + references[0].sha256 = "d" * 64 + + +def test_count_size_and_aggregate_boundaries() -> None: + maximum = receipt(0, size_bytes=AGENT_ATTACHMENT_MAX_BYTES) + zero = receipt(1, size_bytes=0) + references = prepare_attachment_references([maximum, zero], scope()) + assert len(references) == 2 + assert AGENT_ATTACHMENT_MAX_BYTES == AGENT_ATTACHMENT_MAX_TOTAL_BYTES == 104_857_600 + + ten = [receipt(index) for index in range(AGENT_ATTACHMENT_MAX_COUNT)] + assert len(prepare_attachment_references(ten, scope())) == 10 + with pytest.raises(AgentAttachmentValidationError): + prepare_attachment_references([*ten, receipt(10)], scope()) + with pytest.raises(AgentAttachmentValidationError): + prepare_attachment_references( + [maximum, receipt(1, size_bytes=1)], + scope(), + ) + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_receipt(receipt(0, size_bytes=AGENT_ATTACHMENT_MAX_BYTES + 1), scope()) + + +def test_utf8_byte_boundaries_mime_boundaries_and_strict_primitives() -> None: + exact_workflow = "é" * 64 + assert len(exact_workflow.encode()) == 128 + assert parse_attachment_scope(scope(workflow_id=exact_workflow)).workflow_id == exact_workflow + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_scope(scope(workflow_id=exact_workflow + "é")) + + exact_filename = "é" * 127 + "a" + assert len(exact_filename.encode()) == 255 + assert parse_attachment_receipt(receipt(2, filename=exact_filename), scope()).filename == exact_filename + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_receipt(receipt(2, filename=exact_filename + "a"), scope()) + + exact_mime = f"{'a' * 127}/{'b' * 127}" + assert len(exact_mime) == 255 + assert parse_attachment_receipt(receipt(2, media_type=exact_mime), scope()).media_type == exact_mime + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_receipt(receipt(2, media_type=f"{'a' * 128}/b"), scope()) + + decomposed = "e\u0301" + decomposed_scope = scope(workflow_id=f"workflow-{decomposed}") + decomposed_receipt = receipt(2, workflow_id=f"workflow-{decomposed}", filename=f"{decomposed}.txt") + parsed = parse_attachment_receipt(decomposed_receipt, decomposed_scope) + assert parsed.workflow_id == f"workflow-{decomposed}" + assert parsed.filename == f"{decomposed}.txt" + assert prepare_attachment_references([parsed], decomposed_scope)[0].attachment_id == parsed.attachment_id + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_receipt(decomposed_receipt, scope(workflow_id="workflow-é")) + + for field, value in [ + ("origin", 7), + ("user_id", 7), + ("session_id", 7), + ("workflow_id", 7), + ]: + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_scope(scope(**{field: value})) + for field, value in [ + ("attachment_id", 7), + ("filename", 7), + ("size_bytes", True), + ("size_bytes", "1"), + ("media_type", 7), + ("sha256", 7), + ]: + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_receipt(receipt(2, **{field: value}), scope()) + + +@pytest.mark.parametrize( + "origin", + [ + "https://xgen.example.com", + "https://localhost:3443", + "https://127.0.0.1:3443", + "https://[::1]:3443", + "https://[2001:db8::1]", + "https://[::ffff:c000:201]", + "https://[2001::1:0:0:1:1]", + "https://foo.0xffg", + "https://" + ".".join(["a" * 63, "b" * 63, "c" * 63, "d" * 61]), + ], +) +def test_canonical_origins(origin: str) -> None: + assert parse_attachment_scope(scope(origin=origin)).origin == origin + + +@pytest.mark.parametrize( + "origin", + [ + "https://127.1", + "https://127.0.0.01", + "https://2130706433", + "https://0x7f.1", + "https://foo.123", + "https://foo.0x1", + "https://foo.0x", + "https://0x", + "https://[2001:0db8::1]", + "https://[::ffff:192.0.2.128]", + "https://[::ffff:192.0.2.1]", + "https://[2001:0:0:1:0:0:1:1]", + "https://[2001:0:0:1::1:1]", + "https://localhost:03443", + "https://localhost:443", + "https://localhost:0", + "https://localhost:65536", + "https://a_b.example", + "https://-a.example", + "https://example.com.", + "https://bücher.example", + "https://example.com/", + "https://example.com?x=1", + "https://example.com#x", + "https://example.com\\path", + "https://EXAMPLE.com", + "https://xn--bcher-kva.example", + "https://example.xn--p1ai", + "https://xn--invalid-.example", + "https://a" + ".".join(["a" * 63] * 4), + "https://" + "a" * 64 + ".example", + ], +) +def test_noncanonical_origins(origin: str) -> None: + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_scope(scope(origin=origin)) + + +def test_context_extras_non_sequences_duplicates_and_aggregate_are_rejected() -> None: + with pytest.raises(AgentAttachmentValidationError): + prepare_attachment_references([], scope(tenant_id="private")) + with pytest.raises(AgentAttachmentValidationError): + prepare_attachment_references((item for item in [receipt(1)]), scope()) + duplicate = receipt(2) + with pytest.raises(AgentAttachmentValidationError): + prepare_attachment_references([duplicate, {**duplicate, "sha256": "b" * 64}], scope()) + + +@pytest.mark.parametrize( + "operation", + [ + lambda: parse_attachment_scope(scope(workflow_id="private/secret")), + lambda: parse_attachment_receipt(receipt(2, filename="private/secret"), scope()), + lambda: prepare_attachment_references( + [receipt(2, filename="private/secret", private_token="private-secret-token")], + scope(), + ), + ], +) +def test_public_errors_are_generic_and_do_not_expose_raw_content(operation) -> None: + with pytest.raises(AgentAttachmentValidationError) as caught: + operation() + assert str(caught.value) == ERROR + assert "private" not in repr(caught.value) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.__suppress_context__ is True + + +def test_model_validation_errors_hide_input_values() -> None: + with pytest.raises(ValidationError) as caught: + AgentAttachmentReceipt.model_validate(receipt(2, filename="private/secret")) + rendered = str(caught.value) + assert "private/secret" not in rendered + assert "input_value" not in rendered diff --git a/tests/test_agent_session_broker_operations.py b/tests/test_agent_session_broker_operations.py new file mode 100644 index 0000000..00e48da --- /dev/null +++ b/tests/test_agent_session_broker_operations.py @@ -0,0 +1,502 @@ +"""Strict Agent Session broker-operation parser and client tests.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from pathlib import Path +import traceback +from uuid import UUID + +import pytest +from pydantic import ValidationError + +from xgen_sdk.agent_session import ( + AGENT_SESSION_BROKER_OPERATION_MAX_COUNT, + AGENT_SESSION_BROKER_OPERATION_MAX_EXPIRY, + AGENT_SESSION_BROKER_OPERATIONS, + AgentSessionBrokerOperationCancellation, + AgentSessionBrokerOperationMetadata, + AgentSessionBrokerOperationReference, + AgentSessionBrokerOperationValidationError, + AgentSessionBrokerOperationsClient, + parse_agent_session_broker_operation_cancellation, + parse_agent_session_broker_operation_metadata, + parse_agent_session_broker_operation_references, +) + + +FIXTURE_PATH = ( + Path(__file__).parent / "fixtures" / "agent-session-broker-operations.json" +) +FIXTURE_BYTES = FIXTURE_PATH.read_bytes() +FIXTURE = json.loads(FIXTURE_BYTES) +SESSION_ID = "11111111-1111-4111-8111-111111111111" +TURN_ID = "22222222-2222-4222-8222-222222222222" +INVOCATION_ID = "77777777-7777-4777-8777-777777777777" +ERROR = "Invalid canonical Agent Session broker operation" +FIXTURE_SHA256 = "bce5c59f4a1d4d4f3e579d94576c7fdd8847cfaaa79e9ce5e4af713c396a3c29" + + +def reference(**changes: object) -> dict[str, object]: + value: dict[str, object] = { + "invocation_id": INVOCATION_ID, + "operation_id": "workflow.code.execute", + } + value.update(changes) + return value + + +def metadata(**changes: object) -> dict[str, object]: + value: dict[str, object] = { + **reference(), + "state": "completed", + "cancel_requested": False, + "target_cleanup": "complete", + "result_available": True, + "expires_at": 1_770_000_030, + } + value.update(changes) + return value + + +def parsed_reference() -> AgentSessionBrokerOperationReference: + return AgentSessionBrokerOperationReference.model_validate(reference()) + + +def test_public_fixture_is_byte_stable_exact_and_secret_free() -> None: + assert hashlib.sha256(FIXTURE_BYTES).hexdigest() == FIXTURE_SHA256 + assert set(FIXTURE) == {"valid", "invalid"} + for group in FIXTURE.values(): + assert set(group) == {"identifiers", "lists", "metadata", "cancellations"} + assert set(FIXTURE["valid"]["identifiers"][0]) == { + "name", + "session_id", + "turn_id", + } + assert all( + set(case) == {"name", "field", "value"} + for case in FIXTURE["invalid"]["identifiers"] + ) + assert all( + set(case) == {"name", "response"} + for validity in FIXTURE.values() + for case in validity["lists"] + ) + assert all( + set(case) == {"name", "reference", "response"} + for validity in FIXTURE.values() + for category in ("metadata", "cancellations") + for case in validity[category] + ) + serialized = FIXTURE_BYTES.decode() + for forbidden in ("grant", "lease", "token", "password", "cipher", "body"): + assert forbidden not in serialized.lower() + + +def test_public_fixture_identifiers_match_canonical_request_context() -> None: + case = FIXTURE["valid"]["identifiers"][0] + assert case["session_id"] == SESSION_ID + assert case["turn_id"] == TURN_ID + + +@pytest.mark.parametrize( + "case", FIXTURE["invalid"]["identifiers"], ids=lambda c: c["name"] +) +def test_public_fixture_invalid_identifiers_fail_before_transport( + case: dict[str, object], +) -> None: + calls: list[str] = [] + + async def transport(path: str) -> object: + calls.append(path) + return [] + + async def scenario() -> None: + client = AgentSessionBrokerOperationsClient(transport, transport) + field = case["field"] + value = case["value"] + with pytest.raises(AgentSessionBrokerOperationValidationError): + if field == "session_id": + await client.list(value, TURN_ID) # type: ignore[arg-type] + elif field == "turn_id": + await client.list(SESSION_ID, value) # type: ignore[arg-type] + else: + forged = AgentSessionBrokerOperationReference.model_construct( + **reference(invocation_id=value) + ) + await client.read(SESSION_ID, TURN_ID, forged) + + asyncio.run(scenario()) + assert calls == [] + + +@pytest.mark.parametrize("case", FIXTURE["valid"]["lists"], ids=lambda c: c["name"]) +def test_public_fixture_valid_lists(case: dict[str, object]) -> None: + parsed = parse_agent_session_broker_operation_references(case["response"]) + assert len(parsed) == len(case["response"]) # type: ignore[arg-type] + assert len({item.invocation_id for item in parsed}) == len(parsed) + + +@pytest.mark.parametrize("case", FIXTURE["invalid"]["lists"], ids=lambda c: c["name"]) +def test_public_fixture_invalid_lists(case: dict[str, object]) -> None: + with pytest.raises(AgentSessionBrokerOperationValidationError, match=f"^{ERROR}$"): + parse_agent_session_broker_operation_references(case["response"]) + + +@pytest.mark.parametrize( + "case", FIXTURE["valid"]["metadata"], ids=lambda c: c["name"] +) +def test_public_fixture_valid_metadata(case: dict[str, object]) -> None: + expected = AgentSessionBrokerOperationReference.model_validate(case["reference"]) + parsed = parse_agent_session_broker_operation_metadata( + case["response"], expected_reference=expected + ) + assert parsed.invocation_id == expected.invocation_id + assert parsed.operation_id == expected.operation_id + + +@pytest.mark.parametrize( + "case", FIXTURE["invalid"]["metadata"], ids=lambda c: c["name"] +) +def test_public_fixture_invalid_metadata(case: dict[str, object]) -> None: + expected = AgentSessionBrokerOperationReference.model_validate(case["reference"]) + with pytest.raises(AgentSessionBrokerOperationValidationError, match=f"^{ERROR}$"): + parse_agent_session_broker_operation_metadata( + case["response"], expected_reference=expected + ) + + +@pytest.mark.parametrize( + "case", FIXTURE["valid"]["cancellations"], ids=lambda c: c["name"] +) +def test_public_fixture_valid_cancellations(case: dict[str, object]) -> None: + expected = AgentSessionBrokerOperationReference.model_validate(case["reference"]) + parsed = parse_agent_session_broker_operation_cancellation( + case["response"], expected_reference=expected + ) + assert parsed.invocation_id == expected.invocation_id + assert parsed.cancel_requested is True + + +@pytest.mark.parametrize( + "case", FIXTURE["invalid"]["cancellations"], ids=lambda c: c["name"] +) +def test_public_fixture_invalid_cancellations(case: dict[str, object]) -> None: + expected = AgentSessionBrokerOperationReference.model_validate(case["reference"]) + with pytest.raises(AgentSessionBrokerOperationValidationError, match=f"^{ERROR}$"): + parse_agent_session_broker_operation_cancellation( + case["response"], expected_reference=expected + ) + + +def test_constants_match_server_bounds_and_allowlist() -> None: + assert AGENT_SESSION_BROKER_OPERATION_MAX_COUNT == 128 + assert AGENT_SESSION_BROKER_OPERATION_MAX_EXPIRY == 9_007_199_254_740_991 + assert AGENT_SESSION_BROKER_OPERATIONS == { + "documents.collections.list", + "documents.collection.documents.list", + "workflow.actor.authorize", + "documents.attachment.index", + "workflow.code.execute", + "workflow.attachments.stage", + } + + +def test_dtos_are_frozen_exact_and_round_trip_as_public_wire_json() -> None: + ref = parsed_reference() + observed = AgentSessionBrokerOperationMetadata.model_validate(metadata()) + accepted = AgentSessionBrokerOperationCancellation.model_validate( + {"invocation_id": INVOCATION_ID, "cancel_requested": True} + ) + assert ref.model_dump(mode="json") == reference() + assert observed.model_dump(mode="json") == metadata() + assert accepted.model_dump(mode="json") == { + "invocation_id": INVOCATION_ID, + "cancel_requested": True, + } + for value in (ref, observed, accepted): + with pytest.raises(ValidationError): + value.invocation_id = UUID(SESSION_ID) + with pytest.raises(ValidationError): + AgentSessionBrokerOperationMetadata.model_validate(metadata(result={})) + + +def test_parsers_revalidate_construct_copy_and_subclass_bypasses() -> None: + ref = parsed_reference() + parsed_refs = parse_agent_session_broker_operation_references([ref]) + assert parsed_refs == (ref,) + assert parsed_refs[0] is not ref + + forged_ref = AgentSessionBrokerOperationReference.model_construct( + **reference(operation_id="documents.private.read") + ) + injected_ref = ref.model_copy(update={"grant": "not-a-real-grant"}) + + class ReferenceSubclass(AgentSessionBrokerOperationReference): + pass + + for unsafe in (forged_ref, injected_ref, ReferenceSubclass(**reference())): + with pytest.raises(AgentSessionBrokerOperationValidationError): + parse_agent_session_broker_operation_references([unsafe]) + + observed = AgentSessionBrokerOperationMetadata.model_validate(metadata()) + forged_metadata = AgentSessionBrokerOperationMetadata.model_construct( + **metadata(expires_at=True) + ) + injected_metadata = observed.model_copy(update={"result": {"private": "value"}}) + inconsistent_metadata = observed.model_copy(update={"state": "running"}) + + class MetadataSubclass(AgentSessionBrokerOperationMetadata): + pass + + for unsafe in ( + forged_metadata, + injected_metadata, + inconsistent_metadata, + MetadataSubclass(**metadata()), + ): + with pytest.raises(AgentSessionBrokerOperationValidationError): + parse_agent_session_broker_operation_metadata( + unsafe, expected_reference=ref + ) + + cancellation = AgentSessionBrokerOperationCancellation.model_validate( + {"invocation_id": INVOCATION_ID, "cancel_requested": True} + ) + forged_cancellation = cancellation.model_copy(update={"cancel_requested": False}) + with pytest.raises(AgentSessionBrokerOperationValidationError): + parse_agent_session_broker_operation_cancellation( + forged_cancellation, expected_reference=ref + ) + + +@pytest.mark.parametrize( + "session_id,turn_id", + [ + (None, TURN_ID), + ("00000000-0000-0000-0000-000000000000", TURN_ID), + ("AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", TURN_ID), + (SESSION_ID, "not-a-uuid"), + (True, TURN_ID), + ], +) +def test_client_rejects_invalid_session_and_turn_ids_before_transport( + session_id: object, turn_id: object +) -> None: + calls: list[str] = [] + + async def get(path: str) -> object: + calls.append(path) + return [] + + async def post(path: str) -> object: + calls.append(path) + return {} + + async def scenario() -> None: + with pytest.raises(AgentSessionBrokerOperationValidationError): + await AgentSessionBrokerOperationsClient(get, post).list( # type: ignore[arg-type] + session_id, turn_id + ) + + asyncio.run(scenario()) + assert calls == [] + + +@pytest.mark.parametrize("method", ["read", "cancel"]) +def test_client_revalidates_reference_before_transport_with_secret_free_error( + method: str, +) -> None: + calls: list[str] = [] + + async def transport(path: str) -> object: + calls.append(path) + return {} + + forged = AgentSessionBrokerOperationReference.model_construct( + **reference(invocation_id="private-canary") + ) + + async def scenario() -> None: + client = AgentSessionBrokerOperationsClient(transport, transport) + with pytest.raises(AgentSessionBrokerOperationValidationError) as caught: + if method == "read": + await client.read(SESSION_ID, TURN_ID, forged) + else: + await client.cancel(SESSION_ID, TURN_ID, forged) + assert str(caught.value) == ERROR + assert "canary" not in repr(caught.value) + assert "canary" not in "".join(traceback.format_exception(caught.value)) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.__suppress_context__ is True + + asyncio.run(scenario()) + assert calls == [] + + +def test_client_uses_exact_queryless_paths_and_empty_post_signature() -> None: + calls: list[tuple[str, str]] = [] + + async def get(path: str) -> object: + calls.append(("GET", path)) + if path.endswith("/operations"): + return [reference()] + return metadata() + + async def post(path: str) -> object: + calls.append(("POST", path)) + return {"invocation_id": INVOCATION_ID, "cancel_requested": True} + + async def scenario() -> None: + client = AgentSessionBrokerOperationsClient(get, post) + listed = await client.list(UUID(SESSION_ID), UUID(TURN_ID)) + assert listed == (parsed_reference(),) + observed = await client.read(SESSION_ID, TURN_ID, listed[0]) + assert observed.result_available is True + cancelled = await client.cancel(SESSION_ID, TURN_ID, listed[0]) + assert cancelled.cancel_requested is True + + asyncio.run(scenario()) + base = f"/api/agentflow/agent-sessions/{SESSION_ID}/turns/{TURN_ID}/operations" + assert calls == [ + ("GET", base), + ("GET", f"{base}/{INVOCATION_ID}"), + ("POST", f"{base}/{INVOCATION_ID}/cancel"), + ] + assert all("?" not in path for _, path in calls) + + +def test_read_binds_both_invocation_and_expected_operation() -> None: + calls = 0 + + async def get(path: str) -> object: + nonlocal calls + calls += 1 + assert path.endswith(f"/{INVOCATION_ID}") + return metadata(operation_id="workflow.attachments.stage") + + async def post(path: str) -> object: + raise AssertionError(path) + + async def scenario() -> None: + with pytest.raises(AgentSessionBrokerOperationValidationError): + await AgentSessionBrokerOperationsClient(get, post).read( + SESSION_ID, TURN_ID, parsed_reference() + ) + + asyncio.run(scenario()) + assert calls == 1 + + +@pytest.mark.parametrize("method", ["list", "read", "cancel"]) +def test_client_does_not_retry_invalid_responses(method: str) -> None: + calls = 0 + + async def get(path: str) -> object: + nonlocal calls + calls += 1 + return None + + async def post(path: str) -> object: + nonlocal calls + calls += 1 + return None + + async def scenario() -> None: + client = AgentSessionBrokerOperationsClient(get, post) + with pytest.raises(AgentSessionBrokerOperationValidationError): + if method == "list": + await client.list(SESSION_ID, TURN_ID) + elif method == "read": + await client.read(SESSION_ID, TURN_ID, parsed_reference()) + else: + await client.cancel(SESSION_ID, TURN_ID, parsed_reference()) + + asyncio.run(scenario()) + assert calls == 1 + + +@pytest.mark.parametrize("method", ["list", "read", "cancel"]) +def test_client_propagates_cancellation_without_retry(method: str) -> None: + calls = 0 + + async def get(path: str) -> object: + nonlocal calls + calls += 1 + raise asyncio.CancelledError + + async def post(path: str) -> object: + nonlocal calls + calls += 1 + raise asyncio.CancelledError + + async def scenario() -> None: + client = AgentSessionBrokerOperationsClient(get, post) + with pytest.raises(asyncio.CancelledError): + if method == "list": + await client.list(SESSION_ID, TURN_ID) + elif method == "read": + await client.read(SESSION_ID, TURN_ID, parsed_reference()) + else: + await client.cancel(SESSION_ID, TURN_ID, parsed_reference()) + + asyncio.run(scenario()) + assert calls == 1 + + +def test_client_propagates_transport_errors_unchanged() -> None: + failure = RuntimeError("transport-owned failure") + + async def get(path: str) -> object: + raise failure + + async def post(path: str) -> object: + raise AssertionError(path) + + async def scenario() -> None: + with pytest.raises(RuntimeError) as caught: + await AgentSessionBrokerOperationsClient(get, post).list( + SESSION_ID, TURN_ID + ) + assert caught.value is failure + + asyncio.run(scenario()) + + +def test_public_validation_errors_are_fixed_and_hide_raw_values() -> None: + unsafe = metadata( + invocation_id="private-secret", + result={"private": "private-secret"}, + ) + with pytest.raises(AgentSessionBrokerOperationValidationError) as caught: + parse_agent_session_broker_operation_metadata( + unsafe, expected_reference=parsed_reference() + ) + assert str(caught.value) == ERROR + assert "private" not in repr(caught.value) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.__suppress_context__ is True + + +def test_direct_model_validation_hides_input_values() -> None: + with pytest.raises(ValidationError) as caught: + AgentSessionBrokerOperationMetadata.model_validate( + metadata(invocation_id="private-secret") + ) + rendered = str(caught.value) + assert "private-secret" not in rendered + assert "input_value" not in rendered + + +def test_client_requires_callable_transports() -> None: + async def transport(path: str) -> object: + return path + + with pytest.raises(TypeError, match="get transport must be callable"): + AgentSessionBrokerOperationsClient(None, transport) # type: ignore[arg-type] + with pytest.raises(TypeError, match="post transport must be callable"): + AgentSessionBrokerOperationsClient(transport, None) # type: ignore[arg-type] diff --git a/tests/test_agent_session_event_replay.py b/tests/test_agent_session_event_replay.py new file mode 100644 index 0000000..0322386 --- /dev/null +++ b/tests/test_agent_session_event_replay.py @@ -0,0 +1,147 @@ +"""Workflow HTTP and WebSocket Agent Session replay contract fixtures.""" + +from uuid import UUID + +import pytest +from pydantic import ValidationError + +from xgen_sdk.agent_session import ( + AgentSessionEventFrame, + AgentSessionEventPage, + AgentSessionReplayCursor, + AgentSessionReplayGap, + AgentSessionSnapshot, + SessionCursorConflictResponse, + apply_session_event_page, +) + + +SID = "018f1240-0000-7000-8000-000000000001" +EVENT1 = "018f1240-0000-7000-8000-000000000002" +EVENT2 = "018f1240-0000-7000-8000-000000000003" +EVENT3 = "018f1240-0000-7000-8000-000000000004" +TURN = "018f1240-0000-7000-8000-000000000005" + + +def event(sequence: int, event_id: str) -> dict: + return { + "event_id": event_id, + "sequence": sequence, + "event_type": "turn.accepted", + "turn_id": TURN, + "platform_session_id": SID, + "device_id": SID, + "origin_id": "cli-1", + "idempotency_key": "submission-1", + "payload_ref": None, + "created_at": "2026-09-29T04:00:00+00:00", + } + + +def page(events: list[dict], next_cursor: int, snapshot_sequence: int, + *, state_version: int = 3, has_more: bool = False) -> AgentSessionEventPage: + return AgentSessionEventPage.model_validate({ + "events": events, + "next_cursor": next_cursor, + "snapshot_sequence": snapshot_sequence, + "state_version": state_version, + "has_more": has_more, + }) + + +def initial() -> AgentSessionReplayCursor: + return AgentSessionReplayCursor(sequence=0, event_id=None, state_version=1) + + +def test_snapshot_frame_and_cursor_conflict_match_workflow_wire_shape(): + snapshot = AgentSessionSnapshot.model_validate({ + "id": SID, "workflow_id": "owned", "title": "My session", + "current_sequence": 2, "state_version": 3, + "latest_turn": {"id": TURN, "status": "accepted", "accepted_sequence": 2}, + "message_history_complete": False, + }) + assert snapshot.id == UUID(SID) + assert snapshot.latest_turn.accepted_sequence == snapshot.current_sequence + frame = AgentSessionEventFrame.model_validate({ + "type": "agent_session.events", **page([event(1, EVENT1)], 1, 2, has_more=True).model_dump(mode="json"), + }) + assert frame.events[0].event_id == UUID(EVENT1) + assert frame.has_more is True + conflict = SessionCursorConflictResponse.model_validate({ + "detail": {"code": "SESSION_CURSOR_GAP", "current_sequence": 2, "state_version": 3}, + }) + assert conflict.detail.current_sequence == 2 + ahead = SessionCursorConflictResponse.model_validate({ + "detail": {"code": "SESSION_CURSOR_AHEAD", "current_sequence": 2, "state_version": 3}, + }) + assert ahead.detail.code == "SESSION_CURSOR_AHEAD" + with pytest.raises(ValidationError): + AgentSessionEventFrame.model_validate({ + **frame.model_dump(mode="json"), "type": "agent_session.focus_changed", + }) + + +def test_paginated_replay_and_overlapping_retry_emit_each_event_once(): + first = page([event(1, EVENT1)], 1, 2, has_more=True) + cursor, accepted = apply_session_event_page(initial(), first, after_sequence=0) + assert cursor.sequence == 1 + assert [item.event_id for item in accepted] == [UUID(EVENT1)] + + duplicate_cursor, duplicate = apply_session_event_page(cursor, first, after_sequence=0) + assert duplicate_cursor == cursor + assert duplicate == () + + overlap = page([event(1, EVENT1), event(2, EVENT2)], 2, 2) + cursor, accepted = apply_session_event_page(cursor, overlap, after_sequence=0) + assert cursor.sequence == 2 + assert cursor.event_id == UUID(EVENT2) + assert [item.sequence for item in accepted] == [2] + + empty = page([], 2, 2) + assert apply_session_event_page(cursor, empty, after_sequence=2) == (cursor, ()) + newer_state = page([], 2, 2, state_version=4) + cursor, accepted = apply_session_event_page(cursor, newer_state, after_sequence=2) + assert accepted == () + assert cursor.state_version == 4 + next_frame = AgentSessionEventFrame.model_validate({ + "type": "agent_session.events", **page([event(3, EVENT3)], 3, 3, state_version=4).model_dump(mode="json"), + }) + cursor, accepted = apply_session_event_page(cursor, next_frame, after_sequence=2) + assert (cursor.sequence, cursor.state_version, len(accepted)) == (3, 4, 1) + + +@pytest.mark.parametrize("broken,after", [ + (lambda: page([event(2, EVENT1)], 2, 2), 0), + (lambda: page([event(1, EVENT1)], 0, 1), 0), + (lambda: page([event(1, EVENT1)], 1, 0), 0), + (lambda: page([event(1, EVENT1)], 1, 2), 0), + (lambda: page([event(1, EVENT1)], 1, 1, has_more=True), 0), + (lambda: page([], 0, 0), True), +]) +def test_replay_rejects_gap_bad_cursor_or_inconsistent_page(broken, after): + with pytest.raises(AgentSessionReplayGap): + apply_session_event_page(initial(), broken(), after_sequence=after) + + +def test_replay_rejects_changed_duplicate_and_state_downgrade(): + cursor = AgentSessionReplayCursor(sequence=1, event_id=UUID(EVENT1), state_version=3) + with pytest.raises(AgentSessionReplayGap): + apply_session_event_page(cursor, page([event(1, EVENT2)], 1, 1), after_sequence=0) + with pytest.raises(AgentSessionReplayGap): + apply_session_event_page( + cursor, page([event(2, EVENT2)], 2, 2, state_version=2), after_sequence=1, + ) + with pytest.raises(AgentSessionReplayGap): + apply_session_event_page(cursor, page([event(3, EVENT3)], 3, 3), after_sequence=2) + + +def test_wire_models_reject_incomplete_or_ill_typed_events(): + with pytest.raises(ValidationError): + page([dict(event(1, EVENT1), sequence=True)], 1, 1) + with pytest.raises(ValidationError): + page([dict(event(1, EVENT1), secret="unexpected")], 1, 1) + with pytest.raises(ValidationError): + AgentSessionSnapshot.model_validate({ + "id": SID, "workflow_id": "owned", "title": "", "current_sequence": -1, + "state_version": 1, "latest_turn": None, "message_history_complete": True, + }) diff --git a/tests/test_agent_session_focus.py b/tests/test_agent_session_focus.py new file mode 100644 index 0000000..77207d6 --- /dev/null +++ b/tests/test_agent_session_focus.py @@ -0,0 +1,114 @@ +"""Workflow account focus response fixtures and replay safety checks.""" + +from uuid import UUID + +import pytest +from pydantic import ValidationError + +from xgen_sdk.agent_session import ( + AccountEventPage, + AgentSessionCreated, + CreateAgentSession, + CursorConflictResponse, + FocusConflictResponse, + FocusReplayGap, + FocusState, + SwitchFocus, + apply_event_page, +) + + +SID = "018f1240-0000-7000-8000-000000000001" +EVENT1 = "018f1240-0000-7000-8000-000000000002" +EVENT2 = "018f1240-0000-7000-8000-000000000003" + + +def event(sequence, event_id, previous, active): + return { + "event_id": event_id, + "sequence": sequence, + "event_type": "agent_session.focus_changed", + "previous_agent_session_id": previous, + "active_agent_session_id": active, + "origin_id": "web-tab", + "created_at": "2026-09-27T12:00:00+00:00", + } + + +def page(events, next_cursor, snapshot_version, has_more=False): + return AccountEventPage.model_validate({ + "events": events, + "next_cursor": next_cursor, + "snapshot_version": snapshot_version, + "has_more": has_more, + }) + + +def test_workflow_create_focus_and_conflict_contract(): + request = CreateAgentSession(workflow_id="owned", expected_version=0) + assert request.model_dump(mode="json") == { + "workflow_id": "owned", "expected_version": 0, "title": "", "origin_id": None, + } + created = AgentSessionCreated.model_validate({ + "id": SID, "workflow_id": "owned", + "focus": {"active_agent_session_id": SID, "version": 1, "event_id": EVENT1}, + }) + assert created.id == UUID(SID) + assert created.focus.active_agent_session_id == created.id + with pytest.raises(ValidationError): + AgentSessionCreated.model_validate({ + "id": EVENT2, "workflow_id": "owned", + "focus": created.focus.model_dump(mode="json"), + }) + assert SwitchFocus(active_agent_session_id=None, expected_version=1).model_dump(mode="json") == { + "active_agent_session_id": None, "expected_version": 1, "origin_id": None, + } + conflict = FocusConflictResponse.model_validate({ + "detail": {"code": "FOCUS_VERSION_CONFLICT", "current": created.focus.model_dump(mode="json")}, + }) + assert conflict.detail.current.version == 1 + cursor_conflict = CursorConflictResponse.model_validate({ + "detail": {"code": "ACCOUNT_CURSOR_GAP", "current": created.focus.model_dump(mode="json")}, + }) + assert cursor_conflict.detail.current == created.focus + + +def test_replay_pages_resume_and_ignore_identical_retry(): + initial = FocusState(active_agent_session_id=None, version=0, event_id=None) + first = page([event(1, EVENT1, None, SID)], 1, 2, True) + focused = apply_event_page(initial, first, after_sequence=0) + assert focused.active_agent_session_id == UUID(SID) + assert focused.version == 1 + assert apply_event_page(focused, first, after_sequence=0) == focused + + second = page([event(2, EVENT2, SID, None)], 2, 2) + cleared = apply_event_page(focused, second, after_sequence=1) + assert cleared.active_agent_session_id is None + assert cleared.version == 2 + assert apply_event_page(cleared, page([], 2, 2), after_sequence=2) == cleared + + +@pytest.mark.parametrize("broken", [ + lambda: page([event(2, EVENT1, None, SID)], 2, 2), + lambda: page([event(1, EVENT1, SID, SID)], 1, 1), + lambda: page([event(1, EVENT1, None, SID)], 0, 1), + lambda: page([event(1, EVENT1, None, SID)], 1, 1, True), +]) +def test_replay_rejects_gap_pointer_or_cursor_inconsistency(broken): + initial = FocusState(active_agent_session_id=None, version=0, event_id=None) + with pytest.raises(FocusReplayGap): + apply_event_page(initial, broken(), after_sequence=0) + + +def test_replay_rejects_changed_duplicate_and_bad_models(): + initial = FocusState(active_agent_session_id=UUID(SID), version=1, event_id=UUID(EVENT1)) + changed = page([event(1, EVENT2, None, SID)], 1, 1) + with pytest.raises(FocusReplayGap): + apply_event_page(initial, changed, after_sequence=0) + # GET /me/agent-state has no historical event_id; its pointer is authoritative. + snapshot = FocusState(active_agent_session_id=UUID(SID), version=1, event_id=None) + assert apply_event_page(snapshot, changed, after_sequence=0) == snapshot + with pytest.raises(ValidationError): + CreateAgentSession(workflow_id="owned", expected_version=True) + with pytest.raises(ValidationError): + page([dict(event(1, EVENT1, None, SID), event_type="turn.started")], 1, 1) diff --git a/tests/test_agent_session_messages.py b/tests/test_agent_session_messages.py new file mode 100644 index 0000000..3d876a2 --- /dev/null +++ b/tests/test_agent_session_messages.py @@ -0,0 +1,120 @@ +"""Workflow linked-message wire shape and sparse pagination safety.""" + +from uuid import UUID + +import pytest +from pydantic import ValidationError + +from xgen_sdk.agent_session import ( + AgentSessionMessageConflictResponse, + AgentSessionMessageCursor, + AgentSessionMessageGap, + AgentSessionMessagePage, + apply_message_page, +) + + +SID = UUID("018f1240-0000-7000-8000-000000000001") +OTHER_SID = UUID("018f1240-0000-7000-8000-000000000002") +TURN1 = UUID("018f1240-0000-7000-8000-000000000003") +TURN2 = UUID("018f1240-0000-7000-8000-000000000004") +TURN3 = UUID("018f1240-0000-7000-8000-000000000005") + + +def message(sequence: int, turn_id: UUID, **changes: object) -> dict: + item = { + "turn_id": str(turn_id), "sequence": sequence, "status": "completed", + "input_text": "hello", "output_text": "answer", "content_complete": True, + "source": "user", + } + item.update(changes) + return item + + +def page(messages: list[dict], next_cursor: int, snapshot_sequence: int, + *, state_version: int = 3, has_more: bool = False) -> AgentSessionMessagePage: + return AgentSessionMessagePage.model_validate({ + "messages": messages, "next_cursor": next_cursor, + "snapshot_sequence": snapshot_sequence, "state_version": state_version, + "has_more": has_more, + }) + + +def initial() -> AgentSessionMessageCursor: + return AgentSessionMessageCursor(session_id=SID, sequence=0, turn_id=None, state_version=1) + + +def test_workflow_wire_shape_preserves_sparse_pages_and_missing_text(): + first = page([message(2, TURN1)], 2, 6, has_more=True) + assert first.messages[0].turn_id == TURN1 + assert first.snapshot_sequence == 6 + incomplete = page([ + message(5, TURN2, input_text=None, output_text=None, + content_complete=False, source="unknown", status="failed"), + ], 5, 6, state_version=5) + assert incomplete.messages[0].source == "unknown" + assert incomplete.messages[0].content_complete is False + assert incomplete.next_cursor != incomplete.snapshot_sequence + conflict = AgentSessionMessageConflictResponse.model_validate({ + "detail": {"code": "SESSION_MESSAGE_LINK_INVALID", "current_sequence": 6, "state_version": 5}, + }) + assert conflict.detail.code == "SESSION_MESSAGE_LINK_INVALID" + assert AgentSessionMessageConflictResponse.model_validate({ + "detail": {"code": "SESSION_CURSOR_AHEAD", "current_sequence": 6, "state_version": 5}, + }).detail.current_sequence == 6 + + +def test_sparse_pagination_and_overlapping_retries_do_not_duplicate_messages(): + first = page([message(2, TURN1)], 2, 6, has_more=True) + cursor, accepted = apply_message_page(initial(), first, session_id=SID, after_sequence=0) + assert (cursor.sequence, cursor.turn_id) == (2, TURN1) + assert [item.turn_id for item in accepted] == [TURN1] + + second = page([message(5, TURN2, source="subagent_report")], 5, 6, state_version=5) + cursor, accepted = apply_message_page(cursor, second, session_id=SID, after_sequence=2) + assert (cursor.sequence, cursor.turn_id) == (5, TURN2) + assert [item.sequence for item in accepted] == [5] + + assert apply_message_page(cursor, first, session_id=SID, after_sequence=0) == (cursor, ()) + overlap = page([message(2, TURN1), message(5, TURN2)], 5, 6, state_version=5) + assert apply_message_page(cursor, overlap, session_id=SID, after_sequence=0) == (cursor, ()) + empty = page([], 5, 7, state_version=6) + cursor, accepted = apply_message_page(cursor, empty, session_id=SID, after_sequence=5) + assert accepted == () + assert (cursor.sequence, cursor.state_version) == (5, 6) + + +@pytest.mark.parametrize("broken", [ + lambda: page([message(2, TURN1, content_complete=False)], 2, 6), + lambda: page([message(2, TURN1, input_text=None, content_complete=False, source="user")], 2, 6), + lambda: page([message(2, TURN1, secret="must not be exposed")], 2, 6), + lambda: page([message(2, TURN1, status="running")], 2, 6), + lambda: page([message(2, TURN1, output_text="x" * 262145)], 2, 6), + lambda: page([message(5, TURN1), message(2, TURN2)], 2, 6), + lambda: page([message(2, TURN1), message(5, TURN1)], 5, 6), + lambda: page([message(2, TURN1)], 3, 6), + lambda: page([message(2, TURN1)], 2, 1), + lambda: page([], 0, 6, has_more=True), + lambda: page([message(2, TURN1)], 2, 2, has_more=True), + lambda: page([message(index + 1, UUID(int=index + 1)) for index in range(21)], 21, 25), +]) +def test_wire_model_rejects_inconsistent_or_excessive_content(broken): + with pytest.raises(ValidationError): + broken() + + +def test_apply_rejects_foreign_session_cursor_changed_anchor_and_state_downgrade(): + cursor = AgentSessionMessageCursor(session_id=SID, sequence=2, turn_id=TURN1, state_version=3) + valid = page([message(5, TURN2)], 5, 6, state_version=4) + for session_id, after_sequence, response in ( + (OTHER_SID, 2, valid), + (SID, True, valid), + (SID, 3, valid), + (SID, 0, valid), + (SID, 0, page([message(2, TURN2), message(5, TURN3)], 5, 6, state_version=4)), + (SID, 2, page([message(5, TURN2)], 5, 6, state_version=2)), + (SID, 2, page([message(5, TURN1)], 5, 6, state_version=4)), + (SID, 2, page([], 3, 6, state_version=4)), + ): + with pytest.raises(AgentSessionMessageGap): + apply_message_page(cursor, response, session_id=session_id, after_sequence=after_sequence) diff --git a/tests/test_agent_session_turn_contract.py b/tests/test_agent_session_turn_contract.py new file mode 100644 index 0000000..0f42690 --- /dev/null +++ b/tests/test_agent_session_turn_contract.py @@ -0,0 +1,204 @@ +"""Strict canonical Agent Session turn submission contract tests.""" + +from __future__ import annotations + +import json + +import pytest +from pydantic import ValidationError + +from xgen_sdk.agent_session import ( + AgentAttachmentReference, + AgentAttachmentValidationError, + AgentSessionTurnValidationError, + SubmitAgentSessionTurn, + parse_attachment_references, + parse_turn_submission, +) + + +ATTACHMENT_1 = "11111111-1111-4111-8111-111111111111" +ATTACHMENT_2 = "22222222-2222-4222-8222-222222222222" +DIGEST_1 = "a" * 64 +DIGEST_2 = "b" * 64 +ATTACHMENT_ERROR = "Invalid canonical Agent Session attachment metadata" +TURN_ERROR = "Invalid canonical Agent Session turn submission" + + +def reference(attachment_id: str = ATTACHMENT_1, sha256: str = DIGEST_1) -> dict[str, str]: + return {"attachment_id": attachment_id, "sha256": sha256} + + +def submission(**changes: object) -> dict[str, object]: + value: dict[str, object] = { + "input_text": "hello", + "expected_state_version": 1, + "idempotency_key": "request-1", + } + value.update(changes) + return value + + +def test_reference_parser_copies_preserves_order_and_rejects_duplicates_and_count() -> None: + source = [reference(), reference(ATTACHMENT_2, DIGEST_2)] + parsed = parse_attachment_references(source) + assert isinstance(parsed, tuple) + assert [item.attachment_id for item in parsed] == [ATTACHMENT_1, ATTACHMENT_2] + source[0]["attachment_id"] = ATTACHMENT_2 + assert parsed[0].attachment_id == ATTACHMENT_1 + + with pytest.raises(AgentAttachmentValidationError, match=f"^{ATTACHMENT_ERROR}$"): + parse_attachment_references([reference(), reference(ATTACHMENT_1, DIGEST_2)]) + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_references([reference(f"{index:08x}-1111-4111-8111-111111111111") for index in range(11)]) + + +@pytest.mark.parametrize("unsafe", [None, reference(), "value", iter([])]) +def test_reference_parser_requires_an_exact_list_or_tuple(unsafe: object) -> None: + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_references(unsafe) + + +def test_reference_parser_revalidates_forged_models_and_rejects_subclasses() -> None: + forged = AgentAttachmentReference.model_construct(attachment_id=ATTACHMENT_1, sha256=True) + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_references([forged]) + + copied = AgentAttachmentReference(**reference()).model_copy(update={"private": "secret"}) + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_references([copied]) + + class ReferenceSubclass(AgentAttachmentReference): + pass + + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_references([ReferenceSubclass(**reference())]) + + class ListSubclass(list): + pass + + with pytest.raises(AgentAttachmentValidationError): + parse_attachment_references(ListSubclass([reference()])) + + +def test_turn_shape_is_frozen_and_serializes_attachments_as_a_json_array() -> None: + turn = SubmitAgentSessionTurn(**submission(attachments=[reference(), reference(ATTACHMENT_2, DIGEST_2)])) + assert isinstance(turn.attachments, tuple) + assert json.loads(turn.model_dump_json()) == { + "input_text": "hello", + "expected_state_version": 1, + "idempotency_key": "request-1", + "origin_id": None, + "attachments": [reference(), reference(ATTACHMENT_2, DIGEST_2)], + } + with pytest.raises(ValidationError): + turn.input_text = "changed" + with pytest.raises(ValidationError): + turn.attachments[0].sha256 = DIGEST_2 + + +def test_turn_accepts_fastapi_list_and_empty_or_omitted_attachments() -> None: + listed = SubmitAgentSessionTurn.model_validate(submission(attachments=[reference()])) + empty = SubmitAgentSessionTurn.model_validate(submission(attachments=[])) + omitted = SubmitAgentSessionTurn.model_validate(submission()) + assert listed.attachments == (AgentAttachmentReference(**reference()),) + assert empty.attachments == omitted.attachments == () + + +def test_wire_schema_advertises_the_attachment_count_limit() -> None: + schema = SubmitAgentSessionTurn.model_json_schema() + assert schema['properties']['attachments']['type'] == 'array' + assert schema['properties']['attachments']['maxItems'] == 10 + + +@pytest.mark.parametrize( + "changes", + [ + {"input_text": ""}, + {"input_text": "x" * 262_145}, + {"input_text": 1}, + {"expected_state_version": 0}, + {"expected_state_version": True}, + {"expected_state_version": "1"}, + {"idempotency_key": ""}, + {"idempotency_key": "x" * 129}, + {"idempotency_key": 1}, + {"origin_id": ""}, + {"origin_id": "x" * 129}, + {"origin_id": 1}, + {"attachments": iter([reference()])}, + {"attachments": [reference(), reference()]}, + {"private_token": "private-secret"}, + ], +) +def test_turn_rejects_invalid_primitives_extras_and_attachment_shapes(changes: dict[str, object]) -> None: + with pytest.raises(ValidationError): + SubmitAgentSessionTurn.model_validate(submission(**changes)) + + +def test_turn_parser_requires_exact_dto_and_revalidates_construct_and_copy_bypasses() -> None: + turn = SubmitAgentSessionTurn(**submission(attachments=[reference()])) + copied = parse_turn_submission(turn) + assert copied == turn + assert copied is not turn + assert copied.attachments[0] is not turn.attachments[0] + + with pytest.raises(AgentSessionTurnValidationError): + parse_turn_submission(submission()) + forged = SubmitAgentSessionTurn.model_construct(**submission(expected_state_version=True), attachments=()) + with pytest.raises(AgentSessionTurnValidationError): + parse_turn_submission(forged) + invalid_copy = turn.model_copy(update={"expected_state_version": True}) + with pytest.raises(AgentSessionTurnValidationError): + parse_turn_submission(invalid_copy) + injected = turn.model_copy(update={"private_token": "private-secret"}) + with pytest.raises(AgentSessionTurnValidationError): + parse_turn_submission(injected) + injected_reference = turn.attachments[0].model_copy(update={"private_token": "private-secret"}) + forged_nested = turn.model_copy(update={"attachments": (injected_reference,)}) + with pytest.raises(AgentSessionTurnValidationError): + parse_turn_submission(forged_nested) + + class TurnSubclass(SubmitAgentSessionTurn): + pass + + with pytest.raises(AgentSessionTurnValidationError): + parse_turn_submission(TurnSubclass(**submission())) + + +@pytest.mark.parametrize( + "operation,error_type,message", + [ + ( + lambda: parse_attachment_references([reference(sha256="private-secret")]), + AgentAttachmentValidationError, + ATTACHMENT_ERROR, + ), + ( + lambda: parse_turn_submission( + SubmitAgentSessionTurn.model_construct( + **submission(input_text="private-secret", expected_state_version=True), + attachments=(), + ) + ), + AgentSessionTurnValidationError, + TURN_ERROR, + ), + ], +) +def test_public_helper_errors_are_generic_without_raw_input_or_context(operation, error_type, message) -> None: + with pytest.raises(error_type) as caught: + operation() + assert str(caught.value) == message + assert "private" not in repr(caught.value) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.__suppress_context__ is True + + +def test_direct_model_validation_hides_input_values() -> None: + with pytest.raises(ValidationError) as caught: + SubmitAgentSessionTurn.model_validate(submission(input_text="private-secret", expected_state_version=True)) + rendered = str(caught.value) + assert "private-secret" not in rendered + assert "input_value" not in rendered diff --git a/tests/test_agent_session_turn_status.py b/tests/test_agent_session_turn_status.py new file mode 100644 index 0000000..4004265 --- /dev/null +++ b/tests/test_agent_session_turn_status.py @@ -0,0 +1,219 @@ +"""Strict Agent Session turn-status parser and read-client tests.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from uuid import UUID + +import pytest +from pydantic import ValidationError + +from xgen_sdk.agent_session import ( + AgentSessionTurnStatus, + AgentSessionTurnStatusClient, + AgentSessionTurnStatusValidationError, + parse_agent_session_turn_status, +) + + +FIXTURE = json.loads( + (Path(__file__).parent / "fixtures" / "agent-session-turn-status.json").read_text() +) +SESSION_ID = "11111111-1111-4111-8111-111111111111" +TURN_ID = "22222222-2222-4222-8222-222222222222" +ERROR = "Invalid canonical Agent Session turn status" +FIXTURE_FIELDS = {"name", "session_id", "turn_id", "response"} + + +def response(**changes: object) -> dict[str, object]: + value: dict[str, object] = { + "turn_id": TURN_ID, + "agent_session_id": SESSION_ID, + "status": "running", + "progress": "running_or_unknown", + "accepted_sequence": 10, + "started_sequence": 11, + "terminal_sequence": None, + "result_available": False, + "state_version": 4, + } + value.update(changes) + return value + + +def test_public_fixture_has_a_stable_sanitized_cross_client_shape() -> None: + assert set(FIXTURE) == {"valid", "invalid"} + assert FIXTURE["valid"] and FIXTURE["invalid"] + for case in (*FIXTURE["valid"], *FIXTURE["invalid"]): + assert set(case) == FIXTURE_FIELDS + serialized = json.dumps(case) + assert "prompt" not in serialized + assert "cipher" not in serialized + + +@pytest.mark.parametrize("case", FIXTURE["valid"], ids=lambda case: case["name"]) +def test_public_fixture_valid_cases(case: dict[str, object]) -> None: + parsed = parse_agent_session_turn_status( + case["response"], + expected_session_id=case["session_id"], + expected_turn_id=case["turn_id"], + ) + assert parsed.agent_session_id == UUID(case["session_id"]) + assert parsed.turn_id == UUID(case["turn_id"]) + + +@pytest.mark.parametrize("case", FIXTURE["invalid"], ids=lambda case: case["name"]) +def test_public_fixture_invalid_cases(case: dict[str, object]) -> None: + with pytest.raises(AgentSessionTurnStatusValidationError, match=f"^{ERROR}$"): + parse_agent_session_turn_status( + case["response"], + expected_session_id=case["session_id"], + expected_turn_id=case["turn_id"], + ) + + +def test_dto_is_frozen_exact_and_round_trips_as_wire_json() -> None: + status = AgentSessionTurnStatus.model_validate(response()) + assert status.model_dump(mode="json") == response() + with pytest.raises(ValidationError): + status.status = "completed" + with pytest.raises(ValidationError): + AgentSessionTurnStatus.model_validate(response(extra="rejected")) + + +def test_parser_revalidates_construct_copy_and_subclass_bypasses() -> None: + status = AgentSessionTurnStatus.model_validate(response()) + parsed = parse_agent_session_turn_status( + status, expected_session_id=SESSION_ID, expected_turn_id=TURN_ID + ) + assert parsed == status + assert parsed is not status + + forged = AgentSessionTurnStatus.model_construct( + **response(accepted_sequence=True) + ) + injected = status.model_copy(update={"private_token": "not-a-real-token"}) + inconsistent = status.model_copy(update={"started_sequence": 10}) + + class StatusSubclass(AgentSessionTurnStatus): + pass + + for unsafe in (forged, injected, inconsistent, StatusSubclass(**response())): + with pytest.raises(AgentSessionTurnStatusValidationError): + parse_agent_session_turn_status( + unsafe, expected_session_id=SESSION_ID, expected_turn_id=TURN_ID + ) + + +@pytest.mark.parametrize( + "session_id,turn_id", + [ + (None, TURN_ID), + ("00000000-0000-0000-0000-000000000000", TURN_ID), + ("AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", TURN_ID), + (SESSION_ID, "not-a-uuid"), + ], +) +def test_client_rejects_invalid_ids_before_transport( + session_id: object, turn_id: object +) -> None: + calls: list[str] = [] + + async def get(path: str) -> object: + calls.append(path) + return response() + + async def scenario() -> None: + with pytest.raises(AgentSessionTurnStatusValidationError): + await AgentSessionTurnStatusClient(get).read(session_id, turn_id) # type: ignore[arg-type] + + asyncio.run(scenario()) + assert calls == [] + + +def test_client_uses_one_exact_queryless_get_path_and_binds_first_response() -> None: + calls: list[str] = [] + + async def get(path: str) -> object: + calls.append(path) + return response(turn_id="33333333-3333-4333-8333-333333333333") + + async def scenario() -> None: + with pytest.raises(AgentSessionTurnStatusValidationError): + await AgentSessionTurnStatusClient(get).read( + UUID(SESSION_ID), UUID(TURN_ID) + ) + + asyncio.run(scenario()) + assert calls == [ + f"/api/agentflow/agent-sessions/{SESSION_ID}/turns/{TURN_ID}" + ] + + +def test_client_returns_a_revalidated_status_from_the_caller_transport() -> None: + async def get(path: str) -> object: + assert "?" not in path + return response() + + parsed = asyncio.run(AgentSessionTurnStatusClient(get).read(SESSION_ID, TURN_ID)) + assert parsed.status == "running" + assert parsed.agent_session_id == UUID(SESSION_ID) + assert parsed.turn_id == UUID(TURN_ID) + + +def test_client_does_not_retry_or_mutate_after_invalid_response() -> None: + calls = 0 + + async def get(path: str) -> object: + nonlocal calls + calls += 1 + assert path.endswith(f"/{TURN_ID}") + return response(status="queued") + + async def scenario() -> None: + with pytest.raises(AgentSessionTurnStatusValidationError): + await AgentSessionTurnStatusClient(get).read(SESSION_ID, TURN_ID) + + asyncio.run(scenario()) + assert calls == 1 + + +def test_client_propagates_cancellation_without_retry() -> None: + calls = 0 + + async def get(path: str) -> object: + nonlocal calls + calls += 1 + raise asyncio.CancelledError + + async def scenario() -> None: + with pytest.raises(asyncio.CancelledError): + await AgentSessionTurnStatusClient(get).read(SESSION_ID, TURN_ID) + + asyncio.run(scenario()) + assert calls == 1 + + +def test_public_validation_errors_are_generic_and_hide_raw_values() -> None: + unsafe = response(turn_id="private-secret", private_token="private-secret") + with pytest.raises(AgentSessionTurnStatusValidationError) as caught: + parse_agent_session_turn_status( + unsafe, + expected_session_id=SESSION_ID, + expected_turn_id=TURN_ID, + ) + assert str(caught.value) == ERROR + assert "private" not in repr(caught.value) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.__suppress_context__ is True + + +def test_direct_model_validation_hides_input_values() -> None: + with pytest.raises(ValidationError) as caught: + AgentSessionTurnStatus.model_validate(response(turn_id="private-secret")) + rendered = str(caught.value) + assert "private-secret" not in rendered + assert "input_value" not in rendered diff --git a/tests/test_approval_catalog.py b/tests/test_approval_catalog.py index 87df538..bfb4e43 100644 --- a/tests/test_approval_catalog.py +++ b/tests/test_approval_catalog.py @@ -46,7 +46,7 @@ def test_every_catalogued_action_is_registered(): def test_the_gated_set_is_what_xgen_actually_gates_today(): """지금 XGEN 이 실제로 승인을 받는 행위 전부 — 전수 조사(2026-09-11)의 결론. - 배포 2(처음·갱신) + 지식·파일 저장소 5 + DB 2 + 도구 1. 여기서 무엇이 빠지면 그 행위는 + 배포 2(처음·갱신) + 지식·파일 저장소 5 + DB 2 + 도구 2. 여기서 무엇이 빠지면 그 행위는 결재로 옮겨지지 못한 채 옛 게이트에 남거나, 아무 통제 없이 열린다. 클라우드 6종은 2026-09-30 [파일 클라우드]와 함께 거뒀다(RETIRED). """ @@ -55,7 +55,7 @@ def test_the_gated_set_is_what_xgen_actually_gates_today(): "collection.create", "collection.upload", "collection.update", "filestore.upload", "filestore.embed", "db.create", "db.share", - "tool.publish", + "tool.publish", "capability.invoke", } diff --git a/tests/test_control_policy.py b/tests/test_control_policy.py index 899a897..7f9a2ee 100644 --- a/tests/test_control_policy.py +++ b/tests/test_control_policy.py @@ -408,12 +408,16 @@ def test_empty_matches_hide_nothing_and_break_nothing(): def test_large_html_with_one_hit_stays_light(): - import resource + import sys + + resource = pytest.importorskip("resource") page = "
" + "일반 로그 줄 R&D " * 250000 + "연락 010-1234-5678" before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss out = cp.strip_bytes(page.encode(), "big.html", _rules(PHONE)) - grown_mb = (resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - before) / 1024 + # getrusage reports bytes on Darwin and KiB on Linux. + rss_units_per_mb = 1024 * 1024 if sys.platform == "darwin" else 1024 + grown_mb = (resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - before) / rss_units_per_mb assert out.hits == 1 and out.content.decode().endswith("연락 ***-****-5678") assert grown_mb < 300, f"글자마다 표를 만들지 않는다 (+{grown_mb:.0f} MB)"