refactor(token-meter): make context occupancy durable projection state
Replace the transient `session/model-request` mux frame with ordinary durable session state. Occupancy now rides two last-wins projection fields instead of a non-replayable frame that needed removal tombstones and cross-stream fencing. The frame was the only non-replayable class on the mux stream. Because host and mux are independent SSE streams with no cross-stream order, a request emitted before a removal could arrive after `host/session-removed`, and a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. Fixing that needed a lifecycle generation on every frame; the frame itself was the problem. Removed: the `session/model-request` frame and schema, the `agent/model-request` core event, the ApiProxy measurement point, the client-side telemetry map and removal tombstone, and the synthetic `cancelled` open error used to signal reconnect through the error channel. Added: `request/context`, a log-only session event recording the registration-bound capacity of the route a request resolved to, appended beside `request/header` from the lookup that already prepared the call and skipped when the route is unchanged. Capacity stays out of `EpochHeader` because it is adapter metadata about a route, not an input the request was built from, so it must not join request reconstruction or header equality. The `contextPressure` projection pairs the newest provider-reported prompt size with the newest recorded capacity. The two are deliberately not one atomic request observation: switching models can pair a fresh capacity with the prior route's pressure until the next request reports usage. The figure is a user-facing reference, and this matches how the TUI status line has always computed occupancy.
This commit is contained in:
@@ -12,7 +12,7 @@ export type {
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
|
||||
@@ -35,6 +35,10 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
|
||||
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
|
||||
export type ConnectionState = 'connected' | 'reconnecting'
|
||||
|
||||
/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to
|
||||
* SessionManager. */
|
||||
export interface ConnectionSinks {
|
||||
@@ -42,8 +46,9 @@ export interface ConnectionSinks {
|
||||
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
|
||||
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
|
||||
onConnected?: () => void
|
||||
/** After every failed generation closes and before retry starts. Not emitted when the controller is stopped. */
|
||||
onDisconnected?: () => void
|
||||
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
||||
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +63,7 @@ export class ConnectionController {
|
||||
private attempt = 0
|
||||
private current: AbortController | null = null
|
||||
private running = false
|
||||
private lastState: ConnectionState | null = null
|
||||
private readonly config: Required<ConnectionConfig>
|
||||
|
||||
constructor(
|
||||
@@ -114,8 +120,8 @@ export class ConnectionController {
|
||||
if (gen === this.generation && !ac.signal.aborted) ac.abort()
|
||||
resolve()
|
||||
}
|
||||
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, ac.signal, settle)
|
||||
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, ac.signal, settle)
|
||||
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle)
|
||||
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle)
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -132,6 +138,7 @@ export class ConnectionController {
|
||||
timeout.abort()
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.emitState('connected')
|
||||
this.callSink(this.sinks.onConnected)
|
||||
} catch {
|
||||
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
||||
@@ -140,7 +147,7 @@ export class ConnectionController {
|
||||
|
||||
await failed
|
||||
if (!this.isRunning()) return
|
||||
this.callSink(this.sinks.onDisconnected)
|
||||
this.emitState('reconnecting')
|
||||
this.attempt += 1
|
||||
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
|
||||
const idle = new AbortController()
|
||||
@@ -148,15 +155,20 @@ export class ConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deduplicated state emission (sink isolation applies). */
|
||||
private emitState(state: ConnectionState): void {
|
||||
if (this.lastState === state) return
|
||||
this.lastState = state
|
||||
this.callSink(() => this.sinks.onStateChange?.(state))
|
||||
}
|
||||
|
||||
private async pumpStream<F extends { type: string }>(
|
||||
stream: AsyncIterable<RpcRequest<F>>,
|
||||
sink: ((envelope: RpcRequest<F>) => void) | undefined,
|
||||
signal: AbortSignal,
|
||||
onEnd: () => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
for await (const envelope of stream) {
|
||||
if (signal.aborted) break
|
||||
if (envelope.payload.type === 'stream/error') break
|
||||
if (sink !== undefined) this.callSink(() => { sink(envelope) })
|
||||
}
|
||||
|
||||
@@ -444,6 +444,47 @@ function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection
|
||||
return totals
|
||||
}
|
||||
|
||||
/** Latest log-only capacity record, or undefined before any request ran. */
|
||||
function lastRequestContext(
|
||||
log: readonly SessionEvent[],
|
||||
): { provider: string; model: string; contextWindow: number } | undefined {
|
||||
const event = log.findLast(item => (item as { type: string }).type === 'request/context')
|
||||
return event === undefined
|
||||
? undefined
|
||||
: (event as unknown as { data: { provider: string; model: string; contextWindow: number } }).data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture parallel of token-meter's request-pressure projection: the last
|
||||
* provider-reported prompt size paired with the last recorded capacity. The
|
||||
* two need not come from one request — see the token-meter README.
|
||||
*/
|
||||
function contextPressureOf(
|
||||
log: readonly SessionEvent[],
|
||||
): { pressureTokens: number; contextWindow?: number } {
|
||||
let pressureTokens = 0
|
||||
for (const event of log) {
|
||||
const item = event as unknown as {
|
||||
type: string
|
||||
data: { usage?: TokenUsage; chunk?: { type?: string; usage?: TokenUsage } }
|
||||
}
|
||||
const usage = item.type === 'assistant/chunk' && item.data.chunk?.type === 'usage'
|
||||
? item.data.chunk.usage
|
||||
: item.type === 'assistant/message'
|
||||
? item.data.usage
|
||||
: undefined
|
||||
if (usage === undefined) continue
|
||||
pressureTokens = usage.inputTokens
|
||||
+ (usage.cacheReadTokens ?? 0)
|
||||
+ (usage.cacheWriteTokens ?? 0)
|
||||
}
|
||||
const contextWindow = lastRequestContext(log)?.contextWindow
|
||||
return {
|
||||
pressureTokens,
|
||||
...contextWindow === undefined ? {} : { contextWindow },
|
||||
}
|
||||
}
|
||||
|
||||
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
|
||||
@@ -460,23 +501,32 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
values['goal'] = backscanGoal(log)
|
||||
// Always present (token-meter composed): full-log provider billing.
|
||||
values['tokenUsage'] = tokenUsageOf(log)
|
||||
// Always present (token-meter composed): last request pressure and capacity.
|
||||
values['contextPressure'] = contextPressureOf(log)
|
||||
return values
|
||||
}
|
||||
|
||||
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
|
||||
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
|
||||
const type = (event as { type: string }).type
|
||||
// One usage sample advances both token-meter units.
|
||||
if (
|
||||
(type === 'assistant/chunk'
|
||||
&& (event as unknown as { data: { chunk?: { type?: string } } }).data.chunk?.type === 'usage')
|
||||
|| (type === 'assistant/message'
|
||||
&& (event as unknown as { data: { usage?: TokenUsage } }).data.usage !== undefined)
|
||||
) {
|
||||
return [
|
||||
{ type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq },
|
||||
{ type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq },
|
||||
]
|
||||
}
|
||||
if (type === 'request/context') {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'tokenUsage',
|
||||
value: tokenUsageOf(log),
|
||||
key: 'contextPressure',
|
||||
value: contextPressureOf(log),
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
@@ -1136,20 +1186,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
|
||||
}
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
// Capacity parallel of the host token-meter's request/context record:
|
||||
// log-only, appended inside the open turn, and deduplicated against the
|
||||
// route already recorded (the fixture never varies contextWindow).
|
||||
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
emitMux({
|
||||
type: 'session/model-request',
|
||||
sessionId: id,
|
||||
// The fixture's durable transcript is historically zero-based, while
|
||||
// the real Agent's request telemetry opens turns at one.
|
||||
turn: turn + 1,
|
||||
step: 1,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
// No fixture token-meter is composed, so omit the request-pressure
|
||||
// numerator instead of substituting cumulative provider billing.
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
if (lastRequestContext(logOf(id))?.model !== target.model) {
|
||||
append(id, {
|
||||
type: 'request/context',
|
||||
data: { provider: target.provider, model: target.model, contextWindow: 128_000 },
|
||||
})
|
||||
}
|
||||
startReply(
|
||||
id,
|
||||
turn,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks } from './connection.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
|
||||
@@ -17,7 +17,7 @@ export type {
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
@@ -27,7 +27,7 @@ export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks }
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IApiClient, SessionId } from '../src/client/api.ts'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import type { ConnectionState } from '../src/client/connection.ts'
|
||||
import { ConnectionController } from '../src/client/connection.ts'
|
||||
import { FakeApiClient, deferred, ok } from './fake-api.ts'
|
||||
|
||||
@@ -103,51 +104,6 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a sibling stream frame buffered behind a generation failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const lateMux = deferred<undefined>()
|
||||
const originalEvents = api.events
|
||||
Object.defineProperty(api, 'events', {
|
||||
value: {
|
||||
host: (...args: Parameters<IApiClient['events']['host']>) => originalEvents.host(...args),
|
||||
mux: (_payload: unknown, _signal: AbortSignal, onOpen?: () => void) => (async function* () {
|
||||
onOpen?.()
|
||||
await lateMux.promise
|
||||
yield { rpcId: 'late-mux' as never, payload: subscribedFrame(2) }
|
||||
})(),
|
||||
} satisfies IApiClient['events'],
|
||||
})
|
||||
const muxSeen: number[] = []
|
||||
let connected = 0
|
||||
let disconnected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, {
|
||||
onMuxEnvelope: (envelope) => {
|
||||
if (envelope.payload.type === 'session/subscribed') muxSeen.push(envelope.payload.lastSeq)
|
||||
},
|
||||
onConnected: () => { connected++ },
|
||||
onDisconnected: () => {
|
||||
disconnected++
|
||||
lateMux.resolve(undefined)
|
||||
controller.stop()
|
||||
},
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
api.pushHost({
|
||||
type: 'stream/error',
|
||||
error: { code: 'internal', message: 'host stream failed', details: {} },
|
||||
})
|
||||
await vi.waitFor(() => { expect(disconnected).toBe(1) })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(muxSeen).toEqual([])
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates sink exceptions from the pump', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const seen: string[] = []
|
||||
@@ -203,7 +159,29 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reports every failed generation before retry', async () => {
|
||||
it('emits deduplicated connected/reconnecting state transitions', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, {
|
||||
onConnected: () => { connected++ },
|
||||
onStateChange: state => states.push(state),
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(states).toEqual(['connected'])
|
||||
api.failStreams(new Error('torn'))
|
||||
await vi.waitFor(() => { expect(connected).toBe(2) })
|
||||
expect(states).toEqual(['connected', 'reconnecting', 'connected'])
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
||||
let describeCalls = 0
|
||||
@@ -211,19 +189,19 @@ describe('connection lifecycle', () => {
|
||||
describeCalls++
|
||||
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
|
||||
}
|
||||
let disconnected = 0
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, {
|
||||
onConnected: () => { connected++ },
|
||||
onDisconnected: () => { disconnected++ },
|
||||
onStateChange: state => states.push(state),
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(disconnected).toBe(2)
|
||||
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
|
||||
@@ -194,19 +194,17 @@ describe('createFixtureApi', () => {
|
||||
expect(types).toContain('assistant/chunk')
|
||||
expect(types).toContain('assistant/message')
|
||||
expect(types.at(-1)).toBe('turn/end')
|
||||
expect(frames).toContainEqual({
|
||||
type: 'session/model-request',
|
||||
sessionId: id,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
// Capacity is durable log state, not a transient frame: the prompt path
|
||||
// records request/context and the projection carries it to the client.
|
||||
expect(types).toContain('request/context')
|
||||
expect(frames.some(frame =>
|
||||
frame.type === 'session/projection'
|
||||
&& frame.key === 'tokenUsage'
|
||||
&& (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true)
|
||||
expect(frames.some(frame =>
|
||||
frame.type === 'session/projection'
|
||||
&& frame.key === 'contextPressure'
|
||||
&& (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true)
|
||||
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
|
||||
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
|
||||
// Idle cancel: no replay in flight, must not explode; running flips false.
|
||||
@@ -238,7 +236,7 @@ describe('createFixtureApi', () => {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 9) abort.abort()
|
||||
if (envelopes.length >= 10) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -253,11 +251,11 @@ describe('createFixtureApi', () => {
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
|
||||
expect(first[7]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[7]?.rpcId).toBe(first[7]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[8]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[8]?.rpcId).toBe(first[8]?.rpcId)
|
||||
expect(first.some(envelope => envelope.payload.type === 'session/model-request')).toBe(false)
|
||||
expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' })
|
||||
expect(first[8]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[9]?.rpcId).toBe(first[9]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: cc3c4a8ad293451323a757a8ee85e3b903dc176f
|
||||
README.zh.md: f95b3966708b19e57b5c9ef46e0d165dafffdd53
|
||||
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
|
||||
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`, `title`, and `tokenUsage`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. `ConversationSnapshot.modelRequest` separately retains the complete latest `session/model-request` observed on the current mux connection. Each frame replaces the whole snapshot, so omitted numerator or capacity fields clear an earlier value. `SessionManager` buffers one pre-instantiation snapshot, while `session/subscribed`, disconnect, and removal clear resident and pending values; removal also installs a request-only fence so a late transient frame from the independent mux stream cannot repopulate request telemetry, and the next mux subscription or connection generation releases that fence without blocking replayable frame classes. Reconnect, restore, and a new subscription therefore show no context percentage until another request is observed. Model selection alone does not alter request telemetry.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
## Session title projection
|
||||
|
||||
`SessionManager` retains the generic per-session projection store independently of Session-instance arrival, so live `title` frames can update list rows before a conversation opens. A subscription baseline truncates projection rows beyond `lastSeq`; the next history-tail baseline re-seeds durable values, and explicit Session removal clears the store. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` always falls back through the cwd basename and session id while the `title` key is absent.
|
||||
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
|
||||
|
||||
## Session model selection
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`、`title` 与 `tokenUsage`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。`ConversationSnapshot.modelRequest` 另行保留当前 mux 连接观察到的最新完整 `session/model-request`。每个帧都会替换整个快照,因此分子或容量字段一旦缺失,就会清除先前值。`SessionManager` 会缓冲一个实例化前快照;`session/subscribed`、断开连接和移除会话则会清除常驻值与待处理值;移除还会安装仅针对请求的栅栏,避免独立 mux 流中延迟到达的瞬时帧重新填充请求遥测,下一次 mux 订阅或连接 generation 会解除该栅栏,而不会阻断可回放的帧类别。因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
`SessionManager` 独立于 Session 实例是否到达而保留逐会话通用投影值仓,因此实时 `title` 帧可以在会话打开前更新列表行。订阅基线会截断 seq 超过 `lastSeq` 的投影行;下一份 history 尾页基线重新播种持久值,显式移除 Session 则清除该值仓。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`title` key 缺失时,`displayTitle` 始终依次回退到 cwd basename 和 Session id。
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
|
||||
|
||||
## 会话模型选择
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export type {
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
@@ -154,11 +154,11 @@ export function apply(ctx: Context): void {
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
},
|
||||
onDisconnected: () => {
|
||||
onStateChange: (state) => {
|
||||
// Generation death fires before any next-generation frame can arrive
|
||||
// (reconnect replays flow from stream open, ahead of onConnected):
|
||||
// the only safe moment to drop generation-scoped interaction state.
|
||||
sessions.handleDisconnected()
|
||||
if (state === 'reconnecting') sessions.handleDisconnected()
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ModelRequestTelemetry, RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
export type { TodoItem }
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
* (text body / collapsible reasoning / tool-call card head / other fallback). */
|
||||
export type AssistantBlock =
|
||||
@@ -267,6 +270,4 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
blank: boolean
|
||||
lastAgentError: string | null
|
||||
/** Latest atomic model-request snapshot on this mux generation. */
|
||||
modelRequest: ModelRequestTelemetry | null
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type {
|
||||
HostFrame, IApiClient, ModelRequestTelemetry, MuxFrame, RpcError, RpcRequest,
|
||||
RpcResult, SessionId, SessionSummary, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -60,19 +57,6 @@ export class SessionManager {
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
/**
|
||||
* Latest request telemetry observed for an uninstantiated session on the
|
||||
* current mux generation. Unlike durable history, this transient frame
|
||||
* cannot be backfilled when get() lazily creates the Session.
|
||||
*/
|
||||
private readonly modelRequests = new Map<SessionId, ModelRequestTelemetry>()
|
||||
/**
|
||||
* Removal fence for the one non-replayable mux frame. Host and mux use
|
||||
* independent SSE streams, so a request emitted before removal can arrive
|
||||
* after host/session-removed. Durable/replayed frame classes stay unfenced;
|
||||
* the next mux subscription is the same-stream proof that the id is live.
|
||||
*/
|
||||
private readonly removedModelRequests = new Set<SessionId>()
|
||||
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
|
||||
* replays of the same requested frame). Manager-owned rather than read off Session instances
|
||||
* because the sidebar must light up for sessions never instantiated. Cleared per connection
|
||||
@@ -181,7 +165,6 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
private createSession(sessionId: SessionId): Session {
|
||||
const modelRequest = this.modelRequests.get(sessionId)
|
||||
return new Session(sessionId, this.api, {
|
||||
// The sender's local first-send flip mirrors into the list row so the
|
||||
// session surfaces (lists filter on blank) before any host frame lands.
|
||||
@@ -189,7 +172,6 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
|
||||
},
|
||||
projections: this.projectionStore(sessionId),
|
||||
...(modelRequest === undefined ? {} : { modelRequest }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -364,16 +346,7 @@ export class SessionManager {
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/model-request') {
|
||||
if (this.removedModelRequests.has(frame.sessionId)) return
|
||||
// Transient and non-replayable: retain the whole latest request until
|
||||
// lazy instantiation. Missing fields replace rather than inherit.
|
||||
const { type: _type, sessionId, ...modelRequest } = frame
|
||||
this.modelRequests.set(sessionId, modelRequest)
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
this.removedModelRequests.delete(frame.sessionId)
|
||||
this.modelRequests.delete(frame.sessionId)
|
||||
// Rows past the host's durable baseline rode state a restart lost; drop
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
@@ -449,11 +422,9 @@ export class SessionManager {
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
this.removedModelRequests.add(frame.sessionId)
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.modelRequests.delete(frame.sessionId) // connection-local request telemetry dies with the Host session
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
return
|
||||
@@ -494,9 +465,6 @@ export class SessionManager {
|
||||
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
|
||||
else this.pendingBuffers.set(sessionId, kept)
|
||||
}
|
||||
this.modelRequests.clear()
|
||||
this.removedModelRequests.clear()
|
||||
for (const session of this.sessions.values()) session.handleReconnecting()
|
||||
}
|
||||
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
ModelRequestTelemetry, SessionId, ToolEventView,
|
||||
SessionId, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -43,8 +43,6 @@ export interface SessionOptions {
|
||||
* private store (bare object-layer construction).
|
||||
*/
|
||||
projections?: ProjectionValueStore
|
||||
/** Request telemetry already observed on this mux generation before lazy construction. */
|
||||
modelRequest?: ModelRequestTelemetry
|
||||
}
|
||||
|
||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||
@@ -84,8 +82,9 @@ export class Session implements SessionFace {
|
||||
private openState: OpenState = 'cold'
|
||||
private openError: RpcError | null = null
|
||||
private openPromise: Promise<void> | null = null
|
||||
/** Bumped at disconnect and resync to invalidate in-flight history work: a reconnect must
|
||||
* rebuild, never adopt a pre-disconnect response (audit S4). */
|
||||
/** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt
|
||||
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
|
||||
* passes drop all writes once the generation moves on. */
|
||||
private openGeneration = 0
|
||||
private loadingOlder = false
|
||||
private readonly foldAdapter = new FoldAdapter()
|
||||
@@ -110,8 +109,6 @@ export class Session implements SessionFace {
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Latest atomic request snapshot observed on this mux connection. */
|
||||
private modelRequest: ModelRequestTelemetry | null
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
@@ -173,7 +170,6 @@ export class Session implements SessionFace {
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.modelRequest = options.modelRequest ?? null
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -286,14 +282,12 @@ export class Session implements SessionFace {
|
||||
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||
const generation = this.openGeneration
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.openGeneration) return
|
||||
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
@@ -317,10 +311,8 @@ export class Session implements SessionFace {
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] loadOlder failed:', error)
|
||||
} finally {
|
||||
if (generation === this.openGeneration) {
|
||||
this.loadingOlder = false
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
this.loadingOlder = false
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,10 +321,11 @@ export class Session implements SessionFace {
|
||||
* in-flight open first — its history request rode the dead connection and must not settle
|
||||
* the fresh generation into 'error' (audit S4). */
|
||||
async resync(): Promise<void> {
|
||||
// Queue and request telemetry are NOT cleared here: onConnected
|
||||
// (which drives resync) races the mux frames — fresh-generation state may
|
||||
// have landed already, and the host never resends request telemetry.
|
||||
// session/subscribed owns the reset before the queue snapshot.
|
||||
// The queue mirror is NOT cleared here: onConnected (which drives resync)
|
||||
// races the mux frames — the fresh generation's baseline may have landed
|
||||
// already, and the host never resends it. The mirror re-baselines on the
|
||||
// session/subscribed frame instead (same stream as the queue snapshot
|
||||
// that follows it, so ordering is guaranteed).
|
||||
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
|
||||
this.openGeneration++
|
||||
this.openPromise = null
|
||||
@@ -341,8 +334,6 @@ export class Session implements SessionFace {
|
||||
this.events = []
|
||||
this.views = []
|
||||
this.baseSeq = 0
|
||||
this.loadingOlder = false
|
||||
this.stitching = false
|
||||
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
|
||||
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
|
||||
this.pending.clear()
|
||||
@@ -403,7 +394,6 @@ export class Session implements SessionFace {
|
||||
}
|
||||
case 'session/subscribed': {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
let changed = false
|
||||
// New mux-generation baseline: the host pushes this session's queue
|
||||
// snapshot AFTER the subscribed frame on the same stream, so the
|
||||
// stale mirror clears here — race-free against onConnected/resync
|
||||
@@ -411,25 +401,8 @@ export class Session implements SessionFace {
|
||||
if (this.queued.length > 0) {
|
||||
this.queued = []
|
||||
this.queueRev++
|
||||
changed = true
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
if (this.modelRequest !== null) {
|
||||
this.modelRequest = null
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'session/model-request': {
|
||||
const {
|
||||
type: _type,
|
||||
sessionId: _sessionId,
|
||||
...modelRequest
|
||||
} = frame
|
||||
// Whole-frame replacement is load-bearing: an omitted numerator or
|
||||
// capacity clears that field from the preceding request.
|
||||
this.modelRequest = modelRequest
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'approval/requested': {
|
||||
@@ -501,41 +474,10 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Connection-loss boundary: clear values that are not replayed before the next stream starts. */
|
||||
handleReconnecting(): void {
|
||||
this.openGeneration++
|
||||
let changed = false
|
||||
if (this.openState === 'loading') {
|
||||
// The in-flight history request belongs to the dead generation. Its
|
||||
// eventual success or failure is fenced below, so settle the visible
|
||||
// pane now instead of leaving it loading throughout an outage.
|
||||
this.openState = 'error'
|
||||
this.openError = {
|
||||
code: 'cancelled',
|
||||
message: 'session history request cancelled after connection loss',
|
||||
details: {},
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (this.loadingOlder) {
|
||||
// The stale request's generation-fenced finally cannot clear this bit.
|
||||
// Release the paging control synchronously at the connection boundary.
|
||||
this.loadingOlder = false
|
||||
changed = true
|
||||
}
|
||||
if (this.modelRequest !== null) {
|
||||
this.modelRequest = null
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** host/session-removed relay: flag the resident snapshot and clear request telemetry. */
|
||||
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
|
||||
handleRemoved(): void {
|
||||
const changed = !this.removed || this.modelRequest !== null
|
||||
this.removed = true
|
||||
this.modelRequest = null
|
||||
if (changed) this.notifier.markDirty()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -579,23 +521,13 @@ export class Session implements SessionFace {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.projections,
|
||||
)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) {
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.projections,
|
||||
)
|
||||
}
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -616,11 +548,7 @@ export class Session implements SessionFace {
|
||||
* A carried projections block seeds the value store (higher seq wins, so a stale
|
||||
* baseline cannot overwrite a newer push frame); the window events themselves are
|
||||
* never folded — the host is the only computation site. */
|
||||
private installWindow(
|
||||
entries: HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
projections: ProjectionsBaseline | undefined,
|
||||
): void {
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
@@ -676,16 +604,12 @@ export class Session implements SessionFace {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.projections,
|
||||
)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
} finally {
|
||||
if (generation === this.openGeneration) this.stitching = false
|
||||
this.stitching = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,7 +841,6 @@ export class Session implements SessionFace {
|
||||
promptError: this.promptError,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
modelRequest: this.modelRequest,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,60 +102,6 @@ describe('runtime client apply', () => {
|
||||
expect(bench.api.callsOf('session.create')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears connection-local request telemetry after every disconnected generation but not connected', async () => {
|
||||
const bench = await mount()
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'session' as never,
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: 's-state' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const session = sessions.binding('s-state' as never)?.session
|
||||
if (session === undefined) throw new Error('session binding missing')
|
||||
bench.sinks?.onMuxEnvelope?.({
|
||||
rpcId: 'request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: 's-state',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 128_000,
|
||||
} as never,
|
||||
})
|
||||
|
||||
bench.sinks?.onConnected?.()
|
||||
expect(session.getSnapshot().modelRequest).toMatchObject({
|
||||
model: 'alpha',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
|
||||
bench.sinks?.onDisconnected?.()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
// Every failed generation invokes its own disconnect callback, which
|
||||
// clears telemetry received before that generation's handshake failed.
|
||||
bench.sinks?.onMuxEnvelope?.({
|
||||
rpcId: 'request-2' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: 's-state',
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'beta',
|
||||
contextTokens: 48_000,
|
||||
contextWindow: 256_000,
|
||||
} as never,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest?.model).toBe('beta')
|
||||
bench.sinks?.onDisconnected?.()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('stops the stream loop when the plugin fiber unloads', async () => {
|
||||
const bench = await mount()
|
||||
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels,
|
||||
SessionProjectionsBlock, SkillEntry,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -65,11 +64,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{
|
||||
events: never[]
|
||||
hasMore: boolean
|
||||
projections?: SessionProjectionsBlock
|
||||
}>> =
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
|
||||
@@ -41,228 +41,6 @@ describe('instances', () => {
|
||||
expect(manager.get(S2).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('retains the latest transient request snapshot until lazy instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-1' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 12_000,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-2' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 1,
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'beta',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(manager.get(S1).getSnapshot().modelRequest).toEqual({
|
||||
turn: 1,
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'beta',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('retains whole-frame replacement before lazy instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-with-capacity' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-without-capacity' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 1,
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'unknown-capacity',
|
||||
},
|
||||
})
|
||||
|
||||
expect(manager.get(S1).getSnapshot().modelRequest).toEqual({
|
||||
turn: 1,
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'unknown-capacity',
|
||||
})
|
||||
})
|
||||
|
||||
it('clears retained request telemetry on subscribed and removal', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-before-subscribe' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 },
|
||||
})
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-after-subscribe' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 1,
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'beta',
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest?.contextWindow).toBe(256_000)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'removed' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S1 },
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'late-resident-request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'late',
|
||||
contextWindow: 512_000,
|
||||
},
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'resumed-subscription' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'resumed-request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'resumed',
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toMatchObject({
|
||||
model: 'resumed',
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-before-lazy-removal' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S2,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'gamma',
|
||||
contextWindow: 64_000,
|
||||
},
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'lazy-removed' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'late-lazy-request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S2,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'late-lazy',
|
||||
contextWindow: 512_000,
|
||||
},
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('clears resident and lazy request telemetry on disconnect', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const session = manager.get(S1)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'resident-request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'resident',
|
||||
contextTokens: 35,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'lazy-request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S2,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'lazy',
|
||||
contextTokens: 70,
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toMatchObject({
|
||||
model: 'resident',
|
||||
contextTokens: 35,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
|
||||
manager.handleDisconnected()
|
||||
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
|
||||
@@ -93,13 +93,6 @@ describe('queue retirement (host queuedMirror rules)', () => {
|
||||
expect(session.getSnapshot().queue).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an unrelated durable event leaves the queue unchanged', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
|
||||
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: ev.user(0, 'unrelated') })
|
||||
expect(session.getSnapshot().queue.map(row => row.key)).toEqual(['p-1'])
|
||||
})
|
||||
|
||||
it('steering/message drains the source-matched steering row only', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering
|
||||
|
||||
@@ -7,11 +7,8 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
SessionId, SessionProjectionsBlock,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
@@ -25,17 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
|
||||
function histResponse(
|
||||
events: SessionEvent[],
|
||||
hasMore = false,
|
||||
projections?: SessionProjectionsBlock,
|
||||
) {
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({
|
||||
events: entries(events) as never[],
|
||||
hasMore,
|
||||
...projections === undefined ? {} : { projections },
|
||||
}))
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
@@ -51,7 +40,6 @@ describe('open', () => {
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.hasMore).toBe(true)
|
||||
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
|
||||
expect(snapshot.modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
|
||||
@@ -116,87 +104,6 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('replaces the whole request snapshot, clears omitted fields, and resets at subscription', async () => {
|
||||
const { session } = await opened()
|
||||
session.handleMuxEnvelope('request-1' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toEqual({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
|
||||
session.handleMuxEnvelope('request-2' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'without-capacity',
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toEqual({
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'without-capacity',
|
||||
})
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
sessionId: SID,
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
session.handleMuxEnvelope('request-3' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'beta',
|
||||
contextTokens: 20,
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toMatchObject({
|
||||
turn: 3,
|
||||
contextTokens: 20,
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes a subscribed reset when request telemetry arrived first', async () => {
|
||||
const { session } = await opened()
|
||||
session.handleMuxEnvelope('request' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 8_000,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest?.contextWindow).toBe(128_000)
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
sessionId: SID,
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
|
||||
// Live path: run mints an executing node, done settles it in the flow.
|
||||
const { session } = await opened()
|
||||
@@ -353,27 +260,6 @@ describe('paging', () => {
|
||||
await Promise.all([first, second])
|
||||
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
|
||||
})
|
||||
|
||||
it('drops an older page from the disconnected generation', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
await session.open()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => stale.promise
|
||||
const loading = session.loadOlder()
|
||||
|
||||
session.handleReconnecting()
|
||||
stale.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[],
|
||||
hasMore: false,
|
||||
}))
|
||||
await loading
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9])
|
||||
|
||||
api.onHistory = () => histResponse(plainTurn(12, 2, '重连问', '重连答'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([13, 15])
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt and cancel errors', () => {
|
||||
@@ -416,23 +302,6 @@ describe('prompt and cancel errors', () => {
|
||||
})
|
||||
|
||||
describe('pending interactions', () => {
|
||||
it('routes an approval wait response through the original requested rpcId', async () => {
|
||||
const { api, session } = makeSession()
|
||||
session.handleMuxEnvelope('ra-answer' as never, {
|
||||
type: 'approval/requested',
|
||||
sessionId: SID,
|
||||
approvalId: 'ap-answer' as never,
|
||||
toolName: 'bash',
|
||||
})
|
||||
const wait = session.getSnapshot().pending[0]!
|
||||
await wait.respond({ ok: true, value: { decision: 'allow' } })
|
||||
expect(api.callsOf('respond')).toEqual([{
|
||||
type: 'client-response',
|
||||
rpcId: 'ra-answer',
|
||||
result: { ok: true, value: { decision: 'allow' } },
|
||||
}])
|
||||
})
|
||||
|
||||
it('adds approval/question on requested and removes them on resolved', async () => {
|
||||
const { session } = makeSession()
|
||||
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
|
||||
@@ -475,16 +344,6 @@ describe('pending interactions', () => {
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
it('rejects a second scope bind and allows rebinding after explicit release', () => {
|
||||
const { session } = makeSession()
|
||||
const first = new Context()
|
||||
const second = new Context()
|
||||
session.bindScope(first)
|
||||
expect(() => { session.bindScope(second) }).toThrow(`session ${SID} already has a bound scope`)
|
||||
session.unbindScope()
|
||||
expect(() => { session.bindScope(second) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('prompt transport throw folds to internal promptError', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
|
||||
@@ -715,49 +574,22 @@ describe('remaining branches', () => {
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
})
|
||||
|
||||
it('drops a stale gap repair without clearing a newer generation repair', async () => {
|
||||
it('drops a gap repair superseded by a full resync while its pull was in flight', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const staleRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => staleRepair.promise
|
||||
const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => repairPull.promise
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
|
||||
session.handleReconnecting()
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
|
||||
await session.resync()
|
||||
|
||||
const freshRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
let freshRepairCalls = 0
|
||||
api.onHistory = () => {
|
||||
freshRepairCalls++
|
||||
return freshRepair.promise
|
||||
}
|
||||
session.handleMuxEnvelope('fresh-gap' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(15, '新洞'),
|
||||
})
|
||||
expect(freshRepairCalls).toBe(1)
|
||||
|
||||
staleRepair.resolve(ok({
|
||||
const resynced = session.resync() // bumps the generation
|
||||
repairPull.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
})) // repair result: stale, dropped
|
||||
await Promise.resolve()
|
||||
session.handleMuxEnvelope('fresh-buffer' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(16, '继续缓存'),
|
||||
})
|
||||
expect(freshRepairCalls).toBe(1) // stale finally did not clear the newer stitching owner
|
||||
|
||||
freshRepair.resolve(ok({
|
||||
events: entries([...plainTurn(6, 1, 'c', 'd'), ...plainTurn(12, 2, 'e', 'f')]) as never[],
|
||||
hasMore: false,
|
||||
}))
|
||||
await vi.waitFor(() => {
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9, 13, 15])
|
||||
})
|
||||
await resynced
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
})
|
||||
|
||||
it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => {
|
||||
@@ -822,133 +654,6 @@ describe('remaining branches', () => {
|
||||
})
|
||||
|
||||
describe('resync', () => {
|
||||
it('settles an in-flight open when its connection generation dies', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => stale.promise
|
||||
const opening = session.open()
|
||||
expect(session.getSnapshot().openState).toBe('loading')
|
||||
|
||||
session.handleReconnecting()
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
openState: 'error',
|
||||
openError: {
|
||||
code: 'cancelled',
|
||||
message: 'session history request cancelled after connection loss',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
|
||||
stale.reject(new Error('dead generation failed'))
|
||||
await opening
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
openState: 'error',
|
||||
openError: {
|
||||
code: 'cancelled',
|
||||
message: 'session history request cancelled after connection loss',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('settles an in-flight older-page load when its connection generation dies', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
await session.open()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => stale.promise
|
||||
const paging = session.loadOlder()
|
||||
expect(session.getSnapshot().loadingOlder).toBe(true)
|
||||
|
||||
session.handleReconnecting()
|
||||
expect(session.getSnapshot().loadingOlder).toBe(false)
|
||||
|
||||
stale.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[],
|
||||
hasMore: false,
|
||||
}))
|
||||
await paging
|
||||
expect(session.getSnapshot().loadingOlder).toBe(false)
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9])
|
||||
})
|
||||
|
||||
it('clears request telemetry on reconnect and drops a stale in-flight history response', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => stale.promise
|
||||
const opening = session.open()
|
||||
session.handleMuxEnvelope('old-request' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'old',
|
||||
contextTokens: 20,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
|
||||
session.handleReconnecting()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
stale.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[],
|
||||
hasMore: false,
|
||||
}))
|
||||
await opening
|
||||
expect(session.getSnapshot().nodes).toEqual([])
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9])
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves a fresh-generation request snapshot when history resync fails', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
sessionId: SID,
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
session.handleMuxEnvelope('fresh-request' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'fresh',
|
||||
contextTokens: 20,
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
api.onHistory = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'history refresh failed',
|
||||
details: {},
|
||||
}))
|
||||
|
||||
await session.resync()
|
||||
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
openState: 'error',
|
||||
modelRequest: {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'fresh',
|
||||
contextTokens: 20,
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rebuilds the window and clears pending; cold instances no-op', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
|
||||
@@ -62,7 +62,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
promptError: null,
|
||||
blank: false,
|
||||
lastAgentError: null,
|
||||
modelRequest: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,9 +221,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({
|
||||
useProjection, useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder,
|
||||
}: ChatViewSlotProps) {
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
@@ -231,8 +229,7 @@ export function ChatView({
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openError = useSession(s => s.openError)
|
||||
const openErrorMessage = openError === null ? null : `${openError.message}(${openError.code})`
|
||||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
@@ -356,12 +353,7 @@ export function ChatView({
|
||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && openError?.code === 'cancelled' && (
|
||||
<div className={css.hint}>连接已中断,等待重连…</div>
|
||||
)}
|
||||
{openState === 'error' && openError?.code !== 'cancelled' && (
|
||||
<div className={css.openError}>历史加载失败:{openErrorMessage}</div>
|
||||
)}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
@@ -397,7 +389,7 @@ export function ChatView({
|
||||
{running && <TurnDots />}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} useProjection={useProjection} />
|
||||
<StatsLine useSession={useSession} />
|
||||
{!atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Settled-node identity prevents stream-delta updates from rerendering this row.
|
||||
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type {
|
||||
ConversationSnapshot, UseProjection,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelRequestTelemetry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
interface VisibleCounts {
|
||||
@@ -57,16 +58,19 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Current context occupancy using the TUI's integer rounding and upper clamp.
|
||||
* @param request - one atomic request snapshot observed on this mux generation.
|
||||
* @returns occupancy percent, or null when either input is unavailable.
|
||||
* Approximate context occupancy, using the TUI's integer rounding and upper
|
||||
* clamp. The numerator and capacity are independent last-wins projection
|
||||
* fields, so this is a reference figure rather than an exact request
|
||||
* measurement (see the token-meter README).
|
||||
* @param pressure - the session's context-pressure projection value.
|
||||
* @returns occupancy percent, or null when no capacity is known.
|
||||
*/
|
||||
export function contextPercent(request: ModelRequestTelemetry | null): number | null {
|
||||
if (request?.contextTokens === undefined || request.contextWindow === undefined) return null
|
||||
return Math.min(100, Math.round(request.contextTokens / request.contextWindow * 100))
|
||||
export function contextPercent(pressure: ContextPressureProjection | undefined): number | null {
|
||||
if (pressure?.contextWindow === undefined) return null
|
||||
return Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100))
|
||||
}
|
||||
|
||||
/** Props: standard session hooks handed down by ChatView. */
|
||||
/** Props: the framework's session snapshot and projection hook seats. */
|
||||
export interface StatsLineProps {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
useProjection: UseProjection
|
||||
@@ -74,8 +78,8 @@ export interface StatsLineProps {
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const modelRequest = useSession(s => s.modelRequest)
|
||||
const usage = useProjection('tokenUsage')
|
||||
const pressure = useProjection('contextPressure')
|
||||
const counts = useMemo(() => deriveVisibleCounts(nodes), [nodes])
|
||||
const hasUsage = usage !== undefined && (
|
||||
usage.uncachedInputTokens !== 0
|
||||
@@ -83,24 +87,22 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
|
||||
|| usage.cacheReadTokens !== 0
|
||||
|| usage.cacheWriteTokens !== 0
|
||||
)
|
||||
const context = contextPercent(modelRequest)
|
||||
const context = contextPercent(pressure)
|
||||
if (counts.steps === 0 && !hasUsage && context === null) return null
|
||||
|
||||
const parts: string[] = []
|
||||
if (usage === undefined) {
|
||||
parts.push('usage unknown')
|
||||
} else {
|
||||
if (usage !== undefined) {
|
||||
parts.push(`${formatMetricTokens(usage.uncachedInputTokens)} uncached input`)
|
||||
parts.push(`${formatMetricTokens(usage.outputTokens)} output`)
|
||||
parts.push(`${formatMetricTokens(usage.cacheReadTokens)} cache read`)
|
||||
const cacheHit = cacheHitPercent(usage)
|
||||
if (cacheHit !== null) parts.push(`cache hit ${cacheHit}%`)
|
||||
}
|
||||
// contextPercent validates both fields; repeat the capacity guard so that
|
||||
// TypeScript carries the same refinement into the formatting branch.
|
||||
parts.push(context === null || modelRequest?.contextWindow === undefined
|
||||
? 'context unknown'
|
||||
: `context ${context}% of ${formatMetricTokens(modelRequest.contextWindow)}`)
|
||||
// Capacity absent (no token-meter, or an adapter that advertises none) omits
|
||||
// the segment: an unknown denominator has no percentage worth a placeholder.
|
||||
if (context !== null && pressure?.contextWindow !== undefined) {
|
||||
parts.push(`context ${context}% of ${formatMetricTokens(pressure.contextWindow)}`)
|
||||
}
|
||||
parts.push(`${counts.turns} turns`)
|
||||
parts.push(`${counts.steps} steps`)
|
||||
return <div className={css.root}>{parts.join(' · ')}</div>
|
||||
|
||||
@@ -207,25 +207,15 @@ describe('small branch tails', () => {
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// cacheHitPct is null only when input+cacheRead are both zero (pure
|
||||
// output accounting) — any input makes it a real 0%.
|
||||
const snap = {
|
||||
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
|
||||
modelRequest: null,
|
||||
}
|
||||
const usage = {
|
||||
uncachedInputTokens: 0,
|
||||
outputTokens: 10,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 5_000,
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
|
||||
useProjection={(() => usage)}
|
||||
/>,
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
|
||||
)
|
||||
expect(view.getByText(
|
||||
'0 uncached input · 10 output · 0 cache read · context unknown · 1 turns · 1 steps',
|
||||
)).toBeTruthy()
|
||||
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ function snapshotWith(
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,17 +390,6 @@ describe('ChatView', () => {
|
||||
const loading = makeHarness({ openState: 'loading' })
|
||||
const lv = render(<loading.ChatView {...loading.props} />)
|
||||
expect(lv.getByText('载入历史…')).toBeTruthy()
|
||||
const reconnecting = makeHarness({
|
||||
openState: 'error',
|
||||
openError: {
|
||||
code: 'cancelled',
|
||||
message: 'session history request cancelled after connection loss',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
const rv = render(<reconnecting.ChatView {...reconnecting.props} />)
|
||||
expect(rv.getByText('连接已中断,等待重连…')).toBeTruthy()
|
||||
expect(rv.queryByText(/session history request/)).toBeNull()
|
||||
})
|
||||
|
||||
it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('render branch tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('StatsLine takes durable counters from tokenUsage while keeping visible node counts', () => {
|
||||
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
|
||||
const snap = {
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
|
||||
@@ -44,24 +44,12 @@ describe('render branch tails', () => {
|
||||
// outputTokens absent: the tokens sum's ?? 0 arm for output.
|
||||
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
],
|
||||
modelRequest: null,
|
||||
}
|
||||
const usage = {
|
||||
uncachedInputTokens: 9,
|
||||
outputTokens: 6,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
|
||||
useProjection={(() => usage)}
|
||||
/>,
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
|
||||
)
|
||||
expect(view.getByText(
|
||||
'9 uncached input · 6 output · 0 cache read · cache hit 0% · context unknown · 2 turns · 3 steps',
|
||||
)).toBeTruthy()
|
||||
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
const props: InputBarProps = {
|
||||
sessionId: SID,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
const barProps: InputBarProps = {
|
||||
sessionId,
|
||||
|
||||
@@ -20,7 +20,7 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
|
||||
README.md: ffdce143c0023bab22efc49fb1272377bf0bfdcf
|
||||
README.zh.md: 9dd982785590d1de6864ede8f071d3aa884a2827
|
||||
README.md: 6bb8b12af69f54c2a75cd672e4d3802887808c76
|
||||
README.zh.md: f9eb8aa3cdead427a88492e35c00eab80ba12f91
|
||||
|
||||
@@ -63,7 +63,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
|
||||
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history.
|
||||
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. Once the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop emits one contained live `agent/model-request` notification with turn, step, route, and optional capacity copied from that same prepared call. This is an observed Agent-loop attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while a short-circuit handle or later lazy adapter construction, failure, or abortion still counts. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ interface Config {
|
||||
|
||||
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
|
||||
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条实时 `agent/model-request` 通知,并收容该通知的失败;通知中包含轮次、步骤、路由,以及从同一次准备完成的调用中复制的可选容量。这是 AgentLoop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
|
||||
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
|
||||
|
||||
@@ -90,7 +90,7 @@ interface Config {
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall 可以改变最终请求,并使其监听器负责保持协议连贯。
|
||||
每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall(瀑布式事件)可以改变最终请求,并使其监听器负责保持协议连贯。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -489,20 +489,6 @@ export class ReactLoopAgent implements Agent {
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
|
||||
emitAgentEvent(
|
||||
this.loopCtx,
|
||||
this,
|
||||
'agent/model-request',
|
||||
turn,
|
||||
step,
|
||||
{
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...preparedCall?.context === undefined
|
||||
? {}
|
||||
: { contextWindow: preparedCall.context.contextWindow },
|
||||
},
|
||||
)
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
signal.throwIfAborted()
|
||||
@@ -640,6 +626,23 @@ export class ReactLoopAgent implements Agent {
|
||||
session.append('request/header', { header, reason: 'change' })
|
||||
}
|
||||
|
||||
// Capacity of the route this request resolved to, recorded from the same
|
||||
// registration-bound lookup that prepared the call (no second resolve).
|
||||
// Deduplicated against the last record: an unchanged route logs nothing.
|
||||
const contextWindow = preparedCall?.context?.contextWindow
|
||||
if (contextWindow !== undefined) {
|
||||
const previous = session.requestContext()
|
||||
if (previous?.provider !== config.provider
|
||||
|| previous.model !== config.model
|
||||
|| previous.contextWindow !== contextWindow) {
|
||||
session.append('request/context', {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
contextWindow,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const request = markAgentLoopRequest(deepFreeze({
|
||||
...header.config,
|
||||
messages: boundaryMessages,
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
@@ -181,7 +179,6 @@ describe('request stability across the loop', () => {
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
context: { contextWindow: 64_000 },
|
||||
reasoning: await reasoning.promise,
|
||||
}
|
||||
}
|
||||
@@ -192,12 +189,6 @@ describe('request stability across the loop', () => {
|
||||
})
|
||||
const disposeFirst = ctx.llm.registerAdapter(['mock'], first)
|
||||
const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' })
|
||||
const dispatched: number[] = []
|
||||
ctx.on('agent/model-request', (subject, _turn, _step, request) => {
|
||||
if (subject === agent && request.contextWindow !== undefined) {
|
||||
dispatched.push(request.contextWindow)
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await started.promise
|
||||
@@ -213,7 +204,6 @@ describe('request stability across the loop', () => {
|
||||
ReasoningEffortId('high'),
|
||||
])
|
||||
expect(second.requests).toHaveLength(0)
|
||||
expect(dispatched).toEqual([64_000])
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high'))
|
||||
})
|
||||
@@ -293,7 +283,6 @@ describe('request stability across the loop', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
let observed: GenerateOptions | undefined
|
||||
let observedRequest: { provider: string; model: string; contextWindow?: number } | undefined
|
||||
ctx.on('llm/stream', (options) => {
|
||||
observed = options
|
||||
return (async function* () {
|
||||
@@ -304,15 +293,11 @@ describe('request stability across the loop', () => {
|
||||
provider: 'listener',
|
||||
model: 'virtual',
|
||||
})
|
||||
ctx.on('agent/model-request', (subject, _turn, _step, request) => {
|
||||
if (subject === agent) observedRequest = { ...request }
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(observed).toMatchObject({ provider: 'listener', model: 'virtual' })
|
||||
expect(observedRequest).toEqual({ provider: 'listener', model: 'virtual' })
|
||||
expect(agent.session.requestHeader()?.config).toEqual({
|
||||
provider: 'listener',
|
||||
model: 'virtual',
|
||||
@@ -323,129 +308,6 @@ describe('request stability across the loop', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies one contained request attempt after the outer stream handle returns', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: 'stable base' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
let resolutions = 0
|
||||
const adapter = new class extends LlmAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
resolutions += 1
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...model === 'capacity'
|
||||
? { context: { contextWindow: 128_000 } }
|
||||
: {},
|
||||
})
|
||||
}
|
||||
|
||||
override stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.model === 'lazy-sync-failure') {
|
||||
throw new LlmError('lazy construction failed', 'CONSTRUCTION')
|
||||
}
|
||||
if (options.model === 'async-failure') {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => Promise.reject(new LlmError('iteration failed', 'ITERATION')),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return (async function* () {
|
||||
yield* textResponse(options.model)
|
||||
})()
|
||||
}
|
||||
}()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('model-request-live'), {
|
||||
provider: 'mock',
|
||||
model: 'capacity',
|
||||
})
|
||||
const returnedHandles = new Set<string>()
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
if (options.model === 'outer-failure') {
|
||||
throw new Error('outer waterfall failed before returning a handle')
|
||||
}
|
||||
if (options.model === 'lazy-sync-failure') {
|
||||
const stream = (async function* () {
|
||||
yield* next()
|
||||
})()
|
||||
returnedHandles.add(options.model)
|
||||
return stream
|
||||
}
|
||||
const stream = next()
|
||||
returnedHandles.add(options.model)
|
||||
return stream
|
||||
})
|
||||
const observed: {
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
model: string
|
||||
contextWindow?: number
|
||||
}[] = []
|
||||
const observedBeforeHandleReturn: string[] = []
|
||||
ctx.on('agent/model-request', (subject) => {
|
||||
if (subject === agent) throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('agent/model-request', (subject, turn, step, request) => {
|
||||
if (subject !== agent) return
|
||||
if (!returnedHandles.has(request.model)) observedBeforeHandleReturn.push(request.model)
|
||||
observed.push({ turn, step, ...request })
|
||||
})
|
||||
ctx.on('agent/request', async (_subject, turn, _step, _signal, next) => ({
|
||||
...await next(),
|
||||
model: [
|
||||
'capacity',
|
||||
'unknown',
|
||||
'async-failure',
|
||||
'lazy-sync-failure',
|
||||
'outer-failure',
|
||||
][turn - 1]!,
|
||||
}))
|
||||
|
||||
for (const prompt of ['one', 'two', 'three', 'four', 'five']) {
|
||||
send(agent, prompt)
|
||||
await waitForIdle(ctx, agent)
|
||||
}
|
||||
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
model: 'capacity',
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
model: 'unknown',
|
||||
},
|
||||
{
|
||||
turn: 3,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
model: 'async-failure',
|
||||
},
|
||||
{
|
||||
turn: 4,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
model: 'lazy-sync-failure',
|
||||
},
|
||||
])
|
||||
expect(resolutions).toBe(5)
|
||||
expect(observedBeforeHandleReturn).toEqual([])
|
||||
expect(returnedHandles.has('outer-failure')).toBe(false)
|
||||
})
|
||||
|
||||
it('a compaction replace rewrites the resend, and the log explains it', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
|
||||
README.md: afad797f1f21240610c8fe37aa6d2f55cf06fd26
|
||||
README.zh.md: 4e8d366a1e72071805073088b136a49a4a3703d9
|
||||
README.md: 9ca79f28506b133a555bd7d1e984386c715fd9d6
|
||||
README.zh.md: 165f71f1b395bdf0c229e2c4b1a30e89347b6be1
|
||||
|
||||
@@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports the route and optional context capacity copied from the registration-bound prepared call whose outer stream handle returned. It is neither durable state nor proof of provider I/O. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
|
||||
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;由绑定注册项的准备完成调用发起的外层流返回句柄后,失败受收容的 `agent/model-request` 通知会报告其路由,以及从该调用复制的可选上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
|
||||
`PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识。
|
||||
|
||||
|
||||
@@ -91,16 +91,6 @@ export type PromptDecision =
|
||||
/** Model-request failure with an optional machine-routable provider code. */
|
||||
export type RequestError = Error & { code?: string }
|
||||
|
||||
/** Live metadata for one model request whose outer stream handle was obtained. */
|
||||
export interface AgentModelRequest {
|
||||
/** Final request provider route; a short-circuit listener may own it. */
|
||||
readonly provider: string
|
||||
/** Final request model id; a short-circuit listener may own it. */
|
||||
readonly model: string
|
||||
/** Registration-bound context capacity when preparation exposed one. */
|
||||
readonly contextWindow?: number
|
||||
}
|
||||
|
||||
/** Action returned by a listener that owns model-request recovery. */
|
||||
export type RequestErrorAction = { kind: 'retry' } | undefined
|
||||
|
||||
@@ -347,20 +337,6 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* One model request obtained its outer `llm/stream` handle and is about to
|
||||
* iterate it. This observes an Agent-loop request attempt, not proof that
|
||||
* provider I/O began. The notification is live, contained, and not replayed.
|
||||
* Preparation or a synchronous outer waterfall failure emits nothing;
|
||||
* failures or abortion after the handle returns still count.
|
||||
* @param agent - the agent dispatching the model request.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the request's step number.
|
||||
* @param request - final route plus registration-bound context capacity.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/model-request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, request: AgentModelRequest): void
|
||||
/**
|
||||
* Handle a model-request failure after its failed step has closed but
|
||||
* before the failed turn closes. A listener returns `{ kind: 'retry' }`
|
||||
|
||||
@@ -15,7 +15,6 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
|
||||
'agent/inbox/dequeue': args => args[0],
|
||||
'agent/inbox/discard': args => args[0],
|
||||
'agent/inbox/enqueue': args => args[0],
|
||||
'agent/model-request': args => args[0],
|
||||
'agent/prompt-submit': args => args[0],
|
||||
'agent/request': args => args[0],
|
||||
'agent/request-error': args => args[0],
|
||||
|
||||
@@ -56,7 +56,6 @@ describe('scoped-dispatch invariants', () => {
|
||||
'agent/step': [agent, 1, 1, signal],
|
||||
'agent/prompt-submit': [agent, message, signal, () => Promise.resolve({ kind: 'allow' })],
|
||||
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)],
|
||||
'agent/model-request': [agent, 1, 1, { provider: 'p', model: 'm', contextWindow: 128_000 }],
|
||||
'agent/request-error': [
|
||||
agent,
|
||||
1,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -559,6 +559,30 @@ export class Session {
|
||||
return this.headerFold
|
||||
}
|
||||
|
||||
/** Cached fold of the request-context events — see {@link requestContext}. */
|
||||
private contextFold: RequestContext | undefined
|
||||
/** Log position (events consumed) the context fold has reached. */
|
||||
private contextFoldSeq = 0
|
||||
|
||||
/**
|
||||
* The route capacity in force after the log's last `request/context` event —
|
||||
* what the NEXT request deduplicates against — or undefined before any such
|
||||
* record. Maintained incrementally like {@link requestHeader}, so a per-step
|
||||
* read costs O(new events).
|
||||
* @returns the folded capacity record, or undefined when none exists yet.
|
||||
*/
|
||||
requestContext(): RequestContext | undefined {
|
||||
if (this.contextFoldSeq < this.log.length) {
|
||||
for (const event of this.log.slice(this.contextFoldSeq)) {
|
||||
// Frozen for the same reason as the header fold: it is session state
|
||||
// exposed by reference and every later dedup compares against it.
|
||||
if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
|
||||
}
|
||||
this.contextFoldSeq = this.log.length
|
||||
}
|
||||
return this.contextFold
|
||||
}
|
||||
|
||||
/** The derived-message cache: frozen projections, extended per unseen node. */
|
||||
private derived: Message[] = []
|
||||
/** Surface position (nodes projected) the cache has reached. */
|
||||
|
||||
@@ -146,6 +146,7 @@ function validateEvent(
|
||||
break
|
||||
case 'steering/message':
|
||||
case 'todo/write':
|
||||
case 'request/context':
|
||||
case 'request/header': {
|
||||
if (trace.openTurn === null) {
|
||||
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
|
||||
|
||||
@@ -169,6 +169,20 @@ export interface EpochHeader {
|
||||
tools?: ToolSchema[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration-bound context capacity of one resolved model route. Adapter
|
||||
* metadata about a route rather than a request input, which is why it lives
|
||||
* outside {@link EpochHeader}.
|
||||
*/
|
||||
export interface RequestContext {
|
||||
/** Registered provider route the capacity was resolved through. */
|
||||
provider: string
|
||||
/** Provider-owned model id the capacity belongs to. */
|
||||
model: string
|
||||
/** Maximum combined request and response context in tokens. */
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
|
||||
* header (a new conversation); `'resume'` — a loop instance's first request
|
||||
@@ -250,6 +264,16 @@ export interface SessionEventMap {
|
||||
* It is log-only; the latest snapshot reconstructs the request header.
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* Registration-bound context capacity for the route a request resolved to,
|
||||
* appended inside its step beside `request/header` and only when the route
|
||||
* or capacity differs from the last record. It is log-only and deliberately
|
||||
* NOT part of {@link EpochHeader}: capacity is adapter metadata about a
|
||||
* route, not an input the request was built from, so it must not participate
|
||||
* in request reconstruction or header equality. Absent for a route whose
|
||||
* adapter advertises no capacity.
|
||||
*/
|
||||
'request/context': RequestContext
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
|
||||
@@ -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: 872d653a194bc08ec7a4a2125137af51d0105a52
|
||||
README.zh.md: 5470b7680b1fea42cc3383ebcc121f05015f5226
|
||||
README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74
|
||||
README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9
|
||||
|
||||
@@ -22,10 +22,6 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
`session.history` pages on message boundaries. Its tail page (no `beforeSeq`) carries only the generic `projections` baseline for registered units; older pages omit it. When token-meter is composed with the projection registry, full-log provider billing rides the ordinary `tokenUsage` key. Its usage chunks and final messages are deduplicated by `(turn, step)`, while cache reads and writes remain disjoint buckets. ApiProxy owns no token-specific history field, mux frame, projector, revision counter, or refresh queue.
|
||||
|
||||
Request context uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an observed request attempt returns its outer stream handle. This boundary does not prove provider I/O began. In the same synchronous event boundary, ApiProxy optionally reads `tokenMeter.measure(session).totalTokens` once and combines it with capacity from that exact prepared call. The atomic frame carries turn, step, final provider/model, and optional `contextTokens`/`contextWindow` only to mux connections already open at that instant. Measurement failure omits only the numerator. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay an earlier snapshot, and missing fields in a later frame replace rather than inherit prior values.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
@@ -22,10 +22,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`session.history` 按消息边界分页。其尾页(不带 `beforeSeq`)只携带已注册单元的通用 `projections` 基线;较早页面则省略该基线。当 token-meter 与投影注册表组合时,完整日志中的提供方计费用量会通过普通 `tokenUsage` 键承载。系统按 `(turn, step)` 对其用量分片和最终消息去重,缓存读取与写入则仍是相互独立的计数项。ApiProxy 不拥有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或刷新队列。
|
||||
|
||||
请求上下文使用独立的临时 `session/model-request` mux 帧。外层流调用为一次已观测的请求尝试返回句柄后,系统会根据 Agent 通知发出该帧,并收容通知失败。这个边界不能证明提供方 I/O 已开始。在同一同步事件边界内,ApiProxy 会可选地读取一次 `tokenMeter.measure(session).totalTokens`,并将结果与该次准备完成调用的容量合并。这个原子帧携带轮次、步骤、最终提供方/模型与可选的 `contextTokens`/`contextWindow`,且只发送给当时已经打开的 mux 连接。测量失败时只省略分子。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放更早的快照;后续帧中缺失的字段会清除对应的先前值,而不是继承它。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
@@ -60,19 +60,12 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-token-meter": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@ import type {
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
// Type-only: resolves the optional `ctx.get('tokenMeter')` service seam.
|
||||
import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
// GoalError narrows domain rejections to their stable codes at the wire boundary.
|
||||
import { GoalError } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
|
||||
@@ -497,31 +495,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
for (const queue of muxQueues) queue.push(envelope)
|
||||
}
|
||||
|
||||
ctx.on('agent/model-request', (agent, turn, step, request) => {
|
||||
const tokenMeter = ctx.get('tokenMeter')
|
||||
let contextTokens: number | undefined
|
||||
if (tokenMeter !== undefined) {
|
||||
try {
|
||||
contextTokens = tokenMeter.measure(agent.session).totalTokens
|
||||
} catch {
|
||||
// A malformed or temporarily unmeasurable replay omits only the
|
||||
// numerator; this request still replaces stale telemetry.
|
||||
}
|
||||
}
|
||||
broadcast({
|
||||
type: 'session/model-request',
|
||||
sessionId: agent.session.id,
|
||||
turn,
|
||||
step,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...contextTokens === undefined ? {} : { contextTokens },
|
||||
...request.contextWindow === undefined
|
||||
? {}
|
||||
: { contextWindow: request.contextWindow },
|
||||
})
|
||||
})
|
||||
|
||||
// Projection change feed → session/projection push frames. The carrier
|
||||
// mints the wire frame (the seam package holds no wire vocabulary); the
|
||||
// child activates only when a projection registry is composed, and the
|
||||
@@ -966,7 +939,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
})
|
||||
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
|
||||
// the one path that never needs fresh projection state.
|
||||
// the one path that never needs a fresh projection baseline.
|
||||
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
|
||||
return ok(request, {
|
||||
events: entries,
|
||||
|
||||
@@ -10,9 +10,7 @@ import type { HostFrame, MuxFrame } from './events.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
@@ -37,16 +35,6 @@ const messageSchema = z.object({
|
||||
export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
|
||||
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
|
||||
z.object({
|
||||
type: z.literal('session/model-request'),
|
||||
sessionId: sessionIdSchema,
|
||||
turn: z.number().int().positive(),
|
||||
step: z.number().int().positive(),
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
contextTokens: z.number().int().nonnegative().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
|
||||
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')]) }),
|
||||
// Non-empty by wire contract: the user-interaction service rejects empty
|
||||
|
||||
@@ -31,24 +31,6 @@ export type ToolEventView =
|
||||
| { for: 'call'; view: ToolCallView }
|
||||
| { for: 'result'; view: ToolResultView }
|
||||
|
||||
/** Atomic telemetry captured at one observed model-request boundary. */
|
||||
export interface ModelRequestTelemetry {
|
||||
/**
|
||||
* Agent request identity and resolved route. The current StatsLine consumes
|
||||
* only occupancy, while the complete snapshot preserves provenance for
|
||||
* diagnostics and later request-bound consumers without consulting mutable
|
||||
* selected-model state.
|
||||
*/
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
model: string
|
||||
/** Token-meter pressure measured synchronously for this exact request. */
|
||||
contextTokens?: number
|
||||
/** Registration-bound capacity from this exact prepared call. */
|
||||
contextWindow?: number
|
||||
}
|
||||
|
||||
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
|
||||
export interface EventsApi {
|
||||
/**
|
||||
@@ -75,18 +57,6 @@ export interface EventsApi {
|
||||
export type MuxFrame =
|
||||
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
|
||||
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
|
||||
/**
|
||||
* One request attempt observed by this already-open mux connection after its
|
||||
* final route and outer `llm/stream` handle were obtained. This does not prove
|
||||
* provider I/O began. The frame is transient: mux baselines, reconnects, and
|
||||
* session history never replay it. The optional numerator and capacity are
|
||||
* one atomic request snapshot; absent fields explicitly replace, rather
|
||||
* than inherit from, the preceding request.
|
||||
*/
|
||||
| ({
|
||||
type: 'session/model-request'
|
||||
sessionId: SessionId
|
||||
} & ModelRequestTelemetry)
|
||||
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
|
||||
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
|
||||
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
|
||||
|
||||
@@ -29,16 +29,13 @@ export interface ApiProxy {
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock,
|
||||
SessionsApi, SessionSummary,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type {
|
||||
EventsApi, HostFrame, ModelRequestTelemetry, MuxFrame, ToolCallView, ToolEventView, ToolResultView,
|
||||
} from './events.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
@@ -151,7 +151,7 @@ export const sessionProjectionsBlockSchema = z.object({
|
||||
values: z.record(z.string(), z.unknown()),
|
||||
}) as unknown as z.ZodType<SessionProjectionsBlock>
|
||||
|
||||
/** session.history response value (projections ride the tail page only). */
|
||||
/** session.history response value (projections rides the tail page only). */
|
||||
export const sessionHistoryValueSchema = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
|
||||
@@ -188,15 +188,9 @@ export interface SessionsApi {
|
||||
* the client needs a fresh baseline already pulls the tail page, and
|
||||
* loadOlder (the only beforeSeq path) is the only path that never needs one.
|
||||
* A deployment without the registry serves histories without the block.
|
||||
* Model-request telemetry is connection-local and is never reconstructed
|
||||
* from history.
|
||||
*/
|
||||
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
|
||||
Promise<RpcResponse<{
|
||||
events: HistoryEntry[]
|
||||
hasMore: boolean
|
||||
projections?: SessionProjectionsBlock
|
||||
}>>
|
||||
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
|
||||
|
||||
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
|
||||
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
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('atomically measures the observed request, forwards only live, and degrades per field', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
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 measure = vi.fn(() => ({ totalTokens: 321 }))
|
||||
const removeTokenMeter = ctx.provide('tokenMeter', { measure })
|
||||
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)
|
||||
|
||||
agentEvents(ctx, agent).emit('agent/model-request', 1, 2, {
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(measure).toHaveBeenCalledWith(session)
|
||||
expect(await nextFrame(primary, 'session/model-request')).toEqual({
|
||||
type: 'session/model-request',
|
||||
sessionId: session.id,
|
||||
turn: 1,
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 321,
|
||||
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).not.toHaveProperty('metrics')
|
||||
expect(history.result.value).not.toHaveProperty('modelRequest')
|
||||
|
||||
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)
|
||||
|
||||
measure.mockImplementation(() => { throw new Error('unmeasurable replay') })
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
removeTokenMeter()
|
||||
agentEvents(ctx, agent).emit('agent/model-request', 3, 1, {
|
||||
provider: 'test',
|
||||
model: 'without-meter',
|
||||
contextWindow: 64_000,
|
||||
})
|
||||
expect(await nextFrame(primary, 'session/model-request')).toEqual({
|
||||
type: 'session/model-request',
|
||||
sessionId: session.id,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'without-meter',
|
||||
contextWindow: 64_000,
|
||||
})
|
||||
|
||||
primaryAbort.abort()
|
||||
reconnectAbort.abort()
|
||||
await primary.return?.()
|
||||
await reconnect.return?.()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -153,13 +153,11 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
|
||||
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
|
||||
const history = sessionHistoryValueSchema.parse({
|
||||
expect(sessionHistoryValueSchema.parse({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
projections: { asOfSeq: 11, values: { todos: [] } },
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
expect(history.projections).toEqual({ asOfSeq: 11, values: { todos: [] } })
|
||||
}).hasMore).toBe(false)
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
@@ -361,24 +359,6 @@ describe('events frame schemas', () => {
|
||||
const frames = [
|
||||
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
|
||||
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
|
||||
{
|
||||
type: 'session/model-request',
|
||||
sessionId: 's',
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-chat',
|
||||
contextTokens: 8_000,
|
||||
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 }] },
|
||||
@@ -391,10 +371,6 @@ describe('events frame schemas', () => {
|
||||
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
|
||||
for (const invalid of [
|
||||
{ type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' },
|
||||
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 0, provider: 'p', model: 'm' },
|
||||
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextTokens: -1 },
|
||||
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 },
|
||||
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
TokenMeterConfig,
|
||||
TokenSurfaceNode,
|
||||
} from './types.ts'
|
||||
import { tokenUsageProjectionDefinition } from './usage-projection.ts'
|
||||
import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
@@ -97,6 +97,7 @@ export class TokenMeterService extends Service {
|
||||
// compositions without the generic registry keep the meter's old shape.
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition)
|
||||
projectionCtx.sessionProjections.register(contextPressureProjectionDefinition)
|
||||
})
|
||||
|
||||
// Readers catch up independently, while eager observation bounds ordinary
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Pure client-safe token-usage projection vocabulary.
|
||||
* Pure client-safe token-projection vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-token-meter/projection
|
||||
*/
|
||||
@@ -17,9 +17,34 @@ export interface TokenUsageProjection {
|
||||
cacheWriteTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate context occupancy for a status display.
|
||||
*
|
||||
* The two fields are deliberately NOT one atomic request observation:
|
||||
* `pressureTokens` is the newest provider-reported prompt size in the log,
|
||||
* `contextWindow` the newest recorded route capacity. Switching models can
|
||||
* therefore pair a fresh capacity with the previous route's pressure until the
|
||||
* next request reports usage. This is an intentional trade — the value is a
|
||||
* user-facing reference, not a billing or gating input — and it matches how
|
||||
* the TUI status line has always computed occupancy. See the token-meter
|
||||
* README for the full rationale.
|
||||
*/
|
||||
export interface ContextPressureProjection {
|
||||
/**
|
||||
* Provider-reported prompt size of the most recent request: uncached input
|
||||
* plus cache reads and writes. Response output is excluded, so this does not
|
||||
* grow as the current turn streams.
|
||||
*/
|
||||
pressureTokens: number
|
||||
/** Newest recorded route capacity; absent when no adapter advertised one. */
|
||||
contextWindow?: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Provider-reported usage accumulated across the complete durable log. */
|
||||
tokenUsage: TokenUsageProjection
|
||||
/** Newest request pressure paired with the newest known route capacity. */
|
||||
contextPressure: ContextPressureProjection
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Pure fold for durable provider-reported token usage.
|
||||
* Pure folds for durable provider-reported token usage and context occupancy.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { TokenUsageProjection } from './projection.ts'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
|
||||
|
||||
interface UsageSample {
|
||||
turn: number
|
||||
@@ -56,13 +56,26 @@ const projectionSchema = z.object({
|
||||
cacheWriteTokens: z.number().int().nonnegative(),
|
||||
}).strict()
|
||||
|
||||
// Cast for the optional capacity: under exactOptionalPropertyTypes zod infers
|
||||
// `number | undefined` where the interface declares an absent-or-number field.
|
||||
const pressureSchema = z.object({
|
||||
pressureTokens: z.number().int().nonnegative(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
}).strict() as unknown as z.ZodType<ContextPressureProjection>
|
||||
|
||||
/** Prompt-side pressure of one request: input plus cache traffic, no output. */
|
||||
const pressureFrom = (usage: TokenUsage): number =>
|
||||
usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
||||
|
||||
/**
|
||||
* Token-meter's session projection unit.
|
||||
*
|
||||
* Usage chunks provide an early sample that survives a later request failure;
|
||||
* an assistant message provides the final sample for the same turn/step. A
|
||||
* repeated sample replaces that step's earlier value instead of double
|
||||
* counting it.
|
||||
* counting it. The single `last` slot relies on the session-log invariant
|
||||
* that usage reports for one turn/step are adjacent: once a later step begins,
|
||||
* a legal log never reports usage for an earlier step again.
|
||||
*/
|
||||
export const tokenUsageProjectionDefinition:
|
||||
ProjectionDefinition<'tokenUsage', TokenUsageState> = {
|
||||
@@ -98,3 +111,41 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = {
|
||||
view: state => state.totals,
|
||||
stateVersion: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-meter's context-occupancy projection unit.
|
||||
*
|
||||
* Two independent last-wins slots: the newest usage sample supplies the
|
||||
* numerator, the newest `request/context` record the denominator. Both are
|
||||
* whole values, so replay order alone decides the result and no cross-field
|
||||
* consistency is claimed — the pair is explicitly not one atomic request
|
||||
* observation (see {@link ContextPressureProjection}).
|
||||
*
|
||||
* The numerator is prompt-side only, so it holds still while a turn streams
|
||||
* and steps forward once the next request reports its usage.
|
||||
*/
|
||||
export const contextPressureProjectionDefinition:
|
||||
ProjectionDefinition<'contextPressure', ContextPressureProjection> = {
|
||||
key: 'contextPressure',
|
||||
schema: pressureSchema,
|
||||
init: () => ({ pressureTokens: 0 }),
|
||||
apply: (state, event) => {
|
||||
if (event.type === 'request/context') {
|
||||
return event.data.contextWindow === state.contextWindow
|
||||
? state
|
||||
: { ...state, contextWindow: event.data.contextWindow }
|
||||
}
|
||||
const usage = event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
|
||||
? event.data.chunk.usage
|
||||
: event.type === 'assistant/message'
|
||||
? event.data.usage
|
||||
: undefined
|
||||
if (usage === undefined) return state
|
||||
const pressureTokens = pressureFrom(usage)
|
||||
return pressureTokens === state.pressureTokens
|
||||
? state
|
||||
: { ...state, pressureTokens }
|
||||
},
|
||||
view: state => state,
|
||||
stateVersion: 0,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user