refactor(token-meter): make context occupancy durable projection state

Replace the transient `session/model-request` mux frame with ordinary durable
session state. Occupancy now rides two last-wins projection fields instead of a
non-replayable frame that needed removal tombstones and cross-stream fencing.

The frame was the only non-replayable class on the mux stream. Because host and
mux are independent SSE streams with no cross-stream order, a request emitted
before a removal could arrive after `host/session-removed`, and a legitimate
request for a new lifecycle reusing the same id could be fenced by a late
removal. Fixing that needed a lifecycle generation on every frame; the frame
itself was the problem.

Removed: the `session/model-request` frame and schema, the `agent/model-request`
core event, the ApiProxy measurement point, the client-side telemetry map and
removal tombstone, and the synthetic `cancelled` open error used to signal
reconnect through the error channel.

Added: `request/context`, a log-only session event recording the
registration-bound capacity of the route a request resolved to, appended beside
`request/header` from the lookup that already prepared the call and skipped when
the route is unchanged. Capacity stays out of `EpochHeader` because it is
adapter metadata about a route, not an input the request was built from, so it
must not join request reconstruction or header equality.

The `contextPressure` projection pairs the newest provider-reported prompt size
with the newest recorded capacity. The two are deliberately not one atomic
request observation: switching models can pair a fresh capacity with the prior
route's pressure until the next request reports usage. The figure is a
user-facing reference, and this matches how the TUI status line has always
computed occupancy.
This commit is contained in:
Hypatia May
2026-07-30 13:53:08 +08:00
parent fdccc58cef
commit 4819210142
62 changed files with 382 additions and 1362 deletions
@@ -221,9 +221,7 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({
useProjection, useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder,
}: ChatViewSlotProps) {
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -231,8 +229,7 @@ export function ChatView({
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const openState = useSession(s => s.openState)
const openError = useSession(s => s.openError)
const openErrorMessage = openError === null ? null : `${openError.message}(${openError.code})`
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
@@ -356,12 +353,7 @@ export function ChatView({
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
{openState === 'error' && openError?.code === 'cancelled' && (
<div className={css.hint}>连接已中断,等待重连…</div>
)}
{openState === 'error' && openError?.code !== 'cancelled' && (
<div className={css.openError}>历史加载失败:{openErrorMessage}</div>
)}
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
@@ -397,7 +389,7 @@ export function ChatView({
{running && <TurnDots />}
</div>
</div>
<StatsLine useSession={useSession} useProjection={useProjection} />
<StatsLine useSession={useSession} />
{!atBottom && (
<button
type="button"
@@ -1,12 +1,13 @@
// Settled-node identity prevents stream-delta updates from rerendering this row.
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { memo, useMemo } from 'react'
import type {
ConversationSnapshot, UseProjection,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelRequestTelemetry } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import css from './StatsLine.module.css'
interface VisibleCounts {
@@ -57,16 +58,19 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null {
}
/**
* Current context occupancy using the TUI's integer rounding and upper clamp.
* @param request - one atomic request snapshot observed on this mux generation.
* @returns occupancy percent, or null when either input is unavailable.
* Approximate context occupancy, using the TUI's integer rounding and upper
* clamp. The numerator and capacity are independent last-wins projection
* fields, so this is a reference figure rather than an exact request
* measurement (see the token-meter README).
* @param pressure - the session's context-pressure projection value.
* @returns occupancy percent, or null when no capacity is known.
*/
export function contextPercent(request: ModelRequestTelemetry | null): number | null {
if (request?.contextTokens === undefined || request.contextWindow === undefined) return null
return Math.min(100, Math.round(request.contextTokens / request.contextWindow * 100))
export function contextPercent(pressure: ContextPressureProjection | undefined): number | null {
if (pressure?.contextWindow === undefined) return null
return Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100))
}
/** Props: standard session hooks handed down by ChatView. */
/** Props: the framework's session snapshot and projection hook seats. */
export interface StatsLineProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
useProjection: UseProjection
@@ -74,8 +78,8 @@ export interface StatsLineProps {
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const modelRequest = useSession(s => s.modelRequest)
const usage = useProjection('tokenUsage')
const pressure = useProjection('contextPressure')
const counts = useMemo(() => deriveVisibleCounts(nodes), [nodes])
const hasUsage = usage !== undefined && (
usage.uncachedInputTokens !== 0
@@ -83,24 +87,22 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
|| usage.cacheReadTokens !== 0
|| usage.cacheWriteTokens !== 0
)
const context = contextPercent(modelRequest)
const context = contextPercent(pressure)
if (counts.steps === 0 && !hasUsage && context === null) return null
const parts: string[] = []
if (usage === undefined) {
parts.push('usage unknown')
} else {
if (usage !== undefined) {
parts.push(`${formatMetricTokens(usage.uncachedInputTokens)} uncached input`)
parts.push(`${formatMetricTokens(usage.outputTokens)} output`)
parts.push(`${formatMetricTokens(usage.cacheReadTokens)} cache read`)
const cacheHit = cacheHitPercent(usage)
if (cacheHit !== null) parts.push(`cache hit ${cacheHit}%`)
}
// contextPercent validates both fields; repeat the capacity guard so that
// TypeScript carries the same refinement into the formatting branch.
parts.push(context === null || modelRequest?.contextWindow === undefined
? 'context unknown'
: `context ${context}% of ${formatMetricTokens(modelRequest.contextWindow)}`)
// Capacity absent (no token-meter, or an adapter that advertises none) omits
// the segment: an unknown denominator has no percentage worth a placeholder.
if (context !== null && pressure?.contextWindow !== undefined) {
parts.push(`context ${context}% of ${formatMetricTokens(pressure.contextWindow)}`)
}
parts.push(`${counts.turns} turns`)
parts.push(`${counts.steps} steps`)
return <div className={css.root}>{parts.join(' · ')}</div>