Skip to content

feat(session): derive agent phase server-side and emit it as an additive field - #548

Draft
RonenMars wants to merge 2 commits into
mainfrom
feat/agent-sub-status
Draft

feat(session): derive agent phase server-side and emit it as an additive field#548
RonenMars wants to merge 2 commits into
mainfrom
feat/agent-sub-status

Conversation

@RonenMars

Copy link
Copy Markdown
Owner

Server-side agent sub-status, Codex half. Replaces the client-side approach abandoned in tb-mobile #647.

Draft: four verification gaps remain, tracked in #541. Opened for visibility and early review of the contract rather than to merge as-is.

What this adds

subStatus — the agent's phase within a running turn (thinking | streaming | hooks | acting | working) — scraped from the rendered PTY screen alongside the detectors that already share that read, and emitted as an additive field plus a scoped session_phase WS frame.

Codex only for now. Its status bar is binary (Ready / Working) and needs no footer archaeology, so it validates the whole transport end to end. The Claude branch of parseAgentPhase deliberately returns null until its marker grammar is re-verified against a tool-heavy capture — reporting no phase is the correct pre-feature behaviour, and a guessed phase is worse than none.

Three decisions worth reviewing

The field is never optional, and is null when there is no phase. Clients merge session frames ({...prev, ...next}), and a merge cannot express a removed key — so an omitted field keeps its previous value and the indicator latches on a finished turn. That is precisely the bug #647 shipped. For the same reason managedToResponse emits it unconditionally rather than through the ...(x != null && { x }) guard the neighbouring optional fields use: != catches null as well as undefined and would convert an explicit clear back into absence. There is a comment on it saying not to tidy it into that block.

The phase is cleared server-side, in markReady. The exit edge is not an output event, so it cannot be read off the screen — pty-manager.ts:1103-1108 documents that a mid-conversation return to idle can go undetected forever when a differential repaint omits the marker. Leaving liveness to consumers is how #647 failed. markReady is the single idempotent running → waiting_input transition and is driven by the waiting-for-input OSC, which arrives even when no further chunk will.

The frame is a minimal session_phase message, not a SessionResponse copy, and is scoped to that session's subscribers. managedToResponse recomputes elapsedMs from new Date() on every call for a live session, so a session copy would differ on every tick regardless of the phase — and a merging client would get a fresh object identity several times a second for a whole turn. The scoped path (ws-hub.ts:74) exists for exactly this message class; its existing tenants are also bespoke frames.

Also rejected, and recorded so it is not rediscovered as a saving: riding the existing onStatusChange, which needs no new plumbing but routes a cosmetic field through recordStatus, whose update statement has no same-status guard.

Verification

tsc --noEmit clean, biome clean, 21 tests across three files.

The contract tests were checked against deliberately-broken variants rather than trusted green, because every failure mode here is silent — nothing throws and nothing fails a typecheck:

deliberate break result
move subStatus into managedToResponse's != null guard block 2 tests fail
delete the phase-change case in the pty-host relay 2 tests fail
neither 6/6 pass

The second matters because declaring the event in the union type is not the same as wiring it — the type alone typechecks and silently does nothing when THREADBASE_FEATURE_PTY_HOST=1.

What is missing — #541

  • Integration test through a fake PTY, mirroring __tests__/session-status-line.test.ts:54
  • An assertion that the scrape pass stays non-fatal — a throw inside detectLivePrompts is swallowed into a warn at both call sites
  • Direct setPhase / markReady unit tests
  • Full-suite run

Compatibility

docs/compatibility/tb-mobile.md updated per its own header requirement. Bumps PTY_HOST_PROTOCOL_VERSION to 3 — a private streamer↔host change with no mobile-visible effect, but required, because the detector runs in the host.

Refs #538. Plan: #537docs/plans/2026-08-12-agent-sub-status-server-side.md.

…ive field

Adds `subStatus`, the agent's phase within a running turn, scraped from the rendered PTY screen alongside the detectors that already share that read.
Codex only for now — its status bar is binary and needs no footer archaeology — while the Claude branch returns null until its marker grammar is re-verified against a capture, because a guessed phase is worse than none.

The field is always serialised and null when there is no phase.
Absence must never carry meaning: clients merge session frames, a merge cannot express a removed key, and an omitted field would keep its previous value and latch the indicator on a finished turn.
For the same reason the phase is cleared server-side in markReady rather than left for consumers to age out, and the mapper emits it unconditionally rather than through the `!= null` guard the neighbouring optional fields use.

The frame is a scoped `session_phase` message rather than a session copy, because managedToResponse recomputes elapsedMs on every call and a copy would differ on every tick regardless of the phase.
Bumps the pty-host protocol to 3 — the detector runs in the host, so without the event the feature silently no-ops when that flag is on.

Refs #538
… tests

Replaces the `!` assertions with a `responseFor` helper that throws on a missing session, so a broken store fails with a named error rather than a TypeError on a null property.
Verified the tests still discriminate afterwards: moving `subStatus` into managedToResponse's `!= null` guard block fails two, and deleting the `phase-change` relay case fails two.

Refs #538
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant