diff --git a/docs/api-reference.md b/docs/api-reference.md index 0272905e..d1d7efa2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -325,6 +325,44 @@ finalize any in-progress assistant message. {"type": "stream_end"} ``` +**`state_change`** -- the worker thread transitioned to a new state. Drives +the client's busy-mode (composer in send vs. stop, spinner indicators, +auto-focus on idle). Sent live during normal operation AND on every fresh +SSE subscribe (so a mid-stream page refresh restores the correct composer +state without waiting for the next live transition). + +```json +{"type": "state_change", "state": "running"} +``` + +| Field | Type | Description | +|----------|--------|----------------------------------------------------------------------| +| `state` | string | One of `"running"`, `"thinking"`, `"attention"`, `"idle"`, `"error"` | + +**`in_progress_snapshot`** -- one-shot replay of the in-progress turn's +content + reasoning text-so-far when this client connects mid-stream. +Lets a refreshing browser tab restore partial assistant text immediately +instead of waiting for the response to complete. Yielded once after the +kind-specific replay phase (history + pending), only when at least one +of `content` / `reasoning` is non-empty. Both halves render into the same +assistant bubble the live `content` / `reasoning` events would target; +clients should treat the snapshot as idempotent (skip overwrite if the +current local buffer is already a superset prefix — covers EventSource +auto-reconnect re-replays). + +```json +{ + "type": "in_progress_snapshot", + "content": "Here is the answer so far: it depends on ", + "reasoning": "The user is asking about a comparison; let me think about..." +} +``` + +| Field | Type | Description | +|--------------|--------|------------------------------------------------------------| +| `content` | string | Joined assistant content text accumulated this turn | +| `reasoning` | string | Joined reasoning / chain-of-thought text accumulated | + **`tool_info`** -- one or more tool calls that were auto-approved (no user action required). @@ -522,7 +560,13 @@ Each SSE connection to a workstream receives its own delivery queue. Events produced by the worker thread are fanned out to all registered listener queues, so multiple consumers (browser, console proxy, SDK) can connect simultaneously and each receives every event. On reconnect the client receives -a full history replay, so no catch-up mechanism is needed. +the kind-specific replay (`connected` + `status` + `history` + pending +approval / plan for interactive; `connected` + `status` + pending for coord) +followed by a `state_change` carrying the current worker state and an +optional `in_progress_snapshot` carrying any partial content / reasoning +buffered for the in-progress turn — so a mid-stream refresh restores both +the busy-mode UI and the partial assistant text without waiting for the +response to complete. --- diff --git a/docs/architecture.md b/docs/architecture.md index 7362a0f6..9723cecc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -231,11 +231,13 @@ The engine emits state changes via `_emit_state()` which calls > See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png) -Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 14 +Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 16 methods. Every frontend must implement all of them. ```python class SessionUI(Protocol): + def on_turn_start(self) -> None: ... + def on_turn_committed(self) -> None: ... def on_thinking_start(self) -> None: ... def on_thinking_stop(self) -> None: ... def on_reasoning_token(self, text: str) -> None: ... @@ -252,6 +254,14 @@ class SessionUI(Protocol): def on_rename(self, name: str) -> None: ... # propagate alias to tab/UI label ``` +`on_turn_start` fires at the top of each iteration of the send-loop; +`on_turn_committed` fires immediately after `messages.append(assistant_msg)`. +`SessionUIBase` uses both to reset the per-turn inflight buffers +(`_ws_inflight_content` / `_ws_inflight_reasoning` / `_ws_inflight_seq`) +that fuel the SSE refresh-resume `in_progress_snapshot` event — see +the per-workstream events stream in +[`docs/api-reference.md`](api-reference.md#get-v1apiworkstreamsws_idevents). + `on_rename` is called by the `/name` command (on success) and after a successful `/resume` (if the resumed session has an alias or title). `WebUI.on_rename` broadcasts a `ws_rename` event on the global SSE channel and updates the in-memory `Workstream.name`; `TerminalUI.on_rename` is a no-op. ### Three Implementations diff --git a/docs/coordinator-api-tour.md b/docs/coordinator-api-tour.md index 491f2274..7c278f55 100644 --- a/docs/coordinator-api-tour.md +++ b/docs/coordinator-api-tour.md @@ -115,7 +115,8 @@ with a `type` field. The recurring shapes a UI has to handle: | `tool_output_chunk` | Streaming tool output (e.g. long bash command) | `call_id`, `chunk` | | `approve_request` | One or more tool calls need operator approval | `items: [{call_id, header, preview, func_name, approval_label, needs_approval}]` | | `approval_resolved` | Operator answered the approval prompt | `approved`, `feedback` | -| `state_change` | Worker-thread state transition | `state` ∈ `running`, `thinking`, `attention`, `idle`, `error` | +| `state_change` | Worker-thread state transition (also re-emitted with the current state on every fresh subscribe so refresh-mid-stream restores composer mode) | `state` ∈ `running`, `thinking`, `attention`, `idle`, `error` | +| `in_progress_snapshot` | One-shot replay of the in-progress turn's content + reasoning when this client connects mid-stream | `content`, `reasoning` | | `status` | Token usage + context-window snapshot (fires on every streaming tick) | `prompt_tokens`, `completion_tokens`, `total_tokens`, `context_window`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` | | `rename` | Session's display name changed | `name` | | `intent_verdict` | Intent judge produced a verdict on a pending tool call | `risk_level`, `recommendation`, `reasons` | @@ -130,9 +131,13 @@ with a `type` field. The recurring shapes a UI has to handle: **Reconnection contract:** a freshly-opened SSE connection receives the current snapshot of any pending tool approval (`approve_request` -is re-sent if unresolved) and any in-flight `wait_*` / `batch_*` -indicator — so a tab refresh mid-approval doesn't strand the -operator. +is re-sent if unresolved), any in-flight `wait_*` / `batch_*` +indicator, the worker's current `state_change`, and an +`in_progress_snapshot` carrying any partial content / reasoning the +model has produced for the in-progress turn — so a tab refresh +mid-approval, mid-tool-execution, or mid-stream restores both the +correct composer mode and the partial assistant text without waiting +for the response to complete. --- diff --git a/docs/diagrams/04-conversation-turn.puml b/docs/diagrams/04-conversation-turn.puml index 8426a995..1d76a576 100644 --- a/docs/diagrams/04-conversation-turn.puml +++ b/docs/diagrams/04-conversation-turn.puml @@ -24,6 +24,14 @@ CS -> DB : save_message(ws_id, "user", input) group loop [while tool_calls present] + CS -> UI : on_turn_start() + note right of UI + SessionUIBase resets the per-turn inflight + buffers (_ws_inflight_content / reasoning / + seq) that fuel the SSE in_progress_snapshot + event for mid-stream refresh resume. + end note + CS -> UI : on_state_change("thinking") CS -> UI : on_thinking_start() @@ -73,6 +81,14 @@ group loop [while tool_calls present] CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio CS -> CS : messages.append(assistant_msg) + CS -> UI : on_turn_committed() + note right of UI + Drops the per-turn inflight buffers — the + assistant message is now in the history + list, so the in_progress_snapshot must + not re-render it during the next tool- + execution window or the next streaming turn. + end note CS -> DB : save_message(ws_id, "assistant", content) CS -> DB : save_message(ws_id, "tool_call", ...) ×N diff --git a/docs/diagrams/png/04-conversation-turn.png b/docs/diagrams/png/04-conversation-turn.png index 77ce41ee..da47e704 100644 --- a/docs/diagrams/png/04-conversation-turn.png +++ b/docs/diagrams/png/04-conversation-turn.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61 -size 325245 +oid sha256:c14dfbb2db8dcb22cd332b2cf0e53ba75141adb213dae47dd9dbfd389ed482fe +size 354799 diff --git a/docs/sdk.md b/docs/sdk.md index 0304cd05..a4c0ae14 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -138,6 +138,9 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi | `error` | `ErrorEvent` | `message` | | `info` | `InfoEvent` | `message` | | `stream_end` | `StreamEndEvent` | — | +| `state_change` | `StateChangeEvent` | `state` ∈ `running`/`thinking`/`attention`/`idle`/`error` | +| `in_progress_snapshot` | `InProgressSnapshotEvent` | `content`, `reasoning` (one-shot mid-stream refresh resume) | +| `approval_resolved` | `ApprovalResolvedEvent` | `approved`, `feedback` | | `cancelled` | `CancelledEvent` | — | **Global events** (from `stream_global_events()`):