session-models.host.spec.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. /**
  2. * Session Controller model-directory and selection behavior: dynamic provider grouping,
  3. * provider-local catalog failures, logged-selection restoration without stale
  4. * catalog injection, advisory pass-through models, and the prompt-assembly
  5. * boundary for a running selection change.
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import { Context } from '@deepseek-ai/cordis'
  9. import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import AttachmentStore from '@deepseek-ai/dsh-attachment'
  12. import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  13. import type {
  14. GenerateOptions, LlmCallConfig, LlmCallConfigAdapterDefaults, LlmModelInfo,
  15. LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
  16. UserMessage,
  17. } from '@deepseek-ai/dsh-llm'
  18. import SessionStore from '@deepseek-ai/dsh-session'
  19. import type { SessionId } from '@deepseek-ai/dsh-session'
  20. import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
  21. import { ApiSessionAgentController } from '../src/agent.ts'
  22. import { buildModelCatalog } from '../src/catalog.ts'
  23. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  24. import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
  25. import { createSessionTestRemote } from './test-remote.ts'
  26. function request<P>(payload: P): P {
  27. return payload
  28. }
  29. let nextRequestId = 1
  30. function promptRequest(
  31. payload: Omit<SessionPromptRequest, 'requestId'>,
  32. ): SessionPromptRequest {
  33. return {
  34. ...payload,
  35. requestId: `models-${String(nextRequestId++)}` as SessionRequestId,
  36. }
  37. }
  38. class CatalogAdapter extends LlmAdapter {
  39. constructor(
  40. private readonly name: string,
  41. private readonly models: readonly LlmModelInfo[] | Error,
  42. private readonly reasoning?: LlmModelReasoningInfo,
  43. private readonly exactError?: Error,
  44. ) {
  45. super()
  46. }
  47. override providerInfo(provider: string): LlmProviderInfo {
  48. return { id: provider, name: this.name }
  49. }
  50. override listModels(): Promise<readonly LlmModelInfo[]> {
  51. return this.models instanceof Error
  52. ? Promise.reject(this.models)
  53. : Promise.resolve(this.models)
  54. }
  55. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  56. if (this.exactError !== undefined) return Promise.reject(this.exactError)
  57. return Promise.resolve({
  58. provider,
  59. id: model,
  60. name: model,
  61. ...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
  62. })
  63. }
  64. override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  65. // Catalog tests never enter provider streaming.
  66. }
  67. }
  68. const REASONING: LlmModelReasoningInfo = {
  69. efforts: [
  70. { id: ReasoningEffortId('off'), name: 'Off' },
  71. { id: ReasoningEffortId('high'), name: 'High' },
  72. { id: ReasoningEffortId('max'), name: 'Max' },
  73. ],
  74. defaultEffort: ReasoningEffortId('high'),
  75. }
  76. async function harness(logged?: {
  77. provider: string
  78. model: string
  79. reasoningEffort?: ReasoningEffortId
  80. adapterDefaults?: LlmCallConfigAdapterDefaults
  81. }): Promise<{
  82. ctx: Context
  83. agent: Agent
  84. sessionId: SessionId
  85. }> {
  86. const ctx = new Context()
  87. await ctx.plugin(SessionStore)
  88. await ctx.plugin(SystemPrompt, { persona: '' })
  89. await ctx.plugin(LlmRuntime)
  90. await ctx.plugin(AgentRegistry)
  91. ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
  92. { provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
  93. { provider: 'deepseek-official', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
  94. ], REASONING))
  95. ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
  96. ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
  97. { provider: 'metadata-broken', id: 'listed', name: 'Listed' },
  98. ], undefined, new Error('reasoning metadata offline')))
  99. ctx.llm.registerAdapter(['remote-rejected'], new CatalogAdapter(
  100. 'Remote Rejected',
  101. [],
  102. undefined,
  103. new TypertRemoteFailure({
  104. code: 'fixture-rejected',
  105. message: 'fixture rejected the selection',
  106. details: { provider: 'remote-rejected' },
  107. }),
  108. ))
  109. ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
  110. ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
  111. { provider: 'duplicate', id: 'same', name: 'Same' },
  112. { provider: 'duplicate', id: 'same', name: 'Same Again' },
  113. ]))
  114. const session = ctx.sessions.create()
  115. if (logged !== undefined) {
  116. const { adapterDefaults, ...config } = logged
  117. session.append('request/header', {
  118. header: { config, ...adapterDefaults === undefined ? {} : { adapterDefaults } },
  119. reason: 'initial',
  120. })
  121. }
  122. const agent = {
  123. id: session.id,
  124. session,
  125. status: 'running',
  126. ctx,
  127. inbox: { nextTurn: [], nextStep: [] },
  128. } as unknown as Agent
  129. ctx.agents.register(agent)
  130. return { ctx, agent, sessionId: session.id }
  131. }
  132. function expectValue<T>(result: { ok: true; value: T } | { ok: false }): T {
  133. if (!result.ok) throw new Error('expected successful response')
  134. return result.value
  135. }
  136. function registerTextOnly(ctx: Context): void {
  137. ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
  138. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  139. return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
  140. }
  141. }('Text Only', []))
  142. }
  143. /** Resolve the Client-visible next selection from durable state and the Host default. */
  144. function currentSelection(ctx: Context, sessionId: SessionId) {
  145. const session = ctx.sessions.get(sessionId)
  146. if (session === undefined) throw new Error('expected a live test Session')
  147. return ctx.sessionProjections.snapshot(session).values.modelSelection?.next
  148. ?? ctx.agentDefaultModel.currentSelection()
  149. }
  150. describe('Web session model selection', () => {
  151. it('validates an ordered image batch before persisting any member', async () => {
  152. const { ctx, agent, sessionId } = await harness()
  153. const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve())
  154. const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
  155. attachmentId: `att-${String(input.data[0])}`,
  156. mediaType: input.mediaType,
  157. bytes: input.data.byteLength,
  158. width: 1,
  159. height: 1,
  160. ...input.name === undefined ? {} : { name: input.name },
  161. }))
  162. const attachments = {
  163. imageLimits: {
  164. maxImageBytes: 4,
  165. maxImagesPerMessage: 2,
  166. maxMessageImageBytes: 4,
  167. maxImagePixels: 4,
  168. maxImageDimension: 2000,
  169. mediaTypes: ['image/png'],
  170. },
  171. validateImage,
  172. saveImage,
  173. }
  174. ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
  175. const followup = vi.fn()
  176. Object.assign(agent, { followup })
  177. const remote = createSessionTestRemote(ctx, {
  178. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  179. cwd: '/tmp',
  180. })
  181. const result = await remote.prompt(promptRequest({
  182. sessionId,
  183. mode: 'queue' as const,
  184. content: [
  185. { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' },
  186. { type: 'text' as const, text: 'compare' },
  187. { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' },
  188. ],
  189. }))
  190. expect(result.ok).toBe(true)
  191. expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
  192. expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
  193. expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
  194. {
  195. type: 'image',
  196. attachment: {
  197. attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png',
  198. },
  199. },
  200. { type: 'text', text: 'compare' },
  201. { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
  202. ])
  203. const denied = await remote.prompt(promptRequest({
  204. sessionId,
  205. mode: 'queue' as const,
  206. content: Array.from({ length: 3 }, () => ({
  207. type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==',
  208. })),
  209. }))
  210. expect(denied).toMatchObject({
  211. ok: false,
  212. error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
  213. })
  214. expect(saveImage).toHaveBeenCalledTimes(2)
  215. await ctx.fiber.dispose()
  216. })
  217. it('delivers an admitted image batch through steer with the same ordered content as queue', async () => {
  218. const { ctx, agent, sessionId } = await harness()
  219. const attachments = {
  220. imageLimits: {
  221. maxImageBytes: 4,
  222. maxImagesPerMessage: 2,
  223. maxMessageImageBytes: 4,
  224. maxImagePixels: 4,
  225. maxImageDimension: 2000,
  226. mediaTypes: ['image/png'],
  227. },
  228. validateImage: vi.fn(() => Promise.resolve()),
  229. saveImage: vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
  230. attachmentId: `att-${String(input.data[0])}`,
  231. mediaType: input.mediaType,
  232. bytes: input.data.byteLength,
  233. width: 1,
  234. height: 1,
  235. ...input.name === undefined ? {} : { name: input.name },
  236. })),
  237. }
  238. ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
  239. const steer = vi.fn()
  240. const followup = vi.fn()
  241. Object.assign(agent, { steer, followup })
  242. const remote = createSessionTestRemote(ctx, {
  243. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  244. cwd: '/tmp',
  245. })
  246. const result = await remote.prompt(promptRequest({
  247. sessionId,
  248. mode: 'steer' as const,
  249. content: [
  250. { type: 'text' as const, text: 'look at this' },
  251. { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'mid-turn.png' },
  252. ],
  253. }))
  254. expect(result.ok).toBe(true)
  255. expect(followup).not.toHaveBeenCalled()
  256. expect((steer.mock.calls[0]?.[0] as UserMessage).content).toEqual([
  257. { type: 'text', text: 'look at this' },
  258. {
  259. type: 'image',
  260. attachment: {
  261. attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'mid-turn.png',
  262. },
  263. },
  264. ])
  265. await ctx.fiber.dispose()
  266. })
  267. it('allows a text-only selection while durable or pending images remain available for later models', async () => {
  268. const { ctx, agent, sessionId } = await harness()
  269. registerTextOnly(ctx)
  270. const remote = createSessionTestRemote(ctx, {
  271. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  272. cwd: '/tmp',
  273. })
  274. const image = {
  275. type: 'image' as const,
  276. attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
  277. }
  278. const imageEvent = agent.session.append('user/message', {
  279. id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
  280. } as never, { surfaceOp: 'append' })
  281. expect(expectValue(await remote.selectModel(request({
  282. sessionId, provider: 'text-only', model: 'plain',
  283. }))).selected).toEqual({ provider: 'text-only', model: 'plain' })
  284. agent.session.append('user/message', {
  285. id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
  286. content: [{ type: 'text', text: 'image summarized' }],
  287. } as never, {
  288. surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq },
  289. sourceEventSeqs: [imageEvent.seq],
  290. })
  291. ;(agent.inbox.nextTurn as UserMessage[]).push({
  292. id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
  293. } as never)
  294. expect(expectValue(await remote.selectModel(request({
  295. sessionId, provider: 'text-only', model: 'plain',
  296. }))).selected).toEqual({ provider: 'text-only', model: 'plain' })
  297. await ctx.fiber.dispose()
  298. })
  299. it('authorizes attachment bytes only when the session event stream references the id', async () => {
  300. const { ctx, agent, sessionId } = await harness()
  301. const ref = {
  302. attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1,
  303. }
  304. const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
  305. ctx.provide('attachments', { readImage } as never)
  306. const remote = createSessionTestRemote(ctx, {
  307. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  308. cwd: '/tmp',
  309. })
  310. agent.session.append('agent/inbox/spliced', {
  311. target: 'next-turn',
  312. start: 0,
  313. inserted: [{
  314. id: 'queued-image', role: 'user', source: { kind: 'user' },
  315. content: [{ type: 'image', attachment: ref }],
  316. }],
  317. } as never)
  318. const allowed = await remote.attachment(request({
  319. sessionId, attachmentId: 'att-authorized' as never,
  320. }))
  321. expect(allowed).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } })
  322. const denied = await remote.attachment(request({
  323. sessionId, attachmentId: 'att-other' as never,
  324. }))
  325. expect(denied).toMatchObject({
  326. ok: false,
  327. error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
  328. })
  329. expect(readImage).toHaveBeenCalledOnce()
  330. await ctx.fiber.dispose()
  331. })
  332. it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => {
  333. const { ctx, sessionId } = await harness({
  334. provider: 'deepseek-official',
  335. model: 'private-preview',
  336. reasoningEffort: ReasoningEffortId('max'),
  337. })
  338. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
  339. const catalog = expectValue(await remote.modelCatalog())
  340. expect(currentSelection(ctx, sessionId)).toEqual({
  341. provider: 'deepseek-official',
  342. model: 'private-preview',
  343. reasoningEffort: 'max',
  344. })
  345. expect(catalog.groups).toEqual([{
  346. id: 'deepseek-official',
  347. name: 'DeepSeek',
  348. models: [
  349. { id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
  350. {
  351. id: 'deepseek-reasoner',
  352. name: 'DeepSeek Reasoner',
  353. description: 'Reasoning model',
  354. reasoning: REASONING,
  355. },
  356. ],
  357. }])
  358. expect(catalog.failures).toEqual([
  359. { id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
  360. { id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
  361. {
  362. id: 'duplicate',
  363. name: 'Duplicate Provider',
  364. message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
  365. },
  366. ])
  367. await ctx.fiber.dispose()
  368. })
  369. it('preserves optional catalog metadata and string provider failures', async () => {
  370. const { ctx } = await harness()
  371. ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [
  372. { provider: 'plain', id: 'plain-model', name: 'Plain Model' },
  373. ]))
  374. ctx.llm.registerAdapter(['described-reasoning'], new CatalogAdapter('Described Reasoning', [
  375. { provider: 'described-reasoning', id: 'reasoning-model', name: 'Reasoning Model' },
  376. ], {
  377. efforts: [{ id: ReasoningEffortId('high'), name: 'High', description: 'More thinking' }],
  378. }))
  379. ctx.llm.registerAdapter(['string-failure'], new class extends CatalogAdapter {
  380. override listModels(): Promise<readonly LlmModelInfo[]> {
  381. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario.
  382. return Promise.reject('string catalog failure')
  383. }
  384. }('String Failure', []))
  385. createSessionTestRemote(ctx, {
  386. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  387. cwd: '/tmp',
  388. })
  389. const catalog = await buildModelCatalog(ctx)
  390. expect(catalog.groups).toEqual(expect.arrayContaining([
  391. { id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] },
  392. {
  393. id: 'described-reasoning',
  394. name: 'Described Reasoning',
  395. models: [{
  396. id: 'reasoning-model',
  397. name: 'Reasoning Model',
  398. reasoning: {
  399. efforts: [{ id: 'high', name: 'High', description: 'More thinking' }],
  400. },
  401. }],
  402. },
  403. ]))
  404. expect(catalog.failures).toContainEqual({
  405. id: 'string-failure', name: 'String Failure', message: 'string catalog failure',
  406. })
  407. await ctx.fiber.dispose()
  408. })
  409. it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
  410. const { ctx, agent, sessionId } = await harness()
  411. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
  412. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  413. const signal = new AbortController().signal
  414. expect(currentSelection(ctx, sessionId))
  415. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  416. const selected = expectValue(await remote.selectModel(request({
  417. sessionId,
  418. provider: 'deepseek-official',
  419. model: 'private-preview',
  420. reasoningEffort: 'max',
  421. })))
  422. expect(selected.selected).toEqual({
  423. provider: 'deepseek-official',
  424. model: 'private-preview',
  425. reasoningEffort: 'max',
  426. })
  427. await expect(agentEvents(ctx, agent).waterfall(
  428. 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
  429. )).resolves.toEqual(seed)
  430. expect((await ctx.systemPrompt.assemble()).variables)
  431. .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
  432. await expect(agentEvents(ctx, agent).waterfall(
  433. 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
  434. )).resolves.toMatchObject({
  435. provider: 'deepseek-official',
  436. model: 'private-preview',
  437. reasoningEffort: 'max',
  438. })
  439. const unsupported = await remote.selectModel(request({
  440. sessionId,
  441. provider: 'deepseek-official',
  442. model: 'private-preview',
  443. reasoningEffort: 'medium',
  444. }))
  445. expect(unsupported).toMatchObject({
  446. ok: false,
  447. error: {
  448. code: 'model-unavailable',
  449. message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"',
  450. },
  451. })
  452. const rejected = await remote.selectModel(request({
  453. sessionId,
  454. provider: 'missing',
  455. model: 'model',
  456. }))
  457. expect(rejected).toEqual({
  458. ok: false,
  459. error: {
  460. code: 'model-unavailable',
  461. message: 'no adapter registered for provider "missing"',
  462. details: { provider: 'missing', model: 'model' },
  463. },
  464. })
  465. expect(await remote.selectModel(request({
  466. sessionId,
  467. provider: 'remote-rejected',
  468. model: 'model',
  469. }))).toEqual({
  470. ok: false,
  471. error: {
  472. code: 'fixture-rejected',
  473. message: 'fixture rejected the selection',
  474. details: { provider: 'remote-rejected' },
  475. },
  476. })
  477. expect(currentSelection(ctx, sessionId))
  478. .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
  479. await ctx.fiber.dispose()
  480. })
  481. it('reads the Agent default live for a session whose log names no selection', async () => {
  482. const { ctx, sessionId } = await harness()
  483. let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
  484. createSessionTestRemote(ctx, {
  485. defaultModelSelection: () => stored,
  486. cwd: '/tmp',
  487. })
  488. expect(currentSelection(ctx, sessionId))
  489. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  490. // The default moving after the session exists still reaches it: New
  491. // Session reuses a blank session rather than minting another, so a seed
  492. // captured at creation would show the superseded model there.
  493. stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
  494. expect(currentSelection(ctx, sessionId))
  495. .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
  496. await ctx.fiber.dispose()
  497. })
  498. it('keeps a session on its logged selection when the Agent default differs', async () => {
  499. const { ctx, sessionId } = await harness({
  500. provider: 'deepseek-official',
  501. model: 'deepseek-chat',
  502. })
  503. let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
  504. createSessionTestRemote(ctx, {
  505. defaultModelSelection: () => stored,
  506. cwd: '/tmp',
  507. })
  508. stored = { provider: 'duplicate', model: 'same' }
  509. expect(currentSelection(ctx, sessionId))
  510. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  511. await ctx.fiber.dispose()
  512. })
  513. it('does not reinterpret an adapter-owned reasoning default as an explicit Web selection', async () => {
  514. const { ctx, agent } = await harness({
  515. provider: 'deepseek-official',
  516. model: 'deepseek-chat',
  517. reasoningEffort: ReasoningEffortId('high'),
  518. adapterDefaults: { reasoningEffort: true },
  519. })
  520. createSessionTestRemote(ctx, {
  521. defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }),
  522. cwd: '/tmp',
  523. })
  524. expect(new ApiSessionAgentController(ctx).selectionFor(agent).current)
  525. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  526. await ctx.fiber.dispose()
  527. })
  528. it('saves an accepted selection as the default and survives a storage failure', async () => {
  529. const { ctx, sessionId } = await harness()
  530. const saved: unknown[] = []
  531. let reject = false
  532. const remote = createSessionTestRemote(ctx, {
  533. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  534. saveDefaultModelSelection: (selection) => {
  535. saved.push(selection)
  536. return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
  537. },
  538. cwd: '/tmp',
  539. })
  540. expectValue(await remote.selectModel(request({
  541. sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max',
  542. })))
  543. expect(saved).toEqual([
  544. { provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' },
  545. ])
  546. // A refused selection never becomes anyone's default.
  547. await remote.selectModel(request({ sessionId, provider: 'missing', model: 'model' }))
  548. expect(saved).toHaveLength(1)
  549. // Storage failing is not the selection failing: the switch already applies
  550. // to this session, so the call still succeeds.
  551. reject = true
  552. const stillAccepted = expectValue(await remote.selectModel(request({
  553. sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
  554. })))
  555. expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
  556. expect(currentSelection(ctx, sessionId))
  557. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
  558. await ctx.fiber.dispose()
  559. })
  560. it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
  561. const { ctx, sessionId } = await harness()
  562. const remote = createSessionTestRemote(ctx, {
  563. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
  564. cwd: '/tmp',
  565. })
  566. // The client disabling its input is an affordance; this method stays
  567. // callable, so the refusal has to live here.
  568. const refused = await remote.prompt(promptRequest({
  569. sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
  570. }))
  571. expect(refused).toMatchObject({
  572. ok: false,
  573. error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
  574. })
  575. const unavailableCatalog = await buildModelCatalog(ctx)
  576. expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false)
  577. // An advisory-unlisted model on a live route is NOT this: the route
  578. // serves it, so the prompt goes through and nothing blocks.
  579. expectValue(await remote.selectModel(request({
  580. sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
  581. })))
  582. const catalog = await buildModelCatalog(ctx)
  583. expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true)
  584. expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
  585. .not.toContain('unlisted-but-served')
  586. await ctx.fiber.dispose()
  587. })
  588. it('serves a session and its catalog when the stored default names a route that is gone', async () => {
  589. const { ctx, sessionId } = await harness()
  590. createSessionTestRemote(ctx, {
  591. // What a Models-page removal leaves behind: the settings document still
  592. // names the route the user last picked, and nothing serves it.
  593. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
  594. cwd: '/tmp',
  595. })
  596. const catalog = await buildModelCatalog(ctx)
  597. // Passed through rather than repaired: matching no group is precisely what
  598. // makes the composer seat prompt for a selection instead of naming a model
  599. // the deployment cannot reach.
  600. expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
  601. expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
  602. .not.toContain('deleted-gateway/deleted-model')
  603. await ctx.fiber.dispose()
  604. })
  605. it('maps image admission failures and accepts image-capable selections', async () => {
  606. const { ctx, agent, sessionId } = await harness()
  607. registerTextOnly(ctx)
  608. ctx.llm.registerAdapter(['image-capable'], new class extends CatalogAdapter {
  609. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  610. return Promise.resolve({
  611. provider, id: model, name: model, inputModalities: ['text', 'image'],
  612. })
  613. }
  614. }('Image Capable', []))
  615. ctx.llm.registerAdapter(['string-error'], new class extends CatalogAdapter {
  616. override resolveModel(): Promise<LlmResolvedModelInfo> {
  617. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario.
  618. return Promise.reject('string selection failure')
  619. }
  620. }('String Error', []))
  621. let saveMode: 'success' | 'error' | 'remote' = 'success'
  622. const savedRef = {
  623. attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1,
  624. }
  625. ctx.provide('attachments', {
  626. saveImages: () => {
  627. if (saveMode === 'error') return Promise.reject(new Error('image store offline'))
  628. if (saveMode === 'remote') {
  629. return Promise.reject(new TypertRemoteFailure({
  630. code: 'fixture-rejected', message: 'fixture rejected', details: {},
  631. }))
  632. }
  633. return Promise.resolve([savedRef])
  634. },
  635. } as never)
  636. const followup = vi.fn()
  637. Object.assign(agent, { followup })
  638. const remote = createSessionTestRemote(ctx, {
  639. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  640. cwd: '/tmp',
  641. })
  642. const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==' }
  643. expectValue(await remote.selectModel(request({
  644. sessionId, provider: 'text-only', model: 'plain',
  645. })))
  646. expect(await remote.prompt(promptRequest({
  647. sessionId, mode: 'queue', content: [image],
  648. }))).toMatchObject({
  649. ok: false,
  650. error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
  651. })
  652. expectValue(await remote.selectModel(request({
  653. sessionId, provider: 'image-capable', model: 'vision',
  654. })))
  655. expect(await remote.prompt(promptRequest({
  656. sessionId, mode: 'queue', content: [{ ...image, data: '' }],
  657. }))).toMatchObject({
  658. ok: false,
  659. error: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } },
  660. })
  661. saveMode = 'error'
  662. expect(await remote.prompt(promptRequest({
  663. sessionId, mode: 'queue', content: [image],
  664. }))).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
  665. saveMode = 'remote'
  666. expect(await remote.prompt(promptRequest({
  667. sessionId, mode: 'queue', content: [image],
  668. }))).toMatchObject({ ok: false, error: { code: 'fixture-rejected' } })
  669. saveMode = 'success'
  670. expectValue(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image] })))
  671. expect(followup).toHaveBeenCalledOnce()
  672. ;(agent.inbox.nextTurn as UserMessage[]).push({
  673. id: 'pending-image', role: 'user', source: { kind: 'user' },
  674. content: [{ type: 'image', attachment: savedRef }],
  675. } as never)
  676. expectValue(await remote.selectModel(request({
  677. sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
  678. })))
  679. expectValue(await remote.selectModel(request({
  680. sessionId, provider: 'image-capable', model: 'vision',
  681. })))
  682. expect(await remote.selectModel(request({
  683. sessionId, provider: 'metadata-broken', model: 'broken',
  684. }))).toMatchObject({
  685. ok: false, error: { code: 'model-unavailable', message: 'reasoning metadata offline' },
  686. })
  687. expect(await remote.selectModel(request({
  688. sessionId, provider: 'string-error', model: 'broken',
  689. }))).toMatchObject({
  690. ok: false,
  691. error: { code: 'model-unavailable', message: 'string selection failure' },
  692. })
  693. await ctx.fiber.dispose()
  694. })
  695. })