contract(issue#1): amend per Volva paraphrase — body, id range, INV-003
Volva's contract paraphrase round (thread 01KS4B3B0Y62) surfaced three
real contract-time ambiguities — addressing each here before applying
ready-for-agent.
1) reconnect_turn body was a punt. STEP 2 literally said "json={'content':
''} OR with no body (TBD per spec — confirm during implementation)".
The spec §Reconnect flow example shows POST with Content-Type:
application/json and a body shaped {"content": "..."} — the wire schema
requires content; the server identifies the resume target via the
Last-Event-ID header and does NOT re-process content (spec line 732:
"agent's tools and LLM call run exactly once regardless of disconnects/
reconnects"). reconnect_turn now takes content: str explicitly; STEP 2
sends json={"content": content}. Caller convention: pass the original
content sent to stream_turn. POST-002 added to assert byte-for-byte
body shape; new test body_threads_content covers it.
2) _parse_sse_id allowed turn_id and seq ≥ 0 — too loose. Spec §SSE id
format line 705 says seq starts at 1 (resets per turn); turn_id is
from SQLite turns.id (autoincrement, ≥1). Tightened POST-001 to require
both ≥1; STEP 5 raises ValueError on either < 1. Test happy_zero_seq
flipped to adversarial zero_seq; added zero_turn_id and negative_seq.
INV-002 tightened to reflect the same range.
3) INV-003 (TurnIdFlip) had a subtle wording gap between stream_turn
(first event ESTABLISHES turn_id; cannot be a flip) and reconnect_turn
(expected turn_id parsed FROM last_event_id BEFORE connect; first event
is already a flip-candidate). Volva noticed the reconnect test said
"first event was not yielded" while stream_turn semantics depend on
the first event being yielded unconditionally. Spelled out both entry
points in INV-003 as a numbered sub-list. reconnect_turn STEP 4 and
test turn_id_flip_on_first_event reworded to match.
Volva flags #3 (MalformedSseId-vs-ValueError split) and #5 (exactly-
one-terminal as server-assumed, not client-verified) reviewed and kept
as-is — both intentional. Drift check unchanged: amending the contract
does not touch the pinned issue body, so prd: hashes remain valid.
This commit is contained in:
@@ -24,7 +24,7 @@ assumptions:
|
||||
- "Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) is the wire contract. Event names, JSON shapes, status codes, and `id:` format are read FROM the spec, not from any Worldtree source import."
|
||||
- "`httpx-sse` correctly parses the full SSE wire format including multi-line `data:`, `id:`, `event:`, `retry:`, and comment lines. Hand-rolled `data:`-only parsing is rejected (would silently drop the load-bearing composite `id:`)."
|
||||
- "Server emits exactly one terminal event (`done` | `error` | `cancelled`) per turn (spec INV at §SSE Event Types: 'Every stream terminates with exactly one done, error, or cancelled event')."
|
||||
- "Composite SSE wire `id:` format is `{turn_id}:{seq}` where both parts are decimal integers (spec INV-014). No other separators, no quoting."
|
||||
- "Composite SSE wire `id:` format is `{turn_id}:{seq}` where both parts are decimal integers ≥ 1 (spec §SSE id format: `turn_id` is SQLite `turns.id` (autoincrement, ≥ 1); `seq` is a per-turn monotonic integer starting at 1, resetting per turn). No other separators, no quoting."
|
||||
- "Cancel takes effect at the next agent_turn event boundary; in-flight tool calls run to completion. The cancel HTTP call is fast (200 OK with envelope) and decoupled from the SSE stream's `cancelled` terminal event."
|
||||
open_questions:
|
||||
- "Reconnect-by-replay semantics: should `reconnect()` yield ONLY the post-`Last-Event-ID` events (server-replayed buffered + live), or also re-yield the historical events the caller already saw? Current draft: only replayed+live (the server already only sends those). Caller is responsible for de-dup if it re-displays."
|
||||
@@ -62,8 +62,10 @@ Why this module is the natural smallest unit to TDD against: it's the integratio
|
||||
## Invariants
|
||||
|
||||
- **INV-001 [hard]**: Every successful `stream_turn` / `reconnect_turn` iteration terminates with exactly one terminal event of type `done`, `error`, or `cancelled`. The iterator MUST NOT raise StopAsyncIteration before a terminal event arrives unless the underlying HTTP connection drops mid-stream — in which case it raises `SseConnectionDropped` (caller may invoke `reconnect_turn` if it has the last seen `sse_id`).
|
||||
- **INV-002 [hard]**: Every yielded `Event` carries its parsed `sse_id: SseId = (turn_id: int, seq: int)`. Events whose SSE `id:` wire field is absent, malformed, or non-composite (`123` instead of `123:4`) MUST raise `MalformedSseId` before yielding. This is the foot-gun that hand-rolled `data:`-only parsing silently drops; the module's value is making it impossible to silently drop. (See `docs/design-brief.md` §3.)
|
||||
- **INV-003 [hard]**: The composite `sse_id`'s `turn_id` component is stable across all events of a single turn (spec INV-014). The module MUST detect a turn_id flip mid-stream and raise `TurnIdFlip` rather than yield events from a different turn.
|
||||
- **INV-002 [hard]**: Every yielded `Event` carries its parsed `sse_id: SseId = (turn_id: int, seq: int)` with `turn_id ≥ 1` and `seq ≥ 1` (server-side spec invariants). Events whose SSE `id:` wire field is absent, malformed, non-composite (`123` instead of `123:4`), or carries an out-of-range integer MUST raise `MalformedSseId` before yielding. This is the foot-gun that hand-rolled `data:`-only parsing silently drops; the module's value is making it impossible to silently drop. (See `docs/design-brief.md` §3.)
|
||||
- **INV-003 [hard]**: The composite `sse_id`'s `turn_id` component is stable across all events of a single turn (spec INV-014). The module MUST detect a turn_id flip and raise `TurnIdFlip` rather than yield an event from the wrong turn. The check has **two distinct entry points** depending on origin:
|
||||
- In `stream_turn`, the caller has no prior expectation of `turn_id`. The **first** event yielded establishes `established_turn_id`; subsequent events must match it. The first event is always yielded (it cannot be a flip).
|
||||
- In `reconnect_turn`, `last_event_id` carries the caller's prior expectation. `established_turn_id` is parsed FROM `last_event_id` BEFORE the connection opens. The **first** event from the server is therefore already a flip-candidate — if its `turn_id` differs from the expected one, the module raises `TurnIdFlip` BEFORE yielding it.
|
||||
- **INV-004 [hard]**: `reconnect_turn` sets the `Last-Event-ID` HTTP header with the verbatim composite string `{turn_id}:{seq}` (not the integer parts separately). The caller passes the same string format the server emitted.
|
||||
- **INV-005 [hard]**: No `core.*` or `worldtree.*` imports. Boundary verified by the existing `tests/test_no_worldtree_imports.py`.
|
||||
- **INV-006 [soft, recovery_window=1]**: The `Event` yielded immediately AFTER a `worker_phase` event MAY belong to the next phase (e.g., a `text` event after `worker_phase Streaming`). Phase ordering invariant is server-side, not client-side; the consumer just forwards what arrives.
|
||||
@@ -75,8 +77,8 @@ Per design-brief §8d: in-process reconnect only. `reconnect_turn` is the resume
|
||||
|
||||
The module's resume contract:
|
||||
|
||||
1. The caller holds the most recently yielded event's `sse_id`. (Easy to thread: `last_seen = event.sse_id` on every iteration.)
|
||||
2. On any `SseConnectionDropped`, the caller MAY invoke `reconnect_turn(client, session_id, last_event_id=f"{last_seen.turn_id}:{last_seen.seq}")`.
|
||||
1. The caller holds the most recently yielded event's `sse_id` AND the original `content` it sent to `stream_turn`. (Easy to thread: `last_seen = event.sse_id` on every iteration; `content` stays in the caller's frame.)
|
||||
2. On any `SseConnectionDropped`, the caller MAY invoke `reconnect_turn(client, session_id, content, last_event_id=f"{last_seen.turn_id}:{last_seen.seq}")`. The `content` argument is sent verbatim in the JSON body (spec wire requirement) but the server identifies the resume target by `Last-Event-ID` and does NOT re-process `content` (spec §Reconnect flow).
|
||||
3. The server replays buffered events with `seq > last_seen.seq` for that `turn_id`, then resumes live streaming. The module yields them as it received them.
|
||||
4. If the server returns 412 `buffer_expired`, the module raises `ResumeBufferExpired(buffered_from_seq=X)`. Caller's policy.
|
||||
5. If the server returns 410 `turn_finished`, the module raises `ResumeTurnFinished(turn_id=N)`. The turn completed while the connection was dropped; caller can `GET /sessions/{id}/messages` to fetch the final assistant message.
|
||||
@@ -100,7 +102,7 @@ PRE: [PRE-002 hard] session_id is a non-empty string -- assert session_id and is
|
||||
PRE: [PRE-003 hard] content is a non-empty string -- assert content and isinstance(content, str)
|
||||
POST: [POST-001 side_effect] exactly one POST to /sessions/{session_id}/messages was issued -- assert mock_router.calls.call_count == 1
|
||||
POST: [POST-002 return_value] AsyncIterator yields ≥1 event ending in exactly one of (Done, Error, Cancelled) -- collect events; assert isinstance(events[-1], (Done, Error, Cancelled))
|
||||
POST: [POST-003 state_change] every yielded Event has a populated sse_id with int turn_id and int seq -- assert all(isinstance(e.sse_id.turn_id, int) and isinstance(e.sse_id.seq, int) for e in events)
|
||||
POST: [POST-003 state_change] every yielded Event has a populated sse_id with both fields >= 1 -- assert all(e.sse_id.turn_id >= 1 and e.sse_id.seq >= 1 for e in events)
|
||||
ERROR_ROUTING:
|
||||
httpx.HTTPStatusError:
|
||||
local_handling: re-raise as SseConnectFailed(status=resp.status_code, body=resp.read()[:1024]) — server returned non-2xx before stream started (e.g., 404 session_not_found)
|
||||
@@ -151,14 +153,16 @@ TESTS:
|
||||
```
|
||||
|
||||
```contract
|
||||
FN reconnect_turn(client: httpx.AsyncClient, session_id: str, last_event_id: str) -> AsyncIterator[Event]
|
||||
BRIEF: Re-POST /sessions/{session_id}/messages with `Last-Event-ID` header to resume an in-flight turn; server replays buffered events with seq > last_event_id.seq then streams live until terminal event.
|
||||
FN reconnect_turn(client: httpx.AsyncClient, session_id: str, content: str, last_event_id: str) -> AsyncIterator[Event]
|
||||
BRIEF: Re-POST /sessions/{session_id}/messages with `Last-Event-ID` header to resume an in-flight turn; server replays buffered events with seq > last_event_id.seq then streams live until terminal event. `content` is sent verbatim in the JSON body to satisfy the endpoint's request schema; the server resolves the resume target by `Last-Event-ID` and does NOT re-process `content` or re-invoke the agent (spec §Reconnect flow: "the agent's tools and LLM call run exactly once regardless of how many disconnects/reconnects occur"). Caller convention: pass the same content originally sent to `stream_turn`.
|
||||
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||
PRE: [PRE-002 hard] session_id is non-empty -- assert session_id
|
||||
PRE: [PRE-003 hard] last_event_id matches `{int}:{int}` format -- _parse_sse_id(last_event_id) succeeds; raise ValueError otherwise (the caller is misusing the API; do not RAISE on the SSE stream's behalf)
|
||||
PRE: [PRE-003 hard] content is a string -- assert isinstance(content, str) (non-empty NOT required for resume — server ignores it; caller convention is to pass the original content for clarity, but the wire-level requirement is only "valid JSON satisfying the request schema")
|
||||
PRE: [PRE-004 hard] last_event_id matches `{int}:{int}` format with both ints ≥ 1 -- _parse_sse_id(last_event_id) succeeds; raise ValueError otherwise (the caller is misusing the API; do not RAISE on the SSE stream's behalf)
|
||||
POST: [POST-001 side_effect] exactly one POST issued with `Last-Event-ID: <last_event_id>` header verbatim -- assert mock_router.calls[0].request.headers["Last-Event-ID"] == last_event_id
|
||||
POST: [POST-002 return_value] iterator yields the (server-replayed buffered events with seq > last_event_id.seq) followed by live events, terminating in Done/Error/Cancelled
|
||||
POST: [POST-003 state_change] all yielded events share `turn_id == _parse_sse_id(last_event_id).turn_id` -- assert all(e.sse_id.turn_id == resume_turn_id for e in events)
|
||||
POST: [POST-002 side_effect] outbound JSON body is `{"content": <content arg verbatim>}` -- assert json.loads(mock_router.calls[0].request.content) == {"content": content}
|
||||
POST: [POST-003 return_value] iterator yields the (server-replayed buffered events with seq > last_event_id.seq) followed by live events, terminating in Done/Error/Cancelled
|
||||
POST: [POST-004 state_change] all yielded events share `turn_id == _parse_sse_id(last_event_id).turn_id` -- assert all(e.sse_id.turn_id == resume_turn_id for e in events)
|
||||
ERROR_ROUTING:
|
||||
HTTP 400 invalid_last_event_id:
|
||||
local_handling: parse body for error_code; raise InvalidLastEventId(raw=last_event_id)
|
||||
@@ -177,22 +181,23 @@ ERROR_ROUTING:
|
||||
flow_control: abort
|
||||
state_recovery: none
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate `_parse_sse_id(last_event_id)` succeeds (raise ValueError on malformed input — this is caller misuse, not stream corruption)
|
||||
2. [sequential, flexibility=prescriptive] Open SSE connection via httpx_sse.aconnect_sse with method="POST", url=f"/sessions/{session_id}/messages", json={"content": ""} OR with no body (TBD per spec §Reconnect & Resume — confirm during implementation), headers={"Last-Event-ID": last_event_id}
|
||||
1. [setup, flexibility=prescriptive] Validate `_parse_sse_id(last_event_id)` succeeds (raise ValueError on malformed input — this is caller misuse, not stream corruption); SET expected_turn_id = parsed.turn_id
|
||||
2. [sequential, flexibility=prescriptive] Open SSE connection via httpx_sse.aconnect_sse with method="POST", url=f"/sessions/{session_id}/messages", json={"content": content}, headers={"Last-Event-ID": last_event_id}. The body shape matches the spec §Reconnect flow example verbatim; the server resolves the resume target from the header, not the body.
|
||||
3. [branch, flexibility=prescriptive] IF response status == 400: parse body and RAISE InvalidLastEventId
|
||||
ELIF response status == 410: parse body and RAISE ResumeTurnFinished
|
||||
ELIF response status == 412: parse body and RAISE ResumeBufferExpired
|
||||
ELIF response status != 200: RAISE SseConnectFailed
|
||||
4. [loop, flexibility=prescriptive] Iterate event_source as in stream_turn STEP 3 (INV-002 INV-003 apply equally; TurnIdFlip means caller passed a last_event_id whose turn_id no longer matches what the server thinks is the current turn)
|
||||
4. [loop, flexibility=prescriptive] Iterate event_source. For EACH event: parse sse_id; if turn_id != expected_turn_id, RAISE TurnIdFlip BEFORE yielding (per INV-003: in reconnect_turn the first event is already a flip-candidate — there is no first-event-establishes allowance). Otherwise yield. INV-002 applies equally; malformed ids RAISE MalformedSseId.
|
||||
5. [cleanup] Close event_source context
|
||||
TESTS:
|
||||
happy_resume_from_seq_3 [scenario,tracer]: mock previously streamed `42:1, 42:2, 42:3`; caller invokes reconnect_turn("...", "42:3"); mock replays `42:4, 42:5` then live `42:6 done` → consumer yields events 4-6; all turn_id==42
|
||||
happy_resume_from_seq_3 [scenario,tracer]: mock previously streamed `42:1, 42:2, 42:3`; caller invokes reconnect_turn("...", "original content", "42:3"); mock replays `42:4, 42:5` then live `42:6 done` → consumer yields events 4-6; all turn_id==42
|
||||
header_verbatim [trace]: assert outbound request carries `Last-Event-ID: 42:3` header exactly (not `42` only, not `seq=3`)
|
||||
body_threads_content [trace]: reconnect_turn(..., content="hello mimir", last_event_id="42:3") → outbound JSON body is `{"content": "hello mimir"}` byte-for-byte
|
||||
buffer_expired [error]: mock returns 412 with `{"error":"buffer_expired","buffered_from_seq":10,"turn_id":42}` → consumer raises ResumeBufferExpired(turn_id=42, buffered_from_seq=10)
|
||||
turn_finished [error]: mock returns 410 with `{"error":"turn_finished","turn_id":42}` → consumer raises ResumeTurnFinished(turn_id=42)
|
||||
invalid_last_event_id_server [error]: mock returns 400 with `{"error":"invalid_last_event_id"}` → consumer raises InvalidLastEventId
|
||||
malformed_input [adversarial]: caller passes last_event_id="42" (no seq) → consumer raises ValueError before opening any connection (no HTTP issued)
|
||||
turn_id_flip_on_resume [adversarial]: caller passes last_event_id="42:3" but server streams `99:4` → consumer raises TurnIdFlip(established=42, got=99); first event was not yielded
|
||||
turn_id_flip_on_first_event [adversarial]: caller passes last_event_id="42:3" but server's first replayed/live event is `99:4` → consumer raises TurnIdFlip(expected=42, got=99) BEFORE yielding (the first event in reconnect_turn IS a flip-candidate — unlike stream_turn which establishes turn_id from the first event)
|
||||
```
|
||||
|
||||
```contract
|
||||
@@ -238,9 +243,9 @@ TESTS:
|
||||
|
||||
```contract
|
||||
FN _parse_sse_id(raw: str) -> SseId
|
||||
BRIEF: Parse the SSE wire `id:` field as the composite `{turn_id}:{seq}` per spec INV-014. Returns SseId NamedTuple(turn_id: int, seq: int). Raises ValueError on any deviation (missing colon, non-integer parts, extra colons, empty parts).
|
||||
BRIEF: Parse the SSE wire `id:` field as the composite `{turn_id}:{seq}` per spec §SSE id format. Returns SseId NamedTuple(turn_id: int, seq: int). Strict: both parts MUST be decimal integers `≥ 1` (server invariant — `turn_id` from SQLite autoincrement, `seq` starts at 1 per spec line 705). Raises ValueError on any deviation (missing colon, non-integer parts, extra colons, empty parts, out-of-range ints).
|
||||
PRE: [PRE-001 hard] raw is a string -- isinstance(raw, str)
|
||||
POST: [POST-001 return_value] returns SseId with turn_id and seq both non-negative ints -- assert result.turn_id >= 0 and result.seq >= 0
|
||||
POST: [POST-001 return_value] returns SseId with both fields >= 1 -- assert result.turn_id >= 1 and result.seq >= 1
|
||||
POST: [POST-002 exception] on any malformed input, raises ValueError with `raw[:64]` in the message -- pytest.raises(ValueError, match=re.escape(raw[:64]))
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate `raw` is non-empty; raise ValueError on empty
|
||||
@@ -249,17 +254,20 @@ STEPS:
|
||||
4. [sequential, flexibility=prescriptive] CALL int() on parts[0] and parts[1]
|
||||
ON ValueError:
|
||||
RE-RAISE with truncated raw in the message
|
||||
5. [branch, flexibility=prescriptive] IF either int is negative: RAISE ValueError(f"expected non-negative ints, got: {raw[:64]!r}")
|
||||
5. [branch, flexibility=prescriptive] IF turn_id < 1 OR seq < 1: RAISE ValueError(f"expected both ints >= 1 per spec, got: {raw[:64]!r}")
|
||||
6. [cleanup] RETURN SseId(turn_id=parts[0], seq=parts[1])
|
||||
TESTS:
|
||||
happy_simple [happy,tracer]: "42:3" → SseId(turn_id=42, seq=3)
|
||||
happy_zero_seq [happy]: "1:0" → SseId(turn_id=1, seq=0)
|
||||
happy_seq_one [happy]: "1:1" → SseId(turn_id=1, seq=1) (the smallest valid id per spec — first event of the first turn)
|
||||
empty [adversarial]: "" → ValueError
|
||||
no_colon [adversarial]: "42" → ValueError with "42" in the message
|
||||
too_many_colons [adversarial]: "42:3:7" → ValueError
|
||||
alpha_turn_id [adversarial]: "foo:3" → ValueError
|
||||
alpha_seq [adversarial]: "42:bar" → ValueError
|
||||
zero_turn_id [adversarial]: "0:3" → ValueError (server invariant: turn_id from SQLite autoincrement starts at 1)
|
||||
zero_seq [adversarial]: "1:0" → ValueError (server invariant: seq starts at 1, per spec §SSE id format line 705)
|
||||
negative_turn_id [adversarial]: "-1:3" → ValueError
|
||||
negative_seq [adversarial]: "42:-1" → ValueError
|
||||
trailing_whitespace [adversarial]: "42:3 " → ValueError (strict; do not strip; the server emits clean ids)
|
||||
truncation [security]: input is 5000-char string with no colon → ValueError message includes only `raw[:64]` (not the full 5000)
|
||||
```
|
||||
|
||||
@@ -77,6 +77,7 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[2026-05-20]` **First contract: `ratatoskr.sse_client`.** Bundles `stream_turn` + `reconnect_turn` + `cancel_turn` + private `_parse_sse_id` into one module — the SSE-resume flow is coupled (cancel needs `turn_id` from the SSE wire `id:`, reconnect re-uses the same parsed `SseId`), so they share a contract. Hard invariant INV-002 makes the composite `{turn_id}:{seq}` `id:` parsing load-bearing — closes the foot-gun the design-brief §3 names (hand-rolled `data:`-only parsing silently drops the `id:`). v2.1 test categories `adversarial`/`scenario`/`trace` used freely; parser warns but format spec §2.1.E permits them.
|
||||
- `[2026-05-21]` **Contract converted to issue-scoped (issue #1).** Moved `docs/contracts/sse_client.contract.md` → `docs/contracts/issues/1.contract.md`. Frontmatter shape switched from module-scoped (`module:`/`purpose:`) to issue-scoped (`target_module:`/`scope:`/`prd:`) per CONTRACT-FORMAT §2.1.I. `prd:` block pins to issue #1's body hash (`abcbc49467e86f1d`). `scripts/contract_drift_check.py` returns clean. **Known parser stale-ness**: `contract_parser.py --validate` ERRORs on issue-scoped frontmatter (missing `module:`/`purpose:`) — this is CONTRACT-FORMAT §2.1.L H10, a documented Brokkr-side follow-up. Parser is a canonical sync, so we do NOT patch it locally (would drift from canonical). Treat parser ERROR-on-issue-scoped as expected until the canonical bumps.
|
||||
- `[2026-05-21]` **Default issue-tracker labels seeded** (17 total). Sleipnir gating (`ready-for-agent`, `blocked-needs-contract`, `blocked-needs-dependency`), triage (`needs-triage`, `needs-architect-decision`, `needs-info`), type (`bug`, `enhancement`, `task`, `documentation`), resolution (`duplicate`, `wontfix`, `invalid`), Ratatoskr-specific area (`sse-client`, `tui`, `cli`, `observability`).
|
||||
- `[2026-05-21]` **Volva paraphrase round on `docs/contracts/issues/1.contract.md`.** Volva flagged 5 ambiguities; operator approved amendments to 3 of them. (1) `reconnect_turn` STEP 2 punt resolved: signature now carries `content: str`; STEP 2 body is `json={"content": content}` matching spec §Reconnect flow example verbatim. Spec line 732 makes the agent's tools+LLM run "exactly once regardless of disconnects/reconnects" — the `content` is a wire-schema requirement, not re-processed server-side. (2) `_parse_sse_id` tightened: `turn_id ≥ 1` AND `seq ≥ 1` (was `≥ 0`); spec §SSE id format line 705 explicitly states `seq` starts at 1, and `turn_id` is SQLite autoincrement (≥1). Test `happy_zero_seq` flipped to `zero_seq [adversarial]`; new `zero_turn_id` + `negative_seq` adversarial tests added. (3) INV-003 clarified to spell out the two-entry-point semantics: `stream_turn` establishes `turn_id` from the first event (first event always yields); `reconnect_turn` parses the expected `turn_id` FROM `last_event_id` BEFORE the connection opens, so the first server event is already a flip-candidate and is NOT yielded on mismatch. Volva flags #3 (MalformedSseId-vs-ValueError split) and #5 (exactly-one-terminal as server-assumed) noted but kept as-is — deliberate distinctions. Drift check still clean against issue #1 (amending the contract doesn't touch the pinned issue body).
|
||||
|
||||
## Tried and abandoned
|
||||
|
||||
|
||||
Reference in New Issue
Block a user