feat(web): choose the agent preset on the new-session screen
The composer seat spent nearly all its life disabled: a session's composition is fixed once a turn has run. Move the choice to the new-session screen beside the workspace picker, where it still works, and let the session header report what a running session runs. The hero pick is staged rather than applied — that screen precedes the session it belongs to. It lands when a session becomes current and is still blank, which covers both the session a workspace connect creates and the blank one it reuses; riding `sessions.create` would miss the second. It is spent on first use, matching the workspace picker. Fix the durability the header field claimed but never had: `agentPreset` was declared on `SessionHeader` and dropped by the JSONL header line, the SQLite sessions row, the derived query index, and the cold list projection, so every resumed session came back composed from nothing. Add the web e2e lane that would have caught it — the one lane that mounts the shipped roster, which needed `cordis:group` in the scaffold's Loader builtins, as `mountRootInclude` already registers.
This commit is contained in:
153
apps/web/tests/agent-preset-selection.e2e.ts
Normal file
153
apps/web/tests/agent-preset-selection.e2e.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Web e2e scenario: agent-preset selection. The roster's `roots` is an
|
||||
// assembly fact the CLI entry resolves and patches in, so every other lane
|
||||
// boots with an empty roster and no preset surface at all; this is the one
|
||||
// lane that mounts the SHIPPED presets and puts them in front of a browser.
|
||||
//
|
||||
// Two surfaces, one host rule: a session's composition is fixed when the
|
||||
// session starts. Before that, the new-session chip stages the choice beside
|
||||
// the workspace picker — the only screen where it still works. After it, the
|
||||
// session header names what the session runs and offers no control at all,
|
||||
// because the host answers `agent-preset-locked` to anything else.
|
||||
//
|
||||
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
|
||||
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 {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', import.meta.url))
|
||||
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
|
||||
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
|
||||
const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
|
||||
/** The shipped roster, beside the composition that names it. */
|
||||
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'agent-preset-selection-web-e2e'
|
||||
|
||||
/**
|
||||
* A settled one-turn session with no model content: this lane asserts chrome
|
||||
* around a conversation, not a conversation, and a recorded turn would tie
|
||||
* the golden to a provider's wording for no gain.
|
||||
* @returns a tokenized session log ending on a closed turn.
|
||||
*/
|
||||
function seedLog(): string {
|
||||
const time = 1784974100000
|
||||
const at = (index: number, event: Record<string, unknown>): string =>
|
||||
JSON.stringify({ ...event, seq: index, time: time + index })
|
||||
return [
|
||||
JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }),
|
||||
at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }),
|
||||
at(1, {
|
||||
type: 'user/message',
|
||||
data: { content: [{ type: 'text', text: 'Seeded turn.' }], source: { kind: 'user', rpcId: 'seed' } },
|
||||
surfaceOp: 'append',
|
||||
}),
|
||||
at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }),
|
||||
at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: agent-preset selection', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' },
|
||||
})
|
||||
// A resumed session runs what it was created with; seeding one that
|
||||
// records `minimal` is what makes the header label a claim about the
|
||||
// session rather than an echo of the current default.
|
||||
await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
|
||||
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('offers the chip on the new-session screen, beside the workspace picker', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero'))
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
|
||||
// The chip opens on the deployment default, by the name that preset
|
||||
// publishes rather than its directory name.
|
||||
expect(snapshot).toContain('标准模式')
|
||||
})
|
||||
|
||||
it('names every preset and what it is for', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu'))
|
||||
await page.getByRole('button', { name: '标准模式' }).click()
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
|
||||
// Every shipped preset, each with the sentence saying what it composes —
|
||||
// the id alone never said what a preset does.
|
||||
expect(snapshot).toContain('极简模式')
|
||||
expect(snapshot).toContain('创造模式')
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
it('applies the staged pick to the blank session, and the host honors it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage'))
|
||||
await page.getByRole('button', { name: '标准模式' }).click()
|
||||
await page.getByRole('menuitem', { name: /极简模式/ }).click()
|
||||
|
||||
// The chip stages; the blank session the workspace connect produced is
|
||||
// what the stage lands on. The host's own answer is what comes back.
|
||||
await expect.poll(async () => {
|
||||
const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {},
|
||||
}),
|
||||
})
|
||||
const body = await response.json() as {
|
||||
result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } }
|
||||
}
|
||||
return JSON.stringify(body.result.value?.sessions ?? body.result)
|
||||
}, { timeout: 15_000 }).toContain('minimal')
|
||||
})
|
||||
|
||||
it('labels a resumed session with the preset it was created under', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
|
||||
// The seeded session's cwd is the scaffold root rather than the connected
|
||||
// workspace, so it lists under Ungrouped; the group collapses by default.
|
||||
await page.getByRole('treeitem', { name: /^Ungrouped/ }).click()
|
||||
await page.locator('[role="treeitem"]').last().click()
|
||||
await page.getByText('Seeded turn.').waitFor({ timeout: 15_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('极简模式')
|
||||
// Static chrome, not a control: the header can only report a composition
|
||||
// the host would refuse to change.
|
||||
expect(snapshot).not.toContain('button "极简模式"')
|
||||
})
|
||||
|
||||
it('drove every surface without a page error or a stream warning', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -180,6 +180,20 @@ export interface LaunchOptions {
|
||||
/** Credential reference resolved by the shipped search provider. */
|
||||
apiKeyEnv: string
|
||||
}
|
||||
/**
|
||||
* Replace the roster the scaffold mounts by default (the shipped directory
|
||||
* at `system` trust, default `standard`). Supply this only to change WHICH
|
||||
* presets a scenario sees — a writable user root, a different default —
|
||||
* never to turn the roster on: without one every session composes an agent
|
||||
* with no tools, no persona, and no token meter, which is not a shape the
|
||||
* product ever boots in. The patch lands after the default, so it wins.
|
||||
*/
|
||||
agentPresets?: {
|
||||
/** Roots to discover, in precedence order; the shipped directory is `system`. */
|
||||
roots: { path: string; trust: 'system' | 'user' }[]
|
||||
/** The preset a session that names none is composed from. */
|
||||
default: string
|
||||
}
|
||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
/**
|
||||
@@ -319,6 +333,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// disable+insert pair.
|
||||
{ id: 'directory-picker', disabled: true },
|
||||
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
|
||||
...options.agentPresets === undefined
|
||||
? []
|
||||
: [{ id: 'agent-presets', config: options.agentPresets }],
|
||||
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
@@ -511,6 +528,8 @@ export function fixtureUserPrompts(fixtureText: string): string[] {
|
||||
* @param scaffold - the target scaffold.
|
||||
* @param fixtureText - raw recorded session.jsonl contents.
|
||||
* @param id - the seeded session id (stable for deterministic goldens).
|
||||
* @param agentPreset - the preset the recorded session was composed from,
|
||||
* for scenarios asserting what a resumed session reports running.
|
||||
* @returns the seeded id.
|
||||
*/
|
||||
/**
|
||||
@@ -534,7 +553,12 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i
|
||||
: realized.split(fixtureCwd).join(scaffold.workspaceCwd)
|
||||
}
|
||||
|
||||
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
|
||||
export async function seedSession(
|
||||
scaffold: WebScaffold,
|
||||
fixtureText: string,
|
||||
id: string,
|
||||
agentPreset?: string,
|
||||
): Promise<SessionId> {
|
||||
const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id))
|
||||
if (events.length === 0) throw new Error('seed fixture has no events')
|
||||
const last = events[events.length - 1]!
|
||||
@@ -547,6 +571,7 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
|
||||
createdAt: Date.now() - 60_000,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
delegationDepth: 0,
|
||||
...agentPreset === undefined ? {} : { agentPreset },
|
||||
}
|
||||
const seeder = new Context()
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Seeded turn" [disabled]
|
||||
- img
|
||||
- text: 极简模式
|
||||
@@ -0,0 +1,8 @@
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- img
|
||||
@@ -0,0 +1,6 @@
|
||||
- menu:
|
||||
- menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。":
|
||||
- text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- img
|
||||
- menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。"
|
||||
- menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。"
|
||||
Reference in New Issue
Block a user