default-model.e2e.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. // Web e2e scenario: switching models in the composer is how this deployment's
  2. // default is chosen. The gesture writes the shared `agent-default-model` 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 because the adapter registry is empty. Both
  8. // routes are declared host-side (not through the UI, which has its own
  9. // scenario) through the pi-ai adapter the shipped tree already mounts: a
  10. // fixture-less scaffold registers no adapter at all, so the routes the
  11. // picker offers — and the one the composer must start on — have to come from
  12. // somewhere, and settings profiles are the product's own way to add them.
  13. import { readFile } from 'node:fs/promises'
  14. import { fileURLToPath } from 'node:url'
  15. import { join } from 'node:path'
  16. import type { Browser, Page } from 'playwright'
  17. import { chromium } from 'playwright'
  18. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  19. import { SessionId } from '@deepseek-ai/dsh-session'
  20. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  21. import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
  22. import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
  23. /** Points the shipped shared Agent default at this scenario's own route. */
  24. const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
  25. /** The route this scenario starts on, patched over the shipped default. */
  26. const START_ROUTE = 'origin-gateway'
  27. const START_MODEL = 'origin-large'
  28. /** The route the switch lands on, which then becomes the saved default. */
  29. const ROUTE = 'acme-gateway'
  30. const MODEL = 'acme-large'
  31. describe('web e2e: the composer model switch is the default for later sessions', () => {
  32. let scaffold: WebScaffold
  33. let browser: Browser
  34. let page: Page
  35. let tripwire: ReturnType<typeof watchConsole>
  36. /** Create one session and its agent through the same wire face the browser uses. */
  37. const createSession = async (sessionId: string): Promise<string> => {
  38. const response = await scaffold.ctx.sessionController.create({
  39. sessionId: SessionId(sessionId),
  40. cwd: scaffold.workspaceCwd,
  41. })
  42. return response.sessionId
  43. }
  44. /** The route the Client derives from the Session projection and Host default. */
  45. const currentOf = (sessionId: string): Promise<unknown> => {
  46. const session = scaffold.ctx.sessions.get(SessionId(sessionId))
  47. if (session === undefined) throw new Error(`session "${sessionId}" is not live`)
  48. return Promise.resolve(
  49. scaffold.ctx.sessionProjections.snapshot(session).values.modelSelection?.next
  50. ?? scaffold.ctx.agentDefaultModel.currentSelection(),
  51. )
  52. }
  53. beforeAll(async () => {
  54. scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
  55. // Two routes so the picker has somewhere to start and somewhere to go.
  56. // Declared through the settings seam rather than the Models page: this
  57. // scenario is about the composer, and the declaring flow is covered by
  58. // models-settings.e2e.
  59. await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
  60. providers: {
  61. [START_ROUTE]: {
  62. displayName: 'Origin Gateway',
  63. api: 'openai-completions',
  64. baseURL: 'https://gateway.origin.example/v1',
  65. models: [{ id: START_MODEL, name: 'Origin Large' }],
  66. },
  67. [ROUTE]: {
  68. displayName: 'Acme Gateway',
  69. api: 'openai-completions',
  70. baseURL: 'https://gateway.acme.example/v1',
  71. models: [{ id: MODEL, name: 'Acme Large' }],
  72. },
  73. },
  74. })
  75. browser = await chromium.launch()
  76. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  77. tripwire = watchConsole(page)
  78. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  79. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  80. // The composer's seats only exist once a workspace is connected: without
  81. // one the input is the locked placeholder and no session scope is open.
  82. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
  83. }, 120_000)
  84. afterAll(async () => {
  85. await browser?.close()
  86. await scaffold?.close()
  87. })
  88. it('writes the switched model as the default and leaves a logged session alone', async () => {
  89. onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
  90. // A session that has already run a turn, spelled as the fact a turn
  91. // leaves behind: its own logged route.
  92. const loggedId = await createSession('default-model-logged')
  93. scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
  94. header: { config: { provider: START_ROUTE, model: START_MODEL } },
  95. reason: 'initial',
  96. })
  97. const trigger = page.getByRole('button', { name: /^选择模型/ })
  98. await trigger.waitFor({ timeout: 15_000 })
  99. await trigger.click()
  100. await page.getByRole('menuitem', { name: /模型/ }).click()
  101. await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
  102. // The switch is what sets the default: the shared Agent-route settings section
  103. // now names it, beside the provider profiles the Models page writes.
  104. await expect.poll(
  105. async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
  106. { timeout: 10_000 },
  107. ).toContain('agent-default-model:')
  108. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  109. expect(document).toContain(`provider: ${ROUTE}`)
  110. expect(document).toContain(`model: ${MODEL}`)
  111. // A session created after the switch starts from it...
  112. expect(await currentOf(await createSession('default-model-after')))
  113. .toEqual({ provider: ROUTE, model: MODEL })
  114. // ...while the one holding a logged route keeps deriving from its log.
  115. expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
  116. expect(tripwire.pageErrors).toEqual([])
  117. }, 60_000)
  118. it('goes inert when the route the default names stops being served', async () => {
  119. onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
  120. const box = page.locator('textarea[data-input-phase], textarea').first()
  121. await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
  122. // What removing the provider on the Models page leaves behind: the saved
  123. // default still names the route, and nothing serves it any more.
  124. // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
  125. // stored profile in place.
  126. await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} })
  127. await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false)
  128. expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型')
  129. // The block is an affordance; the refusal is the Host's. A client that
  130. // never disabled anything still cannot start a turn on a dead route.
  131. await expect(scaffold.ctx.sessionController.prompt({
  132. requestId: 'default-model-refused' as never,
  133. sessionId: SessionId(await createSession('default-model-refusal')),
  134. mode: 'queue',
  135. content: [{ type: 'text', text: 'hi' }],
  136. }, new AbortController().signal)).rejects.toMatchObject({ failure: { code: 'model-unavailable' } })
  137. // The way out stays open. Locking the model seat with everything else
  138. // would leave the composer asking for the one thing it prevents.
  139. const seat = page.getByRole('button', { name: /^选择模型/ })
  140. expect(await seat.isEnabled()).toBe(true)
  141. await seat.click()
  142. await page.getByRole('menuitem', { name: /模型/ }).click()
  143. await page.getByRole('menuitemradio').first().click()
  144. await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
  145. expect(tripwire.pageErrors).toEqual([])
  146. }, 60_000)
  147. })