From 3571bca1affdb3cd9ff0ec05626fd896b5bc30ad Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Wed, 24 Jun 2026 19:28:40 -0600 Subject: [PATCH] feat(chat): honest native-client contract for queue_depth + SSE event kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote to the generated OpenAPI contract exactly the fields/events a native chat client branches on, and prove the served JSON matches the declaration. - POST /api/chat: accepted-turn 200 now carries queue_depth (int) alongside use_id/queued; queue-full 429 carries a stable top-level queue_depth in the Error envelope. Both named in the contract. - Contract framework: _response() renders allOf[Error, named_fields] when a reason-code response also declares named fields, keeping x-reason-codes. - error_response() gains an optional `extra` dict (DRY shared envelope). - Root SSE: x-chat-events extension names the 9 native chat event kinds. - Close the talent_queued omission in CALLOSUM_REGISTRY["chat"]. - Route-level conformance tests assert real Flask JSON ⊆/⊇ the declared shape. - Regenerated docs/openapi/convey-clients.json + CONVEY.md callosum block. All changes additive (classify_changes vs HEAD == []); make check-openapi green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/CONVEY.md | 2 +- docs/openapi/convey-clients.json | 42 +++++++- solstone/convey/chat.py | 8 +- solstone/convey/chat_contract.py | 16 +++- solstone/convey/contract/assemble.py | 11 ++- solstone/convey/root_contract.py | 22 +++++ solstone/convey/utils.py | 3 + tests/test_convey_chat.py | 1 + tests/test_openapi_contract.py | 138 +++++++++++++++++++++++++++ 9 files changed, 231 insertions(+), 12 deletions(-) diff --git a/docs/CONVEY.md b/docs/CONVEY.md index 648c368e9..87ab4f3cd 100644 --- a/docs/CONVEY.md +++ b/docs/CONVEY.md @@ -151,7 +151,7 @@ facet or scope set before forwarding any event. | Tract | Events | |---|---| | `activity` | `live`, `recorded` | -| `chat` | `owner_message`, `sol_message`, `talent_spawned`, `talent_finished`, `talent_errored`, `reflection_ready`, `chat_queue_depth`, `chat_error`, `sol_chat_request`, `sol_chat_request_superseded`, `owner_chat_open`, `owner_chat_dismissed`, `support_draft`, `result`, `support_submit_claim` | +| `chat` | `owner_message`, `sol_message`, `talent_queued`, `talent_spawned`, `talent_finished`, `talent_errored`, `reflection_ready`, `chat_queue_depth`, `chat_error`, `sol_chat_request`, `sol_chat_request_superseded`, `owner_chat_open`, `owner_chat_dismissed`, `support_draft`, `result`, `support_submit_claim` | | `cortex` | `request`, `start`, `thinking`, `tool_start`, `tool_end`, `finish`, `error`, `talent_updated`, `info`, `status` | | `importer` | `started`, `status`, `completed`, `error` | | `logs` | `exec`, `line`, `exit` | diff --git a/docs/openapi/convey-clients.json b/docs/openapi/convey-clients.json index 84b077235..07472583e 100644 --- a/docs/openapi/convey-clients.json +++ b/docs/openapi/convey-clients.json @@ -267,12 +267,16 @@ "content": { "application/json": { "example": { + "queue_depth": 0, "queued": false, "use_id": "1781803200000" }, "schema": { "additionalProperties": true, "properties": { + "queue_depth": { + "type": "integer" + }, "queued": { "type": "boolean" }, @@ -282,7 +286,8 @@ }, "required": [ "use_id", - "queued" + "queued", + "queue_depth" ], "type": "object" } @@ -320,7 +325,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "allOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "additionalProperties": true, + "properties": { + "queue_depth": { + "type": "integer" + } + }, + "required": [ + "queue_depth" + ], + "type": "object" + } + ] } } }, @@ -3293,7 +3314,21 @@ } } }, - "description": "Callosum event stream. Heartbeat frames are comments `: heartbeat\\n\\n`; data frames are `data: {json}\\n\\n` and carry CallosumEvent JSON for all tracts, including chat." + "description": "Callosum event stream. Heartbeat frames are comments `: heartbeat\\n\\n`; data frames are `data: {json}\\n\\n` and carry CallosumEvent JSON for all tracts, including chat.", + "x-chat-events": { + "description": "CallosumEvent kinds on tract 'chat' a native client branches on. Payloads remain open (CallosumEvent.additionalProperties).", + "kinds": [ + "owner_message", + "sol_message", + "talent_queued", + "talent_spawned", + "talent_finished", + "talent_errored", + "chat_queue_depth", + "result", + "chat_error" + ] + } }, "403": { "content": { @@ -3324,6 +3359,7 @@ "chat": [ "owner_message", "sol_message", + "talent_queued", "talent_spawned", "talent_finished", "talent_errored", diff --git a/solstone/convey/chat.py b/solstone/convey/chat.py index a2d1a8c54..72e34d942 100644 --- a/solstone/convey/chat.py +++ b/solstone/convey/chat.py @@ -172,7 +172,10 @@ def post_chat() -> Any: with _state_lock: if _current_chat_use_id is not None and len(_queued_triggers) >= 10: - return error_response(CHAT_QUEUE_FULL) + return error_response( + CHAT_QUEUE_FULL, + extra={"queue_depth": len(_queued_triggers)}, + ) append_chat_event("owner_message", **event_fields) @@ -182,6 +185,7 @@ def post_chat() -> Any: trigger, location, ) + queue_depth = len(_queued_triggers) if start_info is not None: spawn_result = _spawn_chat_generate(start_info) @@ -196,7 +200,7 @@ def post_chat() -> Any: detail="Failed to connect to agent service", ) - return jsonify(use_id=response_use_id, queued=queued) + return jsonify(use_id=response_use_id, queued=queued, queue_depth=queue_depth) @chat_bp.route(f"/{KIND_SOL_CHAT_REQUEST}/open", methods=["POST"]) diff --git a/solstone/convey/chat_contract.py b/solstone/convey/chat_contract.py index 9762141f0..6b67174f3 100644 --- a/solstone/convey/chat_contract.py +++ b/solstone/convey/chat_contract.py @@ -56,18 +56,24 @@ OPERATIONS: list[OperationSpec] = [ named_fields=( FieldSpec("use_id", "string", required=True), FieldSpec("queued", "boolean", required=True), + FieldSpec("queue_depth", "integer", required=True), ), - example={"use_id": "1781803200000", "queued": False}, + example={ + "use_id": "1781803200000", + "queued": False, + "queue_depth": 0, + }, ), _json_error( 400, ("missing_required_field",), "Message text was missing.", ), - _json_error( - 429, - ("chat_queue_full",), - "Chat queue was full.", + ResponseSpec( + status=429, + description="Chat queue was full.", + reason_codes=("chat_queue_full",), + named_fields=(FieldSpec("queue_depth", "integer", required=True),), ), _json_error( 503, diff --git a/solstone/convey/contract/assemble.py b/solstone/convey/contract/assemble.py index b24e5c657..6d90ee99f 100644 --- a/solstone/convey/contract/assemble.py +++ b/solstone/convey/contract/assemble.py @@ -31,6 +31,7 @@ CALLOSUM_REGISTRY: dict[str, list[str]] = { "chat": [ "owner_message", "sol_message", + "talent_queued", "talent_spawned", "talent_finished", "talent_errored", @@ -198,9 +199,17 @@ def _response(response: ResponseSpec) -> dict[str, Any]: result: dict[str, Any] = {"description": response.description} if response.reason_codes: + schema: dict[str, Any] = {"$ref": "#/components/schemas/Error"} + if response.named_fields: + schema = { + "allOf": [ + {"$ref": "#/components/schemas/Error"}, + _object_schema(response.named_fields), + ] + } result["content"] = { "application/json": { - "schema": {"$ref": "#/components/schemas/Error"}, + "schema": schema, } } result["x-reason-codes"] = sorted(set(response.reason_codes)) diff --git a/solstone/convey/root_contract.py b/solstone/convey/root_contract.py index 9e1e09c6e..687516549 100644 --- a/solstone/convey/root_contract.py +++ b/solstone/convey/root_contract.py @@ -7,6 +7,18 @@ from __future__ import annotations from solstone.convey.contract import OperationSpec, ResponseSpec +NATIVE_CHAT_EVENT_KINDS = [ + "owner_message", + "sol_message", + "talent_queued", + "talent_spawned", + "talent_finished", + "talent_errored", + "chat_queue_depth", + "result", + "chat_error", +] + def _json_error( status: int, @@ -49,6 +61,16 @@ OPERATIONS: list[OperationSpec] = [ "ts": 1781803200000, "message": "What changed?", }, + extensions={ + "x-chat-events": { + "description": ( + "CallosumEvent kinds on tract 'chat' a native client " + "branches on. Payloads remain open " + "(CallosumEvent.additionalProperties)." + ), + "kinds": NATIVE_CHAT_EVENT_KINDS, + } + }, ), _json_error( 403, diff --git a/solstone/convey/utils.py b/solstone/convey/utils.py index 48caa00f7..a38ecc1db 100644 --- a/solstone/convey/utils.py +++ b/solstone/convey/utils.py @@ -271,6 +271,7 @@ def error_response( status: int | None = None, *, detail: str | None = None, + extra: dict[str, Any] | None = None, ) -> tuple[Response, int]: """Create a standard JSON error response. @@ -280,6 +281,7 @@ def error_response( reason: Reason constant status: Optional HTTP status override detail: Optional implementation-specific context + extra: Optional additional JSON fields Returns: Tuple of (jsonify response, status_code) ready for Flask return @@ -288,6 +290,7 @@ def error_response( return ( jsonify( { + **(extra or {}), "error": reason.message, "reason_code": reason.code, "detail": detail or "", diff --git a/tests/test_convey_chat.py b/tests/test_convey_chat.py index 243bac153..65bd7d2d5 100644 --- a/tests/test_convey_chat.py +++ b/tests/test_convey_chat.py @@ -2351,6 +2351,7 @@ def test_post_chat_rejects_when_queue_depth_cap_reached(chat_client, monkeypatch "error": "Chat queue full", "reason_code": "chat_queue_full", "detail": "", + "queue_depth": 10, } events = read_chat_events(date.today().strftime("%Y%m%d")) assert [event for event in events if event["kind"] == "owner_message"] == [] diff --git a/tests/test_openapi_contract.py b/tests/test_openapi_contract.py index 1fd79d895..ff39841d3 100644 --- a/tests/test_openapi_contract.py +++ b/tests/test_openapi_contract.py @@ -11,10 +11,12 @@ from typing import Any import pytest +import solstone.convey.chat as chat from solstone.apps.home.contract import OPERATIONS as HOME_OPERATIONS from solstone.apps.network.contract import OPERATIONS as LINK_OPERATIONS from solstone.apps.observer.contract import OPERATIONS as OBSERVER_OPERATIONS from solstone.convey import create_app +from solstone.convey.chat import ChatSpawnResult from solstone.convey.chat_contract import OPERATIONS as CHAT_OPERATIONS from solstone.convey.contract.assemble import build_document from solstone.convey.contract.diff import ( @@ -175,6 +177,33 @@ def _push_identity() -> ConveyIdentity: ) +def _reset_chat_state() -> None: + chat.stop_all_chat_runtime() + with chat._state_lock: + chat._current_chat_use_id = None + chat._current_chat_state = None + chat._queued_triggers.clear() + chat._active_talents.clear() + chat._reserved_use_ids.clear() + chat._thinking_buffers.clear() + chat._thinking_providers.clear() + for timer in chat._watchdog_timers.values(): + timer.cancel() + chat._watchdog_timers.clear() + chat._last_use_id = 0 + + +def _patch_chat_post_dependencies(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "solstone.think.identity.ensure_identity_directory", + lambda: None, + ) + monkeypatch.setattr( + "solstone.convey.chat._spawn_chat_generate", + lambda _action: ChatSpawnResult(ok=True), + ) + + def test_all_fragment_routes_resolve(contract_app): app, _client, _journal = contract_app assert build_document()["paths"] @@ -331,6 +360,104 @@ def test_home_pulse_named_fields_present(contract_app): assert schema.get("additionalProperties") is True +def test_post_chat_accepted_named_fields_present(contract_app, monkeypatch): + _reset_chat_state() + _patch_chat_post_dependencies(monkeypatch) + _app, client, _journal = contract_app + document = build_document() + + response = client.post("/api/chat", json={"message": "hi"}) + + assert response.status_code == 200, response.get_data(as_text=True) + body = response.get_json() + assert isinstance(body, dict) + assert isinstance(body.get("use_id"), str) and body["use_id"] + assert isinstance(body.get("queued"), bool) + assert isinstance(body.get("queue_depth"), int) + allowed = _declared_response_fields(document, "chat.postMessage", 200) + assert allowed <= set(body) + assert undeclared_top_level_fields(allowed, body) == [] + + +def test_post_chat_queue_full_carries_depth(contract_app, monkeypatch): + _reset_chat_state() + _app, client, _journal = contract_app + document = build_document() + monkeypatch.setattr( + "solstone.think.identity.ensure_identity_directory", + lambda: None, + ) + with chat._state_lock: + chat._current_chat_use_id = "current" + chat._current_chat_state = { + "raw_use_id": "raw-current", + "raw_use_ids_seen": {"raw-current"}, + "trigger": {"type": "owner_message", "message": "busy"}, + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, + "retry_count": 0, + } + for index in range(10): + chat._queued_triggers.append( + { + "use_id": str(index + 1), + "trigger": { + "type": "owner_message", + "message": f"queued {index}", + }, + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, + } + ) + + response = client.post("/api/chat", json={"message": "x"}) + + assert response.status_code == 429 + body = response.get_json() + assert isinstance(body, dict) + _assert_structured_error(body, document) + assert body["reason_code"] == "chat_queue_full" + assert isinstance(body["queue_depth"], int) and body["queue_depth"] == 10 + + +def test_post_chat_missing_message_reason_code(contract_app, monkeypatch): + _reset_chat_state() + _patch_chat_post_dependencies(monkeypatch) + _app, client, _journal = contract_app + document = build_document() + + response = client.post("/api/chat", json={}) + + assert response.status_code == 400 + body = response.get_json() + assert isinstance(body, dict) + _assert_structured_error(body, document) + assert body["reason_code"] == "missing_required_field" + + +def test_chat_session_empty_state_named_fields(contract_app): + _reset_chat_state() + _app, client, _journal = contract_app + document = build_document() + + response = client.get("/api/chat/session") + + assert response.status_code == 200, response.get_data(as_text=True) + body = response.get_json() + assert isinstance(body, dict) + allowed = _declared_response_fields(document, "chat.session", 200) + assert allowed <= set(body) + assert undeclared_top_level_fields(allowed, body) == [] + assert body["chat_error"] is None + assert body["latest_sol_message"] is None + for key in ( + "active_talents", + "queued_talents", + "completed_talents", + "errored_talents", + ): + assert body[key] == [] + assert isinstance(body["queue_depth"], int) + + def test_contracted_inventory_triples(): document = build_document() expected_operation_ids = { @@ -360,6 +487,17 @@ def test_root_sse_event_stream(): "$ref": "#/components/schemas/CallosumEvent" } assert "x-sse-error-frame" not in response + assert set(response["x-chat-events"]["kinds"]) == { + "owner_message", + "sol_message", + "talent_queued", + "talent_spawned", + "talent_finished", + "talent_errored", + "chat_queue_depth", + "result", + "chat_error", + } def test_all_referenced_reason_codes_are_global(): -- 2.51.2