default-model.e2e.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. // Web e2e scenario: switching models in the composer is how this deployment's
  2. // default is chosen. The gesture writes the `api-gateway` settings section, a
  3. // session created afterwards starts from it, and a session that already logged
  4. // a route keeps deriving from its own log — the tier order the gateway
  5. // resolves on every read.
  6. // Zero model calls: the switch is settings/llm-domain traffic only, so there
  7. // is no fixture and a stray stream would fail loud on the open seam. A second
  8. // route is declared host-side (not through the UI, which has its own
  9. // scenario) purely so the picker has somewhere to switch to: the keyless
  10. // replay catalog publishes a single model.
  11. import { readFile } from 'node:fs/promises'
  12. import { join } from 'node:path'
  13. import type { Browser, Page } from 'playwright'
  14. import { chromium } from 'playwright'
  15. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  16. import { SessionId } from '@deepseek-ai/dsh-session'
  17. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  18. import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
  19. import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
  20. /** The route declared for this scenario, and the model the switch lands on. */
  21. const ROUTE = 'acme-gateway'
  22. const MODEL = 'acme-large'
  23. describe('web e2e: the composer model switch is the default for later sessions', () => {
  24. let scaffold: WebScaffold
  25. let browser: Browser
  26. let page: Page
  27. let tripwire: ReturnType<typeof watchConsole>
  28. /** Create one session and its agent through the same wire face the browser uses. */
  29. const createSession = async (sessionId: string): Promise<string> => {
  30. const response = await scaffold.ctx.apiProxy.sessions.create({
  31. rpcId: `default-model-create-${sessionId}` as never,
  32. payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd },
  33. })
  34. if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`)
  35. return response.result.value.sessionId
  36. }
  37. /** The route the gateway reports for one session, through the real wire face. */
  38. const currentOf = async (sessionId: string): Promise<unknown> => {
  39. const response = await scaffold.ctx.apiProxy.sessions.models({
  40. rpcId: `default-model-${sessionId}` as never,
  41. payload: { sessionId: SessionId(sessionId) },
  42. })
  43. if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`)
  44. return response.result.value.current
  45. }
  46. beforeAll(async () => {
  47. scaffold = await launchWebScaffold({})
  48. // A second route so the picker has two models. Declared through the
  49. // settings seam rather than the Models page: this scenario is about the
  50. // composer, and the declaring flow is covered by models-settings.e2e.
  51. await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
  52. providers: {
  53. [ROUTE]: {
  54. displayName: 'Acme Gateway',
  55. api: 'openai-completions',
  56. baseURL: 'https://gateway.acme.example/v1',
  57. models: [{ id: MODEL, name: 'Acme Large' }],
  58. },
  59. },
  60. })
  61. browser = await chromium.launch()
  62. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  63. tripwire = watchConsole(page)
  64. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  65. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  66. // The composer's seats only exist once a workspace is connected: without
  67. // one the input is the locked placeholder and no session scope is open.
  68. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
  69. }, 120_000)
  70. afterAll(async () => {
  71. await browser?.close()
  72. await scaffold?.close()
  73. })
  74. it('writes the switched model as the default and leaves a logged session alone', async () => {
  75. onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
  76. // A session that has already run a turn, spelled as the fact a turn
  77. // leaves behind: its own logged route.
  78. const loggedId = await createSession('default-model-logged')
  79. scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
  80. header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } },
  81. reason: 'initial',
  82. })
  83. const trigger = page.getByRole('button', { name: /^选择模型/ })
  84. await trigger.waitFor({ timeout: 15_000 })
  85. await trigger.click()
  86. await page.getByRole('menuitem', { name: /模型/ }).click()
  87. await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
  88. // The switch is what sets the default: the gateway's own settings section
  89. // now names it, beside the provider profiles the Models page writes.
  90. await expect.poll(
  91. async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
  92. { timeout: 10_000 },
  93. ).toContain('api-gateway:')
  94. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  95. expect(document).toContain(`provider: ${ROUTE}`)
  96. expect(document).toContain(`model: ${MODEL}`)
  97. // A session created after the switch starts from it...
  98. expect(await currentOf(await createSession('default-model-after')))
  99. .toEqual({ provider: ROUTE, model: MODEL })
  100. // ...while the one holding a logged route keeps deriving from its log.
  101. expect(await currentOf(loggedId))
  102. .toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
  103. expect(tripwire.pageErrors).toEqual([])
  104. }, 60_000)
  105. })