feat(session): persist model selection and share its catalog

This commit is contained in:
imccyu committed 2026-08-25 06:07:42 +08:00
1 parent 69fad4b8db
commit 822d735356
34 files changed
+1170 -516

No files matched your search

+8 -3
View File
@@ -46,9 +46,14 @@ describe('web e2e: the composer model switch is the default for later sessions',
return response.sessionId
}
/** The route the gateway reports for one session, through the real wire face. */
const currentOf = async (sessionId: string): Promise<unknown> => {
return (await scaffold.ctx.sessionController.models({ sessionId: SessionId(sessionId) })).current
/** The route the Client derives from the Session projection and Host default. */
const currentOf = (sessionId: string): Promise<unknown> => {
const session = scaffold.ctx.sessions.get(SessionId(sessionId))
if (session === undefined) throw new Error(`session "${sessionId}" is not live`)
return Promise.resolve(
scaffold.ctx.sessionProjections.snapshot(session).values.modelSelection?.next
?? scaffold.ctx.agentDefaultModel.currentSelection(),
)
}
beforeAll(async () => {
@@ -234,7 +234,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
// trigger — on the page; the scaffold boots without one.
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e')
const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true })
const modelTrigger = page.getByRole('button', { name: /^选择模型/ })
await modelTrigger.waitFor({ timeout: 10_000 })
await modelTrigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
+39 -67
View File
@@ -1,46 +1,19 @@
// Web e2e scenario: startup auto-selection keeps the hero on screen.
//
// A page load with a workspace already registered runs
// `WorkspaceRuntime.startInitialSelection`: it connects the most recent
// workspace and opens its blank session. `openState` flips to `loading` the
// moment `open()` lands; driving `data-phase=settling` on the conversation
// root from that flip would hide the composer seat and the header
// (`visibility:hidden`) for the whole `session.page` round-trip — the
// center column blanks and repaints like a full-page refresh on every launch.
//
// The unit spec pins the phase condition over hand-built stores. What only the
// assembled application can show is that the path a user actually takes
// reaches it: the real selection service, the real client session opening over
// the real /api transport, and a real browser deciding what is painted.
// The initial Workspace pick also records the resident Hero/composer nodes and
// proves that opening the first blank Session fills the strict outlets without
// replacing those nodes.
//
// The round-trip against a loopback host is far too fast to observe, so this
// scenario HOLDS the `session.page` response open in the browser's network
// handler and asserts the visible frame while it is in flight. That wait is
// what makes the assertions non-vacuous: without the phase exemption, the held
// window is exactly when `settling` would be painted and the composer hidden.
//
// Zero model calls: registering a workspace and opening its blank session are
// host RPCs with no model involvement. A stray stream would fail loud with
// NO_ADAPTER.
/** Web acceptance that startup Session opening preserves the resident Hero tree. */
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
/** Wire path of the history round-trip the conversation root waits out. */
const HISTORY_ROUTE = '**/api/session/page'
/**
* The conversation root's own phase attribute. `div` disambiguates it from the
* composer textarea, which carries an unrelated `data-phase` of its own.
*/
const ROOT_PHASE = 'div[data-phase]'
/** Every distinct `data-phase` the conversation root shows, in order, across one page load. */
/** Every distinct conversation-root phase observed during one page load. */
function recordedPhases(page: Page): Promise<string[]> {
return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases)
}
@@ -114,10 +87,8 @@ describe('web e2e: startup auto-selection', () => {
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
it('keeps the hero and composer visible while the opening follow snapshot is pending', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
// Runs before any page script on the reload below, so the first phase the
// root ever renders is recorded, not just the ones after a listener attaches.
await page.addInitScript(() => {
const phases: string[] = []
;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
@@ -128,41 +99,42 @@ describe('web e2e: startup auto-selection', () => {
}, 8)
})
let releaseHistory = (): void => {}
const historyHeld = new Promise<void>((resolve) => { releaseHistory = resolve })
let historyRequested = (): void => {}
const historyInFlight = new Promise<void>((resolve) => { historyRequested = resolve })
let releaseOpening = (): void => {}
const openingHeld = new Promise<void>((resolve) => { releaseOpening = resolve })
let openingRequested = (): void => {}
const openingInFlight = new Promise<void>((resolve) => { openingRequested = resolve })
let gated = false
await page.route(HISTORY_ROUTE, async (route) => {
// Only the auto-selection's own round-trip is held; later pages must not
// deadlock behind a gate this test has already released.
if (gated) { await route.continue(); return }
gated = true
historyRequested()
await historyHeld
await route.continue()
})
const readObservation = scaffold.ctx.sessionQuery.observeSession
.bind(scaffold.ctx.sessionQuery)
const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession')
.mockImplementation(async (sessionId, options) => {
const observation = await readObservation(sessionId, options)
if (gated) return observation
gated = true
openingRequested()
await openingHeld
return observation
})
const warningsBefore = tripwire.warnings.length
await page.reload({ waitUntil: 'commit' })
await historyInFlight
try {
await page.reload({ waitUntil: 'commit' })
await openingInFlight
// The frame a user sees while the session is still opening: hero phase, the
// hero title, and a composer that is actually painted (`settling` hides the
// seat with `visibility:hidden`, which Playwright reports as not visible).
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
expect(await page.locator('textarea').first().isVisible()).toBe(true)
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
expect(await page.locator('textarea').first().isVisible()).toBe(true)
releaseHistory()
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
.waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
// Settling is not merely absent from the frame sampled above: the root
// never entered it at any point of the load.
expect(await recordedPhases(page)).toEqual(['hero'])
expect(tripwire.pageErrors).toEqual([])
releaseOpening()
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
.waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
expect(await recordedPhases(page)).toEqual(['hero'])
expect(tripwire.pageErrors).toEqual([])
} finally {
releaseOpening()
observe.mockRestore()
}
}, 120_000)
})
+1 -1
View File
@@ -49,7 +49,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types'
export type {
ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
CredentialView, DirectoryListing, DiscoveredModelView, IApiClient,
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
SubagentAddress, SubagentCatalog,
@@ -104,7 +104,6 @@
"peerDependenciesMeta": {
"@deepseek-ai/dsh-jobs": { "optional": true },
"@deepseek-ai/dsh-session-persistence": { "optional": true },
"@deepseek-ai/dsh-session-projection": { "optional": true },
"@deepseek-ai/dsh-session-projection-cache": { "optional": true }
},
"devDependencies": {
+158 -45
View File
@@ -7,12 +7,13 @@ import type {
Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-agent-presets'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
import type { SessionError } from './types.ts'
import type { ModelSelection, SessionError } from './types.ts'
/** Cold Session identity absent from persistence. */
export class ApiSessionNotFound extends Error {}
@@ -66,7 +67,10 @@ export type ApiSessionAgentResult =
| { readonly agent: Agent }
| { readonly error: ApiSessionAgentError }
type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection }
type InstalledSelection = ModelSelectionRef & {
current: AgentModelSelection
consume(provider: string, model: string, reasoningEffort: string | undefined): boolean
}
/**
* Test whether generic Session routing must leave an identity to subagent routing.
@@ -112,19 +116,22 @@ export async function inspectApiSession(
sessionId: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
try {
using observation = await ctx.sessionQuery.observeSession(sessionId, {
...(signal === undefined ? {} : { signal }),
projectionMode: 'none',
})
if (observation.header.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
return { meta: observation.header, events: [...observation.events] }
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
throw error
}
const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
if (meta === undefined || meta.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
const inspected = await persistence.inspect(sessionId, signal)
if (inspected.meta.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
return { meta: inspected.meta, events: [...inspected.events] }
}
/** Owns every operation that may create, resume, or configure a Web Agent. */
@@ -159,6 +166,22 @@ export class ApiSessionAgentController {
* @returns the live Agent or a stable Session-domain failure.
*/
async resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
return this.resolve(sessionId)
}
/**
* Resolve one ordinary Session from an already-retained exact observation.
* @param observation - Host-owned observation whose preparation stays pinned through setup.
* @returns the live Agent or a stable Session-domain failure.
*/
async resolveObservedAgent(observation: SessionObservation): Promise<ApiSessionAgentResult> {
return this.resolve(observation.header.id, observation)
}
private async resolve(
sessionId: SessionId,
observation?: SessionObservation,
): Promise<ApiSessionAgentResult> {
const live = this.liveAgent(sessionId)
if (live !== undefined) return live
const attached = this.ctx.sessions.get(sessionId)
@@ -168,7 +191,7 @@ export class ApiSessionAgentController {
let resume = this.resumes.get(sessionId)
if (resume === undefined) {
resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) })
resume = this.resume(sessionId, observation).finally(() => { this.resumes.delete(sessionId) })
this.resumes.set(sessionId, resume)
}
try {
@@ -240,7 +263,9 @@ export class ApiSessionAgentController {
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
if (presetId !== undefined) {
this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session))
}
if (agent.session.header.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
@@ -255,7 +280,13 @@ export class ApiSessionAgentController {
selectionFor(agent: Agent): InstalledSelection {
const installed = this.selections.get(agent)
if (installed !== undefined) return installed
let picked: AgentModelSelection | undefined
const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection')
if (projectionState === undefined) {
throw new Error('api-session: required modelSelection projection is not registered')
}
let picked = projectionState.pending === null
? undefined
: agentModelSelection(projectionState.pending)
const defaultModel = this.ctx.agentDefaultModel
const selection: InstalledSelection = {
get current(): AgentModelSelection {
@@ -271,6 +302,13 @@ export class ApiSessionAgentController {
set current(next: AgentModelSelection) {
picked = next
},
consume(provider: string, model: string, reasoningEffort: string | undefined): boolean {
if (picked?.provider !== provider
|| picked.model !== model
|| picked.reasoningEffort !== reasoningEffort) return false
picked = undefined
return true
},
assembled: undefined,
}
installModelSelection(agent.ctx, selection)
@@ -278,6 +316,42 @@ export class ApiSessionAgentController {
return selection
}
/**
* Commit and cache one validated selection for the next prompt assembly.
* @param agent - live Agent that owns the selection.
* @param selection - validated selection to record and apply.
*/
selectForNextRequest(agent: Agent, selection: AgentModelSelection): void {
agent.session.append('model/selection', selection)
this.selectionFor(agent).current = selection
}
/**
* Let a matching durable request header retire the execution cache.
* @param agent - live Agent whose request was recorded.
* @param provider - provider route used by the request.
* @param model - provider-owned model used by the request.
* @param reasoningEffort - adapter-owned effort used by the request.
* @returns whether the pending selection was consumed.
*/
consumeSelection(
agent: Agent,
provider: string,
model: string,
reasoningEffort: string | undefined,
): boolean {
return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false
}
/**
* Read the current Agent preset from the Session projection.
* @param session - live Session whose projection state is available.
* @returns the current preset, or undefined when the capability is absent.
*/
presetForSession(session: Session): string | undefined {
return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined
}
/**
* Serialize image admission and model selection for one Agent.
* @param agent - live Agent that owns the serialization chain.
@@ -319,15 +393,31 @@ export class ApiSessionAgentController {
: { agent }
}
private async resume(sessionId: SessionId): Promise<Agent> {
const inspected = await inspectApiSession(this.ctx, sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise<Agent> {
if (supplied !== undefined) return this.resumeObserved(sessionId, supplied)
try {
using observation = await this.ctx.sessionQuery.observeSession(sessionId)
return await this.resumeObserved(sessionId, observation)
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
throw error
}
}
private async resumeObserved(
sessionId: SessionId,
observation: SessionObservation,
): Promise<Agent> {
if (observation.header.id !== sessionId || observation.header.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
const composition = await this.composeAgent(resolveSessionPreset({
header: inspected.meta,
events: inspected.events,
}))
const composition = await this.composeAgent(this.presetForObservation(observation))
const published = this.ctx.sessions.get(sessionId)
const live = this.ctx.agents.get(sessionId)
if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) {
@@ -353,26 +443,27 @@ export class ApiSessionAgentController {
}
if (live !== undefined) return live
const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined
const stored = persistence === undefined
? undefined
: (await persistence.list()).find(header => header.id === sessionId)
if (persistence !== undefined && stored !== undefined) {
const inspected = await persistence.inspect(sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
if (checkPersistedIdentity) {
try {
using observation = await this.ctx.sessionQuery.observeSession(sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (observation.header.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd)
}
const storedPreset = this.presetForObservation(observation)
this.assertPresetUnchanged(sessionId, presetId, storedPreset)
const composition = await this.composeAgent(storedPreset)
return (await this.ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: this.agentOptions(),
setup: composition.setup,
})).agent
} catch (error: unknown) {
if (!(error instanceof SessionQueryError)
|| error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
}
if (inspected.meta.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
}
const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
this.assertPresetUnchanged(sessionId, presetId, storedPreset)
const composition = await this.composeAgent(storedPreset)
return (await this.ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: this.agentOptions(),
setup: composition.setup,
})).agent
}
try {
@@ -403,6 +494,18 @@ export class ApiSessionAgentController {
this.selectionFor(agent)
}
/**
* Read the current Agent preset from an all-projections observation.
* @param observation - exact Session observation carrying its projection snapshot.
* @returns the current preset, or undefined when the capability is absent.
*/
presetForObservation(observation: SessionObservation): string | undefined {
if (observation.projections === undefined) {
throw new Error('api-session: Agent activation requires a projected Session observation')
}
return observation.projections.values.agentPreset ?? undefined
}
private assertPresetUnchanged(
sessionId: SessionId,
requested: string | undefined,
@@ -412,3 +515,13 @@ export class ApiSessionAgentController {
throw new ApiSessionPresetConflict(sessionId, requested, existing)
}
}
function agentModelSelection(selection: ModelSelection): AgentModelSelection {
return {
provider: selection.provider,
model: selection.model,
...(selection.reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }),
}
}
+11 -7
View File
@@ -2,21 +2,23 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ModelCatalogFailure,
ModelProviderGroup,
ModelCatalog,
ModelReasoning,
ModelSelection,
} from './types.ts'
/**
* Build the browser model catalog without requiring a Session.
* @param ctx - Host context carrying the live LLM registry.
* @param defaultSelection - deployment default used before a Session selects a model.
* @returns successful non-empty provider groups and isolated provider failures.
*/
export async function buildModelCatalog(ctx: Context): Promise<{
readonly groups: ModelProviderGroup[]
readonly failures: ModelCatalogFailure[]
}> {
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
export async function buildModelCatalog(
ctx: Context,
defaultSelection: ModelSelection = ctx.agentDefaultModel.currentSelection(),
): Promise<ModelCatalog> {
const providers = ctx.llm.listProviders()
const catalog = await Promise.all(providers.map(async (provider) => {
try {
const models = await ctx.llm.listModels(provider.id)
const entries = await Promise.all(models.map(async (model) => {
@@ -56,6 +58,8 @@ export async function buildModelCatalog(ctx: Context): Promise<{
}
}))
return {
default: { ...defaultSelection },
routableProviders: providers.map(provider => provider.id),
groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
.filter(group => group.models.length > 0),
failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
+18 -35
View File
@@ -3,9 +3,7 @@
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import {
PresetMountError, UnknownPresetError, resolveSessionPreset,
} from '@deepseek-ai/dsh-agent-presets'
import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import {
@@ -13,7 +11,8 @@ import {
} from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
@@ -27,7 +26,6 @@ import {
hasApiSessionSubagentOwner,
inspectApiSession,
} from './agent.ts'
import { buildModelCatalog } from './catalog.ts'
import type {
SessionAttachmentRequest,
SessionAttachmentValue,
@@ -37,8 +35,6 @@ import type {
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionModels,
SessionModelsRequest,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
@@ -110,27 +106,10 @@ export class SessionCommandController {
)
}
}
const agentPreset = resolveSessionPreset(adopted.session)
const agentPreset = this.agents.presetForSession(adopted.session)
return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }
}
/**
* Read the current selection and advisory model catalog, explicitly resuming the Session.
* @param request - Session whose model state is requested.
* @returns the current selection and available model groups.
*/
async models(request: SessionModelsRequest): Promise<SessionModels> {
const agent = await this.resolveAgent(request.sessionId)
const current = this.agents.selectionFor(agent).current
const { groups, failures } = await buildModelCatalog(this.ctx)
return {
current: { ...current },
routable: routeServed(this.ctx, current.provider),
groups,
failures,
}
}
/**
* Validate and install one Session-local model selection.
* @param request - Session identity and requested model selection.
@@ -154,7 +133,7 @@ export class SessionCommandController {
? {}
: { reasoningEffort: resolved.reasoningEffort }),
}
this.agents.selectionFor(agent).current = selected
this.agents.selectForNextRequest(agent, selected)
try {
await this.ctx.agentDefaultModel.saveSelection(selected)
} catch (error) {
@@ -210,12 +189,15 @@ export class SessionCommandController {
&& (!Number.isInteger(request.atSeq) || request.atSeq < 0)) {
reject('bad-request', 'atSeq must be a non-negative integer', {})
}
let source: SessionReadState
let observed: SessionObservation
try {
source = await this.readSessionState(request.sessionId)
observed = await this.ctx.sessionQuery.observeSession(request.sessionId)
} catch (error) {
if (error instanceof ApiSessionNotFound) {
reject('session-not-found', error.message, { sessionId: request.sessionId })
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
reject('session-not-found', `session "${request.sessionId}" not found`, {
sessionId: request.sessionId,
})
}
reject(
'internal',
@@ -223,6 +205,7 @@ export class SessionCommandController {
{},
)
}
using source = observed
const lastSeq = source.events.at(-1)?.seq ?? -1
const atSeq = request.atSeq
const anchoredBoundary = atSeq === undefined
@@ -245,7 +228,7 @@ export class SessionCommandController {
while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++
let workspace: Workspace | undefined
try {
workspace = await this.forkWorkspace(source)
workspace = await this.forkWorkspace(source.header)
} catch (error) {
reject(
'internal',
@@ -254,7 +237,7 @@ export class SessionCommandController {
)
}
const childId = SessionId(`session-${randomUUID()}`)
const composition = await this.agents.composeAgent(resolveSessionPreset(source))
const composition = await this.agents.composeAgent(this.agents.presetForObservation(source))
try {
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
await this.ctx.agents.create({
@@ -262,7 +245,7 @@ export class SessionCommandController {
seed: source.events.slice(0, cut),
meta: {
...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
parentSession: source.id,
parentSession: source.header.id,
seedLength: cut,
...(composition.agentPreset === undefined
? {}
@@ -507,10 +490,10 @@ export class SessionCommandController {
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
}
private async forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
private async forkWorkspace(source: SessionHeader): Promise<Workspace | undefined> {
const workspaces = this.ctx.workspaceRegistry.list()
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
if (direct !== undefined || source.header.origin !== 'subagent') return direct
if (direct !== undefined || source.origin !== 'subagent') return direct
const lineage = await this.ctx.sessionQuery.traceSession(source.id)
for (const ancestor of lineage.ancestors) {
const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
@@ -0,0 +1,83 @@
/** Durable model-selection intent and request-use projection. */
import type { Context } from '@deepseek-ai/cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { z } from 'zod'
import type {
ModelSelection,
ModelSelectionProjection,
ModelSelectionProjectionState,
} from './types.ts'
const modelSelectionSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) as unknown as z.ZodType<ModelSelection>
const modelSelectionProjectionStateSchema = z.object({
lastUsed: modelSelectionSchema.nullable(),
pending: modelSelectionSchema.nullable(),
}) as unknown as z.ZodType<ModelSelectionProjectionState>
const modelSelectionProjectionSchema = z.object({
lastUsed: modelSelectionSchema.nullable(),
next: modelSelectionSchema.nullable(),
}) as unknown as z.ZodType<ModelSelectionProjection>
/**
* Advance durable model-selection state by one Session event.
* @param state - selection state before the event.
* @param event - next committed Session event.
* @returns the original or advanced selection state.
*/
function applyModelSelectionProjection(
state: ModelSelectionProjectionState,
event: SessionEvent,
): ModelSelectionProjectionState {
if (event.type === 'model/selection') {
return sameSelection(state.pending, event.data)
? state
: { lastUsed: state.lastUsed, pending: event.data }
}
if (event.type !== 'request/header') return state
const lastUsed: ModelSelection = {
provider: event.data.header.config.provider,
model: event.data.header.config.model,
...(event.data.header.config.reasoningEffort === undefined
? {}
: { reasoningEffort: String(event.data.header.config.reasoningEffort) }),
}
const pending = sameSelection(state.pending, lastUsed) ? null : state.pending
return sameSelection(state.lastUsed, lastUsed) && pending === state.pending
? state
: { lastUsed, pending }
}
const modelSelectionProjection = {
key: 'modelSelection',
stateSchema: modelSelectionProjectionStateSchema,
init: () => ({ lastUsed: null, pending: null }),
apply: applyModelSelectionProjection,
wire: {
viewSchema: modelSelectionProjectionSchema,
view: state => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }),
},
stateVersion: 2,
} satisfies ProjectionDefinition<'modelSelection', ModelSelectionProjectionState>
function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean {
return left === right || (left !== null && right !== null
&& left.provider === right.provider
&& left.model === right.model
&& left.reasoningEffort === right.reasoningEffort)
}
/**
* Register the durable model-selection projection when the registry is present.
* @param ctx - Session Controller context.
*/
export function installModelSelectionProjection(ctx: Context): void {
ctx.sessionProjections.register(modelSelectionProjection)
}
@@ -4,8 +4,10 @@ import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -16,6 +18,8 @@ import {
ApiSessionSubagentOwnership,
inspectApiSession,
} from '../src/agent.ts'
import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const roots: Context[] = []
@@ -29,6 +33,9 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(agentPresetProjectionDefinition)
installModelSelectionProjection(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
@@ -45,6 +52,10 @@ function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
}
}
function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
}
function agent(ctx: Context, meta: SessionHeader): Agent {
const session = ctx.sessions.create(meta.id, { meta })
return { id: meta.id, session, status: 'idle', ctx } as Agent
@@ -67,48 +78,94 @@ describe('ApiSession identity failures', () => {
.toContain('belongs to "/existing"')
})
it('rejects absent persistence, catalog misses, and cwd-less inspected artifacts', async () => {
it('maps absent and cwd-less point observations to not found', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
await expect(inspectApiSession(ctx, SessionId('missing')))
.rejects.toThrow('session persistence is not configured')
.rejects.toBeInstanceOf(ApiSessionNotFound)
const inspect = vi.fn(() => Promise.resolve({ meta: header('missing'), events: [] as SessionEvent[] }))
const disposeMissing = ctx.provide('sessionPersistence', {
const inspect = vi.fn(() => Promise.resolve(undefined))
const disposeMissing = providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect,
} as never)
})
await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
expect(inspect).not.toHaveBeenCalled()
expect(inspect).toHaveBeenCalledOnce()
disposeMissing()
const listed = header('cwd-less-catalog', null)
const disposeListed = ctx.provide('sessionPersistence', {
const disposeListed = providePersistence(ctx, {
list: () => Promise.resolve([listed]),
inspect,
} as never)
inspect: () => Promise.resolve({ meta: listed, events: [] }),
})
await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
disposeListed()
const catalog = header('cwd-less-inspect')
const inspected = header('cwd-less-inspect', null)
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([catalog]),
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
} as never)
})
await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
})
it('forwards an explicit inspection signal', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
const meta = header('signalled-inspection')
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] }))
providePersistence(ctx, { inspect })
const signal = new AbortController().signal
await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, events: [] })
expect(inspect).toHaveBeenCalledWith(meta.id, signal)
})
})
describe('ApiSession Agent lookup and recovery', () => {
it('resumes directly from a retained observation and rejects an invalid observed header', async () => {
const { ctx, agents } = await harness()
const meta = header('observed-resume')
const resumed = unpublishedAgent(ctx, meta)
const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
agent: resumed,
dispose: () => Promise.resolve(),
})
const observed = {
source: 'prepared',
header: meta,
events: [],
cursor: -1,
projections: { asOfSeq: -1, values: {} },
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation
await expect(agents.resolveObservedAgent(observed)).resolves.toEqual({ agent: resumed })
expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
const invalid = {
...observed,
header: header('observed-without-cwd', null),
} as SessionObservation
await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({
error: { code: 'session-not-found' },
})
})
it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => {
const { ctx } = await harness()
const live = agent(ctx, header('live'))
ctx.agents.register(live)
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
})
const host = ctx.typert.contexts.getHost('agent')
if (host === undefined) throw new Error('Agent Context resolver was not registered')
@@ -119,10 +176,10 @@ describe('ApiSession Agent lookup and recovery', () => {
it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
const ordinary = await harness()
const ordinaryMeta = header('ordinary-race')
ordinary.ctx.provide('sessionPersistence', {
providePersistence(ordinary.ctx, {
list: () => Promise.resolve([ordinaryMeta]),
inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }),
} as never)
})
const winner = agent(ordinary.ctx, ordinaryMeta)
vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
ordinary.ctx.agents.register(winner)
@@ -132,10 +189,10 @@ describe('ApiSession Agent lookup and recovery', () => {
const child = await harness()
const childMeta = header('child-race')
child.ctx.provide('sessionPersistence', {
providePersistence(child.ctx, {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
} as never)
})
vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => {
child.ctx.sessions.create(childMeta.id, {
meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
@@ -149,25 +206,84 @@ describe('ApiSession Agent lookup and recovery', () => {
it('reports not-found and ordinary resume failures without fabricating an Agent', async () => {
const missing = await harness()
missing.ctx.provide('sessionPersistence', {
providePersistence(missing.ctx, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
})
await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
error: { code: 'session-not-found' },
})
const failed = await harness()
const meta = header('failed')
failed.ctx.provide('sessionPersistence', {
providePersistence(failed.ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] }),
} as never)
})
vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string },
})
})
it('requires projected observations before activation', async () => {
const { agents } = await harness()
const meta = header('unprojected-observation')
const observed = {
source: 'prepared',
header: meta,
events: [],
cursor: -1,
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation
expect(() => agents.presetForObservation(observed)).toThrow(
'Agent activation requires a projected Session observation',
)
})
})
describe('ApiSession model selection', () => {
it('requires the model-selection projection', async () => {
const { ctx, agents } = await harness()
const live = agent(ctx, header('missing-model-projection'))
vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection')
})
it('reads a reasoning-free request and consumes only the exact pending selection', async () => {
const { ctx, agents } = await harness()
const logged = agent(ctx, header('logged-model'))
logged.session.append('request/header', {
header: { config: { provider: 'logged-provider', model: 'logged-model' } },
reason: 'initial',
})
expect(agents.selectionFor(logged).current).toEqual({
provider: 'logged-provider',
model: 'logged-model',
})
const pending = agent(ctx, header('pending-model'))
const selection = agents.selectionFor(pending)
agents.selectForNextRequest(pending, {
provider: 'selected-provider',
model: 'selected-model',
reasoningEffort: 'high' as never,
})
expect(selection.current).toMatchObject({
provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high',
})
expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true)
expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' })
const untouched = agent(ctx, header('uninstalled-model'))
expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false)
})
})
describe('ApiSession create or adoption', () => {
@@ -252,10 +368,10 @@ describe('ApiSession create or adoption', () => {
time: 1,
data: { agentPreset: 'minimal' },
}] as SessionEvent[]
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
})
ctx.provide('agentPresets', {
resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }),
mount: () => Promise.resolve(),
@@ -278,10 +394,10 @@ describe('ApiSession create or adoption', () => {
it('rejects an ownership race before resume and a persisted cwd conflict', async () => {
const child = await harness()
const childMeta = header('resume-child-race')
child.ctx.provide('sessionPersistence', {
providePersistence(child.ctx, {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
} as never)
})
child.ctx.provide('agentPresets', {
resolve: () => {
child.ctx.sessions.create(childMeta.id, {
@@ -297,10 +413,10 @@ describe('ApiSession create or adoption', () => {
const conflict = await harness()
const stored = header('stored-cwd-conflict', '/stored')
conflict.ctx.provide('sessionPersistence', {
providePersistence(conflict.ctx, {
list: () => Promise.resolve([stored]),
inspect: () => Promise.resolve({ meta: stored, events: [] }),
} as never)
})
await expect(conflict.agents.ensureSession(stored.id, '/requested', true))
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
})
@@ -11,6 +11,7 @@ import {
ApiSessionCwdConflict,
} from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
await expect(operation).rejects.toMatchObject({ failure: { code } })
@@ -20,6 +21,8 @@ function controllerAgents(overrides: object = {}): ApiSessionAgentController {
return {
ensureSession: () => Promise.resolve(),
composeAgent: () => Promise.resolve({ setup: () => {} }),
presetForSession: () => undefined,
presetForObservation: () => undefined,
...overrides,
} as unknown as ApiSessionAgentController
}
@@ -28,6 +31,7 @@ async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
@@ -167,23 +171,23 @@ function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle {
}
describe('Session fork failures', () => {
it('distinguishes missing cold sources from unavailable persistence', async () => {
const unavailable = await baseContext()
unavailable.provide('workspaceRegistry', { list: () => [] } as never)
it('maps missing cold sources with and without persistence', async () => {
const withoutPersistence = await baseContext()
withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never)
const unavailableController = new SessionCommandController(
unavailable, controllerAgents(), '/default',
withoutPersistence, controllerAgents(), '/default',
)
await expectFailure(unavailableController.fork({
sessionId: SessionId('missing'),
}), 'internal')
await unavailable.fiber.dispose()
}), 'session-not-found')
await withoutPersistence.fiber.dispose()
const missing = await baseContext()
missing.provide('workspaceRegistry', { list: () => [] } as never)
missing.provide('sessionPersistence', {
missing.provide('sessionPersistence', testSessionPersistence(missing, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
}) as never)
const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
await expectFailure(missingController.fork({
sessionId: SessionId('missing'),
@@ -191,6 +195,16 @@ describe('Session fork failures', () => {
await missing.fiber.dispose()
})
it('maps an observation failure to an internal fork error', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal')
await ctx.fiber.dispose()
})
it('rejects a Session with no completed turn', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
@@ -204,9 +218,8 @@ describe('Session fork failures', () => {
it('maps lineage lookup and Agent creation failures', async () => {
const lineage = await baseContext()
lineage.provide('workspaceRegistry', { list: () => [] } as never)
lineage.provide('sessionQuery', {
traceSession: () => Promise.reject(new Error('lineage unavailable')),
} as never)
vi.spyOn(lineage.sessionQuery, 'traceSession')
.mockRejectedValue(new Error('lineage unavailable'))
const child = completedSession(lineage, 'subagent-source', '/workspace', {
parentSession: SessionId('parent'),
origin: 'subagent',
@@ -3,12 +3,13 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest'
import { ApiSessionAgentController } from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
async function commandHarness(): Promise<{
ctx: Context
@@ -142,10 +143,11 @@ async function persistedController(
await ctx.plugin(SessionStore)
const sessionId = SessionId('cold-attachment')
const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
}) as never)
installSessionReadTestServices(ctx)
ctx.provide('attachments', { readImage } as never)
const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
@@ -158,15 +160,31 @@ describe('Session attachment authorization', () => {
const inserted = imageRef('inserted')
const streamed = imageRef('streamed')
const events = [
event('fixture/direct', 0, {
{ ...event('fixture/direct', 0, {
content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
type: 'tool-result', content: [{ type: 'image', attachment: nested }],
}],
}), ignorable: true as const },
{ ...event('assistant/message', 1, {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'image', attachment: message }],
source: { provider: 'fixture', model: 'fixture' },
}),
}), surfaceOp: 'append' as const },
event('agent/inbox/spliced', 2, {
target: 'next-turn',
start: 0,
inserted: [createUserMessage({
content: [{ type: 'image', attachment: inserted }],
source: { kind: 'user' },
})],
}),
event('assistant/message', 1, { message: { content: [{ type: 'image', attachment: message }] } }),
event('agent/inbox/spliced', 2, { inserted: [{ content: [{ type: 'image', attachment: inserted }] }] }),
event('assistant/chunk', 3, {
chunk: { type: 'block-end', block: { type: 'image', attachment: streamed } },
turn: 1,
step: 1,
chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
}),
]
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
@@ -183,6 +201,7 @@ describe('Session attachment authorization', () => {
it('maps missing persistence identities and attachment backend failures', async () => {
const noPersistence = new Context()
await noPersistence.plugin(SessionStore)
installSessionReadTestServices(noPersistence)
const noPersistenceController = new SessionCommandController(
noPersistence,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
@@ -190,14 +209,15 @@ describe('Session attachment authorization', () => {
)
await expectFailure(noPersistenceController.attachment({
sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
}), 'internal')
}), 'session-not-found')
const missing = new Context()
await missing.plugin(SessionStore)
missing.provide('sessionPersistence', {
missing.provide('sessionPersistence', testSessionPersistence(missing, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
}) as never)
installSessionReadTestServices(missing)
const missingController = new SessionCommandController(
missing,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
@@ -223,4 +243,21 @@ describe('Session attachment authorization', () => {
await fixture.ctx.fiber.dispose()
}
})
it('maps a cold observation failure to an internal authorization error', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
const controller = new SessionCommandController(
ctx,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
'/workspace',
)
await expectFailure(controller.attachment({
sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
}), 'internal')
await ctx.fiber.dispose()
})
})
@@ -10,7 +10,9 @@ import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import { createSessionTestRemote } from './test-remote.ts'
import {
createSessionTestRemote, installSessionReadTestServices, testSessionPersistence,
} from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -23,6 +25,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('workspaceRegistry', { list: () => workspaces } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
@@ -116,18 +119,16 @@ describe('sessions.fork', () => {
parentSession: child.id,
origin: 'subagent',
})
ctx.provide('sessionQuery', {
traceSession: vi.fn(() => Promise.resolve({
target: { header: grandchild.header, live: true, persisted: false },
ancestors: [
{ header: child.header, live: true, persisted: false },
{ header: owner.header, live: true, persisted: false },
],
descendants: [],
complete: true,
root: { header: owner.header, live: true, persisted: false },
})),
} as never)
vi.spyOn(ctx.sessionQuery, 'traceSession').mockResolvedValue({
target: { header: grandchild.header, live: true, persisted: false },
ancestors: [
{ header: child.header, live: true, persisted: false },
{ header: owner.header, live: true, persisted: false },
],
descendants: [],
complete: true,
root: { header: owner.header, live: true, persisted: false },
})
const response = await remote(ctx).fork(request({ sessionId: grandchild.id }))
@@ -165,19 +166,10 @@ describe('sessions.fork', () => {
},
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
ctx.provide('sessionQuery', {
traceSession: () => Promise.resolve({
target: { header, live: false, persisted: true },
ancestors: [],
descendants: [],
complete: true,
root: { header, live: false, persisted: true },
}),
} as never)
}) as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const response = await remote(ctx).fork(request({ sessionId: sourceId }))
@@ -19,6 +19,7 @@ import type {
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import { buildModelCatalog } from '../src/catalog.ts'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { createSessionTestRemote } from './test-remote.ts'
@@ -146,6 +147,14 @@ function registerTextOnly(ctx: Context): void {
}('Text Only', []))
}
/** Resolve the Client-visible next selection from durable state and the Host default. */
function currentSelection(ctx: Context, sessionId: SessionId) {
const session = ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('expected a live test Session')
return ctx.sessionProjections.snapshot(session).values.modelSelection?.next
?? ctx.agentDefaultModel.currentSelection()
}
describe('Web session model selection', () => {
it('validates an ordered image batch before persisting any member', async () => {
const { ctx, agent, sessionId } = await harness()
@@ -227,7 +236,7 @@ describe('Web session model selection', () => {
type: 'image' as const,
attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
}
agent.session.append('user/message', {
const imageEvent = agent.session.append('user/message', {
id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
} as never, { surfaceOp: 'append' })
expect(expectValue(await remote.selectModel(request({
@@ -238,8 +247,8 @@ describe('Web session model selection', () => {
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
content: [{ type: 'text', text: 'image summarized' }],
} as never, {
surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
sourceEventSeqs: agent.session.events.map(event => event.seq),
surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq },
sourceEventSeqs: [imageEvent.seq],
})
;(agent.inbox.nextTurn as UserMessage[]).push({
id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
@@ -290,10 +299,10 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const catalog = expectValue(await remote.models(request({ sessionId })))
expect(catalog.current).toEqual({
const catalog = await buildModelCatalog(ctx)
expect(currentSelection(ctx, sessionId)).toEqual({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: 'max',
@@ -324,7 +333,7 @@ describe('Web session model selection', () => {
})
it('preserves optional catalog metadata and string provider failures', async () => {
const { ctx, sessionId } = await harness()
const { ctx } = await harness()
ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [
{ provider: 'plain', id: 'plain-model', name: 'Plain Model' },
]))
@@ -339,12 +348,12 @@ describe('Web session model selection', () => {
return Promise.reject('string catalog failure')
}
}('String Failure', []))
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const catalog = expectValue(await remote.models(request({ sessionId })))
const catalog = await buildModelCatalog(ctx)
expect(catalog.groups).toEqual(expect.arrayContaining([
{ id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] },
{
@@ -371,10 +380,8 @@ describe('Web session model selection', () => {
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
const selected = expectValue(await remote.selectModel(request({
sessionId,
@@ -389,7 +396,7 @@ describe('Web session model selection', () => {
})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
)).resolves.toEqual(seed)
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
@@ -440,7 +447,7 @@ describe('Web session model selection', () => {
details: { provider: 'remote-rejected' },
},
})
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
await ctx.fiber.dispose()
})
@@ -448,18 +455,18 @@ describe('Web session model selection', () => {
it('reads the Agent default live for a session whose log names no selection', async () => {
const { ctx, sessionId } = await harness()
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
// The default moving after the session exists still reaches it: New
// Session reuses a blank session rather than minting another, so a seed
// captured at creation would show the superseded model there.
stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await ctx.fiber.dispose()
})
@@ -470,13 +477,13 @@ describe('Web session model selection', () => {
model: 'deepseek-chat',
})
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
stored = { provider: 'duplicate', model: 'same' }
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
@@ -512,7 +519,7 @@ describe('Web session model selection', () => {
sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
})))
expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
await ctx.fiber.dispose()
})
@@ -533,15 +540,16 @@ describe('Web session model selection', () => {
ok: false,
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
})
expect(expectValue(await remote.models(request({ sessionId }))).routable).toBe(false)
const unavailableCatalog = await buildModelCatalog(ctx)
expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false)
// An advisory-unlisted model on a live route is NOT this: the route
// serves it, so the prompt goes through and nothing blocks.
expectValue(await remote.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
})))
const catalog = expectValue(await remote.models(request({ sessionId })))
expect(catalog.routable).toBe(true)
const catalog = await buildModelCatalog(ctx)
expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true)
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
.not.toContain('unlisted-but-served')
await ctx.fiber.dispose()
@@ -549,18 +557,18 @@ describe('Web session model selection', () => {
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
const { ctx, sessionId } = await harness()
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
// What a Models-page removal leaves behind: the settings document still
// names the route the user last picked, and nothing serves it.
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
})
const catalog = expectValue(await remote.models(request({ sessionId })))
const catalog = await buildModelCatalog(ctx)
// Passed through rather than repaired: matching no group is precisely what
// makes the composer seat prompt for a selection instead of naming a model
// the deployment cannot reach.
expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
.not.toContain('deleted-gateway/deleted-model')
await ctx.fiber.dispose()
@@ -6,7 +6,7 @@ import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { describe, expect, it } from 'vitest'
@@ -38,8 +38,9 @@ async function harness(presets?: readonly string[]) {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
if (presets !== undefined) {
ctx.provide('agentPresets', roster(presets) as never)
}
const factory: AgentFactory = {
async createAgent(_ownerCtx, options) {
@@ -63,6 +64,7 @@ async function harness(presets?: readonly string[]) {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd,
})
if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition)
return { ctx, remote }
}
@@ -15,7 +15,8 @@
"src/commands.ts",
"src/control.ts",
"src/history.ts",
"src/list.ts"
"src/list.ts",
"src/model-selection-projection.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
+1 -1
View File
@@ -10,7 +10,7 @@ export type {
DirectoryEntry, DirectoryListing,
ResponseValue,
SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
@@ -19,6 +19,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
import type {
JsonValue,
SessionEvent,
SessionHeader,
SessionId,
} from '@deepseek-ai/dsh-session/types'
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
@@ -86,7 +87,7 @@ type FixtureSessionAddress =
interface FixtureFollowRequest {
readonly address: FixtureSessionAddress
readonly afterSeq?: number
readonly maxMessages?: number
}
interface FixturePageRequest {
@@ -97,7 +98,14 @@ interface FixturePageRequest {
}
type FixtureFollowFrame =
| { readonly type: 'opened'; readonly cursor: number }
| {
readonly type: 'snapshot'
readonly header: SessionHeader
readonly cursor: number
readonly events: readonly FixtureHistoryEntry[]
readonly hasMore: boolean
readonly projections: FixtureProjectionsBlock
}
| ({ readonly type: 'event' } & FixtureHistoryEntry)
type FixtureFollowEventFrame = Extract<FixtureFollowFrame, { type: 'event' }>
@@ -207,7 +215,6 @@ interface FixtureSessionApi {
readonly beforeSeq?: number
readonly maxMessages?: number
}): Promise<ConnectionRpcResult<unknown>>
models(request: { readonly sessionId: SessionId }): Promise<ConnectionRpcResult<unknown>>
selectModel(request: {
readonly sessionId: SessionId
readonly provider: string
@@ -522,7 +529,7 @@ const OPENAI_REASONING = {
defaultEffort: 'medium',
}
/** Catalog served by `session.models` and `llm.models` alike (fresh copies per call). */
/** Catalog served by `llm.models` (fresh copies per call). */
function fixtureModelGroups(): ModelProviderGroup[] {
return [
{
@@ -1206,6 +1213,7 @@ function contextPressureOf(
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
const values: Record<string, unknown> = {}
values['modelSelection'] = modelSelectionProjectionOf(log)
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
if (titleEvent !== undefined) {
values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
@@ -1242,6 +1250,37 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
return values
}
function modelSelectionProjectionOf(log: readonly SessionEvent[]): {
lastUsed: ModelSelection | null
next: ModelSelection | null
} {
let lastUsed: ModelSelection | null = null
let pending: ModelSelection | null = null
for (const event of log) {
if ((event as { type: string }).type === 'model/selection') {
pending = (event as unknown as { data: ModelSelection }).data
continue
}
if (event.type !== 'request/header') continue
lastUsed = {
provider: event.data.header.config.provider,
model: event.data.header.config.model,
...(event.data.header.config.reasoningEffort === undefined
? {}
: { reasoningEffort: event.data.header.config.reasoningEffort }),
}
if (sameModelSelection(pending, lastUsed)) pending = null
}
return { lastUsed, next: pending ?? lastUsed }
}
function sameModelSelection(left: ModelSelection | null, right: ModelSelection | null): boolean {
return left === right || (left !== null && right !== null
&& left.provider === right.provider
&& left.model === right.model
&& left.reasoningEffort === right.reasoningEffort)
}
/** Host parallel: emit one Session control projection frame per key advanced by the event. */
function projectionFramesOf(
id: SessionId,
@@ -1250,6 +1289,15 @@ function projectionFramesOf(
): FixtureProjectionFrame[] {
const type = (event as { type: string }).type
const frames: FixtureProjectionFrame[] = []
if (type === 'model/selection' || type === 'request/header') {
frames.push({
type: 'projection',
sessionId: id,
key: 'modelSelection',
value: modelSelectionProjectionOf(log),
seq: event.seq,
})
}
// One usage sample advances both token-meter units.
if (usageSampleOf(event) !== undefined) {
frames.push(
@@ -2623,28 +2671,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const boundedLog = log.slice(0, throughSeq + 1)
// Snapshot at request time, then deliver after the transit delay.
const page = pageOf(boundedLog, request.beforeSeq, request.maxMessages ?? 50)
// Tail page carries the projections block (host parallel: one consistent
// cut over the registered units; asOfSeq = window tail seq, -1 on an
// empty log — the host's session.seq-1 convention).
const projections = request.beforeSeq === undefined
? { asOfSeq: throughSeq, values: projectionValuesOf(boundedLog) }
: undefined
const doomed = failNextHistory
failNextHistory = false
const delay = historyDelayMs
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
if (doomed) throw new Error('fixture: simulated history transport failure')
return sessionOk({ ...page, ...projections === undefined ? {} : { projections } })
return sessionOk(page)
},
models: request => sessionOk({
current: modelSelections.get(request.sessionId)
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
// The fixture's routes all serve; a surface exercising the blocked
// posture drives it through its own stub.
routable: true,
groups: fixtureModelGroups(),
failures: [],
}),
selectModel: (request) => {
const selected: ModelSelection = {
provider: request.provider,
@@ -2653,6 +2686,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
? {}
: { reasoningEffort: request.reasoningEffort },
}
append(request.sessionId, { type: 'model/selection', data: selected })
modelSelections.set(request.sessionId, selected)
return sessionOk({ selected })
},
@@ -2717,6 +2751,25 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// log-only, appended inside the open turn, and deduplicated against the
// route already recorded (the fixture never varies contextWindow).
const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
const previousHeader = logOf(id).findLast(event => event.type === 'request/header')
const previousSelection = previousHeader?.type === 'request/header'
? {
provider: previousHeader.data.header.config.provider,
model: previousHeader.data.header.config.model,
...(previousHeader.data.header.config.reasoningEffort === undefined
? {}
: { reasoningEffort: previousHeader.data.header.config.reasoningEffort }),
}
: null
if (!sameModelSelection(previousSelection, selection)) {
append(id, {
type: 'request/header',
data: {
header: { config: selection },
reason: previousHeader === undefined ? 'initial' : 'change',
},
})
}
if (lastRequestContext(logOf(id))?.model !== selection.model) {
append(id, {
type: 'request/context',
@@ -2897,23 +2950,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
streamBreakers.add(breakNow)
const snapshot = [...logOf(sessionId)]
const cursor = snapshot.at(-1)?.seq ?? -1
if (request.afterSeq !== undefined && request.afterSeq > cursor) {
throw new Error(
`fixture: session event resume seq ${String(request.afterSeq)} is past cursor ${String(cursor)}`,
)
}
let nextSeq = (request.afterSeq ?? cursor) + 1
const summary = summaryOf(sessionId)
/* v8 ignore next -- existence was checked before the stream registered. */
if (summary === undefined) throw new Error(`fixture: no session ${sessionId}`)
const initial = pageOf(snapshot, undefined, request.maxMessages ?? 50)
let nextSeq = cursor + 1
try {
yield { type: 'opened', cursor }
if (request.afterSeq !== undefined) {
for (const event of snapshot) {
if (event.seq < nextSeq) continue
if (event.seq !== nextSeq) {
throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`)
}
nextSeq++
yield { type: 'event', event }
}
yield {
type: 'snapshot',
header: {
version: 0,
id: sessionId,
createdAt: summary.updatedAt,
...(summary.cwd === undefined ? {} : { cwd: summary.cwd }),
...(summary.parentSessionId === undefined ? {} : { parentSession: summary.parentSessionId }),
...(summary.origin === undefined ? {} : { origin: summary.origin }),
...(summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }),
},
cursor,
events: initial.events,
hasMore: initial.hasMore,
projections: { asOfSeq: cursor, values: projectionValuesOf(snapshot) },
}
for await (const frame of conn.drain(signal)) {
if (frame.event.seq < nextSeq) continue
@@ -3346,7 +3403,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
{ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true },
],
}),
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
models: request => ok(request, {
default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
routableProviders: ['deepseek-official', 'openai', 'acme-gateway'],
groups: fixtureModelGroups(),
failures: [],
}),
// The fixture endpoint is imaginary, so the interrogation answers the
// catalog it already serves — enough for a surface to exercise adopting
// candidates without a reachable provider.
@@ -3411,9 +3473,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
case 'session/create': return sessionApi.create(
request as Parameters<FixtureSessionApi['create']>[0],
)
case 'session/models': return sessionApi.models(
request as Parameters<FixtureSessionApi['models']>[0],
)
case 'session/selectModel': return sessionApi.selectModel(
request as Parameters<FixtureSessionApi['selectModel']>[0],
)
@@ -33,7 +33,7 @@ export type {
ApiProxy, HostApi,
DirectoryEntry, DirectoryListing,
SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
@@ -141,7 +141,12 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({
default: { provider: 'fixture', model: 'fixture' },
routableProviders: [],
groups: [],
failures: [],
}))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
@@ -1,6 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
ModelProviderGroup,
ModelSelection,
RpcMessage,
RpcRequest,
@@ -42,14 +41,19 @@ interface FixtureHistoryEntry {
interface FixturePage {
readonly events: readonly FixtureHistoryEntry[]
readonly hasMore: boolean
readonly projections?: {
readonly asOfSeq: number
readonly values: Readonly<Record<string, unknown>>
}
}
type FixtureFollowFrame =
| { readonly type: 'opened'; readonly cursor: number }
| {
readonly type: 'snapshot'
readonly cursor: number
readonly events: readonly FixtureHistoryEntry[]
readonly hasMore: boolean
readonly projections: {
readonly asOfSeq: number
readonly values: Readonly<Record<string, unknown>>
}
}
| ({ readonly type: 'event' } & FixtureHistoryEntry)
type FixtureControlFrame =
@@ -88,7 +92,6 @@ interface FixtureSessionRequests {
readonly beforeSeq?: number
readonly maxMessages?: number
}
models: { readonly sessionId: SessionId }
selectModel: {
readonly sessionId: SessionId
readonly provider: string
@@ -114,12 +117,6 @@ interface FixtureSessionValues {
search: { readonly items: readonly { readonly sessionId: SessionId; readonly snippet: string }[]; readonly hasMore: boolean }
create: { readonly sessionId: SessionId }
history: FixturePage
models: {
readonly current: ModelSelection
readonly routable: boolean
readonly groups: readonly ModelProviderGroup[]
readonly failures: readonly unknown[]
}
selectModel: { readonly selected: ModelSelection }
prompt: { readonly accepted: true }
cancel: Record<never, never>
@@ -141,7 +138,7 @@ type FixtureSessionClient = {
}
interface FixtureSessionRemote {
follow(sessionId: SessionId, signal: AbortSignal, afterSeq?: number): AsyncIterable<FixtureFollowFrame>
follow(sessionId: SessionId, signal: AbortSignal): AsyncIterable<FixtureFollowFrame>
control(signal: AbortSignal): AsyncIterable<FixtureControlFrame>
}
@@ -331,7 +328,6 @@ function createSessionApi(rpc: ClientConnectionRpc): FixtureSessionApi {
search: (request, signal) => call('search', request, signal),
create: (request, signal) => call('create', request, signal),
history: (request, signal) => call('history', request, signal),
models: (request, signal) => call('models', request, signal),
selectModel: (request, signal) => call('selectModel', request, signal),
prompt: (request, signal) => call('prompt', request, signal),
cancel: (request, signal) => call('cancel', request, signal),
@@ -346,7 +342,6 @@ function createSessionClient(rpc: ClientConnectionRpc): FixtureSessionClient {
search: (request, signal) => api.search(req(request), signal),
create: (request, signal) => api.create(req(request), signal),
history: (request, signal) => api.history(req(request), signal),
models: (request, signal) => api.models(req(request), signal),
selectModel: (request, signal) => api.selectModel(req(request), signal),
prompt: (request, signal) => api.prompt(req(request), signal),
cancel: (request, signal) => api.cancel(req(request), signal),
@@ -361,11 +356,8 @@ function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote {
return stream as AsyncIterable<F>
}
return {
follow: (sessionId, signal, afterSeq) => open<FixtureFollowFrame>('session/follow', {
request: {
address: { kind: 'session', sessionId },
...afterSeq === undefined ? {} : { afterSeq },
},
follow: (sessionId, signal) => open<FixtureFollowFrame>('session/follow', {
request: { address: { kind: 'session', sessionId } },
}, signal),
control: signal => open<FixtureControlFrame>('session/control', {}, signal),
}
@@ -499,7 +491,7 @@ async function nextRemoteEvent(
async function readOpeningCursor(remote: FixtureSessionRemote, sessionId: SessionId): Promise<number> {
const abort = new AbortController()
for await (const frame of remote.follow(sessionId, abort.signal)) {
if (frame.type !== 'opened') continue
if (frame.type !== 'snapshot') continue
abort.abort()
return frame.cursor
}
@@ -599,52 +591,10 @@ describe('createFixtureApi', () => {
const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
if (!clamped.result.ok) throw new Error('clamped failed')
expect(clamped.result.value.events).toEqual([])
// Unknown session: empty page, not an error (history of a bare id). The
// tail block still rides it — empty-log cut at -1, the host convention.
// Unknown session: empty page, not an error (history of a bare id).
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
if (!empty.result.ok) throw new Error('empty failed')
// Fixture composes the todos + plan units (host parallel when tool-todo
// and plan-mode are mounted): the empty-log values.
expect(empty.result.value).toEqual({
events: [], hasMore: false, projections: { asOfSeq: -1, values: {
todos: null,
// Permission unit composed: the composition-default select.
permissions: {
options: [
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
],
currentValue: 'workspace-write',
},
plan: { active: false, pending: false },
goal: null,
tokenUsage: {
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
// No request ran, so neither pressure nor capacity is known yet.
contextPressure: {},
contextBreakdown: {
systemTokens: 0,
toolsTokens: 0,
messageTokens: 0,
},
// Session-stats unit composed: no figure accrues on the empty log.
sessionStats: {
turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
},
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
maxImageDimension: 2000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
},
} },
})
expect(empty.result.value).toEqual({ events: [], hasMore: false })
})
it('serves raw history entries with replayable tool-result metadata', async () => {
@@ -693,7 +643,7 @@ describe('createFixtureApi', () => {
it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
const api = createFixtureApi()
const sessionId = sid('fx-alpha')
const catalog = await api.sessions.models(req({ sessionId }))
const catalog = await api.llm.models(req({}))
if (!catalog.result.ok) throw new Error('models failed')
expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI'])
expect(catalog.result.value.groups[0]?.models.map(model => model.id))
@@ -1462,33 +1412,29 @@ describe('createFixtureApi', () => {
const gapAbort = new AbortController()
const gapIterator = api.sessionRemote.follow(sid('fx-alpha'), gapAbort.signal)[Symbol.asyncIterator]()
const opening = await gapIterator.next()
if (opening.done || opening.value.type !== 'opened') throw new Error('follow opening cursor missing')
const resumeCursor = opening.value.cursor
if (opening.done || opening.value.type !== 'snapshot') throw new Error('follow opening snapshot missing')
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
await expect(gapIterator.next()).rejects.toThrow(/stream skipped seq/)
// Reopening from the established cursor replays both durable events.
// Reopening replaces the window with a complete snapshot containing both durable events.
const followAbort = new AbortController()
const controlAbort = new AbortController()
const followed: FixtureFollowFrame[] = []
const controlled: FixtureControlFrame[] = []
const following = (async () => {
for await (const frame of api.sessionRemote.follow(
sid('fx-alpha'),
followAbort.signal,
resumeCursor,
)) followed.push(frame)
for await (const frame of api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal)) {
followed.push(frame)
}
})()
const controlling = (async () => {
for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlled.push(frame)
})()
await new Promise(resolve => setTimeout(resolve, 10))
await vi.waitFor(() => {
expect(followed.some(frame => frame.type === 'event'
&& JSON.stringify(frame.event.data).includes('静默丢帧'))).toBe(true)
expect(followed.some(frame => frame.type === 'event'
&& JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true)
const snapshot = followed.find(frame => frame.type === 'snapshot')
expect(snapshot?.events.some(entry => JSON.stringify(entry.event.data).includes('静默丢帧'))).toBe(true)
expect(snapshot?.events.some(entry => JSON.stringify(entry.event.data).includes('正常直播'))).toBe(true)
})
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
hooks.beginModelRetry('fx-alpha')
@@ -1497,7 +1443,6 @@ describe('createFixtureApi', () => {
hooks.beginModelRetry('fx-alpha')
hooks.cancelModelRetryDuringBackoff('fx-alpha')
await vi.waitFor(() => {
expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true)
expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'llm/retry')).toBe(true)
expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('重试后的完整回复'))).toBe(true)
expect(followed.some(frame => frame.type === 'event'
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-model-selection",
"description": "Model selection: the /model popupSelect over session.models / session.selectModel",
"description": "Model selection over the shared model catalog, Session projection, and session.selectModel",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
@@ -33,6 +33,7 @@
"client": {
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-commands",
"@deepseek-ai/dsh-api-remotes"
@@ -107,14 +107,6 @@ export function ModelSelect(
load()
}
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => {
if (available) {
lastActionRef.current = 'load'
load()
}
}, [available, load])
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
@@ -202,13 +194,19 @@ export function ModelSelect(
void select(selection).then(settleSelection)
}
const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
const waiting = state.current === null && state.status === 'loading'
const modelLabel = waiting
? t('trigger.loading')
: currentChoice?.model.name
?? (state.current === null ? t('trigger.fallback') : `${state.current.provider}/${state.current.model}`)
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
const triggerAria = currentChoice === undefined
? t('trigger.selectAria')
: effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
const triggerAria = waiting
? t('trigger.loading')
: state.current === null
? t('trigger.selectAria')
: effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
itemRefs.current = []
let itemIndex = 0
const itemRef = () => {
@@ -0,0 +1,89 @@
/** One Host-generation model catalog shared by every Session selector. */
import {
type IApiClient,
type ModelCatalog,
} from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
/** Observable lifecycle of the shared model catalog. */
export interface ModelCatalogState {
value: ModelCatalog | null
status: 'idle' | 'loading' | 'ready' | 'error'
error: string | null
}
/** Loads at most one model catalog for the current Host generation. */
export class ModelCatalogDirectory {
/** Current shared catalog value and load lifecycle. */
readonly store: SnapshotStore<ModelCatalogState> = createSnapshotStore({
value: null,
status: 'idle',
error: null,
})
private generation = 0
private inflight: Promise<ModelCatalog> | undefined
/** @param api - shared connection API client. */
constructor(private readonly api: IApiClient) {}
/**
* Return the current generation's catalog, sharing its one in-flight load.
* @returns the loaded global catalog.
*/
load(): Promise<ModelCatalog> {
const state = this.store.getSnapshot()
if (state.status === 'ready' && state.value !== null) return Promise.resolve(state.value)
if (this.inflight !== undefined) return this.inflight
const generation = this.generation
this.store.update((draft) => {
draft.status = 'loading'
draft.error = null
})
const operation = this.api.llm.models({}).then((response) => {
if (!response.result.ok) {
throw new Error(`${response.result.error.code}: ${response.result.error.message}`)
}
if (generation === this.generation) {
this.store.set({ value: response.result.value, status: 'ready', error: null })
}
return response.result.value
}).catch((error: unknown) => {
if (generation === this.generation) {
this.store.update((draft) => {
draft.status = 'error'
draft.error = error instanceof Error ? error.message : String(error)
})
}
throw error
}).finally(() => {
if (generation === this.generation && this.inflight === operation) this.inflight = undefined
})
this.inflight = operation
return operation
}
/**
* Invalidate the loaded catalog; the next explicit menu read reloads it.
* @param clear - whether values from the previous Host generation must be hidden.
*/
private invalidate(clear = false): void {
this.generation += 1
this.inflight = undefined
const value = clear ? null : this.store.getSnapshot().value
this.store.set({ value, status: 'idle', error: null })
}
/** Invalidate and reload the catalog after a Host-side model input changes. */
refresh(): void {
this.invalidate()
void this.load().catch(() => { /* the selector exposes the shared error */ })
}
/** Clear Host-specific values and load the replacement Host generation. */
resetGeneration(): void {
this.invalidate(true)
void this.load().catch(() => { /* the selector exposes the shared error */ })
}
}
@@ -1,21 +1,21 @@
/**
* Per-session model directory: the ONE state both selection entries share.
* The /model popup and the composer-seat selector load through the same
* controller and submit through the same selectModel call, so the host stays
* the single fact source and the store is one shared echo — a switch made in
* either entry is what the other shows next.
* The /model popup and composer seat combine one shared Host catalog with the
* Session's durable selection projection, then submit through the same
* selectModel call. A switch made in either entry updates this shared state.
*/
import type {
ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionModels,
ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection,
} from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ModelCatalogDirectory } from './catalog.ts'
/** Directory snapshot both entries render from. */
export interface ModelDirectoryState {
/** Model selection the host reports for the next assembled step; null before the first load. */
/** Effective selection: durable next-request projection, then Host default. */
current: ModelSelection | null
/**
* Whether an adapter serves the current selection's provider, as the host reports
@@ -42,55 +42,47 @@ export class ModelDirectory {
current: null, routable: null, groups: [], failures: [], status: 'idle', error: null,
})
/** Latest operation wins; an older response never overwrites a newer one. */
/** Latest selection operation wins; an older response never overwrites a newer one. */
private generation = 0
private disposed = false
private resolved = false
private readonly unsubscribeCatalog: () => void
private readonly unsubscribeSelection: () => void
/**
* @param sessions - the session wire face (captured from the plugin's root connection).
* @param sessionId - the owning session.
* @param available - whether this session may use Agent-bound model RPCs.
* @param catalog - Host-generation catalog shared by every Session.
* @param projected - durable model selection projected from Session history.
*/
constructor(
private readonly sessions: Pick<TypertClientRemote['session'], 'models' | 'selectModel'>,
private readonly sessions: Pick<TypertClientRemote['session'], 'selectModel'>,
private readonly sessionId: SessionId,
private readonly available: () => boolean,
) {}
/**
* Refresh the advisory directory (both entries call this on open).
* Failure preserves the last good groups and current selection.
* @returns the fresh directory value.
*/
async load(): Promise<SessionModels> {
this.assertAvailable()
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
const result = await this.sessions.models({ sessionId: this.sessionId })
if (this.disposed || generation !== this.generation) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
return result.value
}
if (!result.ok) {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`)
}
const { current, routable, groups, failures } = result.value
this.store.update((s) => {
s.current = current
s.routable = routable
s.groups = groups
s.failures = failures
s.status = 'ready'
s.error = null
})
return result.value
private readonly catalog: ModelCatalogDirectory,
private readonly projected: ObservableSnapshot<unknown>,
) {
this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() })
this.unsubscribeSelection = projected.subscribe(() => { this.syncInputs() })
this.syncInputs()
}
/**
* Select the complete provider/model/reasoning selection (both entries submit through here). Success
* updates the shared current; failure surfaces on the store and throws so
* each entry's own retry surface engages.
* Ensure the Host generation's shared advisory catalog is loaded.
* @returns the fresh directory value.
*/
async load(): Promise<ModelDirectoryState> {
this.assertAvailable()
await this.catalog.load()
this.syncInputs()
return this.store.getSnapshot()
}
/**
* Select the complete provider/model/reasoning selection. The durable
* projection frame updates the shared current; failures surface on the store
* and throw so each entry's own retry surface engages.
* @param selection - provider, provider-owned model id, and optional adapter-owned effort.
*/
async select(selection: ModelSelection): Promise<void> {
@@ -113,39 +105,28 @@ export class ModelDirectory {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
}
// The Host validated the route before accepting it, so a selection that
// landed is by construction one it can serve.
this.store.update((s) => {
s.current = result.value.selected
s.routable = true
s.status = 'ready'
s.error = null
})
this.store.update((s) => { s.status = 'ready'; s.error = null })
this.syncInputs()
}
/**
* Drop the previous Host generation's projection and repull it. Clearing
* first prevents an unconsumed process-local selection from being displayed
* while the restarted Host has restored the last logged model selection.
* Invalidate an in-flight selection response from the previous Host generation.
*/
resetConnected(): void {
if (this.disposed) return
++this.generation
this.store.update((s) => {
s.current = null
s.routable = null
s.groups = []
s.failures = []
s.status = 'idle'
s.error = null
this.store.update((state) => {
if (state.status === 'selecting') state.status = 'idle'
state.error = null
})
if (!this.available()) return
void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ })
this.syncInputs()
}
/** Scope teardown: late settlements lose write access to the store. */
dispose(): void {
this.disposed = true
this.unsubscribeSelection()
this.unsubscribeCatalog()
}
private assertAvailable(): void {
@@ -153,4 +134,46 @@ export class ModelDirectory {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
}
private syncInputs(): void {
if (this.disposed) return
const catalog = this.catalog.store.getSnapshot()
const projected = modelSelectionProjection(this.projected.getSnapshot())
if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) {
if (this.resolved) {
if (catalog.status === 'error') {
this.store.update((state) => {
state.status = 'error'
state.error = catalog.error
})
}
return
}
this.store.set({
current: null,
routable: null,
groups: [],
failures: [],
status: catalog.status === 'error' ? 'error' : 'loading',
error: catalog.error,
})
return
}
const current = projected.next ?? catalog.value.default
this.resolved = true
this.store.set({
current,
routable: catalog.value.routableProviders.includes(current.provider),
groups: catalog.value.groups,
failures: catalog.value.failures,
status: this.store.getSnapshot().status === 'selecting'
? 'selecting'
: 'ready',
error: null,
})
}
}
function modelSelectionProjection(value: unknown): ModelSelectionProjection | undefined {
return value === undefined ? undefined : value as ModelSelectionProjection
}
@@ -1,18 +1,17 @@
/**
* Model selection plugin, browser half — TWO entries over ONE per-session
* directory owned by ModelDirectoryResolver (`ctx.modelDirectories`). The /model popupSelect
* contribution and the composer's named `conversation.input.model` seat both
* load the session's provider-grouped advisory directory (`session.models`)
* and submit through `session.selectModel` via the same directory instance,
* so the host-reported current selection is the single fact both surfaces echo
* — a switch made in either entry is what the other shows next. Failures
* contribution and the composer's named `conversation.input.model` seat share
* one Host-generation `llm.models` catalog, combine it with the Session's
* durable model-selection projection, and submit through `session.selectModel`.
* A switch made in either entry is what the other shows next. Failures
* ride each entry's own retry surface (popup shell error/retry; seat menu
* inline error) without forking the state. Addressed subagent sessions expose
* neither entry because those Agent-bound RPCs would activate persisted
* history outside the direct-parent continuation path.
*/
// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge.
import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
@@ -48,7 +47,7 @@ function rowId(providerId: string, modelId: string): string {
}
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] {
const rows: SelectOption[] = []
for (const group of directory.groups) {
for (const model of group.models) {
@@ -56,7 +55,9 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt
id: rowId(group.id, model.id),
label: model.name,
detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
...(directory.current.provider === group.id && directory.current.model === model.id
...(directory.current !== null
&& directory.current.provider === group.id
&& directory.current.model === model.id
? { active: true } : {}),
})
}
@@ -13,6 +13,7 @@ export const zh = {
'command.description': '选择本会话使用的模型',
'option.loadError': '目录加载失败:{message}',
'trigger.fallback': '选择模型',
'trigger.loading': '正在加载模型…',
'trigger.selectAria': '选择模型',
'trigger.aria': '选择模型,当前 {model}',
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
@@ -37,6 +38,7 @@ export const en = {
'command.description': 'Select the model for this conversation',
'option.loadError': 'Catalog failed to load: {message}',
'trigger.fallback': 'Select model',
'trigger.loading': 'Loading models…',
'trigger.selectAria': 'Select model',
'trigger.aria': 'Select model, current {model}',
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
@@ -15,7 +15,9 @@
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { ModelCatalogDirectory } from './catalog.ts'
import { ModelDirectory } from './directory.ts'
declare module '@deepseek-ai/cordis' {
@@ -32,9 +34,10 @@ interface LiveState {
/** The `ctx.modelDirectories` session model-selection service. */
export class ModelDirectoryResolver extends Service {
static inject = ['sessions', 'remote', 'remote.session']
static inject = ['sessions', 'remote', 'remote.session', 'connection']
private readonly live: LiveState = { directories: new Map() }
private readonly catalog: ModelCatalogDirectory
/** Localized composer-block copy; this plugin owns the string it raises. */
private readonly blockReason: () => string
@@ -46,18 +49,17 @@ export class ModelDirectoryResolver extends Service {
constructor(ctx: Context, config: { blockReason: () => string }) {
super(ctx, 'modelDirectories')
this.blockReason = config.blockReason
const connection = ctx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error('ui-model-selection: connection service is unavailable')
this.catalog = new ModelCatalogDirectory(connection.api)
void this.catalog.load().catch(() => { /* selectors expose the shared error */ })
ctx.on('connection/reset', () => {
this.catalog.resetGeneration()
for (const directory of this.live.directories.values()) directory.resetConnected()
})
// Either source can change the directory: registry topology commits and
// settings documents that carry provider catalogs or default selection.
const refresh = (): void => {
for (const directory of this.live.directories.values()) {
directory.load().catch(() => undefined)
}
}
ctx.remote.$on('llm/adapters-updated', refresh)
ctx.remote.$on('settings/document-updated', refresh)
ctx.remote.$on('llm/adapters-updated', () => { this.catalog.refresh() })
ctx.remote.$on('settings/document-updated', () => { this.catalog.refresh() })
ctx.remote.$on('credentials/reference-updated', () => { this.catalog.refresh() })
}
/**
@@ -73,10 +75,14 @@ export class ModelDirectoryResolver extends Service {
const sessions = this.ctx.sessions
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`)
const binding = sessions.binding(sessionId)
if (binding === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no binding`)
const directory = new ModelDirectory(
this.ctx.remote.session,
sessionId,
() => sessions.subagentAddress(sessionId) === undefined,
this.catalog,
binding.session.projections.faceOf('modelSelection'),
)
live.directories.set(sessionId, directory)
// The composer cannot read this plugin (the dependency runs one way), so
@@ -14,7 +14,7 @@ export interface ModelSelectInjected {
available: boolean
/** The session's shared directory store (same instance the /model popup reads). */
directory: SnapshotStore<ModelDirectoryState>
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
/** Ensure the shared advisory catalog is loaded (errors land on the store). */
load: () => void
/**
* Select a complete provider/model/reasoning selection.
@@ -9,12 +9,13 @@
* Scope disposal drops the directory (HMR safety).
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createScope } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { ModelSelectInjected } from '../src/client/slots.ts'
import { apply, inject } from '../src/client/index.ts'
@@ -56,30 +57,51 @@ const GROUPS = [{
/** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */
async function bench() {
const ctx = new Context()
let current: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
let defaultSelection: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
let selected = defaultSelection
const calls = { models: 0, select: 0 }
const projections = new Map<SessionId, SnapshotStore<ModelSelectionProjection | undefined>>()
// Whether the Host reports an adapter for the current route; the composer
// block follows this, never catalog membership.
let routable = true
const sessionRemote = {
models: () => {
calls.models += 1
return Promise.resolve({ ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } })
},
selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => {
selectModel: (payload: { sessionId: SessionId; provider: string; model: string; reasoningEffort?: string }) => {
calls.select += 1
current = {
selected = {
provider: payload.provider,
model: payload.model,
...payload.reasoningEffort === undefined
? {}
: { reasoningEffort: payload.reasoningEffort },
}
return Promise.resolve({ ok: true as const, value: { selected: current } })
projections.get(payload.sessionId)?.set({ lastUsed: null, next: selected })
return Promise.resolve({ ok: true as const, value: { selected } })
},
}
const remote = Object.assign(new TestRemote(ctx), { session: sessionRemote })
ctx.reflect.provide('remote.session', sessionRemote)
// Whether the Host reports an adapter for the current route; the composer
// block follows this, never catalog membership.
let routable = true
ctx.provide('connection', {
api: {
llm: {
models: () => {
calls.models += 1
return Promise.resolve({
rpcId: 'model-catalog',
result: {
ok: true as const,
value: {
default: defaultSelection,
routableProviders: routable ? ['deepseek-official'] : [],
groups: GROUPS,
failures: [],
},
},
})
},
},
},
isLoopback: false,
} as never)
const blocks = new Map<SessionId, { reason: string } | undefined>()
ctx.provide('conversation', {
blocks: {
@@ -114,6 +136,17 @@ async function bench() {
const addressed = new Set<SessionId>()
ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id),
binding: (id: SessionId) => {
const scope = scopes.get(id)
const projection = projections.get(id)
return scope === undefined || projection === undefined
? undefined
: {
sessionId: id,
session: { projections: { faceOf: () => projection } },
ctx: scope,
}
},
subagentAddress: (id: SessionId) => addressed.has(id)
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
@@ -122,16 +155,22 @@ async function bench() {
await fiber.await()
await ctx.plugin(function probe() {}).await()
const mint = (key: string) => {
const handle = createScope(ctx, sid(key))
scopes.set(sid(key), handle.ctx)
const id = sid(key)
const handle = createScope(ctx, id)
scopes.set(id, handle.ctx)
projections.set(id, createSnapshotStore<ModelSelectionProjection | undefined>({
lastUsed: null,
next: null,
}))
return handle
}
return {
ctx, fiber, mint, calls, remote,
contribution: () => contribution!,
seat: () => seats.get('conversation.input.model')!,
hostCurrent: () => current,
setHostCurrent: (selection: ModelSelection) => { current = selection },
hostCurrent: () => selected,
setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) },
address: (id: SessionId) => { addressed.add(id) },
setRoutable: (next: boolean) => { routable = next },
blockOf: (key: string) => blocks.get(sid(key)),
@@ -209,9 +248,14 @@ describe('ui-model-selection dual entry', () => {
expect(faceA.directory).not.toBe(faceB.directory)
// The service face resolves the same instance the seat inject handed out.
expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory)
await Promise.all([
b.contribution().ui.options(projection('a'), new AbortController().signal),
b.contribution().ui.options(projection('b'), new AbortController().signal),
])
expect(b.calls.models).toBe(1)
})
it('drops an unconsumed local selection and restores the Host target after reconnect', async () => {
it('keeps the durable projected selection while the eager catalog reconnects', async () => {
const b = await bench()
b.mint('s1')
const face = b.seat().inject!(sid('s1'))
@@ -219,12 +263,40 @@ describe('ui-model-selection dual entry', () => {
b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
b.ctx.emit('connection/reset')
expect(face.directory.getSnapshot()).toMatchObject({ current: null, status: 'loading' })
await Promise.resolve()
expect(face.directory.getSnapshot()).toMatchObject({
current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
status: 'ready',
})
face.load()
expect(face.directory.getSnapshot()).toMatchObject({
current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
status: 'ready',
})
})
it('keeps the last complete view while a refreshed catalog catches up with projection', async () => {
const b = await bench()
b.mint('s1')
const face = b.seat().inject!(sid('s1'))
face.load()
expect(face.directory.getSnapshot().current?.model).toBe('deepseek-v4-flash')
b.remote.emit('settings/document-updated', ['llm-deepseek', 1])
b.setProjected(sid('s1'), {
lastUsed: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
})
expect(face.directory.getSnapshot()).toMatchObject({
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
status: 'ready',
})
await vi.waitFor(() => {
expect(face.directory.getSnapshot()).toMatchObject({
current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
status: 'ready',
})
})
})
it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => {
@@ -249,19 +321,22 @@ describe('ui-model-selection dual entry', () => {
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')).toBeUndefined()
expect(b.calls.models).toBe(1)
b.setRoutable(false)
b.remote.emit('llm/adapters-updated', [])
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
// Recovering clears it without a reload of the surface.
b.setRoutable(true)
b.remote.emit('settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
expect(b.calls.models).toBe(2)
// Recovering clears it without a reload of the surface.
b.setRoutable(true)
b.remote.emit('llm/adapters-updated', [])
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')).toBeUndefined()
expect(b.calls.models).toBe(3)
})
it('never blocks on catalog membership alone', async () => {
@@ -286,9 +361,8 @@ describe('ui-model-selection dual entry', () => {
b.setRoutable(false)
const face = b.seat().inject!(sid('s1'))
face.load()
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')).toBeDefined()
b.remote.emit('llm/adapters-updated', [])
await vi.waitFor(() => { expect(b.blockOf('s1')).toBeDefined() })
await scope.fiber.dispose()
expect(b.blockOf('s1')).toBeUndefined()
@@ -322,6 +396,6 @@ describe('ui-model-selection dual entry', () => {
})).rejects.toThrow(/unavailable for addressed subagent/)
b.ctx.emit('connection/reset')
await Promise.resolve()
expect(b.calls).toEqual({ models: 0, select: 0 })
expect(b.calls).toEqual({ models: 2, select: 0 })
})
})
@@ -0,0 +1,93 @@
import type { IApiClient, ModelCatalog } from '@deepseek-ai/dsh-client-connection/client'
import { describe, expect, it, vi } from 'vitest'
import { ModelCatalogDirectory } from '../src/client/catalog.ts'
const catalog = (model: string): ModelCatalog => ({
default: { provider: 'fixture', model },
routableProviders: ['fixture'],
groups: [{ id: 'fixture', name: 'Fixture', models: [{ id: model, name: model }] }],
failures: [],
})
function directory(models: () => Promise<unknown>): ModelCatalogDirectory {
return new ModelCatalogDirectory({ llm: { models } } as unknown as IApiClient)
}
describe('ModelCatalogDirectory', () => {
it('shares one failing request, exposes the RPC error, and permits a retry', async () => {
const models = vi.fn()
.mockResolvedValueOnce({
result: { ok: false, error: { code: 'unavailable', message: 'catalog offline', details: {} } },
})
.mockResolvedValueOnce({ result: { ok: true, value: catalog('recovered') } })
const subject = directory(models)
const first = subject.load()
expect(subject.load()).toBe(first)
await expect(first).rejects.toThrow('unavailable: catalog offline')
expect(subject.store.getSnapshot()).toMatchObject({ status: 'error', error: 'unavailable: catalog offline' })
await expect(subject.load()).resolves.toEqual(catalog('recovered'))
expect(models).toHaveBeenCalledTimes(2)
})
it('does not publish a successful result from an invalidated generation', async () => {
const first = Promise.withResolvers<unknown>()
const second = Promise.withResolvers<unknown>()
const models = vi.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const subject = directory(models)
const stale = subject.load()
subject.resetGeneration()
first.resolve({ result: { ok: true, value: catalog('stale') } })
await expect(stale).resolves.toEqual(catalog('stale'))
expect(subject.store.getSnapshot()).toMatchObject({ value: null, status: 'loading' })
second.resolve({ result: { ok: true, value: catalog('fresh') } })
await vi.waitFor(() => {
expect(subject.store.getSnapshot()).toMatchObject({ value: catalog('fresh'), status: 'ready' })
})
})
it('does not publish a failure from an invalidated generation', async () => {
const first = Promise.withResolvers<unknown>()
const second = Promise.withResolvers<unknown>()
const models = vi.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const subject = directory(models)
const stale = subject.load()
subject.resetGeneration()
first.reject(new Error('stale failure'))
await expect(stale).rejects.toThrow('stale failure')
expect(subject.store.getSnapshot()).toMatchObject({ value: null, status: 'loading', error: null })
second.resolve({ result: { ok: true, value: catalog('fresh') } })
await vi.waitFor(() => {
expect(subject.store.getSnapshot()).toMatchObject({ value: catalog('fresh'), status: 'ready' })
})
})
it('contains refresh failures while retaining old data and clears it on a failed Host reset', async () => {
const models = vi.fn()
.mockResolvedValueOnce({ result: { ok: true, value: catalog('old') } })
.mockRejectedValueOnce('refresh failed')
.mockRejectedValueOnce(new Error('reset failed'))
const subject = directory(models)
await subject.load()
subject.refresh()
await vi.waitFor(() => {
expect(subject.store.getSnapshot()).toEqual({
value: catalog('old'), status: 'error', error: 'refresh failed',
})
})
subject.resetGeneration()
await vi.waitFor(() => {
expect(subject.store.getSnapshot()).toEqual({
value: null, status: 'error', error: 'reset failed',
})
})
})
})
@@ -112,7 +112,7 @@ describe('ModelSelect reasoning effort', () => {
.toEqual(['Default', 'Standard'])
})
it('prompts for a selection when the current model is no longer advertised', () => {
it('shows the durable model id when the catalog has no matching display name', () => {
const directory = createSnapshotStore(state({
current: { provider: 'deepseek-official', model: 'removed-model' },
}))
@@ -126,15 +126,41 @@ describe('ModelSelect reasoning effort', () => {
t={t}
/>)
const trigger = screen.getByRole('button', { name: '选择模型' })
expect(trigger.textContent).toContain('选择模型')
const trigger = screen.getByRole('button', { name: '选择模型,当前 deepseek-official/removed-model' })
expect(trigger.textContent).toContain('deepseek-official/removed-model')
fireEvent.click(trigger)
expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
expect(screen.queryByText('removed-model')).toBeNull()
expect(screen.queryByRole('menuitemradio', { name: 'removed-model' })).toBeNull()
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
})
it('shows loading until the catalog and Session projection are both ready', async () => {
const directory = createSnapshotStore<ModelDirectoryState>(state({
current: null,
routable: null,
groups: [],
status: 'loading',
}))
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
t={t}
/>)
expect(screen.getByRole('button', { name: '正在加载模型…' }).textContent)
.toContain('正在加载模型…')
directory.set(state())
await waitFor(() => {
expect(screen.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
})).toBeTruthy()
})
})
it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
const groups = [{
id: 'deepseek-official',
@@ -11,6 +11,9 @@
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../connection/tsconfig.client.json"
},
{
"path": "../../../vendor/cordis"
},
@@ -36,6 +36,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'hook/result',
'llm/retry',
'llm/retry-started',
'model/selection',
'permission/preset',
'plan/mode',
'request/context',