From f566dc1d011d4eebc9d97498bdbcc3fc2ec9ca44 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 23:16:16 -0400 Subject: [PATCH 1/5] docs(#4812): add session sse contract rfc --- docs/CONTRACTS.md | 7 + docs/rfcs/README.md | 3 + docs/rfcs/session-sse-contract-v1.md | 242 ++++++++++++++++++ ...test_issue4812_session_sse_contract_rfc.py | 189 ++++++++++++++ 4 files changed, 441 insertions(+) create mode 100644 docs/rfcs/session-sse-contract-v1.md create mode 100644 tests/test_issue4812_session_sse_contract_rfc.py diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 39a0ccc3..df22273b 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -60,6 +60,13 @@ contributor guidance; it does not change runtime behavior or CI gates. queued messages, interrupt replacement, steer visibility, or leftover-steer recovery changes. - [`docs/rfcs/README.md`](rfcs/README.md): RFC conventions and current RFC index. +- [`docs/rfcs/session-sse-contract-v1.md`](rfcs/session-sse-contract-v1.md): + proposed contract vocabulary, cursor/resume semantics, replay identity, snapshot + fallback, event taxonomy, and implementation gates for the per-session SSE + stream `GET /api/sessions/{session_id}/events` (#4812). Distinct from the + existing global session-list stream `GET /api/sessions/events`. Start here for + any work that touches per-session SSE, `Last-Event-ID` replay, or session + lifecycle event delivery. Proposed RFCs do not authorize implementation. When a change touches streaming, recovery, replay, compression, context reconstruction, cancellation, approval/clarify, session metadata, or run state, diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 2f4c72df..921bb2d4 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -66,3 +66,6 @@ First-time contributor RFCs should be discussed in an issue before opening a PR. #3058 control-surface companion to #3400 for Queue, Steer, Stop-and-send, Interrupt, and leftover-steer inputs submitted while a long-running agent session is active. +- [`session-sse-contract-v1.md`](session-sse-contract-v1.md) — #4812 Proposed + contract vocabulary, replay identity, event taxonomy, cursor/resume semantics, + and implementation gates for `GET /api/sessions/{session_id}/events`. diff --git a/docs/rfcs/session-sse-contract-v1.md b/docs/rfcs/session-sse-contract-v1.md new file mode 100644 index 00000000..58c33026 --- /dev/null +++ b/docs/rfcs/session-sse-contract-v1.md @@ -0,0 +1,242 @@ +# Session SSE Contract v1 + +- **Status:** Proposed +- **Author:** @rodboev +- **Created:** 2026-07-04 +- **Tracking:** #4812 + +Refs #4812 + +--- + +## Problem + +hermes-webui has no stable, cross-client contract for observing the lifecycle +of an individual session over SSE. Five or more future consumers — WebUI +reconnect/multi-tab, Android wrapper, iOS/PWA wrapper, desktop/TWA wrapper, and +test/CLI observers — each need a resumable, dedupe-safe event stream. Without a +shared contract, every client invents its own cursor, heartbeat, and event-type +semantics, multiplying coordination cost as new producers are added. + +The maintainer asked for a docs-only RFC first, holding implementation until +sequence and replay semantics are settled (comment 2026-06-24T17:14:05Z, +2026-06-25T04:50:06Z on #4812). This document settles the contract vocabulary +against current source before any route is added. + +## Goals + +- Define the SSE envelope and event-type vocabulary for a proposed per-session + stream `GET /api/sessions/{session_id}/events`. +- Specify replay identity using the existing run-journal cursor model. +- Specify the snapshot fallback for stale or evicted cursors. +- Document the distinction from the existing global session-list stream. +- Record open implementation gates that must be resolved before the endpoint + ships. + +## Non-goals + +- This RFC does **not** implement `GET /api/sessions/{session_id}/events`. No + route, handler, or related code is added in this PR. +- This RFC does **not** modify `GET /api/sessions/events` (the existing global + session-list invalidation stream at `api/routes.py:16118`). +- This RFC does **not** replace or modify existing streams: `/api/chat/stream`, + `/api/approval/stream`, or `/api/clarify/stream`. +- This RFC does **not** introduce Android, iOS, or PWA client code. +- This RFC does **not** claim Android/iOS background reconnect behavior or + production proxy delivery; those require owner-reaching proof in a later + implementation PR. +- This RFC does **not** promise a new session-global sequence counter in Phase 1. + +## Current source inventory + +### Existing global session-list stream + +`GET /api/sessions/events` is a **different endpoint** from the one this RFC +proposes. It is routed at `api/routes.py:12296-12297` and implemented by +`_handle_session_events_stream()` at `api/routes.py:16118-16131`. It emits bare +`sessions_changed` events and keepalives for any change to the session list. It +is a global invalidation signal, not a per-session lifecycle stream. The proposed +`GET /api/sessions/{session_id}/events` is per-session and path-distinct. + +### Heartbeat + +`_SSE_HEARTBEAT_INTERVAL_SECONDS = 5` (`api/routes.py:1018-1030`) is the current +heartbeat interval for SSE streams. Phase 1 reuses this constant rather than +adding a separate configurable knob. + +### Run-journal cursor and replay + +Current replay identity is run/stream-scoped: + +- `_parse_run_journal_event_id()` and `_parse_run_journal_after_seq()` at + `api/routes.py:15638-15665` parse the cursor from `Last-Event-ID`. +- `_runner_event_id()` at `api/routes.py:15730-15737` constructs the event `id` + field as `stream_id:seq`. +- Live event `id:` is emitted at `api/routes.py:15869-15885`. +- `_replay_run_journal()` reads events by `(session_id, stream_id)` at + `api/routes.py:15668-15700`. +- `api/streaming.py:6265-6285` writes current live agent streams to + `STREAMS[stream_id]`. +- `api/streaming.py:6620-6634` appends SSE events to the run journal and carries + per-item `event_id` into the live queue. + +The existing run journal represents `session_id`, `stream_id`, `seq`, and +`event_id`, but **not** a session-global monotonic sequence. Phase 1 must not +promise a session-global counter because current source does not provide one. + +## Proposed endpoint + +``` +GET /api/sessions/{session_id}/events +``` + +This endpoint is **path-distinct** from `GET /api/sessions/events`. The +`{session_id}` path segment is required; the global endpoint has no such segment. + +Response: `Content-Type: text/event-stream`. Authentication and session +visibility checks reuse existing mechanisms. + +## Envelope + +Each SSE event carries a JSON payload with this structure: + +```json +{ + "schema_version": 1, + "session_id": "", + "event_type": "", + "event_id": "", + "stream_id": "", + "seq": , + "emitted_at": "", + "payload": { ... }, + "meta": { ... } +} +``` + +- `schema_version`: integer, always `1` for Phase 1 events. +- `session_id`: the session this event belongs to. +- `event_type`: one of the event types listed in the taxonomy below. +- `event_id`: opaque client cursor (see Cursor and resume semantics). +- `stream_id`: the run journal stream this event came from, if applicable. +- `seq`: monotonic within a stream/run (see Cursor and resume semantics). +- `emitted_at`: server-side emission timestamp in ISO-8601 UTC. +- `payload`: event-type-specific data. +- `meta`: optional; reserved for tracing and debug metadata. + +## Event taxonomy (Phase 1 draft) + +| event_type | Source | Description | +|---|---|---| +| `chat_delta` | run journal / live stream | Token or chunk from an assistant reply. | +| `tool_call` | run journal / live stream | Tool invocation record. | +| `tool_result` | run journal / live stream | Tool result record. | +| `approval_request` | run journal | Approval prompt sent to the user. | +| `clarify_request` | run journal | Clarification prompt sent to the user. | +| `run_started` | run journal | Run entered active state. | +| `run_finished` | run journal | Run reached a terminal state (complete, cancelled, error). | +| `session_snapshot` | server fallback | Current session projection; emitted when replay is unavailable. | +| `heartbeat` | server | Keepalive emitted on the `_SSE_HEARTBEAT_INTERVAL_SECONDS` cadence. | + +The event-type table is a draft. The final table must be confirmed during +maintainer review before implementation. + +## Cursor and resume semantics + +`Last-Event-ID` is the standard SSE reconnect header. Clients send the last +`event_id` value seen on reconnect; the server uses it to resume replay from that +position. + +**`event_id` is opaque to clients.** Its current source-compatible form is +`stream_id:seq`, as constructed by `_runner_event_id()` at +`api/routes.py:15730-15737`. Clients must treat it as an opaque string and must +not parse or construct cursor values. + +**`seq` is monotonic within a stream/run.** It is not a session-global counter +and is not promised to increase monotonically across streams or runs. Phase 1 +does not claim a pre-existing session-global sequence because current source does +not provide one. + +**Clients dedupe by `event_id`.** If a reconnect causes overlap with already-seen +events, clients use `event_id` to detect and skip duplicates. + +## Replay source + +Phase 1 uses the **durable run journal** as the replay source for replayable +events. The live `STREAMS[stream_id]` queue (`api/streaming.py:6265-6285`) is +not a reliable replay source because it holds only recent in-memory state. + +A future implementation must replay from the run journal via the existing +`_replay_run_journal()` path (`api/routes.py:15668-15700`) and fall back to the +snapshot mechanism when journal entries are unavailable for a given cursor. + +## Snapshot fallback + +When the `Last-Event-ID` cursor is evicted, expired, unknown, or refers to a +stream that is no longer replayable, the server must: + +1. Emit a `session_snapshot` event containing the current session projection. +2. Continue the live stream from the present without pretending that missed + events were replayed. + +`session_snapshot` is a recovery boundary, not proof of exact missed-event +replay. Clients receiving a snapshot must treat prior cursor state as invalid and +resync from the snapshot payload. + +## Heartbeat + +Phase 1 reuses `_SSE_HEARTBEAT_INTERVAL_SECONDS` (`api/routes.py:1018-1030`) for +heartbeat cadence. A new per-session configurable heartbeat knob is **not** added +in Phase 1. The implementation PR must follow whatever value the constant holds +at implementation time; it must not hard-code a separate interval. + +## Security and privacy + +- Reuse existing auth and session visibility checks. A client must not be able to + subscribe to events for a session it does not own. +- Payloads must not include credentials, raw provider API keys, or unsanitized + internal error details. +- `meta` fields are for tracing and debug metadata and must not carry + security-sensitive values in production. + +## Implementation gates (open questions) + +The following decisions must be resolved before any implementation PR for this +endpoint is accepted: + +1. **Sequence semantics**: Maintainer must confirm that stream/run-scoped `seq` + (not session-global) is acceptable for Phase 1 clients. +2. **Retention policy**: How long are run journal entries retained for replay? + What is the eviction boundary that triggers the snapshot fallback? +3. **Event-type table**: The taxonomy above is a draft. The complete event-type + list must be confirmed during review of this RFC. +4. **Auth behavior on reconnect**: Does `Last-Event-ID` replay require the same + auth token, or can it continue across token refresh? +5. **Client proof**: At least one browser-based client (WebUI) and at least one + non-browser client (Android wrapper or CLI) must provide owner-reaching + reconnect proof before implementation closes #4812. +6. **Proxy and keepalive**: The 5 s heartbeat choice must survive real proxy + deployments. This requires manual-owner-proof or standards-doc evidence in the + implementation PR. + +## Bypass risks + +Future implementation work must not: + +- Introduce an in-memory-only cursor that bypasses the run journal. +- Conflate `GET /api/sessions/events` (global session-list invalidation) with + `GET /api/sessions/{session_id}/events` (per-session lifecycle). +- Promise a session-global monotonic sequence without defining a migration from + the current stream/run-scoped model. + +Tests in `tests/test_issue4812_session_sse_contract_rfc.py` assert these +boundaries so review catches regressions against this contract. + +## Rollout plan + +1. This RFC is accepted by maintainer review on #4812. +2. Retention and event-type decisions are confirmed. +3. Client proof (browser + non-browser) is provided. +4. An implementation PR adds `GET /api/sessions/{session_id}/events` following + this contract vocabulary. +5. Implementation PR closes #4812. diff --git a/tests/test_issue4812_session_sse_contract_rfc.py b/tests/test_issue4812_session_sse_contract_rfc.py new file mode 100644 index 00000000..6d0ab43e --- /dev/null +++ b/tests/test_issue4812_session_sse_contract_rfc.py @@ -0,0 +1,189 @@ +"""Docs/source contract tests for issue #4812 session SSE contract RFC. + +These tests assert the invariants of docs/rfcs/session-sse-contract-v1.md +without requiring a running server or endpoint implementation. +""" + +import pathlib + +REPO = pathlib.Path(__file__).parent.parent +RFC = REPO / "docs" / "rfcs" / "session-sse-contract-v1.md" +RFC_README = REPO / "docs" / "rfcs" / "README.md" +CONTRACTS = REPO / "docs" / "CONTRACTS.md" + + +def _rfc() -> str: + return RFC.read_text(encoding="utf-8") + + +def _readme() -> str: + return RFC_README.read_text(encoding="utf-8") + + +def _contracts() -> str: + return CONTRACTS.read_text(encoding="utf-8") + + +class TestRFCExists: + def test_rfc_file_exists(self): + assert RFC.exists(), f"RFC file not found: {RFC}" + + def test_rfc_has_status_proposed(self): + assert "Status:** Proposed" in _rfc(), "RFC must have 'Status: Proposed' header" + + def test_rfc_has_author(self): + assert "Author:** @" in _rfc(), "RFC must have 'Author: @...' header" + + def test_rfc_has_created_date(self): + assert "Created:** " in _rfc(), "RFC must have 'Created: YYYY-MM-DD' header" + + def test_rfc_has_tracking_issue(self): + assert "Tracking:** #4812" in _rfc(), "RFC must have 'Tracking: #4812' header" + + def test_rfc_refs_not_closes(self): + text = _rfc() + assert "Refs #4812" in text, "RFC must use 'Refs #4812', not 'Closes #4812'" + assert "Closes #4812" not in text, "RFC must not use 'Closes #4812'" + + +class TestRFCIndexed: + def test_readme_indexes_rfc(self): + assert "session-sse-contract-v1.md" in _readme(), ( + "docs/rfcs/README.md must list session-sse-contract-v1.md" + ) + + def test_readme_indexes_rfc_with_issue(self): + assert "#4812" in _readme(), ( + "docs/rfcs/README.md index entry must reference #4812" + ) + + def test_contracts_indexes_rfc(self): + assert "session-sse-contract-v1.md" in _contracts(), ( + "docs/CONTRACTS.md must reference session-sse-contract-v1.md" + ) + + +class TestEndpointDistinction: + def test_rfc_names_proposed_per_session_endpoint(self): + assert "/api/sessions/{session_id}/events" in _rfc(), ( + "RFC must propose GET /api/sessions/{session_id}/events" + ) + + def test_rfc_distinguishes_global_sessions_events(self): + text = _rfc() + assert "/api/sessions/events" in text, ( + "RFC must mention existing GET /api/sessions/events" + ) + assert "/api/sessions/{session_id}/events" in text, ( + "RFC must distinguish per-session endpoint from global endpoint" + ) + + def test_rfc_states_global_endpoint_is_different(self): + text = _rfc() + assert "path-distinct" in text or "different endpoint" in text, ( + "RFC must explicitly state the two endpoints are distinct" + ) + + def test_contracts_distinguishes_both_endpoints(self): + text = _contracts() + assert "/api/sessions/{session_id}/events" in text, ( + "CONTRACTS.md must mention proposed per-session endpoint" + ) + assert "/api/sessions/events" in text, ( + "CONTRACTS.md must mention existing global sessions/events endpoint" + ) + + +class TestSequenceAndReplaySemantics: + def test_rfc_includes_last_event_id(self): + assert "Last-Event-ID" in _rfc(), "RFC must include Last-Event-ID" + + def test_rfc_includes_event_id(self): + assert "event_id" in _rfc(), "RFC must include event_id" + + def test_rfc_includes_stream_id(self): + assert "stream_id" in _rfc(), "RFC must include stream_id" + + def test_rfc_includes_seq(self): + assert '"seq"' in _rfc() or "`seq`" in _rfc(), "RFC must include seq field" + + def test_rfc_includes_session_snapshot(self): + assert "session_snapshot" in _rfc(), "RFC must include session_snapshot event" + + def test_rfc_states_seq_is_stream_scoped(self): + text = _rfc() + assert "monotonic within a stream" in text or "stream/run-scoped" in text, ( + "RFC must state seq is monotonic within a stream/run, not session-global" + ) + + def test_rfc_does_not_promise_session_global_counter(self): + text = _rfc() + assert "does not claim a pre-existing session-global sequence" in text or ( + "not a session-global counter" in text + ), ( + "RFC must explicitly state Phase 1 does not claim a session-global counter" + ) + + def test_rfc_states_event_id_is_opaque(self): + assert "opaque" in _rfc(), "RFC must state event_id is opaque to clients" + + def test_rfc_names_run_journal_as_replay_source(self): + text = _rfc() + assert "run journal" in text.lower(), ( + "RFC must name the run journal as the replay source" + ) + + def test_rfc_defines_session_snapshot_as_fallback(self): + text = _rfc() + assert "session_snapshot" in text + assert "fallback" in text.lower() or "snapshot fallback" in text.lower(), ( + "RFC must define session_snapshot as the stale-cursor fallback" + ) + + def test_rfc_snapshot_is_not_exact_replay(self): + text = _rfc() + assert "not proof of exact missed-event replay" in text or ( + "recovery boundary" in text + ), ( + "RFC must state snapshot is a recovery boundary, not exact missed-event replay" + ) + + +class TestHeartbeat: + def test_rfc_references_heartbeat_constant(self): + assert "_SSE_HEARTBEAT_INTERVAL_SECONDS" in _rfc(), ( + "RFC must reference _SSE_HEARTBEAT_INTERVAL_SECONDS rather than inventing a new constant" + ) + + def test_rfc_does_not_add_new_heartbeat_knob(self): + text = _rfc() + assert "new per-session configurable heartbeat" not in text or ( + "not** added" in text or "not added" in text.lower() + ), ( + "RFC must not add a new per-session heartbeat knob in Phase 1" + ) + + +class TestDocsOnlyScope: + def test_rfc_states_no_endpoint_implementation(self): + text = _rfc() + assert "does **not** implement" in text or "does not implement" in text, ( + "RFC must state it does not implement the endpoint" + ) + + def test_rfc_has_non_goals_section(self): + assert "Non-goals" in _rfc(), "RFC must have a Non-goals section" + + def test_rfc_lists_implementation_gates(self): + text = _rfc() + assert "implementation gate" in text.lower() or "open question" in text.lower(), ( + "RFC must list open implementation gates" + ) + + def test_contracts_preserves_no_implementation_warning(self): + text = _contracts() + assert "not authorize implementation" in text or ( + "Proposed RFCs are review guardrails, not implementation authorization" in text + ), ( + "CONTRACTS.md must preserve warning that proposed RFCs do not authorize implementation" + ) From 55ca782c31e064c7a44346e1b8feb028ac06e7e2 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 4 Jul 2026 01:21:52 -0400 Subject: [PATCH 2/5] docs(#4812): keep session sse rfc citations current --- docs/rfcs/session-sse-contract-v1.md | 11 ++++++++--- tests/test_issue4812_session_sse_contract_rfc.py | 13 ++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/rfcs/session-sse-contract-v1.md b/docs/rfcs/session-sse-contract-v1.md index 58c33026..0673d7e2 100644 --- a/docs/rfcs/session-sse-contract-v1.md +++ b/docs/rfcs/session-sse-contract-v1.md @@ -38,7 +38,8 @@ against current source before any route is added. - This RFC does **not** implement `GET /api/sessions/{session_id}/events`. No route, handler, or related code is added in this PR. - This RFC does **not** modify `GET /api/sessions/events` (the existing global - session-list invalidation stream at `api/routes.py:16118`). + session-list invalidation stream routed at `api/routes.py:12320-12321` and + implemented by `_handle_session_events_stream()` at `api/routes.py:16142`). - This RFC does **not** replace or modify existing streams: `/api/chat/stream`, `/api/approval/stream`, or `/api/clarify/stream`. - This RFC does **not** introduce Android, iOS, or PWA client code. @@ -52,8 +53,8 @@ against current source before any route is added. ### Existing global session-list stream `GET /api/sessions/events` is a **different endpoint** from the one this RFC -proposes. It is routed at `api/routes.py:12296-12297` and implemented by -`_handle_session_events_stream()` at `api/routes.py:16118-16131`. It emits bare +proposes. It is routed at `api/routes.py:12320-12321` and implemented by +`_handle_session_events_stream()` at `api/routes.py:16142`. It emits bare `sessions_changed` events and keepalives for any change to the session list. It is a global invalidation signal, not a per-session lifecycle stream. The proposed `GET /api/sessions/{session_id}/events` is per-session and path-distinct. @@ -68,6 +69,10 @@ adding a separate configurable knob. Current replay identity is run/stream-scoped: +Line ranges in this inventory were verified against WebUI `master` when this +RFC was written. Function, constant, and endpoint names are the stable anchors +if source layout moves later. + - `_parse_run_journal_event_id()` and `_parse_run_journal_after_seq()` at `api/routes.py:15638-15665` parse the cursor from `Last-Event-ID`. - `_runner_event_id()` at `api/routes.py:15730-15737` constructs the event `id` diff --git a/tests/test_issue4812_session_sse_contract_rfc.py b/tests/test_issue4812_session_sse_contract_rfc.py index 6d0ab43e..c4db78d6 100644 --- a/tests/test_issue4812_session_sse_contract_rfc.py +++ b/tests/test_issue4812_session_sse_contract_rfc.py @@ -84,6 +84,17 @@ class TestEndpointDistinction: "RFC must explicitly state the two endpoints are distinct" ) + def test_rfc_cites_current_global_endpoint_source(self): + text = _rfc() + assert "api/routes.py:12320-12321" in text, ( + "RFC must cite the current global sessions/events route match" + ) + assert "api/routes.py:16142" in text, ( + "RFC must cite the current _handle_session_events_stream definition" + ) + assert "api/routes.py:12296-12297" not in text + assert "api/routes.py:16118" not in text + def test_contracts_distinguishes_both_endpoints(self): text = _contracts() assert "/api/sessions/{session_id}/events" in text, ( @@ -158,7 +169,7 @@ class TestHeartbeat: def test_rfc_does_not_add_new_heartbeat_knob(self): text = _rfc() assert "new per-session configurable heartbeat" not in text or ( - "not** added" in text or "not added" in text.lower() + "not added" in text.replace("*", "").lower() ), ( "RFC must not add a new per-session heartbeat knob in Phase 1" ) From dcaf5a69aa847b65287b0d6891314aa98e4f1bde Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 4 Jul 2026 01:38:23 -0400 Subject: [PATCH 3/5] test(docs): harden heartbeat rfc contract guard (#4812) --- docs/rfcs/session-sse-contract-v1.md | 8 +++++ ...test_issue4812_session_sse_contract_rfc.py | 32 ++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/rfcs/session-sse-contract-v1.md b/docs/rfcs/session-sse-contract-v1.md index 0673d7e2..d38da3e8 100644 --- a/docs/rfcs/session-sse-contract-v1.md +++ b/docs/rfcs/session-sse-contract-v1.md @@ -129,6 +129,11 @@ Each SSE event carries a JSON payload with this structure: - `payload`: event-type-specific data. - `meta`: optional; reserved for tracing and debug metadata. +Server-generated events that do not originate in the run journal, currently +`heartbeat` and `session_snapshot`, need an explicit `event_id` / `stream_id` / +`seq` rule before implementation. This RFC records that as an implementation +gate rather than inventing values without source support. + ## Event taxonomy (Phase 1 draft) | event_type | Source | Description | @@ -223,6 +228,9 @@ endpoint is accepted: 6. **Proxy and keepalive**: The 5 s heartbeat choice must survive real proxy deployments. This requires manual-owner-proof or standards-doc evidence in the implementation PR. +7. **Server-generated event identity**: Maintainer must confirm how + `heartbeat` and `session_snapshot` populate `event_id`, `stream_id`, and + `seq`, because those events do not originate in the run journal. ## Bypass risks diff --git a/tests/test_issue4812_session_sse_contract_rfc.py b/tests/test_issue4812_session_sse_contract_rfc.py index c4db78d6..c1996c04 100644 --- a/tests/test_issue4812_session_sse_contract_rfc.py +++ b/tests/test_issue4812_session_sse_contract_rfc.py @@ -24,6 +24,12 @@ def _contracts() -> str: return CONTRACTS.read_text(encoding="utf-8") +def _rfc_section(text: str, heading: str) -> str: + marker = f"\n## {heading}\n" + assert marker in text, f"RFC must include a '## {heading}' section" + return text.split(marker, 1)[1].split("\n## ", 1)[0] + + class TestRFCExists: def test_rfc_file_exists(self): assert RFC.exists(), f"RFC file not found: {RFC}" @@ -92,8 +98,12 @@ class TestEndpointDistinction: assert "api/routes.py:16142" in text, ( "RFC must cite the current _handle_session_events_stream definition" ) - assert "api/routes.py:12296-12297" not in text - assert "api/routes.py:16118" not in text + assert "api/routes.py:12296-12297" not in text, ( + "RFC must not cite the stale global sessions/events route range" + ) + assert "api/routes.py:16118" not in text, ( + "RFC must not cite the stale _handle_session_events_stream line" + ) def test_contracts_distinguishes_both_endpoints(self): text = _contracts() @@ -138,6 +148,18 @@ class TestSequenceAndReplaySemantics: def test_rfc_states_event_id_is_opaque(self): assert "opaque" in _rfc(), "RFC must state event_id is opaque to clients" + def test_rfc_gates_server_generated_event_identity(self): + text = _rfc() + assert "Server-generated event identity" in text, ( + "RFC must gate heartbeat and snapshot event identity before implementation" + ) + assert "heartbeat" in text and "session_snapshot" in text, ( + "RFC must name heartbeat and session_snapshot in the event identity gate" + ) + assert "event_id" in text and "stream_id" in text and "`seq`" in text, ( + "RFC must gate event_id, stream_id, and seq values for server events" + ) + def test_rfc_names_run_journal_as_replay_source(self): text = _rfc() assert "run journal" in text.lower(), ( @@ -168,9 +190,9 @@ class TestHeartbeat: def test_rfc_does_not_add_new_heartbeat_knob(self): text = _rfc() - assert "new per-session configurable heartbeat" not in text or ( - "not added" in text.replace("*", "").lower() - ), ( + heartbeat_section = _rfc_section(text, "Heartbeat") + normalized = heartbeat_section.replace("*", "").lower() + assert "new per-session configurable heartbeat knob is not added" in normalized, ( "RFC must not add a new per-session heartbeat knob in Phase 1" ) From 6182ae044bf12ce4ab7ade1b1d78885d38401793 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 06:37:18 +0000 Subject: [PATCH 4/5] release #5513: correct RFC source anchors + self-validating test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex Phase-1 gate found the RFC cited stale/wrong source anchors (docs-only, but actively misleading to future implementers): /api/sessions/events is routed at routes.py:12345-12346 (RFC said 12320-12321 = /api/chat/cancel); _handle_session_events_stream at 16177 (RFC said 16142 = a gateway-sse comment); replay reads after_event_id/after_seq QUERY PARAMS (RFC said Last-Event-ID header — that's the *proposed* new-endpoint contract, not current). Corrected all six routes.py anchors + the replay-mechanism description, verified each against master. The accompanying test asserted the WRONG anchors and passed (codifying the error). Rewrote test_rfc_cites_current_global_endpoint_source to parse every routes.py anchor from the RFC and verify it lands on the real /api/sessions/events route + _handle_session_events_stream definition — so it now catches drift instead of locking in a stale number. Co-authored-by: rodboev --- docs/rfcs/session-sse-contract-v1.md | 25 ++++++----- ...test_issue4812_session_sse_contract_rfc.py | 41 ++++++++++++++----- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/docs/rfcs/session-sse-contract-v1.md b/docs/rfcs/session-sse-contract-v1.md index d38da3e8..9f36662e 100644 --- a/docs/rfcs/session-sse-contract-v1.md +++ b/docs/rfcs/session-sse-contract-v1.md @@ -38,8 +38,8 @@ against current source before any route is added. - This RFC does **not** implement `GET /api/sessions/{session_id}/events`. No route, handler, or related code is added in this PR. - This RFC does **not** modify `GET /api/sessions/events` (the existing global - session-list invalidation stream routed at `api/routes.py:12320-12321` and - implemented by `_handle_session_events_stream()` at `api/routes.py:16142`). + session-list invalidation stream routed at `api/routes.py:12345-12346` and + implemented by `_handle_session_events_stream()` at `api/routes.py:16177`). - This RFC does **not** replace or modify existing streams: `/api/chat/stream`, `/api/approval/stream`, or `/api/clarify/stream`. - This RFC does **not** introduce Android, iOS, or PWA client code. @@ -53,8 +53,8 @@ against current source before any route is added. ### Existing global session-list stream `GET /api/sessions/events` is a **different endpoint** from the one this RFC -proposes. It is routed at `api/routes.py:12320-12321` and implemented by -`_handle_session_events_stream()` at `api/routes.py:16142`. It emits bare +proposes. It is routed at `api/routes.py:12345-12346` and implemented by +`_handle_session_events_stream()` at `api/routes.py:16177`. It emits bare `sessions_changed` events and keepalives for any change to the session list. It is a global invalidation signal, not a per-session lifecycle stream. The proposed `GET /api/sessions/{session_id}/events` is per-session and path-distinct. @@ -73,13 +73,16 @@ Line ranges in this inventory were verified against WebUI `master` when this RFC was written. Function, constant, and endpoint names are the stable anchors if source layout moves later. -- `_parse_run_journal_event_id()` and `_parse_run_journal_after_seq()` at - `api/routes.py:15638-15665` parse the cursor from `Last-Event-ID`. -- `_runner_event_id()` at `api/routes.py:15730-15737` constructs the event `id` +- `_parse_run_journal_event_id()` (`api/routes.py:15673-15686`) and + `_parse_run_journal_after_seq()` (`api/routes.py:15688-15701`) parse the replay + cursor from the `after_event_id` / `after_seq` **query params** (not the + `Last-Event-ID` header — that header is the *proposed* new-endpoint contract + below, §Reconnect). +- `_runner_event_id()` at `api/routes.py:15765-15772` constructs the event `id` field as `stream_id:seq`. -- Live event `id:` is emitted at `api/routes.py:15869-15885`. +- Live event `id:` is emitted during replay/stream at `api/routes.py:15703-15762`. - `_replay_run_journal()` reads events by `(session_id, stream_id)` at - `api/routes.py:15668-15700`. + `api/routes.py:15703-15735`. - `api/streaming.py:6265-6285` writes current live agent streams to `STREAMS[stream_id]`. - `api/streaming.py:6620-6634` appends SSE events to the run journal and carries @@ -159,7 +162,7 @@ position. **`event_id` is opaque to clients.** Its current source-compatible form is `stream_id:seq`, as constructed by `_runner_event_id()` at -`api/routes.py:15730-15737`. Clients must treat it as an opaque string and must +`api/routes.py:15765-15772`. Clients must treat it as an opaque string and must not parse or construct cursor values. **`seq` is monotonic within a stream/run.** It is not a session-global counter @@ -177,7 +180,7 @@ events. The live `STREAMS[stream_id]` queue (`api/streaming.py:6265-6285`) is not a reliable replay source because it holds only recent in-memory state. A future implementation must replay from the run journal via the existing -`_replay_run_journal()` path (`api/routes.py:15668-15700`) and fall back to the +`_replay_run_journal()` path (`api/routes.py:15703-15735`) and fall back to the snapshot mechanism when journal entries are unavailable for a given cursor. ## Snapshot fallback diff --git a/tests/test_issue4812_session_sse_contract_rfc.py b/tests/test_issue4812_session_sse_contract_rfc.py index c1996c04..e70925ec 100644 --- a/tests/test_issue4812_session_sse_contract_rfc.py +++ b/tests/test_issue4812_session_sse_contract_rfc.py @@ -91,18 +91,39 @@ class TestEndpointDistinction: ) def test_rfc_cites_current_global_endpoint_source(self): + """The RFC's source anchors for the existing global stream must be + ACCURATE against current api/routes.py — verify the cited lines actually + contain what the RFC claims, rather than string-matching a fixed number + (which silently rots and codifies wrong anchors, #5513 gate finding).""" + import re text = _rfc() - assert "api/routes.py:12320-12321" in text, ( - "RFC must cite the current global sessions/events route match" + routes = (REPO / "api" / "routes.py").read_text(encoding="utf-8").splitlines() + + # Pull every `api/routes.py:-` or `api/routes.py:` + # anchor the RFC cites and confirm the referenced span exists. + anchors = re.findall(r"api/routes\.py:(\d+)(?:-(\d+))?", text) + assert anchors, "RFC must cite at least one api/routes.py source anchor" + for start, end in anchors: + lo = int(start) + hi = int(end) if end else lo + assert 1 <= lo <= len(routes), f"RFC cites api/routes.py:{start} beyond EOF ({len(routes)} lines)" + assert 1 <= hi <= len(routes), f"RFC cites api/routes.py:{end} beyond EOF ({len(routes)} lines)" + + # The two load-bearing anchors must land on the real definitions. + def _anchor_line(label): + m = re.search(r"%s.*?api/routes\.py:(\d+)" % re.escape(label), text, re.DOTALL) + assert m, f"RFC must cite a routes.py anchor near {label!r}" + return int(m.group(1)) + + route_line = _anchor_line("routed at") + assert "/api/sessions/events" in routes[route_line - 1], ( + f"RFC's routed-at anchor api/routes.py:{route_line} must be the " + f"/api/sessions/events route; got: {routes[route_line - 1].strip()!r}" ) - assert "api/routes.py:16142" in text, ( - "RFC must cite the current _handle_session_events_stream definition" - ) - assert "api/routes.py:12296-12297" not in text, ( - "RFC must not cite the stale global sessions/events route range" - ) - assert "api/routes.py:16118" not in text, ( - "RFC must not cite the stale _handle_session_events_stream line" + handler_line = _anchor_line("_handle_session_events_stream()") + assert "def _handle_session_events_stream" in routes[handler_line - 1], ( + f"RFC's handler anchor api/routes.py:{handler_line} must be the " + f"_handle_session_events_stream definition; got: {routes[handler_line - 1].strip()!r}" ) def test_contracts_distinguishes_both_endpoints(self): From a94dacb679591d1d351fbb554e1aa657024e13dd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 06:48:35 +0000 Subject: [PATCH 5/5] release #5513: fix live-emission anchor + source-validate all run-journal anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-gate finding: line 83 cited api/routes.py:15703-15762 for live SSE id: emission, but that range is replay/helper code — the actual _sse_with_id() call sites are 15918 (live /api/chat/stream), 15811 (runner-observe), 15721/15734 (journal replay). Corrected the bullet with the real, distinct anchors. Also extended the test (test_rfc_run_journal_anchors_land_on_real_source) to validate each named-symbol anchor (_parse_run_journal_event_id/_after_seq, _runner_event_id, _replay_run_journal) + the live-emission anchor against the actual source token, so no stale line number can silently pass. Co-authored-by: rodboev --- docs/rfcs/session-sse-contract-v1.md | 5 ++- ...test_issue4812_session_sse_contract_rfc.py | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/rfcs/session-sse-contract-v1.md b/docs/rfcs/session-sse-contract-v1.md index 9f36662e..0c4aa5f1 100644 --- a/docs/rfcs/session-sse-contract-v1.md +++ b/docs/rfcs/session-sse-contract-v1.md @@ -80,7 +80,10 @@ if source layout moves later. below, §Reconnect). - `_runner_event_id()` at `api/routes.py:15765-15772` constructs the event `id` field as `stream_id:seq`. -- Live event `id:` is emitted during replay/stream at `api/routes.py:15703-15762`. +- SSE frames carry their `id:` via the `_sse_with_id()` helper, emitted on the + live `/api/chat/stream` path at `api/routes.py:15918`, on the runner-observe + path at `api/routes.py:15811`, and during journal replay at + `api/routes.py:15721` / `15734`. - `_replay_run_journal()` reads events by `(session_id, stream_id)` at `api/routes.py:15703-15735`. - `api/streaming.py:6265-6285` writes current live agent streams to diff --git a/tests/test_issue4812_session_sse_contract_rfc.py b/tests/test_issue4812_session_sse_contract_rfc.py index e70925ec..7e3e33f2 100644 --- a/tests/test_issue4812_session_sse_contract_rfc.py +++ b/tests/test_issue4812_session_sse_contract_rfc.py @@ -126,6 +126,41 @@ class TestEndpointDistinction: f"_handle_session_events_stream definition; got: {routes[handler_line - 1].strip()!r}" ) + def test_rfc_run_journal_anchors_land_on_real_source(self): + """Every named-symbol / emission anchor the RFC cites in the run-journal + inventory must land on the actual source token (not just be in-bounds), + so a stale line number can't silently pass (#5513 gate finding 2).""" + import re + text = _rfc() + routes = (REPO / "api" / "routes.py").read_text(encoding="utf-8").splitlines() + + def _first_anchor_after(label): + m = re.search(r"%s.*?api/routes\.py:(\d+)" % re.escape(label), text, re.DOTALL) + assert m, f"RFC must cite a routes.py anchor near {label!r}" + return int(m.group(1)) + + # (label in RFC prose, token that must appear on the cited line) + checks = [ + ("_parse_run_journal_event_id()", "def _parse_run_journal_event_id"), + ("_parse_run_journal_after_seq()", "def _parse_run_journal_after_seq"), + ("_runner_event_id()", "def _runner_event_id"), + ("_replay_run_journal()", "def _replay_run_journal"), + ] + for label, token in checks: + line = _first_anchor_after(label) + assert 1 <= line <= len(routes), f"{label} anchor api/routes.py:{line} beyond EOF" + assert token in routes[line - 1], ( + f"RFC's {label} anchor api/routes.py:{line} must contain {token!r}; " + f"got: {routes[line - 1].strip()!r}" + ) + + # The live-emission bullet must cite the real _sse_with_id call site. + emit_line = _first_anchor_after("live `/api/chat/stream` path at") + assert "_sse_with_id" in routes[emit_line - 1], ( + f"RFC's live-emission anchor api/routes.py:{emit_line} must be an " + f"_sse_with_id() call; got: {routes[emit_line - 1].strip()!r}" + ) + def test_contracts_distinguishes_both_endpoints(self): text = _contracts() assert "/api/sessions/{session_id}/events" in text, (