192 lines
14 KiB
Markdown
192 lines
14 KiB
Markdown
# Fido Connection-State Improvement Plan
|
||
|
||
**Status:** Proposed
|
||
**Scope:** Replace the free-text, single "thinking…" status with a small, explicit state machine so the command bar accurately reflects what the session is doing at every moment.
|
||
|
||
## Goals
|
||
|
||
- Replace the ambiguous free-text status line with a closed set of clearly distinct connection states: **idle**, **connecting**, **thinking**, **working**, and **streaming**.
|
||
- Make every state transition driven by an observable provider event, not by timers or guesswork, so the status always tells the truth.
|
||
- Eliminate the dead-time window between submitting a message and the first model delta, during which the UI currently shows a misleading "thinking…".
|
||
- Distinguish "the model is reasoning" from "the model is calling a tool" from "the model is writing a reply", because these feel very different to the user even though all three are "busy".
|
||
- Keep the existing architecture: the state lives in the UI store, the REPL drives transitions, and nothing about the provider or message flow changes.
|
||
- Preserve cache-stable system prompts and the existing transcript/tool-notification behavior.
|
||
|
||
## Non-goals
|
||
|
||
- Changing the provider, the HTTP/SSE transport, or `llmorbiter` itself.
|
||
- Streaming visible assistant text into the history pane (the current UI logs the assistant reply at end of turn; that is a separate feature).
|
||
- Adding progress bars, elapsed-time counters, or token-rate meters.
|
||
- Changing the prompt, tool contracts, or any tool behavior.
|
||
|
||
## Design principles
|
||
|
||
1. **A closed enum, not free text.** The status is one of five constants, never a hand-written sentence assembled by string concatenation.
|
||
2. **Events drive transitions, not timers.** "Thinking" starts because a reasoning delta arrived, not after a debounce. There is no time-based guesswork.
|
||
3. **A state machine with a total function.** Every event maps to a state, so the behavior is exhaustive and testable, including out-of-order and backward moves.
|
||
4. **Last event wins; switch immediately.** Each delta that arrives sets the state right away, including a delta that moves the state backward. Reasoning, tool calls, and output are not interleaved within a request, so the reducer never has to arbitrate a genuine tie; the goal is to reflect the newest activity the instant it is observed rather than to smooth it out.
|
||
5. **One owner.** The REPL (`src/repl.js`) is the only module that decides transitions, because it already owns the turn lifecycle and the provider callbacks. The UI store and view only render the state they are given.
|
||
6. **Backward-compatible rendering.** The spinner animation, colors, and layout stay. Only the label text and its source change.
|
||
7. **Cancel and error are terminal, not busy.** Cancellation and errors must always fall back to `idle` so the prompt returns; no state may strand the session busy.
|
||
|
||
## The five states
|
||
|
||
| State | Meaning | Entered when |
|
||
| --- | --- | --- |
|
||
| `idle` | No turn is running; the main prompt is shown and awaiting user input. | Session start, after a turn completes, after cancel, after an error. |
|
||
| `connecting` | The turn has started and the HTTP request to establish the SSE stream has been issued, but the stream is not yet producing deltas. This is the "we sent it, nothing has come back yet" window. | Immediately when a turn begins, before any delta. |
|
||
| `thinking` | The model is producing reasoning deltas. | Every `onReasoning` delta. |
|
||
| `working` | The model is calling a tool. | Every `onToolCall` delta. |
|
||
| `streaming` | The model is writing visible output (assistant content). | Every `onContent` delta. |
|
||
|
||
Note: `thinking`, `working`, and `streaming` describe what the model stream is currently doing, so the label tracks the most recent delta at all times. The work performed *inside* a tool still surfaces through the existing per-tool transcript notifications; this plan does not replace that.
|
||
|
||
## State machine
|
||
|
||
The state is a pure function of the most recent event. `connecting` holds only until the first delta of any kind; from then on each delta sets the state directly, and the state is free to move in either direction.
|
||
|
||
```text
|
||
(turn begins)
|
||
idle ─────────────────────────► connecting
|
||
▲ │
|
||
│ │ any delta kind
|
||
│ ┌────────────────────────┼────────────────────────┐
|
||
│ │ onReasoning │ onToolCall │ onContent
|
||
│ ▼ ▼ ▼
|
||
│ thinking working streaming
|
||
│ └────────────────────────┼────────────────────────┘
|
||
│ │
|
||
│ (each later delta re-enters directly;
|
||
│ direction is not restricted)
|
||
│
|
||
turn ends / cancel / error
|
||
```
|
||
|
||
### Legal transitions
|
||
|
||
| From | Event | To | Notes |
|
||
| --- | --- | --- | --- |
|
||
| `idle` | turn start (`runTurn`) | `connecting` | Clears reasoning, sets busy, issues the request. |
|
||
| any | `onReasoning` delta | `thinking` | Reasoning is the newest observed activity. |
|
||
| any | `onToolCall` delta | `working` | A tool call is the newest observed activity. |
|
||
| any | `onContent` delta | `streaming` | Visible output is the newest observed activity. |
|
||
| any | turn end (success) | `idle` | Reply logged, busy cleared, prompt returns. |
|
||
| any | `AbortError` / cancel | `idle` | Draft restored, busy cleared. |
|
||
| any | error | `idle` | Error logged, busy cleared. |
|
||
|
||
The three delta rows are deliberately *not* restricted to particular source states. A delta always sets the state, even if it moves the state backward, so the label always names the model's current activity rather than the furthest point it has reached. `connecting` is the one state with a special property: it is only left via a delta, and a turn that produces no deltas at all stays in `connecting` until the turn ends.
|
||
|
||
### Ordering nuance: a turn can run several provider iterations
|
||
|
||
`runTurn` calls `orbiter.run(...)` with `maxIterations: 20`, so one user message can produce several request/response cycles. Two options:
|
||
|
||
- **Turn-scoped (recommended first version):** the state persists across the whole `runTurn` call and only resets to `connecting` at the start of the *next* user turn. A tool-calling iteration therefore shows `working`, and the final reply shows `streaming`; it never returns to `connecting` mid-turn, which is correct — the session is still connected.
|
||
- **Iteration-scoped (later refinement):** reset to `connecting` at the top of each provider iteration so the user sees the reconnect between iterations. This is more precise but noisier, and needs an iteration-boundary hook from the provider. Do not add it until the turn-scoped version is in place.
|
||
|
||
## Implementation
|
||
|
||
### 1. A dedicated state module
|
||
|
||
Add `src/turn-state.js` exporting the enum and a pure reducer, so the rules are unit-testable without React, the store, or a provider:
|
||
|
||
```js
|
||
export const TURN_STATES = {
|
||
IDLE: 'idle',
|
||
CONNECTING: 'connecting',
|
||
THINKING: 'thinking',
|
||
WORKING: 'working',
|
||
STREAMING: 'streaming',
|
||
};
|
||
|
||
// Pure: (current, event) -> next state. Deltas always win; the current
|
||
// value is irrelevant to every row except the turn-level ones.
|
||
export function nextState(current, event) { /* per the transition table */ }
|
||
|
||
// Optional helper for the status line.
|
||
export function statusLabel(state) { /* 'connecting…' | 'thinking…' | ... */ }
|
||
```
|
||
|
||
`event` is one of `'turn_start'`, `'reasoning'`, `'tool_call'`, `'content'`, `'turn_end'`, `'cancel'`, `'error'`. Because the reducer is a pure function of `(current, event) -> state`, the whole machine is trivially testable, and an unknown event resolves to a defined result rather than an exception. With this design the reducer is stateless for every delta event, so it cannot drift out of sync: the state is always exactly "whatever the last delta said", or `connecting`/`idle` when no delta applies.
|
||
|
||
### 2. Store and view changes
|
||
|
||
- `src/ui/store.js`: replace the free-text `status: ''` with the enum (`status: 'idle'` initially). Update `setStatus` to accept a state, and make `setBusy(false)` reset the state to `idle` so no path strands the command bar busy.
|
||
- `src/ui/index.js`: keep the `status(text)` facade but repurpose it for state values, or add a clearly named `setTurnState(state)`. The REPL remains the only caller.
|
||
- `src/ui/app.js` `StatusLine`: map each state to a label instead of rendering a raw provider string or the hardcoded `'working…'` fallback. Keep the existing spinner and yellow color:
|
||
- `connecting` → `connecting…`
|
||
- `thinking` → `thinking…`
|
||
- `working` → `working…`
|
||
- `streaming` → `streaming…`
|
||
- `idle` → not rendered; the existing `› (idle)` placeholder covers the non-busy case.
|
||
|
||
`CommandBar` and the tiny-terminal fallback in `app.js` both render `StatusLine`, so one change to the label mapping updates both paths.
|
||
|
||
### 3. REPL wiring
|
||
|
||
In `src/repl.js`:
|
||
|
||
- In `createOrbiter`, dispatch the corresponding event from `onReasoning`, `onToolCall`, and `onContent` and push the resulting state to the UI. These callbacks already fire repeatedly with growing text (`turnReasoning.text = text`, `ui.setReasoning(text)`), so every callback invocation is a live transition opportunity and the label follows the newest delta.
|
||
- In `runTurn`, emit `turn_start` (→ `connecting`) where it currently calls `ui.busy(true)` and `ui.status('thinking…')`, and emit `turn_end` (→ `idle`) in the `finally` block next to `ui.busy(false)`. The `finally` block already covers the cancel and error paths, so the terminal transition is centralized.
|
||
- Reset the state at the start of every turn so a new turn always begins at `connecting` and never inherits the previous turn's terminal state.
|
||
|
||
### Relationship to `onStatus`
|
||
|
||
`createOrbiter` currently wires `onStatus: (s) => ui.status(s)`, forwarding a provider-supplied string straight into the status line. That is a different concern from the connection state, and it is the reason the state is not trustworthy today: the label depends on whatever text the provider chooses to emit, and the hardcoded `'thinking…'` at turn start contradicts it.
|
||
|
||
The plan:
|
||
|
||
- The **connection state** is owned by Fido and driven by Fido's own observable events (`turn_start`, the delta callbacks, `turn_end`) per the table above.
|
||
- If `onStatus` text is still displayed, treat it as an optional **detail** layered on the state label, never as a replacement for it. If the two disagree, the state machine wins.
|
||
- This keeps the UI correct even when the provider emits sparse, absent, or misleading status text.
|
||
|
||
## Implementation order
|
||
|
||
### Phase 1: State module and unit tests
|
||
|
||
- [ ] Add `src/turn-state.js` with `TURN_STATES`, `nextState(current, event)`, and `statusLabel(state)`.
|
||
- [ ] Add `test/turn-state.test.js` covering every row of the transition table, each delta kind reached from every state, and the backward cases (a later `reasoning` delta after `streaming` *does* move the state back to `thinking`).
|
||
|
||
### Phase 2: Store and view rendering
|
||
|
||
- [ ] Convert the store status to the state enum; reset to `idle` on turn end, cancel, error, and `setBusy(false)`.
|
||
- [ ] Map each state to a label in `StatusLine`; keep the spinner, color, and both render paths (fullscreen layout and tiny-terminal fallback).
|
||
- [ ] Update `.ui-smoke.mjs` to drive the store into each non-idle state and assert the matching label renders, replacing the current hardcoded `setStatus('thinking…')` fixture.
|
||
|
||
### Phase 3: REPL wiring
|
||
|
||
- [ ] Emit `turn_start`/`turn_end` and the delta events from `createOrbiter`/`runTurn`; drive the UI exclusively through the reducer.
|
||
- [ ] Ensure each new turn starts at `connecting` with no cross-turn state bleed, and that cancel and error always land on `idle`.
|
||
- [ ] Exercise a fake provider (no live model call) to confirm the observed label sequences: plain answer `connecting → streaming`; reasoning then answer `connecting → thinking → streaming`; reasoning, tool, answer `connecting → thinking → working → streaming`.
|
||
|
||
### Phase 4: Polish and edge cases
|
||
|
||
- [ ] Decide whether a per-state detail from `onStatus` is shown; if so, add it as a non-authoritative suffix and cap its visible width.
|
||
- [ ] Add a short "Connection states" note to the README UI section documenting the five states and the spinner.
|
||
- [ ] Check that a fast reasoning→tool→output turn reads as three quick, distinct label changes rather than one stalled label, and that the switch happens on the first delta of each kind.
|
||
|
||
## Acceptance criteria
|
||
|
||
### State machine
|
||
|
||
- `turn_start` from `idle` always yields `connecting`; no turn ever begins in `thinking`, `working`, or `streaming`.
|
||
- Every reasoning delta sets the state to `thinking`, every tool-call delta to `working`, and every content delta to `streaming`, regardless of which state was current.
|
||
- A later delta of a kind that already occurred still updates the state, including when it moves the state backward; the label is never a stale "furthest point reached" value.
|
||
- Turn end, cancel, and error all resolve to `idle`; no path strands the session busy.
|
||
- A turn with no reasoning and no tools goes `connecting → streaming`; a turn that reasons then calls a tool goes `connecting → thinking → working`.
|
||
|
||
### UI
|
||
|
||
- The command bar shows the correct label for each state, with the spinner still animating, in both the fullscreen layout and the tiny-terminal fallback.
|
||
- The existing `› (idle)` placeholder appears only when no turn is running; the busy state is never blank.
|
||
- The footer, header, transcript, tool notifications, and scroll behavior are unchanged.
|
||
|
||
### Regression
|
||
|
||
- Existing `npm test` tests remain green.
|
||
- The state model is pure and unit-tested without React, the store, or a live provider.
|
||
- No change to the provider, transport, system prompt, tools, or message flow.
|
||
|
||
## Relationship to PLAN.md
|
||
|
||
This document is independent of the todo/subagent plan in `PLAN.md`. The two touch the same command bar and status chrome, so if both are implemented, reconcile them: `PLAN.md` proposes a second accent-colored subagent activity bar, which should get its own explicit lifecycle state rather than overloading `working`. Keep the five main-agent states defined here, and layer delegation on top as a distinct, additive concept.
|