refactor(web): publish transient model request capacity (round 1)

This commit is contained in:
Hypatia May
2026-07-28 18:35:39 +08:00
parent 3f0ba77bfa
commit 35b9c454e5
54 changed files with 765 additions and 1352 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: e2ba8c0e5b2620d095636503f47fac5480c6c2fa
README.zh.md: 2aae0bd683e9ea1e1fb43f73000420dc2354ee0b
README.md: d3d1711242f71832f63eb570242fdf9e149cc988
README.zh.md: 4e52058c1795ea23a9ca8ed46b8890c85f129eb4

View File

@@ -20,7 +20,9 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries session-level projections the page window cannot supply: the in-flight partial's chunk events; `todos`, the latest `todo/write` whole-list projection; and `metrics`, full-log usage deduplicated by `(turn, step)` plus current token-meter pressure and exact selected-route capacity when available. Older pages omit the session-level projections. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries session-level projections the page window cannot supply: the in-flight partial's chunk events; `todos`, the latest `todo/write` whole-list projection; and `metrics`, full-log usage deduplicated by `(turn, step)` plus current token-meter pressure. Older pages omit the session-level projections. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads.
Context capacity uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an actual request reaches dispatch. It carries turn, step, final provider/model, and optional capacity only to mux connections already open at that instant. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay prior capacity; a frame without capacity explicitly clears the earlier connection-local value.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.

View File

@@ -20,7 +20,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)携带页窗口本身无法提供的会话级投影:进行中局部消息的分片事件;`todos`,即最后一次 `todo/write` 的整表投影;以及 `metrics`,即按 `(turn, step)` 去重的完整日志用量,并在可用时包含当前 token 计量压力和所选精确路由的容量。较早的页面省略会话级投影。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)携带页窗口本身无法提供的会话级投影:进行中局部消息的分片事件;`todos`,即最后一次 `todo/write` 的整表投影;以及 `metrics`,即按 `(turn, step)` 去重的完整日志用量与当前 token 计量压力。较早的页面省略会话级投影。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。
上下文容量使用独立的临时 `session/model-request` mux 帧;实际请求到达分派点后,该帧由失败会被收容的 Agent 通知发出。该帧携带轮次、步骤、最终提供方/模型与可选容量,且只发送给当时已经打开的 mux 连接。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放先前的容量;不带容量的帧会显式清除较早的连接本地值。
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。

View File

@@ -6,7 +6,6 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { FiberState } from 'cordis'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
@@ -409,26 +408,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return target
}
/**
* Read the best capacity route without taking ownership of foreign routing.
* Web agents expose their live selection; other agents expose only a route
* that already crossed the durable request-header boundary.
*/
function metricsRouteFor(agent: Agent): Pick<AgentLlmTarget, 'provider' | 'model'> | undefined {
const installed = targets.get(agent)
if (installed !== undefined) return installed.current
const logged = agent.session.requestHeader()?.config
return logged === undefined
? undefined
: { provider: logged.provider, model: logged.model }
}
/** Pair a registry agent only with the exact Session lifecycle it owns. */
function metricsAgentFor(session: Session): Agent | undefined {
const agent = ctx.get('agents')?.get(session.id)
return agent?.session === session ? agent : undefined
}
/** Pre-publication setup used by both fresh and resumed Web agents. */
function installTarget(agentCtx: Context): void {
const agent = agentCtx.agent
@@ -444,39 +423,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const pendingMetricSessions = new Set<Session>()
let metricFlushScheduled = false
let metricsDisposed = false
const metricsProjector = new SessionMetricsProjector(
ctx,
metricsRouteFor,
(agent) => {
if (metricsDisposed) return
const agents = ctx.get('agents')
if (agents?.get(agent.id) !== agent) return
const sessions = ctx.get('sessions')
if (sessions?.get(agent.id) !== agent.session) return
scheduleMetrics(agent.session)
},
)
const metricsProjector = new SessionMetricsProjector(ctx)
/** Queue one full-log metrics publication after synchronous session listeners drain. */
function scheduleMetrics(session: Session): void {
if (metricsDisposed || muxQueues.size === 0) return
if (muxQueues.size === 0) return
pendingMetricSessions.add(session)
if (metricFlushScheduled) return
metricFlushScheduled = true
queueMicrotask(() => {
metricFlushScheduled = false
if (metricsDisposed) {
pendingMetricSessions.clear()
return
}
const sessions = [...pendingMetricSessions]
pendingMetricSessions.clear()
for (const current of sessions) {
broadcast({
type: 'session/metrics',
sessionId: current.id,
metrics: metricsProjector.snapshot(current, metricsAgentFor(current)),
metrics: metricsProjector.snapshot(current),
})
}
})
@@ -488,25 +451,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (affectsSessionMetrics(event)) scheduleMetrics(session)
}),
ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }),
ctx.on('agent/model-request', (agent, turn, step, request) => {
broadcast({
type: 'session/model-request',
sessionId: agent.session.id,
turn,
step,
provider: request.provider,
model: request.model,
...request.contextWindow === undefined
? {}
: { contextWindow: request.contextWindow },
})
}),
ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }),
ctx.on('internal/status', (fiber) => {
if (metricsDisposed) return
if (fiber.state === FiberState.UNLOADING) {
metricsProjector.invalidateCapacities()
return
}
if (fiber.state !== FiberState.ACTIVE
&& fiber.state !== FiberState.FAILED
&& fiber.state !== FiberState.DISPOSED) return
metricsProjector.invalidateCapacities()
const sessions = ctx.get('sessions')
if (sessions === undefined) return
for (const session of sessions.list()) scheduleMetrics(session)
}, { global: true }),
]
return () => {
metricsDisposed = true
metricsProjector.dispose()
pendingMetricSessions.clear()
for (const dispose of disposers) dispose()
}
@@ -819,7 +779,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// client cannot reconstruct session-level state from it).
const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined
const metrics = beforeSeq === undefined
? metricsProjector.snapshot(found.agent.session, found.agent)
? metricsProjector.snapshot(found.agent.session)
: undefined
return ok(request, {
events: entries,
@@ -920,11 +880,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
: { reasoningEffort: resolved.reasoningEffort },
}
targetFor(found.agent).current = selected
broadcast({
type: 'session/metrics',
sessionId: found.agent.session.id,
metrics: metricsProjector.snapshot(found.agent.session, found.agent),
})
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
return err(request, {
@@ -1235,7 +1190,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({
type: 'session/metrics',
sessionId: session.id,
metrics: metricsProjector.snapshot(session, metricsAgentFor(session)),
metrics: metricsProjector.snapshot(session),
}))
}
for (const pending of pendingQuestions.values()) {
@@ -1292,7 +1247,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({
type: 'session/metrics',
sessionId: session.id,
metrics: metricsProjector.snapshot(session, metricsAgentFor(session)),
metrics: metricsProjector.snapshot(session),
}))
}),
ctx.on('session/disposed', (session: Session) => {

View File

@@ -30,6 +30,15 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('session/metrics'), sessionId: sessionIdSchema, metrics: sessionMetricsSchema }),
z.object({
type: z.literal('session/model-request'),
sessionId: sessionIdSchema,
turn: z.number().int().positive(),
step: z.number().int().positive(),
provider: z.string().min(1),
model: z.string().min(1),
contextWindow: z.number().int().positive().optional(),
}),
z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),

View File

@@ -59,6 +59,22 @@ export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'session/metrics'; sessionId: SessionId; metrics: SessionMetrics }
/**
* One model request observed by this already-open mux connection after its
* final route and stream handle were resolved. This frame is transient: mux
* baselines, reconnects, and session history never replay it. An absent
* `contextWindow` explicitly clears a capacity observed from an earlier
* request on the same connection.
*/
| {
type: 'session/model-request'
sessionId: SessionId
turn: number
step: number
provider: string
model: string
contextWindow?: number
}
| { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }

View File

@@ -145,7 +145,7 @@ export const todoItemSchema = z.object({
status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),
})
/** Host-owned durable usage and current-context projection. */
/** Host-owned durable usage and current-pressure projection. */
export const sessionMetricsSchema = z.object({
logRevision: z.number().int().nonnegative(),
projectionRevision: z.number().int().nonnegative(),
@@ -154,7 +154,6 @@ export const sessionMetricsSchema = z.object({
cacheReadTokens: z.number().nonnegative(),
cacheWriteTokens: z.number().nonnegative(),
contextTokens: z.number().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<SessionMetrics>>
/** session.history response value. */

View File

@@ -34,9 +34,9 @@ export interface HistoryEntry {
/**
* Host-owned token metrics for one durable session revision. Provider usage
* buckets are cumulative across the full log; current context fields describe
* the replayed request surface at this revision and are absent when the Host
* cannot measure pressure or resolve exact-route capacity.
* buckets are cumulative across the full log; current context pressure
* describes the replayed request surface at this revision and is absent when
* the Host cannot measure it.
*/
export interface SessionMetrics {
/** Number of durable events included in this projection. */
@@ -53,8 +53,6 @@ export interface SessionMetrics {
cacheWriteTokens: number
/** Current request pressure from `ctx.tokenMeter.measure(session).totalTokens`. */
contextTokens?: number
/** Exact selected-route capacity from `ctx.llm.resolveModelInfo()`. */
contextWindow?: number
}
/** Complete model target selected for one session. */
@@ -178,8 +176,10 @@ export interface SessionsApi {
* projection (latest `todo/write` over the FULL log, independent of the page window) —
* so a paged client restores the plan without walking history; absent when the session
* never wrote one. Older pages omit it (the projection is session-level, not per-page).
* The same tail-only rule carries `metrics`, whose cumulative usage and current context
* are Host projections over the full log rather than products of the returned page.
* The same tail-only rule carries `metrics`, whose cumulative usage and
* current pressure are Host projections over the full log rather than
* products of the returned page. Live model capacity is connection-local
* telemetry and is never reconstructed here.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; todos?: TodoItem[]; metrics?: SessionMetrics }>>

View File

@@ -5,7 +5,6 @@
*/
import type { Context } from 'cordis'
import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionMetrics } from './api/sessions.ts'
@@ -20,27 +19,10 @@ interface UsageState {
byStep: Map<string, TokenUsage>
}
interface CapacityState {
routeKey: string | undefined
generation: number
epoch: number
status: 'pending' | 'ready' | 'retryable'
contextWindow?: number
controller?: AbortController
}
type CapacityTarget = Pick<AgentLlmTarget, 'provider' | 'model'>
interface TokenMeterLike {
measure(session: Session): { totalTokens: number }
}
interface LlmLike {
resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
context?: { contextWindow: number }
}>
}
function usageFrom(event: SessionEvent): { turn: number; step: number; usage: TokenUsage } | undefined {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
@@ -79,51 +61,19 @@ function recordUsage(state: UsageState, turn: number, step: number, usage: Token
state.cacheWriteTokens += usage.cacheWriteTokens ?? 0
}
function routeKeyFor(target: CapacityTarget | undefined): string | undefined {
return target === undefined ? undefined : `${target.provider}\u0000${target.model}`
}
/**
* Projects durable cumulative usage and route-aware current context without
* awaiting model metadata on the session append path.
*/
/** Projects durable cumulative usage and synchronous current context pressure. */
export class SessionMetricsProjector {
private readonly usage = new WeakMap<Session, UsageState>()
private readonly capacities = new WeakMap<Agent, CapacityState>()
private readonly pendingCapacities = new Set<CapacityState>()
private capacityEpoch = 0
private disposed = false
/**
* @param ctx - Host context providing optional token-meter and LLM services.
* @param targetFor - side-effect-free selected or logged route lookup for one attached agent.
* @param onCapacityResolved - schedules a fresh live projection after exact-route metadata resolves.
*/
constructor(
private readonly ctx: Context,
private readonly targetFor: (agent: Agent) => CapacityTarget | undefined,
private readonly onCapacityResolved: (agent: Agent) => void,
) {}
/** Retire adapter-owned metadata and fence every resolution already in flight. */
invalidateCapacities(): void {
this.capacityEpoch++
for (const pending of this.pendingCapacities) this.abortCapacityResolution(pending)
}
/** Permanently retire capacity projection and cancel every adapter-owned lookup. */
dispose(): void {
this.disposed = true
this.invalidateCapacities()
}
/** @param ctx - Host context providing an optional token-meter service. */
constructor(private readonly ctx: Context) {}
/**
* Read a fresh detached projection through the session's durable tail.
* @param session - authoritative durable log owner.
* @param agent - attached route owner, when available.
* @returns cumulative usage and any currently available pressure/capacity.
* @returns cumulative usage and any currently measurable pressure.
*/
snapshot(session: Session, agent?: Agent): SessionMetrics {
snapshot(session: Session): SessionMetrics {
const state = this.syncUsage(session)
const tokenMeter = this.ctx.get('tokenMeter') as TokenMeterLike | undefined
let contextTokens: number | undefined
@@ -134,7 +84,6 @@ export class SessionMetricsProjector {
// A malformed or temporarily unmeasurable replay has no honest pressure value.
}
}
const contextWindow = agent === undefined ? undefined : this.capacityFor(agent)
return {
logRevision: state.logRevision,
projectionRevision: state.projectionRevision++,
@@ -143,7 +92,6 @@ export class SessionMetricsProjector {
cacheReadTokens: state.cacheReadTokens,
cacheWriteTokens: state.cacheWriteTokens,
...contextTokens === undefined ? {} : { contextTokens },
...contextWindow === undefined ? {} : { contextWindow },
}
}
@@ -171,87 +119,4 @@ export class SessionMetricsProjector {
}
return state
}
private capacityFor(agent: Agent): number | undefined {
if (this.disposed) return undefined
const target = this.targetFor(agent)
const routeKey = routeKeyFor(target)
let state = this.capacities.get(agent)
if (state === undefined
|| state.routeKey !== routeKey
|| state.epoch !== this.capacityEpoch
|| state.status === 'retryable') {
this.abortCapacityResolution(state)
state = {
routeKey,
generation: (state?.generation ?? 0) + 1,
epoch: this.capacityEpoch,
status: target === undefined ? 'ready' : 'pending',
}
this.capacities.set(agent, state)
if (target !== undefined) this.resolveCapacity(agent, target, state)
}
return state.status === 'ready' ? state.contextWindow : undefined
}
private resolveCapacity(
agent: Agent,
target: CapacityTarget,
pending: CapacityState,
): void {
const llm = this.ctx.get('llm') as LlmLike | undefined
if (llm === undefined) {
pending.status = 'retryable'
return
}
const controller = new AbortController()
pending.controller = controller
this.pendingCapacities.add(pending)
void Promise.resolve()
.then(() => {
controller.signal.throwIfAborted()
return llm.resolveModelInfo(target.provider, target.model, controller.signal)
})
.then(
(resolved) => {
this.finishCapacityResolution(pending, controller)
if (this.capacityResolutionIsStale(agent, pending)) return
pending.status = 'ready'
if (resolved.context !== undefined) pending.contextWindow = resolved.context.contextWindow
this.onCapacityResolved(agent)
},
() => {
this.finishCapacityResolution(pending, controller)
if (!this.capacityResolutionIsStale(agent, pending)) pending.status = 'retryable'
},
)
}
private abortCapacityResolution(pending: CapacityState | undefined): void {
if (pending === undefined || pending.controller === undefined) return
const controller = pending.controller
delete pending.controller
this.pendingCapacities.delete(pending)
controller.abort()
}
private finishCapacityResolution(pending: CapacityState, controller: AbortController): void {
this.pendingCapacities.delete(pending)
if (pending.controller === controller) delete pending.controller
}
private capacityResolutionIsStale(agent: Agent, pending: CapacityState): boolean {
if (pending.epoch !== this.capacityEpoch) return true
if (this.capacities.get(agent)?.generation !== pending.generation) return true
if (routeKeyFor(this.targetFor(agent)) === pending.routeKey) return false
// Unknown is the neutral generation; the next observed concrete route
// starts a fresh resolution even when it equals the route that disappeared.
this.capacities.set(agent, {
routeKey: undefined,
generation: pending.generation + 1,
epoch: this.capacityEpoch,
status: 'ready',
})
return true
}
}

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
import { createApiProxy } from '../src/api-proxy.ts'
async function nextFrame<K extends MuxFrame['type']>(
iterator: AsyncIterator<RpcRequest<MuxFrame>>,
type: K,
): Promise<Extract<MuxFrame, { type: K }>> {
for (;;) {
const next = await iterator.next()
if (next.done) throw new Error(`mux ended before ${type}`)
if (next.value.payload.type === type) {
return next.value.payload as Extract<MuxFrame, { type: K }>
}
}
}
describe('ApiProxy model-request telemetry', () => {
it('forwards only to open mux connections and never backfills history or reconnect baselines', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('model-request-telemetry'))
const agent = {
id: session.id,
session,
status: 'running',
ctx,
} as Agent
ctx.agents.register(agent)
const api = createApiProxy(ctx, {
provider: 'test',
model: 'alpha',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const primaryAbort = new AbortController()
const primary = api.events.mux(
{ rpcId: RpcId('primary'), payload: {} },
primaryAbort.signal,
)[Symbol.asyncIterator]()
expect((await nextFrame(primary, 'session/subscribed')).sessionId).toBe(session.id)
expect((await nextFrame(primary, 'session/metrics')).metrics).not.toHaveProperty('contextWindow')
agentEvents(ctx, agent).emit('agent/model-request', 1, 2, {
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
})
expect(await nextFrame(primary, 'session/model-request')).toEqual({
type: 'session/model-request',
sessionId: session.id,
turn: 1,
step: 2,
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
})
const history = await api.sessions.history({
rpcId: RpcId('history'),
payload: { sessionId: session.id },
})
if (!history.result.ok) throw new Error('history failed')
expect(history.result.value.metrics).not.toHaveProperty('contextWindow')
const reconnectAbort = new AbortController()
const reconnect = api.events.mux(
{ rpcId: RpcId('reconnect'), payload: {} },
reconnectAbort.signal,
)[Symbol.asyncIterator]()
expect((await nextFrame(reconnect, 'session/subscribed')).sessionId).toBe(session.id)
expect((await nextFrame(reconnect, 'session/metrics')).metrics).not.toHaveProperty('contextWindow')
agentEvents(ctx, agent).emit('agent/model-request', 2, 1, {
provider: 'test',
model: 'without-capacity',
})
for (const iterator of [primary, reconnect]) {
expect(await nextFrame(iterator, 'session/model-request')).toEqual({
type: 'session/model-request',
sessionId: session.id,
turn: 2,
step: 1,
provider: 'test',
model: 'without-capacity',
})
}
primaryAbort.abort()
reconnectAbort.abort()
await primary.return?.()
await reconnect.return?.()
await ctx.fiber.dispose()
})
})

View File

@@ -4,23 +4,20 @@
* models, and the prompt-assembly boundary for a running selection change.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context, FiberState } from 'cordis'
import type { Fiber } from 'cordis'
import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import { createApiProxy } from '../src/api-proxy.ts'
let nextRpc = 1
@@ -64,40 +61,6 @@ class CatalogAdapter extends LlmAdapter {
}
}
class DeferredCatalogAdapter extends CatalogAdapter {
readonly pending: {
result: PromiseWithResolvers<LlmResolvedModelInfo>
signal: AbortSignal | undefined
}[] = []
constructor() {
super('Deferred', [
{ provider: 'deferred', id: 'lifecycle-model', name: 'Lifecycle model' },
])
}
override resolveModel(
_provider: string,
_model: string,
signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const result = Promise.withResolvers<LlmResolvedModelInfo>()
this.pending.push({ result, signal })
return result.promise
}
resolve(index: number, contextWindow: number): void {
const pending = this.pending[index]
if (pending === undefined) throw new Error(`no pending resolution at index ${String(index)}`)
pending.result.resolve({
provider: 'deferred',
id: 'lifecycle-model',
name: 'Lifecycle model',
context: { contextWindow },
})
}
}
const REASONING: LlmModelReasoningInfo = {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
@@ -107,18 +70,13 @@ const REASONING: LlmModelReasoningInfo = {
defaultEffort: ReasoningEffortId('high'),
}
async function hostContext(
onSessions?: (fiber: Fiber) => void,
onAgents?: (fiber: Fiber) => void,
): Promise<Context> {
async function hostContext(): Promise<Context> {
const ctx = new Context()
const sessionsFiber = await ctx.plugin(SessionStore)
onSessions?.(sessionsFiber)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(LlmService)
await ctx.plugin(UserInteractionService)
const agentsFiber = await ctx.plugin(AgentRegistry)
onAgents?.(agentsFiber)
await ctx.plugin(AgentRegistry)
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
@@ -164,65 +122,6 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
return response.result.value
}
async function nextMetrics(
iterator: AsyncIterator<RpcRequest<MuxFrame>>,
): Promise<Extract<MuxFrame, { type: 'session/metrics' }>['metrics']> {
for (;;) {
const next = await iterator.next()
if (next.done) throw new Error('mux ended before a metrics frame')
if (next.value.payload.type === 'session/metrics') return next.value.payload.metrics
}
}
function attachLifecycleSession(
ctx: Context,
sessionId: SessionId,
withMarker = false,
): { session: Session; detach: () => void } {
const session = ctx.sessions.prepare(sessionId)
session.append('request/header', {
header: { config: { provider: 'deferred', model: 'lifecycle-model' } },
reason: 'initial',
})
if (withMarker) {
session.append('user/message', {
content: [{ type: 'text', text: 'replacement marker' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
}
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
return { session, detach }
}
function attachLifecycleAgent(
ctx: Context,
session: Session,
): () => void {
const agent = {
id: session.id,
session,
status: 'running',
ctx,
} as Agent
const detach = ctx.agents.enter(agent, undefined)
ctx.agents.announce(agent)
return detach
}
function settleCapacityCompletion(): Promise<void> {
return new Promise<void>((resolve) => { setImmediate(resolve) })
}
function installDeferredAdapter(
ctx: Context,
adapter: DeferredCatalogAdapter,
): Fiber & PromiseLike<Fiber> {
return ctx.plugin(Object.assign((inner: Context) => {
inner.llm.registerAdapter(['deferred'], adapter)
}, { inject: ['llm'] }))
}
describe('Web session model selection', () => {
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
const { ctx, sessionId } = await harness({
@@ -230,7 +129,12 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -271,7 +175,12 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -336,534 +245,4 @@ describe('Web session model selection', () => {
.toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
await ctx.fiber.dispose()
})
it('publishes unknown capacity immediately on selection, then the exact selected route capacity', async () => {
const { ctx, sessionId } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
expectValue(await api.sessions.models(request({ sessionId })))
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
expectValue(await api.sessions.selectModel(request({
sessionId,
provider: 'deepseek',
model: 'private-preview',
})))
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
await ctx.fiber.dispose()
})
it('uses logged capacity without installing Web routing while scheduling foreign metrics', async () => {
const ctx = await hostContext()
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
const initialMetrics = nextMetrics(iterator)
const session = ctx.sessions.create()
expect((await initialMetrics).contextWindow).toBeUndefined()
session.append('request/header', {
header: { config: { provider: 'deepseek', model: 'private-preview' } },
reason: 'change',
})
const foreign = {
id: session.id,
session,
status: 'running',
ctx,
} as Agent
const foreignTarget: AgentLlmTargetRef = {
current: { provider: 'foreign', model: 'foreign-model' },
assembled: undefined,
}
const disposeForeignTarget = installAgentLlmTarget(foreign.ctx, foreignTarget)
const scheduledMetrics = nextMetrics(iterator)
ctx.agents.register(foreign)
expect((await scheduledMetrics).contextWindow).toBeUndefined()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'foreign', model: 'foreign-model' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
await expect(agentEvents(ctx, foreign).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'foreign', model: 'foreign-model' })
disposeForeignTarget()
expect((await ctx.systemPrompt.assemble()).variables).not.toHaveProperty('provider')
await expect(agentEvents(ctx, foreign).waterfall(
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
controller.abort()
await iterator.return?.()
await ctx.fiber.dispose()
})
it('drops capacity completion from a replaced agent that retains the exact session', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-lifecycle'))
const retire = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
retire()
const detachLive = attachLifecycleAgent(ctx, lifecycle.session)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
deferred.resolve(1, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
detachLive()
lifecycle.detach()
await ctx.fiber.dispose()
})
it('drops capacity completion from a replaced session while its old agent remains live', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const sessionId = SessionId('capacity-session-lifecycle')
const retiredSession = attachLifecycleSession(ctx, sessionId)
const retireAgent = attachLifecycleAgent(ctx, retiredSession.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
retiredSession.detach()
const liveSession = attachLifecycleSession(ctx, sessionId, true)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
retireAgent()
const detachLiveAgent = attachLifecycleAgent(ctx, liveSession.session)
const scheduled = await nextMetrics(iterator)
expect(scheduled.logRevision).toBe(2)
expect(scheduled.contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
deferred.resolve(1, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
detachLiveAgent()
liveSession.detach()
await ctx.fiber.dispose()
})
it('does not project retired agent capacity into replacement session snapshots', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const sessionId = SessionId('capacity-snapshot-lifecycle')
const retiredSession = attachLifecycleSession(ctx, sessionId)
const retireAgent = attachLifecycleAgent(ctx, retiredSession.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const primaryController = new AbortController()
const primary = api.events.mux(request({}), primaryController.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(primary)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
expect((await nextMetrics(primary)).contextWindow).toBe(64_000)
retiredSession.detach()
const replacement = attachLifecycleSession(ctx, sessionId)
const createdBaseline = await nextMetrics(primary)
replacement.session.append('user/message', {
content: [{ type: 'text', text: 'replacement marker' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
const scheduledFlush = await nextMetrics(primary)
const reconnectController = new AbortController()
const reconnect = api.events.mux(request({}), reconnectController.signal)[Symbol.asyncIterator]()
const reconnectBaseline = await nextMetrics(reconnect)
expect(createdBaseline.logRevision).toBe(1)
for (const metrics of [scheduledFlush, reconnectBaseline]) {
expect(metrics.logRevision).toBe(2)
}
expect({
created: createdBaseline.contextWindow,
scheduled: scheduledFlush.contextWindow,
reconnect: reconnectBaseline.contextWindow,
}).toEqual({ created: undefined, scheduled: undefined, reconnect: undefined })
retireAgent()
const detachReplacementAgent = attachLifecycleAgent(ctx, replacement.session)
expect((await nextMetrics(primary)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
deferred.resolve(1, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(primary)).contextWindow).toBe(128_000)
primaryController.abort()
reconnectController.abort()
await primary.return?.()
await reconnect.return?.()
detachReplacementAgent()
replacement.detach()
await ctx.fiber.dispose()
})
it('refreshes same-route capacity after adapter owner replacement', async () => {
const ctx = await hostContext()
const retiredAdapter = new DeferredCatalogAdapter()
const retiredFiber = await installDeferredAdapter(ctx, retiredAdapter)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-lifecycle'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) })
retiredAdapter.resolve(0, 64_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
await retiredFiber.dispose()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
const replacementAdapter = new DeferredCatalogAdapter()
const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) })
replacementAdapter.resolve(0, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
expect(lifecycle.session.requestHeader()?.config).toMatchObject({
provider: 'deferred',
model: 'lifecycle-model',
})
controller.abort()
await iterator.return?.()
detachAgent()
lifecycle.detach()
await replacementFiber.dispose()
await ctx.fiber.dispose()
})
it('aborts pending capacity during adapter UNLOADING and refreshes after settlement', async () => {
const ctx = await hostContext()
const retiredAdapter = new DeferredCatalogAdapter()
const releaseUnload = Promise.withResolvers<undefined>()
const releaseCancellationWait = Promise.withResolvers<undefined>()
const abortObserved = Promise.withResolvers<undefined>()
const retiredFiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.llm.registerAdapter(['deferred'], retiredAdapter)
inner.effect(
() => () => releaseUnload.promise,
'test: hold adapter unload',
)
inner.effect(() => () => {
const signal = retiredAdapter.pending[0]?.signal
if (signal === undefined) throw new Error('pending capacity signal missing')
const cancellation = new Promise<void>((resolve) => {
const finish = () => {
abortObserved.resolve(undefined)
resolve()
}
if (signal.aborted) finish()
else signal.addEventListener('abort', finish, { once: true })
})
return Promise.race([cancellation, releaseCancellationWait.promise])
}, 'test: await capacity cancellation')
}, { inject: ['llm'] }))
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-unloading'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo')
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) })
expect(resolveModelInfo).toHaveBeenCalledOnce()
const listSessions = vi.spyOn(ctx.sessions, 'list')
listSessions.mockClear()
const pendingFrame = iterator.next()
const disposing = retiredFiber.dispose()
try {
await vi.waitFor(() => {
expect(retiredAdapter.pending[0]?.signal?.aborted).toBe(true)
})
await abortObserved.promise
expect(retiredFiber.state).toBe(FiberState.UNLOADING)
retiredAdapter.resolve(0, 64_000)
await settleCapacityCompletion()
expect(resolveModelInfo).toHaveBeenCalledOnce()
expect(listSessions).not.toHaveBeenCalled()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
expect(outcome).toBe('idle')
} finally {
releaseCancellationWait.resolve(undefined)
releaseUnload.resolve(undefined)
await disposing
}
expect(retiredFiber.state).toBe(FiberState.DISPOSED)
const settledFrame = await pendingFrame
if (settledFrame.done || settledFrame.value.payload.type !== 'session/metrics') {
throw new Error('expected settled metrics refresh')
}
expect(settledFrame.value.payload.metrics.contextWindow).toBeUndefined()
await settleCapacityCompletion()
expect(resolveModelInfo).toHaveBeenCalledTimes(2)
const replacementAdapter = new DeferredCatalogAdapter()
const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) })
expect(resolveModelInfo).toHaveBeenCalledTimes(3)
replacementAdapter.resolve(0, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
detachAgent()
lifecycle.detach()
await replacementFiber.dispose()
await ctx.fiber.dispose()
})
it('aborts pending capacity when the API proxy fiber is disposed', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-api-proxy-teardown'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const proxy = Promise.withResolvers<ReturnType<typeof createApiProxy>>()
const proxyFiber = await ctx.plugin(Object.assign((inner: Context) => {
proxy.resolve(createApiProxy(inner, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
}))
}, { inject: ['agents', 'sessions', 'userInteraction'] }))
const api = await proxy.promise
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
expect(deferred.pending[0]?.signal?.aborted).toBe(false)
await proxyFiber.dispose()
expect(deferred.pending[0]?.signal?.aborted).toBe(true)
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
const pendingFrame = iterator.next()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
expect(outcome).toBe('idle')
controller.abort()
await expect(pendingFrame).resolves.toMatchObject({ done: true })
await iterator.return?.()
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
})
it('does not read the sessions service after its disposal status', async () => {
let sessionsFiber: Fiber | undefined
const ctx = await hostContext((fiber) => { sessionsFiber = fiber })
if (sessionsFiber === undefined) throw new Error('sessions fiber missing')
createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('sessions service missing')
const list = vi.spyOn(sessions, 'list').mockImplementation(() => {
throw new Error('disposed sessions service read')
})
await expect(sessionsFiber.dispose()).resolves.toBeUndefined()
expect(list).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('invalidates pending capacity before SessionStore teardown can reach its callback', async () => {
let sessionsFiber: Fiber | undefined
const ctx = await hostContext((fiber) => { sessionsFiber = fiber })
if (sessionsFiber === undefined) throw new Error('sessions fiber missing')
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-session-store-teardown'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
const agents = ctx.get('agents')
if (agents === undefined) throw new Error('agent registry missing')
expect(agents.get(lifecycle.session.id)).toBeDefined()
const getAgent = vi.spyOn(agents, 'get')
await sessionsFiber.dispose()
getAgent.mockClear()
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
expect(getAgent).not.toHaveBeenCalled()
const pendingFrame = iterator.next()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
expect(outcome).toBe('idle')
controller.abort()
await expect(pendingFrame).resolves.toMatchObject({ done: true })
await iterator.return?.()
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
})
it('publishes unknown metrics after AgentRegistry terminal disposal with mux active', async () => {
let agentsFiber: Fiber | undefined
const ctx = await hostContext(undefined, (fiber) => { agentsFiber = fiber })
if (agentsFiber === undefined) throw new Error('agent registry fiber missing')
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-registry-disposed'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
await agentsFiber.dispose()
const refresh = nextMetrics(iterator).then(
metrics => ({ kind: 'metrics' as const, metrics }),
() => ({ kind: 'error' as const }),
)
const outcome = await Promise.race([
refresh,
new Promise<{ kind: 'idle' }>((resolve) => {
setImmediate(() => { resolve({ kind: 'idle' }) })
}),
])
controller.abort()
await refresh
await iterator.return?.()
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
expect(outcome.kind).toBe('metrics')
if (outcome.kind === 'metrics') {
expect(outcome.metrics.contextWindow).toBeUndefined()
expect(outcome.metrics.logRevision).toBe(1)
}
})
it.each(['agents', 'sessions'] as const)(
'drops capacity completion while %s is unavailable during unload',
async (serviceName) => {
let sessionsFiber: Fiber | undefined
let agentsFiber: Fiber | undefined
const ctx = await hostContext(
(fiber) => { sessionsFiber = fiber },
(fiber) => { agentsFiber = fiber },
)
const heldFiber = serviceName === 'sessions' ? sessionsFiber : agentsFiber
if (heldFiber === undefined) throw new Error(`${serviceName} fiber missing`)
const unloadStarted = Promise.withResolvers<undefined>()
const releaseUnload = Promise.withResolvers<undefined>()
heldFiber.ctx.effect(() => () => {
unloadStarted.resolve(undefined)
return releaseUnload.promise
}, `test: hold ${serviceName} unload`)
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId(`capacity-${serviceName}-unloading`))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
const agents = ctx.get('agents')
if (agents === undefined) throw new Error('agent registry missing')
const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('sessions service missing')
const getAgent = vi.spyOn(agents, 'get')
const getSession = vi.spyOn(sessions, 'get')
const disposing = heldFiber.dispose()
await unloadStarted.promise
await vi.waitFor(() => { expect(ctx.get(serviceName)).toBeUndefined() })
expect(heldFiber.state).toBe(FiberState.UNLOADING)
getAgent.mockClear()
getSession.mockClear()
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
const agentReads = getAgent.mock.calls.length
const sessionReads = getSession.mock.calls.length
const pendingFrame = iterator.next()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
controller.abort()
await expect(pendingFrame).resolves.toMatchObject({ done: true })
await iterator.return?.()
releaseUnload.resolve(undefined)
await disposing
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
expect(agentReads).toBe(0)
expect(sessionReads).toBe(0)
expect(outcome).toBe('idle')
},
)
})

View File

@@ -146,10 +146,9 @@ describe('sessions domain schemas', () => {
cacheReadTokens: 4_000,
cacheWriteTokens: 500,
contextTokens: 8_000,
contextWindow: 128_000,
},
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}).metrics?.contextWindow).toBe(128_000)
}).metrics?.contextTokens).toBe(8_000)
expect(() => sessionMetricsSchema.parse({
logRevision: 1,
projectionRevision: 0,
@@ -158,15 +157,6 @@ describe('sessions domain schemas', () => {
cacheReadTokens: 0,
cacheWriteTokens: 0,
})).toThrow()
expect(() => sessionMetricsSchema.parse({
logRevision: 1,
projectionRevision: 0,
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
contextWindow: 0,
})).toThrow()
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
@@ -344,6 +334,23 @@ describe('events frame schemas', () => {
cacheWriteTokens: 40,
},
},
{
type: 'session/model-request',
sessionId: 's',
turn: 2,
step: 1,
provider: 'deepseek',
model: 'deepseek-chat',
contextWindow: 128_000,
},
{
type: 'session/model-request',
sessionId: 's',
turn: 3,
step: 1,
provider: 'deepseek',
model: 'unknown-capacity',
},
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
@@ -358,6 +365,8 @@ describe('events frame schemas', () => {
{ type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 },
{ type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN },
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()

View File

@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { affectsSessionMetrics, SessionMetricsProjector } from '../src/session-metrics.ts'
@@ -29,16 +28,8 @@ function assistant(
}, { surfaceOp: 'append' })
}
function agent(session: Session): Agent {
return { id: session.id, session } as Agent
}
function settleAsyncWork(): Promise<void> {
return new Promise<void>((resolve) => { setImmediate(resolve) })
}
describe('SessionMetricsProjector', () => {
it('filters text/reasoning stream deltas while retaining usage, headers, and surface mutations', () => {
it('filters text/reasoning deltas while retaining usage, headers, and surface mutations', () => {
const session = new Session(SessionId('metrics-filter'))
const text = session.append('assistant/chunk', {
turn: 1,
@@ -58,13 +49,16 @@ describe('SessionMetricsProjector', () => {
content: [{ type: 'text', text: 'question' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const plain = session.append('step/start', { turn: 1, step: 1 })
expect(affectsSessionMetrics(text)).toBe(false)
expect(affectsSessionMetrics(usage)).toBe(true)
expect(affectsSessionMetrics(header)).toBe(true)
expect(affectsSessionMetrics(surface)).toBe(true)
expect(affectsSessionMetrics(plain)).toBe(false)
})
it('reconciles usage by turn:step, keeps cache writes disjoint, and survives a surface replacement', () => {
it('folds usage by turn and step while synchronous pressure follows surface replacement', () => {
const ctx = new Context()
ctx.provide('tokenMeter', {
measure(session: Session) {
@@ -83,11 +77,8 @@ describe('SessionMetricsProjector', () => {
cacheWriteTokens: 8,
})
const current: AgentLlmTarget = { provider: 'test', model: 'alpha' }
const projector = new SessionMetricsProjector(ctx, () => current, () => {})
const attached = agent(session)
const before = projector.snapshot(session, attached)
expect(before).toMatchObject({
const projector = new SessionMetricsProjector(ctx)
expect(projector.snapshot(session)).toMatchObject({
uncachedInputTokens: 11,
outputTokens: 3,
cacheReadTokens: 89,
@@ -104,8 +95,7 @@ describe('SessionMetricsProjector', () => {
surfaceOp: { op: 'replace', start: first.seq, end: assistantSeq },
sourceEventSeqs: [first.seq, assistantSeq],
})
const compacted = projector.snapshot(session, attached)
expect(compacted).toMatchObject({
expect(projector.snapshot(session)).toMatchObject({
uncachedInputTokens: 11,
outputTokens: 3,
cacheReadTokens: 89,
@@ -113,357 +103,38 @@ describe('SessionMetricsProjector', () => {
contextTokens: 100,
})
// A replayed usage event for the same step replaces the settled value.
session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: {
type: 'usage',
usage: {
inputTokens: 12,
outputTokens: 4,
cacheReadTokens: 88,
cacheWriteTokens: 9,
},
usage: { inputTokens: 12, outputTokens: 4, cacheReadTokens: 88, cacheWriteTokens: 9 },
},
})
const replayed = projector.snapshot(session, attached)
expect(replayed).toMatchObject({
uncachedInputTokens: 12,
outputTokens: 4,
cacheReadTokens: 88,
cacheWriteTokens: 9,
contextTokens: 100,
})
assistant(session, 1, 2, {
inputTokens: 1_000,
outputTokens: 500,
cacheReadTokens: 2_000,
cacheWriteTokens: 3_000,
})
const after = projector.snapshot(session, attached)
expect(after).toMatchObject({
logRevision: session.events.length,
projectionRevision: 3,
uncachedInputTokens: 1_012,
outputTokens: 504,
cacheReadTokens: 2_088,
cacheWriteTokens: 3_009,
contextTokens: 200,
})
expect(after.uncachedInputTokens).not.toBe(
after.uncachedInputTokens + after.cacheReadTokens + after.cacheWriteTokens,
)
})
it('publishes only the selected route capacity when asynchronous resolutions race', async () => {
const ctx = new Context()
const resolutions = new Map<string, {
signal: AbortSignal | undefined
resolve(contextWindow: number): void
}>()
ctx.provide('tokenMeter', { measure: () => ({ totalTokens: 35_000 }) })
ctx.provide('llm', {
resolveModelInfo(_provider: string, model: string, signal?: AbortSignal) {
return new Promise<{ context: { contextWindow: number } }>((resolve) => {
resolutions.set(model, {
signal,
resolve(contextWindow) {
resolve({ context: { contextWindow } })
},
})
})
},
})
const session = new Session(SessionId('capacity-race'))
const attached = agent(session)
let current: AgentLlmTarget = { provider: 'test', model: 'alpha' }
const resolved = vi.fn()
const projector = new SessionMetricsProjector(ctx, () => current, resolved)
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) })
expect(resolutions.get('alpha')?.signal?.aborted).toBe(false)
current = { provider: 'test', model: 'beta' }
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
expect(resolutions.get('alpha')?.signal?.aborted).toBe(true)
await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) })
expect(resolutions.get('beta')?.signal?.aborted).toBe(false)
resolutions.get('alpha')?.resolve(64_000)
await Promise.resolve()
expect(resolved).not.toHaveBeenCalled()
resolutions.get('beta')?.resolve(128_000)
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached)).toMatchObject({
contextTokens: 35_000,
contextWindow: 128_000,
})
})
it('retries a failed same-route capacity lookup only on the next snapshot', async () => {
const ctx = new Context()
const attempts: PromiseWithResolvers<{ context: { contextWindow: number } }>[] = []
ctx.provide('llm', {
resolveModelInfo() {
const attempt = Promise.withResolvers<{ context: { contextWindow: number } }>()
attempts.push(attempt)
return attempt.promise
},
})
const session = new Session(SessionId('capacity-retry'))
const attached = agent(session)
const resolved = vi.fn()
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'alpha' }),
resolved,
)
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(attempts).toHaveLength(1) })
attempts[0]?.reject(new Error('metadata temporarily unavailable'))
await settleAsyncWork()
expect(attempts).toHaveLength(1)
expect(resolved).not.toHaveBeenCalled()
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await settleAsyncWork()
expect(attempts).toHaveLength(2)
attempts[1]?.resolve({ context: { contextWindow: 128_000 } })
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBe(128_000)
})
it('aborts every active capacity on invalidation and resolves fresh generations', async () => {
const ctx = new Context()
const attempts: {
result: PromiseWithResolvers<{ context: { contextWindow: number } }>
signal: AbortSignal | undefined
}[] = []
ctx.provide('llm', {
resolveModelInfo(_provider: string, _model: string, signal?: AbortSignal) {
const result = Promise.withResolvers<{ context: { contextWindow: number } }>()
attempts.push({ result, signal })
return result.promise
},
})
const firstSession = new Session(SessionId('capacity-invalidation-first'))
const secondSession = new Session(SessionId('capacity-invalidation-second'))
const firstAgent = agent(firstSession)
const secondAgent = agent(secondSession)
const resolved = vi.fn()
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'alpha' }),
resolved,
)
expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined()
expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(attempts).toHaveLength(2) })
expect(attempts.map(attempt => attempt.signal?.aborted)).toEqual([false, false])
projector.invalidateCapacities()
expect(attempts.map(attempt => attempt.signal?.aborted)).toEqual([true, true])
attempts[0]?.result.resolve({ context: { contextWindow: 32_000 } })
attempts[1]?.result.resolve({ context: { contextWindow: 64_000 } })
await settleAsyncWork()
expect(resolved).not.toHaveBeenCalled()
expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined()
expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(attempts).toHaveLength(4) })
expect(attempts.slice(2).map(attempt => attempt.signal?.aborted)).toEqual([false, false])
attempts[2]?.result.resolve({ context: { contextWindow: 128_000 } })
attempts[3]?.result.resolve({ context: { contextWindow: 256_000 } })
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledTimes(2) })
expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBe(128_000)
expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBe(256_000)
projector.dispose()
expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined()
expect(attempts).toHaveLength(4)
})
it('skips adapter work invalidated before its deferred invocation', async () => {
const ctx = new Context()
const attempts: {
result: PromiseWithResolvers<{ context: { contextWindow: number } }>
signal: AbortSignal | undefined
}[] = []
const resolveModelInfo = vi.fn((
_provider: string,
_model: string,
signal?: AbortSignal,
) => {
const result = Promise.withResolvers<{ context: { contextWindow: number } }>()
attempts.push({ result, signal })
return result.promise
})
ctx.provide('llm', { resolveModelInfo })
const session = new Session(SessionId('capacity-pre-invocation-invalidation'))
const attached = agent(session)
const resolved = vi.fn()
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'alpha' }),
resolved,
)
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
projector.invalidateCapacities()
await settleAsyncWork()
expect(resolveModelInfo).not.toHaveBeenCalled()
expect(resolved).not.toHaveBeenCalled()
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(attempts).toHaveLength(1) })
expect(attempts[0]?.signal?.aborted).toBe(false)
attempts[0]?.result.resolve({ context: { contextWindow: 128_000 } })
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBe(128_000)
})
it('starts a fresh capacity generation when an unavailable route returns', async () => {
const ctx = new Context()
const resolutions: {
signal: AbortSignal | undefined
resolve(contextWindow: number): void
}[] = []
ctx.provide('llm', {
resolveModelInfo(_provider: string, _model: string, signal?: AbortSignal) {
return new Promise<{ context: { contextWindow: number } }>((resolve) => {
resolutions.push({
signal,
resolve(contextWindow) {
resolve({ context: { contextWindow } })
},
})
})
},
})
const session = new Session(SessionId('capacity-route-return'))
const attached = agent(session)
let current: AgentLlmTarget | undefined = { provider: 'test', model: 'alpha' }
const resolved = vi.fn()
const targetFor = vi.fn(() => current)
const projector = new SessionMetricsProjector(ctx, targetFor, resolved)
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(resolutions).toHaveLength(1) })
current = undefined
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
expect(resolutions[0]?.signal?.aborted).toBe(true)
resolutions[0]?.resolve(64_000)
await settleAsyncWork()
expect(resolved).not.toHaveBeenCalled()
current = { provider: 'test', model: 'alpha' }
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(resolutions).toHaveLength(2) })
expect(resolutions[1]?.signal?.aborted).toBe(false)
resolutions[1]?.resolve(128_000)
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBe(128_000)
})
it('omits current context fields when measurement or model metadata is unavailable', async () => {
const ctx = new Context()
ctx.provide('tokenMeter', { measure: () => { throw new Error('unmeasurable') } })
ctx.provide('llm', { resolveModelInfo: () => Promise.reject(new Error('metadata unavailable')) })
const session = new Session(SessionId('missing-metrics'))
const attached = agent(session)
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'missing' }),
() => {},
)
const metrics = projector.snapshot(session, attached)
expect(metrics.contextTokens).toBeUndefined()
expect(metrics.contextWindow).toBeUndefined()
await vi.waitFor(() => {
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
})
})
it('keeps optional usage buckets at zero and tolerates absent host services or detached agents', async () => {
const ctx = new Context()
const session = new Session(SessionId('optional-metrics'))
assistant(session, 1, 0, { inputTokens: 7, outputTokens: 2 })
const attached = agent(session)
const selected: { current?: AgentLlmTarget } = {}
const projector = new SessionMetricsProjector(
ctx,
() => selected.current,
() => {},
)
assistant(session, 1, 2, { inputTokens: 1_000, outputTokens: 500 })
expect(projector.snapshot(session)).toMatchObject({
uncachedInputTokens: 7,
outputTokens: 2,
cacheReadTokens: 0,
cacheWriteTokens: 0,
logRevision: session.events.length,
projectionRevision: 2,
uncachedInputTokens: 1_012,
outputTokens: 504,
cacheReadTokens: 88,
cacheWriteTokens: 9,
contextTokens: 200,
})
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
selected.current = { provider: 'test', model: 'no-service' }
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await Promise.resolve()
})
it('publishes a resolved route with no advertised capacity as unknown', async () => {
const ctx = new Context()
ctx.provide('llm', { resolveModelInfo: () => Promise.resolve({}) })
const session = new Session(SessionId('no-capacity'))
const attached = agent(session)
const resolved = vi.fn()
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'metadata-without-context' }),
resolved,
)
it('omits pressure when the token meter is absent or cannot measure the replay', () => {
const session = new Session(SessionId('metrics-pressure-unknown'))
const withoutMeter = new SessionMetricsProjector(new Context()).snapshot(session)
expect(withoutMeter.contextTokens).toBeUndefined()
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
})
it('ignores stale resolution failures and route metadata after the target moves', async () => {
const ctx = new Context()
const resolutions = new Map<string, {
resolve(value: { context: { contextWindow: number } }): void
reject(error: Error): void
}>()
ctx.provide('llm', {
resolveModelInfo(_provider: string, model: string) {
return new Promise<{ context: { contextWindow: number } }>((resolve, reject) => {
resolutions.set(model, { resolve, reject })
})
ctx.provide('tokenMeter', {
measure() {
throw new Error('unmeasurable replay')
},
})
const session = new Session(SessionId('stale-capacity'))
const attached = agent(session)
let current: AgentLlmTarget = { provider: 'test', model: 'alpha' }
const resolved = vi.fn()
const targetFor = vi.fn(() => current)
const projector = new SessionMetricsProjector(ctx, targetFor, resolved)
projector.snapshot(session, attached)
await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) })
current = { provider: 'test', model: 'route-moved-before-snapshot' }
resolutions.get('alpha')?.resolve({ context: { contextWindow: 64_000 } })
await vi.waitFor(() => { expect(targetFor).toHaveBeenCalledTimes(2) })
expect(resolved).not.toHaveBeenCalled()
projector.snapshot(session, attached)
await vi.waitFor(() => { expect(resolutions.has('route-moved-before-snapshot')).toBe(true) })
current = { provider: 'test', model: 'beta' }
projector.snapshot(session, attached)
await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) })
resolutions.get('route-moved-before-snapshot')?.reject(new Error('stale failure'))
await Promise.resolve()
resolutions.get('beta')?.resolve({ context: { contextWindow: 128_000 } })
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBe(128_000)
expect(new SessionMetricsProjector(ctx).snapshot(session).contextTokens).toBeUndefined()
})
})