# Waiting-for-reply UX — v2 spec (three-class contract)

Tracks: [#545](https://github.com/mekenthompson/switchroom/issues/545),
[#553](https://github.com/mekenthompson/switchroom/issues/553) (PR series),
[#1713](https://github.com/switchroom/switchroom/issues/1713)
(reflective status-reaction restoration)

> **#1713 note — defect restoration, not feature change.** The Class B
> contract below ("Ladder progresses through 🤔 / tool-glyphs … **Must
> NOT collapse straight to 👍**") already documents the correct
> reflective behaviour. The implementation had regressed: plain `reply`
> and `stream_reply done=true` were firing the terminal 👍 mid-turn,
> collapsing the ladder. #1713 restores the documented contract — the
> **`turn_end` IPC event (Stop hook) is the sole terminal trigger**.
> Mid-turn replies (ack OR final-answer) are non-events for the
> reaction. Working states (🤔 / ✍ / 👨‍💻 / ⚡ / 🗜) are bidirectional
> and may re-enter any number of times within a turn. 🔥 (5xx) is also
> non-terminal: recovery to a working state is allowed; only
> `finalize()` ends the controller. Controller debounce is 3500ms by
> default (#1713 spec: 3-5s) to coalesce rapid state flips.

This document codifies the user-perceived contract for what happens
between "I sent a Telegram message" and "the agent's reply is locked
in." The contract varies by **turn class**. The v2 rewrite (#553)
sharpens the gates: tools alone never trigger the progress card,
placeholder text is removed entirely, and sub-agents (= background
workers) are the single concept for parallel work.

## Three turn classes

### Class A — Instant (<2s, NO tools)

| Surface           | Contract                                                               |
| ----------------- | ---------------------------------------------------------------------- |
| Status reaction   | 👀 within 800ms of inbound. Terminates with 👍.                        |
| Progress card     | **Never rendered.** Suppressed regardless of `initialDelayMs`.         |
| Placeholder text  | **Never sent.** No `🔵 thinking` / `📚 recalling memories` / `💭 thinking`. |
| Answer text       | First answer-text edit lands within **1500ms** of inbound (Class A budget; pinned in #553 PR 3). |
| Ladder            | 👀 → 👍. Optional 🤔 if the controller debounce window is crossed.    |

User experience: feels like a chat partner typing back instantly.

### Class B — Short (2–60s, tools, NO sub-agents)

| Surface           | Contract                                                               |
| ----------------- | ---------------------------------------------------------------------- |
| Status reaction   | 👀 within 800ms. Ladder progresses through 🤔 / tool-glyphs (🔥/✍/👨‍💻/⚡) before 👍. **Must NOT collapse straight to 👍.** |
| Progress card     | **Never rendered.** The card gate is `(elapsed >= 60s) OR (sub-agent appeared)` — tools alone do NOT trigger it. |
| Placeholder text  | **Never sent.**                                                        |
| Answer text       | First answer-text edit lands within **3000ms** of inbound (Class B/C budget; pinned in #553 PR 3). Streams progressively as the model produces tokens. |
| Final             | 👍 + locked stream answer.                                             |

User experience: live ladder of tool reactions, answer text starts
streaming as soon as the model resumes, no fake "thinking" spacers.

### Class C — Long-running (>60s OR sub-agents/background workers)

| Surface           | Contract                                                               |
| ----------------- | ---------------------------------------------------------------------- |
| Status reaction   | 👀 within 800ms. Ladder throughout. Settles to 👍 only after full quiescence (all sub-agents terminal). |
| Progress card     | Renders the moment the gate trips: `(elapsed >= 60s) OR (any sub-agent has appeared)`. Stays pinned-feel and stable. |
| Card "Done" stamp | **≥ last sub-agent terminal timestamp.** Card never marks Done while a sub-agent is still in flight. |
| Sub-agent header  | Header count == rendered-list-length. **No drift between summary and bullets.** |
| Placeholder text  | **Never sent.**                                                        |

A "background worker" ≡ a sub-agent dispatched with
`Agent({ run_in_background: true })`. There is no separate concept —
the card gate, the bullet list, and the quiescence check all key on
the sub-agent stream.

## Key invariants (v2)

1. **No placeholder strings** — `🔵 thinking`, `📚 recalling memories`,
   and `💭 thinking` must never appear in any `sendMessage` /
   `editMessageText` payload at any point in any turn class. PR 5
   removes the production code that emits them.
2. **Card gate** — `(elapsed >= 60s) OR (any sub-agent has appeared)`.
   Tool-use count, tool category, and parent narrative content are
   NOT inputs to the gate.
3. **First-answer-text deadline** — Class A: <1500ms. Class B/C:
   <3000ms (committed in #553 PR 3). Budget: 500ms inbound coalesce +
   ~1s minInitialChars-driven first send + ~1.5s model TTFT for short
   replies.
4. **Sub-agent header == list length** — every render of the card.

## PR 1 — foundation: spec + harness extensions (this PR)

PR 1 ships:

- This rewritten spec — supersedes the v1 four-failure-mode framing.
- Three new helpers on `tests/real-gateway-harness.ts`:
  - `expectNoPlaceholderEdits(chatId)` — returns recorded calls whose
    payload matches a banned placeholder string. Tests assert
    `toEqual([])`.
  - `expectNoCardSent(chatId)` — wraps `progressCardSendMs` for
    assertion-friendly use (`.toBeNull()`).
  - `firstAnswerTextMs(chatId)` — first `sendMessage` /
    `editMessageText` whose payload is neither a card payload nor a
    placeholder string.
- A new RED test file `tests/real-gateway-spec.test.ts` (all
  `describe.skip`'d) pinning the three-class contract:
  - Class A — 5 tests
  - Class B — 4 tests
  - Class C — 5 tests
  Each carries a `// TODO(#553-PR-N)` marker for which subsequent PR
  un-skips it.

PR 1 does NOT change production code. The existing F1/F2/F3/F4
regression tests stay green; the new spec tests are skipped, so they
do not gate CI yet.

## PR 2–5 — implementation roadmap

| PR  | Scope                                                                | Un-skips                                            | Status   |
| --- | -------------------------------------------------------------------- | --------------------------------------------------- | -------- |
| 2   | Kill instant-draft placeholder; preserve early-ack 👀                | Class A no-placeholder, Class B no-placeholder      | shipped  |
| 3   | First-answer-text deadline implementation; tighten <Ns numbers       | Class A/B/C answer-text-deadline assertions         | shipped  |
| 4   | Card-gate rewrite to `(>=60s) OR (sub-agent appeared)`               | Class B no-card; Class C card-gate tests            | shipped  |
| 5   | Remove `🔵 thinking` / `📚 recalling memories` / `💭 thinking` strings; sub-agent header = list length | Remaining no-placeholder + sub-agent count tests    | shipped  |

**PR 4 status:** shipped the card-gate rewrite. Defaults change
from `initialDelayMs=30_000, promoteAfterMs=5_000, promoteOnParentToolCount=3`
to `initialDelayMs=60_000, promoteAfterMs=0 (disabled), promoteOnParentToolCount=0
(disabled)`. The Class B "no card rendered" and Class C "card renders on
sub-agent" / "card renders after 60s" tests are un-skipped. F3's late-card
symptom (long single-tool turn shows no card) is now intentional spec
behaviour rather than a bug — see `real-gateway-f3-late-card.test.ts`
header for the reframe.

**PR 5 status (this PR):** ships the placeholder-text removal. The
pre-allocated draft path, the `update_placeholder` IPC handler, the
placeholder heartbeat (`placeholder-heartbeat.ts`), the phase enrichment
(`placeholder-phase.ts`), the `forum-topic-placeholder.ts` substitute,
and all associated state in `gateway.ts` are deleted. recall.py's
`update_placeholder` IPC calls now no-op cleanly because the gateway
no longer registers an `onUpdatePlaceholder` handler — the IPC client
emits a fire-and-forget JSON line, and the gateway dispatch silently
ignores the unknown message type. Hindsight (`vendor/hindsight-memory/`)
is untouched. The Class A/B/C no-placeholder assertions and the Class C
sub-agent-header-equals-list-length assertion are un-skipped.

## IPC + bridge lifecycle invariants

Pinned by `tests/real-gateway-ipc-lifecycle.test.ts`. These five
invariants codify behaviors that the user-perceived waiting-UX spec
above implicitly relies on but which live one layer down — at the
IPC server / bridge connect-disconnect boundary. Multiple production
bugs in 2026-05 traced back to the absence of test coverage at this
layer; this section is the canonical write-up of what the gateway
must guarantee.

- **I1 — Anonymous IPC client lifecycle is observably invisible.**
  A client that connects but never registers an agent (e.g. recall.py
  sending a one-shot legacy IPC message) MUST NOT mutate any active
  status reactions, MUST NOT dispose the progress driver, and MUST
  NOT close any draft streams. The disconnect path of an unregistered
  client is a no-op on user-visible state. (Bug A — premature 👍 on
  recall.py disconnect, fixed in PR #600.)

- **I2 — Per-agent disconnect isolation.** When agent X's bridge
  disconnects, only X's status reactions get flushed to `setDone()`.
  Agent Y's active reactions stay untouched. Switchroom is single-
  agent-per-gateway today, but the invariant pins the right
  semantics so multi-agent gateways cannot regress silently.

- **I3 — 👍 fires AFTER real delivery, not after JSONL `turn_end`.**
  For any reply tool path (`reply`, `stream_reply done=true`), the
  controller's `setDone()` MUST happen at-or-after the timestamp of
  the final outbound to Telegram for that reply. Firing on the
  JSONL `turn_end` event before the round-trip completes produces a
  visible flash of 👍 with no reply text. (Bug D + Bug Z.)

- **I4 — Legacy IPC types are tolerated, not lethal.** Any message
  type the gateway no longer handles (e.g. `update_placeholder`
  after #553 PR 5) MUST be soft-accepted: the validator returns
  false, `processBuffer` logs and continues, the connection stays
  open, and no active state is mutated. (Bug B — gateway crash on
  recall.py's `update_placeholder` after PR 5; fixed in PR #600.)

- **I5 — Wake-audit dedup.** The `.wake-audit-pending` sentinel and
  audit cycle MUST NOT re-fire mid-conversation under `--continue`
  respawn. (Bug C — duplicate greeting reply on respawn. The actual
  fix lives in profiles, not in the gateway, but the regression
  surface is observable here as "duplicate outbound for the same
  logical turn" and so the test invariant lives in this file.)

The test file documents which invariants currently have green tests,
which depend on in-flight PRs to land first, and which are `.skip`'d
pending the matching fix branch. See the file header for the
bug-to-PR map.

## Failure-mode history (F1–F4, fixed in earlier #553 PRs)

The v1 spec framed the rewrite around four observed regressions from
the 2026-04-30 live demo. They are all fixed; the regression tests
(`tests/real-gateway-f1-ladder-integrity.test.ts`,
`real-gateway-f2-instant-draft.test.ts`, `real-gateway-f3-late-card.test.ts`,
`real-gateway-f4-interim-text.test.ts`) stay in place to keep the gaps closed.

| ID  | Symptom                                                                    | Class | Status                                            |
| --- | -------------------------------------------------------------------------- | ----- | ------------------------------------------------- |
| F1  | Ladder collapses straight to 👍 (skips 👀 → 🤔 → 🔥)                       | B     | Fixed — `StatusReactionController.finishWithState` flushes pending pre-terminal emoji. |
| F2  | No instant draft / typing signal — chat sits silent "for ages"             | All   | Fixed — `handleInboundCoalesced` fires 👀 directly on raw arrival before the coalesce buffer. |
| F3  | Progress card renders late (after turn_end, or never on long turns)        | C     | Fixed under v1 rules with a 5s time-promote in the driver; **superseded by PR 4**, which replaces the gate with `(>=60s) OR (sub-agent)`. |
| F4  | Pre-tool preamble static — one preamble then silence                       | B     | Regression-guarded only; not reproducible deterministically. The v2 contract sidesteps F4 by tightening the first-answer-text deadline (Class B/C, PR 3). |

The F1/F2/F3/F4 tests remain green throughout the v2 rewrite — they
encode tighter invariants than the v2 spec relaxes. PR 4's gate
change does not regress F3 (the F3 long-single-tool case crosses the
60s threshold).

## Test methodology

- **Time control**: `vi.useFakeTimers()` + `vi.setSystemTime` for
  deterministic wall-clock assertions. The harness records every
  outbound `bot.api` call with `Date.now()` at invocation, so all
  deadlines are reproducibly measurable.
- **Recorder** (existing): `firstReactionMs`, `progressCardSendMs`,
  `reactionSequence`, `lastReactionEmoji`, `edits`, `sentTexts`.
- **Recorder** (PR 1 additions): `expectNoPlaceholderEdits`,
  `expectNoCardSent`, `firstAnswerTextMs`.
- **Production wiring**: the harness uses the actual
  `StatusReactionController`, `createProgressDriver`, and
  `createInboundCoalescer` from production — not mocks. The bot.api
  layer below is a recording fake.
- **Out of scope**: foreman queue, IPC bridge, auth, history. Those
  do not influence the user-perceived waiting UX.

## CI gate

The harness runs as part of the root vitest suite via `npm test` →
`vitest run`. PR 1's spec tests are `describe.skip`'d and do not
fail CI; PRs 2–5 un-skip them as the production code lands.

## Phase 1 / Phase 3 history (legacy)

For posterity:

- **Phase 1** (#547): `tests/waiting-ux.e2e.test.ts` — controller +
  driver in isolation, hand-written `feedSessionEvent` adapter. F2
  passed trivially because the harness called `setQueued()`
  synchronously inside `inbound()`.
- **Phase 3** (#553 PR 1, original): `tests/real-gateway-harness.ts`
  composed the production `InboundCoalescer` before the Phase 1
  controller stack, exposing the real F2 gap (👀 only fired after
  the coalesce window). F2's fix landed against this harness.
- **v2 rewrite** (this PR series, also numbered #553): same Phase 3
  harness, plus three v2 helpers; new spec test file pins the
  three-class contract.


## Silent-end contract (#1122 / #1161 / #1664 / #1741 / #1744)

The "silent-end" safety net catches turns that end without the agent delivering a final answer via `reply` / `stream_reply`. The hook re-prompts once; on the second consecutive silent end it gives up and sends a fallback message so the turn never just vanishes.

### What it is

A per-agent state file (`$TELEGRAM_STATE_DIR/silent-end-pending.json`, fallback `~/.claude/channels/telegram/silent-end-pending.json`) acts as a one-bit handshake between the gateway and the Stop hook (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`). When the file exists, the Stop hook blocks the session stop and injects a re-prompt; when absent, the stop is allowed.

### When the file is WRITTEN

The gateway writes the file at `turn_end` if and only if `turn.finalAnswerDelivered === false` — i.e. no `reply` or `stream_reply` call during this turn passed the `isFinalAnswerReply` predicate. See `gateway.ts` around L7267 (`recordUndeliveredTurnEnd`). The write is idempotent across re-prompt rounds: the same turnKey inherits the prior `retryCount`; a different turnKey resets to 0.

### When the file is CLEARED

The gateway calls `clearSilentEndState(turnKey)` on every reply that qualifies as the final answer. Three call sites:

1. `executeReply` (gateway.ts L4599-4611) — fires on every `reply` tool call. Clears iff `isFinalAnswerReply` returns true.
2. `executeStreamReply` first-emit branch (gateway.ts L5172-5195) — fires only on the FIRST emit per stream (gated by `!activeDraftStreams.has(sKey)`). Clears iff that first emit qualifies as final.
3. `executeStreamReply` final-answer site (gateway.ts L5335-5358, added in #1744 follow-up) — fires on every emit that qualifies as final, regardless of whether it is the first emit. This is the load-bearing site for streams whose first emit was ack-shaped but whose later emit (typically `done=true`) carries the real answer. Without site 3, such streams leak the state file.

The clear is fail-silent and turnKey-keyed: a clear for turnKey A does NOT unlink a state file written for turnKey B (see `silent-end.ts clearSilentEndState`). This makes calling it unconditionally on every final-answer emit safe.

### The gate predicate

A reply qualifies as the final answer when `isFinalAnswerReply` returns true. The predicate (in `final-answer-detect.ts`) is a logical OR of three signals:

- `done === true` (stream_reply terminal call), OR
- `disableNotification === false` (pacing-contract final-answer signal), OR
- `text.length >= 200` (length backstop for substantive replies mis-marked as interim).

A reply with `disable_notification: true`, short text, and no `done` is an interim ack — it never clears the state.

### Send sites and tests

- `executeReply` (gateway.ts:4340) — single send site, single clear (site 1 above).
- `executeStreamReply` (gateway.ts:5089) — two clear sites (sites 2 and 3 above) to cover both the first-emit and the later-emit final-answer paths.
- Unit coverage: `tests/silent-end.test.ts` (`#1741` block) — the executeReply gate as a unit.
- Integration coverage: `tests/silent-end-integration.test.ts` (#1744) — the stream first-emit-vs-later-emit branching, with the ack-then-final regression case pinned.
