|
|
@@ -0,0 +1,162 @@
|
|
|
+/**
|
|
|
+ * 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 } from '@deepseek-ai/dsh-llm'
|
|
|
+import type {
|
|
|
+ GenerateOptions, LlmCallConfig, LlmModelInfo, LlmProviderInfo, 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,
|
|
|
+ ) {
|
|
|
+ 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 async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
|
+ // Catalog tests never enter provider streaming.
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function harness(logged?: { provider: string; model: string }): 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' },
|
|
|
+ ]))
|
|
|
+ ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog 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' })
|
|
|
+ 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' })
|
|
|
+ expect(catalog.groups).toEqual([{
|
|
|
+ id: 'deepseek',
|
|
|
+ name: 'DeepSeek',
|
|
|
+ models: [
|
|
|
+ { id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
|
|
+ { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
|
|
|
+ { id: 'private-preview', name: 'private-preview', unlisted: true },
|
|
|
+ ],
|
|
|
+ }])
|
|
|
+ expect(catalog.failures).toEqual([
|
|
|
+ { id: 'broken', name: 'Broken Provider', message: 'catalog 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',
|
|
|
+ })))
|
|
|
+ expect(selected.selected).toEqual({ provider: 'deepseek', model: 'private-preview' })
|
|
|
+ await expect(agentEvents(ctx, agent).waterfall(
|
|
|
+ 'agent/request', 1, 0, seed, 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, seed, signal, () => Promise.resolve(seed),
|
|
|
+ )).resolves.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
|
|
|
+
|
|
|
+ const rejected = await api.sessions.selectModel(request({
|
|
|
+ sessionId,
|
|
|
+ provider: 'missing',
|
|
|
+ model: 'model',
|
|
|
+ }))
|
|
|
+ expect(rejected.result).toEqual({
|
|
|
+ ok: false,
|
|
|
+ error: {
|
|
|
+ code: 'model-unavailable',
|
|
|
+ message: 'provider "missing" is not registered',
|
|
|
+ details: { provider: 'missing', model: 'model' },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
|
|
+ .toEqual({ provider: 'deepseek', model: 'private-preview' })
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+})
|