|
| 1 | +<!-- |
| 2 | + Licensed to the Apache Software Foundation (ASF) under one |
| 3 | + or more contributor license agreements. See the NOTICE file |
| 4 | + distributed with this work for additional information |
| 5 | + regarding copyright ownership. The ASF licenses this file |
| 6 | + to you under the Apache License, Version 2.0 (the |
| 7 | + "License"); you may not use this file except in compliance |
| 8 | + with the License. You may obtain a copy of the License at |
| 9 | +
|
| 10 | + http://www.apache.org/licenses/LICENSE-2.0 |
| 11 | +
|
| 12 | + Unless required by applicable law or agreed to in writing, |
| 13 | + software distributed under the License is distributed on an |
| 14 | + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 15 | + KIND, either express or implied. See the License for the |
| 16 | + specific language governing permissions and limitations |
| 17 | + under the License. |
| 18 | +--> |
| 19 | + |
| 20 | +# TUI live ctx updates (#4545) |
| 21 | + |
| 22 | +Status: design. Issue: https://github.com/apache/maka/issues/4545 |
| 23 | + |
| 24 | +## Problem |
| 25 | + |
| 26 | +The TUI statusline `ctx used/window pct%` segment updates **once per turn**, when |
| 27 | +the turn fully ends. During a long agentic turn — dozens of tool steps over |
| 28 | +minutes, exactly when the context grows fastest — the indicator sits stale at |
| 29 | +the previous turn's value, so the user loses the signal that says "time to |
| 30 | +`/compact` or wrap up". |
| 31 | + |
| 32 | +## Audit: how it works today |
| 33 | + |
| 34 | +Every claim below was verified against `main` (`6c632b1339`). |
| 35 | + |
| 36 | +### Current TUI path (push, once per turn) |
| 37 | + |
| 38 | +| # | Claim | Evidence | |
| 39 | +|---|-------|----------| |
| 40 | +| 1 | The ctx segment renders `used = modelContextWindow - usage.contextRemaining`; window comes from the model catalog | `packages/cli/src/pi-transcript.ts` L1678–1693 (`renderMakaPiStatusLine`), window wired at `packages/cli/src/pi-tui-runner.ts` L618, L1266 | |
| 41 | +| 2 | `usage.contextRemaining` is only written by `accumulateUsage`, reached from stored messages (transcript rebuild) or a live `token_usage` SessionEvent | `packages/cli/src/pi-transcript.ts` L248–267 (`accumulateUsage`), L472, L751, L1003 | |
| 42 | +| 3 | The runtime emits `token_usage` with `contextRemaining` exactly once per send, in the *Final usage event* block after the agent loop breaks | `packages/runtime/src/ai-sdk-backend.ts` ~L2738–2800 | |
| 43 | +| 4 | Mid-turn, every `step-finish` boundary already captures `stepUsage.inputTokens` into `lastStepInputTokens` — but it only feeds the end-of-turn computation and the durable `recordUsageCheckpoint` hook, which is fire-and-forget persistence, not a live event | `packages/runtime/src/ai-sdk-backend.ts` L2181–2202; hook contract L751–753 | |
| 44 | +| 5 | `/context` is refused mid-turn, but the gate is the TUI's own `runControl` serial lock (exists to stop prompts racing session/model switches), not a protocol limit | `packages/cli/src/pi-tui-runner.ts` L3261–3265, L882–914 | |
| 45 | + |
| 46 | +### Desktop prior art (pull, per settled request) |
| 47 | + |
| 48 | +| # | Claim | Evidence | |
| 49 | +|---|-------|----------| |
| 50 | +| 6 | The Host commits a latest-context snapshot at **every provider request settlement** (each LLM step), carrying `inputTokens` and `contextWindow` | `packages/runtime/src/provider-request-telemetry.ts` `finalize` → `emitModelCallAttempt` → `accounting.record({ attempt, latestContext })` (~L469–640); `packages/runtime/src/latest-context-snapshot.ts` | |
| 51 | +| 7 | The commit is awaited **before** the `finish` part is enqueued to the consumer, so any UI event that follows the step (e.g. `tool_start`) observes the snapshot already durable — no read race | `packages/runtime/src/provider-request-telemetry.ts` stream `pull` handler ~L368–390 | |
| 52 | +| 8 | `context.diagnostics.query` is a plain read: header snapshot + run-store projection read; no execution authority, no busy gate | `packages/runtime-host/src/server/context-coordinator.ts` `#queryDiagnostics`; spec `mode: 'query'` in `packages/runtime-host/src/protocol/context.ts` L107–117 | |
| 53 | +| 9 | The desktop inspector subscribes to the live session event stream and re-reads the diagnostics on trace-relevant events (`tool_start`, `tool_result`, `token_usage`, `provider_retry`, `error`, `complete`, `abort`), coalesced at 400 ms; a failed re-read leaves the last value standing | `apps/desktop/src/renderer/session-trace-refresh.ts` L21–37; `apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts` L59 (`TRACE_REFRESH_DEBOUNCE_MS = 400`), L255–274 | |
| 54 | +| 10 | Desktop derives the bar as `used = inputTokens`, `ratio = used / contextWindow`, from the snapshot alone | `session-inspector-overview-model.ts` `contextBudget()` ~L210–241 | |
| 55 | +| 11 | The TUI driver already exposes the same query; the TUI always talks to the Host | `packages/cli/src/runtime-host-session-driver.ts` L1117 (`getContextDiagnostics`); interface `packages/cli/src/session-driver.ts` L230 (optional) | |
| 56 | +| 12 | The TUI runner's `onEvent` sees every live event mid-turn | `packages/cli/src/pi-tui-runner.ts` L1455–1483 | |
| 57 | + |
| 58 | +Semantics line up: the statusline's `contextRemaining = window − lastStepInputTokens` |
| 59 | +(#1067) and the snapshot's `inputTokens` describe the same settled request, so |
| 60 | +`contextRemaining ≡ diagnostics.contextWindow − diagnostics.inputTokens`. |
| 61 | + |
| 62 | +## Design: reuse the desktop pull model in the TUI |
| 63 | + |
| 64 | +Add a live-refresh hook to the TUI runner. No protocol, runtime, or persistence |
| 65 | +changes. |
| 66 | + |
| 67 | +### New module: `packages/cli/src/tui-context-refresh.ts` |
| 68 | + |
| 69 | +- `isCtxRefreshRelevantEvent(event: SessionEvent): boolean` — same event set as |
| 70 | + desktop's `TRACE_RELEVANT_EVENT_TYPES` (audit #9). `tool_start`/`tool_result` |
| 71 | + are the mid-turn step boundaries; the rest close or annotate the turn. |
| 72 | + Keeping the set identical to desktop's keeps one answer to "when is the |
| 73 | + context worth re-reading". |
| 74 | +- `createCtxRefresher({ query, apply, delayMs, schedule, cancel })` — a |
| 75 | + restart-on-call debounce with a monotonic revision counter, mirroring |
| 76 | + desktop's `createRefreshCoalescer` plus the `contextRevisionRef` guard: |
| 77 | + only the latest issued query may apply; a late or failed resolution leaves |
| 78 | + the current value standing (audit #9). Clock and timer injected, following |
| 79 | + the runner's existing `shellRunTicker` seam, so tests drive it |
| 80 | + deterministically. |
| 81 | + |
| 82 | +### Wiring in `pi-tui-runner.ts` |
| 83 | + |
| 84 | +In `onEvent` (audit #12), after `applyMakaSessionEventToTranscript`: |
| 85 | + |
| 86 | +1. `if (isCtxRefreshRelevantEvent(event)) ctxRefresher.request()`. |
| 87 | +2. The refresher calls `input.driver.getContextDiagnostics?.()` directly — |
| 88 | + deliberately **not** through `runControl`, whose serial lock exists for |
| 89 | + mutations (audit #5). |
| 90 | +3. On `status: 'available'` with both `inputTokens` and `contextWindow` |
| 91 | + present, set `state.usage.contextRemaining = contextWindow − inputTokens` |
| 92 | + and `requestRender()`. The statusline keeps its existing formula, color |
| 93 | + thresholds, and degradation states untouched; the catalog window stays the |
| 94 | + displayed denominator, matching what the `token_usage` path already does |
| 95 | + (both windows derive from the selected model's metadata). |
| 96 | +4. Stale-session guard: the query captures `driver.getSessionId()` at request |
| 97 | + time and `apply` drops the result when it changed — session switches reset |
| 98 | + `state.usage` (`replaceTranscript`), and a pre-switch value must not land |
| 99 | + afterwards. This guard covers every switch path uniformly, so no per-switch |
| 100 | + cancellation wiring is needed; a refresh scheduled across a switch simply |
| 101 | + queries the adopted session, which is the value the statusline should show. |
| 102 | +5. Lifecycle: `ctxRefresher.cancel()` on teardown (alongside the existing |
| 103 | + ticker disposal), which also retires any in-flight query. |
| 104 | +6. Event coverage: every live turn drains through `runMakaPiTuiTurn`'s |
| 105 | + `onEvent` (user-submitted and Host-attached turns alike) and the |
| 106 | + `resumeLatest` loop — both hooked. `/compact` is deliberately not hooked: |
| 107 | + its own `token_usage` already writes the authoritative post-compact value. |
| 108 | + |
| 109 | +The end-of-turn `token_usage` event stays the authoritative **persisted** |
| 110 | +record; the pull only enriches the live turn. Both derive from the same |
| 111 | +settled request, so they cannot disagree. |
| 112 | + |
| 113 | +### Out of scope (recorded, not forgotten) |
| 114 | + |
| 115 | +- Unlocking `/context` mid-turn over the same query path — a free follow-up, |
| 116 | + kept out of this PR to stay small. |
| 117 | +- Desktop needs nothing; it already has this granularity. |
| 118 | +- Token-level updates during one streaming request: providers only report |
| 119 | + input tokens at completion, so exact mid-request values do not exist; the |
| 120 | + pre-dispatch `bytes/4` estimate is too rough (base64 attachments) to show. |
| 121 | + |
| 122 | +### Why not a new push event |
| 123 | + |
| 124 | +- Protocol surface: a new SessionEvent type touches the core schema, the |
| 125 | + backend emission point, the host mapper, and rebuild/persistence semantics. |
| 126 | +- It creates a second derivation of the same number; pull keeps TUI and |
| 127 | + desktop on one source of truth (the snapshot row), so resume / backfill / |
| 128 | + compact edge cases cannot drift between two paths. |
| 129 | +- Reusing `token_usage` with partial fields was rejected: `accumulateUsage` |
| 130 | + treats it as cumulative billing input, and "incomplete usage is no usage" |
| 131 | + (#972). |
| 132 | + |
| 133 | +## Test plan (`packages/cli/src/__tests__/`) |
| 134 | + |
| 135 | +- Mid-turn `tool_start` with a diagnostics result → statusline ctx reflects |
| 136 | + the new value before turn end. |
| 137 | +- Debounce: a burst of relevant events within the window issues one query. |
| 138 | +- Revision guard: two overlapping queries resolve out of order → the older |
| 139 | + resolution is dropped. |
| 140 | +- Query failure / `status: 'unavailable'` → previous value stands. |
| 141 | +- Session switch between request and resolution → value not applied. |
| 142 | +- Driver without `getContextDiagnostics` (optional method) → no-op, no crash. |
| 143 | +- Turn-end `token_usage` still lands exactly as today (regression guard on |
| 144 | + `accumulateUsage`). |
0 commit comments