diff --git a/docs/public-api-schema-index.md b/docs/public-api-schema-index.md index b64dc62..969ff08 100644 --- a/docs/public-api-schema-index.md +++ b/docs/public-api-schema-index.md @@ -170,6 +170,48 @@ Related artifacts: - `GET /v1/media/{upload_id}` - response: `api.media.get.response.v1.json` +## Sessions (anti-messenger / D-030) + +A Session is a first-class orchestrator (separate from Intent) that holds Messages +(delivered over DB + SSE, never Pub/Sub) and may spawn manual Intents. Protocol +artifacts: + +- `schemas/protocol/session.envelope.v1.json` +- `schemas/protocol/session.lifecycle.v1.json` +- `schemas/protocol/session.event.v1.json` + +Intent/message schemas carry an optional `session_id` (and `parent_intent_id` on +`intent.lifecycle.v1`) to back-link work spawned within a Session. These additions +are optional and backward-compatible (v1 line unchanged). + +### Files + +- `schemas/public_api/api.sessions.create.request.v1.json` +- `schemas/public_api/api.sessions.create.response.v1.json` +- `schemas/public_api/api.sessions.get.response.v1.json` +- `schemas/public_api/api.sessions.events.list.response.v1.json` +- `schemas/public_api/api.sessions.messages.append.request.v1.json` +- `schemas/public_api/api.sessions.list.response.v1.json` + +### Endpoint Mapping + +- `POST /v1/sessions` + - request: `api.sessions.create.request.v1.json` + - response: `api.sessions.create.response.v1.json` +- `GET /v1/sessions` + - unified visitor inbox, scoped strictly by participation + - response: `api.sessions.list.response.v1.json` +- `GET /v1/sessions/{session_id}` + - response: `api.sessions.get.response.v1.json` +- `GET /v1/sessions/{session_id}/events` + - response: `api.sessions.events.list.response.v1.json` +- `GET /v1/sessions/{session_id}/stream` + - transport: `text/event-stream` (SSE) + - event payload shape: same event object as `api.sessions.events.list.response.v1.json` item +- `POST /v1/sessions/{session_id}/messages` + - request: `api.sessions.messages.append.request.v1.json` + - response: `api.sessions.get.response.v1.json` + ## Track F Phase 1 Families (Draft Contracts) ### Files diff --git a/docs/schema-versioning-rules.md b/docs/schema-versioning-rules.md index 7de2bf5..98897d2 100644 --- a/docs/schema-versioning-rules.md +++ b/docs/schema-versioning-rules.md @@ -41,6 +41,9 @@ Rules apply to: - `intent.error.v1.json` - `intent.lifecycle.v1.json` - `intent.event.v1.json` +- `session.envelope.v1.json` +- `session.lifecycle.v1.json` +- `session.event.v1.json` ## Required Public API Schemas (v1 line) @@ -60,3 +63,9 @@ Rules apply to: - `api.inbox.reply.request.v1.json` - `api.inbox.delegate.request.v1.json` - `api.inbox.decision.request.v1.json` +- `api.sessions.create.request.v1.json` +- `api.sessions.create.response.v1.json` +- `api.sessions.get.response.v1.json` +- `api.sessions.events.list.response.v1.json` +- `api.sessions.messages.append.request.v1.json` +- `api.sessions.list.response.v1.json` diff --git a/schemas/protocol/intent.envelope.v1.json b/schemas/protocol/intent.envelope.v1.json index 0f3a22b..1255b86 100644 --- a/schemas/protocol/intent.envelope.v1.json +++ b/schemas/protocol/intent.envelope.v1.json @@ -51,6 +51,11 @@ "payload": { "type": "object" }, + "session_id": { + "description": "Optional. Set when this intent is spawned within a Session orchestrator (see session.envelope.v1).", + "type": "string", + "format": "uuid" + }, "metadata": { "type": "object", "additionalProperties": { diff --git a/schemas/protocol/intent.event.v1.json b/schemas/protocol/intent.event.v1.json index 233ab83..2e4ffcf 100644 --- a/schemas/protocol/intent.event.v1.json +++ b/schemas/protocol/intent.event.v1.json @@ -79,6 +79,11 @@ "WAITING_FOR_TIME" ] }, + "session_id": { + "description": "Optional. Set when the intent belongs to a Session orchestrator (see session.event.v1).", + "type": "string", + "format": "uuid" + }, "payload": { "type": "object" }, diff --git a/schemas/protocol/intent.lifecycle.v1.json b/schemas/protocol/intent.lifecycle.v1.json index 8c67e38..54edb89 100644 --- a/schemas/protocol/intent.lifecycle.v1.json +++ b/schemas/protocol/intent.lifecycle.v1.json @@ -70,6 +70,16 @@ "type": "string", "maxLength": 128 }, + "session_id": { + "description": "Optional. Set when this intent was spawned by a Session orchestrator (see session.lifecycle.v1). Absent for standalone agent-to-agent intents.", + "type": "string", + "format": "uuid" + }, + "parent_intent_id": { + "description": "Optional. Parent intent in a multi-intent hierarchy spawned within a Session.", + "type": "string", + "format": "uuid" + }, "metadata": { "type": "object", "additionalProperties": { diff --git a/schemas/protocol/message.envelope.v3.json b/schemas/protocol/message.envelope.v3.json index d648ea9..889846c 100644 --- a/schemas/protocol/message.envelope.v3.json +++ b/schemas/protocol/message.envelope.v3.json @@ -30,6 +30,14 @@ ], "format": "uuid" }, + "session_id": { + "description": "Optional. Set when this message belongs to a Session orchestrator (see session.envelope.v1).", + "type": [ + "string", + "null" + ], + "format": "uuid" + }, "from": { "type": "string", "minLength": 3, diff --git a/schemas/protocol/session.envelope.v1.json b/schemas/protocol/session.envelope.v1.json new file mode 100644 index 0000000..0cf1aab --- /dev/null +++ b/schemas/protocol/session.envelope.v1.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/protocol/session.envelope.v1.json", + "title": "SessionEnvelopeV1", + "description": "Wire envelope identifying a Session. A Session is a first-class orchestrator (separate from Intent) that holds Messages and may spawn Intents.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "session_id", + "correlation_id", + "created_at", + "owner_agent" + ], + "properties": { + "version": { + "type": "string", + "const": "v1" + }, + "session_id": { + "type": "string", + "format": "uuid" + }, + "correlation_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "owner_agent": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "visitor_ref": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "parent_session_id": { + "type": "string", + "format": "uuid" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/schemas/protocol/session.event.v1.json b/schemas/protocol/session.event.v1.json new file mode 100644 index 0000000..49e5fdc --- /dev/null +++ b/schemas/protocol/session.event.v1.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/protocol/session.event.v1.json", + "title": "SessionEventV1", + "description": "Append-only event in a Session event log. Monotonic seq per session. Sessions orchestrate Messages over DB+SSE and may spawn Intents; intent linkage is carried via intent_id on the relevant events.", + "type": "object", + "additionalProperties": false, + "required": [ + "session_id", + "seq", + "event_type", + "at" + ], + "properties": { + "session_id": { + "type": "string", + "format": "uuid" + }, + "seq": { + "type": "integer", + "minimum": 1 + }, + "event_type": { + "type": "string", + "enum": [ + "session.created", + "session.message", + "session.owner_intervened", + "session.intent_spawned", + "session.intent_resolved", + "session.resolved", + "session.reopened", + "session.closed", + "session.pruned" + ] + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "WAITING_OWNER", + "RESOLVED", + "CLOSED", + "PRUNED" + ] + }, + "at": { + "type": "string", + "format": "date-time" + }, + "actor": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "intent_id": { + "type": "string", + "format": "uuid" + }, + "message_id": { + "type": "string", + "format": "uuid" + }, + "payload": { + "type": "object" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "integer", + "boolean", + "null" + ] + } + } + } +} diff --git a/schemas/protocol/session.lifecycle.v1.json b/schemas/protocol/session.lifecycle.v1.json new file mode 100644 index 0000000..185d7d8 --- /dev/null +++ b/schemas/protocol/session.lifecycle.v1.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/protocol/session.lifecycle.v1.json", + "title": "SessionLifecycleV1", + "description": "Lifecycle state of a Session. Unlike Intent (forward-only, terminal-final), a Session is bidirectional: a RESOLVED session may transition back to OPEN when the visitor returns.", + "type": "object", + "additionalProperties": false, + "required": [ + "session_id", + "correlation_id", + "status", + "seq", + "created_at", + "updated_at" + ], + "properties": { + "session_id": { + "type": "string", + "format": "uuid" + }, + "correlation_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "WAITING_OWNER", + "RESOLVED", + "CLOSED", + "PRUNED" + ] + }, + "seq": { + "type": "integer", + "minimum": 1 + }, + "owner_agent": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "visitor_ref": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "parent_session_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "last_activity_at": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "integer", + "boolean", + "null" + ] + } + } + } +} diff --git a/schemas/public_api/api.sessions.create.request.v1.json b/schemas/public_api/api.sessions.create.request.v1.json new file mode 100644 index 0000000..7ce26f8 --- /dev/null +++ b/schemas/public_api/api.sessions.create.request.v1.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.sessions.create.request.v1.json", + "title": "ApiSessionsCreateRequestV1", + "type": "object", + "additionalProperties": false, + "required": [ + "to_agent", + "correlation_id" + ], + "properties": { + "to_agent": { + "description": "The owner's published agent address (or resolved internal agent address) the visitor is opening a conversation with.", + "type": "string", + "minLength": 3, + "maxLength": 383 + }, + "correlation_id": { + "type": "string", + "format": "uuid" + }, + "from_agent": { + "description": "Deprecated — the visitor identity is derived from the authenticated visitor session. Accepted for backward compatibility but ignored by the server.", + "type": "string", + "minLength": 3, + "maxLength": 383 + }, + "parent_session_id": { + "description": "Optional parent session for multi-session coordination.", + "type": "string", + "format": "uuid" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "integer", + "boolean", + "null" + ] + } + } + } +} diff --git a/schemas/public_api/api.sessions.create.response.v1.json b/schemas/public_api/api.sessions.create.response.v1.json new file mode 100644 index 0000000..f34156e --- /dev/null +++ b/schemas/public_api/api.sessions.create.response.v1.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.sessions.create.response.v1.json", + "title": "ApiSessionsCreateResponseV1", + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "session_id", + "status", + "created_at" + ], + "properties": { + "ok": { + "type": "boolean", + "const": true + }, + "session_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "WAITING_OWNER", + "RESOLVED", + "CLOSED", + "PRUNED" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/schemas/public_api/api.sessions.events.list.response.v1.json b/schemas/public_api/api.sessions.events.list.response.v1.json new file mode 100644 index 0000000..47966c8 --- /dev/null +++ b/schemas/public_api/api.sessions.events.list.response.v1.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.sessions.events.list.response.v1.json", + "title": "ApiSessionsEventsListResponseV1", + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "events" + ], + "properties": { + "ok": { + "type": "boolean", + "const": true + }, + "events": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": [ + "session_id", + "seq", + "event_type", + "at" + ], + "properties": { + "session_id": { + "type": "string", + "format": "uuid" + }, + "seq": { + "type": "integer", + "minimum": 1 + }, + "event_type": { + "type": "string", + "pattern": "^session\\.[a-z_]+$" + }, + "status": { + "type": [ + "string", + "null" + ], + "enum": [ + "OPEN", + "WAITING_OWNER", + "RESOLVED", + "CLOSED", + "PRUNED", + null + ] + }, + "actor": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "intent_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "details": { + "type": "object" + } + } + } + } + } +} diff --git a/schemas/public_api/api.sessions.get.response.v1.json b/schemas/public_api/api.sessions.get.response.v1.json new file mode 100644 index 0000000..62c235a --- /dev/null +++ b/schemas/public_api/api.sessions.get.response.v1.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.sessions.get.response.v1.json", + "title": "ApiSessionsGetResponseV1", + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "session" + ], + "properties": { + "ok": { + "type": "boolean", + "const": true + }, + "session": { + "type": "object", + "additionalProperties": true, + "required": [ + "session_id", + "correlation_id", + "status", + "owner_agent", + "created_at", + "updated_at" + ], + "properties": { + "session_id": { + "type": "string", + "format": "uuid" + }, + "correlation_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "WAITING_OWNER", + "RESOLVED", + "CLOSED", + "PRUNED" + ] + }, + "owner_agent": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "visitor_ref": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "parent_session_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "last_activity_at": { + "type": "string", + "format": "date-time" + }, + "message_count": { + "type": "integer", + "minimum": 0 + }, + "intent_count": { + "type": "integer", + "minimum": 0 + } + } + } + } +} diff --git a/schemas/public_api/api.sessions.list.response.v1.json b/schemas/public_api/api.sessions.list.response.v1.json new file mode 100644 index 0000000..2007c2a --- /dev/null +++ b/schemas/public_api/api.sessions.list.response.v1.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.sessions.list.response.v1.json", + "title": "ApiSessionsListResponseV1", + "description": "Unified visitor inbox: every session the authenticated visitor participates in, across all owners' agents. Scoped strictly by participation.", + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "sessions" + ], + "properties": { + "ok": { + "type": "boolean", + "const": true + }, + "sessions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": [ + "session_id", + "owner_agent", + "status", + "created_at", + "updated_at" + ], + "properties": { + "session_id": { + "type": "string", + "format": "uuid" + }, + "owner_agent": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "WAITING_OWNER", + "RESOLVED", + "CLOSED", + "PRUNED" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "last_activity_at": { + "type": "string", + "format": "date-time" + }, + "unread": { + "type": "boolean" + } + } + } + } + } +} diff --git a/schemas/public_api/api.sessions.messages.append.request.v1.json b/schemas/public_api/api.sessions.messages.append.request.v1.json new file mode 100644 index 0000000..debd127 --- /dev/null +++ b/schemas/public_api/api.sessions.messages.append.request.v1.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://axme.dev/schemas/public_api/api.sessions.messages.append.request.v1.json", + "title": "ApiSessionsMessagesAppendRequestV1", + "type": "object", + "additionalProperties": false, + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 5000 + }, + "attachments": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "upload_id" + ], + "properties": { + "upload_id": { + "type": "string", + "minLength": 1 + }, + "mime_type": { + "type": "string", + "minLength": 3 + } + } + } + } + } +} diff --git a/tests/test_schema_contracts.py b/tests/test_schema_contracts.py index 07b0f4d..355f92a 100644 --- a/tests/test_schema_contracts.py +++ b/tests/test_schema_contracts.py @@ -508,3 +508,155 @@ def test_all_public_api_schemas_parseable(self): for schema_path in sorted(PUBLIC_API.rglob("*.json")): doc = json.loads(schema_path.read_text(encoding="utf-8")) assert "$id" in doc + + +# --------------------------------------------------------------------------- +# Group 12: Session protocol schemas (anti-messenger / D-030) +# --------------------------------------------------------------------------- + +SESSION_STATUSES = {"OPEN", "WAITING_OWNER", "RESOLVED", "CLOSED", "PRUNED"} + + +class TestSessionLifecycle: + schema = load(PROTOCOL / "session.lifecycle.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/protocol/session.lifecycle.v1.json" + + def test_status_enum_is_session_specific(self): + assert set(self.schema["properties"]["status"]["enum"]) == SESSION_STATUSES + + def test_session_is_not_forward_only_like_intent(self): + # D-031: Intent is forward-only with final terminals. Session is + # bidirectional (RESOLVED -> OPEN on visitor return), so it must NOT + # reuse the Intent lifecycle enum. + statuses = set(self.schema["properties"]["status"]["enum"]) + for intent_terminal in ("COMPLETED", "FAILED", "CANCELED", "TIMED_OUT"): + assert intent_terminal not in statuses + + def test_required_fields_present(self): + required = self.schema["required"] + for field in ["session_id", "correlation_id", "status", "seq", "created_at", "updated_at"]: + assert field in required + + def test_additional_properties_false(self): + assert self.schema["additionalProperties"] is False + + +class TestSessionEvent: + schema = load(PROTOCOL / "session.event.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/protocol/session.event.v1.json" + + def test_event_types_present(self): + event_types = set(self.schema["properties"]["event_type"]["enum"]) + for et in [ + "session.created", "session.message", "session.owner_intervened", + "session.intent_spawned", "session.intent_resolved", "session.resolved", + "session.reopened", "session.closed", "session.pruned", + ]: + assert et in event_types, f"Missing session event_type: {et}" + + def test_required_fields(self): + for field in ["session_id", "seq", "event_type", "at"]: + assert field in self.schema["required"] + + def test_intent_linkage_field_present(self): + # session.intent_spawned / session.intent_resolved carry the spawned intent id + assert "intent_id" in self.schema["properties"] + + +class TestSessionEnvelope: + schema = load(PROTOCOL / "session.envelope.v1.json") + + def test_schema_has_correct_id(self): + assert self.schema["$id"] == "https://axme.dev/schemas/protocol/session.envelope.v1.json" + + def test_version_const_v1(self): + assert self.schema["properties"]["version"]["const"] == "v1" + + def test_required_fields(self): + for field in ["version", "session_id", "correlation_id", "created_at", "owner_agent"]: + assert field in self.schema["required"] + + +# --------------------------------------------------------------------------- +# Group 13: additive session_id linkage on existing intent/message schemas +# (must stay v1 — optional, never required) +# --------------------------------------------------------------------------- + +class TestIntentSessionLinkageAdditive: + lifecycle = load(PROTOCOL / "intent.lifecycle.v1.json") + event = load(PROTOCOL / "intent.event.v1.json") + envelope = load(PROTOCOL / "intent.envelope.v1.json") + message_v3 = load(PROTOCOL / "message.envelope.v3.json") + + def test_lifecycle_session_id_optional(self): + assert "session_id" in self.lifecycle["properties"] + assert "parent_intent_id" in self.lifecycle["properties"] + assert "session_id" not in self.lifecycle["required"] + assert "parent_intent_id" not in self.lifecycle["required"] + + def test_lifecycle_status_enum_unchanged(self): + # additive change must not touch the Intent status enum + assert set(self.lifecycle["properties"]["status"]["enum"]) == { + "CREATED", "SUBMITTED", "DELIVERED", "ACKNOWLEDGED", "IN_PROGRESS", + "WAITING", "COMPLETED", "FAILED", "CANCELED", "TIMED_OUT", + } + + def test_event_session_id_optional(self): + assert "session_id" in self.event["properties"] + assert "session_id" not in self.event["required"] + + def test_envelope_session_id_optional(self): + assert "session_id" in self.envelope["properties"] + assert "session_id" not in self.envelope["required"] + + def test_message_v3_session_id_optional_nullable(self): + prop = self.message_v3["properties"]["session_id"] + assert prop["type"] == ["string", "null"] + assert "session_id" not in self.message_v3["required"] + + +# --------------------------------------------------------------------------- +# Group 14: Session public API schemas +# --------------------------------------------------------------------------- + +class TestSessionPublicApi: + create_req = load(PUBLIC_API / "api.sessions.create.request.v1.json") + create_resp = load(PUBLIC_API / "api.sessions.create.response.v1.json") + get_resp = load(PUBLIC_API / "api.sessions.get.response.v1.json") + events_resp = load(PUBLIC_API / "api.sessions.events.list.response.v1.json") + append_req = load(PUBLIC_API / "api.sessions.messages.append.request.v1.json") + list_resp = load(PUBLIC_API / "api.sessions.list.response.v1.json") + + def test_ids_correct(self): + base = "https://axme.dev/schemas/public_api/" + assert self.create_req["$id"] == base + "api.sessions.create.request.v1.json" + assert self.create_resp["$id"] == base + "api.sessions.create.response.v1.json" + assert self.get_resp["$id"] == base + "api.sessions.get.response.v1.json" + assert self.events_resp["$id"] == base + "api.sessions.events.list.response.v1.json" + assert self.append_req["$id"] == base + "api.sessions.messages.append.request.v1.json" + assert self.list_resp["$id"] == base + "api.sessions.list.response.v1.json" + + def test_create_request_required(self): + assert set(self.create_req["required"]) == {"to_agent", "correlation_id"} + + def test_create_response_status_session_enum(self): + assert set(self.create_resp["properties"]["status"]["enum"]) == SESSION_STATUSES + assert set(self.create_resp["required"]) == {"ok", "session_id", "status", "created_at"} + + def test_get_response_shape(self): + assert set(self.get_resp["required"]) == {"ok", "session"} + + def test_events_response_event_type_pattern(self): + item = self.events_resp["properties"]["events"]["items"] + assert item["properties"]["event_type"]["pattern"] == r"^session\.[a-z_]+$" + assert set(item["required"]) == {"session_id", "seq", "event_type", "at"} + + def test_append_request_required_message(self): + assert self.append_req["required"] == ["message"] + + def test_list_response_required(self): + assert set(self.list_resp["required"]) == {"ok", "sessions"}