Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions __tests__/agent-phase-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Contract tests for the agent-phase field: the wire shape and the pty-host relay.
//
// These exist because every failure mode this feature has is SILENT. A dropped
// null latches the indicator on a finished turn; a missing pty-host relay makes
// the feature no-op when the host flag is on. Neither shows up in a typecheck,
// and neither throws.

import { describe, expect, it, vi } from "vitest";
import {
encodeMessage,
type HostEvent,
type HostRequest,
PTY_HOST_PROTOCOL_VERSION,
} from "../src/pty-host/protocol";
import { RemoteSessionRunner } from "../src/pty-host/remote-session-runner";
import { SessionStore } from "../src/session-store";
import type { ManagedSession, PTYManagerOptions, SessionResponse } from "../src/types";

const STARTED = new Date("2026-08-12T10:00:00Z");

function mkSession(over: Partial<ManagedSession> = {}): ManagedSession {
return {
id: "sess-1",
provider: "claude-code",
projectPath: "/repo",
projectName: "repo",
branch: "main",
status: "running",
startedAt: STARTED,
completedAt: null,
lastOutput: "",
promptCount: 0,
inputHistory: [],
...over,
} as ManagedSession;
}

/** Minimal transport, mirroring the harness in pty-host-protocol.test.ts. */
function mkHost() {
let onLine: (line: string) => void = () => {};
const transport = {
send(line: string) {
const req = JSON.parse(line) as HostRequest;
queueMicrotask(() => {
const result =
req.type === "status" ? { protocolVersion: PTY_HOST_PROTOCOL_VERSION, sessions: [] } : {};
onLine(encodeMessage({ id: req.id, ok: true, result }));
});
},
onLine(handler: (line: string) => void) {
onLine = handler;
},
onClose(_handler: () => void) {},
close: vi.fn(),
};
return { transport, emit: (e: HostEvent) => onLine(encodeMessage(e)) };
}

/** Serialise one session and hand back a definitely-present response. */
function responseFor(over: Partial<ManagedSession>): Readonly<SessionResponse> {
const store = new SessionStore();
store.addManaged(mkSession(over));
const resp = store.get("sess-1", new Set());
if (resp === null) throw new Error("session was not stored");
return resp;
}

describe("agent phase — wire contract", () => {
// The load-bearing one. managedToResponse guards ~19 optional fields with
// `...(x != null && { x })`, and `!=` catches null as well as undefined. If
// subStatus is ever moved into that block, an explicit "no phase" silently
// becomes an absent key — and because clients merge session frames, an absent
// key keeps its previous value. That is how tb-mobile PR #647's indicator
// latched onto finished turns.
it("serialises an explicit null rather than omitting the key", () => {
const resp = responseFor({ subStatus: null });

expect("subStatus" in resp).toBe(true);
expect(resp.subStatus).toBeNull();
// The distinction a merge cannot express: absent !== null.
expect(JSON.stringify(resp)).toContain('"subStatus":null');
});

it("serialises a set phase", () => {
expect(responseFor({ subStatus: "working" }).subStatus).toBe("working");
});

it("emits null for a session that predates the field", () => {
const store = new SessionStore();
const stored = mkSession();
delete (stored as Partial<ManagedSession>).subStatus;
store.addManaged(stored);

const resp = store.get("sess-1", new Set());
if (resp === null) throw new Error("session was not stored");
expect("subStatus" in resp).toBe(true);
expect(resp.subStatus).toBeNull();
});
});

describe("agent phase — pty-host relay", () => {
// protocol.ts's header: "the detectors that fire them run in the host, so
// every one of those callbacks needs an event here or the feature silently
// stops working when the flag is on." Declaring the event in the union type
// is NOT the same as wiring it — the type alone typechecks and does nothing.
it("delivers a phase-change event to onPhaseChange", async () => {
const onPhaseChange = vi.fn();
const host = mkHost();
await RemoteSessionRunner.connect(host.transport, { onPhaseChange } as PTYManagerOptions);

host.emit({ type: "event", event: "phase-change", sessionId: "sess-1", phase: "working" });

expect(onPhaseChange).toHaveBeenCalledWith("sess-1", "working");
});

// JSON.stringify drops undefined but preserves null — the property the whole
// clearing contract rests on. Asserted through the real encoder rather than
// assumed, because it is why `null` was chosen over omission.
it("relays an explicit null across the boundary without dropping it", async () => {
const onPhaseChange = vi.fn();
const host = mkHost();
await RemoteSessionRunner.connect(host.transport, { onPhaseChange } as PTYManagerOptions);

host.emit({ type: "event", event: "phase-change", sessionId: "sess-1", phase: null });

expect(onPhaseChange).toHaveBeenCalledWith("sess-1", null);
});

it("does not throw when the callback is omitted (additive)", async () => {
const host = mkHost();
await RemoteSessionRunner.connect(host.transport, {} as PTYManagerOptions);

expect(() =>
host.emit({ type: "event", event: "phase-change", sessionId: "sess-1", phase: "working" }),
).not.toThrow();
});
});
64 changes: 64 additions & 0 deletions __tests__/parse-agent-phase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER } from "../src/providers";
import { parseAgentPhase } from "../src/services/questions/parseAgentPhase";

// Captured Codex status bars (live PTY probe, Phase 0 findings).
const CODEX_WORKING = "gpt-5.5 medium · /Users/x/dev/tb · gpt-5.5 · medium · Working · Wo…";
const CODEX_READY = "gpt-5.5 medium · /Users/x/dev/tb · gpt-5.5 · medium · Ready · Wo…";
const CODEX_STARTING = "gpt-5.5 medium · /Users/x/dev/tb · gpt-5.5 · medium · Starting · Wo…";

describe("parseAgentPhase — codex", () => {
it("reports working while the status bar says Working", () => {
expect(parseAgentPhase([CODEX_WORKING], CODEX_CLI_PROVIDER)).toBe("working");
});

it("reports working while MCP servers are Starting", () => {
expect(parseAgentPhase([CODEX_STARTING], CODEX_CLI_PROVIDER)).toBe("working");
});

it("reports no phase when the composer is Ready", () => {
expect(parseAgentPhase([CODEX_READY], CODEX_CLI_PROVIDER)).toBeNull();
});

it("reads the last non-blank line, not the last line", () => {
const screen = ["some output", CODEX_WORKING, "", " "];
expect(parseAgentPhase(screen, CODEX_CLI_PROVIDER)).toBe("working");
});

// PRE-EXISTING LIMITATION, documented rather than asserted-away. The status
// bar carries the cwd, and CODEX_BUSY_STATUS_RE is /\b(?:Starting|Working)\b/
// — whose comment claims whole-word matching stops a project path from
// false-hitting. It does not: `/` is a non-word character, so `\b` matches at
// a path boundary and `/Users/x/Working/repo` tests true even on a Ready bar.
//
// This affects codexScreenBlocksComposer() and codexScreenShowsReady() today,
// independently of this feature — a session under a path segment named
// "Working" or "Starting" never reads as ready. Filed separately; this test
// pins current behaviour so the fix has something to flip.
it("inherits the status-bar path false-positive (known, filed separately)", () => {
const screen = ["gpt-5.5 · /Users/x/Working/repo · gpt-5.5 · medium · Ready · Wo…"];
expect(parseAgentPhase(screen, CODEX_CLI_PROVIDER)).toBe("working");
});

it("reports no phase for an empty screen", () => {
expect(parseAgentPhase([], CODEX_CLI_PROVIDER)).toBeNull();
expect(parseAgentPhase(["", " "], CODEX_CLI_PROVIDER)).toBeNull();
});
});

describe("parseAgentPhase — claude", () => {
// The Claude grammar is deliberately unimplemented until its markers are
// re-verified against a fresh PTY capture. Until then it must report no
// phase rather than guess — a wrong phase is worse than none.
it("reports no phase pending marker re-verification", () => {
const footer = "✽ Roosting… (5s · ↓ 82 tokens)";
expect(parseAgentPhase([footer], CLAUDE_CODE_PROVIDER)).toBeNull();
});
});

describe("parseAgentPhase — unknown provider", () => {
it("makes no claim rather than falling back to another provider's grammar", () => {
// Codex's own bar, read as some future provider: must not be interpreted.
expect(parseAgentPhase([CODEX_WORKING], "some-future-cli" as never)).toBeNull();
});
});
11 changes: 11 additions & 0 deletions docs/compatibility/tb-mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ A recovered session's `status` has to report `idle` (it holds no PTY, and a nove
This field carries that one bit separately, so a client can say "interrupted mid-response" instead of "idle".
Purely additive and safe to ignore: nothing keys off it server-side, and a client that never reads it behaves exactly as it does today.
Adopting it is tracked as tb-mobile PR M2.
- Session — new field `subStatus`: the agent's phase *within* a running turn (`"thinking" | "streaming" | "hooks" | "acting" | "working"`), scraped from the rendered PTY screen.
**Unlike every other addition on this list it is NOT optional — it is always serialised, and is `null` when there is no phase.**
That is deliberate and must not be "tidied" into the `...(x != null && { x })` block that the neighbouring optional fields use: `!=` catches `null` as well as `undefined`, so an explicit clear would become an absent key.
Mobile merges session frames (`{...prev, ...next}`), and a merge cannot express a removed key, so an absent field keeps its previous value and the indicator latches on a finished turn — the failure tb-mobile PR #647 shipped.
A client that ignores the field behaves exactly as today; a client that renders it must treat an unrecognised value as "no phase" rather than coercing it, because the union will grow.
It deliberately does **not** add a `SessionStatus` value: `VALID_STATUSES` rejects unknown values and `?status=` filtering would make those sessions vanish from already-shipped apps.
- WebSocket — new event `session_phase`: `{ type, sessionId, phase: AgentPhase | null, updatedAt }`, scoped to that session's subscribers rather than broadcast globally.
Purely additive; a client that never handles it behaves as today, and `subStatus` on the session object stays the source of truth for the GET path and for reconnect.
It is a minimal frame rather than a `SessionResponse` copy on purpose: `managedToResponse` recomputes `elapsedMs` from `new Date()` on every call for a live session, so a session-copy frame would differ on every tick whether or not the phase changed, and a merging client would get a fresh object identity several times a second for the whole turn.
`phase` follows the same always-present/nullable contract as the field.
- **pty-host protocol version 3** (private streamer-to-host protocol; no mobile-visible change): adds the `phase-change` event. The detectors run in the host, so without it the indicator silently no-ops when `THREADBASE_FEATURE_PTY_HOST=1`.
- Session — new value on the existing optional `lifecycle` field: `"starting"`, for a managed session this run holds no PTY for and has observed no exit for (no `completedAt`, no `failureReason`).
It replaces the `"completed"` those sessions used to report, which made "has not attached yet" and "has ended" the same value on the wire — the ambiguity tb-mobile #508 needs resolved.
`status`, `ptyAttached` and every other field are unchanged, and `lifecycle` was already optional, so a client that does not know the value behaves exactly as it does today; adopting it is tracked in tb-mobile #508.
Expand Down
2 changes: 2 additions & 0 deletions src/pty-host/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export class SessionHost {
onReady: (session) => this.emit({ type: "event", event: "ready", session }),
onPermissionChange: (sessionId, gate) =>
this.emit({ type: "event", event: "permission-change", sessionId, gate }),
onPhaseChange: (sessionId, phase) =>
this.emit({ type: "event", event: "phase-change", sessionId, phase }),
onLiveQuestion: (sessionId, questions) =>
this.emit({ type: "event", event: "live-question", sessionId, questions }),
onLiveQuestionGone: (sessionId) =>
Expand Down
14 changes: 12 additions & 2 deletions src/pty-host/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ProviderName } from "../providers";
import type {
AgentPhase,
AskQuestion,
ManagedSession,
PermissionOption,
Expand Down Expand Up @@ -43,9 +44,12 @@ import type {

/**
* Bumped on any incompatible change to the shapes below. Version 2 adds the
* heartbeat and shutdown controls required for host supervision.
* heartbeat and shutdown controls required for host supervision. Version 3
* adds the `phase-change` event — the detector runs in the host, so without a
* verb here the agent-phase indicator silently stops working when the flag is
* on, which is exactly the failure this file's header warns about.
*/
export const PTY_HOST_PROTOCOL_VERSION = 2;
export const PTY_HOST_PROTOCOL_VERSION = 3;

export interface HostHeartbeatState {
registryState: "known" | "unknown";
Expand Down Expand Up @@ -119,6 +123,12 @@ export type HostEvent =
cursor?: number;
} | null;
}
/**
* Agent phase changed within a running turn, including to `null` at turn
* end. Carries the same always-present/nullable contract as the wire field:
* absence must never mean "cleared", because the consumer merges state.
*/
| { type: "event"; event: "phase-change"; sessionId: string; phase: AgentPhase | null }
| { type: "event"; event: "live-question"; sessionId: string; questions: AskQuestion[] }
| { type: "event"; event: "live-question-gone"; sessionId: string }
| { type: "event"; event: "user-message"; sessionId: string; text: string; ts: number }
Expand Down
3 changes: 3 additions & 0 deletions src/pty-host/remote-session-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ export class RemoteSessionRunner implements SessionRunner {
case "permission-change":
this.options.onPermissionChange?.(event.sessionId, event.gate);
break;
case "phase-change":
this.options.onPhaseChange?.(event.sessionId, event.phase);
break;
case "live-question":
this.options.onLiveQuestion?.(event.sessionId, event.questions);
break;
Expand Down
38 changes: 38 additions & 0 deletions src/pty-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
questionContentKey,
} from "./services/questions/detectQuestionFromScreen";
import { detectShellPrompt } from "./services/questions/detectShellPrompt";
import { parseAgentPhase } from "./services/questions/parseAgentPhase";
import type {
AgentPhase,
ManagedSession,
PTYManagerOptions,
SessionRunner,
Expand Down Expand Up @@ -211,6 +213,7 @@ export class PTYManager implements SessionRunner {
private onStatusChange: PTYManagerOptions["onStatusChange"];
private onReady: PTYManagerOptions["onReady"];
private onPermissionChange: PTYManagerOptions["onPermissionChange"];
private onPhaseChange: PTYManagerOptions["onPhaseChange"];
private onLiveQuestion: PTYManagerOptions["onLiveQuestion"];
private onLiveQuestionGone: PTYManagerOptions["onLiveQuestionGone"];
private onUserMessage: PTYManagerOptions["onUserMessage"];
Expand Down Expand Up @@ -273,6 +276,7 @@ export class PTYManager implements SessionRunner {
this.onStatusChange = options.onStatusChange;
this.onReady = options.onReady;
this.onPermissionChange = options.onPermissionChange;
this.onPhaseChange = options.onPhaseChange;
this.onLiveQuestion = options.onLiveQuestion;
this.onLiveQuestionGone = options.onLiveQuestionGone;
this.onUserMessage = options.onUserMessage;
Expand Down Expand Up @@ -941,6 +945,16 @@ export class PTYManager implements SessionRunner {

const lines = await this.getOutputLines(sessionId, 60);

// Phase refinement, off the screen read the detectors below already share.
// Only while the turn is running: the phase describes what the agent is
// doing *within* `running`, and markReady clears it at turn end. Reading it
// in any other state would let a stale screen re-assert a phase after the
// clear, which is the latch this design exists to prevent.
const phaseSession = this.sessions.get(sessionId);
if (phaseSession?.status === "running") {
this.setPhase(sessionId, phaseSession, parseAgentPhase(lines, CLAUDE_CODE_PROVIDER));
}

// Authoritative footer test on the FULL rendered screen (not just the
// trigger chunk). The "Enter to select · Tab/Arrow keys to navigate" footer
// is unique to an AskUserQuestion menu; a permission gate uses "Tab to amend
Expand Down Expand Up @@ -1165,6 +1179,21 @@ export class PTYManager implements SessionRunner {

// Transition a session from "running" to "waiting_input", clear pendingReady,
// and flush any queued input. Idempotent: callers can invoke at any chunk.
/**
* Record the agent's phase and notify only on a real change.
*
* The change guard is load-bearing, not an optimisation: the scrape pass runs
* on every chunk, so an unguarded setter would fire the callback — and the
* WS frame behind it — several times a second for the entire duration of a
* turn while reporting the same value.
*/
private setPhase(sessionId: string, session: InternalSession, phase: AgentPhase | null): void {
const next = phase ?? null;
if ((session.subStatus ?? null) === next) return;
session.subStatus = next;
this.onPhaseChange?.(sessionId, next);
}

private markReady(
sessionId: string,
session: InternalSession,
Expand All @@ -1174,6 +1203,15 @@ export class PTYManager implements SessionRunner {
this.clearReadyFallback(sessionId);
session.lastActivityAt = new Date();
session.status = "waiting_input";
// Turn end clears the phase. This is the only place it can happen
// correctly: the exit edge is not an output event, so it cannot be read off
// the screen — Claude's TUI does differential repaints and a return to idle
// can go undetected forever if the last chunk didn't carry the marker.
// Without this the phase latches on any session that stops emitting, which
// is the bug tb-mobile PR #647 shipped. markReady is the single idempotent
// running -> waiting_input transition, and it is driven by the waiting-for-
// input OSC, which arrives even when no further chunk will.
this.setPhase(sessionId, session, null);
// C3: record HOW we concluded this, not just that we did. A
// `timeout-fallback` here means no marker ever appeared and we assumed —
// previously indistinguishable on the wire from an observed marker.
Expand Down
12 changes: 12 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,18 @@ export class StreamerServer {
seq,
});
},
onPhaseChange: (sessionId, phase) => {
// Scoped to this session's subscribers and sent as a minimal frame —
// NOT routed through onStatusChange's handler, which writes a DB row,
// refreshes the scanner index, broadcasts globally and pokes the APNs
// and push notifiers on every call. This can fire every scrape tick.
this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
type: "session_phase",
sessionId,
phase,
updatedAt: new Date().toISOString(),
});
},
onUserMessage: (sessionId, text, ts) => {
this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
type: "user_message",
Expand Down
Loading
Loading