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:
@@ -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>
|
||||
|
||||
@@ -207,25 +207,15 @@ describe('small branch tails', () => {
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// cacheHitPct is null only when input+cacheRead are both zero (pure
|
||||
// output accounting) — any input makes it a real 0%.
|
||||
const snap = {
|
||||
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
|
||||
modelRequest: null,
|
||||
}
|
||||
const usage = {
|
||||
uncachedInputTokens: 0,
|
||||
outputTokens: 10,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 5_000,
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
|
||||
useProjection={(() => usage)}
|
||||
/>,
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
|
||||
)
|
||||
expect(view.getByText(
|
||||
'0 uncached input · 10 output · 0 cache read · context unknown · 1 turns · 1 steps',
|
||||
)).toBeTruthy()
|
||||
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ function snapshotWith(
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,17 +390,6 @@ describe('ChatView', () => {
|
||||
const loading = makeHarness({ openState: 'loading' })
|
||||
const lv = render(<loading.ChatView {...loading.props} />)
|
||||
expect(lv.getByText('载入历史…')).toBeTruthy()
|
||||
const reconnecting = makeHarness({
|
||||
openState: 'error',
|
||||
openError: {
|
||||
code: 'cancelled',
|
||||
message: 'session history request cancelled after connection loss',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
const rv = render(<reconnecting.ChatView {...reconnecting.props} />)
|
||||
expect(rv.getByText('连接已中断,等待重连…')).toBeTruthy()
|
||||
expect(rv.queryByText(/session history request/)).toBeNull()
|
||||
})
|
||||
|
||||
it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('render branch tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('StatsLine takes durable counters from tokenUsage while keeping visible node counts', () => {
|
||||
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
|
||||
const snap = {
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
|
||||
@@ -44,24 +44,12 @@ describe('render branch tails', () => {
|
||||
// outputTokens absent: the tokens sum's ?? 0 arm for output.
|
||||
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
],
|
||||
modelRequest: null,
|
||||
}
|
||||
const usage = {
|
||||
uncachedInputTokens: 9,
|
||||
outputTokens: 6,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
|
||||
useProjection={(() => usage)}
|
||||
/>,
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
|
||||
)
|
||||
expect(view.getByText(
|
||||
'9 uncached input · 6 output · 0 cache read · cache hit 0% · context unknown · 2 turns · 3 steps',
|
||||
)).toBeTruthy()
|
||||
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
const props: InputBarProps = {
|
||||
sessionId: SID,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
const barProps: InputBarProps = {
|
||||
sessionId,
|
||||
|
||||
@@ -20,7 +20,7 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
|
||||
Reference in New Issue
Block a user