diff --git a/docs/cross-platform-session-integration.md b/docs/cross-platform-session-integration.md index 850dcfb..5666201 100644 --- a/docs/cross-platform-session-integration.md +++ b/docs/cross-platform-session-integration.md @@ -15,6 +15,27 @@ Workflow의 Agent Session 이벤트 계약도 `AgentSessionSnapshot`, `AgentSess 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) diff --git a/src/xgen_sdk/agent_session/__init__.py b/src/xgen_sdk/agent_session/__init__.py index 4063fdd..8829540 100644 --- a/src/xgen_sdk/agent_session/__init__.py +++ b/src/xgen_sdk/agent_session/__init__.py @@ -53,6 +53,13 @@ SubmitAgentSessionTurn, parse_turn_submission, ) +from .turn_status import ( + AgentSessionTurnStatus, + AgentSessionTurnStatusClient, + AgentSessionTurnStatusGetTransport, + AgentSessionTurnStatusValidationError, + parse_agent_session_turn_status, +) __all__ = [ "AGENT_ATTACHMENT_MAX_BYTES", @@ -76,6 +83,10 @@ "AgentSessionReplayGap", "AgentSessionSnapshot", "AgentSessionTurnSummary", + "AgentSessionTurnStatus", + "AgentSessionTurnStatusClient", + "AgentSessionTurnStatusGetTransport", + "AgentSessionTurnStatusValidationError", "AgentSessionTurnValidationError", "AgentSessionCreated", "CreateAgentSession", @@ -93,5 +104,6 @@ "parse_attachment_references", "parse_attachment_scope", "parse_turn_submission", + "parse_agent_session_turn_status", "prepare_attachment_references", ] 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/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_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