personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(chat): honest native-client contract for queue_depth + SSE event kinds

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) <noreply@anthropic.com>

+231 -12
+1 -1
docs/CONVEY.md
··· 151 151 | Tract | Events | 152 152 |---|---| 153 153 | `activity` | `live`, `recorded` | 154 - | `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` | 154 + | `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` | 155 155 | `cortex` | `request`, `start`, `thinking`, `tool_start`, `tool_end`, `finish`, `error`, `talent_updated`, `info`, `status` | 156 156 | `importer` | `started`, `status`, `completed`, `error` | 157 157 | `logs` | `exec`, `line`, `exit` |
+39 -3
docs/openapi/convey-clients.json
··· 267 267 "content": { 268 268 "application/json": { 269 269 "example": { 270 + "queue_depth": 0, 270 271 "queued": false, 271 272 "use_id": "1781803200000" 272 273 }, 273 274 "schema": { 274 275 "additionalProperties": true, 275 276 "properties": { 277 + "queue_depth": { 278 + "type": "integer" 279 + }, 276 280 "queued": { 277 281 "type": "boolean" 278 282 }, ··· 282 286 }, 283 287 "required": [ 284 288 "use_id", 285 - "queued" 289 + "queued", 290 + "queue_depth" 286 291 ], 287 292 "type": "object" 288 293 } ··· 320 325 "content": { 321 326 "application/json": { 322 327 "schema": { 323 - "$ref": "#/components/schemas/Error" 328 + "allOf": [ 329 + { 330 + "$ref": "#/components/schemas/Error" 331 + }, 332 + { 333 + "additionalProperties": true, 334 + "properties": { 335 + "queue_depth": { 336 + "type": "integer" 337 + } 338 + }, 339 + "required": [ 340 + "queue_depth" 341 + ], 342 + "type": "object" 343 + } 344 + ] 324 345 } 325 346 } 326 347 }, ··· 3293 3314 } 3294 3315 } 3295 3316 }, 3296 - "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." 3317 + "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.", 3318 + "x-chat-events": { 3319 + "description": "CallosumEvent kinds on tract 'chat' a native client branches on. Payloads remain open (CallosumEvent.additionalProperties).", 3320 + "kinds": [ 3321 + "owner_message", 3322 + "sol_message", 3323 + "talent_queued", 3324 + "talent_spawned", 3325 + "talent_finished", 3326 + "talent_errored", 3327 + "chat_queue_depth", 3328 + "result", 3329 + "chat_error" 3330 + ] 3331 + } 3297 3332 }, 3298 3333 "403": { 3299 3334 "content": { ··· 3324 3359 "chat": [ 3325 3360 "owner_message", 3326 3361 "sol_message", 3362 + "talent_queued", 3327 3363 "talent_spawned", 3328 3364 "talent_finished", 3329 3365 "talent_errored",
+6 -2
solstone/convey/chat.py
··· 172 172 173 173 with _state_lock: 174 174 if _current_chat_use_id is not None and len(_queued_triggers) >= 10: 175 - return error_response(CHAT_QUEUE_FULL) 175 + return error_response( 176 + CHAT_QUEUE_FULL, 177 + extra={"queue_depth": len(_queued_triggers)}, 178 + ) 176 179 177 180 append_chat_event("owner_message", **event_fields) 178 181 ··· 182 185 trigger, 183 186 location, 184 187 ) 188 + queue_depth = len(_queued_triggers) 185 189 186 190 if start_info is not None: 187 191 spawn_result = _spawn_chat_generate(start_info) ··· 196 200 detail="Failed to connect to agent service", 197 201 ) 198 202 199 - return jsonify(use_id=response_use_id, queued=queued) 203 + return jsonify(use_id=response_use_id, queued=queued, queue_depth=queue_depth) 200 204 201 205 202 206 @chat_bp.route(f"/{KIND_SOL_CHAT_REQUEST}/open", methods=["POST"])
+11 -5
solstone/convey/chat_contract.py
··· 56 56 named_fields=( 57 57 FieldSpec("use_id", "string", required=True), 58 58 FieldSpec("queued", "boolean", required=True), 59 + FieldSpec("queue_depth", "integer", required=True), 59 60 ), 60 - example={"use_id": "1781803200000", "queued": False}, 61 + example={ 62 + "use_id": "1781803200000", 63 + "queued": False, 64 + "queue_depth": 0, 65 + }, 61 66 ), 62 67 _json_error( 63 68 400, 64 69 ("missing_required_field",), 65 70 "Message text was missing.", 66 71 ), 67 - _json_error( 68 - 429, 69 - ("chat_queue_full",), 70 - "Chat queue was full.", 72 + ResponseSpec( 73 + status=429, 74 + description="Chat queue was full.", 75 + reason_codes=("chat_queue_full",), 76 + named_fields=(FieldSpec("queue_depth", "integer", required=True),), 71 77 ), 72 78 _json_error( 73 79 503,
+10 -1
solstone/convey/contract/assemble.py
··· 31 31 "chat": [ 32 32 "owner_message", 33 33 "sol_message", 34 + "talent_queued", 34 35 "talent_spawned", 35 36 "talent_finished", 36 37 "talent_errored", ··· 198 199 result: dict[str, Any] = {"description": response.description} 199 200 200 201 if response.reason_codes: 202 + schema: dict[str, Any] = {"$ref": "#/components/schemas/Error"} 203 + if response.named_fields: 204 + schema = { 205 + "allOf": [ 206 + {"$ref": "#/components/schemas/Error"}, 207 + _object_schema(response.named_fields), 208 + ] 209 + } 201 210 result["content"] = { 202 211 "application/json": { 203 - "schema": {"$ref": "#/components/schemas/Error"}, 212 + "schema": schema, 204 213 } 205 214 } 206 215 result["x-reason-codes"] = sorted(set(response.reason_codes))
+22
solstone/convey/root_contract.py
··· 7 7 8 8 from solstone.convey.contract import OperationSpec, ResponseSpec 9 9 10 + NATIVE_CHAT_EVENT_KINDS = [ 11 + "owner_message", 12 + "sol_message", 13 + "talent_queued", 14 + "talent_spawned", 15 + "talent_finished", 16 + "talent_errored", 17 + "chat_queue_depth", 18 + "result", 19 + "chat_error", 20 + ] 21 + 10 22 11 23 def _json_error( 12 24 status: int, ··· 48 60 "event": "owner_message", 49 61 "ts": 1781803200000, 50 62 "message": "What changed?", 63 + }, 64 + extensions={ 65 + "x-chat-events": { 66 + "description": ( 67 + "CallosumEvent kinds on tract 'chat' a native client " 68 + "branches on. Payloads remain open " 69 + "(CallosumEvent.additionalProperties)." 70 + ), 71 + "kinds": NATIVE_CHAT_EVENT_KINDS, 72 + } 51 73 }, 52 74 ), 53 75 _json_error(
+3
solstone/convey/utils.py
··· 271 271 status: int | None = None, 272 272 *, 273 273 detail: str | None = None, 274 + extra: dict[str, Any] | None = None, 274 275 ) -> tuple[Response, int]: 275 276 """Create a standard JSON error response. 276 277 ··· 280 281 reason: Reason constant 281 282 status: Optional HTTP status override 282 283 detail: Optional implementation-specific context 284 + extra: Optional additional JSON fields 283 285 284 286 Returns: 285 287 Tuple of (jsonify response, status_code) ready for Flask return ··· 288 290 return ( 289 291 jsonify( 290 292 { 293 + **(extra or {}), 291 294 "error": reason.message, 292 295 "reason_code": reason.code, 293 296 "detail": detail or "",
+1
tests/test_convey_chat.py
··· 2351 2351 "error": "Chat queue full", 2352 2352 "reason_code": "chat_queue_full", 2353 2353 "detail": "", 2354 + "queue_depth": 10, 2354 2355 } 2355 2356 events = read_chat_events(date.today().strftime("%Y%m%d")) 2356 2357 assert [event for event in events if event["kind"] == "owner_message"] == []
+138
tests/test_openapi_contract.py
··· 11 11 12 12 import pytest 13 13 14 + import solstone.convey.chat as chat 14 15 from solstone.apps.home.contract import OPERATIONS as HOME_OPERATIONS 15 16 from solstone.apps.network.contract import OPERATIONS as LINK_OPERATIONS 16 17 from solstone.apps.observer.contract import OPERATIONS as OBSERVER_OPERATIONS 17 18 from solstone.convey import create_app 19 + from solstone.convey.chat import ChatSpawnResult 18 20 from solstone.convey.chat_contract import OPERATIONS as CHAT_OPERATIONS 19 21 from solstone.convey.contract.assemble import build_document 20 22 from solstone.convey.contract.diff import ( ··· 175 177 ) 176 178 177 179 180 + def _reset_chat_state() -> None: 181 + chat.stop_all_chat_runtime() 182 + with chat._state_lock: 183 + chat._current_chat_use_id = None 184 + chat._current_chat_state = None 185 + chat._queued_triggers.clear() 186 + chat._active_talents.clear() 187 + chat._reserved_use_ids.clear() 188 + chat._thinking_buffers.clear() 189 + chat._thinking_providers.clear() 190 + for timer in chat._watchdog_timers.values(): 191 + timer.cancel() 192 + chat._watchdog_timers.clear() 193 + chat._last_use_id = 0 194 + 195 + 196 + def _patch_chat_post_dependencies(monkeypatch: pytest.MonkeyPatch) -> None: 197 + monkeypatch.setattr( 198 + "solstone.think.identity.ensure_identity_directory", 199 + lambda: None, 200 + ) 201 + monkeypatch.setattr( 202 + "solstone.convey.chat._spawn_chat_generate", 203 + lambda _action: ChatSpawnResult(ok=True), 204 + ) 205 + 206 + 178 207 def test_all_fragment_routes_resolve(contract_app): 179 208 app, _client, _journal = contract_app 180 209 assert build_document()["paths"] ··· 331 360 assert schema.get("additionalProperties") is True 332 361 333 362 363 + def test_post_chat_accepted_named_fields_present(contract_app, monkeypatch): 364 + _reset_chat_state() 365 + _patch_chat_post_dependencies(monkeypatch) 366 + _app, client, _journal = contract_app 367 + document = build_document() 368 + 369 + response = client.post("/api/chat", json={"message": "hi"}) 370 + 371 + assert response.status_code == 200, response.get_data(as_text=True) 372 + body = response.get_json() 373 + assert isinstance(body, dict) 374 + assert isinstance(body.get("use_id"), str) and body["use_id"] 375 + assert isinstance(body.get("queued"), bool) 376 + assert isinstance(body.get("queue_depth"), int) 377 + allowed = _declared_response_fields(document, "chat.postMessage", 200) 378 + assert allowed <= set(body) 379 + assert undeclared_top_level_fields(allowed, body) == [] 380 + 381 + 382 + def test_post_chat_queue_full_carries_depth(contract_app, monkeypatch): 383 + _reset_chat_state() 384 + _app, client, _journal = contract_app 385 + document = build_document() 386 + monkeypatch.setattr( 387 + "solstone.think.identity.ensure_identity_directory", 388 + lambda: None, 389 + ) 390 + with chat._state_lock: 391 + chat._current_chat_use_id = "current" 392 + chat._current_chat_state = { 393 + "raw_use_id": "raw-current", 394 + "raw_use_ids_seen": {"raw-current"}, 395 + "trigger": {"type": "owner_message", "message": "busy"}, 396 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 397 + "retry_count": 0, 398 + } 399 + for index in range(10): 400 + chat._queued_triggers.append( 401 + { 402 + "use_id": str(index + 1), 403 + "trigger": { 404 + "type": "owner_message", 405 + "message": f"queued {index}", 406 + }, 407 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 408 + } 409 + ) 410 + 411 + response = client.post("/api/chat", json={"message": "x"}) 412 + 413 + assert response.status_code == 429 414 + body = response.get_json() 415 + assert isinstance(body, dict) 416 + _assert_structured_error(body, document) 417 + assert body["reason_code"] == "chat_queue_full" 418 + assert isinstance(body["queue_depth"], int) and body["queue_depth"] == 10 419 + 420 + 421 + def test_post_chat_missing_message_reason_code(contract_app, monkeypatch): 422 + _reset_chat_state() 423 + _patch_chat_post_dependencies(monkeypatch) 424 + _app, client, _journal = contract_app 425 + document = build_document() 426 + 427 + response = client.post("/api/chat", json={}) 428 + 429 + assert response.status_code == 400 430 + body = response.get_json() 431 + assert isinstance(body, dict) 432 + _assert_structured_error(body, document) 433 + assert body["reason_code"] == "missing_required_field" 434 + 435 + 436 + def test_chat_session_empty_state_named_fields(contract_app): 437 + _reset_chat_state() 438 + _app, client, _journal = contract_app 439 + document = build_document() 440 + 441 + response = client.get("/api/chat/session") 442 + 443 + assert response.status_code == 200, response.get_data(as_text=True) 444 + body = response.get_json() 445 + assert isinstance(body, dict) 446 + allowed = _declared_response_fields(document, "chat.session", 200) 447 + assert allowed <= set(body) 448 + assert undeclared_top_level_fields(allowed, body) == [] 449 + assert body["chat_error"] is None 450 + assert body["latest_sol_message"] is None 451 + for key in ( 452 + "active_talents", 453 + "queued_talents", 454 + "completed_talents", 455 + "errored_talents", 456 + ): 457 + assert body[key] == [] 458 + assert isinstance(body["queue_depth"], int) 459 + 460 + 334 461 def test_contracted_inventory_triples(): 335 462 document = build_document() 336 463 expected_operation_ids = { ··· 360 487 "$ref": "#/components/schemas/CallosumEvent" 361 488 } 362 489 assert "x-sse-error-frame" not in response 490 + assert set(response["x-chat-events"]["kinds"]) == { 491 + "owner_message", 492 + "sol_message", 493 + "talent_queued", 494 + "talent_spawned", 495 + "talent_finished", 496 + "talent_errored", 497 + "chat_queue_depth", 498 + "result", 499 + "chat_error", 500 + } 363 501 364 502 365 503 def test_all_referenced_reason_codes_are_global():