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

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

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

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

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

The `contextPressure` projection pairs the newest provider-reported prompt size
with the newest recorded capacity. The two are deliberately not one atomic
request observation: switching models can pair a fresh capacity with the prior
route's pressure until the next request reports usage. The figure is a
user-facing reference, and this matches how the TUI status line has always
computed occupancy.
This commit is contained in:
Hypatia May
2026-07-30 13:53:08 +08:00
parent fdccc58cef
commit 4819210142
62 changed files with 382 additions and 1362 deletions

View File

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

View File

@@ -35,6 +35,10 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
})
}
/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
export type ConnectionState = 'connected' | 'reconnecting'
/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to
* SessionManager. */
export interface ConnectionSinks {
@@ -42,8 +46,9 @@ export interface ConnectionSinks {
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
onConnected?: () => void
/** After every failed generation closes and before retry starts. Not emitted when the controller is stopped. */
onDisconnected?: () => void
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
onStateChange?: (state: ConnectionState) => void
}
/**
@@ -58,6 +63,7 @@ export class ConnectionController {
private attempt = 0
private current: AbortController | null = null
private running = false
private lastState: ConnectionState | null = null
private readonly config: Required<ConnectionConfig>
constructor(
@@ -114,8 +120,8 @@ export class ConnectionController {
if (gen === this.generation && !ac.signal.aborted) ac.abort()
resolve()
}
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, ac.signal, settle)
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, ac.signal, settle)
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle)
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle)
})
try {
@@ -132,6 +138,7 @@ export class ConnectionController {
timeout.abort()
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.emitState('connected')
this.callSink(this.sinks.onConnected)
} catch {
// Transport failure: treat as generation failure, fall through to the shared backoff.
@@ -140,7 +147,7 @@ export class ConnectionController {
await failed
if (!this.isRunning()) return
this.callSink(this.sinks.onDisconnected)
this.emitState('reconnecting')
this.attempt += 1
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
const idle = new AbortController()
@@ -148,15 +155,20 @@ export class ConnectionController {
}
}
/** Deduplicated state emission (sink isolation applies). */
private emitState(state: ConnectionState): void {
if (this.lastState === state) return
this.lastState = state
this.callSink(() => this.sinks.onStateChange?.(state))
}
private async pumpStream<F extends { type: string }>(
stream: AsyncIterable<RpcRequest<F>>,
sink: ((envelope: RpcRequest<F>) => void) | undefined,
signal: AbortSignal,
onEnd: () => void,
): Promise<void> {
try {
for await (const envelope of stream) {
if (signal.aborted) break
if (envelope.payload.type === 'stream/error') break
if (sink !== undefined) this.callSink(() => { sink(envelope) })
}

View File

@@ -444,6 +444,47 @@ function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection
return totals
}
/** Latest log-only capacity record, or undefined before any request ran. */
function lastRequestContext(
log: readonly SessionEvent[],
): { provider: string; model: string; contextWindow: number } | undefined {
const event = log.findLast(item => (item as { type: string }).type === 'request/context')
return event === undefined
? undefined
: (event as unknown as { data: { provider: string; model: string; contextWindow: number } }).data
}
/**
* Fixture parallel of token-meter's request-pressure projection: the last
* provider-reported prompt size paired with the last recorded capacity. The
* two need not come from one request — see the token-meter README.
*/
function contextPressureOf(
log: readonly SessionEvent[],
): { pressureTokens: number; contextWindow?: number } {
let pressureTokens = 0
for (const event of log) {
const item = event as unknown as {
type: string
data: { 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) continue
pressureTokens = usage.inputTokens
+ (usage.cacheReadTokens ?? 0)
+ (usage.cacheWriteTokens ?? 0)
}
const contextWindow = lastRequestContext(log)?.contextWindow
return {
pressureTokens,
...contextWindow === undefined ? {} : { contextWindow },
}
}
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')
@@ -460,23 +501,32 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
values['goal'] = backscanGoal(log)
// Always present (token-meter composed): full-log provider billing.
values['tokenUsage'] = tokenUsageOf(log)
// Always present (token-meter composed): last request pressure and capacity.
values['contextPressure'] = contextPressureOf(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
// One usage sample advances both token-meter units.
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 },
{ type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq },
]
}
if (type === 'request/context') {
return [{
type: 'session/projection',
sessionId: id,
key: 'tokenUsage',
value: tokenUsageOf(log),
key: 'contextPressure',
value: contextPressureOf(log),
seq: event.seq,
}]
}
@@ -1136,20 +1186,16 @@ 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) })
// Capacity parallel of the host token-meter's request/context record:
// log-only, appended inside the open turn, and deduplicated against the
// route already recorded (the fixture never varies contextWindow).
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
emitMux({
type: 'session/model-request',
sessionId: id,
// The fixture's durable transcript is historically zero-based, while
// the real Agent's request telemetry opens turns at one.
turn: turn + 1,
step: 1,
provider: target.provider,
model: target.model,
// No fixture token-meter is composed, so omit the request-pressure
// numerator instead of substituting cumulative provider billing.
contextWindow: 128_000,
})
if (lastRequestContext(logOf(id))?.model !== target.model) {
append(id, {
type: 'request/context',
data: { provider: target.provider, model: target.model, contextWindow: 128_000 },
})
}
startReply(
id,
turn,

View File

@@ -5,7 +5,7 @@
*/
import type { Context } from 'cordis'
import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks } from './connection.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
@@ -17,7 +17,7 @@ export type {
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock,
ModelReasoningEffort, ModelTarget, SessionModels,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
@@ -27,7 +27,7 @@ export { RpcId, AbstractApiClient, transportError } from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks }
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
/** Required services (none — this is the wire root). */

View File

@@ -7,7 +7,8 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { IApiClient, SessionId } from '../src/client/api.ts'
import type { SessionId } from '../src/client/api.ts'
import type { ConnectionState } from '../src/client/connection.ts'
import { ConnectionController } from '../src/client/connection.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
@@ -103,51 +104,6 @@ describe('connection lifecycle', () => {
}
})
it('drops a sibling stream frame buffered behind a generation failure', async () => {
const api = new FakeApiClient()
const lateMux = deferred<undefined>()
const originalEvents = api.events
Object.defineProperty(api, 'events', {
value: {
host: (...args: Parameters<IApiClient['events']['host']>) => originalEvents.host(...args),
mux: (_payload: unknown, _signal: AbortSignal, onOpen?: () => void) => (async function* () {
onOpen?.()
await lateMux.promise
yield { rpcId: 'late-mux' as never, payload: subscribedFrame(2) }
})(),
} satisfies IApiClient['events'],
})
const muxSeen: number[] = []
let connected = 0
let disconnected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onMuxEnvelope: (envelope) => {
if (envelope.payload.type === 'session/subscribed') muxSeen.push(envelope.payload.lastSeq)
},
onConnected: () => { connected++ },
onDisconnected: () => {
disconnected++
lateMux.resolve(undefined)
controller.stop()
},
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
api.pushHost({
type: 'stream/error',
error: { code: 'internal', message: 'host stream failed', details: {} },
})
await vi.waitFor(() => { expect(disconnected).toBe(1) })
await new Promise(resolve => setTimeout(resolve, 0))
expect(muxSeen).toEqual([])
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('isolates sink exceptions from the pump', async () => {
const api = new FakeApiClient()
const seen: string[] = []
@@ -203,7 +159,29 @@ describe('connection lifecycle', () => {
}
})
it('reports every failed generation before retry', async () => {
it('emits deduplicated connected/reconnecting state transitions', async () => {
const api = new FakeApiClient()
const states: ConnectionState[] = []
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['connected'])
api.failStreams(new Error('torn'))
await vi.waitFor(() => { expect(connected).toBe(2) })
expect(states).toEqual(['connected', 'reconnecting', 'connected'])
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
let describeCalls = 0
@@ -211,19 +189,19 @@ describe('connection lifecycle', () => {
describeCalls++
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
}
let disconnected = 0
const states: ConnectionState[] = []
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onDisconnected: () => { disconnected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(disconnected).toBe(2)
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {
controller.stop()
warnSpy.mockRestore()

View File

@@ -194,19 +194,17 @@ 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: 1,
step: 1,
provider: 'deepseek',
model: 'deepseek-v4-flash',
contextWindow: 128_000,
})
// Capacity is durable log state, not a transient frame: the prompt path
// records request/context and the projection carries it to the client.
expect(types).toContain('request/context')
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'tokenUsage'
&& (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true)
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'contextPressure'
&& (frame.value as { contextWindow?: number }).contextWindow === 128_000)).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.
@@ -238,7 +236,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 9) abort.abort()
if (envelopes.length >= 10) abort.abort()
}
return envelopes
}
@@ -253,11 +251,11 @@ describe('createFixtureApi', () => {
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: '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)
expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' })
expect(first[8]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[9]?.rpcId).toBe(first[9]?.rpcId)
})
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: cc3c4a8ad293451323a757a8ee85e3b903dc176f
README.zh.md: f95b3966708b19e57b5c9ef46e0d165dafffdd53
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9

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`, `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; removal also installs a request-only fence so a late transient frame from the independent mux stream cannot repopulate request telemetry, and the next mux subscription or connection generation releases that fence without blocking replayable frame classes. Reconnect, restore, and a new subscription therefore show no context percentage until another request is observed. Model selection alone does not alter request telemetry.
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`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
## Workspace and Session lists
@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## Session title projection
`SessionManager` retains the generic per-session projection store independently of Session-instance arrival, so live `title` frames can update list rows before a conversation opens. A subscription baseline truncates projection rows beyond `lastSeq`; the next history-tail baseline re-seeds durable values, and explicit Session removal clears the store. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` always falls back through the cwd basename and session id while the `title` key is absent.
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
## Session model selection

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost 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`、断开连接和移除会话则会清除常驻值与待处理值;移除还会安装仅针对请求的栅栏,避免独立 mux 流中延迟到达的瞬时帧重新填充请求遥测,下一次 mux 订阅或连接 generation 会解除该栅栏,而不会阻断可回放的帧类别。因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
## Workspace 与 Session 列表
@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## Session 标题投影
`SessionManager` 独立于 Session 实例是否到达而保留逐会话通用投影值仓,因此实时 `title` 帧可以在会话打开前更新列表行。订阅基线会截断 seq 超过 `lastSeq`投影行;下一份 history 尾页基线重新播种持久值,显式移除 Session 则清除该值仓。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`title` key 缺失时,`displayTitle` 始终依次回退到 cwd basename 和 Session id。
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过 `lastSeq`任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
## 会话模型选择

View File

@@ -40,7 +40,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
@@ -154,11 +154,11 @@ export function apply(ctx: Context): void {
workspaces.handleConnected()
ctx.emit('connection/reset')
},
onDisconnected: () => {
onStateChange: (state) => {
// Generation death fires before any next-generation frame can arrive
// (reconnect replays flow from stream open, ahead of onConnected):
// the only safe moment to drop generation-scoped interaction state.
sessions.handleDisconnected()
if (state === 'reconnecting') sessions.handleDisconnected()
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')

View File

@@ -5,11 +5,14 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
ModelRequestTelemetry, RpcError, SessionId, ToolCallView, ToolResultView,
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -267,6 +270,4 @@ export interface ConversationSnapshot {
*/
blank: boolean
lastAgentError: string | null
/** Latest atomic model-request snapshot on this mux generation. */
modelRequest: ModelRequestTelemetry | null
}

View File

@@ -2,10 +2,7 @@
// 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 {
HostFrame, IApiClient, ModelRequestTelemetry, MuxFrame, RpcError, RpcRequest,
RpcResult, SessionId, SessionSummary, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient, HostFrame, 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'
@@ -60,19 +57,6 @@ export class SessionManager {
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
/**
* 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 modelRequests = new Map<SessionId, ModelRequestTelemetry>()
/**
* Removal fence for the one non-replayable mux frame. Host and mux use
* independent SSE streams, so a request emitted before removal can arrive
* after host/session-removed. Durable/replayed frame classes stay unfenced;
* the next mux subscription is the same-stream proof that the id is live.
*/
private readonly removedModelRequests = new Set<SessionId>()
/** 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
@@ -181,7 +165,6 @@ export class SessionManager {
}
private createSession(sessionId: SessionId): Session {
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.
@@ -189,7 +172,6 @@ export class SessionManager {
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
},
projections: this.projectionStore(sessionId),
...(modelRequest === undefined ? {} : { modelRequest }),
})
}
@@ -364,16 +346,7 @@ export class SessionManager {
this.notifier.markDirty()
return
}
if (frame.type === 'session/model-request') {
if (this.removedModelRequests.has(frame.sessionId)) return
// 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.removedModelRequests.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)
@@ -449,11 +422,9 @@ export class SessionManager {
return
}
case 'host/session-removed': {
this.removedModelRequests.add(frame.sessionId)
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.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
@@ -494,9 +465,6 @@ export class SessionManager {
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
else this.pendingBuffers.set(sessionId, kept)
}
this.modelRequests.clear()
this.removedModelRequests.clear()
for (const session of this.sessions.values()) session.handleReconnecting()
}
/** After each connection generation: refresh the session baseline and rebuild opened windows. */

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,
ModelRequestTelemetry, SessionId, ToolEventView,
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,6 @@ export interface SessionOptions {
* private store (bare object-layer construction).
*/
projections?: ProjectionValueStore
/** 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. */
@@ -84,8 +82,9 @@ export class Session implements SessionFace {
private openState: OpenState = 'cold'
private openError: RpcError | null = null
private openPromise: Promise<void> | null = null
/** Bumped at disconnect and resync to invalidate in-flight history work: a reconnect must
* rebuild, never adopt a pre-disconnect response (audit S4). */
/** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private readonly foldAdapter = new FoldAdapter()
@@ -110,8 +109,6 @@ 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
/** 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[]>()
@@ -173,7 +170,6 @@ export class Session implements SessionFace {
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.modelRequest = options.modelRequest ?? null
this.snapshotCache = this.buildSnapshot()
}
@@ -286,14 +282,12 @@ export class Session implements SessionFace {
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
const generation = this.openGeneration
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
if (generation !== this.openGeneration) return
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -317,10 +311,8 @@ export class Session implements SessionFace {
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
} finally {
if (generation === this.openGeneration) {
this.loadingOlder = false
this.notifier.markDirty()
}
this.loadingOlder = false
this.notifier.markDirty()
}
}
@@ -329,10 +321,11 @@ 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 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 request telemetry.
// session/subscribed owns the reset before the queue snapshot.
// The queue mirror is NOT cleared here: onConnected (which drives resync)
// races the mux frames — the fresh generation's baseline may have landed
// already, and the host never resends it. The mirror re-baselines on the
// session/subscribed frame instead (same stream as the queue snapshot
// that follows it, so ordering is guaranteed).
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
@@ -341,8 +334,6 @@ export class Session implements SessionFace {
this.events = []
this.views = []
this.baseSeq = 0
this.loadingOlder = false
this.stitching = false
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
this.pending.clear()
@@ -403,7 +394,6 @@ export class Session implements SessionFace {
}
case 'session/subscribed': {
this.subscribedLastSeq = frame.lastSeq
let changed = false
// New mux-generation baseline: the host pushes this session's queue
// snapshot AFTER the subscribed frame on the same stream, so the
// stale mirror clears here — race-free against onConnected/resync
@@ -411,25 +401,8 @@ export class Session implements SessionFace {
if (this.queued.length > 0) {
this.queued = []
this.queueRev++
changed = true
this.notifier.markDirty()
}
if (this.modelRequest !== null) {
this.modelRequest = null
changed = true
}
if (changed) this.notifier.markDirty()
return
}
case 'session/model-request': {
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
}
case 'approval/requested': {
@@ -501,41 +474,10 @@ export class Session implements SessionFace {
this.notifier.markDirty()
}
/** Connection-loss boundary: clear values that are not replayed before the next stream starts. */
handleReconnecting(): void {
this.openGeneration++
let changed = false
if (this.openState === 'loading') {
// The in-flight history request belongs to the dead generation. Its
// eventual success or failure is fenced below, so settle the visible
// pane now instead of leaving it loading throughout an outage.
this.openState = 'error'
this.openError = {
code: 'cancelled',
message: 'session history request cancelled after connection loss',
details: {},
}
changed = true
}
if (this.loadingOlder) {
// The stale request's generation-fenced finally cannot clear this bit.
// Release the paging control synchronously at the connection boundary.
this.loadingOlder = false
changed = true
}
if (this.modelRequest !== null) {
this.modelRequest = null
changed = true
}
if (changed) this.notifier.markDirty()
}
/** host/session-removed relay: flag the resident snapshot and clear request telemetry. */
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
handleRemoved(): void {
const changed = !this.removed || this.modelRequest !== null
this.removed = true
this.modelRequest = null
if (changed) this.notifier.markDirty()
this.notifier.markDirty()
}
/**
@@ -579,23 +521,13 @@ export class Session implements SessionFace {
this.openError = result.error
return
}
this.installWindow(
result.value.events,
result.value.hasMore,
result.value.projections,
)
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
// 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()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) {
this.installWindow(
result.value.events,
result.value.hasMore,
result.value.projections,
)
}
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
this.openState = 'open'
} catch (error) {
@@ -616,11 +548,7 @@ export class Session implements SessionFace {
* A carried projections block seeds the value store (higher seq wins, so a stale
* baseline cannot overwrite a newer push frame); the window events themselves are
* never folded — the host is the only computation site. */
private installWindow(
entries: HistoryEntry[],
hasMore: boolean,
projections: ProjectionsBaseline | undefined,
): void {
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
@@ -676,16 +604,12 @@ export class Session implements SessionFace {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(
result.value.events,
result.value.hasMore,
result.value.projections,
)
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
} finally {
if (generation === this.openGeneration) this.stitching = false
this.stitching = false
}
}
@@ -917,7 +841,6 @@ export class Session implements SessionFace {
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
modelRequest: this.modelRequest,
}
}
}

View File

@@ -102,60 +102,6 @@ describe('runtime client apply', () => {
expect(bench.api.callsOf('session.create')).toHaveLength(1)
})
it('clears connection-local request telemetry after every disconnected generation but not connected', async () => {
const bench = await mount()
const sessions = bench.ctx.get('sessions') as SessionsService
bench.sinks?.onHostEnvelope?.({
rpcId: 'session' as never,
payload: { type: 'host/session-added', blank: true, sessionId: 's-state' } as never,
})
await Promise.resolve()
const session = sessions.binding('s-state' as never)?.session
if (session === undefined) throw new Error('session binding missing')
bench.sinks?.onMuxEnvelope?.({
rpcId: 'request' as never,
payload: {
type: 'session/model-request',
sessionId: 's-state',
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 32_000,
contextWindow: 128_000,
} as never,
})
bench.sinks?.onConnected?.()
expect(session.getSnapshot().modelRequest).toMatchObject({
model: 'alpha',
contextTokens: 32_000,
contextWindow: 128_000,
})
bench.sinks?.onDisconnected?.()
expect(session.getSnapshot().modelRequest).toBeNull()
// Every failed generation invokes its own disconnect callback, which
// clears telemetry received before that generation's handshake failed.
bench.sinks?.onMuxEnvelope?.({
rpcId: 'request-2' as never,
payload: {
type: 'session/model-request',
sessionId: 's-state',
turn: 2,
step: 1,
provider: 'test',
model: 'beta',
contextTokens: 48_000,
contextWindow: 256_000,
} as never,
})
expect(session.getSnapshot().modelRequest?.model).toBe('beta')
bench.sinks?.onDisconnected?.()
expect(session.getSnapshot().modelRequest).toBeNull()
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))

View File

@@ -4,8 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels,
SessionProjectionsBlock, SkillEntry,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -65,11 +64,7 @@ export class FakeApiClient implements IApiClient {
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{
events: never[]
hasMore: boolean
projections?: SessionProjectionsBlock
}>> =
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({

View File

@@ -41,228 +41,6 @@ describe('instances', () => {
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
it('retains the latest transient request snapshot until lazy instantiation', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'request-1' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 12_000,
contextWindow: 128_000,
},
})
manager.handleMuxEnvelope({
rpcId: 'request-2' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 1,
step: 2,
provider: 'test',
model: 'beta',
contextTokens: 32_000,
contextWindow: 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 whole-frame replacement before lazy instantiation', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'request-with-capacity' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
},
})
manager.handleMuxEnvelope({
rpcId: 'request-without-capacity' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 1,
step: 2,
provider: 'test',
model: 'unknown-capacity',
},
})
expect(manager.get(S1).getSnapshot().modelRequest).toEqual({
turn: 1,
step: 2,
provider: 'test',
model: 'unknown-capacity',
})
})
it('clears retained request telemetry on subscribed and removal', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'request-before-subscribe' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
},
})
manager.handleMuxEnvelope({
rpcId: 'subscribed' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 },
})
const session = manager.get(S1)
expect(session.getSnapshot().modelRequest).toBeNull()
manager.handleMuxEnvelope({
rpcId: 'request-after-subscribe' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 1,
step: 2,
provider: 'test',
model: 'beta',
contextWindow: 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().modelRequest).toBeNull()
manager.handleMuxEnvelope({
rpcId: 'late-resident-request' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 2,
step: 1,
provider: 'test',
model: 'late',
contextWindow: 512_000,
},
})
expect(session.getSnapshot().modelRequest).toBeNull()
manager.handleMuxEnvelope({
rpcId: 'resumed-subscription' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 },
})
manager.handleMuxEnvelope({
rpcId: 'resumed-request' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 1,
step: 1,
provider: 'test',
model: 'resumed',
contextWindow: 256_000,
},
})
expect(session.getSnapshot().modelRequest).toMatchObject({
model: 'resumed',
contextWindow: 256_000,
})
manager.handleMuxEnvelope({
rpcId: 'request-before-lazy-removal' as never,
payload: {
type: 'session/model-request',
sessionId: S2,
turn: 1,
step: 1,
provider: 'test',
model: 'gamma',
contextWindow: 64_000,
},
})
manager.handleHostEnvelope({
rpcId: 'lazy-removed' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
manager.handleMuxEnvelope({
rpcId: 'late-lazy-request' as never,
payload: {
type: 'session/model-request',
sessionId: S2,
turn: 2,
step: 1,
provider: 'test',
model: 'late-lazy',
contextWindow: 512_000,
},
})
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
})
it('clears resident and lazy request telemetry on disconnect', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const session = manager.get(S1)
manager.handleMuxEnvelope({
rpcId: 'resident-request' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 1,
step: 1,
provider: 'test',
model: 'resident',
contextTokens: 35,
contextWindow: 128_000,
},
})
manager.handleMuxEnvelope({
rpcId: 'lazy-request' as never,
payload: {
type: 'session/model-request',
sessionId: S2,
turn: 1,
step: 1,
provider: 'test',
model: 'lazy',
contextTokens: 70,
contextWindow: 256_000,
},
})
expect(session.getSnapshot().modelRequest).toMatchObject({
model: 'resident',
contextTokens: 35,
contextWindow: 128_000,
})
manager.handleDisconnected()
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', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)

View File

@@ -93,13 +93,6 @@ describe('queue retirement (host queuedMirror rules)', () => {
expect(session.getSnapshot().queue).toHaveLength(1)
})
it('an unrelated durable event leaves the queue unchanged', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: ev.user(0, 'unrelated') })
expect(session.getSnapshot().queue.map(row => row.key)).toEqual(['p-1'])
})
it('steering/message drains the source-matched steering row only', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering

View File

@@ -7,11 +7,8 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
SessionId, SessionProjectionsBlock,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
@@ -25,17 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
return { api, session: new Session(SID, api) }
}
function histResponse(
events: SessionEvent[],
hasMore = false,
projections?: SessionProjectionsBlock,
) {
function histResponse(events: SessionEvent[], hasMore = false) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({
events: entries(events) as never[],
hasMore,
...projections === undefined ? {} : { projections },
}))
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('open', () => {
@@ -51,7 +40,6 @@ describe('open', () => {
expect(snapshot.openState).toBe('open')
expect(snapshot.hasMore).toBe(true)
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
expect(snapshot.modelRequest).toBeNull()
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
@@ -116,87 +104,6 @@ describe('live event path', () => {
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
it('replaces the whole request snapshot, clears omitted fields, and resets at subscription', async () => {
const { session } = await opened()
session.handleMuxEnvelope('request-1' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 32_000,
contextWindow: 128_000,
})
expect(session.getSnapshot().modelRequest).toEqual({
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 32_000,
contextWindow: 128_000,
})
session.handleMuxEnvelope('request-2' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 2,
step: 1,
provider: 'test',
model: 'without-capacity',
})
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().modelRequest).toBeNull()
session.handleMuxEnvelope('request-3' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 3,
step: 1,
provider: 'test',
model: 'beta',
contextTokens: 20,
contextWindow: 256_000,
})
expect(session.getSnapshot().modelRequest).toMatchObject({
turn: 3,
contextTokens: 20,
contextWindow: 256_000,
})
})
it('publishes a subscribed reset when request telemetry arrived first', async () => {
const { session } = await opened()
session.handleMuxEnvelope('request' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 8_000,
contextWindow: 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().modelRequest).toBeNull()
})
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
// Live path: run mints an executing node, done settles it in the flow.
const { session } = await opened()
@@ -353,27 +260,6 @@ describe('paging', () => {
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
})
it('drops an older page from the disconnected generation', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答'), true)
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const loading = session.loadOlder()
session.handleReconnecting()
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[],
hasMore: false,
}))
await loading
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9])
api.onHistory = () => histResponse(plainTurn(12, 2, '重连问', '重连答'))
await session.resync()
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([13, 15])
})
})
describe('prompt and cancel errors', () => {
@@ -416,23 +302,6 @@ describe('prompt and cancel errors', () => {
})
describe('pending interactions', () => {
it('routes an approval wait response through the original requested rpcId', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('ra-answer' as never, {
type: 'approval/requested',
sessionId: SID,
approvalId: 'ap-answer' as never,
toolName: 'bash',
})
const wait = session.getSnapshot().pending[0]!
await wait.respond({ ok: true, value: { decision: 'allow' } })
expect(api.callsOf('respond')).toEqual([{
type: 'client-response',
rpcId: 'ra-answer',
result: { ok: true, value: { decision: 'allow' } },
}])
})
it('adds approval/question on requested and removes them on resolved', async () => {
const { session } = makeSession()
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
@@ -475,16 +344,6 @@ describe('pending interactions', () => {
})
describe('remaining branches', () => {
it('rejects a second scope bind and allows rebinding after explicit release', () => {
const { session } = makeSession()
const first = new Context()
const second = new Context()
session.bindScope(first)
expect(() => { session.bindScope(second) }).toThrow(`session ${SID} already has a bound scope`)
session.unbindScope()
expect(() => { session.bindScope(second) }).not.toThrow()
})
it('prompt transport throw folds to internal promptError', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
@@ -715,49 +574,22 @@ describe('remaining branches', () => {
expect(session.getSnapshot().openState).toBe('open')
})
it('drops a stale gap repair without clearing a newer generation repair', async () => {
it('drops a gap repair superseded by a full resync while its pull was in flight', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const staleRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => staleRepair.promise
const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => repairPull.promise
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
session.handleReconnecting()
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
await session.resync()
const freshRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let freshRepairCalls = 0
api.onHistory = () => {
freshRepairCalls++
return freshRepair.promise
}
session.handleMuxEnvelope('fresh-gap' as never, {
type: 'session/event',
sessionId: SID,
event: ev.user(15, '新洞'),
})
expect(freshRepairCalls).toBe(1)
staleRepair.resolve(ok({
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
})) // repair result: stale, dropped
await Promise.resolve()
session.handleMuxEnvelope('fresh-buffer' as never, {
type: 'session/event',
sessionId: SID,
event: ev.user(16, '继续缓存'),
})
expect(freshRepairCalls).toBe(1) // stale finally did not clear the newer stitching owner
freshRepair.resolve(ok({
events: entries([...plainTurn(6, 1, 'c', 'd'), ...plainTurn(12, 2, 'e', 'f')]) as never[],
hasMore: false,
}))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9, 13, 15])
})
await resynced
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
})
it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => {
@@ -822,133 +654,6 @@ describe('remaining branches', () => {
})
describe('resync', () => {
it('settles an in-flight open when its connection generation dies', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const opening = session.open()
expect(session.getSnapshot().openState).toBe('loading')
session.handleReconnecting()
expect(session.getSnapshot()).toMatchObject({
openState: 'error',
openError: {
code: 'cancelled',
message: 'session history request cancelled after connection loss',
details: {},
},
})
stale.reject(new Error('dead generation failed'))
await opening
expect(session.getSnapshot()).toMatchObject({
openState: 'error',
openError: {
code: 'cancelled',
message: 'session history request cancelled after connection loss',
details: {},
},
})
})
it('settles an in-flight older-page load when its connection generation dies', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答'), true)
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const paging = session.loadOlder()
expect(session.getSnapshot().loadingOlder).toBe(true)
session.handleReconnecting()
expect(session.getSnapshot().loadingOlder).toBe(false)
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[],
hasMore: false,
}))
await paging
expect(session.getSnapshot().loadingOlder).toBe(false)
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9])
})
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()
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().modelRequest).toBeNull()
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[],
hasMore: false,
}))
await opening
expect(session.getSnapshot().nodes).toEqual([])
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().modelRequest).toBeNull()
})
it('preserves a fresh-generation request snapshot when history resync fails', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('sub' as never, {
type: 'session/subscribed',
sessionId: SID,
lastSeq: 5,
})
expect(session.getSnapshot().modelRequest).toBeNull()
session.handleMuxEnvelope('fresh-request' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 2,
step: 1,
provider: 'test',
model: 'fresh',
contextTokens: 20,
contextWindow: 256_000,
})
api.onHistory = () => Promise.resolve(err({
code: 'internal',
message: 'history refresh failed',
details: {},
}))
await session.resync()
expect(session.getSnapshot()).toMatchObject({
openState: 'error',
modelRequest: {
turn: 2,
step: 1,
provider: 'test',
model: 'fresh',
contextTokens: 20,
contextWindow: 256_000,
},
})
})
it('rebuilds the window and clears pending; cold instances no-op', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))

View File

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

View File

@@ -221,9 +221,7 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({
useProjection, useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder,
}: ChatViewSlotProps) {
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -231,8 +229,7 @@ export function ChatView({
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const openState = useSession(s => s.openState)
const openError = useSession(s => s.openError)
const openErrorMessage = openError === null ? null : `${openError.message}${openError.code}`
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
@@ -356,12 +353,7 @@ export function ChatView({
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && openError?.code === 'cancelled' && (
<div className={css.hint}></div>
)}
{openState === 'error' && openError?.code !== 'cancelled' && (
<div className={css.openError}>{openErrorMessage}</div>
)}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
@@ -397,7 +389,7 @@ export function ChatView({
{running && <TurnDots />}
</div>
</div>
<StatsLine useSession={useSession} useProjection={useProjection} />
<StatsLine useSession={useSession} />
{!atBottom && (
<button
type="button"

View File

@@ -1,12 +1,13 @@
// Settled-node identity prevents stream-delta updates from rerendering this row.
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { memo, useMemo } from 'react'
import type {
ConversationSnapshot, UseProjection,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelRequestTelemetry } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import css from './StatsLine.module.css'
interface VisibleCounts {
@@ -57,16 +58,19 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null {
}
/**
* Current context occupancy using the TUI's integer rounding and upper clamp.
* @param request - one atomic request snapshot observed on this mux generation.
* @returns occupancy percent, or null when either input is unavailable.
* Approximate context occupancy, using the TUI's integer rounding and upper
* clamp. The numerator and capacity are independent last-wins projection
* fields, so this is a reference figure rather than an exact request
* measurement (see the token-meter README).
* @param pressure - the session's context-pressure projection value.
* @returns occupancy percent, or null when no capacity is known.
*/
export function contextPercent(request: ModelRequestTelemetry | null): number | null {
if (request?.contextTokens === undefined || request.contextWindow === undefined) return null
return Math.min(100, Math.round(request.contextTokens / request.contextWindow * 100))
export function contextPercent(pressure: ContextPressureProjection | undefined): number | null {
if (pressure?.contextWindow === undefined) return null
return Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100))
}
/** Props: standard session hooks handed down by ChatView. */
/** Props: the framework's session snapshot and projection hook seats. */
export interface StatsLineProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
useProjection: UseProjection
@@ -74,8 +78,8 @@ export interface StatsLineProps {
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const modelRequest = useSession(s => s.modelRequest)
const usage = useProjection('tokenUsage')
const pressure = useProjection('contextPressure')
const counts = useMemo(() => deriveVisibleCounts(nodes), [nodes])
const hasUsage = usage !== undefined && (
usage.uncachedInputTokens !== 0
@@ -83,24 +87,22 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
|| usage.cacheReadTokens !== 0
|| usage.cacheWriteTokens !== 0
)
const context = contextPercent(modelRequest)
const context = contextPercent(pressure)
if (counts.steps === 0 && !hasUsage && context === null) return null
const parts: string[] = []
if (usage === undefined) {
parts.push('usage unknown')
} else {
if (usage !== undefined) {
parts.push(`${formatMetricTokens(usage.uncachedInputTokens)} uncached input`)
parts.push(`${formatMetricTokens(usage.outputTokens)} output`)
parts.push(`${formatMetricTokens(usage.cacheReadTokens)} cache read`)
const cacheHit = cacheHitPercent(usage)
if (cacheHit !== null) parts.push(`cache hit ${cacheHit}%`)
}
// contextPercent validates both fields; repeat the capacity guard so that
// TypeScript carries the same refinement into the formatting branch.
parts.push(context === null || modelRequest?.contextWindow === undefined
? 'context unknown'
: `context ${context}% of ${formatMetricTokens(modelRequest.contextWindow)}`)
// Capacity absent (no token-meter, or an adapter that advertises none) omits
// the segment: an unknown denominator has no percentage worth a placeholder.
if (context !== null && pressure?.contextWindow !== undefined) {
parts.push(`context ${context}% of ${formatMetricTokens(pressure.contextWindow)}`)
}
parts.push(`${counts.turns} turns`)
parts.push(`${counts.steps} steps`)
return <div className={css.root}>{parts.join(' · ')}</div>

View File

@@ -207,25 +207,15 @@ describe('small branch tails', () => {
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// cacheHitPct is null only when input+cacheRead are both zero (pure
// output accounting) — any input makes it a real 0%.
const snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
modelRequest: null,
}
const usage = {
uncachedInputTokens: 0,
outputTokens: 10,
cacheReadTokens: 0,
cacheWriteTokens: 5_000,
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
useProjection={(() => usage)}
/>,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
)
expect(view.getByText(
'0 uncached input · 10 output · 0 cache read · context unknown · 1 turns · 1 steps',
)).toBeTruthy()
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
})
})

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

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, modelRequest: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -390,17 +390,6 @@ describe('ChatView', () => {
const loading = makeHarness({ openState: 'loading' })
const lv = render(<loading.ChatView {...loading.props} />)
expect(lv.getByText('载入历史…')).toBeTruthy()
const reconnecting = makeHarness({
openState: 'error',
openError: {
code: 'cancelled',
message: 'session history request cancelled after connection loss',
details: {},
},
})
const rv = render(<reconnecting.ChatView {...reconnecting.props} />)
expect(rv.getByText('连接已中断,等待重连…')).toBeTruthy()
expect(rv.queryByText(/session history request/)).toBeNull()
})
it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {

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, modelRequest: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -36,7 +36,7 @@ describe('render branch tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsLine takes durable counters from tokenUsage while keeping visible node counts', () => {
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
const snap = {
nodes: [
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
@@ -44,24 +44,12 @@ describe('render branch tails', () => {
// outputTokens absent: the tokens sum's ?? 0 arm for output.
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
],
modelRequest: null,
}
const usage = {
uncachedInputTokens: 9,
outputTokens: 6,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useProjection={(() => usage)}
/>,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText(
'9 uncached input · 6 output · 0 cache read · cache hit 0% · context unknown · 2 turns · 3 steps',
)).toBeTruthy()
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {

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

View File

@@ -10,9 +10,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
// Export discipline: packages/client/AGENTS.md.
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'