feat(web): add durable session metrics (round 1)
This commit is contained in:
@@ -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: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499
|
||||
README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200
|
||||
README.md: fbb3a142b9efa50045f127d430bd2be2849f01f2
|
||||
README.zh.md: b5bd3c458ed462e01dfae5bcd7399dde5552dc8d
|
||||
|
||||
@@ -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. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync.
|
||||
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. `ConversationSnapshot` carries two Host-owned full-log projections. `todos` comes from the tail history page, survives older-page prepend, and follows live `todo/write` events. `metrics` comes from tail history and live `session/metrics` frames, survives older-page prepend, and accepts only nondecreasing log and projection revisions; a subscription baseline clears it before replay so a new stream generation can restart revisions safely. Missing metrics remain `null` rather than being inferred from the visible node window.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
|
||||
@@ -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。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。
|
||||
客户端 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。`ConversationSnapshot` 携带两项由 Host 拥有的完整日志投影。`todos` 来自 history 尾页,在向前加载较早页面时保留,并随实时 `todo/write` 事件更新。`metrics` 来自 history 尾页和实时 `session/metrics` 帧,在向前加载较早页面时保留,并且只接受日志修订号与投影修订号均不减小的数据;订阅基线会在回放前将其清除,使新的流代次可以安全地从头开始计数修订号。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
RpcError, SessionId, SessionMetrics, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
@@ -246,4 +246,10 @@ export interface ConversationSnapshot {
|
||||
/** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
|
||||
* write (last write wins); empty = the log holds no plan. */
|
||||
todos: readonly TodoItem[]
|
||||
/**
|
||||
* Host-owned cumulative usage and current-context projection. Independent
|
||||
* of `nodes` pagination; null until a tail response or live metrics frame
|
||||
* supplies a current value.
|
||||
*/
|
||||
metrics: SessionMetrics | null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
SessionId, SessionMetrics, 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.
|
||||
@@ -102,6 +102,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
|
||||
* field is the authoritative empty list) and every live write overwrites it. */
|
||||
private todos: readonly TodoItem[] = []
|
||||
/** Host-owned metrics projection; ordering resets on each subscribed baseline. */
|
||||
private metrics: SessionMetrics | null = 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[]>()
|
||||
@@ -296,6 +298,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.events = []
|
||||
this.views = []
|
||||
this.baseSeq = 0
|
||||
this.metrics = null
|
||||
// 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()
|
||||
@@ -364,6 +367,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
if (this.metrics !== null) {
|
||||
this.metrics = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'session/metrics': {
|
||||
this.installMetrics(frame.metrics)
|
||||
return
|
||||
}
|
||||
case 'approval/requested': {
|
||||
@@ -482,13 +493,20 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos, 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()
|
||||
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.todos)
|
||||
if (result.ok) {
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.todos,
|
||||
result.value.metrics,
|
||||
)
|
||||
}
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -506,7 +524,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
|
||||
* (doOpen flips it after install), so recursing would push every buffered event straight
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
|
||||
private installWindow(
|
||||
entries: HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
todos: readonly TodoItem[] | undefined,
|
||||
metrics: SessionMetrics | undefined,
|
||||
): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
@@ -519,6 +542,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
// field is the authoritative empty list, not a missing carrier. Assigning
|
||||
// it clears a plan the log never kept (a write lost to a host crash).
|
||||
this.todos = todos ?? []
|
||||
if (metrics !== undefined) this.installMetrics(metrics)
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
const buffered = this.liveBuffer
|
||||
@@ -569,7 +593,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
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.todos)
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.todos,
|
||||
result.value.metrics,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
@@ -761,6 +790,20 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
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.
|
||||
@@ -811,6 +854,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
todos: this.todos,
|
||||
metrics: this.metrics,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionMetrics, SessionModels, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -63,7 +63,12 @@ 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; todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[] }>> =
|
||||
=> Promise<RpcResponse<{
|
||||
events: never[]
|
||||
hasMore: boolean
|
||||
todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]
|
||||
metrics?: SessionMetrics
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
|
||||
@@ -85,6 +85,13 @@ 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
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId, SessionMetrics } 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'
|
||||
@@ -22,9 +23,37 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) {
|
||||
function histResponse(
|
||||
events: SessionEvent[],
|
||||
hasMore = false,
|
||||
todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[],
|
||||
metrics?: SessionMetrics,
|
||||
) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } }))
|
||||
return Promise.resolve(ok({
|
||||
events: entries(events) as never[],
|
||||
hasMore,
|
||||
...todos === undefined ? {} : { todos },
|
||||
...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,
|
||||
contextWindow: 100,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
@@ -40,6 +69,19 @@ 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)
|
||||
})
|
||||
|
||||
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
|
||||
@@ -104,6 +146,43 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('orders live metrics, rejects stale projections, and clears the value at a reconnect baseline', 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)
|
||||
|
||||
session.handleMuxEnvelope('m2' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: metrics(9, 9, { uncachedInputTokens: 1 }),
|
||||
})
|
||||
session.handleMuxEnvelope('m3' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: metrics(7, 11, { uncachedInputTokens: 2 }),
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(current)
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
sessionId: SID,
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
const nextGeneration = metrics(0, 10, { contextTokens: 20 })
|
||||
session.handleMuxEnvelope('m4' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: nextGeneration,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(nextGeneration)
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -332,6 +411,23 @@ 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' })
|
||||
@@ -374,6 +470,16 @@ 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'))
|
||||
|
||||
Reference in New Issue
Block a user