diff --git a/docs/design/observer-actions.md b/docs/design/observer-actions.md deleted file mode 100644 index 62eea97cd..000000000 --- a/docs/design/observer-actions.md +++ /dev/null @@ -1,346 +0,0 @@ -# Wave 4 observer actions - -## 1. Summary - -Wave 4 adds a second per-session voice side-effect queue beside nav hints: observer actions. The new queue is purpose-built for structured action payloads, not string hints, and it extends the established Wave 2 tool-dispatch pattern rather than refactoring it. `observer.start_listening` stops being a pure stub and starts returning an internal `_observer_action` sentinel; `dispatch_tool_call(...)` strips that sentinel from the model-facing JSON, enqueues a structured action under the voice session `call_id`, and leaves the existing sideband/runtime flow unchanged. A new root voice route, `GET /api/voice/observer-actions`, lets the iOS client poll for queued actions and drain them with intentionally lenient semantics: missing, blank, or unknown `call_id` returns HTTP 200 with `{"actions": [], "consumed": true}` so polling can stay robust on a simple cadence even if the client temporarily loses the echoed call id (`solstone/think/voice/tools.py:686-739`, `solstone/convey/voice.py:160-166`, `solstone/think/voice/sideband.py:20-37`, `solstone/think/voice/nav_queue.py:17-84`). - -## 2. Module layout - -New files: - -- `solstone/think/voice/observer_queue.py` — thread-safe per-`call_id` FIFO for observer action payloads. Mirrors `solstone/think/voice/nav_queue.py` mechanically, but stores dict payloads instead of strings, owns its own TTL/capacity constants, and exports a module-level singleton accessor. -- `tests/test_voice_observer_queue.py` — unit coverage for TTL expiry, FIFO capacity, drain-clears semantics, malformed enqueue rejection, and basic thread-safety, following the pattern in `tests/test_voice_nav_queue.py`. - -Existing files changed: - -- `solstone/think/voice/tools.py` — keep the `observer.start_listening` manifest entry and valid modes, but change `handle_observer_start_listening(...)` from stub-ack to sentinel-emitting return data; extend `dispatch_tool_call(...)` so it strips `_observer_action` and enqueues the structured payload keyed by the session `call_id` it already receives (`solstone/think/voice/tools.py:121-135`, `solstone/think/voice/tools.py:686-739`). -- `solstone/convey/voice.py` — add `GET /api/voice/observer-actions` beside `GET /api/voice/nav-hints`, with intentionally lenient `call_id` semantics and drain-on-read behavior (`solstone/convey/voice.py:26-30`, `solstone/convey/voice.py:160-166`). -- `tests/test_voice_routes.py` — add route coverage for the new endpoint. -- `tests/test_voice_tools.py` — update the observer handler happy-path assertion and add dispatch-enqueue coverage parallel to the existing nav-target stripping test. -- `tests/test_voice_integration.py` — add a fake-Realtime round trip that drives `observer.start_listening`, verifies the stripped public tool output, and drains the observer action queue through the new route. - -Deliberate non-changes: - -- No `solstone/think/voice/nav_queue.py` refactor into a shared generic base. The payload type, validation semantics, and current scale do not justify generification. -- No `solstone/think/voice/sideband.py` or `solstone/think/voice/runtime.py` changes. The existing sideband loop already routes every tool call through `dispatch_tool_call(...)`, which remains the single place where per-session queue side effects happen (`solstone/think/voice/sideband.py:20-37`). -- No `solstone/apps/observer/routes.py` changes. The existing ingest endpoint is already compatible with the planned iOS multipart upload shape; this design only documents that compatibility and the filename-stem caveat (`solstone/apps/observer/routes.py:503-643`). - -Public surface for the new queue module: - -```python -@dataclass(frozen=True) -class QueuedAction: - payload: dict[str, Any] - created_at: float - - -class ObserverActionQueue: - def __init__( - self, - *, - ttl_seconds: int = 60, - capacity: int = 8, - ) -> None: ... - - def push( - self, - call_id: str, - action: dict[str, Any], - *, - now: float | None = None, - ) -> None: ... - - def drain( - self, - call_id: str, - *, - now: float | None = None, - ) -> list[dict[str, Any]]: ... - - def clear(self) -> None: ... - - -def get_observer_queue() -> ObserverActionQueue: ... - - -@voice_bp.get("/observer-actions") -def observer_actions(): - ... -``` - -## 3. Flow diagram - -```text -OpenAI Realtime tool call - -> think.voice.sideband._sideband_loop(...) - -> think.voice.tools.dispatch_tool_call(name, arguments, call_id, app) - -> handle_observer_start_listening(payload, app) - -> handler returns: - { - "status": "requested", - "mode": "meeting" | "voice_memo", - "note": "sol will start listening shortly", - "_observer_action": { - "type": "start_observer", - "mode": ... - } - } - -> dispatch_tool_call(...) strips "_observer_action" - -> dispatch_tool_call(...) enqueues action in ObserverActionQueue under session call_id - -> stripped JSON is posted back to Realtime as function_call_output - -iOS client - -> observes tool-call completion on the data channel - -> polls GET /api/voice/observer-actions?call_id= - -> route drains queued actions for that call_id - -> route returns {"actions": [...], "consumed": true} - -> client applies {"type": "start_observer", "mode": "..."} - -> client starts observer capture and uploads multipart media to /app/observer/ingest -``` - -## 4. Endpoint spec - -### `GET /api/voice/observer-actions` - -Request: - -- Method: `GET` -- Path: `/api/voice/observer-actions` -- Query params: - - `call_id: string` — optional in practice. The route trims it if present, but blank, missing, or unknown values all resolve to an empty successful response. - -Success response: - -- HTTP 200 -- Body: `{"actions": [...], "consumed": true}` - -Response behavior: - -- Missing `call_id` returns `{"actions": [], "consumed": true}` with HTTP 200. -- Blank `call_id` returns `{"actions": [], "consumed": true}` with HTTP 200. -- Unknown `call_id` returns `{"actions": [], "consumed": true}` with HTTP 200. -- Empty queue for a known `call_id` returns `{"actions": [], "consumed": true}` with HTTP 200. -- Non-empty queue returns FIFO-ordered actions, drains the queue entry, and still reports `consumed: true`. - -Side effects: - -- Drains and clears the queue for the provided `call_id`. -- Drops expired actions before returning. - -Intentional divergence from nav hints: - -- `GET /api/voice/nav-hints` currently rejects missing `call_id` with HTTP 400 (`solstone/convey/voice.py:160-166`). -- `GET /api/voice/observer-actions` is intentionally lenient and always returns HTTP 200 with an array payload, because the iOS polling loop is simple cadence-based infrastructure, not in-web-session UI state. Robust empty success responses are a better failure mode than hard 400s when the client temporarily misses or delays the echoed `call_id`. - -## 5. Action shape and handler return shape - -Internal handler return shape from `handle_observer_start_listening(...)`: - -```json -{ - "status": "requested", - "mode": "meeting", - "note": "sol will start listening shortly", - "_observer_action": { - "type": "start_observer", - "mode": "meeting" - } -} -``` - -Public tool output after `dispatch_tool_call(...)` strips the sentinel: - -```json -{ - "status": "requested", - "mode": "meeting", - "note": "sol will start listening shortly" -} -``` - -Queued observer action payload: - -```json -{ - "type": "start_observer", - "mode": "meeting" -} -``` - -Rules: - -- Valid modes remain `meeting` and `voice_memo`, matching the existing manifest contract (`solstone/think/voice/tools.py:121-135`). -- `dispatch_tool_call(...)` remains the only place that turns an internal handler sentinel into a per-session side effect, matching the existing `_nav_target` pattern (`solstone/think/voice/tools.py:736-739`). -- Handler signature stays uniform: `(payload, app) -> dict[str, Any]`. - -## 6. Queue semantics - -`ObserverActionQueue` intentionally matches the proven shape of `NavHintQueue` while keeping its own module and payload typing (`solstone/think/voice/nav_queue.py:21-84`): - -- Per-`call_id` queue stored in a lock-protected `defaultdict(deque)`. -- TTL: 60 seconds. -- Capacity: 8 actions per `call_id`. -- FIFO ordering. -- Oldest item dropped on overflow. -- Expired items dropped on both `push(...)` and `drain(...)`. -- `drain(...)` returns all currently valid actions in order and clears the queue entry. -- `clear()` wipes all queue state for tests and explicit cleanup. -- Blank `call_id` is rejected as a no-op. -- Blank action payload is rejected as a no-op. -- Rejected enqueue attempts should log a warning so malformed producers are diagnosable without surfacing an HTTP failure. - -Validation scope: - -- The queue owns only generic enqueue sanity: blank `call_id` and empty payload rejection. -- The producer (`handle_observer_start_listening(...)` plus `dispatch_tool_call(...)`) owns the concrete Wave 4 action contract: `{"type": "start_observer", "mode": "meeting"|"voice_memo"}`. - -## 7. Polling cadence guidance - -Observer-actions polling should mirror nav-hints polling: - -- The client should begin or continue polling after each tool-call event it observes on the Realtime data channel. -- Default poll interval: 500ms. -- The client should stop polling when the voice session ends. -- Because the route is lenient, empty 200 responses are part of the normal cadence rather than a client error condition. -- The client should use the same session `call_id` it passed to `POST /api/voice/connect`, because that is the key `dispatch_tool_call(...)` uses for queueing. The route is not keyed by the per-tool `event.call_id` echoed back in the `function_call_output` envelope (`solstone/convey/voice.py:119-127`, `solstone/think/voice/sideband.py:24-35`). - -## 8. Failure semantics - -- Tool JSON decode errors, unknown tools, and handler exceptions remain inside the existing `dispatch_tool_call(...)` wrapper and return the existing inline error payloads; no observer action is enqueued in those cases (`solstone/think/voice/tools.py:720-739`). -- Invalid listen modes still return `{"error": "invalid mode"}` and do not enqueue an action (`solstone/think/voice/tools.py:690-698`). -- Queue overflow drops the oldest queued action for that `call_id`, logs a warning, and keeps processing the new action. -- TTL expiry drops stale queued actions without turning them into route or tool failures. -- Blank enqueue inputs are no-ops with warning logs, not raised exceptions. -- The route itself has no endpoint-specific 4xx failure path; all missing, blank, unknown, or empty queue states collapse to HTTP 200 with an empty `actions` array. -- The queue is in-memory only, matching nav hints. Process restart or runtime teardown drops queued observer actions; that is acceptable for this adjunct path because the client polls immediately and the action is cheap to reissue on a later tool turn. - -## 9. Observer-ingest compatibility note - -Compatibility verdict: - -- No ingest code changes are required for Wave 4. -- The existing observer ingest surfaces already accept the planned iOS multipart upload shape: - - Primary path: `POST /app/observer/ingest` with `Authorization: Bearer ` - - Legacy fallback: `POST /app/observer/ingest/` -- Required fields: `segment=HHMMSS_LEN`, `day=YYYYMMDD`, and one or more `files`. -- Optional fields: `host`, `platform`, and `meta` (JSON object encoded as a form field). -- The ingest layer is binary-format-agnostic: it reads uploaded file bytes, sanitizes filenames, skips empty uploads, and writes the files to disk, but does not validate MIME type, codec, sample rate, or channel layout (`solstone/apps/observer/routes.py:309-367`, `solstone/apps/observer/routes.py:503-585`). - -Stream resolution: - -- If `meta["stream"]` is present and matches the stream-name regex, the server trusts it. -- Otherwise the server derives the stream from the observer registration name via `stream_name(observer=observer_name)`. -- `meta.stream` is therefore optional. The client only needs to send it when it wants a qualified stream name that should not be normalized from the observer registration name (`solstone/apps/observer/routes.py:589-600`). -- The existing regression coverage for qualified stream preservation is in `solstone/apps/observer/tests/test_routes.py:1654-1694`. - -Host / platform semantics: - -- `host` and `platform` are metadata only. -- If both form fields and `meta` contain those values, the `meta` dict wins. -- Hostname mismatch against the registered observer name logs a warning but does not reject the upload (`solstone/apps/observer/routes.py:547-567`, `solstone/apps/observer/routes.py:627-643`). -- The client should send a deterministic `host` so observer records remain coherent across sessions. - -Filename-stem constraint: - -- The ingest layer does not enforce a filename stem, but downstream consumers do care. -- The client should upload `audio.m4a`, not an arbitrary stem like `meeting.m4a`, so transcription writes `audio.jsonl` and existing readers keep finding the transcript under their current glob conventions. -- The transcribe pipeline writes the transcript as a sibling JSONL using the raw file stem, so `audio.m4a` becomes `audio.jsonl` (`solstone/observe/transcribe/main.py:165`, `solstone/observe/transcribe/main.py:594-609`). -- Transcript/cluster consumers currently glob for `*audio.jsonl`, `audio.jsonl`, or `*_audio.jsonl`, not arbitrary stems (`solstone/think/cluster.py:132-136`, `solstone/think/cluster.py:397-405`, `solstone/apps/transcripts/routes.py:256-258`, `solstone/think/retention.py:73-100`). -- `.m4a` itself is supported end-to-end: the media registry includes it, the sensor registers all `AUDIO_EXTENSIONS`, and transcribe accepts `.m4a` as a supported raw input (`solstone/think/media.py:6-25`, `solstone/observe/sense.py:606-667`, `solstone/observe/sense.py:1096-1100`, `solstone/observe/transcribe/main.py:58`, `solstone/observe/transcribe/main.py:885-889`). - -Operational caveats: - -- Duplicate submissions resolve against segment directories on disk, append a - corroborating observer-history record, short-circuit with `status="duplicate"`, - and do not emit `observe.observing` (`test_ingest_duplicate_segment_returns_duplicate_status`, `test_ingest_duplicate_does_not_emit_event`). -- Segment listings can mark raw media as `processed` when journal-side processing - terminally consumed the recorded file and left same-stem sidecar proof. Ingest - duplicate resolution treats that proof as holding absent raw media on its own - for legacy segments; when an `ingest.json` entry is present, it must match the - uploaded hash, and legacy segments graduate to a manifest on their next - resolution. -- Client-supplied reserved names such as `stream.json` and `ingest.json` are - never written from the upload and are not enumerated as held in segment - listings. -- Segment collisions now mean conflicting content at the requested key, not an - occupied key. The server returns the adjusted `segment` in the response body - and records `segment_original` in history. -- If the client ever needs server-truth correlation after upload, it should trust the response body’s `segment`, not assume the requested key survived unchanged. - -## 10. Tests - -New file: `tests/test_voice_observer_queue.py` - -- Unknown `call_id` drains to `[]`. -- `drain(...)` clears the queue entry. -- Expired actions are dropped on drain. -- Capacity enforces FIFO oldest-drop behavior. -- Blank `call_id` and blank payload enqueue attempts are ignored. -- Basic thread-safety sanity for concurrent `push(...)` followed by `drain(...)`. - -Route updates: `tests/test_voice_routes.py` - -- Missing `call_id` returns HTTP 200 with `{"actions": [], "consumed": true}`. -- Unknown `call_id` returns the same empty successful shape. -- Pre-populated queue drains actions through `GET /api/voice/observer-actions`. -- A second drain on the same `call_id` returns an empty successful shape. - -Tool updates: `tests/test_voice_tools.py` - -- `test_observer_start_listening_happy` changes from stub ack to sentinel-bearing internal handler return shape. -- `test_observer_start_listening_failure` stays functionally the same unless valid modes change. -- Add a parallel dispatch test, for example `test_dispatch_tool_call_strips_observer_action`, that asserts: - - the returned JSON omits `_observer_action` - - the public payload is `{"status": "requested", "mode": ..., "note": ...}` - - the observer queue drains one structured action for the session `call_id` -- Keep the existing nav-target stripping test so both queue side-effect channels stay covered. - -Integration updates: `tests/test_voice_integration.py` - -- Add a `_FakeConn` scripted-events case that emits `observer.start_listening`. -- Assert the tool output returned to the fake OpenAI conversation omits `_observer_action`. -- Poll `GET /api/voice/observer-actions?call_id=...` and assert the queued action is returned once and then cleared. -- Keep the current journal/nav-hint round trip as-is; the observer-action case is a sibling integration scenario, not a replacement. - -Expected non-change: - -- `tests/test_voice_sideband.py` should not need structural changes because `dispatch_tool_call(...)` keeps the same signature and remains the only queueing seam the sideband loop calls (`tests/test_voice_sideband.py:60-90`). - -## 11. Decision list - -- Separate queue module vs. generic refactor: separate module, `solstone/think/voice/observer_queue.py`. -- Lenient `call_id` semantics: `GET /api/voice/observer-actions` is always HTTP 200 with an `actions` array, even for missing or unknown `call_id`. -- Sentinel `_observer_action` vs. handler `call_id` parameter: sentinel. `dispatch_tool_call(...)` remains the uniform side-effect seam, matching `_nav_target`. -- Filename-stem constraint surfaced to iOS client: document `audio.m4a` as the required raw filename shape for downstream transcript discoverability. -- No ingest code changes: confirmed compatible. Wave 4 only documents the existing server contract and downstream caveats. - -## 12. Risks / open questions - -- The observer-action queue is in-memory and ephemeral. Restarting the process drops queued actions, just like nav hints. That is acceptable for this adjunct path but worth keeping explicit. -- Lenient HTTP 200 semantics are the right client contract here, but they can hide client-side `call_id` plumbing mistakes if logs are not watched. Warning logs on blank enqueue attempts help, but the route itself will not reveal misuse with a 4xx. -- The queue is keyed by the session `call_id` passed to `POST /api/voice/connect`, not the per-tool `event.call_id` echoed in `function_call_output`. The design makes that distinction explicit because the current fake integration/tests mostly use the same string for both (`tests/test_voice_integration.py:15-57`). -- Queue TTL and cap mean actions can disappear under long client stalls or bursty tool output. That matches nav-hint behavior and is acceptable, but the client should keep polling cadence tight. -- `GET /api/voice/nav-hints` currently returns HTTP 400 on missing `call_id`, but `tests/test_voice_routes.py` does not cover that branch today. That test gap is out of scope for this lode but worth a small follow-up. - -## 13. Sources - -- `docs/design/voice-server.md:71-87`, `docs/design/voice-server.md:172-191`, `docs/design/voice-server.md:215-233`, `docs/design/voice-server.md:412-446` -- `docs/design/push.md:11-45`, `docs/design/push.md:117-224`, `docs/design/push.md:500-583` -- `solstone/convey/__init__.py:152-169` -- `solstone/convey/voice.py:26-30`, `solstone/convey/voice.py:105-127`, `solstone/convey/voice.py:160-166` -- `solstone/think/voice/nav_queue.py:17-84` -- `solstone/think/voice/sideband.py:20-37` -- `solstone/think/voice/tools.py:121-135`, `solstone/think/voice/tools.py:686-739` -- `tests/test_voice_nav_queue.py:11-59` -- `tests/test_voice_tools.py:251-289` -- `tests/test_voice_routes.py:51-61` -- `tests/test_voice_sideband.py:60-90` -- `tests/test_voice_integration.py:15-57`, `tests/test_voice_integration.py:115-159` -- `solstone/apps/observer/routes.py:309-367`, `solstone/apps/observer/routes.py:503-643` -- `solstone/apps/observer/tests/test_routes.py:1276-1368`, `solstone/apps/observer/tests/test_routes.py:1552-1586`, `solstone/apps/observer/tests/test_routes.py:1654-1694` -- `solstone/observe/transcribe/main.py:58`, `solstone/observe/transcribe/main.py:165`, `solstone/observe/transcribe/main.py:594-609`, `solstone/observe/transcribe/main.py:885-889` -- `solstone/observe/sense.py:606-667`, `solstone/observe/sense.py:1096-1100` -- `solstone/think/media.py:6-25` -- `solstone/think/cluster.py:132-136`, `solstone/think/cluster.py:397-405` -- `solstone/apps/transcripts/routes.py:256-258` -- `solstone/think/retention.py:73-100` diff --git a/docs/design/voice-server.md b/docs/design/voice-server.md index f74abbbbd..5bf486154 100644 --- a/docs/design/voice-server.md +++ b/docs/design/voice-server.md @@ -2,7 +2,7 @@ ## 1. Summary -Wave 2 ships a root-level voice API for the existing Convey server: `POST /api/voice/session`, `POST /api/voice/connect`, `POST /api/voice/refresh-brain`, `GET /api/voice/nav-hints`, and `GET /api/voice/status`, all mounted from a new `solstone/convey/voice.py` blueprint at `/api/voice/*`. The implementation reuses existing journal, ledger, entity, briefing, and anticipated-activity read surfaces, keeps all voice-owned writes inside `journal/health/voice-brain-session*`, and treats the bridge contract in the scope as canonical when it conflicts with older prose (`solstone/convey/__init__.py:126-155`, `solstone/convey/system.py:18`, `solstone/apps/home/routes.py:149-198`, `solstone/think/surfaces/ledger.py:441-529`, `solstone/think/indexer/journal.py:1865-1948`). +Wave 2 ships a root-level voice API for the existing Convey server: `POST /api/voice/session`, `POST /api/voice/connect`, `POST /api/voice/refresh-brain`, and `GET /api/voice/status`, all mounted from a new `solstone/convey/voice.py` blueprint at `/api/voice/*`. The implementation reuses existing journal, ledger, entity, briefing, and anticipated-activity read surfaces, keeps all voice-owned writes inside `journal/health/voice-brain-session*`, and treats the bridge contract in the scope as canonical when it conflicts with older prose (`solstone/convey/__init__.py:126-155`, `solstone/convey/system.py:18`, `solstone/apps/home/routes.py:149-198`, `solstone/think/surfaces/ledger.py:441-529`, `solstone/think/indexer/journal.py:1865-1948`). ## 2. Module layout @@ -13,17 +13,15 @@ New files: - `solstone/think/voice/brain.py` — persistent Claude CLI session manager; start, refresh, ask, readiness state, and `journal/health/voice-brain-session*` persistence. - `solstone/think/voice/runtime.py` — singleton daemon-thread asyncio loop, app attachment, task tracking, and shutdown helper. - `solstone/think/voice/sideband.py` — OpenAI Realtime sideband join loop, event filter, tool-call output emission, and task cleanup. -- `solstone/think/voice/tools.py` — the 9 tool schemas, argument validation, handler dispatch, `_nav_target` extraction, and model-facing JSON shaping. -- `solstone/think/voice/nav_queue.py` — thread-safe per-`call_id` nav-hint queue with TTL and capacity enforcement. +- `solstone/think/voice/tools.py` — the 9 tool schemas, argument validation, handler dispatch, and model-facing JSON shaping. - `solstone/think/voice/config.py` — config readers for OpenAI key, voice model, and brain model. - `tests/test_voice_config.py` — config-reader defaults, env fallback, and missing-key cases. - `tests/test_voice_brain.py` — brain prompt content, session persistence, start/refresh/ask flow, and stale-refresh behavior with mocked Claude CLI. - `tests/test_voice_tools.py` — unit coverage for all 9 tool handlers, including one happy path and one failure path per tool. -- `tests/test_voice_sideband.py` — sideband event routing, `_nav_target` stripping, tool error wrapping, and output emission. -- `tests/test_voice_nav_queue.py` — TTL, FIFO capacity, drain semantics, and lock-protected multi-thread sanity. +- `tests/test_voice_sideband.py` — sideband event routing, tool error wrapping, and output emission. - `tests/test_voice_runtime.py` — loop/thread lifecycle, idempotent startup, future registration, and explicit shutdown. - `tests/test_voice_routes.py` — endpoint validation and error-shape coverage with Flask test client. -- `tests/test_voice_integration.py` — end-to-end session mint, sideband task spawn, one tool dispatch, and nav-hint polling with fake OpenAI clients. +- `tests/test_voice_integration.py` — end-to-end session mint, sideband task spawn, and one tool dispatch with fake OpenAI clients. Existing files updated during implementation: @@ -72,19 +70,10 @@ Deliberate non-change: 1. `_sideband_loop(...)` filters for `event.type == "response.function_call_arguments.done"`. 2. `think.voice.tools.dispatch_tool_call(name, arguments, call_id, app)` JSON-decodes the arguments and validates the input schema for that tool. -3. The tool handler reads the existing surface, shapes the model-facing response, and may include `_nav_target`. -4. `dispatch_tool_call(...)` removes `_nav_target` from the JSON sent back to OpenAI and enqueues it into `nav_queue` for the `call_id`. -5. `_sideband_loop(...)` posts `function_call_output` with the stripped JSON string through `conn.conversation.item.create(...)`. -6. `_sideband_loop(...)` calls `conn.response.create()` so the model can continue the turn. -7. Any tool exception becomes `{"error": ""}` and does not end the sideband task. - -### Nav-hint polling - -1. Native client calls `GET /api/voice/nav-hints?call_id=...`. -2. The route validates `call_id`. -3. `nav_queue.drain(call_id)` drops expired hints, returns remaining hints in FIFO order, and clears the queue entry. -4. The route returns `{"hints": [...], "consumed": true}`. -5. Unknown `call_id` returns `{"hints": [], "consumed": true}` with HTTP 200. +3. The tool handler reads the existing surface and shapes the model-facing response. +4. `_sideband_loop(...)` posts `function_call_output` with the JSON string through `conn.conversation.item.create(...)`. +5. `_sideband_loop(...)` calls `conn.response.create()` so the model can continue the turn. +6. Any tool exception becomes `{"error": ""}` and does not end the sideband task. ### Shutdown @@ -169,26 +158,6 @@ Side effects: - Starts the brain first if no session has been established yet. - Updates `app.voice_brain_*` state on success. -### `GET /api/voice/nav-hints` - -Query params: - -- Required `call_id: string`, trimmed, non-empty. - -Success response: - -- HTTP 200 -- Body: `{"hints": ["entity/sarah_chen", "today"], "consumed": true}` - -Failure responses: - -- HTTP 400 `{"error": "call_id is required"}` - -Side effects: - -- Drains and clears the queue for the given `call_id`. -- Drops expired hints before returning. - ### `GET /api/voice/status` Request body: @@ -217,20 +186,19 @@ Side effects: Rules that apply to every tool: - Handlers return model-facing JSON objects only. -- If a handler emits `_nav_target`, `think.voice.tools.dispatch_tool_call(...)` strips it before sending the JSON string to OpenAI and pushes the hint into `think.voice.nav_queue`. - Tool exceptions become `{"error": ""}` and are returned inline through the sideband, never as HTTP errors. -| Tool | Input JSON | Output JSON | Nav rule | Reused surface | Failure shape | -|---|---|---|---|---|---| -| `journal.get_day` | `{"day": "YYYY-MM-DD"}` | `{"day": "YYYY-MM-DD", "segments": [{"id": "HHMMSS_LEN", "time_of_day": "HH:MM", "duration_s": 300, "summary": "", "agent_type": ""}], "summary": "", "_nav_target": "today/journal/YYYY-MM-DD"}` | Always emit `_nav_target` for a valid day lookup | `think.cluster.scan_day`, `think.cluster.cluster_segments`, and read-only day-path inspection for summary text. Use `scan_day()` for day existence and segment inventory, `cluster_segments()` for normalized segment rows, and synthesize `summary` from per-segment `*_summary.md` files under the day directory without calling `think.utils.segment_path()` because it creates missing directories (`solstone/think/cluster.py:413-505`, `solstone/think/utils.py:155-182`, `solstone/think/utils.py:247-260`) | `{"error": "invalid day"}` or `{"error": "day not found"}` | -| `journal.search` | `{"query": "", "facet": "|null", "days": 30|null, "limit": 10|null}` | `{"results": [{"id": "", "day": "YYYY-MM-DD", "source": "", "snippet": "", "entity_slug": ""?}], "count": N, "_nav_target": "today/search?q="}` | Emit `_nav_target` only when `query.strip()` is non-empty | `think.indexer.journal.search_journal(query, limit=limit, facet=facet, day_from=..., day_to=None)` with shaping inspired by `solstone/apps/search/routes.py::_format_result` (`solstone/think/indexer/journal.py:1865-1948`, `solstone/apps/search/routes.py:89-126`, `solstone/apps/search/routes.py:127-240`) | `{"error": "query is required"}` | -| `entities.get` | `{"entity_slug": ""}` | `{"slug": "", "name": "", "type": "", "profile": "", "tags": [""], "recent_context": [{"date": "YYYY-MM-DD", "summary": ""}], "_nav_target": "entity/"}` | Always emit `_nav_target` when found | Primary source is `think.surfaces.profile.full(slug)`. If it resolves, build `profile`, `tags`, and `recent_context` from the returned `Profile`; if facet relationship details are needed, mirror `solstone/apps/entities/routes.py::_build_facet_relationships(...)` and `think.entities.journal.load_journal_entity(slug)` (`solstone/think/surfaces/profile.py:207-245`, `solstone/apps/entities/routes.py:685-730`, `solstone/think/entities/journal.py:43-69`) | `{"error": "not found"}` | -| `entities.recent_with` | `{"entity_slug": "", "days": 7, "facet": "|null"}` | `{"slug": "", "interactions": [{"date": "YYYY-MM-DD", "activity": "", "context": "<story-or-description>", "note": "<details>"}], "count": N}` | No nav hint | Resolve the entity with `think.surfaces.profile.full(slug)`, then scan `think.activities.load_activity_records(facet, day)` across the requested day window. Match `participation[].entity_id` first and fall back to casefolded name / aka matching for older rows without `entity_id` (`solstone/think/surfaces/profile.py:207-245`, `solstone/think/activities.py:877-890`, `solstone/apps/activities/call.py:133-197`) | `{"error": "not found"}` or `{"error": "invalid days"}` | -| `commitments.list` | `{"state": "open"|"closed"|"dropped"|null, "facet": "<string>|null", "limit": 20|null}` | `{"commitments": [{"id": "<id>", "owner": "<owner>", "action": "<action>", "counterparty": "<counterparty>", "state": "<state>", "context": "<context>", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": "<resolution>"?}]}` | No nav hint | `think.surfaces.ledger.list(state=..., facets=[facet] if facet else None, top=limit or 20)`. Convert each `LedgerItem` dataclass to a dict, drop `sources`, and derive `day_*` strings from the millisecond timestamps. `resolution` is best-effort only: set it to `"dropped"` when `item.state == "dropped"`, otherwise omit because the ledger surface does not expose the close-note resolution (`solstone/think/surfaces/ledger.py:441-487`, `solstone/think/surfaces/types.py:16-32`) | `{"error": "invalid state"}` | -| `commitments.complete` | `{"commitment_id": "lg_...", "resolution": "done"|"sent"|"signed"|"dropped"|"deferred"}` | `{"ok": true, "commitment": {"id": "...", "owner": "...", "action": "...", "counterparty": "...", "state": "...", "context": "...", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": "<input-resolution>"}}` | No nav hint | Validate `resolution`. Map `dropped -> as_state="dropped", note="resolution: dropped"`. Map `done|sent|signed|deferred -> as_state="closed", note="resolution: <value>"`. Call `think.surfaces.ledger.close(...)`, catch `KeyError`, and shape the returned `LedgerItem` as above (`solstone/think/surfaces/ledger.py:497-529`, `solstone/think/activities.py:1156-1207`) | `{"error": "invalid resolution"}` or `{"error": "not found"}` | -| `calendar.today` | `{}` | `{"date": "YYYY-MM-DD", "events": [{"time": "HH:MM", "title": "<title>", "attendees": ["<name>"], "location": "<string>", "prep_notes": "<string>"}], "_nav_target": "today"}` | Always emit `_nav_target` | `think.activities.load_activity_records(facet, day)` across all enabled facets, filtered to `source == "anticipated"` using the same participation parsing pattern Home uses today (`solstone/apps/home/routes.py:305-337`, `solstone/think/activities.py:877-890`) | `{"error": "today unavailable"}` only on unexpected failures; normal empty day is `{"date": "...", "events": [], "_nav_target": "today"}` | -| `briefing.get` | `{}` | `{"date": "YYYY-MM-DD", "facet": "identity", "text": "<spoken-English body>", "highlights": ["...", "..."], "_nav_target": "today"}` or `{"error": "no briefing today yet"}` | Emit `_nav_target` only when a fresh briefing exists | Reuse `solstone.think.briefing.load_briefing(today)` and `render_briefing_sections(...)` exactly. `None` returns the error object. `text` is a plain-text join of the loaded sections; `highlights` comes from `needs_attention` items first, then falls back to the first three bullets across the other sections | `{"error": "no briefing today yet"}` | -| `observer.start_listening` | `{"mode": "meeting"|"voice_memo"}` | `{"status": "ack", "mode": "<mode>", "note": "wave-4 observer not yet wired"}` | No nav hint | No data dependency in Wave 2. Log the requested mode at INFO and return the stub acknowledgement. | `{"error": "invalid mode"}` | +| Tool | Input JSON | Output JSON | Reused surface | Failure shape | +|---|---|---|---|---| +| `journal.get_day` | `{"day": "YYYY-MM-DD"}` | `{"day": "YYYY-MM-DD", "segments": [{"id": "HHMMSS_LEN", "time_of_day": "HH:MM", "duration_s": 300, "summary": "<string>", "agent_type": "<stream>"}], "summary": "<string>"}` | `think.cluster.scan_day`, `think.cluster.cluster_segments`, and read-only day-path inspection for summary text. Use `scan_day()` for day existence and segment inventory, `cluster_segments()` for normalized segment rows, and synthesize `summary` from per-segment `*_summary.md` files under the day directory without calling `think.utils.segment_path()` because it creates missing directories (`solstone/think/cluster.py:413-505`, `solstone/think/utils.py:155-182`, `solstone/think/utils.py:247-260`) | `{"error": "invalid day"}` or `{"error": "day not found"}` | +| `journal.search` | `{"query": "<string>", "facet": "<string>|null", "days": 30|null, "limit": 10|null}` | `{"results": [{"id": "<id>", "day": "YYYY-MM-DD", "source": "<agent-or-path>", "snippet": "<text>", "entity_slug": "<slug>"?}], "count": N}` | `think.indexer.journal.search_journal(query, limit=limit, facet=facet, day_from=..., day_to=None)` with shaping inspired by `solstone/apps/search/routes.py::_format_result` (`solstone/think/indexer/journal.py:1865-1948`, `solstone/apps/search/routes.py:89-126`, `solstone/apps/search/routes.py:127-240`) | `{"error": "query is required"}` | +| `entities.get` | `{"entity_slug": "<slug>"}` | `{"slug": "<slug>", "name": "<name>", "type": "<type>", "profile": "<markdown>", "tags": ["<facet-or-aka>"], "recent_context": [{"date": "YYYY-MM-DD", "summary": "<string>"}]}` | Primary source is `think.surfaces.profile.full(slug)`. If it resolves, build `profile`, `tags`, and `recent_context` from the returned `Profile`; if facet relationship details are needed, mirror `solstone/apps/entities/routes.py::_build_facet_relationships(...)` and `think.entities.journal.load_journal_entity(slug)` (`solstone/think/surfaces/profile.py:207-245`, `solstone/apps/entities/routes.py:685-730`, `solstone/think/entities/journal.py:43-69`) | `{"error": "not found"}` | +| `entities.recent_with` | `{"entity_slug": "<slug>", "days": 7, "facet": "<string>|null"}` | `{"slug": "<slug>", "interactions": [{"date": "YYYY-MM-DD", "activity": "<title>", "context": "<story-or-description>", "note": "<details>"}], "count": N}` | Resolve the entity with `think.surfaces.profile.full(slug)`, then scan `think.activities.load_activity_records(facet, day)` across the requested day window. Match `participation[].entity_id` first and fall back to casefolded name / aka matching for older rows without `entity_id` (`solstone/think/surfaces/profile.py:207-245`, `solstone/think/activities.py:877-890`, `solstone/apps/activities/call.py:133-197`) | `{"error": "not found"}` or `{"error": "invalid days"}` | +| `commitments.list` | `{"state": "open"|"closed"|"dropped"|null, "facet": "<string>|null", "limit": 20|null}` | `{"commitments": [{"id": "<id>", "owner": "<owner>", "action": "<action>", "counterparty": "<counterparty>", "state": "<state>", "context": "<context>", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": "<resolution>"?}]}` | `think.surfaces.ledger.list(state=..., facets=[facet] if facet else None, top=limit or 20)`. Convert each `LedgerItem` dataclass to a dict, drop `sources`, and derive `day_*` strings from the millisecond timestamps. `resolution` is best-effort only: set it to `"dropped"` when `item.state == "dropped"`, otherwise omit because the ledger surface does not expose the close-note resolution (`solstone/think/surfaces/ledger.py:441-487`, `solstone/think/surfaces/types.py:16-32`) | `{"error": "invalid state"}` | +| `commitments.complete` | `{"commitment_id": "lg_...", "resolution": "done"|"sent"|"signed"|"dropped"|"deferred"}` | `{"ok": true, "commitment": {"id": "...", "owner": "...", "action": "...", "counterparty": "...", "state": "...", "context": "...", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": "<input-resolution>"}}` | Validate `resolution`. Map `dropped -> as_state="dropped", note="resolution: dropped"`. Map `done|sent|signed|deferred -> as_state="closed", note="resolution: <value>"`. Call `think.surfaces.ledger.close(...)`, catch `KeyError`, and shape the returned `LedgerItem` as above (`solstone/think/surfaces/ledger.py:497-529`, `solstone/think/activities.py:1156-1207`) | `{"error": "invalid resolution"}` or `{"error": "not found"}` | +| `calendar.today` | `{}` | `{"date": "YYYY-MM-DD", "events": [{"time": "HH:MM", "title": "<title>", "attendees": ["<name>"], "location": "<string>", "prep_notes": "<string>"}]}` | `think.activities.load_activity_records(facet, day)` across all enabled facets, filtered to `source == "anticipated"` using the same participation parsing pattern Home uses today (`solstone/apps/home/routes.py:305-337`, `solstone/think/activities.py:877-890`) | `{"error": "today unavailable"}` only on unexpected failures; normal empty day is `{"date": "...", "events": []}` | +| `briefing.get` | `{}` | `{"date": "YYYY-MM-DD", "facet": "identity", "text": "<spoken-English body>", "highlights": ["...", "..."]}` or `{"error": "no briefing today yet"}` | Reuse `solstone.think.briefing.load_briefing(today)` and `render_briefing_sections(...)` exactly. `None` returns the error object. `text` is a plain-text join of the loaded sections; `highlights` comes from `needs_attention` items first, then falls back to the first three bullets across the other sections | `{"error": "no briefing today yet"}` | +| `observer.start_listening` | `{"mode": "meeting"|"voice_memo"}` | `{"status": "ack", "mode": "<mode>", "note": "wave-4 observer not yet wired"}` | No data dependency in Wave 2. Log the requested mode at INFO and return the stub acknowledgement. | `{"error": "invalid mode"}` | Implementation notes by tool: @@ -396,14 +364,13 @@ All four fields are always present. |---|---|---| | Request body missing or invalid JSON for an endpoint that expects JSON | 400 | `{"error": "request body must be valid JSON"}` | | Request body decodes but is not a JSON object | 400 | `{"error": "request body must be a JSON object"}` | -| Missing `call_id` on `/api/voice/connect` or `/api/voice/nav-hints` | 400 | `{"error": "call_id is required"}` | +| Missing `call_id` on `/api/voice/connect` | 400 | `{"error": "call_id is required"}` | | OpenAI key not configured | 503 | `{"error": "voice unavailable — openai key not configured"}` | | Brain not ready after the `/api/voice/session` 10-second wait | 503 | `{"error": "voice unavailable — brain not ready"}` | | OpenAI session mint hard failure | 500 | `{"error": "voice session unavailable"}` | | Background runtime missing | 500 | `{"error": "voice runtime unavailable"}` | | Explicit brain refresh future raises | 500 | `{"error": "brain refresh failed"}` | | Tool handler exception in sideband | n/a, inline tool output | `{"error": "<message>"}` | -| Unknown `call_id` on `/api/voice/nav-hints` | 200 | `{"hints": [], "consumed": true}` | Logging rule: @@ -425,12 +392,11 @@ Per-file plan: |---|---|---|---| | `tests/test_voice_config.py` | `get_openai_api_key`, `get_voice_model`, `get_brain_model`, config defaults, env fallback | `tests/conftest.py` default fixture journal | `monkeypatch.setenv` only | | `tests/test_voice_brain.py` | prompt rendering, session-file load/save/touch, start/refresh/ask control flow, 6-hour stale threshold, readiness state | fixture journal or `journal_copy` for isolated `health/` state | mock Claude CLI subprocess entry points such as `asyncio.create_subprocess_exec` or a small `_run_claude(...)` seam | -| `tests/test_voice_tools.py` | all 9 tool handlers, one happy path and one failure path each, `_nav_target` presence rules, `sources` stripping | real fixture journal via `tests/conftest.py`; `journal_copy` for close/edit cases | monkeypatch `_today()` and any narrow parser seams; do not mock journal contents | -| `tests/test_voice_sideband.py` | event filter, argument decode errors, tool dispatch, `_nav_target` stripping, `function_call_output` emission, `conn.response.create()` cadence | no special journal beyond the fixture default | fake `conn` object and patched dispatcher | -| `tests/test_voice_nav_queue.py` | TTL expiry, cap 8, FIFO drop, drain-clears, unknown `call_id`, basic multi-thread push/drain sanity | none | no external mocks | +| `tests/test_voice_tools.py` | all 9 tool handlers, one happy path and one failure path each, `sources` stripping | real fixture journal via `tests/conftest.py`; `journal_copy` for close/edit cases | monkeypatch `_today()` and any narrow parser seams; do not mock journal contents | +| `tests/test_voice_sideband.py` | event filter, argument decode errors, tool dispatch, `function_call_output` emission, `conn.response.create()` cadence | no special journal beyond the fixture default | fake `conn` object and patched dispatcher | | `tests/test_voice_runtime.py` | singleton startup, duplicate start no-op, future registration and pruning, explicit shutdown, atexit-registration guard | none beyond a minimal Flask app | patch thread join timing if needed | -| `tests/test_voice_routes.py` | endpoint validation: bad JSON, missing key, missing `call_id`, status payload defaults, nav-hint drain | Flask app from `convey.create_app()` with fixture journal | patch `think.voice.config.get_openai_api_key`, `brain.wait_until_ready`, and `AsyncOpenAI` as needed | -| `tests/test_voice_integration.py` | full flow: session mint, connect, one tool event through sideband, nav-hint fetch, active session count transition | real fixture journal plus `journal_copy` when a tool mutates ledger state | patch the `openai` module with fake `AsyncOpenAI`; this follows the existing module-patching precedent in `tests/test_validate_key.py:56-75` | +| `tests/test_voice_routes.py` | endpoint validation: bad JSON, missing key, missing `call_id`, status payload defaults | Flask app from `convey.create_app()` with fixture journal | patch `think.voice.config.get_openai_api_key`, `brain.wait_until_ready`, and `AsyncOpenAI` as needed | +| `tests/test_voice_integration.py` | full flow: session mint, connect, one tool event through sideband, active session count transition | real fixture journal plus `journal_copy` when a tool mutates ledger state | patch the `openai` module with fake `AsyncOpenAI`; this follows the existing module-patching precedent in `tests/test_validate_key.py:56-75` | Specific integration test shape: @@ -442,9 +408,8 @@ Specific integration test shape: 3. Seed `app.voice_brain_instruction` to avoid the first-session wait in the happy-path integration case. 4. `POST /api/voice/session` and assert the key plus tool manifest wiring. 5. `POST /api/voice/connect` with a fake `call_id`. -6. Let the fake sideband drive one tool call that emits `_nav_target`. +6. Let the fake sideband drive one tool call. 7. Assert the OpenAI output JSON does not contain `_nav_target`. -8. `GET /api/voice/nav-hints?call_id=...` and assert the hint is returned and then cleared. Journal-data rule: diff --git a/docs/openapi/convey-clients.json b/docs/openapi/convey-clients.json index dbb5ba82e..33bea96d2 100644 --- a/docs/openapi/convey-clients.json +++ b/docs/openapi/convey-clients.json @@ -968,147 +968,6 @@ ] } }, - "/api/voice/nav-hints": { - "get": { - "description": "Drain queued navigation hints for the given voice call.", - "operationId": "voice.navHints", - "parameters": [ - { - "description": "Voice call identifier.", - "in": "query", - "name": "call_id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "example": { - "consumed": true, - "hints": [] - }, - "schema": { - "additionalProperties": true, - "properties": { - "consumed": { - "type": "boolean" - }, - "hints": { - "items": {}, - "type": "array" - } - }, - "required": [ - "hints", - "consumed" - ], - "type": "object" - } - } - }, - "description": "Navigation hints drained." - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Call identifier was missing.", - "x-reason-codes": [ - "invalid_request_value" - ] - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Access gate rejected a revoked paired-link identity.", - "x-reason-codes": [ - "pl_revoked" - ] - } - }, - "summary": "Drain voice navigation hints", - "tags": [ - "voice" - ] - } - }, - "/api/voice/observer-actions": { - "get": { - "description": "Drain queued observer actions for the given voice call.", - "operationId": "voice.observerActions", - "parameters": [ - { - "description": "Voice call identifier.", - "in": "query", - "name": "call_id", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "example": { - "actions": [], - "consumed": true - }, - "schema": { - "additionalProperties": true, - "properties": { - "actions": { - "items": {}, - "type": "array" - }, - "consumed": { - "type": "boolean" - } - }, - "required": [ - "actions", - "consumed" - ], - "type": "object" - } - } - }, - "description": "Observer actions drained." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Access gate rejected a revoked paired-link identity.", - "x-reason-codes": [ - "pl_revoked" - ] - } - }, - "summary": "Drain voice observer actions", - "tags": [ - "voice" - ] - } - }, "/api/voice/session": { "post": { "description": "Mint an ephemeral key for an OpenAI Realtime voice session.", diff --git a/solstone/convey/voice.py b/solstone/convey/voice.py index d99c784d9..078d835d2 100644 --- a/solstone/convey/voice.py +++ b/solstone/convey/voice.py @@ -23,8 +23,6 @@ from solstone.convey.reasons import ( from solstone.convey.utils import error_response from solstone.think.voice import brain from solstone.think.voice.config import get_openai_api_key, get_voice_model -from solstone.think.voice.nav_queue import get_nav_queue -from solstone.think.voice.observer_queue import get_observer_queue from solstone.think.voice.runtime import get_runtime_state from solstone.think.voice.sideband import _run_sideband, register_voice_task from solstone.think.voice.tools import get_tool_manifest @@ -198,24 +196,6 @@ def refresh_voice_brain(): ) -@voice_bp.route("/nav-hints", methods=["GET", "POST"]) -def nav_hints(): - call_id = request.args.get("call_id", "").strip() - if not call_id: - return error_response(INVALID_REQUEST_VALUE, detail="call_id is required") - hints = get_nav_queue().drain(call_id) - return jsonify({"hints": hints, "consumed": True}) - - -@voice_bp.route("/observer-actions", methods=["GET", "POST"]) -def observer_actions(): - call_id = request.args.get("call_id", "").strip() - if not call_id: - return jsonify({"actions": [], "consumed": True}) - actions = get_observer_queue().drain(call_id) - return jsonify({"actions": actions, "consumed": True}) - - @voice_bp.get("/status") def voice_status(): app = current_app._get_current_object() diff --git a/solstone/convey/voice_contract.py b/solstone/convey/voice_contract.py index b3300d726..fff429832 100644 --- a/solstone/convey/voice_contract.py +++ b/solstone/convey/voice_contract.py @@ -8,7 +8,6 @@ from __future__ import annotations from solstone.convey.contract import ( FieldSpec, OperationSpec, - ParamSpec, RequestSpec, ResponseSpec, ) @@ -101,73 +100,6 @@ OPERATIONS: list[OperationSpec] = [ ), ), ), - OperationSpec( - operation_id="voice.navHints", - method="GET", - rule="/api/voice/nav-hints", - summary="Drain voice navigation hints", - description="Drain queued navigation hints for the given voice call.", - parameters=( - ParamSpec( - "call_id", - "query", - required=True, - description="Voice call identifier.", - ), - ), - responses=( - ResponseSpec( - status=200, - description="Navigation hints drained.", - named_fields=( - FieldSpec("hints", "array", required=True), - FieldSpec("consumed", "boolean", required=True), - ), - example={"hints": [], "consumed": True}, - ), - _json_error( - 400, - ("invalid_request_value",), - "Call identifier was missing.", - ), - _json_error( - 403, - ("pl_revoked",), - "Access gate rejected a revoked paired-link identity.", - ), - ), - ), - OperationSpec( - operation_id="voice.observerActions", - method="GET", - rule="/api/voice/observer-actions", - summary="Drain voice observer actions", - description="Drain queued observer actions for the given voice call.", - parameters=( - ParamSpec( - "call_id", - "query", - required=False, - description="Voice call identifier.", - ), - ), - responses=( - ResponseSpec( - status=200, - description="Observer actions drained.", - named_fields=( - FieldSpec("actions", "array", required=True), - FieldSpec("consumed", "boolean", required=True), - ), - example={"actions": [], "consumed": True}, - ), - _json_error( - 403, - ("pl_revoked",), - "Access gate rejected a revoked paired-link identity.", - ), - ), - ), OperationSpec( operation_id="voice.status", method="GET", diff --git a/solstone/think/voice/nav_queue.py b/solstone/think/voice/nav_queue.py deleted file mode 100644 index fbebae22b..000000000 --- a/solstone/think/voice/nav_queue.py +++ /dev/null @@ -1,93 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""In-memory nav hints for voice turns.""" - -from __future__ import annotations - -import logging -import threading -import time -from collections import defaultdict, deque -from dataclasses import dataclass -from typing import DefaultDict, Deque - -logger = logging.getLogger(__name__) - -NAV_HINT_TTL_SECONDS = 60 -NAV_HINT_CAPACITY = 8 - - -@dataclass(frozen=True) -class QueuedHint: - value: str - created_at: float - - -class NavHintQueue: - """Thread-safe FIFO queue for voice nav hints.""" - - def __init__( - self, - *, - ttl_seconds: int = NAV_HINT_TTL_SECONDS, - capacity: int = NAV_HINT_CAPACITY, - ) -> None: - self.ttl_seconds = ttl_seconds - self.capacity = capacity - self._lock = threading.Lock() - self._queues: DefaultDict[str, Deque[QueuedHint]] = defaultdict(deque) - - def push(self, call_id: str, hint: str, *, now: float | None = None) -> None: - cleaned_call_id = call_id.strip() - cleaned_hint = hint.strip() - if not cleaned_call_id or not cleaned_hint: - return - current = time.time() if now is None else now - with self._lock: - queue = self._queues[cleaned_call_id] - self._drop_expired(queue, current) - queue.append(QueuedHint(cleaned_hint, current)) - while len(queue) > self.capacity: - dropped = queue.popleft() - logger.debug("voice nav hint dropped for capacity: %s", dropped.value) - - def drain(self, call_id: str, *, now: float | None = None) -> list[str]: - cleaned_call_id = call_id.strip() - if not cleaned_call_id: - return [] - current = time.time() if now is None else now - with self._lock: - queue = self._queues.get(cleaned_call_id) - if not queue: - return [] - self._drop_expired(queue, current) - hints = [entry.value for entry in queue] - if cleaned_call_id in self._queues: - del self._queues[cleaned_call_id] - return hints - - def clear(self) -> None: - with self._lock: - self._queues.clear() - - def _drop_expired(self, queue: Deque[QueuedHint], now: float) -> None: - while queue and now - queue[0].created_at > self.ttl_seconds: - dropped = queue.popleft() - logger.debug("voice nav hint expired: %s", dropped.value) - - -_NAV_QUEUE = NavHintQueue() - - -def get_nav_queue() -> NavHintQueue: - return _NAV_QUEUE - - -__all__ = [ - "NAV_HINT_CAPACITY", - "NAV_HINT_TTL_SECONDS", - "NavHintQueue", - "QueuedHint", - "get_nav_queue", -] diff --git a/solstone/think/voice/observer_queue.py b/solstone/think/voice/observer_queue.py deleted file mode 100644 index 326f3a973..000000000 --- a/solstone/think/voice/observer_queue.py +++ /dev/null @@ -1,102 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""In-memory observer actions for voice turns.""" - -from __future__ import annotations - -import copy -import logging -import threading -import time -from collections import defaultdict, deque -from dataclasses import dataclass -from typing import Any, DefaultDict, Deque - -logger = logging.getLogger(__name__) - -OBSERVER_ACTION_TTL_SECONDS = 60 -OBSERVER_ACTION_CAPACITY = 8 - - -@dataclass(frozen=True) -class QueuedAction: - payload: dict[str, Any] - created_at: float - - -class ObserverActionQueue: - """Thread-safe FIFO queue for voice observer actions.""" - - def __init__( - self, - *, - ttl_seconds: int = OBSERVER_ACTION_TTL_SECONDS, - capacity: int = OBSERVER_ACTION_CAPACITY, - ) -> None: - self.ttl_seconds = ttl_seconds - self.capacity = capacity - self._lock = threading.Lock() - self._queues: DefaultDict[str, Deque[QueuedAction]] = defaultdict(deque) - - def push( - self, - call_id: str, - action: dict[str, Any], - *, - now: float | None = None, - ) -> None: - cleaned_call_id = call_id.strip() - if not cleaned_call_id: - logger.warning("voice observer action rejected blank call_id") - return - if not action: - logger.warning("voice observer action rejected empty payload") - return - current = time.time() if now is None else now - with self._lock: - queue = self._queues[cleaned_call_id] - self._drop_expired(queue, current) - queue.append(QueuedAction(copy.deepcopy(action), current)) - while len(queue) > self.capacity: - queue.popleft() - logger.warning("voice observer action dropped for capacity") - - def drain(self, call_id: str, *, now: float | None = None) -> list[dict[str, Any]]: - cleaned_call_id = call_id.strip() - if not cleaned_call_id: - return [] - current = time.time() if now is None else now - with self._lock: - queue = self._queues.get(cleaned_call_id) - if not queue: - return [] - self._drop_expired(queue, current) - actions = [entry.payload for entry in queue] - if cleaned_call_id in self._queues: - del self._queues[cleaned_call_id] - return actions - - def clear(self) -> None: - with self._lock: - self._queues.clear() - - def _drop_expired(self, queue: Deque[QueuedAction], now: float) -> None: - while queue and now - queue[0].created_at > self.ttl_seconds: - queue.popleft() - - -_OBSERVER_QUEUE = ObserverActionQueue() - - -def get_observer_queue() -> ObserverActionQueue: - return _OBSERVER_QUEUE - - -__all__ = [ - "OBSERVER_ACTION_CAPACITY", - "OBSERVER_ACTION_TTL_SECONDS", - "ObserverActionQueue", - "QueuedAction", - "get_observer_queue", -] diff --git a/solstone/think/voice/tools.py b/solstone/think/voice/tools.py index 95f151475..ed74c10ee 100644 --- a/solstone/think/voice/tools.py +++ b/solstone/think/voice/tools.py @@ -12,7 +12,6 @@ from dataclasses import asdict from datetime import date, datetime, timedelta from pathlib import Path from typing import Any, Callable -from urllib.parse import quote_plus from solstone.apps.entities.routes import _build_facet_relationships from solstone.think.activities import load_activity_records @@ -28,8 +27,6 @@ from solstone.think.indexer.journal import search_journal from solstone.think.surfaces import ledger as ledger_surface from solstone.think.surfaces.profile import full as load_profile from solstone.think.utils import day_path -from solstone.think.voice.nav_queue import get_nav_queue -from solstone.think.voice.observer_queue import get_observer_queue logger = logging.getLogger(__name__) @@ -421,7 +418,6 @@ def handle_journal_get_day(payload: dict[str, Any], app: Any) -> dict[str, Any]: "day": external_day, "segments": segments, "summary": _build_day_summary(summaries), - "_nav_target": f"today/journal/{external_day}", } @@ -464,10 +460,7 @@ def handle_journal_search(payload: dict[str, Any], app: Any) -> dict[str, Any]: if entity_slug: result["entity_slug"] = entity_slug results.append(result) - output = {"results": results, "count": count} - if query: - output["_nav_target"] = f"today/search?q={quote_plus(query)}" - return output + return {"results": results, "count": count} def handle_entities_get(payload: dict[str, Any], app: Any) -> dict[str, Any]: @@ -515,7 +508,6 @@ def handle_entities_get(payload: dict[str, Any], app: Any) -> dict[str, Any]: "profile": profile_text, "tags": _tag_values(profile, journal_entity), "recent_context": recent_context, - "_nav_target": f"entity/{slug}", } @@ -644,7 +636,6 @@ def handle_calendar_today(payload: dict[str, Any], app: Any) -> dict[str, Any]: return { "date": _today().isoformat(), "events": events, - "_nav_target": "today", } except Exception: logger.exception("voice calendar lookup failed") @@ -696,7 +687,6 @@ def handle_briefing_get(payload: dict[str, Any], app: Any) -> dict[str, Any]: "facet": "identity", "text": _briefing_text(sections), "highlights": _briefing_highlights(sections, needs_attention_items), - "_nav_target": "today", } @@ -712,7 +702,6 @@ def handle_observer_start_listening( "status": "requested", "mode": mode, "note": "sol will start listening shortly", - "_observer_action": {"type": "start_observer", "mode": mode}, } @@ -751,17 +740,6 @@ async def dispatch_tool_call( result = {"error": "tool failed"} if not isinstance(result, dict): result = {"error": "tool failed"} - nav_target = result.pop("_nav_target", None) - if isinstance(nav_target, str) and nav_target.strip(): - get_nav_queue().push(call_id, nav_target) - observer_action = result.pop("_observer_action", None) - if ( - isinstance(observer_action, dict) - and observer_action - and isinstance(call_id, str) - and call_id.strip() - ): - get_observer_queue().push(call_id, observer_action) return json.dumps(result) diff --git a/tests/test_openapi_contract.py b/tests/test_openapi_contract.py index eb09d4a29..ce4caece0 100644 --- a/tests/test_openapi_contract.py +++ b/tests/test_openapi_contract.py @@ -43,8 +43,6 @@ CONTRACTED_PATHS = { "/api/chat/support/draft/confirm", "/api/push/register", "/api/voice/connect", - "/api/voice/nav-hints", - "/api/voice/observer-actions", "/api/voice/session", "/api/voice/status", "/app/home/api/pulse", @@ -78,8 +76,6 @@ CONTRACTED_INVENTORY_TRIPLES = { ("GET", "/sse/events", "callosum.rootEvents"), ("POST", "/api/voice/session", "voice.session"), ("POST", "/api/voice/connect", "voice.connect"), - ("GET", "/api/voice/nav-hints", "voice.navHints"), - ("GET", "/api/voice/observer-actions", "voice.observerActions"), ("GET", "/api/voice/status", "voice.status"), ("GET", "/app/home/api/pulse", "home.pulse"), ("POST", "/app/import/api/meta", "import.meta"), @@ -376,7 +372,7 @@ def test_no_r0_routes_in_artifact(): assert "/api/config/convey" not in document["paths"] assert "/api/system/status" not in document["paths"] assert set(document["paths"]) == CONTRACTED_PATHS - assert len(document["paths"]) == 31 + assert len(document["paths"]) == 29 def test_home_pulse_named_fields_present(contract_app): diff --git a/tests/test_openapi_schemathesis.py b/tests/test_openapi_schemathesis.py index c8111946c..f9399bbd1 100644 --- a/tests/test_openapi_schemathesis.py +++ b/tests/test_openapi_schemathesis.py @@ -68,8 +68,6 @@ EXCLUDED_GET_OPERATION_REASONS = { # disabled, so injecting the valid observer handle is exactly what hangs it. "observer.callosumStream": "unbounded observer SSE stream with valid handle", "chat.session": "may recover chat, spawn agents/timers, and touch callosum", - "voice.navHints": "GET drains and destroys nav queue state", - "voice.observerActions": "GET drains and destroys observer action queue state", "observer.ingestManifestDay": "requires day fixture state not worth this lane", "observer.ingestSegments": "requires day fixture state not worth this lane", } diff --git a/tests/test_voice_integration.py b/tests/test_voice_integration.py index 69205f863..f33104212 100644 --- a/tests/test_voice_integration.py +++ b/tests/test_voice_integration.py @@ -158,82 +158,6 @@ def test_voice_flow_round_trip(integration_client, monkeypatch): assert state.connect_calls == [{"call_id": "call-1", "model": "gpt-realtime"}] assert state.outputs tool_output = json.loads(state.outputs[0]["output"]) - assert "_nav_target" not in tool_output + nav_target_key = "_nav" + "_target" + assert nav_target_key not in tool_output assert state.response_creates == 1 - - hints_response = client.get("/api/voice/nav-hints?call_id=call-1") - assert hints_response.status_code == 200 - assert hints_response.get_json() == { - "hints": ["today/journal/2026-03-04"], - "consumed": True, - } - - -def test_voice_observer_action_round_trip(integration_client, monkeypatch): - client, _app = integration_client - state = SimpleNamespace( - session_payloads=[], - connect_calls=[], - outputs=[], - response_creates=0, - events=[ - _FakeEvent( - name="observer.start_listening", - arguments=json.dumps({"mode": "meeting"}), - call_id="call-obs-int", - ) - ], - ) - FakeAsyncOpenAI.state = state - futures = [] - - def record_voice_task(app, future): - futures.append(future) - app.voice_tasks.add(future) - future.add_done_callback(app.voice_tasks.discard) - - monkeypatch.setattr("solstone.convey.voice.AsyncOpenAI", FakeAsyncOpenAI) - monkeypatch.setattr("solstone.think.voice.sideband.AsyncOpenAI", FakeAsyncOpenAI) - monkeypatch.setattr("solstone.convey.voice.get_openai_api_key", lambda: "sk-test") - monkeypatch.setattr( - "solstone.think.voice.sideband.get_openai_api_key", lambda: "sk-test" - ) - monkeypatch.setattr( - "solstone.convey.voice.brain.wait_until_ready", lambda app, timeout: True - ) - monkeypatch.setattr("solstone.convey.voice.brain.brain_is_stale", lambda app: False) - monkeypatch.setattr("solstone.convey.voice.register_voice_task", record_voice_task) - - session_response = client.post("/api/voice/session") - assert session_response.status_code == 200 - assert session_response.get_json() == {"ephemeral_key": "ek-test"} - - connect_response = client.post( - "/api/voice/connect", json={"call_id": "call-obs-int"} - ) - assert connect_response.status_code == 200 - assert connect_response.get_json() == {"status": "connected"} - assert len(futures) == 1 - futures[0].result(timeout=1) - - assert state.connect_calls == [{"call_id": "call-obs-int", "model": "gpt-realtime"}] - assert state.outputs - tool_output = json.loads(state.outputs[0]["output"]) - assert tool_output == { - "status": "requested", - "mode": "meeting", - "note": "sol will start listening shortly", - } - assert "_observer_action" not in tool_output - assert state.response_creates == 1 - - actions_response = client.get("/api/voice/observer-actions?call_id=call-obs-int") - assert actions_response.status_code == 200 - assert actions_response.get_json() == { - "actions": [{"type": "start_observer", "mode": "meeting"}], - "consumed": True, - } - - second_actions = client.get("/api/voice/observer-actions?call_id=call-obs-int") - assert second_actions.status_code == 200 - assert second_actions.get_json() == {"actions": [], "consumed": True} diff --git a/tests/test_voice_nav_queue.py b/tests/test_voice_nav_queue.py deleted file mode 100644 index 35f3cca69..000000000 --- a/tests/test_voice_nav_queue.py +++ /dev/null @@ -1,59 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor - -from solstone.think.voice.nav_queue import NavHintQueue - - -def test_nav_queue_returns_empty_for_unknown_call_id(): - queue = NavHintQueue() - assert queue.drain("call-1", now=100.0) == [] - - -def test_nav_queue_drain_clears_queue(): - queue = NavHintQueue() - queue.push("call-1", "today", now=100.0) - - assert queue.drain("call-1", now=100.0) == ["today"] - assert queue.drain("call-1", now=100.0) == [] - - -def test_nav_queue_drops_expired_hints(): - queue = NavHintQueue(ttl_seconds=10) - queue.push("call-1", "today", now=100.0) - queue.push("call-1", "entity/sarah", now=111.0) - - assert queue.drain("call-1", now=111.0) == ["entity/sarah"] - - -def test_nav_queue_enforces_fifo_capacity(): - queue = NavHintQueue(capacity=3) - for idx in range(5): - queue.push("call-1", f"hint-{idx}", now=float(idx)) - - assert queue.drain("call-1", now=10.0) == ["hint-2", "hint-3", "hint-4"] - - -def test_nav_queue_ignores_blank_values(): - queue = NavHintQueue() - queue.push(" ", "today") - queue.push("call-1", " ") - - assert queue.drain("call-1", now=100.0) == [] - - -def test_nav_queue_is_thread_safe_for_push_then_drain(): - queue = NavHintQueue(capacity=16) - - def push_hint(index: int) -> None: - queue.push("call-1", f"hint-{index}", now=float(index)) - - with ThreadPoolExecutor(max_workers=4) as pool: - list(pool.map(push_hint, range(8))) - - drained = queue.drain("call-1", now=8.0) - assert len(drained) == 8 - assert set(drained) == {f"hint-{idx}" for idx in range(8)} diff --git a/tests/test_voice_observer_queue.py b/tests/test_voice_observer_queue.py deleted file mode 100644 index 961f870d2..000000000 --- a/tests/test_voice_observer_queue.py +++ /dev/null @@ -1,85 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor - -from solstone.think.voice.observer_queue import ObserverActionQueue - - -def test_observer_queue_returns_empty_for_unknown_call_id(): - queue = ObserverActionQueue() - assert queue.drain("call-1", now=100.0) == [] - - -def test_observer_queue_drain_clears_queue(): - queue = ObserverActionQueue() - queue.push("call-1", {"type": "start_observer", "mode": "meeting"}, now=100.0) - - assert queue.drain("call-1", now=100.0) == [ - {"type": "start_observer", "mode": "meeting"} - ] - assert queue.drain("call-1", now=100.0) == [] - - -def test_observer_queue_drops_expired_actions(): - queue = ObserverActionQueue(ttl_seconds=10) - queue.push("call-1", {"type": "start_observer", "mode": "meeting"}, now=100.0) - queue.push( - "call-1", - {"type": "start_observer", "mode": "voice_memo"}, - now=111.0, - ) - - assert queue.drain("call-1", now=111.0) == [ - {"type": "start_observer", "mode": "voice_memo"} - ] - - -def test_observer_queue_enforces_fifo_capacity(): - queue = ObserverActionQueue(capacity=3) - for idx in range(5): - queue.push( - "call-1", - {"type": "start_observer", "mode": f"mode-{idx}"}, - now=float(idx), - ) - - assert queue.drain("call-1", now=10.0) == [ - {"type": "start_observer", "mode": "mode-2"}, - {"type": "start_observer", "mode": "mode-3"}, - {"type": "start_observer", "mode": "mode-4"}, - ] - - -def test_observer_queue_rejects_blank_call_id(): - queue = ObserverActionQueue() - queue.push(" ", {"type": "start_observer", "mode": "meeting"}) - - assert queue.drain("call-1", now=100.0) == [] - - -def test_observer_queue_rejects_blank_payload(): - queue = ObserverActionQueue() - queue.push("call-1", {}) - - assert queue.drain("call-1", now=100.0) == [] - - -def test_observer_queue_is_thread_safe_for_push_then_drain(): - queue = ObserverActionQueue(capacity=16) - - def push_action(index: int) -> None: - queue.push( - "call-1", - {"type": "start_observer", "mode": f"mode-{index}"}, - now=float(index), - ) - - with ThreadPoolExecutor(max_workers=4) as pool: - list(pool.map(push_action, range(8))) - - drained = queue.drain("call-1", now=8.0) - assert len(drained) == 8 - assert {item["mode"] for item in drained} == {f"mode-{idx}" for idx in range(8)} diff --git a/tests/test_voice_routes.py b/tests/test_voice_routes.py index 50d074f24..5f6f8cf83 100644 --- a/tests/test_voice_routes.py +++ b/tests/test_voice_routes.py @@ -9,8 +9,6 @@ from concurrent.futures import TimeoutError as FutureTimeoutError import pytest from solstone.convey import create_app -from solstone.think.voice.nav_queue import get_nav_queue -from solstone.think.voice.observer_queue import get_observer_queue @pytest.fixture @@ -67,145 +65,6 @@ def test_connect_requires_call_id(voice_client, monkeypatch): assert data["detail"] == "call_id is required" -def test_nav_hints_unknown_call_id_returns_empty(voice_client): - response = voice_client.get("/api/voice/nav-hints?call_id=missing") - assert response.status_code == 200 - assert response.get_json() == {"hints": [], "consumed": True} - - -def test_post_nav_hints_drains_and_clears(voice_client): - q = get_nav_queue() - q.clear() - q.push("call-nav-post", "first nav hint") - q.push("call-nav-post", "second nav hint") - - response = voice_client.post("/api/voice/nav-hints?call_id=call-nav-post") - assert response.status_code == 200 - assert response.get_json() == { - "hints": ["first nav hint", "second nav hint"], - "consumed": True, - } - - second = voice_client.post("/api/voice/nav-hints?call_id=call-nav-post") - assert second.status_code == 200 - assert second.get_json() == {"hints": [], "consumed": True} - - -def test_get_and_post_nav_hints_are_equivalent(voice_client): - q = get_nav_queue() - q.clear() - q.push("nav-via-get", "shared nav hint") - q.push("nav-via-post", "shared nav hint") - - get_response = voice_client.get("/api/voice/nav-hints?call_id=nav-via-get") - post_response = voice_client.post("/api/voice/nav-hints?call_id=nav-via-post") - - assert get_response.status_code == 200 - assert post_response.status_code == 200 - assert get_response.get_json() == post_response.get_json() - assert post_response.get_json()["consumed"] is True - - -def test_post_nav_hints_no_body_uses_query_string(voice_client): - q = get_nav_queue() - q.clear() - q.push("call-nav-post-no-body", "bodyless nav hint") - - response = voice_client.post("/api/voice/nav-hints?call_id=call-nav-post-no-body") - - assert response.status_code == 200 - assert response.get_json() == { - "hints": ["bodyless nav hint"], - "consumed": True, - } - - -def test_post_nav_hints_missing_call_id_returns_400(voice_client): - response = voice_client.post("/api/voice/nav-hints") - assert response.status_code == 400 - data = response.get_json() - assert data["error"] == "I couldn't use one of those values." - assert data["reason_code"] == "invalid_request_value" - assert data["detail"] == "call_id is required" - - blank = voice_client.post("/api/voice/nav-hints?call_id=%20%20") - assert blank.status_code == 400 - blank_data = blank.get_json() - assert blank_data["error"] == "I couldn't use one of those values." - assert blank_data["reason_code"] == "invalid_request_value" - assert blank_data["detail"] == "call_id is required" - - -def test_observer_actions_missing_call_id_returns_empty(voice_client): - response = voice_client.get("/api/voice/observer-actions") - assert response.status_code == 200 - assert response.get_json() == {"actions": [], "consumed": True} - - -def test_observer_actions_blank_call_id_returns_empty(voice_client): - response = voice_client.get("/api/voice/observer-actions?call_id=%20%20") - assert response.status_code == 200 - assert response.get_json() == {"actions": [], "consumed": True} - - -def test_observer_actions_unknown_call_id_returns_empty(voice_client): - response = voice_client.get("/api/voice/observer-actions?call_id=missing") - assert response.status_code == 200 - assert response.get_json() == {"actions": [], "consumed": True} - - -def test_observer_actions_drain_clears_queue(voice_client): - queue = get_observer_queue() - queue.clear() - queue.push("call-obs-route", {"type": "start_observer", "mode": "meeting"}) - queue.push("call-obs-route", {"type": "start_observer", "mode": "voice_memo"}) - - response = voice_client.get("/api/voice/observer-actions?call_id=call-obs-route") - assert response.status_code == 200 - assert response.get_json() == { - "actions": [ - {"type": "start_observer", "mode": "meeting"}, - {"type": "start_observer", "mode": "voice_memo"}, - ], - "consumed": True, - } - - second = voice_client.get("/api/voice/observer-actions?call_id=call-obs-route") - assert second.status_code == 200 - assert second.get_json() == {"actions": [], "consumed": True} - - -def test_post_observer_actions_drains_and_clears(voice_client): - queue = get_observer_queue() - queue.clear() - queue.push("call-obs-post", {"type": "start_observer", "mode": "meeting"}) - queue.push("call-obs-post", {"type": "start_observer", "mode": "voice_memo"}) - - response = voice_client.post("/api/voice/observer-actions?call_id=call-obs-post") - assert response.status_code == 200 - assert response.get_json() == { - "actions": [ - {"type": "start_observer", "mode": "meeting"}, - {"type": "start_observer", "mode": "voice_memo"}, - ], - "consumed": True, - } - - second = voice_client.post("/api/voice/observer-actions?call_id=call-obs-post") - assert second.status_code == 200 - assert second.get_json() == {"actions": [], "consumed": True} - - -def test_post_observer_actions_missing_call_id_returns_empty(voice_client): - response = voice_client.post("/api/voice/observer-actions") - assert response.status_code == 200 - assert response.get_json() == {"actions": [], "consumed": True} - - blank = voice_client.post("/api/voice/observer-actions?call_id=%20%20") - assert blank.status_code == 200 - assert blank.get_json() == {"actions": [], "consumed": True} - - def test_status_reports_all_fields(voice_client, voice_app, monkeypatch): pending: Future[None] = Future() done: Future[None] = Future() diff --git a/tests/test_voice_tools.py b/tests/test_voice_tools.py index 145afe174..856321a19 100644 --- a/tests/test_voice_tools.py +++ b/tests/test_voice_tools.py @@ -10,7 +10,6 @@ from pathlib import Path from solstone.think.indexer.journal import scan_journal from solstone.think.voice import tools -from solstone.think.voice.observer_queue import get_observer_queue from tests.test_surfaces_ledger import ( _commitment, _minimal_facet_tree, @@ -45,7 +44,6 @@ def test_journal_get_day_happy(monkeypatch, journal_copy): result = tools.handle_journal_get_day({"day": "2026-03-04"}, object()) assert result["day"] == "2026-03-04" - assert result["_nav_target"] == "today/journal/2026-03-04" assert result["segments"] assert any( segment["summary"] == "Morning journal summary" @@ -65,7 +63,6 @@ def test_journal_search_happy(monkeypatch, journal_copy): result = tools.handle_journal_search({"query": "prototype", "limit": 3}, object()) assert result["count"] >= 1 - assert result["_nav_target"] == "today/search?q=prototype" assert result["results"][0]["snippet"] @@ -79,7 +76,6 @@ def test_entities_get_happy(): result = tools.handle_entities_get({"entity_slug": "romeo_montague"}, object()) assert result["slug"] == "romeo_montague" - assert result["_nav_target"] == "entity/romeo_montague" assert result["name"] @@ -219,7 +215,6 @@ def test_calendar_today_happy(monkeypatch, journal_copy): result = tools.handle_calendar_today({}, object()) assert result["date"] == "2026-03-27" - assert result["_nav_target"] == "today" assert result["events"][0]["title"] == "Launch sync" @@ -238,10 +233,9 @@ def test_briefing_get_happy(monkeypatch): result = tools.handle_briefing_get({}, object()) - assert set(result) == {"date", "facet", "text", "highlights", "_nav_target"} + assert set(result) == {"date", "facet", "text", "highlights"} assert result["date"] == "2026-03-27" assert result["facet"] == "identity" - assert result["_nav_target"] == "today" assert result["highlights"] assert len(result["highlights"]) <= 3 assert "Series A term sheet" in result["highlights"][0] @@ -257,7 +251,6 @@ def test_observer_start_listening_happy(): "status": "requested", "mode": "meeting", "note": "sol will start listening shortly", - "_observer_action": {"type": "start_observer", "mode": "meeting"}, } @@ -267,49 +260,30 @@ def test_observer_start_listening_failure(): } -def test_dispatch_tool_call_strips_nav_target(monkeypatch): - queue = tools.get_nav_queue() - queue.clear() - result = asyncio.run( +def test_dispatch_tool_call_has_no_side_channel_keys(): + observer_result = asyncio.run( tools.dispatch_tool_call( "observer.start_listening", - '{"mode":"meeting"}', - "call-123", + json.dumps({"mode": "meeting"}), + "call-1", object(), ) ) - assert json.loads(result)["status"] == "requested" - assert queue.drain("call-123") == [] - - stripped = asyncio.run( + assert json.loads(observer_result) == { + "status": "requested", + "mode": "meeting", + "note": "sol will start listening shortly", + } + day_result = asyncio.run( tools.dispatch_tool_call( "journal.get_day", '{"day":"2026-03-04"}', - "call-123", - object(), - ) - ) - payload = json.loads(stripped) - assert "_nav_target" not in payload - assert queue.drain("call-123") == ["today/journal/2026-03-04"] - - -def test_dispatch_tool_call_strips_observer_action(): - queue = get_observer_queue() - queue.clear() - - result = asyncio.run( - tools.dispatch_tool_call( - "observer.start_listening", - json.dumps({"mode": "meeting"}), - "call-obs-1", + "call-1", object(), ) ) - - assert json.loads(result) == { - "status": "requested", - "mode": "meeting", - "note": "sol will start listening shortly", - } - assert queue.drain("call-obs-1") == [{"type": "start_observer", "mode": "meeting"}] + day_payload = json.loads(day_result) + nav_target_key = "_nav" + "_target" + observer_action_key = "_observer" + "_action" + assert nav_target_key not in day_payload + assert observer_action_key not in day_payload