| 1 | // Read progress is host-owned, keyed by read id, and upserted: a hundred pages |
| 2 | // of one logical read still render one status line, never a hundred notices. |
| 3 | |
| 4 | /** WireReadStatus is one read's delivery state; zero-based half-open ranges, no text. */ |
| 5 | export interface WireReadStatus { |
| 6 | readId: string; |
| 7 | generation?: number; |
| 8 | seq?: number; |
| 9 | path: string; |
| 10 | intent?: string; |
| 11 | state: string; |
| 12 | verdict?: "partial_read_sufficient" | "full_read_pending" | "read_hard_stop"; |
| 13 | covered?: [number, number][]; |
| 14 | missing?: [number, number][]; |
| 15 | sourceEnd?: number; |
| 16 | hasMore?: boolean; |
| 17 | reason?: string; |
| 18 | recovery?: string; |
| 19 | active?: boolean; |
| 20 | } |
| 21 | |
| 22 | /** Host recovery metadata, never an executable permission or read receipt. */ |
| 23 | export interface OperationDiagnostic { |
| 24 | code: string; |
| 25 | path?: string; |
| 26 | operation_id?: string; |
| 27 | expected_snapshot?: string; |
| 28 | actual_snapshot?: string; |
| 29 | required_ranges?: { start: number; end: number }[]; |
| 30 | recovery: string; |
| 31 | /** Host-issued receipt ids the model may cite instead of retyping a command. */ |
| 32 | available_receipts?: string[]; |
| 33 | /** Closed set of actions the host accepts, e.g. "use_receipt:r_123". */ |
| 34 | allowed_recovery?: string[]; |
| 35 | retryable?: boolean; |
| 36 | retry_budget?: number; |
| 37 | /** Operation lifecycle state; "needs_user" means the host stopped retrying. */ |
| 38 | state?: string; |
| 39 | } |
| 40 | |
| 41 | export type ReadStatusHost = { readStatuses?: Record<string, WireReadStatus>; readStatusClosed?: boolean }; |
| 42 | |
| 43 | export function applyReadStatusEvent<T extends ReadStatusHost & { activeTurnId?: string; turnActive: boolean }>(state: T, event: { turnId?: string; readStatus?: WireReadStatus }): T { |
| 44 | if (state.readStatusClosed || (state.activeTurnId && !state.turnActive)) return state; |
| 45 | if (event.turnId && state.activeTurnId && event.turnId !== state.activeTurnId) return state; |
| 46 | return applyReadStatusFrame(state, event.readStatus); |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * applyReadStatusFrame upserts one frame. A re-ordered frame never moves a read |
| 51 | * backwards, and an unnamed frame is ignored. |
| 52 | */ |
| 53 | export function applyReadStatusFrame<T extends ReadStatusHost>(state: T, incoming: WireReadStatus | undefined): T { |
| 54 | if (!incoming?.readId) return state; |
| 55 | const previous = state.readStatuses?.[incoming.readId]; |
| 56 | if (previous) { |
| 57 | const oldGeneration = previous.generation ?? 0; |
| 58 | const generation = incoming.generation ?? 0; |
| 59 | if (generation < oldGeneration) return state; |
| 60 | if (generation === oldGeneration && (incoming.seq ?? 0) <= (previous.seq ?? 0)) return state; |
| 61 | } |
| 62 | return { ...state, readStatuses: { ...(state.readStatuses ?? {}), [incoming.readId]: incoming } }; |
| 63 | } |
| 64 | |
| 65 | /** ReadStatusKey is the closed set of labels one read status can produce. */ |
| 66 | export type ReadStatusKey = |
| 67 | | "composer.readStatusReading" |
| 68 | | "composer.readStatusCovered" |
| 69 | | "composer.readStatusDone" |
| 70 | | "composer.readStatusPaused" |
| 71 | | "composer.readStatusBudget" |
| 72 | | "composer.readStatusSource" |
| 73 | | "composer.readStatusStalled" |
| 74 | | "composer.readStatusRecovery"; |
| 75 | |
| 76 | /** readStatusLabel renders every active read as one short status line. */ |
| 77 | export function readStatusLabel( |
| 78 | statuses: Record<string, WireReadStatus> | undefined, |
| 79 | t: (key: ReadStatusKey, vars?: Record<string, string | number>) => string, |
| 80 | ): string { |
| 81 | const active = Object.values(statuses ?? {}).filter((status) => status.active); |
| 82 | return active.map((status) => readStatusItemLabel(status, t)).join(" · "); |
| 83 | } |
| 84 | |
| 85 | function readStatusItemLabel(first: WireReadStatus, t: (key: ReadStatusKey, vars?: Record<string, string | number>) => string): string { |
| 86 | const file = first.path.split(/[\\/]/).pop() || first.path; |
| 87 | const covered = first.covered?.length |
| 88 | ? first.covered.map(([start, end]) => `${start + 1}–${end}`).join(", ") |
| 89 | : ""; |
| 90 | if (first.state === "blocked" || first.state === "needs_scope") { |
| 91 | const reason = first.reason === "no_progress" ? "composer.readStatusStalled" |
| 92 | : ["page_budget", "time_budget", "no_headroom", "unknown_window"].includes(first.reason ?? "") ? "composer.readStatusBudget" |
| 93 | : "composer.readStatusSource"; |
| 94 | return [t("composer.readStatusPaused", { file }), t(reason), t("composer.readStatusRecovery")].join(" · "); |
| 95 | } |
| 96 | if (first.hasMore) { |
| 97 | return covered ? t("composer.readStatusCovered", { file, range: covered }) : t("composer.readStatusReading", { file }); |
| 98 | } |
| 99 | return covered ? t("composer.readStatusDone", { file, range: covered }) : t("composer.readStatusReading", { file }); |
| 100 | } |
| 101 | |
| 102 | /** TurnPhaseKey is the closed set of phase labels the composer can show. */ |
| 103 | export type TurnPhaseKey = |
| 104 | | "composer.turnPhaseChecking" |
| 105 | | "composer.turnPhaseVerifying" |
| 106 | | "composer.turnPhaseReviewing" |
| 107 | | "composer.turnPhaseWorking" |
| 108 | | "composer.runAnnounceRunning"; |
| 109 | |
| 110 | /** turnPhaseStatusLabel renders the host turn phase for the status line. */ |
| 111 | export function turnPhaseStatusLabel( |
| 112 | turnPhase: string | undefined, |
| 113 | t: (key: TurnPhaseKey, vars?: Record<string, string | number>) => string, |
| 114 | ): string { |
| 115 | switch ((turnPhase ?? "").trim()) { |
| 116 | case "checking": |
| 117 | return t("composer.turnPhaseChecking"); |
| 118 | case "verifying": |
| 119 | return t("composer.turnPhaseVerifying"); |
| 120 | case "reviewing": |
| 121 | return t("composer.turnPhaseReviewing"); |
| 122 | case "working": |
| 123 | return t("composer.turnPhaseWorking"); |
| 124 | default: |
| 125 | return t("composer.runAnnounceRunning"); |
| 126 | } |
| 127 | } |
| 128 |