- resolveLlmRoute: reuse the yml pi-ai row for providers it already routes (DUPLICATE_ADAPTER boot failure) and detect an unset model by origin, not by comparison against one deployment default; covered by a new spec. - LlmService.resolveModelInfoFor preserves (and validates) modality metadata, arming the host image preflight for exact-route resolution. - session.selectModel refuses a text-only target once the session log carries an image on any replayed route; an accepted switch would strand every later turn with no in-product recovery. - The composer no longer gates image intake on the handshake activeModel snapshot (wrong authority for a per-session decision); the host preflight plus the error strip own capability, deployment limits stay client-side. - InputHub shell teardown releases the scope's draft images (File objects and object URLs leaked for the page lifetime). - session.prompt image parts carry optional alt into the durable block; ImageBlock documents assistant-side rendering as forward compatibility. - Assembled built-client lane apps/web/tests/image-display.snapshot.ts pins the history galleries over the authorized attachment route, the lightbox, and the composer paste rail; the attachment rail is an accessible group. - Docs: validateImage on the seam page, fixture byte metadata matches its PNG, and the Agent Note claims now match the shipped coverage.
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
/**
|
|
* Web session model-directory and selection behavior: dynamic provider grouping,
|
|
* provider-local catalog failures, logged-target restoration, advisory unlisted
|
|
* models, and the prompt-assembly boundary for a running selection change.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
import type {
|
|
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
|
|
LlmResolvedModelInfo, StreamChunk,
|
|
} from '@deepseek-ai/dsh-llm'
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
|
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
|
import { createApiProxy } from '../src/api-proxy.ts'
|
|
|
|
let nextRpc = 1
|
|
function request<P>(payload: P): RpcRequest<P> {
|
|
return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
|
|
}
|
|
|
|
class CatalogAdapter extends LlmAdapter {
|
|
constructor(
|
|
private readonly name: string,
|
|
private readonly models: readonly LlmModelInfo[] | Error,
|
|
private readonly reasoning?: LlmModelReasoningInfo,
|
|
private readonly exactError?: Error,
|
|
) {
|
|
super()
|
|
}
|
|
|
|
override providerInfo(provider: string): LlmProviderInfo {
|
|
return { id: provider, name: this.name }
|
|
}
|
|
|
|
override listModels(): Promise<readonly LlmModelInfo[]> {
|
|
return this.models instanceof Error
|
|
? Promise.reject(this.models)
|
|
: Promise.resolve(this.models)
|
|
}
|
|
|
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
if (this.exactError !== undefined) return Promise.reject(this.exactError)
|
|
return Promise.resolve({
|
|
provider,
|
|
id: model,
|
|
name: model,
|
|
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
|
|
})
|
|
}
|
|
|
|
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
// Catalog tests never enter provider streaming.
|
|
}
|
|
}
|
|
|
|
const REASONING: LlmModelReasoningInfo = {
|
|
efforts: [
|
|
{ id: ReasoningEffortId('off'), name: 'Off' },
|
|
{ id: ReasoningEffortId('high'), name: 'High' },
|
|
{ id: ReasoningEffortId('max'), name: 'Max' },
|
|
],
|
|
defaultEffort: ReasoningEffortId('high'),
|
|
}
|
|
|
|
async function harness(logged?: {
|
|
provider: string
|
|
model: string
|
|
reasoningEffort?: ReasoningEffortId
|
|
}): Promise<{
|
|
ctx: Context
|
|
agent: Agent
|
|
sessionId: SessionId
|
|
}> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(UserInteractionService)
|
|
await ctx.plugin(AgentRegistry)
|
|
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
|
|
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
|
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
|
|
], REASONING))
|
|
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
|
|
ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
|
|
{ provider: 'metadata-broken', id: 'listed', name: 'Listed' },
|
|
], undefined, new Error('reasoning metadata offline')))
|
|
ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
|
|
ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
|
|
{ provider: 'duplicate', id: 'same', name: 'Same' },
|
|
{ provider: 'duplicate', id: 'same', name: 'Same Again' },
|
|
]))
|
|
const session = ctx.sessions.create()
|
|
if (logged !== undefined) {
|
|
session.append('request/header', { header: { config: logged }, reason: 'initial' })
|
|
}
|
|
const agent = {
|
|
id: session.id,
|
|
session,
|
|
status: 'running',
|
|
ctx,
|
|
} as Agent
|
|
ctx.agents.register(agent)
|
|
return { ctx, agent, sessionId: session.id }
|
|
}
|
|
|
|
function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
|
|
if (!response.result.ok) throw new Error('expected successful response')
|
|
return response.result.value
|
|
}
|
|
|
|
describe('Web session model selection', () => {
|
|
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
|
|
const { ctx, sessionId } = await harness({
|
|
provider: 'deepseek',
|
|
model: 'private-preview',
|
|
reasoningEffort: ReasoningEffortId('max'),
|
|
})
|
|
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
|
|
|
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
|
expect(catalog.current).toEqual({
|
|
provider: 'deepseek',
|
|
model: 'private-preview',
|
|
reasoningEffort: 'max',
|
|
})
|
|
expect(catalog.groups).toEqual([{
|
|
id: 'deepseek',
|
|
name: 'DeepSeek',
|
|
models: [
|
|
{ id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
|
|
{
|
|
id: 'deepseek-reasoner',
|
|
name: 'DeepSeek Reasoner',
|
|
description: 'Reasoning model',
|
|
reasoning: REASONING,
|
|
},
|
|
{
|
|
id: 'private-preview',
|
|
name: 'private-preview',
|
|
unlisted: true,
|
|
reasoning: REASONING,
|
|
},
|
|
],
|
|
}])
|
|
expect(catalog.failures).toEqual([
|
|
{ id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
|
|
{ id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
|
|
{
|
|
id: 'duplicate',
|
|
name: 'Duplicate Provider',
|
|
message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
|
|
},
|
|
])
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
|
|
const { ctx, agent, sessionId } = await harness()
|
|
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
|
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
|
const signal = new AbortController().signal
|
|
|
|
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
|
.toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
|
|
expect((await ctx.systemPrompt.assemble()).variables)
|
|
.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
|
|
|
const selected = expectValue(await api.sessions.selectModel(request({
|
|
sessionId,
|
|
provider: 'deepseek',
|
|
model: 'private-preview',
|
|
reasoningEffort: 'max',
|
|
})))
|
|
expect(selected.selected).toEqual({
|
|
provider: 'deepseek',
|
|
model: 'private-preview',
|
|
reasoningEffort: 'max',
|
|
})
|
|
await expect(agentEvents(ctx, agent).waterfall(
|
|
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
|
|
)).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
|
|
|
expect((await ctx.systemPrompt.assemble()).variables)
|
|
.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
|
|
await expect(agentEvents(ctx, agent).waterfall(
|
|
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
|
|
)).resolves.toMatchObject({
|
|
provider: 'deepseek',
|
|
model: 'private-preview',
|
|
reasoningEffort: 'max',
|
|
})
|
|
|
|
const unsupported = await api.sessions.selectModel(request({
|
|
sessionId,
|
|
provider: 'deepseek',
|
|
model: 'private-preview',
|
|
reasoningEffort: 'medium',
|
|
}))
|
|
expect(unsupported.result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: 'model-unavailable',
|
|
message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"',
|
|
},
|
|
})
|
|
|
|
const rejected = await api.sessions.selectModel(request({
|
|
sessionId,
|
|
provider: 'missing',
|
|
model: 'model',
|
|
}))
|
|
expect(rejected.result).toEqual({
|
|
ok: false,
|
|
error: {
|
|
code: 'model-unavailable',
|
|
message: 'no adapter registered for provider "missing"',
|
|
details: { provider: 'missing', model: 'model' },
|
|
},
|
|
})
|
|
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
|
.toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('refuses a text-only selection once the session log carries an image', async () => {
|
|
const { ctx, sessionId, agent } = await harness()
|
|
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
|
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
|
|
}
|
|
}('Text Only', []))
|
|
ctx.llm.registerAdapter(['vision'], new class extends CatalogAdapter {
|
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] })
|
|
}
|
|
}('Vision', []))
|
|
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
|
|
|
// Before any image lands, a text-only selection is legitimate.
|
|
expect(expectValue(await api.sessions.selectModel(request({
|
|
sessionId, provider: 'text-only', model: 'plain',
|
|
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
|
|
|
|
agent.session.append('user/message', {
|
|
id: 'msg-image', role: 'user', source: { kind: 'user' },
|
|
content: [{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
|
|
} as never, { surfaceOp: 'append' })
|
|
|
|
// The log is immutable: a text-only route would fail every later turn.
|
|
const stranded = await api.sessions.selectModel(request({
|
|
sessionId, provider: 'text-only', model: 'plain',
|
|
}))
|
|
expect(stranded.result).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'model-unavailable', message: expect.stringMatching(/history already contains images/) as unknown },
|
|
})
|
|
|
|
// Image-capable and modality-unknown routes stay selectable.
|
|
expect(expectValue(await api.sessions.selectModel(request({
|
|
sessionId, provider: 'vision', model: 'sees',
|
|
}))).selected).toEqual({ provider: 'vision', model: 'sees' })
|
|
expect(expectValue(await api.sessions.selectModel(request({
|
|
sessionId, provider: 'deepseek', model: 'deepseek-chat',
|
|
}))).selected).toEqual({ provider: 'deepseek', model: 'deepseek-chat', reasoningEffort: 'high' })
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('detects images on every replayed route: wrapped messages, streamed blocks, nested tool results', async () => {
|
|
const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }
|
|
const cases: { label: string; append: (agent: Agent) => void }[] = [
|
|
{
|
|
label: 'steering message wrapper',
|
|
append: (agent) => {
|
|
agent.session.append('steering/message', {
|
|
turn: 1, message: { id: 'st-1', role: 'user', source: { kind: 'user' }, content: [image] },
|
|
} as never, { surfaceOp: 'append' })
|
|
},
|
|
},
|
|
{
|
|
label: 'streamed assistant block',
|
|
append: (agent) => {
|
|
agent.session.append('assistant/chunk', {
|
|
turn: 1, step: 0, chunk: { type: 'block-end', index: 0, block: image },
|
|
} as never)
|
|
},
|
|
},
|
|
{
|
|
label: 'nested tool-result content',
|
|
append: (agent) => {
|
|
agent.session.append('user/message', {
|
|
id: 'tr-1', role: 'user', source: { kind: 'tool', callId: 'c1' },
|
|
content: [{ type: 'tool-result', toolCallId: 'c1', content: [image], isError: false }],
|
|
} as never, { surfaceOp: 'append' })
|
|
},
|
|
},
|
|
]
|
|
for (const { label, append } of cases) {
|
|
const { ctx, sessionId, agent } = await harness()
|
|
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
|
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
|
|
}
|
|
}('Text Only', []))
|
|
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
|
append(agent)
|
|
const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
|
|
expect(stranded.result.ok, label).toBe(false)
|
|
await ctx.fiber.dispose()
|
|
}
|
|
})
|
|
})
|