Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

The Client API carrier's `agentPresets` member was the one member of its class
without an `IApiClient[...]` annotation. Inferring it inlined `AgentPresetEntry`
into the emitted declaration by the specifier TS picks — the host `index.ts` —
dragging the whole gateway, and with it the host `Context` merges, into every
Client program importing the carrier. Annotated like its siblings.

`ApiRemoteAgentOptions.setup` now takes the inspected session rather than its
header alone: this layer resolves a resumed session's preset from the LOG,
because a session that switched while blank ran its turns under the newer
composition and the header is written once at creation.

Conflicts:
	apps/web/tests/snapshots/*/*.expected.md
	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
	packages/host/apiproxy/src/api-proxy.ts
	scripts/doc-budgets.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-08 15:00:31 +08:00
649 changed files with 21091 additions and 2838 deletions

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>DeepSeek Harness</title>
</head>

View File

@@ -0,0 +1,16 @@
{
"id": "/",
"name": "DeepSeek Harness",
"short_name": "DSH",
"start_url": "/",
"scope": "/",
"display": "fullscreen",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

View File

@@ -1,5 +1,5 @@
// Shared scaffolding for the assembled-jsdom snapshots: the real built
// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's
// workspace `lib/client.js` artifacts booted through AppWebEntry's
// ModuleLoader path (loadBundle) against the keyless FixtureApiClient
// transport. Every file that mounts this graph needs the same boot entry list,
// the same bundle map, the same jsdom globals, and the same mount call, and
@@ -14,18 +14,21 @@ import { afterEach, beforeEach, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
{ id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
dir: 'ui-workspace',
bundlePath: 'packages/client/ui-workspace/lib/client.js',
url: '/plugins/ui-workspace.js',
rev: 'fx',
inject: [
@@ -34,12 +37,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
'@deepseek-ai/dsh-client-ui-sidebar',
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'),
]))
interface FixtureWindow extends Window {
@@ -97,7 +100,7 @@ export function mountAssembledApp(): void {
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
loadBundle: async (url) => {

View File

@@ -0,0 +1,167 @@
// Web e2e scenario: switching models in the composer is how this deployment's
// default is chosen. The gesture writes the `api-gateway` settings section, a
// session created afterwards starts from it, and a session that already logged
// a route keeps deriving from its own log — the tier order the gateway
// resolves on every read.
// Zero model calls: the switch is settings/llm-domain traffic only, so there
// is no fixture and a stray stream would fail loud on the open seam. Both
// routes are declared host-side (not through the UI, which has its own
// scenario) through the pi-ai adapter the shipped tree already mounts: a
// fixture-less scaffold registers no adapter at all, so the routes the
// picker offers — and the one the composer must start on — have to come from
// somewhere, and settings profiles are the product's own way to add them.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
/** Points the shipped `api-gateway` default at this scenario's own route. */
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
/** The route this scenario starts on, patched over the shipped default. */
const START_ROUTE = 'origin-gateway'
const START_MODEL = 'origin-large'
/** The route the switch lands on, which then becomes the saved default. */
const ROUTE = 'acme-gateway'
const MODEL = 'acme-large'
describe('web e2e: the composer model switch is the default for later sessions', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
/** Create one session and its agent through the same wire face the browser uses. */
const createSession = async (sessionId: string): Promise<string> => {
const response = await scaffold.ctx.apiProxy.sessions.create({
rpcId: `default-model-create-${sessionId}` as never,
payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd },
})
if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`)
return response.result.value.sessionId
}
/** The route the gateway reports for one session, through the real wire face. */
const currentOf = async (sessionId: string): Promise<unknown> => {
const response = await scaffold.ctx.apiProxy.sessions.models({
rpcId: `default-model-${sessionId}` as never,
payload: { sessionId: SessionId(sessionId) },
})
if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`)
return response.result.value.current
}
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
// Two routes so the picker has somewhere to start and somewhere to go.
// Declared through the settings seam rather than the Models page: this
// scenario is about the composer, and the declaring flow is covered by
// models-settings.e2e.
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
[START_ROUTE]: {
displayName: 'Origin Gateway',
api: 'openai-completions',
baseURL: 'https://gateway.origin.example/v1',
models: [{ id: START_MODEL, name: 'Origin Large' }],
},
[ROUTE]: {
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://gateway.acme.example/v1',
models: [{ id: MODEL, name: 'Acme Large' }],
},
},
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The composer's seats only exist once a workspace is connected: without
// one the input is the locked placeholder and no session scope is open.
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('writes the switched model as the default and leaves a logged session alone', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
// A session that has already run a turn, spelled as the fact a turn
// leaves behind: its own logged route.
const loggedId = await createSession('default-model-logged')
scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
header: { config: { provider: START_ROUTE, model: START_MODEL } },
reason: 'initial',
})
const trigger = page.getByRole('button', { name: /^选择模型/ })
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
// The switch is what sets the default: the gateway's own settings section
// now names it, beside the provider profiles the Models page writes.
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).toContain('api-gateway:')
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain(`provider: ${ROUTE}`)
expect(document).toContain(`model: ${MODEL}`)
// A session created after the switch starts from it...
expect(await currentOf(await createSession('default-model-after')))
.toEqual({ provider: ROUTE, model: MODEL })
// ...while the one holding a logged route keeps deriving from its log.
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('goes inert when the route the default names stops being served', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
const box = page.locator('textarea[data-input-phase], textarea').first()
await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
// What removing the provider on the Models page leaves behind: the saved
// default still names the route, and nothing serves it any more.
// `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
// stored profile in place.
await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} })
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false)
expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型')
// The block is an affordance; the refusal is the Host's. A client that
// never disabled anything still cannot start a turn on a dead route.
const refused = await scaffold.ctx.apiProxy.sessions.prompt({
rpcId: 'default-model-refused' as never,
payload: {
sessionId: SessionId(await createSession('default-model-refusal')),
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'hi' }],
},
})
expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
// The way out stays open. Locking the model seat with everything else
// would leave the composer asking for the one thing it prevents.
const seat = page.getByRole('button', { name: /^选择模型/ })
expect(await seat.isEnabled()).toBe(true)
await seat.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio').first().click()
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})

View File

@@ -0,0 +1,8 @@
# The fixture-less web scaffold registers no adapter, so the shipped
# deepseek-official default would be a route nothing serves — which the
# composer now correctly refuses to type into. This scenario declares its own
# pi-ai routes and starts the default on one of them.
- id: api-gateway
config:
provider: origin-gateway
model: origin-large

View File

@@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)

View File

@@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
const originalSource = await readFile(sourcePath)
const originalBundle = await readFile(bundlePath)
const oldText = "Let's start building"
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
const oldText = 'Into the Unknown'
const sourceNeedle = "'hero.headline': 'Into the Unknown'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)

View File

@@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
// The blank frame renders the hero, not the resident composer: the
// headline plus the guidance placeholder are the empty state's anchors.
await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
if (MODE !== 'record') {

View File

@@ -126,7 +126,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', { name: 'Select model', exact: true })
await page.getByRole('button', { name: /^Select model, current/ })
.waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.

View File

@@ -27,6 +27,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
@@ -133,7 +134,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The profile lands in settings.yaml with only the derived reference, the
// key value lands in the harness home's .env, the dormant route
// key value lands in the harness home's .credentials.yaml, the dormant route
// registers, and the topology frame invalidates the page into the row.
await expect.poll(
async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
@@ -145,11 +146,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
expect(document).not.toContain('sk-e2e-minimax')
const credentialFile = join(scaffold.harnessHome, '.env')
const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
await expect.poll(
async () => readFile(credentialFile, 'utf8').catch(() => ''),
{ timeout: 10_000 },
).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
expect(await page.content()).not.toContain('sk-e2e-minimax')
}, 60_000)
@@ -158,22 +159,55 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await dialog.getByText('自定义设置').click()
const effort = dialog.getByLabel('推理强度')
await effort.waitFor({ timeout: 10_000 })
await effort.selectOption('high')
const url = dialog.getByLabel('API 地址')
await url.waitFor({ timeout: 10_000 })
await url.fill('https://gateway.minimax.example/v1')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The editor closes back to the row; the fold's write merged into the
// stored profile beside the reference.
await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0)
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('reasoning: high')
expect(document).toContain('baseURL: https://gateway.minimax.example/v1')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('declares a route the adapter does not ship', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
const dialog = page.getByRole('dialog', { name: '设置' })
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
await declare.click()
await dialog.getByLabel('Provider ID').fill('acme-gateway')
await dialog.getByLabel('显示名称').fill('Acme Gateway')
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
// No reasoning effort on a provider card at all: effort is a per-model
// capability, the models under one provider disagree about it, and a
// switch in the composer already records provider+model+effort together.
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
await dialog.getByRole('button', { name: '添加模型' }).click()
await dialog.getByLabel('模型 ID 1').fill('acme-large')
await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('acme-gateway:')
// The tag follows the adapter's installed catalog: this route is in no
// catalog, while minimax-cn is — even though both now have profiles.
const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first()
await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1)
expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('confirms an identified provider deletion before removing its profile and key', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
@@ -196,7 +230,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).not.toContain('minimax-cn:')
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
.not.toContain('MINIMAX_CN_API_KEY')
await expect.poll(
async () => page.getByRole('dialog', { name: '删除 minimax-cn' }).count(),
@@ -208,7 +242,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
'configured.expected.md', 'declared.expected.md', 'delete.expected.md',
'empty.expected.md', 'native-delete.expected.md',
])
})
})

View File

@@ -113,8 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await settings.getByRole('button', { name: '保存', exact: true }).click()
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
expect((await page.content()).includes(secret)).toBe(false)
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
expect(browserConsole.some(line => line.includes(secret))).toBe(false)

View File

@@ -0,0 +1,76 @@
// Web e2e scenario: the produced-files row a finished turn ends with. Cold-seeds
// a recorded write turn (zero model calls). Package tests cover the derivation
// in isolation, but only the assembled application shows that a turn's writes
// reach the transcript as an openable row (docs/testing.md snapshot rule). The
// click itself is not driven here: it hands the path to the Host's opener,
// which would launch a real application on the machine running the suite.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a
// file, not a new recording (the message-actions borrowing pattern).
const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'produced-files-web-e2e'
/** The file the borrowed recording's write tool produces. */
const PRODUCED = 'policy-neutral.txt'
describe('web e2e: a finished turn ends with the files it produced', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The seeded Session's cwd is the scaffold workspace; the recording's own
// nested directory is created too, so its paths stay resolvable.
await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true })
await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n')
const raw = await readFile(SEED, 'utf8')
expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED)
await seedSession(scaffold, raw, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('lists the written file under the closing message, as an opener', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
// The row the turn ends with — derived from the write call's locations,
// not from whatever the closing message happened to say.
const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first()
await chip.waitFor({ timeout: 15_000 })
expect(await chip.innerText()).toBe(PRODUCED)
// The full path stays reachable for a reader who wants to copy it.
expect(await chip.getAttribute('title')).toContain(PRODUCED)
// A turn's produced files are labelled, not left as bare chips.
expect(await page.getByText('Produced', { exact: true }).count()).toBeGreaterThan(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 90_000)
})

View File

@@ -0,0 +1,27 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { expect, it } from 'vitest'
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
it('ships install metadata with the built web application', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
expect(index).toContain('<link rel="manifest" href="/manifest.webmanifest" />')
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
expect(manifest).toEqual({
id: '/',
name: 'DeepSeek Harness',
short_name: 'DSH',
start_url: '/',
scope: '/',
display: 'fullscreen',
icons: [{
src: '/favicon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
}],
})
})

View File

@@ -46,6 +46,10 @@ import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type {
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import SessionStore, {
@@ -96,6 +100,46 @@ const REPLAY_PROVIDERS = [{
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
}]
/**
* The routes a shipped composition always has, with no ability to stream.
* A fixture-less keyless scenario issues no model calls, but its tree must
* still answer `listProviders()` — surfaces legitimately gate on whether any
* adapter serves a session's route, and an empty registry is a test artifact,
* not a product state.
*/
class RouteOnlyAdapter extends LlmAdapter {
constructor(private readonly providers: typeof REPLAY_PROVIDERS) {
super()
}
override providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: this.providers.find(entry => entry.id === provider)?.name ?? provider }
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve((this.providers.find(entry => entry.id === provider)?.models ?? [])
.map(model => ({ provider, id: model.id, name: model.name })))
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const listed = this.providers.find(entry => entry.id === provider)?.models
.find(entry => entry.id === model)
return Promise.resolve({
provider,
id: model,
name: listed?.name ?? model,
...listed?.contextWindow === undefined ? {} : { contextWindow: listed.contextWindow },
})
}
override async *stream(): AsyncIterable<StreamChunk> {
throw new Error(
'web e2e scaffold: a model call was issued by a scenario that declared no replay fixture'
+ ' — pass replayFixture, or keep the scenario free of model calls',
)
}
}
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
if (contextWindow === undefined) return REPLAY_PROVIDERS
return REPLAY_PROVIDERS.map(provider => ({
@@ -435,6 +479,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
} else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
// No fixture and no shipped adapter would leave the tree with ZERO
// provider routes — a state no product composition has, and one the
// composer now correctly refuses to type into. Register the same routes
// a fixture would, with streaming that still fails loud: the scenario
// issues no model calls, and one that slipped in must not pass quietly.
ctx.effect(() => ctx.llm.registerAdapter(
replayProviders(options.replayContextWindow).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
), 'web e2e scaffold: route-only adapter')
}
} catch (error) {
if (process.cwd() !== originalCwd) process.chdir(originalCwd)

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// Assembled search-card snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless
// Assembled search-card snapshot: boots the real built workspace client bundles
// through AppWebEntry's ModuleLoader path against the keyless
// FixtureApiClient transport (no API key, no model round), opens the fixture
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
// the assembled application. The built-boot smoke proves the graph boots but

View File

@@ -299,10 +299,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the routed id, but without an
// advertised catalog row the selector prompts for a listed replacement.
await page.getByRole('button', { name: 'Select model', exact: true })
// This scenario issues zero model calls — the scaffold's route-only
// adapter serves the catalog and refuses to stream — so history restores
// the routed id and the seat resolves it against an advertised row.
await page.getByRole('button', { name: /^Select model, current/ })
.waitFor({ timeout: 10_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')

View File

@@ -1,7 +1,8 @@
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token +
// browser theme-color metadata)
// the Language row (settings-scoped localization + persisted dsh.locale),
// the busy-state Enter preference, plus Permission as the persisted default
// for subsequently created sessions.
@@ -154,17 +155,37 @@ describe('web e2e: settings modal and General preferences', () => {
it('flips the theme through the Appearance cubes and persists across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
await page.evaluate(() => ({
interface ThemeState {
attr: boolean
background: string
stored: string | null
themeColor: string | null
themeColorCount: number
token: string
}
const readState = async (): Promise<ThemeState> => await page.evaluate(() => {
const metas = document.head.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
const computed = getComputedStyle(document.body)
return {
attr: document.body.hasAttribute('data-ds-dark-theme'),
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
background: computed.backgroundColor,
stored: localStorage.getItem('dsh.theme'),
}))
themeColor: metas[0]?.content ?? null,
themeColorCount: metas.length,
token: computed.getPropertyValue('--dsw-alias-bg-base').trim(),
}
})
const expectThemeColorSynchronized = (state: ThemeState): void => {
expect(state.themeColorCount).toBe(1)
expect(state.background).not.toBe('rgba(0, 0, 0, 0)')
expect(state.themeColor).toBe(state.background)
}
// Pin the OS scheme to light so the default `system` preference resolves
// light and the dark flip below is unambiguously the gesture's doing.
await page.emulateMedia({ colorScheme: 'light' })
const light = await readState()
expect(light.attr).toBe(false)
expectThemeColorSynchronized(light)
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
@@ -179,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(dark.attr).toBe(true)
expect(dark.stored).toBe('dark')
expect(dark.token).not.toBe(light.token)
expectThemeColorSynchronized(dark)
await page.keyboard.press('Escape')
// Reload: the preference survives boot (restore + presenter initial apply).
@@ -190,6 +212,7 @@ describe('web e2e: settings modal and General preferences', () => {
const reloaded = await readState()
expect(reloaded.attr).toBe(true)
expect(reloaded.stored).toBe('dark')
expectThemeColorSynchronized(reloaded)
// `system` follows the emulated OS scheme (dark stays dark, light clears).
await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -197,12 +220,15 @@ describe('web e2e: settings modal and General preferences', () => {
await systemCube.click()
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
expectThemeColorSynchronized(await readState())
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
expectThemeColorSynchronized(await readState())
// Restore for the specs that follow: light preference beats the emulated
// dark OS scheme, leaving the shared page in the light default.
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
expectThemeColorSynchronized(await readState())
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)

View File

@@ -24,8 +24,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building Preview
- text: Into the Unknown Preview
- button "Choose workspace":
- img
- text: workspace

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building Preview
- text: Into the Unknown Preview
- button "Choose workspace":
- img
- text: workspace
@@ -33,8 +33,8 @@
- button "standard":
- text: standard
- img
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: Details

View File

@@ -43,8 +43,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -22,8 +22,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -34,8 +34,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -38,8 +38,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -46,8 +46,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok

View File

@@ -0,0 +1,31 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn
- img "API 密钥已配置"
- button "编辑 minimax-cn": 编辑
- button "删除 minimax-cn": 删除
- listitem:
- text: Acme Gateway 自定义
- button "编辑 Acme Gateway (acme-gateway)": 编辑
- button "删除 Acme Gateway (acme-gateway)": 删除
- button "添加提供方":
- img
- text: 添加提供方
- button "添加自定义提供方":
- img
- text: 添加自定义提供方

View File

@@ -25,12 +25,6 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com
- text: 推理强度
- combobox "推理强度":
- option "默认" [selected]
- option "off"
- option "high"
- option "max"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"

View File

@@ -45,8 +45,8 @@
- img
- 'button "Access mode, current: Read Only"': Read Only
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -43,8 +43,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -39,8 +39,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- text: standard
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok

View File

@@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => {
// seat with `visibility:hidden`, which Playwright reports as not visible).
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
expect(await page.getByText("Let's start building").isVisible()).toBe(true)
expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
expect(await page.locator('textarea').first().isVisible()).toBe(true)
releaseHistory()

View File

@@ -37,6 +37,7 @@
"tests/details-session-lifecycle.e2e.ts",
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/default-model.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/remote-welcome.e2e.ts",
"tests/workspace-management.e2e.ts",
@@ -59,8 +60,9 @@
"tests/permission-policy-context.e2e.ts",
"tests/access-confirmation.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/goal-bar.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/produced-files.e2e.ts",
"tests/goal-bar.e2e.ts",
"tests/subagent-conversation.e2e.ts",
"tests/bash-abort-row.e2e.ts",
"tests/skill-tool-row.e2e.ts",