diff --git a/docs/module-graph.md b/docs/module-graph.md index 44823bcd00..7352c46435 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -269,7 +269,6 @@ flowchart TD pkg_client_web_react --> pkg_invariants pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants @@ -587,6 +586,8 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_token_meter + pkg_host_apiproxy --> pkg_invariants + pkg_host_apiproxy --> pkg_token_meter pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -991,7 +992,6 @@ flowchart TD | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | @@ -1078,6 +1078,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index a15312c9f9..3e748cc68f 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -35,10 +35,6 @@ function sleep(ms: number, signal: AbortSignal): Promise { }) } -/** 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 { @@ -48,9 +44,6 @@ export interface ConnectionSinks { 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 } /** @@ -65,7 +58,6 @@ export class ConnectionController { private attempt = 0 private current: AbortController | null = null private running = false - private lastState: ConnectionState | null = null private readonly config: Required constructor( @@ -140,7 +132,6 @@ 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. @@ -150,7 +141,6 @@ 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() @@ -158,13 +148,6 @@ 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( stream: AsyncIterable>, sink: ((envelope: RpcRequest) => void) | undefined, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index bf26e96f31..0148f63d54 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1143,7 +1143,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // The fixture's durable transcript is historically zero-based, while // the real Agent's request telemetry opens turns at one. turn: turn + 1, - step: 0, + step: 1, provider: target.provider, model: target.model, // No fixture token-meter is composed, so omit the request-pressure diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 4097a23036..1e86aa183d 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -5,7 +5,7 @@ */ import type { Context } from 'cordis' import type { IApiClient } from './api.ts' -import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' +import { ConnectionController, type ConnectionConfig, type ConnectionSinks } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' @@ -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, ConnectionState } +export type { ConnectionConfig, ConnectionSinks } /** Required services (none — this is the wire root). */ diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 9c54fcbcff..c8ed937b8a 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -8,7 +8,6 @@ import { describe, expect, it, vi } from 'vitest' import type { IApiClient, 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' @@ -204,29 +203,7 @@ describe('connection lifecycle', () => { } }) - 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('reports every failed generation while deduplicating consecutive reconnecting state', async () => { + it('reports every failed generation before retry', async () => { const api = new FakeApiClient() const gate = deferred>>() let describeCalls = 0 @@ -234,14 +211,12 @@ describe('connection lifecycle', () => { describeCalls++ return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise } - const states: ConnectionState[] = [] let disconnected = 0 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 { @@ -249,7 +224,6 @@ describe('connection lifecycle', () => { 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() diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 90d23f98cd..e2765a98a1 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -198,7 +198,7 @@ describe('createFixtureApi', () => { type: 'session/model-request', sessionId: id, turn: 1, - step: 0, + step: 1, provider: 'deepseek', model: 'deepseek-v4-flash', contextWindow: 128_000, diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 9f33a7e7ae..aa62fec118 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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: ba9a7d455e8a193f23884411eb1928a10f21ddd0 -README.zh.md: 873fefca48585efed010589917f2c63653b08e5b +README.md: 03767fb5da46106a756207a0dda6353ae83aebc0 +README.zh.md: fe495bb99c81e80695887e9c71b8ebdfe3f7fe78 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index ba9a7d455e..03767fb5da 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -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; 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`, `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 Host-lifecycle tombstone so a late frame from the independent mux stream cannot repopulate the removed session. Reconnect, restore, and a new subscription therefore show no context percentage until another request is observed. Model selection alone does not alter request telemetry. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 873fefca48..fe495bb99c 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -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`、断开连接和移除会话则会清除常驻值与待处理值;因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。 +客户端 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`、断开连接和移除会话则会清除常驻值与待处理值;移除还会安装 Host 生命周期删除标记,避免独立 mux 流中延迟到达的帧重新填充已移除的会话。因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index d53c9c4e94..171e88692f 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -66,6 +66,12 @@ export class SessionManager { * cannot be backfilled when get() lazily creates the Session. */ private readonly modelRequests = new Map() + /** + * Host-lifecycle tombstones. Host and mux use independent SSE streams, so a + * frame emitted before removal can arrive after host/session-removed. Keep + * the id fenced until a later authoritative host/session-added. + */ + private readonly removedSessions = new Set() /** 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 @@ -348,6 +354,7 @@ export class SessionManager { handleMuxEnvelope(envelope: RpcRequest): void { const frame = envelope.payload if (frame.type === 'stream/error') return // Controller already treats this as stream failure + if (this.removedSessions.has(frame.sessionId)) return if (frame.type === 'session/projection') { // Finished host-computed value: land it in the resident store whether or // not the Session is instantiated (list rows read the 'title' key). The @@ -431,6 +438,7 @@ export class SessionManager { const frame = envelope.payload switch (frame.type) { case 'host/session-added': { + this.removedSessions.delete(frame.sessionId) this.mergeSummary({ sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank, ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), @@ -440,6 +448,7 @@ export class SessionManager { return } case 'host/session-removed': { + this.removedSessions.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 diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 765c3f5fa1..b7fd4c617d 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -504,9 +504,24 @@ export class Session implements SessionFace { /** Connection-loss boundary: clear values that are not replayed before the next stream starts. */ handleReconnecting(): void { this.openGeneration++ - if (this.modelRequest === null) return - this.modelRequest = null - this.notifier.markDirty() + 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: 'internal', + message: 'connection lost while loading session history', + details: { sessionId: this.sessionId }, + } + 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. */ diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index e219a31428..eb57c7e4a2 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -118,7 +118,7 @@ describe('runtime client apply', () => { type: 'session/model-request', sessionId: 's-state', turn: 1, - step: 0, + step: 1, provider: 'test', model: 'alpha', contextTokens: 32_000, @@ -145,7 +145,7 @@ describe('runtime client apply', () => { type: 'session/model-request', sessionId: 's-state', turn: 2, - step: 0, + step: 1, provider: 'test', model: 'beta', contextTokens: 48_000, diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index d5a4237fc1..c26c25b1ce 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -156,6 +156,19 @@ describe('instances', () => { 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: 'request-before-lazy-removal' as never, @@ -173,6 +186,18 @@ describe('instances', () => { 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() }) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 3994a691cb..df697d7c55 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -822,6 +822,33 @@ describe('remaining branches', () => { }) describe('resync', () => { + it('settles an in-flight open when its connection generation dies', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onHistory = () => stale.promise + const opening = session.open() + expect(session.getSnapshot().openState).toBe('loading') + + session.handleReconnecting() + expect(session.getSnapshot()).toMatchObject({ + openState: 'error', + openError: { + code: 'internal', + message: 'connection lost while loading session history', + }, + }) + + stale.reject(new Error('dead generation failed')) + await opening + expect(session.getSnapshot()).toMatchObject({ + openState: 'error', + openError: { + code: 'internal', + message: 'connection lost while loading session history', + }, + }) + }) + it('clears request telemetry on reconnect and drops a stale in-flight history response', async () => { const { api, session } = makeSession() const stale = deferred>>() diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 30188d66fb..dc22a19acb 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -60,9 +60,15 @@ "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:^", diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index e94abf6ca2..b7dd5121e8 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -41,7 +41,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ type: z.literal('session/model-request'), sessionId: sessionIdSchema, turn: z.number().int().positive(), - step: z.number().int().nonnegative(), + step: z.number().int().positive(), provider: z.string().min(1), model: z.string().min(1), contextTokens: z.number().int().nonnegative().optional(), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index af032e9511..12f011d6e3 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -33,6 +33,12 @@ export type ToolEventView = /** 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 diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 9d3e6ef34c..af8791431a 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -13,7 +13,8 @@ import type { GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk, } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -51,7 +52,6 @@ class CatalogAdapter extends LlmAdapter { provider, id: model, name: model, - context: { contextWindow: model === 'private-preview' ? 128_000 : 64_000 }, ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, }) } @@ -70,7 +70,15 @@ const REASONING: LlmModelReasoningInfo = { defaultEffort: ReasoningEffortId('high'), } -async function hostContext(): Promise { +async function harness(logged?: { + provider: string + model: string + reasoningEffort?: ReasoningEffortId +}): Promise<{ + ctx: Context + agent: Agent + sessionId: SessionId +}> { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) @@ -90,19 +98,6 @@ async function hostContext(): Promise { { provider: 'duplicate', id: 'same', name: 'Same' }, { provider: 'duplicate', id: 'same', name: 'Same Again' }, ])) - return ctx -} - -async function harness(logged?: { - provider: string - model: string - reasoningEffort?: ReasoningEffortId -}): Promise<{ - ctx: Context - agent: Agent - sessionId: SessionId -}> { - const ctx = await hostContext() const session = ctx.sessions.create() if (logged !== undefined) { session.append('request/header', { header: { config: logged }, reason: 'initial' }) @@ -129,12 +124,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { - provider: 'deepseek', - model: 'deepseek-chat', - cwd: '/tmp', - workspaceRoot: '/tmp', - }) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -175,12 +165,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { - provider: 'deepseek', - model: 'deepseek-chat', - cwd: '/tmp', - workspaceRoot: '/tmp', - }) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 7046780b48..193f145cda 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -365,7 +365,7 @@ describe('events frame schemas', () => { type: 'session/model-request', sessionId: 's', turn: 2, - step: 0, + step: 1, provider: 'deepseek', model: 'deepseek-chat', contextTokens: 8_000, @@ -392,7 +392,7 @@ describe('events frame schemas', () => { expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow() for (const invalid of [ { type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' }, - { type: 'session/model-request', sessionId: 's', turn: 1, step: -1, provider: 'p', model: 'm' }, + { 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 },