Merge remote-tracking branch 'origin/master' into worktree-guifork

This commit is contained in:
imccyu
2026-07-28 10:23:48 +08:00
710 changed files with 24719 additions and 6603 deletions

View File

@@ -0,0 +1,233 @@
/**
* 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()
})
})

View File

@@ -30,7 +30,19 @@ function scriptedApi(overrides: {
sessions: {
list: r => ok(r, { items: [] }),
create: r => ok(r, { sessionId: sid('s-new') }),
history: r => ok(r, { events: [], hasMore: false }),
history: r => ok(r, {
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}),
models: r => ok(r, {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [],
failures: [],
}),
selectModel: r => ok(r, {
selected: { provider: r.payload.provider, model: r.payload.model },
}),
prompt: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,

View File

@@ -37,6 +37,36 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } },
}
},
async models(request) {
return {
rpcId: request.rpcId,
result: {
ok: true,
value: {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [],
failures: [],
},
},
}
},
async selectModel(request) {
return {
rpcId: request.rpcId,
result: {
ok: true,
value: {
selected: {
provider: request.payload.provider,
model: request.payload.model,
...request.payload.reasoningEffort === undefined
? {}
: { reasoningEffort: request.payload.reasoningEffort },
},
},
},
}
},
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
@@ -148,6 +178,23 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
it('covers create/prompt/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
const selected = await c.sessions.selectModel({
sessionId: 's' as never,
provider: 'deepseek',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
})
expect(selected.result).toMatchObject({
ok: true,
value: {
selected: {
provider: 'deepseek',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
},
},
})
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)

View File

@@ -8,8 +8,9 @@ import { z } from 'zod'
import {
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
sessionPromptValueSchema, sessionSummarySchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import {
@@ -56,6 +57,11 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid')
expect(rpcErrorSchema.parse({
code: 'model-unavailable',
message: 'm',
details: { provider: 'p', model: 'm' },
}).code).toBe('model-unavailable')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
@@ -129,7 +135,62 @@ describe('sessions domain schemas', () => {
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
expect(sessionHistoryValueSchema.parse({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}).hasMore).toBe(false)
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
description: 'fast',
unlisted: true,
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'max', name: 'Max', description: 'Largest budget' },
],
defaultEffort: 'off',
},
}],
}],
failures: [{ id: 'broken', name: 'Broken', message: 'offline' }],
}).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash')
expect(sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
}).reasoningEffort).toBe('max')
expect(sessionSelectModelValueSchema.parse({
selected: { provider: 'deepseek', model: 'deepseek-v4-pro', reasoningEffort: 'max' },
}).selected.reasoningEffort).toBe('max')
expect(() => sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: '',
model: 'm',
})).toThrow()
expect(() => sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: 'deepseek',
model: 'm',
reasoningEffort: '',
})).toThrow()
expect(() => sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'm' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }],
}],
failures: [],
})).toThrow()
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
expect(prompt.mode).toBe('queue')
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()