Merge branch 'master' into fix/web-favicon-dark-mode

This commit is contained in:
_Kerman
2026-08-10 15:47:58 +08:00
committed by GitHub
702 changed files with 31006 additions and 1651 deletions

View File

@@ -0,0 +1,278 @@
// Web e2e scenario: the agent-preset settings section as copy-only authoring.
// The browser never edits composition text — a shipped preset opens in a
// read-only viewer, the copy dialog collects an id and an optional display
// name, and the host copies the whole directory. The section's other job is
// getting the user TO the files: this lane pins `nativeOpen: false` (see the
// overlay), so the location affordance answers the preset directory as text —
// the deterministic branch a golden can hold on every platform.
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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 type { Locator } from 'playwright'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url))
const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.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 OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: agent-preset authoring is a host-side copy', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let userRoot: string
/** The settings dialog, opened on the Agent-presets section. */
function settingsDialog(): Locator {
return page.getByRole('dialog', { name: '设置' })
}
/** Tokenize the lane-owned preset root after general aria normalization. */
function withPresetRoot(snapshot: string): string {
const rootSuffix = `/${userRoot.split('/').pop()!}`
return snapshot.split('\n').map((line) => {
const rootStart = line.indexOf(rootSuffix)
if (rootStart === -1) return line
const pathStart = line.lastIndexOf(' ', rootStart) + 1
return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}`
}).join('\n')
}
beforeAll(async () => {
userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
scaffold = await launchWebScaffold({
extraOverlayPath: OVERLAY,
agentPresets: {
roots: [
{ path: SHIPPED_PRESETS, trust: 'system' },
{ path: userRoot, trust: 'user' },
],
default: 'standard',
},
})
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
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 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('offers the roster with copy as the only way to create', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = settingsDialog()
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
// The intro carries the guidance a create button used to imply, and the
// shipped rows offer view/copy but never delete or a location — their
// install is overwritten by upgrades and is not the user's to manage.
expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建')
expect(snapshot).not.toContain('新建预设')
expect(snapshot).toContain('查看: 标准模式')
expect(snapshot).not.toContain('删除: 标准模式')
expect(snapshot).not.toContain('打开目录')
}, 60_000)
it('views a shipped composition read-only instead of editing it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
await viewer.waitFor({ timeout: 10_000 })
// The real shipped composition, not a golden: the viewer shows whatever
// the deployment ships, and this lane only asserts it is shown read-only.
const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8')
expect(await viewer.locator('pre').textContent()).toBe(shipped)
expect(await viewer.getByRole('textbox').count()).toBe(0)
// The header X and the footer button share the 关闭 name; the footer one
// is last in the dialog.
await viewer.getByRole('button', { name: '关闭' }).last().click()
await viewer.waitFor({ state: 'detached', timeout: 10_000 })
}, 60_000)
it('copies 极简模式 whole under a new id and lands in its files', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
await copyDialog.waitFor({ timeout: 10_000 })
const dialogSnapshot = await captureStableAria(
page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE)
// Two fields and nothing else: the id is the directory name the host
// needs up front; description and composition live in the files.
expect(dialogSnapshot).toContain('标识符')
expect(dialogSnapshot).not.toContain('描述')
await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
await copyDialog.getByRole('button', { name: '创建' }).click()
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
// The new row lands in the custom group, and — with no desktop opener —
// its directory is revealed as text right away: landing in the files is
// the completion of a copy, not a follow-up.
await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 })
await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 })
// The copy dialog is detached, so the settings dialog is the only one
// left (it names itself via aria-labelledby, which a CSS attribute
// selector cannot address).
const snapshot = withPresetRoot(
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('{{presetRoot}}/my-agent')
// The host copied the whole directory and rewrote only the display
// metadata: the composition is byte-identical to the shipped source, the
// description rides along for the user to edit in place, and neither the
// source's name nor its roster order survives into the copy.
const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8')
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
expect(metadata).toContain('name: 我的模式')
expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。')
expect(metadata).not.toContain('order:')
}, 60_000)
it('deletes the copy after confirmation and reclaims the roster', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '删除: 我的模式' }).click()
const confirm = page.getByRole('dialog', { name: '删除该预设?' })
await confirm.waitFor({ timeout: 10_000 })
await confirm.getByRole('button', { name: '删除', exact: true }).click()
await confirm.waitFor({ state: 'detached', timeout: 10_000 })
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
// Custom group gone with its only member; the shipped set stands.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)
it('marks damaged presets broken and clears a ghost through delete', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged'))
// The two hand-edit damage shapes: a composition that no longer parses,
// and a directory whose composition file was deleted outright.
await mkdir(join(userRoot, 'broken-yaml'), { recursive: true })
await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n')
await mkdir(join(userRoot, 'ghost'), { recursive: true })
await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n')
// The section reads the roster when it mounts; hop away and back.
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '通用设置' }).click()
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
const snapshot = withPresetRoot(
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
// Both damage shapes surface as marked, unselectable, uncopyable cards
// that still carry their metadata and the discovery-reported reason.
expect(snapshot).toContain('已损坏: broken-yaml')
expect(snapshot).toContain('已损坏: 幽灵预设')
expect(snapshot).toContain('not valid YAML')
expect(snapshot).toContain('agent.cordis.yml is missing')
expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
// A broken card offers no "set default" affordance at all — the aria name
// IS the broken marking, so the picking name must not exist.
expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
// The ghost's way out is the card's own delete — and the id it blocked
// is claimable again immediately afterwards.
await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click()
const confirm = page.getByRole('dialog', { name: '删除该预设?' })
await confirm.waitFor({ timeout: 10_000 })
await confirm.getByRole('button', { name: '删除', exact: true }).click()
await confirm.waitFor({ state: 'detached', timeout: 10_000 })
await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
await copyDialog.waitFor({ timeout: 10_000 })
await copyDialog.getByPlaceholder('my-agent').fill('ghost')
await copyDialog.getByRole('button', { name: '创建' }).click()
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
// Leave the roster as the earlier tests shaped it.
await dialog.getByRole('button', { name: '删除: ghost' }).click()
const cleanup = page.getByRole('dialog', { name: '删除该预设?' })
await cleanup.waitFor({ timeout: 10_000 })
await cleanup.getByRole('button', { name: '删除', exact: true }).click()
await cleanup.waitFor({ state: 'detached', timeout: 10_000 })
await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true })
}, 60_000)
it('starts a creator-mode session from the section', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator'))
// Without a workspace the flow only stages (there is no session to land
// in until one is connected); connect first so the gesture carries all
// the way to a composed host session.
await settingsDialog().getByRole('button', { name: '关闭' }).last().click()
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = settingsDialog()
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click()
// Leaving settings is part of the gesture: the flow lands on the
// new-session screen with the self-referential preset staged, and the
// blank session the flow produces composes from it on the host.
await dialog.waitFor({ state: 'detached', timeout: 10_000 })
await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
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: 'creator-draft-stage', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { sessions: unknown[] } }
}
return JSON.stringify(body.result.value?.sessions ?? body.result)
}, { timeout: 15_000 }).toContain('"agentPreset":"cordis"')
}, 60_000)
it('drove every surface without a page error or a stream warning', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

View File

@@ -0,0 +1,12 @@
# The authoring lane drives the location affordance. A real desktop open
# would pop a file manager on the machine running the tests and the
# capability itself is platform-detected (macOS yes, headless Linux CI no),
# so the gateway is pinned headless: `hasDocument` is false everywhere and
# `openDocument` answers the directory as text — the same branch on every
# host, and the one whose rendering a golden can hold. A patch replaces the
# row's complete config, so the shipped routing defaults ride along.
- id: api-gateway
config:
provider: deepseek-official
model: deepseek-v4-flash
nativeOpen: false

View File

@@ -0,0 +1,234 @@
// 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 { mkdir, writeFile } from 'node:fs/promises'
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 project skill only a preset that mounts `skill-local` can discover. */
const SKILL_NAME = 'preset-catalog-demo'
/**
* Seed one project skill under the connected workspace.
*
* Local skill discovery is a PRESET row, so this file is visible through
* `standard` and invisible through `minimal` — which makes the '/' menu's
* skill group a statement about the session's composition.
* @param workspaceCwd - the scaffold's temp project parent.
*/
async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> {
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), [
'---',
`name: ${SKILL_NAME}`,
'description: Prove the slash catalog follows the session composition',
'---',
'',
'Body.',
'',
].join('\n'))
}
/**
* 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')
}
/**
* The preset the host reports for the blank session the workspace connect
* produced. Addressed by id rather than by scanning the serialized list: the
* seeded session records `minimal` too, so a substring match over the whole
* list answers before the switch has landed.
* @param baseUrl - the scaffold's origin.
* @returns the live session's preset, or undefined before it is listed.
*/
async function livePreset(baseUrl: string): Promise<string | undefined> {
const response = await fetch(`${baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
}
return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
}
/** Every option label the trigger menu currently lists. */
async function menuOptions(page: Page): Promise<string[]> {
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
return await menu.getByRole('option').allTextContents()
}
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')
await seedWorkspaceSkill(scaffold.workspaceCwd)
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(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal')
})
it('re-reads the slash catalog through the composition the switch installed', async () => {
// Continues the previous case: the chip has already applied `minimal` to
// the blank session, and this one reads the menu that switch left behind.
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
const composer = page.locator('textarea:enabled').last()
// `minimal` mounts neither the compaction group nor plan mode nor local
// skill discovery, so the catalog the composer warmed under the
// deployment default must not survive the switch.
await composer.fill('/')
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
.not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
const onMinimal = await menuOptions(page)
expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false)
expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false)
// The host-plane commands and the client's own contribution are the
// floor: they belong to no preset and never move.
expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true)
expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
await composer.fill('')
// Switching back up reaches the host at all — the chip compares the pick
// against its list row, so a row that never reprojected the first switch
// answers "already standard" and sends nothing — and restores the catalog
// instead of leaving the session reading the narrower composition.
await page.getByRole('button', { name: '极简模式' }).click()
await page.getByRole('menuitem', { name: /^标准模式/ }).first().click()
await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard')
await composer.fill('/')
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
const onStandard = await menuOptions(page)
expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
await composer.fill('')
}, 90_000)
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([])
})
})

View File

@@ -0,0 +1,138 @@
// @vitest-environment jsdom
// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
// Opens the fixture history session whose turn 72 carries an image in BOTH a
// user message and an assistant message, and pins the product surfaces: the
// history ImageGallery loading real fixture bytes through the authorized
// sessions.attachment route, the double-click ImageLightbox, and the composer
// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove).
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */
async function openFixtureSession(): Promise<void> {
const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 })
const group = (await within(tree).findAllByText('fixture'))
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
.find(el => el?.getAttribute('aria-expanded') !== null)
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
if (group.getAttribute('aria-expanded') === 'false') {
fireEvent.click(within(group).getByText('fixture'))
await waitFor(() => {
expect(group.getAttribute('aria-expanded')).toBe('true')
})
}
const session = await within(tree).findByText('Fixture 历史会话')
fireEvent.click(session)
await waitFor(() => {
expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0)
}, { timeout: 10_000 })
}
it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => {
localStorage.setItem('dsh.locale', 'zh')
mountAssembledApp()
await openFixtureSession()
// Both the user-side (align=end) and assistant-side (align=start) galleries
// load real fixture bytes over sessions.attachment. jsdom provides
// createObjectURL, so this environment MUST take the object-URL path — a
// data: src here would mean the fallback ran where it should not.
await waitFor(() => {
if (document.querySelector('[data-align="end"] img') === null
|| document.querySelector('[data-align="start"] img') === null) {
throw new Error('history image galleries missing')
}
}, { timeout: 10_000 })
const galleryShape = (align: string) => [...document.querySelectorAll(`[data-align="${align}"] img`)]
.map(img => ({ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0] }))
expect({ user: galleryShape('end'), assistant: galleryShape('start') }).toMatchInlineSnapshot(`
{
"assistant": [
{
"alt": "fixture-image.png",
"scheme": "blob",
},
],
"user": [
{
"alt": "fixture-image.png",
"scheme": "blob",
},
],
}
`)
const userImage = document.querySelector<HTMLElement>('[data-align="end"] img')!
// Double-click opens the original-size lightbox; Escape/close dismisses it.
const frame = userImage.closest('button')
if (frame === null) throw new Error('image frame button missing')
fireEvent.doubleClick(frame)
const lightbox = await screen.findByRole('dialog')
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeNull()
})
})
it('accepts pasted images into the composer rail in order and removes them', async () => {
localStorage.setItem('dsh.locale', 'zh')
mountAssembledApp()
const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="在“fixture”中新建会话"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
// Image-only send arming is pinned at package level (input-bar.spec.tsx);
// this assembled lane pins the intake chain over the built graph.
const textarea = await screen.findByPlaceholderText('描述你想要构建的内容', {}, { timeout: 10_000 })
const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
getData: () => '',
},
})
// The rail is an accessible group holding the draft thumbnail (queried via
// DOM: jsdom's a11y-visibility computation hides the composer subtree).
const rail = await waitFor(() => {
const el = document.querySelector('[role="group"][aria-label="待发送图片"]')
if (el === null) throw new Error('attachment rail missing')
return el
}, { timeout: 5_000 })
expect([...rail.querySelectorAll('img')].map(img => ({
alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0],
}))).toMatchInlineSnapshot(`
[
{
"alt": "pasted.png",
"scheme": "blob",
},
]
`)
const second = new File([new Uint8Array([137, 80, 78, 71])], 'second.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => second }],
getData: () => '',
},
})
await waitFor(() => {
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt')))
.toEqual(['pasted.png', 'second.png'])
})
const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')]
if (remove.length !== 2) throw new Error('remove buttons missing')
for (const button of remove) fireEvent.click(button)
await waitFor(() => {
expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull()
})
})

View File

@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import type {} from '@deepseek-ai/dsh-skill'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
async function writeSkill(root: string, name: string): Promise<void> {
@@ -37,10 +39,25 @@ it('isolates replay skill discovery from every ambient host root', async () => {
let scaffold: WebScaffold | undefined
try {
scaffold = await launchWebScaffold()
const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name)
expect(names).not.toContain('ambient-dsh')
expect(names).not.toContain('ambient-agents')
expect(names).not.toContain('ambient-bundled')
const ctx = scaffold.ctx
// Local skill discovery belongs to the agent's preset LAYER of the host
// registry, so the roots under test are only reachable through a composed
// agent's view — the same scope the gateway's `skill.list` resolves for a
// browser request about a session.
const handle = await ctx.agents.create({
sessionId: SessionId('hermetic-skills'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
const skills = ctx.get('skills')
if (skills === undefined) throw new Error('the composition mounts no skill registry')
const names = (await skills.list({ cwd: scaffold.workspaceCwd, scope: handle.agent })).map(skill => skill.name)
expect(names).not.toContain('ambient-dsh')
expect(names).not.toContain('ambient-agents')
expect(names).not.toContain('ambient-bundled')
} finally {
await handle.dispose()
}
} finally {
try {
await scaffold?.close()

View File

@@ -32,6 +32,7 @@ import { expect } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import Group from '@cordisjs/plugin-group'
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
import {
addHarnessSourceSection,
@@ -85,6 +86,8 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
/** The installation anchor whose dependency surface the profile module fallback mirrors. */
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
/** The deployment's own agent-preset root, shipped beside the app's config. */
const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets')
// Replay publishes the provider catalog the gateway routes to (providers
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
@@ -226,6 +229,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
/**
@@ -281,6 +298,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// paths at load, and an in-process boot must NEVER touch the developer's
// real ~/.dsh document or credential file.
const harnessHome = join(workspaceCwd, '.dsh-home')
// Skill discovery is model-visible input, and its roots now resolve inside a
// PRESET — a subtree this lane's include patches cannot reach, because the
// roster mounts it directly per session rather than as a row of the booted
// tree. The row's documented fallback is the environment, so pin that: the
// whole scaffold lifetime, not just the boot, since presets mount when a
// session is created. Without this a developer's real ~/.dsh/skills silently
// enters replay requests and goldens while CI sees none.
const skillRootEnvironment = {
DSH_HOME: join(workspaceCwd, '.dsh-home'),
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
}
const originalSkillRootEnvironment = Object.fromEntries(
Object.keys(skillRootEnvironment).map(key => [key, process.env[key]]),
)
let skillRootEnvironmentRestored = false
const restoreSkillRootEnvironment = (): void => {
if (skillRootEnvironmentRestored) return
skillRootEnvironmentRestored = true
for (const [key, value] of Object.entries(originalSkillRootEnvironment)) {
if (value === undefined) Reflect.deleteProperty(process.env, key)
else process.env[key] = value
}
}
Object.assign(process.env, skillRootEnvironment)
let persistenceRoot: string
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
@@ -310,6 +352,18 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
...basePatches,
...surfacePatches,
...extraOverlayPatches,
// The roster's `roots` is an assembly fact AppCLIEntry resolves and patches
// in, exactly like `distIndex` on the webserver row — the shipped preset
// directory sits beside the composition that names it, and no config author
// chooses it. This lane boots the shipped tree WITHOUT AppCLIEntry, so it
// has to supply the same fact or the roster resolves nothing and every
// session composes an agent with no tools, no persona, and no token meter.
// Only the shipped root: a developer's own `~/.dsh/.agent-presets` must not be
// able to change a golden.
{
id: 'agent-presets',
config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] },
},
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row
@@ -360,6 +414,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' }] }]
@@ -399,6 +456,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
// `cordis:group` beside it, exactly as `boot()` registers it: a group row is
// how a preset gives one `isolate` realm to a provider and its consumers,
// and a preset resolving package names from its own directory cannot reach
// `@cordisjs/plugin-group` by name.
ctx.loader.builtins.group = Group
// The shipped CLI deliberately has no dependency on this opt-in package.
// Keep the Loader row real without broadening the product installation.
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
@@ -449,6 +511,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
restoreCredentialEnvironment()
restoreSkillRootEnvironment()
if (cleanupFailures.length > 0) {
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
}
@@ -496,6 +559,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
} finally {
restoreCredentialEnvironment()
restoreSkillRootEnvironment()
}
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
},
@@ -562,6 +626,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.
*/
/**
@@ -585,7 +651,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]!
@@ -598,6 +669,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 {

View File

@@ -19,6 +19,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import { join } from 'node:path'
@@ -195,10 +196,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
const meter = scaffold.ctx.get('tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
const realized = realizeSeedFixture(scaffold, raw, SEED_ID)
await seedSession(scaffold, withCompaction(realized, meter), SEED_ID)
// The meter belongs to an agent's preset, not to the process — token
// accounting is per session. It is used here as a pure pricing function
// over fixture content, so a throwaway composition is enough to reach one.
const priced = await scaffold.ctx.agents.create({
sessionId: SessionId('seeded-history-pricing'),
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
let realizedWithCompaction: string
try {
const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
} finally {
await priced.dispose()
}
await seedSession(scaffold, realizedWithCompaction, SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -245,10 +258,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
const projections = body.result.value?.projections
expect(projections).toBeDefined()
expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
// The seed carries a session/title event: the title unit must serve it.
// The seed carries a session/title event: the title unit is host-plane, so
// it folds the detached log and serves the value with nothing composed.
expect(typeof projections?.values.title).toBe('string')
// tool-todo is composed but the seed has no todo/write: whole-value null,
// key PRESENT (absence would mean the unit never registered).
// `todos` IS here, as its empty fold (null). Its unit is registered by
// `tool-todo` inside the default preset's STANDING mount, which the read
// itself ensures — deterministically, not because some unrelated session
// happens to be composed. A present-but-null key is what keeps the
// client's "omitted key = capability absent → clear the row" rule from
// wiping preset-owned projections on cold reads.
expect(projections?.values).toHaveProperty('todos', null)
})

View File

@@ -12,6 +12,7 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-permission'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-commands'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
@@ -66,11 +67,26 @@ afterEach(async () => {
it('assembles the shipped Web catalog with the confined access default', async () => {
scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
const ctx = scaffold.ctx
// The catalog belongs to an AGENT, not to the process: every model-facing row
// now lives in a preset mounted under one session's scope, so the global
// layer holds nothing and a caller must name the agent to see anything. This
// composes from the deployment default — what a session that names no preset
// gets — which is the shape this test has always been about.
expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([])
const handle = await ctx.agents.create({
sessionId: SessionId('shipped-composition'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
const names = ctx.tools.schemas(handle.agent).map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
} finally {
await handle.dispose()
}
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and
@@ -83,18 +99,18 @@ it('assembles the shipped Web catalog with the confined access default', async (
expect(scaffold.ctx.approval.config.policy).toBe('ask')
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
const handle = await scaffold.ctx.agents.create({
const commandHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('shipped-command-catalog'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
try {
expect(scaffold.ctx.commands.list(handle.agent)).toContainEqual({
expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
name: 'feedback',
description: 'record feedback about this session',
input: { hint: '<text>' },
})
} finally {
await handle.dispose()
await commandHandle.dispose()
}
}, 120_000)

View File

@@ -0,0 +1,14 @@
- dialog "复制预设 · 复制自 极简模式":
- heading "复制预设 · 复制自 极简模式" [level=2]
- button "关闭":
- img
- paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。
- text: 标识符
- textbox "标识符":
- /placeholder: my-agent
- text: 名称
- textbox "名称":
- /placeholder: 选择器中显示的名字,缺省用标识符
- alert: 请填写标识符。
- button "取消"
- button "创建" [disabled]

View File

@@ -0,0 +1,81 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
- heading "内置" [level=3]
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
- text: 查看
- 'button "复制: 标准模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用。
- code: code
- 'button "查看: 代码模式"':
- img
- text: 查看
- 'button "复制: 代码模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。
- code: minimal
- 'button "查看: 极简模式"':
- img
- text: 查看
- 'button "复制: 极简模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
- code: cordis
- 'button "查看: 创造模式"':
- img
- text: 查看
- 'button "复制: 创造模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- list:
- listitem:
- 'button "设为默认: 我的模式"':
- text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。
- code: my-agent
- 'button "查看路径: 我的模式"':
- img
- text: 查看路径
- 'button "复制: 我的模式"':
- img
- text: 复制
- 'button "删除: 我的模式"':
- img
- text: 删除
- paragraph:
- text: 预设文件:
- code: {{presetRoot}}/my-agent
- button "用「创造模式」创作自定义预设":
- img
- text: 用「创造模式」创作自定义预设

View File

@@ -0,0 +1,93 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
- heading "内置" [level=3]
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
- text: 查看
- 'button "复制: 标准模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用。
- code: code
- 'button "查看: 代码模式"':
- img
- text: 查看
- 'button "复制: 代码模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。
- code: minimal
- 'button "查看: 极简模式"':
- img
- text: 查看
- 'button "复制: 极简模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
- code: cordis
- 'button "查看: 创造模式"':
- img
- text: 查看
- 'button "复制: 创造模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- list:
- listitem:
- 'button "已损坏: broken-yaml" [disabled]':
- text: broken-yaml 已损坏 自定义 暂无描述。
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
- code: broken-yaml
- 'button "查看路径: broken-yaml"':
- img
- text: 查看路径
- 'button "复制: broken-yaml" [disabled]':
- img
- text: 预设已损坏,无法复制
- 'button "删除: broken-yaml"':
- img
- text: 删除
- listitem:
- 'button "已损坏: 幽灵预设" [disabled]':
- text: 幽灵预设 已损坏 自定义 composition 已被手动删除。
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
- code: ghost
- 'button "查看路径: 幽灵预设"':
- img
- text: 查看路径
- 'button "复制: 幽灵预设" [disabled]':
- img
- text: 预设已损坏,无法复制
- 'button "删除: 幽灵预设"':
- img
- text: 删除
- button "用「创造模式」创作自定义预设":
- img
- text: 用「创造模式」创作自定义预设

View File

@@ -0,0 +1,63 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
- heading "内置" [level=3]
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
- text: 查看
- 'button "复制: 标准模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用。
- code: code
- 'button "查看: 代码模式"':
- img
- text: 查看
- 'button "复制: 代码模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。
- code: minimal
- 'button "查看: 极简模式"':
- img
- text: 查看
- 'button "复制: 极简模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
- code: cordis
- 'button "查看: 创造模式"':
- img
- text: 查看
- 'button "复制: 创造模式"':
- img
- text: 复制
- button "用「创造模式」创作自定义预设":
- img
- text: 用「创造模式」创作自定义预设

View File

@@ -0,0 +1,4 @@
- navigation "Session hierarchy":
- button "Seeded turn" [disabled]
- img
- text: 极简模式

View File

@@ -0,0 +1,8 @@
- button "Choose workspace":
- img
- text: workspace
- img
- button "标准模式":
- img
- text: 标准模式
- img

View File

@@ -0,0 +1,7 @@
- menu:
- menuitem "标准模式 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。":
- text: 标准模式 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- img
- menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用。"
- menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。"
- menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- 'button "Using ONE run_code program: run" [disabled]'
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use only Cordis tools. First" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use the bash tool to" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "workspace" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -25,6 +25,10 @@
- img
- text: workspace
- img
- button "标准模式":
- img
- text: 标准模式
- img
- textbox "Describe what you want to build"
- button "Commands":
- img

View File

@@ -25,6 +25,10 @@
- img
- text: workspace
- img
- button "标准模式":
- img
- text: 标准模式
- img
- textbox "Describe what you want to build"
- button "Commands":
- img

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- 'button "Plan a small change: add" [disabled]'
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -14,7 +16,7 @@
- paragraph: partial
- status: Deep diving...
- button "2 queued messages"
- textbox "Message the agent"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -30,7 +32,7 @@
- tooltip "Save queued message"
- button "Cancel editing":
- img
- textbox "Message the agent"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "workspace" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -29,7 +31,7 @@
- button "Clear goal":
- img
- button "2 queued messages"
- textbox "Message the agent"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -23,7 +25,7 @@
- img
- button "Steer queued message":
- img
- textbox "Message the agent"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write

View File

@@ -7,10 +7,17 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- text: Agent 预设 对此后新建的会话生效。运行中的会话保持它开始时的预设。
- button "标准模式":
- text: 标准模式
- img
- text: 权限 选择新会话的默认权限模式
- button "Workspace Write":
- text: Workspace Write

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "/user-invoke-demo and confirm the fixtur" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -0,0 +1,35 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- text: Running
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
- img
- img
- text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
- status: Deep diving...
- text: "Interjection Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- text: "Interjection Interjection: include the word ORANGE in your final reply."
- button "Copy":
- img
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"

View File

@@ -0,0 +1,47 @@
[
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "reasoning" },
{ "type": "reasoning-delta", "index": 0, "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." },
{ "type": "block-start", "index": 1, "blockType": "tool-call" },
{
"type": "tool-call-delta",
"index": 1,
"id": "call_00_steer_all",
"name": "ask_user_question",
"argumentsDelta": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "reasoning",
"text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
}
},
{
"type": "block-end",
"index": 1,
"block": {
"type": "tool-call",
"id": "call_00_steer_all",
"name": "ask_user_question",
"arguments": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"
}
},
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
},
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "text" },
{ "type": "text-delta", "index": 0, "text": "Got it: BANANA and ORANGE." },
{ "type": "block-end", "index": 0, "block": { "type": "text", "text": "Got it: BANANA and ORANGE." } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } },
{ "type": "finish", "reason": { "kind": "stop" } }
]
}
]

View File

@@ -0,0 +1,45 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
- img
- img
- text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
- button "Ask question 1/1 answered":
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}"
- button "Copy":
- img
- paragraph: "Got it: BANANA and ORANGE."
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Begin your reply with the" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Begin your reply with the" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use web_search to search exactly" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -34,6 +34,18 @@ const REPLAY_PACE_MS = 100
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.'
const STEER = 'Interjection: include the word BANANA in your final reply.'
// Empty-draft flush scenario: an override-only fixture. The whole-script
// replacement answers both model calls of a FRESH session (no recorded
// session.jsonl exists — call 0 keeps the turn open with a question-tool
// call, call 1 is the reply after both steerings drain).
const STEER_ALL_DIR = fileURLToPath(new URL('./snapshots/steer-all', import.meta.url))
const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl')
const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json')
const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md')
const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md')
const STEER_ONE = 'Interjection: include the word BANANA in your final reply.'
const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.'
/** Concatenated assistant text deltas — the model-visible reply body. */
function assistantText(events: SessionEvent[]): string {
return events
@@ -278,3 +290,103 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
expect(tripwire.warnings).toEqual([])
}, 90_000)
})
describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
// The scenario boots a fresh session against the override-only fixture;
// the replay.override.json sidecar replaces the derived script, so the
// (deliberately absent) session.jsonl is never read.
scaffold = await launchWebScaffold({
replayFixture: STEER_ALL_FIXTURE,
replayOverride: STEER_ALL_OVERRIDE,
paceMs: REPLAY_PACE_MS,
})
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
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 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-steer-all'))
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled(30_000)
// Call 0 streams a question-tool call; the fills must land inside the
// first replay window, before the question composer replaces the textarea.
await input.fill(PROMPT)
await input.press('Enter')
await input.fill(STEER_ONE)
await input.press('Enter')
await input.fill(STEER_TWO)
await input.press('Enter')
const dock = page.locator('[data-queue-dock]')
// Both messages queued: the two-row dock shows a collapsed count header,
// and Playwright text matching skips the hidden rows — expand the list,
// then assert each row's content.
await dock.getByText('2 queued messages').waitFor({ timeout: 10_000 })
await dock.getByRole('button').click()
await dock.getByText(STEER_ONE, { exact: true }).waitFor({ timeout: 10_000 })
await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 })
expect(await page.locator('[data-pending-steering]').count()).toBe(0)
// Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock
// empties, and the pending steering renders at the conversation tail.
await input.press('Meta+Enter')
await expect.poll(
() => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(),
{ timeout: 10_000 },
).toBe(2)
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
// The reasoning row streams independently of the steering handoff; wait
// for it so the mid snapshot pins the assistant step, not the pre-render
// gap a fast machine can catch between steering acceptance and the block.
await page.locator('[data-variant="think"]').first().waitFor({ timeout: 10_000 })
const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE)
// Answer the question; the step closes, the loop drains both steerings
// into one next-step request, and the final reply obeys both markers.
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: 30_000 })
await composer.getByRole('radio', { name: 'Yes' }).click()
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
await settled
const first = claimedMessages(sessionEvents, STEER_ONE)
const second = claimedMessages(sessionEvents, STEER_TWO)
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(assistantText(sessionEvents)).toContain('BANANA')
expect(assistantText(sessionEvents)).toContain('ORANGE')
await expect.poll(() => page.getByText(STEER_ONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText(STEER_TWO, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await page.locator('[data-pending-steering]').count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(STEER_ALL_DIR, [
'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md',
])
})
})

View File

@@ -446,7 +446,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
).toBe(3)
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
expect(await hierarchy.getByRole('button').count()).toBe(1)
await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1)
await compareOrRefreshGolden(
FORK_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),

View File

@@ -2,7 +2,7 @@
// Assembled todo snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the
// keyless FixtureApiClient transport, opens the fixture session, and pins the
// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`)
// two surfaces the fixture's parallel plan (turn 73, two items `in_progress`)
// reaches — the `todo_write` tool row and the dock's plan strip.
//
// The row is pinned as three separate fields on purpose. `summary=` is the