feat(web): add session model selector
This commit is contained in:
@@ -6,13 +6,17 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore
|
||||
|
||||
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
|
||||
|
||||
## Session model selection
|
||||
|
||||
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
|
||||
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export type {
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
|
||||
RunningToolCall, SteeringMessageNode,
|
||||
ModelSelectionSnapshot, ModelSelectionStatus, RunningToolCall, SteeringMessageNode,
|
||||
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
// PendingWait is a value export: tests construct fixture waits directly.
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ModelCatalogFailure, ModelProviderGroup, ModelTarget, RpcError, SessionId,
|
||||
ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
@@ -139,6 +142,23 @@ export interface PromptError {
|
||||
error: RpcError
|
||||
}
|
||||
|
||||
/** Lifecycle of the session-local model directory and selection requests. */
|
||||
export type ModelSelectionStatus = 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
|
||||
|
||||
/** Immutable model-selector state owned by the Session object layer. */
|
||||
export interface ModelSelectionSnapshot {
|
||||
/** Target selected for the next assembled step, or null before history opens. */
|
||||
current: ModelTarget | null
|
||||
/** Last successfully loaded provider groups. */
|
||||
groups: readonly ModelProviderGroup[]
|
||||
/** Provider-local failures from the last successful directory response. */
|
||||
failures: readonly ModelCatalogFailure[]
|
||||
/** Current directory or selection operation state. */
|
||||
status: ModelSelectionStatus
|
||||
/** Whole-request or selection failure; partial provider failures use {@link failures}. */
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
@@ -158,4 +178,6 @@ export interface ConversationSnapshot {
|
||||
loadingOlder: boolean
|
||||
promptError: PromptError | null
|
||||
lastAgentError: string | null
|
||||
/** Session-local model target and advisory directory state. */
|
||||
modelSelection: ModelSelectionSnapshot
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
HistoryEntry, IApiClient, ModelTarget, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, SessionModels, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
|
||||
ConversationNode, ConversationSnapshot, ModelSelectionSnapshot, OpenState, PromptError,
|
||||
RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
@@ -69,6 +70,17 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private lastAgentError: string | null = null
|
||||
private modelSelection: ModelSelectionSnapshot = {
|
||||
current: null,
|
||||
groups: [],
|
||||
failures: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
}
|
||||
/** Latest model-directory/selection operation; stale responses drop all writes. */
|
||||
private modelGeneration = 0
|
||||
/** Failed selection target; null means the retryable operation is a directory refresh. */
|
||||
private modelRetryTarget: ModelTarget | null = null
|
||||
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
|
||||
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
|
||||
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
|
||||
@@ -128,6 +140,102 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the advisory provider/model directory. Independent provider
|
||||
* failures remain in a successful snapshot; whole-request failures preserve
|
||||
* the last usable groups and current target.
|
||||
* @returns the model-directory RPC result.
|
||||
*/
|
||||
async refreshModels(): Promise<RpcResult<SessionModels>> {
|
||||
const generation = ++this.modelGeneration
|
||||
this.modelRetryTarget = null
|
||||
this.modelSelection = {
|
||||
...this.modelSelection,
|
||||
status: 'loading',
|
||||
error: null,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<SessionModels>
|
||||
try {
|
||||
result = (await this.api.sessions.models({ sessionId: this.sessionId })).result
|
||||
} catch (error: unknown) {
|
||||
result = transportError(error)
|
||||
}
|
||||
if (generation !== this.modelGeneration) return result
|
||||
this.modelSelection = result.ok
|
||||
? {
|
||||
current: result.value.current,
|
||||
groups: result.value.groups,
|
||||
failures: result.value.failures,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
}
|
||||
: {
|
||||
...this.modelSelection,
|
||||
status: 'error',
|
||||
error: result.error,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the complete route for this session. The host snapshots it at the
|
||||
* next prompt-assembly boundary, so running work keeps its assembled target.
|
||||
* @param target - Provider and provider-owned model id.
|
||||
* @returns the selection RPC result.
|
||||
*/
|
||||
async selectModel(target: ModelTarget): Promise<RpcResult<{ selected: ModelTarget }>> {
|
||||
const generation = ++this.modelGeneration
|
||||
this.modelRetryTarget = target
|
||||
this.modelSelection = {
|
||||
...this.modelSelection,
|
||||
status: 'selecting',
|
||||
error: null,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<{ selected: ModelTarget }>
|
||||
try {
|
||||
result = (await this.api.sessions.selectModel({
|
||||
sessionId: this.sessionId,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
})).result
|
||||
} catch (error: unknown) {
|
||||
result = transportError(error)
|
||||
}
|
||||
if (generation !== this.modelGeneration) return result
|
||||
if (result.ok) this.modelRetryTarget = null
|
||||
this.modelSelection = result.ok
|
||||
? {
|
||||
...this.modelSelection,
|
||||
current: result.value.selected,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
}
|
||||
: {
|
||||
...this.modelSelection,
|
||||
status: 'error',
|
||||
error: result.error,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeat the operation that produced the visible model error.
|
||||
* A failed selection retains its exact target; directory failures refresh.
|
||||
* @returns Whether a model selection succeeded; directory retries return false.
|
||||
*/
|
||||
async retryModelOperation(): Promise<boolean> {
|
||||
const target = this.modelRetryTarget
|
||||
if (target === null) {
|
||||
await this.refreshModels()
|
||||
return false
|
||||
}
|
||||
return (await this.selectModel(target)).ok
|
||||
}
|
||||
|
||||
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
||||
open(): Promise<void> {
|
||||
if (this.openState === 'open') return Promise.resolve()
|
||||
@@ -318,6 +426,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let modelGeneration = this.modelGeneration
|
||||
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration) return
|
||||
if (!result.ok) {
|
||||
@@ -325,13 +434,24 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore)
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
|
||||
)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
modelGeneration = this.modelGeneration
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
|
||||
if (result.ok) {
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -349,13 +469,30 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
|
||||
* (doOpen flips it after install), so recursing would push every buffered event straight
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, modelTarget?: ModelTarget): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
if (modelTarget !== undefined) {
|
||||
const current = this.modelSelection.current
|
||||
if (
|
||||
current === null
|
||||
|| current.provider !== modelTarget.provider
|
||||
|| current.model !== modelTarget.model
|
||||
|| this.modelSelection.error !== null
|
||||
) {
|
||||
this.modelRetryTarget = null
|
||||
this.modelSelection = {
|
||||
...this.modelSelection,
|
||||
current: modelTarget,
|
||||
status: this.modelSelection.groups.length > 0 ? 'ready' : 'idle',
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const item of buffered) this.appendLive(item.event, item.view)
|
||||
@@ -400,11 +537,16 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.openGeneration
|
||||
const modelGeneration = this.modelGeneration
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore)
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
@@ -539,6 +681,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
loadingOlder: this.loadingOlder,
|
||||
promptError: this.promptError,
|
||||
lastAgentError: this.lastAgentError,
|
||||
modelSelection: this.modelSelection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
|
||||
ClientResponse, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -46,10 +47,23 @@ export class FakeApiClient implements IApiClient {
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false, modelTarget: this.defaultModel }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: this.defaultModel,
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
|
||||
}],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: { provider: string; model: string }) =>
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
@@ -66,6 +80,9 @@ export class FakeApiClient implements IApiClient {
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: { provider: string; model: string }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
@@ -267,7 +267,11 @@ describe('remaining branches', () => {
|
||||
describe('connected generation', () => {
|
||||
it('refreshes the list and resyncs only opened instances', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const openedSession = manager.get(S1)
|
||||
await openedSession.open()
|
||||
|
||||
@@ -24,7 +24,11 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
return Promise.resolve(ok({
|
||||
events: entries(events) as never[],
|
||||
hasMore,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
@@ -75,7 +79,11 @@ describe('open', () => {
|
||||
const page = plainTurn(10, 0, '早', '安')
|
||||
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
|
||||
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
|
||||
gate.resolve(ok({ events: entries(page) as never[], hasMore: false }))
|
||||
gate.resolve(ok({
|
||||
events: entries(page) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await opening
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
// Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
|
||||
@@ -83,6 +91,165 @@ describe('open', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('model selection', () => {
|
||||
it('restores the current target from history, then refreshes grouped models with partial failures', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([])
|
||||
api.onModels = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
|
||||
}],
|
||||
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
|
||||
}))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().modelSelection).toEqual({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
})
|
||||
|
||||
const refreshing = session.refreshModels()
|
||||
expect(session.getSnapshot().modelSelection.status).toBe('loading')
|
||||
await refreshing
|
||||
expect(session.getSnapshot().modelSelection).toEqual({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
|
||||
}],
|
||||
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
|
||||
status: 'ready',
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the previous target and directory when selection fails, then accepts a retry', async () => {
|
||||
const { api, session } = makeSession()
|
||||
await session.open()
|
||||
await session.refreshModels()
|
||||
const before = session.getSnapshot().modelSelection
|
||||
api.onSelectModel = () => Promise.resolve(err({
|
||||
code: 'model-unavailable',
|
||||
message: 'gone',
|
||||
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
}))
|
||||
|
||||
const failed = session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
expect(session.getSnapshot().modelSelection.status).toBe('selecting')
|
||||
await failed
|
||||
const errored = session.getSnapshot().modelSelection
|
||||
expect(errored.current).toBe(before.current)
|
||||
expect(errored.groups).toBe(before.groups)
|
||||
expect(errored).toMatchObject({ status: 'error', error: { code: 'model-unavailable' } })
|
||||
|
||||
api.onSelectModel = payload => Promise.resolve(ok({
|
||||
selected: { provider: payload.provider, model: payload.model },
|
||||
}))
|
||||
await session.retryModelOperation()
|
||||
expect(api.callsOf('session.selectModel').at(-1)).toEqual({
|
||||
sessionId: SID,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
})
|
||||
expect(session.getSnapshot().modelSelection).toMatchObject({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
status: 'ready',
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('drops stale directory responses after a newer selection operation wins', async () => {
|
||||
const { api, session } = makeSession()
|
||||
await session.open()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onModels']>>>()
|
||||
api.onModels = () => stale.promise
|
||||
const refreshing = session.refreshModels()
|
||||
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
const selected = session.getSnapshot().modelSelection
|
||||
|
||||
stale.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'stale' },
|
||||
groups: [{ id: 'deepseek', name: 'Old', models: [{ id: 'stale', name: 'Stale' }] }],
|
||||
failures: [],
|
||||
}))
|
||||
await refreshing
|
||||
expect(session.getSnapshot().modelSelection).toBe(selected)
|
||||
expect(session.getSnapshot().modelSelection.current)
|
||||
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
})
|
||||
|
||||
it('does not let an older history response overwrite a newer selected target', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => history.promise
|
||||
const opening = session.open()
|
||||
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
history.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await opening
|
||||
expect(session.getSnapshot().modelSelection.current)
|
||||
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
})
|
||||
|
||||
it('folds directory and selection transport failures without discarding usable state', async () => {
|
||||
const { api, session } = makeSession()
|
||||
await session.open()
|
||||
await session.refreshModels()
|
||||
const groups = session.getSnapshot().modelSelection.groups
|
||||
api.onModels = () => Promise.reject(new Error('directory transport down'))
|
||||
await session.refreshModels()
|
||||
expect(session.getSnapshot().modelSelection).toMatchObject({
|
||||
groups,
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'directory transport down' },
|
||||
})
|
||||
api.onModels = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [...groups],
|
||||
failures: [],
|
||||
}))
|
||||
await session.retryModelOperation()
|
||||
expect(session.getSnapshot().modelSelection.status).toBe('ready')
|
||||
|
||||
api.onSelectModel = () => Promise.reject(new Error('selection transport down'))
|
||||
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
expect(session.getSnapshot().modelSelection).toMatchObject({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups,
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'selection transport down' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reconciles a failed selection from authoritative history on reconnect', async () => {
|
||||
const { api, session } = makeSession()
|
||||
await session.open()
|
||||
api.onSelectModel = () => Promise.reject(new Error('lost response'))
|
||||
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
expect(session.getSnapshot().modelSelection.status).toBe('error')
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
}))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().modelSelection).toMatchObject({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
status: 'idle',
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('live event path', () => {
|
||||
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
|
||||
const { api, session } = makeSession()
|
||||
@@ -210,7 +377,11 @@ describe('paging', () => {
|
||||
api.onHistory = () => gate.promise
|
||||
const first = session.loadOlder()
|
||||
const second = session.loadOlder()
|
||||
gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
gate.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await Promise.all([first, second])
|
||||
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
|
||||
})
|
||||
@@ -482,7 +653,11 @@ describe('remaining branches', () => {
|
||||
const opening = session.open()
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
|
||||
const resynced = session.resync()
|
||||
stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone
|
||||
stale.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
})) // success, but its generation is gone
|
||||
await Promise.all([opening, resynced])
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
|
||||
})
|
||||
@@ -501,7 +676,11 @@ describe('remaining branches', () => {
|
||||
const opening = session.open() // triggers the second pull, which parks
|
||||
await vi.waitFor(() => { expect(call).toBe(2) })
|
||||
const resynced = session.resync()
|
||||
secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false }))
|
||||
secondPull.resolve(ok({
|
||||
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
}))
|
||||
await Promise.all([opening, resynced])
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
})
|
||||
@@ -515,7 +694,11 @@ describe('remaining branches', () => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
|
||||
const resynced = session.resync() // bumps the generation
|
||||
repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped
|
||||
repairPull.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
})) // repair result: stale, dropped
|
||||
await resynced
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
})
|
||||
@@ -559,6 +742,7 @@ describe('remaining branches', () => {
|
||||
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
|
||||
] as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
@@ -662,6 +846,7 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.modelSelection).toBe(before.modelSelection)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
|
||||
Reference in New Issue
Block a user