feat(session): persist model selection and share its catalog
This commit is contained in:
1 parent
69fad4b8db
commit
822d735356
34 files changed
+1170
-516
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) }),
|
||||
}
|
||||
}
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user