refactor(web): project usage and snapshot request context

This commit is contained in:
Hypatia May
2026-07-29 15:27:59 +08:00
parent e37cb23336
commit bf618dabf9
78 changed files with 748 additions and 934 deletions

View File

@@ -12,7 +12,7 @@ export type {
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock,
ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock,
GoalsApi, GoalRef,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'

View File

@@ -15,6 +15,7 @@ import type {
AssistantMessage,
ContentBlock,
MessageSource,
TokenUsage,
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
@@ -103,6 +104,16 @@ function sid(id: string): SessionId {
return id as SessionId
}
/** Deterministic provider billing attached to fixture assistant messages. */
function fixtureUsage(turn: number, step: number): TokenUsage {
return {
inputTokens: 20 + turn % 5,
outputTokens: 8 + step,
cacheReadTokens: turn === 0 ? 0 : 80,
cacheWriteTokens: turn % 10 === 0 ? 4 : 0,
}
}
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / steering / context. */
function buildAlphaLog(): SessionEvent[] {
@@ -110,7 +121,17 @@ function buildAlphaLog(): SessionEvent[] {
let time = Date.now() - 3_600_000
const push = (e: Record<string, unknown>): number => {
const seq = events.length
events.push({ seq, time: (time += 800), ...e })
const data = e['data'] as Record<string, unknown> | undefined
const authored = e['type'] === 'assistant/message' && data !== undefined
? {
...e,
data: {
...data,
usage: fixtureUsage(data['turn'] as number, data['step'] as number),
},
}
: e
events.push({ seq, time: (time += 800), ...authored })
return seq
}
for (let turn = 0; turn < 60; turn++) {
@@ -369,6 +390,60 @@ function permissionSelectOf(
}
}
interface FixtureTokenUsageProjection {
uncachedInputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
/** Fixture parallel of token-meter's last-sample-replacing usage projection. */
function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection {
const totals: FixtureTokenUsageProjection = {
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
let last: {
turn: number
step: number
buckets: FixtureTokenUsageProjection
} | null = null
for (const event of log) {
const item = event as unknown as {
type: string
data: {
turn?: number
step?: number
usage?: TokenUsage
chunk?: { type?: string; usage?: TokenUsage }
}
}
const usage = item.type === 'assistant/chunk' && item.data.chunk?.type === 'usage'
? item.data.chunk.usage
: item.type === 'assistant/message'
? item.data.usage
: undefined
if (usage === undefined || item.data.turn === undefined || item.data.step === undefined) continue
const buckets: FixtureTokenUsageProjection = {
uncachedInputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
}
const previous = last?.turn === item.data.turn && last.step === item.data.step
? last.buckets
: undefined
totals.uncachedInputTokens += buckets.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0)
totals.outputTokens += buckets.outputTokens - (previous?.outputTokens ?? 0)
totals.cacheReadTokens += buckets.cacheReadTokens - (previous?.cacheReadTokens ?? 0)
totals.cacheWriteTokens += buckets.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0)
last = { turn: item.data.turn, step: item.data.step, buckets }
}
return totals
}
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
const values: Record<string, unknown> = {}
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
@@ -383,12 +458,28 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
values['plan'] = planViewOf(log)
// Always present (GoalService unit composed): null before create / after clear.
values['goal'] = backscanGoal(log)
// Always present (token-meter composed): full-log provider billing.
values['tokenUsage'] = tokenUsageOf(log)
return values
}
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
const type = (event as { type: string }).type
if (
(type === 'assistant/chunk'
&& (event as unknown as { data: { chunk?: { type?: string } } }).data.chunk?.type === 'usage')
|| (type === 'assistant/message'
&& (event as unknown as { data: { usage?: TokenUsage } }).data.usage !== undefined)
) {
return [{
type: 'session/projection',
sessionId: id,
key: 'tokenUsage',
value: tokenUsageOf(log),
seq: event.seq,
}]
}
if (type === 'session/title') {
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
@@ -853,7 +944,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
replays.delete(id)
const done = pieces.slice(0, i).join('')
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } })
append(id, {
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn,
step,
message: assistantMessage(text(aborted ? `${done}(已中断)` : done)),
usage: fixtureUsage(turn, step),
},
})
append(id, { type: 'step/end', data: { turn, step } })
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
setRunning(id, false)
@@ -1036,6 +1136,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
const usage = tokenUsageOf(logOf(id))
emitMux({
type: 'session/model-request',
sessionId: id,
turn,
step: 0,
provider: target.provider,
model: target.model,
contextTokens: usage.uncachedInputTokens + usage.outputTokens
+ usage.cacheReadTokens + usage.cacheWriteTokens,
contextWindow: 128_000,
})
startReply(
id,
turn,

View File

@@ -17,7 +17,7 @@ export type {
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock,
ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -84,6 +84,12 @@ describe('createFixtureApi', () => {
},
plan: { active: false, pending: false },
goal: null,
tokenUsage: {
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
} },
})
})
@@ -188,6 +194,20 @@ describe('createFixtureApi', () => {
expect(types).toContain('assistant/chunk')
expect(types).toContain('assistant/message')
expect(types.at(-1)).toBe('turn/end')
expect(frames).toContainEqual({
type: 'session/model-request',
sessionId: id,
turn: 0,
step: 0,
provider: 'deepseek',
model: 'deepseek-v4-flash',
contextTokens: 0,
contextWindow: 128_000,
})
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'tokenUsage'
&& (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true)
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
// Idle cancel: no replay in flight, must not explode; running flips false.
@@ -219,7 +239,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 8) abort.abort()
if (envelopes.length >= 9) abort.abort()
}
return envelopes
}
@@ -227,16 +247,18 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
// Projection baseline frames follow subscribed (domain units + token usage).
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
expect(first[7]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[7]?.rpcId).toBe(first[7]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[8]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[8]?.rpcId).toBe(first[8]?.rpcId)
expect(first.some(envelope => envelope.payload.type === 'session/model-request')).toBe(false)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 879730733f3d89c3962d8c54bcfd53795a980049
README.zh.md: 1b1d7f03d1f5f03c054dfeaa790a9f6f91e0dca2
README.md: ba9a7d455e8a193f23884411eb1928a10f21ddd0
README.zh.md: 873fefca48585efed010589917f2c63653b08e5b

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos` and `title`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. Durable `ConversationSnapshot.metrics` instead comes from the separate history-tail value and live `session/metrics` frames because point-in-time token-meter pressure can advance at the same durable log revision; only nondecreasing log and projection revisions are accepted. `ConversationSnapshot.modelRequestContextWindow` separately retains capacity from the latest `session/model-request` observed on the current mux connection. A later request replaces or clears that value, while `session/subscribed` clears both metrics ordering and capacity; reconnect, restore, and a new subscription therefore show no percentage until another request is observed. Missing metrics remain `null` rather than being inferred from the visible node window.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`, `title`, and `tokenUsage`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. `ConversationSnapshot.modelRequest` separately retains the complete latest `session/model-request` observed on the current mux connection. Each frame replaces the whole snapshot, so omitted numerator or capacity fields clear an earlier value. `SessionManager` buffers one pre-instantiation snapshot, while `session/subscribed`, disconnect, and removal clear resident and pending values; reconnect, restore, and a new subscription therefore show no context percentage until another request is observed. Model selection alone does not alter request telemetry.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos` 与 `title`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。持久的 `ConversationSnapshot.metrics` 则来自独立的 history 尾页值与实时 `session/metrics` 帧,因为即时 token-meter 压力可以在相同持久日志修订号上继续变化;客户端只接受日志修订号与投影修订号均不减小的数据。`ConversationSnapshot.modelRequestContextWindow` 另行保留当前 mux 连接观察到的最新 `session/model-request` 容量。后续请求会替换或清除该值,`session/subscribed` 则同时清除指标顺序状态与容量;因此,重连、恢复和新订阅都不会显示百分比,直到观察到另一次请求。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`、`title` 与 `tokenUsage`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。`ConversationSnapshot.modelRequest` 另行保留当前 mux 连接观察到的最新完整 `session/model-request`。每个帧都会替换整个快照,因此分子或容量字段一旦缺失,就会清除先前值。`SessionManager` 会缓冲一个实例化前快照;`session/subscribed`、断开连接和移除会话则会清除常驻值与待处理值;因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。
## Workspace 与 Session 列表

View File

@@ -6,7 +6,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type {
RpcError, SessionId, SessionMetrics, ToolCallView, ToolResultView,
ModelRequestTelemetry, RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
@@ -267,16 +267,6 @@ export interface ConversationSnapshot {
*/
blank: boolean
lastAgentError: string | null
/**
* Host-owned cumulative usage/current pressure. Independent of `nodes`
* pagination; null until a tail response or live metrics frame supplies a
* current durable value.
*/
metrics: SessionMetrics | null
/**
* Capacity from the latest model-request attempt observed on this mux
* generation. Absent before the first such request, after a request whose
* registration exposes no capacity, and after `session/subscribed`.
*/
modelRequestContextWindow?: number
/** Latest atomic model-request snapshot on this mux generation. */
modelRequest: ModelRequestTelemetry | null
}

View File

@@ -2,7 +2,10 @@
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
HostFrame, IApiClient, ModelRequestTelemetry, MuxFrame, RpcError, RpcRequest,
RpcResult, SessionId, SessionSummary, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -58,11 +61,11 @@ export class SessionManager {
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
/**
* Latest model capacity observed for an uninstantiated session on the
* Latest request telemetry observed for an uninstantiated session on the
* current mux generation. Unlike durable history, this transient frame
* cannot be backfilled when get() lazily creates the Session.
*/
private readonly modelRequestContextWindows = new Map<SessionId, number>()
private readonly modelRequests = new Map<SessionId, ModelRequestTelemetry>()
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
* replays of the same requested frame). Manager-owned rather than read off Session instances
* because the sidebar must light up for sessions never instantiated. Cleared per connection
@@ -171,7 +174,7 @@ export class SessionManager {
}
private createSession(sessionId: SessionId): Session {
const modelRequestContextWindow = this.modelRequestContextWindows.get(sessionId)
const modelRequest = this.modelRequests.get(sessionId)
return new Session(sessionId, this.api, {
// The sender's local first-send flip mirrors into the list row so the
// session surfaces (lists filter on blank) before any host frame lands.
@@ -179,7 +182,7 @@ export class SessionManager {
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
},
projections: this.projectionStore(sessionId),
...(modelRequestContextWindow === undefined ? {} : { modelRequestContextWindow }),
...(modelRequest === undefined ? {} : { modelRequest }),
})
}
@@ -355,13 +358,13 @@ export class SessionManager {
return
}
if (frame.type === 'session/model-request') {
// Transient and non-replayable: retain the latest capacity until lazy
// instantiation. An absent value explicitly clears an earlier one.
if (frame.contextWindow === undefined) this.modelRequestContextWindows.delete(frame.sessionId)
else this.modelRequestContextWindows.set(frame.sessionId, frame.contextWindow)
// Transient and non-replayable: retain the whole latest request until
// lazy instantiation. Missing fields replace rather than inherit.
const { type: _type, sessionId, ...modelRequest } = frame
this.modelRequests.set(sessionId, modelRequest)
}
if (frame.type === 'session/subscribed') {
this.modelRequestContextWindows.delete(frame.sessionId)
this.modelRequests.delete(frame.sessionId)
// Rows past the host's durable baseline rode state a restart lost; drop
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
@@ -440,7 +443,7 @@ export class SessionManager {
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.modelRequestContextWindows.delete(frame.sessionId) // connection-local request capacity dies with the Host session
this.modelRequests.delete(frame.sessionId) // connection-local request telemetry dies with the Host session
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
return
@@ -481,7 +484,7 @@ export class SessionManager {
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
else this.pendingBuffers.set(sessionId, kept)
}
this.modelRequestContextWindows.clear()
this.modelRequests.clear()
for (const session of this.sessions.values()) session.handleReconnecting()
}

View File

@@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, SessionMetrics, ToolEventView,
ModelRequestTelemetry, SessionId, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -43,8 +43,8 @@ export interface SessionOptions {
* private store (bare object-layer construction).
*/
projections?: ProjectionValueStore
/** Model capacity already observed on this mux generation before lazy construction. */
modelRequestContextWindow?: number
/** Request telemetry already observed on this mux generation before lazy construction. */
modelRequest?: ModelRequestTelemetry
}
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
@@ -110,10 +110,8 @@ export class Session implements SessionFace {
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** Host-owned durable usage/current-pressure projection. */
private metrics: SessionMetrics | null = null
/** Latest capacity observed on this mux connection, independent of durable metrics arrival. */
private contextWindow: number | undefined
/** Latest atomic request snapshot observed on this mux connection. */
private modelRequest: ModelRequestTelemetry | null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
@@ -175,7 +173,7 @@ export class Session implements SessionFace {
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.contextWindow = options.modelRequestContextWindow
this.modelRequest = options.modelRequest ?? null
this.snapshotCache = this.buildSnapshot()
}
@@ -331,10 +329,10 @@ export class Session implements SessionFace {
* in-flight open first — its history request rode the dead connection and must not settle
* the fresh generation into 'error' (audit S4). */
async resync(): Promise<void> {
// Queue, metrics, and request capacity are NOT cleared here: onConnected
// Queue and request telemetry are NOT cleared here: onConnected
// (which drives resync) races the mux frames — fresh-generation state may
// have landed already, and the host never resends it. session/subscribed
// owns the generation reset before the queue snapshot and metrics frames.
// have landed already, and the host never resends request telemetry.
// session/subscribed owns the reset before the queue snapshot.
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
this.openGeneration++
this.openPromise = null
@@ -415,24 +413,22 @@ export class Session implements SessionFace {
this.queueRev++
changed = true
}
if (this.contextWindow !== undefined) {
this.contextWindow = undefined
changed = true
}
if (this.metrics !== null) {
this.metrics = null
if (this.modelRequest !== null) {
this.modelRequest = null
changed = true
}
if (changed) this.notifier.markDirty()
return
}
case 'session/metrics': {
this.installMetrics(frame.metrics)
return
}
case 'session/model-request': {
if (this.contextWindow === frame.contextWindow) return
this.contextWindow = frame.contextWindow
const {
type: _type,
sessionId: _sessionId,
...modelRequest
} = frame
// Whole-frame replacement is load-bearing: an omitted numerator or
// capacity clears that field from the preceding request.
this.modelRequest = modelRequest
this.notifier.markDirty()
return
}
@@ -508,17 +504,16 @@ export class Session implements SessionFace {
/** Connection-loss boundary: clear values that are not replayed before the next stream starts. */
handleReconnecting(): void {
this.openGeneration++
if (this.metrics === null && this.contextWindow === undefined) return
this.metrics = null
this.contextWindow = undefined
if (this.modelRequest === null) return
this.modelRequest = null
this.notifier.markDirty()
}
/** host/session-removed relay: flag the resident snapshot and clear connection-local capacity. */
/** host/session-removed relay: flag the resident snapshot and clear request telemetry. */
handleRemoved(): void {
const changed = !this.removed || this.contextWindow !== undefined
const changed = !this.removed || this.modelRequest !== null
this.removed = true
this.contextWindow = undefined
this.modelRequest = null
if (changed) this.notifier.markDirty()
}
@@ -567,7 +562,6 @@ export class Session implements SessionFace {
result.value.events,
result.value.hasMore,
result.value.projections,
result.value.metrics,
)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
@@ -579,7 +573,6 @@ export class Session implements SessionFace {
result.value.events,
result.value.hasMore,
result.value.projections,
result.value.metrics,
)
}
}
@@ -606,7 +599,6 @@ export class Session implements SessionFace {
entries: HistoryEntry[],
hasMore: boolean,
projections: ProjectionsBaseline | undefined,
metrics: SessionMetrics | undefined,
): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
@@ -615,7 +607,6 @@ export class Session implements SessionFace {
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
if (metrics !== undefined) this.installMetrics(metrics)
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
@@ -668,7 +659,6 @@ export class Session implements SessionFace {
result.value.events,
result.value.hasMore,
result.value.projections,
result.value.metrics,
)
}
} catch (error) {
@@ -854,20 +844,6 @@ export class Session implements SessionFace {
return tail === undefined ? null : tail.seq
}
/** Install a metrics snapshot unless a newer durable or publication revision already landed. */
private installMetrics(metrics: SessionMetrics): void {
const current = this.metrics
if (
current !== null
&& (
metrics.logRevision < current.logRevision
|| metrics.projectionRevision < current.projectionRevision
)
) return
this.metrics = metrics
this.notifier.markDirty()
}
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
@@ -920,10 +896,7 @@ export class Session implements SessionFace {
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
metrics: this.metrics,
...(this.contextWindow === undefined
? {}
: { modelRequestContextWindow: this.contextWindow }),
modelRequest: this.modelRequest,
}
}
}

View File

@@ -102,7 +102,7 @@ describe('runtime client apply', () => {
expect(bench.api.callsOf('session.create')).toHaveLength(1)
})
it('clears connection-local Session state on disconnect but not connected', async () => {
it('clears connection-local request telemetry on reconnect but not connected', async () => {
const bench = await mount()
const sessions = bench.ctx.get('sessions') as SessionsService
bench.sinks?.onHostEnvelope?.({
@@ -112,21 +112,8 @@ describe('runtime client apply', () => {
await Promise.resolve()
const session = sessions.binding('s-state' as never)?.session
if (session === undefined) throw new Error('session binding missing')
const currentMetrics = {
projectionRevision: 4,
logRevision: 10,
uncachedInputTokens: 10,
outputTokens: 4,
cacheReadTokens: 90,
cacheWriteTokens: 3,
contextTokens: 35,
}
bench.sinks?.onMuxEnvelope?.({
rpcId: 'metrics' as never,
payload: { type: 'session/metrics', sessionId: 's-state', metrics: currentMetrics } as never,
})
bench.sinks?.onMuxEnvelope?.({
rpcId: 'capacity' as never,
rpcId: 'request' as never,
payload: {
type: 'session/model-request',
sessionId: 's-state',
@@ -134,19 +121,20 @@ describe('runtime client apply', () => {
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 32_000,
contextWindow: 128_000,
} as never,
})
bench.sinks?.onConnected?.()
expect(session.getSnapshot()).toMatchObject({
metrics: currentMetrics,
modelRequestContextWindow: 128_000,
expect(session.getSnapshot().modelRequest).toMatchObject({
model: 'alpha',
contextTokens: 32_000,
contextWindow: 128_000,
})
bench.sinks?.onDisconnected?.()
expect(session.getSnapshot().metrics).toBeNull()
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
bench.sinks?.onStateChange?.('reconnecting')
expect(session.getSnapshot().modelRequest).toBeNull()
})
it('stops the stream loop when the plugin fiber unloads', async () => {

View File

@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionMetrics, SessionModels,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels,
SessionProjectionsBlock, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
@@ -69,7 +69,6 @@ export class FakeApiClient implements IApiClient {
events: never[]
hasMore: boolean
projections?: SessionProjectionsBlock
metrics?: SessionMetrics
}>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))

View File

@@ -4,7 +4,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, SessionMetrics } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts'
@@ -41,7 +41,7 @@ describe('instances', () => {
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
it('retains the latest transient model capacity until lazy instantiation', () => {
it('retains the latest transient request snapshot until lazy instantiation', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
@@ -53,6 +53,7 @@ describe('instances', () => {
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 12_000,
contextWindow: 128_000,
},
})
@@ -65,14 +66,22 @@ describe('instances', () => {
step: 2,
provider: 'test',
model: 'beta',
contextTokens: 32_000,
contextWindow: 256_000,
},
})
expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBe(256_000)
expect(manager.get(S1).getSnapshot().modelRequest).toEqual({
turn: 1,
step: 2,
provider: 'test',
model: 'beta',
contextTokens: 32_000,
contextWindow: 256_000,
})
})
it('retains explicit capacity clearing before lazy instantiation', () => {
it('retains whole-frame replacement before lazy instantiation', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
@@ -99,10 +108,15 @@ describe('instances', () => {
},
})
expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(manager.get(S1).getSnapshot().modelRequest).toEqual({
turn: 1,
step: 2,
provider: 'test',
model: 'unknown-capacity',
})
})
it('clears retained capacity on subscribed and resident capacity on removal', () => {
it('clears retained request telemetry on subscribed and removal', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
@@ -122,7 +136,7 @@ describe('instances', () => {
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 },
})
const session = manager.get(S1)
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(session.getSnapshot().modelRequest).toBeNull()
manager.handleMuxEnvelope({
rpcId: 'request-after-subscribe' as never,
@@ -136,12 +150,12 @@ describe('instances', () => {
contextWindow: 256_000,
},
})
expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000)
expect(session.getSnapshot().modelRequest?.contextWindow).toBe(256_000)
manager.handleHostEnvelope({
rpcId: 'removed' as never,
payload: { type: 'host/session-removed', sessionId: S1 },
})
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(session.getSnapshot().modelRequest).toBeNull()
manager.handleMuxEnvelope({
rpcId: 'request-before-lazy-removal' as never,
@@ -159,28 +173,15 @@ describe('instances', () => {
rpcId: 'lazy-removed' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
})
it('clears resident metrics and capacity plus lazy capacity before reconnect', () => {
it('clears resident and lazy request telemetry on disconnect', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const session = manager.get(S1)
const currentMetrics: SessionMetrics = {
projectionRevision: 4,
logRevision: 10,
uncachedInputTokens: 10,
outputTokens: 4,
cacheReadTokens: 90,
cacheWriteTokens: 3,
contextTokens: 35,
}
manager.handleMuxEnvelope({
rpcId: 'metrics' as never,
payload: { type: 'session/metrics', sessionId: S1, metrics: currentMetrics },
})
manager.handleMuxEnvelope({
rpcId: 'resident-capacity' as never,
rpcId: 'resident-request' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
@@ -188,11 +189,12 @@ describe('instances', () => {
step: 1,
provider: 'test',
model: 'resident',
contextTokens: 35,
contextWindow: 128_000,
},
})
manager.handleMuxEnvelope({
rpcId: 'lazy-capacity' as never,
rpcId: 'lazy-request' as never,
payload: {
type: 'session/model-request',
sessionId: S2,
@@ -200,19 +202,20 @@ describe('instances', () => {
step: 1,
provider: 'test',
model: 'lazy',
contextTokens: 70,
contextWindow: 256_000,
},
})
expect(session.getSnapshot()).toMatchObject({
metrics: currentMetrics,
modelRequestContextWindow: 128_000,
expect(session.getSnapshot().modelRequest).toMatchObject({
model: 'resident',
contextTokens: 35,
contextWindow: 128_000,
})
manager.handleReconnecting()
manager.handleDisconnected()
expect(session.getSnapshot().metrics).toBeNull()
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(session.getSnapshot().modelRequest).toBeNull()
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
})
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {

View File

@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
SessionId, SessionMetrics, SessionProjectionsBlock,
SessionId, SessionProjectionsBlock,
} from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
@@ -29,34 +29,15 @@ function histResponse(
events: SessionEvent[],
hasMore = false,
projections?: SessionProjectionsBlock,
metrics?: SessionMetrics,
) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({
events: entries(events) as never[],
hasMore,
...projections === undefined ? {} : { projections },
...metrics === undefined ? {} : { metrics },
}))
}
function metrics(
projectionRevision: number,
logRevision: number,
over: Partial<SessionMetrics> = {},
): SessionMetrics {
return {
projectionRevision,
logRevision,
uncachedInputTokens: 10,
outputTokens: 4,
cacheReadTokens: 90,
cacheWriteTokens: 3,
contextTokens: 35,
...over,
}
}
describe('open', () => {
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
const { api, session } = makeSession()
@@ -70,19 +51,7 @@ describe('open', () => {
expect(snapshot.openState).toBe('open')
expect(snapshot.hasMore).toBe(true)
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
expect(snapshot.metrics).toBeNull()
})
it('installs full-log metrics independently of older history pages', async () => {
const { api, session } = makeSession()
const tailMetrics = metrics(4, 106)
api.onHistory = () => histResponse(plainTurn(100, 3, '问', '答'), true, undefined, tailMetrics)
await session.open()
expect(session.getSnapshot().metrics).toBe(tailMetrics)
api.onHistory = () => histResponse(plainTurn(94, 2, '旧问', '旧答'))
await session.loadOlder()
expect(session.getSnapshot().metrics).toBe(tailMetrics)
expect(snapshot.modelRequest).toBeNull()
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
@@ -147,17 +116,8 @@ describe('live event path', () => {
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
it('keeps live capacity separate from durable metrics, replaces or clears it on requests, and resets at subscription', async () => {
it('replaces the whole request snapshot, clears omitted fields, and resets at subscription', async () => {
const { session } = await opened()
const current = metrics(8, 10)
session.handleMuxEnvelope('m1' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: current,
})
expect(session.getSnapshot().metrics).toBe(current)
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
session.handleMuxEnvelope('request-1' as never, {
type: 'session/model-request',
sessionId: SID,
@@ -165,32 +125,17 @@ describe('live event path', () => {
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 32_000,
contextWindow: 128_000,
})
expect(session.getSnapshot().metrics).toBe(current)
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
session.handleMuxEnvelope('m2' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: metrics(9, 9, { uncachedInputTokens: 1 }),
expect(session.getSnapshot().modelRequest).toEqual({
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 32_000,
contextWindow: 128_000,
})
session.handleMuxEnvelope('m3' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: metrics(7, 11, { uncachedInputTokens: 2 }),
})
expect(session.getSnapshot().metrics).toBe(current)
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
const ordinaryUpdate = metrics(9, 11, { contextTokens: 40 })
session.handleMuxEnvelope('m4' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: ordinaryUpdate,
})
expect(session.getSnapshot().metrics).toBe(ordinaryUpdate)
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
session.handleMuxEnvelope('request-2' as never, {
type: 'session/model-request',
@@ -200,16 +145,19 @@ describe('live event path', () => {
provider: 'test',
model: 'without-capacity',
})
expect(session.getSnapshot().metrics).toEqual(ordinaryUpdate)
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(session.getSnapshot().modelRequest).toEqual({
turn: 2,
step: 1,
provider: 'test',
model: 'without-capacity',
})
session.handleMuxEnvelope('sub' as never, {
type: 'session/subscribed',
sessionId: SID,
lastSeq: 5,
})
expect(session.getSnapshot().metrics).toBeNull()
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(session.getSnapshot().modelRequest).toBeNull()
session.handleMuxEnvelope('request-3' as never, {
type: 'session/model-request',
sessionId: SID,
@@ -217,19 +165,17 @@ describe('live event path', () => {
step: 1,
provider: 'test',
model: 'beta',
contextTokens: 20,
contextWindow: 256_000,
})
const nextGeneration = metrics(0, 10, { contextTokens: 20 })
session.handleMuxEnvelope('m5' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: nextGeneration,
expect(session.getSnapshot().modelRequest).toMatchObject({
turn: 3,
contextTokens: 20,
contextWindow: 256_000,
})
expect(session.getSnapshot().metrics).toBe(nextGeneration)
expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000)
})
it('publishes a subscribed reset when capacity arrived before durable metrics', async () => {
it('publishes a subscribed reset when request telemetry arrived first', async () => {
const { session } = await opened()
session.handleMuxEnvelope('request' as never, {
type: 'session/model-request',
@@ -238,17 +184,17 @@ describe('live event path', () => {
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 8_000,
contextWindow: 128_000,
})
expect(session.getSnapshot().metrics).toBeNull()
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
expect(session.getSnapshot().modelRequest?.contextWindow).toBe(128_000)
session.handleMuxEnvelope('sub' as never, {
type: 'session/subscribed',
sessionId: SID,
lastSeq: 5,
})
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
expect(session.getSnapshot().modelRequest).toBeNull()
})
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
@@ -876,72 +822,61 @@ describe('remaining branches', () => {
})
describe('resync', () => {
it('fences pre-disconnect history behind a fresh mux metrics baseline', async () => {
it('clears request telemetry on reconnect and drops a stale in-flight history response', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const opening = session.open()
const oldLiveMetrics = metrics(8, 10)
session.handleMuxEnvelope('old-metrics' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: oldLiveMetrics,
})
session.handleMuxEnvelope('old-capacity' as never, {
session.handleMuxEnvelope('old-request' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 1,
step: 1,
provider: 'test',
model: 'old',
contextTokens: 20,
contextWindow: 128_000,
})
session.handleReconnecting()
expect(session.getSnapshot().metrics).toBeNull()
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
const freshMetrics = metrics(0, 1, { contextTokens: 20 })
session.handleMuxEnvelope('fresh-metrics' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: freshMetrics,
})
expect(session.getSnapshot().modelRequest).toBeNull()
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[],
hasMore: false,
metrics: metrics(99, 99, { contextTokens: 999 }),
}))
await opening
expect(session.getSnapshot().nodes).toEqual([])
expect(session.getSnapshot().metrics).toBe(freshMetrics)
expect(session.getSnapshot().modelRequest).toBeNull()
api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答'))
await session.resync()
expect(session.getSnapshot().openState).toBe('open')
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9])
expect(session.getSnapshot().metrics).toBe(freshMetrics)
expect(session.getSnapshot().modelRequest).toBeNull()
})
it('preserves fresh-generation metrics that arrive before a failing history refresh', async () => {
it('preserves a fresh-generation request snapshot when history resync fails', async () => {
const { api, session } = makeSession()
const oldMetrics = metrics(8, 10)
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'), false, undefined, oldMetrics)
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
expect(session.getSnapshot().metrics).toBe(oldMetrics)
session.handleMuxEnvelope('sub' as never, {
type: 'session/subscribed',
sessionId: SID,
lastSeq: 5,
})
expect(session.getSnapshot().metrics).toBeNull()
expect(session.getSnapshot().modelRequest).toBeNull()
const freshMetrics = metrics(0, 10, { contextTokens: 20 })
session.handleMuxEnvelope('fresh-metrics' as never, {
type: 'session/metrics',
session.handleMuxEnvelope('fresh-request' as never, {
type: 'session/model-request',
sessionId: SID,
metrics: freshMetrics,
turn: 2,
step: 1,
provider: 'test',
model: 'fresh',
contextTokens: 20,
contextWindow: 256_000,
})
api.onHistory = () => Promise.resolve(err({
code: 'internal',
@@ -953,7 +888,14 @@ describe('resync', () => {
expect(session.getSnapshot()).toMatchObject({
openState: 'error',
metrics: freshMetrics,
modelRequest: {
turn: 2,
step: 1,
provider: 'test',
model: 'fresh',
contextTokens: 20,
contextWindow: 256_000,
},
})
})

View File

@@ -62,6 +62,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
promptError: null,
blank: false,
lastAgentError: null,
modelRequest: null,
}
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: b74127ffe11df40fcad0c97e2fc6896ec79ad9d1
README.zh.md: f6dfc3ca61ea80758c9f64ca98f034b0832ee89e
README.md: c60a38ec26d4bca15ce23e51dbaedf3ef36022e4
README.zh.md: 65c5554a78960fc4f86a6a370772c95429c648c4

View File

@@ -20,7 +20,7 @@ Per-session UI state for selection and the active view lives in the declared cha
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
The chat stats line reads durable token counters/current pressure from `ConversationSnapshot.metrics` and joins them only at presentation with the separate connection-local `modelRequestContextWindow`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only after the current mux connection observes a model request with capacity. Before that request, after reconnect/restore/new subscription, or after a request without capacity, the percentage is omitted and context is labeled unknown rather than queried ahead or reconstructed from history.
The chat stats line reads full-log billing from the generic `tokenUsage` projection and joins it only at presentation with the connection-local atomic `ConversationSnapshot.modelRequest`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only when the same observed request snapshot contains both `contextTokens` and `contextWindow`. Before that request, after reconnect/restore/new subscription, or after a request missing either field, context is labeled unknown rather than queried from the selected model or reconstructed from history. The existing inline stats row remains the sole context UI; the model selector has no circle or accessory.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).

View File

@@ -20,7 +20,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
聊天统计行从 `ConversationSnapshot.metrics` 读取持久的 token 计数/当前压力,并且只在展示时把它们与独立的连接本地 `modelRequestContextWindow` 结合;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有当前 mux 连接观察到带容量的模型请求后才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求不带容量之后,系统都会省略百分比,并把上下文标为「未知」,而不会提前查询或根据历史记录重建。
聊天统计行从通用 `tokenUsage` 投影读取完整日志计费用量,并且只在展示时把它与连接本地的原子快照 `ConversationSnapshot.modelRequest` 结合;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有同一份已观测请求快照同时包含 `contextTokens` 与 `contextWindow` 时才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求缺少任一字段之后,系统都会把上下文标为「未知」,而不会从所选模型查询或根据历史记录重建。现有的行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。

View File

@@ -44,6 +44,7 @@
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
@@ -52,6 +53,7 @@
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -221,7 +221,9 @@ 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({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
export function ChatView({
useProjection, 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)
@@ -381,7 +383,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
{running && <TurnDots />}
</div>
</div>
<StatsLine useSession={useSession} />
<StatsLine useSession={useSession} useProjection={useProjection} />
{!atBottom && (
<button
type="button"

View File

@@ -1,12 +1,14 @@
// Settled-node identity prevents stream-delta updates from rerendering this row.
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
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 css from './StatsLine.module.css'
type SessionMetrics = NonNullable<ConversationSnapshot['metrics']>
interface VisibleCounts {
turns: number
steps: number
@@ -44,59 +46,59 @@ export function formatMetricTokens(value: number): string {
/**
* Existing Web cache-hit formula over disjoint uncached and cache-read input.
* @param metrics - Host-owned durable usage.
* @param usage - full-log token usage projection.
* @returns rounded integer percent, or null when no input was billed.
*/
export function cacheHitPercent(metrics: SessionMetrics): number | null {
const denominator = metrics.uncachedInputTokens + metrics.cacheReadTokens
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
const denominator = usage.uncachedInputTokens + usage.cacheReadTokens
return denominator === 0
? null
: Math.round(metrics.cacheReadTokens / denominator * 100)
: Math.round(usage.cacheReadTokens / denominator * 100)
}
/**
* Current context occupancy using the TUI's integer rounding and upper clamp.
* @param metrics - Host-owned durable pressure.
* @param contextWindow - capacity from the latest request observed on this mux generation.
* @param request - one atomic request snapshot observed on this mux generation.
* @returns occupancy percent, or null when either input is unavailable.
*/
export function contextPercent(metrics: SessionMetrics, contextWindow: number | undefined): number | null {
if (metrics.contextTokens === undefined || contextWindow === undefined) return null
return Math.min(100, Math.round(metrics.contextTokens / contextWindow * 100))
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))
}
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
/** Props: standard session hooks handed down by ChatView. */
export interface StatsLineProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
useProjection: UseProjection
}
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const metrics = useSession(s => s.metrics)
const contextWindow = useSession(s => s.modelRequestContextWindow)
const modelRequest = useSession(s => s.modelRequest)
const usage = useProjection('tokenUsage')
const counts = useMemo(() => deriveVisibleCounts(nodes), [nodes])
if (counts.steps === 0 && (
metrics === null
|| (
metrics.uncachedInputTokens === 0
&& metrics.outputTokens === 0
&& metrics.cacheReadTokens === 0
&& (metrics.contextTokens ?? 0) === 0
)
)) return null
const hasUsage = usage !== undefined && (
usage.uncachedInputTokens !== 0
|| usage.outputTokens !== 0
|| usage.cacheReadTokens !== 0
|| usage.cacheWriteTokens !== 0
)
const context = contextPercent(modelRequest)
if (counts.steps === 0 && !hasUsage && context === null) return null
const parts: string[] = []
if (metrics === null) {
if (usage === undefined) {
parts.push('usage unknown')
parts.push('context unknown')
} else {
parts.push(`${formatMetricTokens(metrics.uncachedInputTokens)} uncached input`)
parts.push(`${formatMetricTokens(metrics.outputTokens)} output`)
parts.push(`${formatMetricTokens(metrics.cacheReadTokens)} cache read`)
const cacheHit = cacheHitPercent(metrics)
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}%`)
const context = contextPercent(metrics, contextWindow)
parts.push(context === null
? 'context unknown'
: `context ${context}% of ${formatMetricTokens(contextWindow as number)}`)
}
parts.push(context === null || modelRequest?.contextWindow === undefined
? 'context unknown'
: `context ${context}% of ${formatMetricTokens(modelRequest.contextWindow)}`)
parts.push(`${counts.turns} turns`)
parts.push(`${counts.steps} steps`)
return <div className={css.root}>{parts.join(' · ')}</div>

View File

@@ -120,18 +120,20 @@ describe('small branch tails', () => {
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
const snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
metrics: {
logRevision: 2,
projectionRevision: 0,
uncachedInputTokens: 0,
outputTokens: 10,
cacheReadTokens: 0,
cacheWriteTokens: 5_000,
},
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']} />,
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
useProjection={(() => usage)}
/>,
)
expect(view.getByText(
'0 uncached input · 10 output · 0 cache read · context unknown · 1 turns · 1 steps',

View File

@@ -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, metrics: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
}
}

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
// StatsLine (rendered inside the chat view body): durable metrics presentation + the RFC
// StatsLine (rendered inside the chat view body): projection presentation + the RFC
// hard acceptance — zero renders during streaming. Bash sample row: the
// canonical sub-agent differential decided INSIDE the component off the
// standard useSessions kit (no registry predicates — tool ring dissolved).
@@ -7,8 +7,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode, UseProjection,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -31,7 +32,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, metrics: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
}
}
@@ -53,6 +54,27 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
}
function makeProjection(initial?: TokenUsageProjection) {
let value = initial
const subs = new Set<() => void>()
const useValue = bindSnapshotSelector({
getSnapshot: () => value,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
})
return {
set(next: TokenUsageProjection | undefined) {
value = next
for (const fn of [...subs]) fn()
},
useProjection: ((_key: 'tokenUsage', selector?: (usage: TokenUsageProjection | undefined) => unknown,
eq?: (left: unknown, right: unknown) => boolean) =>
useValue(selector ?? (usage => usage), eq)) as UseProjection,
}
}
describe('stats derivation', () => {
it('counts visible turns and steps without reading node usage', () => {
const stats = deriveVisibleCounts([
@@ -74,20 +96,23 @@ describe('stats derivation', () => {
})
it('keeps the cache formula disjoint from cache writes and rounds/clamps context like the TUI', () => {
const durable = {
logRevision: 20,
projectionRevision: 2,
const usage = {
uncachedInputTokens: 100,
outputTokens: 50,
cacheReadTokens: 900,
cacheWriteTokens: 50_000,
contextTokens: 34_500,
}
expect(cacheHitPercent(durable)).toBe(90)
expect(contextPercent(durable, 100_000)).toBe(35)
expect(contextPercent({ ...durable, contextTokens: 200_000 }, 100_000)).toBe(100)
expect(contextPercent(durable, undefined)).toBeNull()
expect(cacheHitPercent({ ...durable, uncachedInputTokens: 0, cacheReadTokens: 0 })).toBeNull()
const request = {
turn: 1, step: 1, provider: 'p', model: 'm',
contextTokens: 34_500, contextWindow: 100_000,
}
expect(cacheHitPercent(usage)).toBe(90)
expect(contextPercent(request)).toBe(35)
expect(contextPercent({ ...request, contextTokens: 200_000 })).toBe(100)
expect(contextPercent({
turn: 1, step: 1, provider: 'p', model: 'm', contextTokens: 34_500,
})).toBeNull()
expect(cacheHitPercent({ ...usage, uncachedInputTokens: 0, cacheReadTokens: 0 })).toBeNull()
})
it('formats large values compactly in the existing en-US style', () => {
@@ -98,25 +123,28 @@ describe('stats derivation', () => {
})
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
return { useSession: bindSnapshotSelector(source) }
function props(
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
projection = makeProjection(),
): StatsLineProps {
return { useSession: bindSnapshotSelector(source), useProjection: projection.useProjection }
}
it('renders separate durable counters, cache hit, context occupancy, and visible counts', () => {
const { source } = makeSource({
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
metrics: {
logRevision: 30,
projectionRevision: 4,
uncachedInputTokens: 120_237,
outputTokens: 13_881,
cacheReadTokens: 2_172_544,
cacheWriteTokens: 99_999,
contextTokens: 89_600,
modelRequest: {
turn: 1, step: 1, provider: 'p', model: 'm',
contextTokens: 89_600, contextWindow: 256_000,
},
modelRequestContextWindow: 256_000,
})
const view = render(<StatsLine {...props(source)} />)
const projection = makeProjection({
uncachedInputTokens: 120_237,
outputTokens: 13_881,
cacheReadTokens: 2_172_544,
cacheWriteTokens: 99_999,
})
const view = render(<StatsLine {...props(source, projection)} />)
expect(view.getByText(
'120.2k uncached input · 13.9k output · 2.2m cache read · cache hit 95% · context 35% of 256k · 1 turns · 1 steps',
)).toBeTruthy()
@@ -125,68 +153,80 @@ describe('StatsLine', () => {
expect(emptyView.container.textContent).toBe('')
})
it('renders durable counters without a percentage before live capacity is observed', () => {
it('renders durable counters without a percentage before a complete request snapshot is observed', () => {
const { source } = makeSource({
nodes: [assistant(1, 1)],
metrics: {
logRevision: 4,
projectionRevision: 1,
uncachedInputTokens: 120,
outputTokens: 20,
cacheReadTokens: 30,
cacheWriteTokens: 10,
modelRequest: {
turn: 1, step: 1, provider: 'p', model: 'm',
contextTokens: 8_000,
},
})
const view = render(<StatsLine {...props(source)} />)
const projection = makeProjection({
uncachedInputTokens: 120,
outputTokens: 20,
cacheReadTokens: 30,
cacheWriteTokens: 10,
})
const view = render(<StatsLine {...props(source, projection)} />)
expect(view.getByText(
'120 uncached input · 20 output · 30 cache read · cache hit 20% · context unknown · 1 turns · 1 steps',
)).toBeTruthy()
expect(view.container.textContent).not.toContain('% of')
})
it('renders honest unknowns when the host projection is missing', () => {
it('renders honest unknowns when the tokenUsage key and request snapshot are missing', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
expect(view.getByText('usage unknown · context unknown · 1 turns · 1 steps')).toBeTruthy()
})
it.each([
{ uncachedInputTokens: 1, outputTokens: 0, cacheReadTokens: 0, contextTokens: 0 },
{ uncachedInputTokens: 0, outputTokens: 1, cacheReadTokens: 0, contextTokens: 0 },
{ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 1, contextTokens: 0 },
{ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, contextTokens: 1 },
])('keeps a metrics-only row visible for each nonzero projection bucket', (nonzero) => {
const { source } = makeSource({
metrics: {
logRevision: 1,
projectionRevision: 0,
cacheWriteTokens: 0,
...nonzero,
{ uncachedInputTokens: 1, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
{ uncachedInputTokens: 0, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
{ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 1, cacheWriteTokens: 0 },
{ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 1 },
])('keeps a usage-only row visible for each nonzero projection bucket', (usage) => {
const { source } = makeSource()
const view = render(<StatsLine {...props(source, makeProjection(usage))} />)
expect(view.container.textContent).toContain('0 turns · 0 steps')
})
it('keeps a context-only row visible and hides an all-zero empty session', () => {
const context = makeSource({
modelRequest: {
turn: 1, step: 1, provider: 'p', model: 'm',
contextTokens: 1, contextWindow: 10,
},
})
const view = render(<StatsLine {...props(source)} />)
expect(view.container.textContent).toContain('0 turns · 0 steps')
expect(render(
<StatsLine {...props(context.source, makeProjection({
uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
}))} />,
).container.textContent).toContain('context 10% of 10')
const empty = makeSource()
expect(render(
<StatsLine {...props(empty.source, makeProjection({
uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
}))} />,
).container.textContent).toBe('')
})
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({
nodes: [assistant(1, 1)],
metrics: {
logRevision: 4,
projectionRevision: 0,
uncachedInputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
})
const projection = makeProjection({
uncachedInputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
})
let renders = 0
function Counting(p: StatsLineProps) {
renders += 1
return <StatsLine {...p} />
}
render(<Counting {...props(source)} />)
render(<Counting {...props(source, projection)} />)
const before = renders
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
@@ -194,6 +234,28 @@ describe('StatsLine', () => {
act(() => { set({ running: true }) })
expect(renders).toBe(before)
})
it('updates for a new usage projection or atomic request snapshot', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
const projection = makeProjection()
const view = render(<StatsLine {...props(source, projection)} />)
expect(view.container.textContent).toContain('usage unknown · context unknown')
act(() => {
projection.set({
uncachedInputTokens: 7, outputTokens: 2, cacheReadTokens: 1, cacheWriteTokens: 0,
})
})
expect(view.container.textContent).toContain('7 uncached input · 2 output · 1 cache read')
act(() => {
set({
modelRequest: {
turn: 1, step: 2, provider: 'p', model: 'm',
contextTokens: 75, contextWindow: 100,
},
})
})
expect(view.container.textContent).toContain('context 75% of 100')
})
})
describe('bash sample row', () => {

View File

@@ -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, metrics: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
}
}

View File

@@ -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, metrics: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
}
}
@@ -36,7 +36,7 @@ describe('render branch tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsLine takes durable counters from metrics while keeping visible node counts', () => {
it('StatsLine takes durable counters from tokenUsage while keeping visible node counts', () => {
const snap = {
nodes: [
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
@@ -44,18 +44,20 @@ 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 } },
],
metrics: {
logRevision: 9,
projectionRevision: 1,
uncachedInputTokens: 9,
outputTokens: 6,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
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>} />,
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useProjection={(() => usage)}
/>,
)
expect(view.getByText(
'9 uncached input · 6 output · 0 cache read · cache hit 0% · context unknown · 2 turns · 3 steps',

View File

@@ -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, metrics: null,
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
...overrides,
}
}

View File

@@ -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, metrics: null,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
})
const props: InputBarProps = {
sessionId: SID,

View File

@@ -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, metrics: null,
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
})
const barProps: InputBarProps = {
sessionId,

View File

@@ -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, metrics: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
}
}

View File

@@ -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, metrics: null,
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
...overrides,
}
}

View File

@@ -26,6 +26,9 @@
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../plan/plan-mode"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: d25779a7104bc0896e0063db236515ce543b6b5a
README.zh.md: ba9332b64c062d71b721007ac66acb484b02cddd
README.md: ffdce143c0023bab22efc49fb1272377bf0bfdcf
README.zh.md: 9dd982785590d1de6864ede8f071d3aa884a2827

View File

@@ -63,7 +63,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history.
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. Once the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop emits one contained live `agent/model-request` notification with turn, step, route, and optional registration-bound capacity. This is an observed Agent-loop attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while a short-circuit handle or later lazy adapter construction, failure, or abortion still counts. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. Once the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop emits one contained live `agent/model-request` notification with turn, step, route, and optional capacity copied from that same prepared call. This is an observed Agent-loop attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while a short-circuit handle or later lazy adapter construction, failure, or abortion still counts. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.

View File

@@ -63,7 +63,7 @@ interface Config {
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条实时 `agent/model-request` 通知,并收容该通知的失败;通知中包含轮次、步骤、路由,以及可选的、与注册项绑定的容量。这是 AgentLoop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条实时 `agent/model-request` 通知,并收容该通知的失败;通知中包含轮次、步骤、路由,以及从同一次准备完成的调用中复制的可选容量。这是 AgentLoop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: 19e04af3517f34131cd7986720f6a49660bd0b33
README.zh.md: c6a4af4eb631e6eb80f33a168e37db314cfd7158
README.md: afad797f1f21240610c8fe37aa6d2f55cf06fd26
README.zh.md: 4e8d366a1e72071805073088b136a49a4a3703d9

View File

@@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports the route and optional registration-bound context capacity for an attempt whose outer stream handle returned. It is neither durable state nor proof of provider I/O. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports the route and optional context capacity copied from the registration-bound prepared call whose outer stream handle returned. It is neither durable state nor proof of provider I/O. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity.

View File

@@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;`agent/model-request` 通知的失败会被收容,外层流调用为一次尝试返回句柄后,该通知会报告其路由以及可选的、与注册项绑定的上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;由绑定注册项的准备完成调用发起的外层流返回句柄后,失败受收容的 `agent/model-request` 通知会报告其路由,以及从该调用复制的可选上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
`PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 5d5f1e4cff97756e650cca02abef221dd9bef8f5
README.zh.md: 3b70aec65189b4a9e71170c4194ea5850fc4cffb
README.md: 872d653a194bc08ec7a4a2125137af51d0105a52
README.zh.md: 5470b7680b1fea42cc3383ebcc121f05015f5226

View File

@@ -22,9 +22,9 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
`session.history` pages on message boundaries. Its tail page (no `beforeSeq`) carries the generic `projections` baseline, including the `todos` whole-list value when that unit is mounted, plus separate `metrics`: full-log usage deduplicated by `(turn, step)` and current token-meter pressure. Older pages omit both session-level carriers. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads.
`session.history` pages on message boundaries. Its tail page (no `beforeSeq`) carries only the generic `projections` baseline for registered units; older pages omit it. When token-meter is composed with the projection registry, full-log provider billing rides the ordinary `tokenUsage` key. Its usage chunks and final messages are deduplicated by `(turn, step)`, while cache reads and writes remain disjoint buckets. ApiProxy owns no token-specific history field, mux frame, projector, revision counter, or refresh queue.
Context capacity uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an observed request attempt returns its outer stream handle. This boundary does not prove provider I/O began. The frame carries turn, step, final provider/model, and optional capacity only to mux connections already open at that instant. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay prior capacity; a frame without capacity explicitly clears the earlier connection-local value.
Request context uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an observed request attempt returns its outer stream handle. This boundary does not prove provider I/O began. In the same synchronous event boundary, ApiProxy optionally reads `tokenMeter.measure(session).totalTokens` once and combines it with capacity from that exact prepared call. The atomic frame carries turn, step, final provider/model, and optional `contextTokens`/`contextWindow` only to mux connections already open at that instant. Measurement failure omits only the numerator. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay an earlier snapshot, and missing fields in a later frame replace rather than inherit prior values.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.

View File

@@ -22,9 +22,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`session.history` 按消息边界分页。其尾页(不带 `beforeSeq`)携带通用 `projections` 基线(挂载对应单元时包含 `todos` 整表值),以及独立的 `metrics`:按 `(turn, step)` 去重的完整日志用量与当前 token 计量压力。较早的页面省略这两种会话级载体。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。
`session.history` 按消息边界分页。其尾页(不带 `beforeSeq`)只携带已注册单元的通用 `projections` 基线;较早页面则省略该基线。当 token-meter 与投影注册表组合时,完整日志中的提供方计费用量会通过普通 `tokenUsage` 键承载。系统按 `(turn, step)` 对其用量分片和最终消息去重,缓存读取与写入则仍是相互独立的计数项。ApiProxy 不拥有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或刷新队列。
上下文容量使用独立的临时 `session/model-request` mux 帧。外层流调用为一次观察到的请求尝试返回句柄后,系统会根据 Agent 通知发出该帧,并收容通知失败。这个边界不能证明提供方 I/O 已开始。该帧携带轮次、步骤、最终提供方/模型与可选容量,且只发送给当时已经打开的 mux 连接。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放先前的容量;不带容量的帧会显式清除较早的连接本地值。
请求上下文使用独立的临时 `session/model-request` mux 帧。外层流调用为一次已观测的请求尝试返回句柄后,系统会根据 Agent 通知发出该帧,并收容通知失败。这个边界不能证明提供方 I/O 已开始。在同一同步事件边界内,ApiProxy 会可选地读取一次 `tokenMeter.measure(session).totalTokens`,并将结果与该次准备完成调用的容量合并。这个原子帧携带轮次、步骤、最终提供方/模型与可选的 `contextTokens`/`contextWindow`,且只发送给当时已经打开的 mux 连接。测量失败时只省略分子。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放更早的快照;后续帧中缺失的字段会清除对应的先前值,而不是继承它。
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。

View File

@@ -51,7 +51,6 @@ import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { affectsSessionMetrics, SessionMetricsProjector } from './session-metrics.ts'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import { openNativePath } from './native-path-opener.ts'
@@ -496,56 +495,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
for (const queue of muxQueues) queue.push(envelope)
}
const pendingMetricSessions = new Set<Session>()
let metricFlushScheduled = false
const metricsProjector = new SessionMetricsProjector(ctx)
/** Queue one full-log metrics publication after synchronous session listeners drain. */
function scheduleMetrics(session: Session): void {
if (muxQueues.size === 0) return
pendingMetricSessions.add(session)
if (metricFlushScheduled) return
metricFlushScheduled = true
queueMicrotask(() => {
metricFlushScheduled = false
const sessions = [...pendingMetricSessions]
pendingMetricSessions.clear()
for (const current of sessions) {
broadcast({
type: 'session/metrics',
sessionId: current.id,
metrics: metricsProjector.snapshot(current),
})
}
})
}
ctx.effect(() => {
const disposers = [
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (affectsSessionMetrics(event)) scheduleMetrics(session)
}),
ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }),
ctx.on('agent/model-request', (agent, turn, step, request) => {
broadcast({
type: 'session/model-request',
sessionId: agent.session.id,
turn,
step,
provider: request.provider,
model: request.model,
...request.contextWindow === undefined
? {}
: { contextWindow: request.contextWindow },
})
}),
ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }),
]
return () => {
pendingMetricSessions.clear()
for (const dispose of disposers) dispose()
}
}, 'api-proxy: session metrics')
return ctx.on('agent/model-request', (agent, turn, step, request) => {
const tokenMeter = ctx.get('tokenMeter') as {
measure(session: Session): { totalTokens: number }
} | undefined
let contextTokens: number | undefined
if (tokenMeter !== undefined) {
try {
contextTokens = tokenMeter.measure(agent.session).totalTokens
} catch {
// A malformed or temporarily unmeasurable replay omits only the
// numerator; this request still replaces stale telemetry.
}
}
broadcast({
type: 'session/model-request',
sessionId: agent.session.id,
turn,
step,
provider: request.provider,
model: request.model,
...contextTokens === undefined ? {} : { contextTokens },
...request.contextWindow === undefined
? {}
: { contextWindow: request.contextWindow },
})
})
}, 'api-proxy: model request telemetry')
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the seam package holds no wire vocabulary); the
@@ -991,16 +968,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return { event, ...view === undefined ? {} : { view } }
})
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
// the one path that never needs fresh projection or metrics state.
// the one path that never needs fresh projection state.
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
const metrics = beforeSeq === undefined
? metricsProjector.snapshot(found.agent.session)
: undefined
return ok(request, {
events: entries,
hasMore: page.hasMore,
...projections === undefined ? {} : { projections },
...metrics === undefined ? {} : { metrics },
})
},
@@ -1501,11 +1474,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
muxQueues.add(queue)
for (const session of ctx.sessions.list()) {
subscribeSession(queue, session)
queue.push(frame({
type: 'session/metrics',
sessionId: session.id,
metrics: metricsProjector.snapshot(session),
}))
}
for (const pending of pendingQuestions.values()) {
queue.push({
@@ -1556,11 +1524,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}),
ctx.on('session/created', (session: Session) => {
subscribeSession(queue, session)
queue.push(frame({
type: 'session/metrics',
sessionId: session.id,
metrics: metricsProjector.snapshot(session),
}))
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)

View File

@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import {
contentBlockSchema, sessionEventSchema, sessionIdSchema, sessionMetricsSchema, toolEventViewSchema,
contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
} from './sessions.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
@@ -37,7 +37,6 @@ const messageSchema = z.object({
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('session/metrics'), sessionId: sessionIdSchema, metrics: sessionMetricsSchema }),
z.object({
type: z.literal('session/model-request'),
sessionId: sessionIdSchema,
@@ -45,6 +44,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
step: z.number().int().positive(),
provider: z.string().min(1),
model: z.string().min(1),
contextTokens: z.number().int().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),

View File

@@ -13,7 +13,6 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
import type { SessionMetrics } from './sessions.ts'
import type { WorkspaceView } from './workspace.ts'
// Client-side consumers take the render-intent vocabulary from the contract;
@@ -32,6 +31,18 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** Atomic telemetry captured at one observed model-request boundary. */
export interface ModelRequestTelemetry {
turn: number
step: number
provider: string
model: string
/** Token-meter pressure measured synchronously for this exact request. */
contextTokens?: number
/** Registration-bound capacity from this exact prepared call. */
contextWindow?: number
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
export interface EventsApi {
/**
@@ -58,23 +69,18 @@ export interface EventsApi {
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'session/metrics'; sessionId: SessionId; metrics: SessionMetrics }
/**
* One request attempt observed by this already-open mux connection after its
* final route and outer `llm/stream` handle were obtained. This does not prove
* provider I/O began. The frame is transient: mux baselines, reconnects, and
* session history never replay it. An absent `contextWindow` explicitly clears
* a capacity observed from an earlier request on the same connection.
* session history never replay it. The optional numerator and capacity are
* one atomic request snapshot; absent fields explicitly replace, rather
* than inherit from, the preceding request.
*/
| {
| ({
type: 'session/model-request'
sessionId: SessionId
turn: number
step: number
provider: string
model: string
contextWindow?: number
}
} & ModelRequestTelemetry)
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }

View File

@@ -29,14 +29,16 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock,
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type {
EventsApi, HostFrame, ModelRequestTelemetry, MuxFrame, ToolCallView, ToolEventView, ToolResultView,
} from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'

View File

@@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionProjectionsBlock, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -151,23 +151,11 @@ export const sessionProjectionsBlockSchema = z.object({
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<SessionProjectionsBlock>
/** Host-owned durable usage and current-pressure projection. */
export const sessionMetricsSchema = z.object({
logRevision: z.number().int().nonnegative(),
projectionRevision: z.number().int().nonnegative(),
uncachedInputTokens: z.number().nonnegative(),
outputTokens: z.number().nonnegative(),
cacheReadTokens: z.number().nonnegative(),
cacheWriteTokens: z.number().nonnegative(),
contextTokens: z.number().nonnegative().optional(),
}) satisfies z.ZodType<Wire<SessionMetrics>>
/** session.history response value (projections and metrics ride the tail page only). */
/** session.history response value (projections ride the tail page only). */
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
metrics: sessionMetricsSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
/** session.models request payload. */
@@ -223,4 +211,3 @@ export const sessionCancelRequestSchema = z.object({
export const sessionCancelValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'session.cancel'>>>

View File

@@ -35,29 +35,6 @@ export interface HistoryEntry {
view?: ToolEventView
}
/**
* Host-owned token metrics for one durable session revision. Provider usage
* buckets are cumulative across the full log; current context pressure
* describes the replayed request surface at this revision and is absent when
* the Host cannot measure it.
*/
export interface SessionMetrics {
/** Number of durable events included in this projection. */
logRevision: number
/** Monotone ordering within one Host process and mux subscription generation. */
projectionRevision: number
/** Cumulative uncached provider input. */
uncachedInputTokens: number
/** Cumulative provider output. */
outputTokens: number
/** Cumulative provider cache reads. */
cacheReadTokens: number
/** Cumulative provider cache writes; excluded from the Web cache-hit formula. */
cacheWriteTokens: number
/** Current request pressure from `ctx.tokenMeter.measure(session).totalTokens`. */
contextTokens?: number
}
/**
* The projection baseline riding the history tail page: one synchronous cut
* over every registered projection unit, read from the registry's watermark
@@ -211,17 +188,14 @@ export interface SessionsApi {
* the client needs a fresh baseline already pulls the tail page, and
* loadOlder (the only beforeSeq path) is the only path that never needs one.
* A deployment without the registry serves histories without the block.
* The same tail-only rule carries `metrics`, whose cumulative usage and
* current token-meter pressure are Host projections over the full log
* rather than products of the returned page. Live model capacity is
* connection-local telemetry and is never reconstructed here.
* Model-request telemetry is connection-local and is never reconstructed
* from history.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: SessionProjectionsBlock
metrics?: SessionMetrics
}>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */

View File

@@ -1,122 +0,0 @@
/**
* Full-log usage and current-context projection for Web clients.
*
* @module @deepseek-ai/dsh-host-apiproxy/session-metrics
*/
import type { Context } from 'cordis'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionMetrics } from './api/sessions.ts'
interface UsageState {
logRevision: number
projectionRevision: number
uncachedInputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
byStep: Map<string, TokenUsage>
}
interface TokenMeterLike {
measure(session: Session): { totalTokens: number }
}
function usageFrom(event: SessionEvent): { turn: number; step: number; usage: TokenUsage } | undefined {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
}
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
}
return undefined
}
/**
* Whether an appended event can change cumulative usage or token-meter
* pressure. Text/reasoning stream deltas remain outside both projections.
* @param event - appended durable event.
* @returns true when the Host must publish a fresh metrics snapshot.
*/
export function affectsSessionMetrics(event: SessionEvent): boolean {
if (event.type === 'assistant/chunk') return event.data.chunk.type === 'usage'
if (event.type === 'request/header') return true
return 'surfaceOp' in event
}
function recordUsage(state: UsageState, turn: number, step: number, usage: TokenUsage): void {
const key = `${turn}:${step}`
const previous = state.byStep.get(key)
if (previous !== undefined) {
state.uncachedInputTokens -= previous.inputTokens
state.outputTokens -= previous.outputTokens
state.cacheReadTokens -= previous.cacheReadTokens ?? 0
state.cacheWriteTokens -= previous.cacheWriteTokens ?? 0
}
state.byStep.set(key, usage)
state.uncachedInputTokens += usage.inputTokens
state.outputTokens += usage.outputTokens
state.cacheReadTokens += usage.cacheReadTokens ?? 0
state.cacheWriteTokens += usage.cacheWriteTokens ?? 0
}
/** Projects durable cumulative usage and synchronous current context pressure. */
export class SessionMetricsProjector {
private readonly usage = new WeakMap<Session, UsageState>()
/** @param ctx - Host context providing an optional token-meter service. */
constructor(private readonly ctx: Context) {}
/**
* Read a fresh detached projection through the session's durable tail.
* @param session - authoritative durable log owner.
* @returns cumulative usage and any currently measurable pressure.
*/
snapshot(session: Session): SessionMetrics {
const state = this.syncUsage(session)
const tokenMeter = this.ctx.get('tokenMeter') as TokenMeterLike | undefined
let contextTokens: number | undefined
if (tokenMeter !== undefined) {
try {
contextTokens = tokenMeter.measure(session).totalTokens
} catch {
// A malformed or temporarily unmeasurable replay has no honest pressure value.
}
}
return {
logRevision: state.logRevision,
projectionRevision: state.projectionRevision++,
uncachedInputTokens: state.uncachedInputTokens,
outputTokens: state.outputTokens,
cacheReadTokens: state.cacheReadTokens,
cacheWriteTokens: state.cacheWriteTokens,
...contextTokens === undefined ? {} : { contextTokens },
}
}
private syncUsage(session: Session): UsageState {
let state = this.usage.get(session)
if (state === undefined) {
state = {
logRevision: 0,
projectionRevision: 0,
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
byStep: new Map(),
}
this.usage.set(session, state)
}
while (state.logRevision < session.events.length) {
const event = session.events[state.logRevision]
/* v8 ignore next -- Session events are append-only and dense; logRevision is bounded by length. */
if (event === undefined) break
const usage = usageFrom(event)
if (usage !== undefined) recordUsage(state, usage.turn, usage.step, usage.usage)
state.logRevision++
}
return state
}
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -22,7 +22,7 @@ async function nextFrame<K extends MuxFrame['type']>(
}
describe('ApiProxy model-request telemetry', () => {
it('forwards only to open mux connections and never backfills history or reconnect baselines', async () => {
it('atomically measures the observed request, forwards only live, and degrades per field', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
@@ -35,6 +35,8 @@ describe('ApiProxy model-request telemetry', () => {
ctx,
} as Agent
ctx.agents.register(agent)
const measure = vi.fn(() => ({ totalTokens: 321 }))
const removeTokenMeter = ctx.provide('tokenMeter' as never, { measure } as never)
const api = createApiProxy(ctx, {
provider: 'test',
model: 'alpha',
@@ -48,13 +50,13 @@ describe('ApiProxy model-request telemetry', () => {
primaryAbort.signal,
)[Symbol.asyncIterator]()
expect((await nextFrame(primary, 'session/subscribed')).sessionId).toBe(session.id)
expect((await nextFrame(primary, 'session/metrics')).metrics).not.toHaveProperty('contextWindow')
agentEvents(ctx, agent).emit('agent/model-request', 1, 2, {
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
})
expect(measure).toHaveBeenCalledWith(session)
expect(await nextFrame(primary, 'session/model-request')).toEqual({
type: 'session/model-request',
sessionId: session.id,
@@ -62,6 +64,7 @@ describe('ApiProxy model-request telemetry', () => {
step: 2,
provider: 'test',
model: 'alpha',
contextTokens: 321,
contextWindow: 128_000,
})
@@ -70,7 +73,8 @@ describe('ApiProxy model-request telemetry', () => {
payload: { sessionId: session.id },
})
if (!history.result.ok) throw new Error('history failed')
expect(history.result.value.metrics).not.toHaveProperty('contextWindow')
expect(history.result.value).not.toHaveProperty('metrics')
expect(history.result.value).not.toHaveProperty('modelRequest')
const reconnectAbort = new AbortController()
const reconnect = api.events.mux(
@@ -78,8 +82,8 @@ describe('ApiProxy model-request telemetry', () => {
reconnectAbort.signal,
)[Symbol.asyncIterator]()
expect((await nextFrame(reconnect, 'session/subscribed')).sessionId).toBe(session.id)
expect((await nextFrame(reconnect, 'session/metrics')).metrics).not.toHaveProperty('contextWindow')
measure.mockImplementation(() => { throw new Error('unmeasurable replay') })
agentEvents(ctx, agent).emit('agent/model-request', 2, 1, {
provider: 'test',
model: 'without-capacity',
@@ -95,6 +99,22 @@ describe('ApiProxy model-request telemetry', () => {
})
}
removeTokenMeter()
agentEvents(ctx, agent).emit('agent/model-request', 3, 1, {
provider: 'test',
model: 'without-meter',
contextWindow: 64_000,
})
expect(await nextFrame(primary, 'session/model-request')).toEqual({
type: 'session/model-request',
sessionId: session.id,
turn: 3,
step: 1,
provider: 'test',
model: 'without-meter',
contextWindow: 64_000,
})
primaryAbort.abort()
reconnectAbort.abort()
await primary.return?.()

View File

@@ -10,7 +10,7 @@ import {
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, sessionMetricsSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
@@ -153,29 +153,13 @@ describe('sessions domain schemas', () => {
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
expect(sessionHistoryValueSchema.parse({
const history = sessionHistoryValueSchema.parse({
events: [],
hasMore: false,
projections: { asOfSeq: 11, values: { todos: [] } },
metrics: {
logRevision: 12,
projectionRevision: 4,
uncachedInputTokens: 1_000,
outputTokens: 200,
cacheReadTokens: 4_000,
cacheWriteTokens: 500,
contextTokens: 8_000,
},
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}).metrics?.contextTokens).toBe(8_000)
expect(() => sessionMetricsSchema.parse({
logRevision: 1,
projectionRevision: 0,
uncachedInputTokens: -1,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
})).toThrow()
})
expect(history.projections).toEqual({ asOfSeq: 11, values: { todos: [] } })
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
@@ -377,18 +361,6 @@ describe('events frame schemas', () => {
const frames = [
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
{
type: 'session/metrics',
sessionId: 's',
metrics: {
logRevision: 3,
projectionRevision: 1,
uncachedInputTokens: 100,
outputTokens: 20,
cacheReadTokens: 300,
cacheWriteTokens: 40,
},
},
{
type: 'session/model-request',
sessionId: 's',
@@ -396,6 +368,7 @@ describe('events frame schemas', () => {
step: 1,
provider: 'deepseek',
model: 'deepseek-chat',
contextTokens: 8_000,
contextWindow: 128_000,
},
{
@@ -419,6 +392,7 @@ describe('events frame schemas', () => {
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
for (const invalid of [
{ type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextTokens: -1 },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 },
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },

View File

@@ -1,143 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { affectsSessionMetrics, SessionMetricsProjector } from '../src/session-metrics.ts'
function assistant(
session: Session,
turn: number,
step: number,
usage: {
inputTokens: number
outputTokens: number
cacheReadTokens?: number
cacheWriteTokens?: number
},
): void {
session.append('assistant/chunk', {
turn,
step,
chunk: { type: 'usage', usage },
})
session.append('assistant/message', {
turn,
step,
message: createAssistantMessage({
content: [{ type: 'text', text: `answer-${turn}-${step}` }],
source: { provider: 'test', model: 'alpha' },
}),
usage,
}, { surfaceOp: 'append' })
}
describe('SessionMetricsProjector', () => {
it('filters text/reasoning deltas while retaining usage, headers, and surface mutations', () => {
const session = new Session(SessionId('metrics-filter'))
const text = session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'x' },
})
const usage = session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } },
})
const header = session.append('request/header', {
header: { config: { provider: 'test', model: 'alpha' } },
reason: 'initial',
})
const surface = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'question' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const plain = session.append('step/start', { turn: 1, step: 1 })
expect(affectsSessionMetrics(text)).toBe(false)
expect(affectsSessionMetrics(usage)).toBe(true)
expect(affectsSessionMetrics(header)).toBe(true)
expect(affectsSessionMetrics(surface)).toBe(true)
expect(affectsSessionMetrics(plain)).toBe(false)
})
it('folds usage by turn and step while synchronous pressure follows surface replacement', () => {
const ctx = new Context()
ctx.provide('tokenMeter', {
measure(session: Session) {
return { totalTokens: session.surface.nodes.length * 100 }
},
})
const session = new Session(SessionId('metrics-fold'))
const first = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'large old surface' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
assistant(session, 1, 1, {
inputTokens: 11,
outputTokens: 3,
cacheReadTokens: 89,
cacheWriteTokens: 8,
})
const projector = new SessionMetricsProjector(ctx)
expect(projector.snapshot(session)).toMatchObject({
uncachedInputTokens: 11,
outputTokens: 3,
cacheReadTokens: 89,
cacheWriteTokens: 8,
contextTokens: 200,
})
const assistantSeq = session.surface.nodes.at(-1)
if (assistantSeq === undefined) throw new Error('assistant surface missing')
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compact summary' }],
source: { kind: 'plugin', plugin: 'test' },
}), {
surfaceOp: { op: 'replace', start: first.seq, end: assistantSeq },
sourceEventSeqs: [first.seq, assistantSeq],
})
expect(projector.snapshot(session)).toMatchObject({
uncachedInputTokens: 11,
outputTokens: 3,
cacheReadTokens: 89,
cacheWriteTokens: 8,
contextTokens: 100,
})
session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: {
type: 'usage',
usage: { inputTokens: 12, outputTokens: 4, cacheReadTokens: 88, cacheWriteTokens: 9 },
},
})
assistant(session, 1, 2, { inputTokens: 1_000, outputTokens: 500 })
expect(projector.snapshot(session)).toMatchObject({
logRevision: session.events.length,
projectionRevision: 2,
uncachedInputTokens: 1_012,
outputTokens: 504,
cacheReadTokens: 88,
cacheWriteTokens: 9,
contextTokens: 200,
})
})
it('omits pressure when the token meter is absent or cannot measure the replay', () => {
const session = new Session(SessionId('metrics-pressure-unknown'))
const withoutMeter = new SessionMetricsProjector(new Context()).snapshot(session)
expect(withoutMeter.contextTokens).toBeUndefined()
const ctx = new Context()
ctx.provide('tokenMeter', {
measure() {
throw new Error('unmeasurable replay')
},
})
expect(new SessionMetricsProjector(ctx).snapshot(session).contextTokens).toBeUndefined()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: cfc3aafc03c27f8a4d879066ab5b46c13e1c2009
README.zh.md: 946d2d5d9acc369d37515f54feca47d4c061f92c
README.md: 1b815cfba8d2ee1dd69e1f00b8a3911c7d9a788c
README.zh.md: 4ba2d340429077a43f88132dceacdabff1f9cf92

View File

@@ -16,7 +16,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize an adapter-configured default without clamping.
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus available context metadata in one exact-model lookup and capture its current adapter registration as one cancellable, one-shot call.
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata (including capacity when available) in one exact-model lookup and capture its current adapter registration as one cancellable, one-shot call.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.

View File

@@ -16,7 +16,7 @@
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置与可用上下文元数据,并将其当前适配器注册捕获为一次可取消、一次性调用。
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置与脱耦的上下文元数据(可用时包括容量),并将其当前适配器注册捕获为一次可取消、一次性调用。
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
README.md: 578728ded9cf51a12abcd70d541404e995028f26
README.zh.md: 51518e98c43e740822970c1a39154f550ea962dc
README.md: 5ab1315d8f5885fb24ea48d4e97b7ab9a596483c
README.zh.md: 063370e90cd01414e899bbde76a68dc3b603c06b

View File

@@ -21,6 +21,12 @@ The fold tracks full request-header snapshots, step boundaries, surface appends
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output.
## Session projection
When the composition provides `ctx.sessionProjections`, token-meter registers the `tokenUsage` unit through an optional child fiber. Its client-safe value is the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision.
The unit uses the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes the key. A headless or TUI composition without the projection seam keeps the measurement service's existing behavior.
## Composition
```yaml

View File

@@ -21,6 +21,12 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
用量计量会求和不重叠的输入、cache-read、cache-write 与输出 bucket;不会再次添加 reasoning。每次成功调用都会记录一个 assistant 锚点,包括无内容调用。显式空溯源列表表示已知空提供方流,而缺失的遗留溯源会保守地将持久 assistant 输出视为提供方输出。
## 会话投影
当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册 `tokenUsage` 单元。其可安全传给客户端的值包含完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。
该单元使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除该键。不带投影 seam 的 headless 或 TUI 组合会保留测量服务的既有行为。
## 组合
```yaml

View File

@@ -17,7 +17,7 @@
},
"./client": {
"types": "./lib/types/client.d.ts",
"default": "./lib/client.js"
"default": "./lib/types/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
@@ -25,7 +25,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"