Merge remote-tracking branch 'github/master' into feat/web-queue-steer-all
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts
This commit is contained in:
@@ -9,8 +9,8 @@
|
||||
// model content as the question composer: the turn cannot complete without it).
|
||||
//
|
||||
// Geometry is the point of the scenario. The command is unbounded model text,
|
||||
// and before the cap a long one grew the card until the refuse/allow buttons
|
||||
// left the viewport — an approval the user could see and not answer.
|
||||
// and an uncapped card grows with it until the refuse/allow buttons leave the
|
||||
// viewport — an approval the user could see and not answer.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -36,8 +36,8 @@ const MODE = webSnapshotMode()
|
||||
|
||||
// Irreducible payload: the command has to be long enough to pass the card's
|
||||
// height cap, which is the only shape that reproduces an action row pushed off
|
||||
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
|
||||
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
|
||||
// screen. Unrelated tokens, not a repeated word — the model compresses a
|
||||
// repeated word into `printf 'alpha %.0s' {1..400}` when recording, and a
|
||||
// short command proves nothing here. The formula keeps the source small; the
|
||||
// model receives the expanded literal it has to put in the command.
|
||||
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
|
||||
@@ -89,7 +89,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
await input.fill('')
|
||||
|
||||
// Read-only: the mode whose denial the model escalates from. Switched
|
||||
// through the shipped access-mode chip, not a test-only seam.
|
||||
// through the shipped access-mode chip, not a test-only override.
|
||||
await page.locator('[aria-label^="Access mode"]').click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await expect.poll(
|
||||
@@ -115,9 +115,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
// The regression this scenario exists for: an uncapped card grew with
|
||||
// the command until the action row left the viewport. Measured at the
|
||||
// lane baseline and at a short viewport, on the live panel.
|
||||
// The uncapped-card hazard the header names, measured at the lane
|
||||
// baseline and at a short viewport, on the live panel.
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
for (const height of [1000, 700]) {
|
||||
await page.setViewportSize({ width: 900, height })
|
||||
|
||||
@@ -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,22 @@ 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-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{
|
||||
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 +38,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 +101,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) => {
|
||||
@@ -115,7 +119,7 @@ export function mountAssembledApp(): void {
|
||||
* Match a CSS-module class by its logical name.
|
||||
* Module class names carry a per-build hash in one of two schemes —
|
||||
* ui-primitives emits `_<name>_<hash>` (name bounded by underscores),
|
||||
* ui-conversation emits `<hash>_<name>` (name at the end) — and a longer name
|
||||
* feature bundles emit `<hash>_<name>` (name at the end) — and a longer name
|
||||
* containing this one must not match (`line` must not hit `lineNumber`).
|
||||
* @param el - element whose class list is inspected.
|
||||
* @param name - logical (unhashed) module class name.
|
||||
|
||||
@@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Resolve the resident approval so the ordinary composer bar (which owns
|
||||
// ContextMeter) resumes without replacing the session shell. This minimal
|
||||
// boot graph intentionally does not mount the separate question UI plugin.
|
||||
@@ -95,7 +94,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
|
||||
.map(style => style.getAttribute('data-plugin'))
|
||||
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
|
||||
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-tool']) {
|
||||
expect(styleOwners).toContain(plugin)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
@@ -160,6 +161,14 @@ function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>):
|
||||
.join('')
|
||||
}
|
||||
|
||||
function messageKey(event: SessionEvent<'user/message'>): string {
|
||||
return conversationContextKey('input-message', String(event.data.id))
|
||||
}
|
||||
|
||||
function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
||||
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
|
||||
}
|
||||
|
||||
describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => sessionEvents.slice(eventStart).some(event => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& userText(event).includes(spec.userMarker)
|
||||
)), { timeout: 15_000 }).toBe(true)
|
||||
const echoedUser = sessionEvents.slice(eventStart).find(
|
||||
(event): event is SessionEvent<'user/message'> => (
|
||||
event.type === 'user/message'
|
||||
@@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
),
|
||||
)
|
||||
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`)
|
||||
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await userRow.textContent()).toContain(spec.userMarker)
|
||||
@@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
|
||||
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
|
||||
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`)
|
||||
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
|
||||
|
||||
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
|
||||
// input only navigates to the semantic target; assertions pin content identity
|
||||
// and interaction routing rather than scroll geometry or mounted row counts.
|
||||
// Long-history Chat behavior contract that stays valid under a virtualized
|
||||
// renderer: wheel input only navigates to the semantic target; assertions pin
|
||||
// content identity and interaction routing rather than scroll geometry or
|
||||
// mounted row counts.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -10,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
@@ -115,6 +117,18 @@ function requiredEvent<T extends SessionEvent['type']>(
|
||||
return event
|
||||
}
|
||||
|
||||
function messageKey(event: SessionEvent<'user/message'>): string {
|
||||
return conversationContextKey('input-message', String(event.data.id))
|
||||
}
|
||||
|
||||
function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
||||
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
|
||||
}
|
||||
|
||||
function turnTailKey(turn: number): string {
|
||||
return conversationContextKey('turn-tail', String(turn))
|
||||
}
|
||||
|
||||
describe('web e2e: long Chat interaction contract', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -176,8 +190,10 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
const expectedUserText = textContent(branchUserEvent.data.content)
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
|
||||
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
|
||||
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
|
||||
const toolUserKey = messageKey(toolUserEvent)
|
||||
const toolAssistantKey = assistantKey(toolAssistantEvent)
|
||||
const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
|
||||
const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
|
||||
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
|
||||
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
|
||||
|
||||
@@ -186,28 +202,27 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
expect(await call1.count()).toBe(1)
|
||||
expect(await call2.count()).toBe(1)
|
||||
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
|
||||
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
|
||||
expect(await call1.textContent()).toContain(toolMarker1)
|
||||
expect(await call2.textContent()).toContain(toolMarker2)
|
||||
|
||||
const expectedOrder = [
|
||||
`node:${String(toolUserEvent.seq)}`,
|
||||
`call:${TARGET_CALL_1}`,
|
||||
`call:${TARGET_CALL_2}`,
|
||||
`node:${String(toolAssistantEvent.seq)}`,
|
||||
toolUserKey,
|
||||
conversationContextKey('tool-call', TARGET_CALL_1),
|
||||
conversationContextKey('tool-call', TARGET_CALL_2),
|
||||
toolAssistantKey,
|
||||
]
|
||||
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
|
||||
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
|
||||
.filter((key): key is string => key !== undefined && keys.includes(key))
|
||||
), expectedOrder)
|
||||
expect(actualOrder).toEqual(expectedOrder)
|
||||
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
||||
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
|
||||
const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
||||
element.closest<HTMLElement>('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
|
||||
))))
|
||||
expect(groupKeys[0]).not.toBeNull()
|
||||
expect(groupKeys[1]).toBe(groupKeys[0])
|
||||
expect(toolKinds).toEqual(['tool-call', 'tool-call'])
|
||||
|
||||
const summary1 = call1.locator('[data-sample="bash"]')
|
||||
const summary2 = call2.locator('[data-sample="bash"]')
|
||||
@@ -219,9 +234,12 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
|
||||
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
|
||||
const branchUserKey = messageKey(branchUserEvent)
|
||||
const branchAssistantKey = assistantKey(branchAssistantEvent)
|
||||
await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
|
||||
const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
|
||||
expect(await userRow.textContent()).toContain(branchUserMarker)
|
||||
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
@@ -230,8 +248,8 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
|
||||
.toBe(expectedUserText)
|
||||
|
||||
await assistantRow.hover()
|
||||
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
|
||||
await turnTailRow.hover()
|
||||
await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -40,6 +40,11 @@ const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
|
||||
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
|
||||
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
|
||||
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
|
||||
const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
|
||||
const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
|
||||
const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
|
||||
const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
|
||||
const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE'
|
||||
|
||||
const HISTORY_FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'HISTORY',
|
||||
@@ -58,6 +63,10 @@ const RESTORE_FIXTURE_B = createChatScrollFixture({
|
||||
title: 'CHAT_SCROLL_RESTORE_B comparison session',
|
||||
turns: 32,
|
||||
})
|
||||
const INPUTS_FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'INPUTS',
|
||||
title: 'CHAT_SCROLL_INPUTS non-wheel reader input session',
|
||||
})
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly distanceFromBottom: number
|
||||
@@ -273,6 +282,34 @@ async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch-style momentum fling over the transcript. Headless Chromium in the
|
||||
* test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture
|
||||
* and Input.dispatchTouchEvent both deliver DOM events without moving any
|
||||
* scroller, and compositor scrollbars ignore synthetic mouse input), so the
|
||||
* fling replays the signature a real pan leaves on the scrollport: per-frame
|
||||
* decaying displacements the component never authored, carrying no wheel
|
||||
* events. Wheel-sign semantics: positive deltaY reads downward.
|
||||
*/
|
||||
async function flingTranscript(page: Page, deltaY: number): Promise<void> {
|
||||
await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => {
|
||||
const direction = Math.sign(delta)
|
||||
let remaining = Math.abs(delta)
|
||||
// Fast launch decaying toward a floor speed, like a released finger. The
|
||||
// floor stays above the follow threshold so contended frames (streaming
|
||||
// writes racing the fling) still deviate far enough to read as input.
|
||||
let velocity = Math.max(120, remaining / 8)
|
||||
while (remaining > 0) {
|
||||
const step = Math.min(velocity, remaining)
|
||||
host.scrollTop += direction * step
|
||||
remaining -= step
|
||||
velocity = Math.max(48, velocity * 0.9)
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
}
|
||||
}, deltaY)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelToHistoryStart(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if ((await scrollGeometry(page)).scrollTop <= 1) break
|
||||
@@ -683,4 +720,112 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
// Keyboard is the only non-wheel device this lane's Chromium can drive for
|
||||
// real (see flingTranscript for the probe results on touch and scrollbars),
|
||||
// so it stands in for the whole hardware input pipeline here.
|
||||
it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-keyboard',
|
||||
seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
await openSeed(
|
||||
world.page,
|
||||
INPUTS_FIXTURE,
|
||||
INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns),
|
||||
)
|
||||
await expectBottom(world.page)
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
|
||||
// Focus rides the last seeded tool row (a tabbable button whose keydown
|
||||
// handler passes scrolling keys through). End first normalizes the
|
||||
// focus-driven scrollIntoView back to the floor.
|
||||
const lastToolRow = world.page.locator(
|
||||
`[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
|
||||
)
|
||||
await lastToolRow.focus()
|
||||
await world.page.keyboard.press('End')
|
||||
await expectBottom(world.page)
|
||||
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
||||
for (let press = 0; press < 3; press += 1) {
|
||||
await world.page.keyboard.press('PageUp')
|
||||
await nextPaint(world.page)
|
||||
}
|
||||
await backToBottom.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 })
|
||||
.toBeGreaterThan(100)
|
||||
await world.page.keyboard.press('End')
|
||||
await expectBottom(world.page)
|
||||
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-fling-stream',
|
||||
replay: [
|
||||
replayEntry(toolStream()),
|
||||
replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)),
|
||||
],
|
||||
seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
|
||||
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
|
||||
await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
let released = false
|
||||
try {
|
||||
const composer = world.page.locator('textarea:enabled').last()
|
||||
await composer.fill(LIVE_FLING_PROMPT)
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
|
||||
await expectBottom(world.page)
|
||||
|
||||
// Fling away while the turn is mid-flight: the scroll burst alone must
|
||||
// release bottom ownership, exactly like a wheel scroll would, even
|
||||
// while streaming keeps re-asserting the floor between frames.
|
||||
await flingTranscript(world.page, -900)
|
||||
await backToBottom.waitFor({ timeout: 10_000 })
|
||||
const awayAnchor = await visibleFlowAnchor(world.page)
|
||||
const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await writeFile(releasePath, 'release\n')
|
||||
released = true
|
||||
await expect.poll(
|
||||
() => world.events.some(event => event.type === 'tool/result'),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(true)
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksBeforeRelease + 5)
|
||||
await expectSameFlowTop(world.page, awayAnchor)
|
||||
|
||||
// Fling back to the floor: re-pin must come from the reader's scroll
|
||||
// itself, and follow must then own the still-streaming tail. The
|
||||
// retry loop chases the floor that streaming keeps pushing down.
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break
|
||||
await flingTranscript(world.page, 1_600)
|
||||
}
|
||||
await expectBottom(world.page)
|
||||
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
||||
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksAtRepin + 5)
|
||||
await expectBottom(world.page)
|
||||
} finally {
|
||||
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
|
||||
}
|
||||
|
||||
await settled
|
||||
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
await world.page.getByText(LIVE_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expectBottom(world.page)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ const MODE = webSnapshotMode()
|
||||
|
||||
// The scenario's one drive prompt: elicits one program with a bash sub-call
|
||||
// and a failing read the program tolerates — the sub-row set the assertions
|
||||
// (and the PR gif) need. Never asserted against model prose.
|
||||
// need. Never asserted against model prose.
|
||||
const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
|
||||
+ 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows'))
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// The parent run_code row wears the code variant with the model-authored
|
||||
// description as its summary (the PR1 presentCall contract).
|
||||
// description as its summary (the presentCall contract).
|
||||
const codeRow = page.locator('[data-variant="code"]').first()
|
||||
await codeRow.waitFor({ timeout: 10_000 })
|
||||
// Nested rows are visible WITHOUT any expand interaction, inside the
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
|
||||
// highlight, the chips and the ghost hint.
|
||||
//
|
||||
// Two layers can only stay together by moving together. They now do: both sit
|
||||
// Two layers can only stay together by moving together. They do: both sit
|
||||
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
|
||||
// tall as the whole draft — so one offset, applied by the browser, moves the
|
||||
// caret and the words in the same frame. Scrolling the textarea and assigning
|
||||
@@ -192,7 +192,7 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
* Absolute glyph coordinates are deliberately absent: they depend on font
|
||||
* metrics and would make the fixture fail on a machine that measures text
|
||||
* differently — a golden that needs re-recording per platform documents the
|
||||
* platform, not the change. What is recorded is the cap, the caret-to-glyph
|
||||
* platform, not the behavior. What is recorded is the cap, the caret-to-glyph
|
||||
* relation, and which lines are on screen, each a comparison that survives any
|
||||
* layout keeping the coupling.
|
||||
* @param top - metrics with the draft scrolled to its start.
|
||||
@@ -297,10 +297,10 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
|
||||
// A layer that breaks lines somewhere else puts the words under the wrong
|
||||
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
|
||||
// draft. The three now share a containing block — the scrollport — so a
|
||||
// scrollbar that consumes layout space costs them the same width; before,
|
||||
// only the textarea scrolled, and WebKit reserved gutter space for it alone
|
||||
// (768 against 776) while chromium and firefox did not.
|
||||
// draft. All three share a containing block — the scrollport — so a
|
||||
// scrollbar that consumes layout space costs them the same width; with
|
||||
// only the textarea scrolling, WebKit reserves gutter space for it alone
|
||||
// (768 against 776) while chromium and firefox do not.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
|
||||
// The mirror decides the box height, so it belongs in the same equality —
|
||||
@@ -315,7 +315,7 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
// The reported symptom, isolated. A scroll offset changes and the caret's
|
||||
// distance to its own glyphs is re-read before the task ends — before any
|
||||
// `scroll` listener could have run. With the layers on one scrollport the
|
||||
// browser moved both, so the distance is unchanged; with the glyph layer
|
||||
// browser moves both, so the distance is unchanged; with the glyph layer
|
||||
// catching up in a listener it is off by the whole delta until a later
|
||||
// frame, which is a caret flying away from its text mid-gesture.
|
||||
const metrics = await measureComposer(page)
|
||||
@@ -348,9 +348,10 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
|
||||
// The other way the box moves, and the one that depends on the browser: the
|
||||
// textarea no longer scrolls, so revealing the caret after an edit is a
|
||||
// scroll-into-view that has to walk up to the scrollport. Scroll away from
|
||||
// the caret first, so the edit has somewhere to bring it back from.
|
||||
// textarea holds no scroll offset of its own, so revealing the caret after
|
||||
// an edit is a scroll-into-view that has to walk up to the scrollport.
|
||||
// Scroll away from the caret first, so the edit has somewhere to bring it
|
||||
// back from.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.press('End')
|
||||
await input.hover()
|
||||
@@ -368,9 +369,9 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
|
||||
// The composer suppresses the native paste — the machine owns the draft and
|
||||
// the undo log — and restores the caret programmatically, which reveals
|
||||
// nothing on its own: measured in chromium and WebKit, the view stayed
|
||||
// where it was while the caret sat at the end of the pasted block. The
|
||||
// restore now scrolls it into view, and this is the case that proves it.
|
||||
// nothing on its own: in chromium and WebKit the view stays put while the
|
||||
// caret sits at the end of the pasted block, so the restore scrolls it
|
||||
// into view; this case pins it.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill('one short line')
|
||||
await input.press('End')
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
// gets an absolutely positioned seat instead, laid out against the padding box,
|
||||
// which the scrollbar never reduces.
|
||||
//
|
||||
// So the two tabs disagreed by exactly the bar's width for as long as the
|
||||
// transcript overflowed: the card jumped sideways on every tab switch, and
|
||||
// inside Chat alone at the moment a growing transcript started to scroll. The
|
||||
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
|
||||
// and states the overlay branch as a scroll container on the same axes, so both
|
||||
// edges are the same edge.
|
||||
// Without a shared reservation the two tabs disagree by exactly the bar's
|
||||
// width for as long as the transcript overflows: the card jumps sideways on
|
||||
// every tab switch, and inside Chat alone at the moment a growing transcript
|
||||
// starts to scroll. The column reserves the gutter unconditionally
|
||||
// (`scrollbar-gutter: stable`) and states the overlay branch as a scroll
|
||||
// container on the same axes, so both edges are the same edge.
|
||||
//
|
||||
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
|
||||
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
|
||||
@@ -26,14 +26,14 @@
|
||||
//
|
||||
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
|
||||
// which is load-bearing rather than incidental. Under that argument a scroll
|
||||
// container's bar consumes no layout width at all, so the two tabs agree before
|
||||
// this change as much as after it and every comparison below holds vacuously —
|
||||
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
|
||||
// and 0 with the argument dropped. Dropping it is also the faithful
|
||||
// container's bar consumes no layout width at all, so the two tabs agree with
|
||||
// and without the reservation and every comparison below holds vacuously —
|
||||
// measured: the unreserved cascade leaves both tabs' bands at 0 there, against
|
||||
// 8 and 0 with the argument dropped. Dropping it is also the faithful
|
||||
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
|
||||
// and a bar that occupies layout space is what the product actually draws.
|
||||
//
|
||||
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
|
||||
// The scenario runs that unreserved cascade in the page — `scrollbar-gutter: auto`
|
||||
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
|
||||
// same two tabs through it, which is what keeps the equal rectangles above from
|
||||
// being explained by a tab switch that never reached the layout. It is the
|
||||
@@ -64,7 +64,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry',
|
||||
* that has to be re-recorded per platform. What is recorded is the distance
|
||||
* between the two tabs' rectangles, which is zero when the reservation holds and
|
||||
* the bar's width when it does not — including under the control, so the golden
|
||||
* carries the difference the fix removes rather than only its absence.
|
||||
* carries the shift the unreserved cascade produces rather than only its absence.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -114,10 +114,10 @@ async function setMeasuredViewport(
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-fix cascade, injected into the page: the reservation dropped and the
|
||||
* overlay branch back to a hidden box. `!important` beats the module rules
|
||||
* without a rebuild, and the id lets the control be lifted again in the same
|
||||
* session.
|
||||
* The unreserved cascade, injected into the page: the reservation dropped and
|
||||
* the overlay branch forced to a hidden box. `!important` beats the module
|
||||
* rules without a rebuild, and the id lets the control be lifted again in the
|
||||
* same session.
|
||||
*/
|
||||
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
|
||||
const CONTROL_CSS = `
|
||||
@@ -221,9 +221,9 @@ async function compareTabs(page: Page): Promise<TabComparison> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pre-fix cascade in the page for one measurement, then lift it.
|
||||
* Run the unreserved cascade in the page for one measurement, then lift it.
|
||||
* @param page - the page under test.
|
||||
* @returns the comparison as the column laid out before this change.
|
||||
* @returns the comparison as the column lays out without the reservation.
|
||||
*/
|
||||
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
|
||||
await page.evaluate(({ id, css }) => {
|
||||
@@ -328,7 +328,7 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
|
||||
const comparison = await compareTabs(page)
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
// The reservation reaches both states, which is the whole change: the same
|
||||
// The reservation reaches both states, which is the whole point: the same
|
||||
// band, on a box that scrolls and on one that only holds a view.
|
||||
expect(comparison.chat.gutter).toBe('stable')
|
||||
expect(comparison.trajectory.gutter).toBe('stable')
|
||||
@@ -348,8 +348,8 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const comparison = await compareTabs(page)
|
||||
// The reported symptom as a number. At this viewport the card sits at its
|
||||
// width cap, so the pre-fix shift showed up as a centring difference — half
|
||||
// the band on each edge — rather than as a width change.
|
||||
// width cap, so the unreserved cascade's shift shows up as a centring
|
||||
// difference — half the band on each edge — rather than as a width change.
|
||||
expect(comparison.leftShift).toBe(0)
|
||||
expect(comparison.rightShift).toBe(0)
|
||||
expect(comparison.widthShift).toBe(0)
|
||||
@@ -363,7 +363,7 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
|
||||
const comparison = await compareTabs(page)
|
||||
// The other geometry, and a different failure: below the cap the card takes
|
||||
// the column's width, so an unreserved gutter changed its WIDTH by the whole
|
||||
// the column's width, so an unreserved gutter changes its WIDTH by the whole
|
||||
// band instead of shifting it by half. Asserted against the capped
|
||||
// measurement rather than against the cap's pixel value, which belongs to
|
||||
// the stylesheet.
|
||||
@@ -379,17 +379,17 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
// The control: without it, equal rectangles could also mean the tab switch
|
||||
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
|
||||
// its bar and the Trajectory branch goes back to a hidden box with none, and
|
||||
// the card moves by half the band on each edge.
|
||||
// never reached the layout. Under the unreserved cascade the Chat scroller
|
||||
// keeps its bar and the Trajectory branch becomes a hidden box with none,
|
||||
// and the card moves by half the band on each edge.
|
||||
const comparison = await compareTabsWithoutReservation(page)
|
||||
expect(comparison.chat.gutter).toBe('auto')
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
expect(comparison.trajectory.band).toBe(0)
|
||||
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
|
||||
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
|
||||
// Restoring the sheet restores the fix, so the control cannot leak into the
|
||||
// remaining measurements.
|
||||
// Restoring the sheet restores the reservation, so the control cannot leak
|
||||
// into the remaining measurements.
|
||||
const restored = await compareTabs(page)
|
||||
expect(restored.leftShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Web e2e scenario: the conversation column scrolls on one axis only, as the
|
||||
// browser actually lays it out. The reported symptom was a horizontal
|
||||
// scrollbar under the whole center column once the window (or the sidebar
|
||||
// drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the
|
||||
// column and becoming user-scrollable.
|
||||
// browser actually lays it out. The hazard: a horizontal scrollbar appears
|
||||
// under the whole center column once the window (or the sidebar drag) narrows
|
||||
// it — the hero's decorative backdrop ellipse bleeds past the column and
|
||||
// becomes user-scrollable.
|
||||
//
|
||||
// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
|
||||
// hero box (ConversationRoot.module.css) so the blur scales with the input
|
||||
// card. What changed is the scroll container: `[data-conversation-scroll]`
|
||||
// scrolls vertically, and a box that scrolls in one axis computes the other
|
||||
// axis's initial `visible` to `auto`, so the bleed came back as a bar. The
|
||||
// fix states `overflow-x: hidden` there.
|
||||
// card. The scroll container is where the bar comes from:
|
||||
// `[data-conversation-scroll]` scrolls vertically, and a one-axis scroller
|
||||
// computes the other axis's initial `visible` to `auto`, so the bleed becomes
|
||||
// a bar; `overflow-x: hidden` on the scroller prevents it.
|
||||
//
|
||||
// Only a real engine reports that pair — the bleed and the resulting scroll
|
||||
// range — so the scenario sweeps viewport widths that bracket the glow's
|
||||
@@ -36,7 +36,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-over
|
||||
* Committed golden of the one-axis relation at every stop. It records
|
||||
* relations and booleans, never absolute coordinates: the column width follows
|
||||
* the viewport and the sidebar, and a golden carrying pixels would document the
|
||||
* platform instead of the change.
|
||||
* platform instead of the behavior.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -60,17 +60,20 @@ interface ColumnMetrics {
|
||||
columnWidth: number
|
||||
/** Resolved `overflow-x` on the conversation scroll container. */
|
||||
overflowX: string
|
||||
/** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */
|
||||
/**
|
||||
* True when the glow's box reaches past the column's content edge — the
|
||||
* condition the `overflow-x: hidden` declaration has to survive.
|
||||
*/
|
||||
glowBleeds: boolean
|
||||
/**
|
||||
* `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
|
||||
* `auto` both report the same value, because `hidden` clips the bleed rather
|
||||
* than reflowing it away. Recorded because it is the vacuity guard in
|
||||
* numbers — it must stay positive at the narrow stops, or the scenario has
|
||||
* stopped reproducing the situation the fix is for.
|
||||
* stopped reproducing the situation `overflow-x: hidden` exists for.
|
||||
*/
|
||||
bleedRange: number
|
||||
/** True when the column still scrolls vertically — the axis the fix must not take away. */
|
||||
/** True when the column still scrolls vertically — the axis `overflow-x: hidden` must not take away. */
|
||||
scrollsVertically: boolean
|
||||
}
|
||||
|
||||
@@ -108,9 +111,10 @@ function measureColumn(page: Page, width: number): Promise<ColumnMetrics> {
|
||||
* This is the one signal that separates the two states, and it is why the
|
||||
* scenario needs a real engine: `overflow-x: hidden` leaves the box
|
||||
* programmatically scrollable and leaves `scrollWidth` untouched, so every
|
||||
* property reading agrees across the fix. Only refusing an actual input event
|
||||
* differs — measured at the 1200px stop, the shipped column stays at 0 while
|
||||
* the same page with `overflow-x: auto` forced on lands at its scroll boundary.
|
||||
* property reading agrees across the two overflow modes. Only refusing an
|
||||
* actual input event differs — measured at the 1200px stop, the shipped
|
||||
* column stays at 0 while the same page with `overflow-x: auto` forced on
|
||||
* lands at its scroll boundary.
|
||||
* @param page - the page under test.
|
||||
* @returns `scrollLeft` after one horizontal wheel over the column.
|
||||
*/
|
||||
@@ -175,10 +179,10 @@ type ColumnStop = ColumnMetrics & {
|
||||
* Render the golden body: one line per stop, relations only.
|
||||
*
|
||||
* Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
|
||||
* which the fix pins to 0 by construction. The bleed is recorded as a boolean
|
||||
* rather than its width, so the golden survives any platform whose column
|
||||
* lands a pixel off — a fixture that has to be re-recorded per platform
|
||||
* documents the platform, not the change.
|
||||
* which the shipped overflow mode pins to 0 by construction. The bleed is
|
||||
* recorded as a boolean rather than its width, so the golden survives any
|
||||
* platform whose column lands a pixel off — a fixture that has to be
|
||||
* re-recorded per platform documents the platform, not the behavior.
|
||||
* @param stops - the measured stops, in sweep order.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
@@ -272,18 +276,19 @@ describe('web e2e: the conversation column scrolls on one axis', () => {
|
||||
// The reported symptom, stated directly: a horizontal wheel over the
|
||||
// column moves nothing, at every stop.
|
||||
expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
|
||||
// The axis the column is a scroller for must survive the fix.
|
||||
// The axis the column is a scroller for must survive `overflow-x: hidden`.
|
||||
expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('reports the pre-fix state when the axis is opened back up', async () => {
|
||||
it('scrolls horizontally again once the axis is opened back up (control)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
|
||||
// The mutation control, run in the page rather than against a second
|
||||
// build: it restores exactly what the fix changed — the initial `visible`
|
||||
// that a one-axis scroller computes to `auto` — and shows the same gesture,
|
||||
// at the same timing, carrying the column to its positive scroll boundary.
|
||||
// build: it lifts exactly the `overflow-x: hidden` declaration, so the
|
||||
// initial `visible` that a one-axis scroller computes to `auto` takes
|
||||
// over, and shows the same gesture, at the same timing, carrying the
|
||||
// column to its positive scroll boundary.
|
||||
// Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
|
||||
// Injected with an id rather than through `addStyleTag`, so the teardown
|
||||
// below can take the sheet out again by selector: it must not outlive this
|
||||
|
||||
95
apps/web/tests/declared-reasoning.e2e.ts
Normal file
95
apps/web/tests/declared-reasoning.e2e.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the
|
||||
// composer's effort pane — the levels a settings profile declares are exactly
|
||||
// what the picker offers, and picking one records it with the Agent default.
|
||||
// Zero model calls: declaring, describing, and switching are settings/llm
|
||||
// traffic only, so there is no fixture and a stray stream would fail loud.
|
||||
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 { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Starts the shipped default on this scenario's declared reasoning model. */
|
||||
const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
// The whole reasoning offer is the profile: key = selectable level, value
|
||||
// = the wire spelling dispatch would send (`max: ultra` renames; the
|
||||
// valueless `off` means "supported, send nothing"). The route sets no
|
||||
// deployment default, so the pane leads with the provider-default entry.
|
||||
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{
|
||||
id: 'acme-think',
|
||||
name: 'Acme Think',
|
||||
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
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 })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('offers exactly the declared levels and records the picked one', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /推理等级/ }).click()
|
||||
|
||||
// Declared levels, nothing else: the provider-default entry (the route
|
||||
// configures no `reasoning`), then Off/High/Max — minimal, low, medium,
|
||||
// and xhigh were not declared and must not be offered.
|
||||
const levels = page.getByRole('menuitemradio')
|
||||
await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 })
|
||||
.toEqual(['Default', 'Off', 'High', 'Max'])
|
||||
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
// Picking a level is the same gesture that saves the default selection, so
|
||||
// the effort lands in the Agent default Settings section beside provider/model.
|
||||
await page.getByRole('menuitemradio', { name: 'High' }).click()
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('reasoningEffort: high')
|
||||
await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('选择模型,当前 Acme Think,推理等级 High')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
8
apps/web/tests/declared-reasoning.overlay.yml
Normal file
8
apps/web/tests/declared-reasoning.overlay.yml
Normal 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. This scenario
|
||||
# starts the default on its own declared reasoning model so the effort pane
|
||||
# describes that model from the first open.
|
||||
- id: agent-default-model
|
||||
config:
|
||||
provider: acme-gateway
|
||||
model: acme-think
|
||||
167
apps/web/tests/default-model.e2e.ts
Normal file
167
apps/web/tests/default-model.e2e.ts
Normal 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 shared `agent-default-model` 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 because the adapter registry is empty. 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 shared Agent 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 shared Agent-route 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('agent-default-model:')
|
||||
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)
|
||||
})
|
||||
8
apps/web/tests/default-model.overlay.yml
Normal file
8
apps/web/tests/default-model.overlay.yml
Normal 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 refuses to type into. This scenario declares its own
|
||||
# pi-ai routes and starts the default on one of them.
|
||||
- id: agent-default-model
|
||||
config:
|
||||
provider: origin-gateway
|
||||
model: origin-large
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
|
||||
// gateway), reload replays everything from the log (zero further model
|
||||
// calls), and the theme scenario proves the shipped dark palette actually
|
||||
// cascades: attribute -> alias token flip -> painted surface change. Per the
|
||||
// lane's scope ruling there is no theme/layout golden (aria is color-blind);
|
||||
// the hero's waiting state gets the one golden here.
|
||||
// cascades: attribute -> alias token flip -> painted surface change. No
|
||||
// theme/layout golden: aria snapshots are color-blind (lane scope: the
|
||||
// browser-e2e-lane Agent Note); the hero's waiting state gets the one golden
|
||||
// here.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -112,8 +113,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||
await planButton.waitFor({ timeout: 10_000 })
|
||||
// The golden encodes an empty composer, and the button arriving does not
|
||||
// mean the submitted text is gone yet: under load the capture caught a
|
||||
// textbox still holding `/plan`.
|
||||
// mean the submitted text is gone yet: under load the capture can catch
|
||||
// a textbox still holding `/plan`.
|
||||
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
|
||||
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
|
||||
@@ -159,7 +160,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') {
|
||||
@@ -232,7 +233,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
|
||||
it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
|
||||
// This scenario pins the ThemeService's DOM contract seam directly (the
|
||||
// This scenario pins the ThemeService's DOM contract directly (the
|
||||
// body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
|
||||
// user gesture above it (Settings -> Appearance cubes) is owned by
|
||||
// settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
|
||||
// and transient-retry recovery, all through the real composition and wire.
|
||||
// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
|
||||
// The model adapter is dsh-llm-replay with override sidecars: `hang` (+ a
|
||||
// readyFile marker) makes mid-stream cancel deterministic by construction,
|
||||
// `throw` entries express provider failures by stable code, and `{ patches }`
|
||||
// augmentation injects a transient throw before the recorded success so
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// provider status. The customized-settings fold writes the curated
|
||||
// reasoning field as a merge patch. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud on the open seam. The provider under test is
|
||||
// stray stream would fail loud because the adapter registry is empty. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
// never shadow the derived reference. The deletion dialog distinguishes a
|
||||
// reference-free profile from a page-managed key before the credential and
|
||||
@@ -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()
|
||||
@@ -78,6 +79,26 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('refuses a key no HTTP header can carry before anything is written', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
const key = dialog.getByLabel('API 密钥')
|
||||
const save = dialog.getByRole('button', { name: '保存', exact: true })
|
||||
|
||||
// A key no HTTP header can carry would save cleanly and fail the first
|
||||
// turn with a ByteString TypeError; the form names the offending field
|
||||
// instead.
|
||||
await key.fill('sk-\u{1F600}minimax')
|
||||
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
|
||||
|
||||
// Clearing it restores submit: an empty field means "keep what is stored",
|
||||
// never a refusal, or editing any other setting would demand the key.
|
||||
await key.fill('')
|
||||
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it('saves a blank key as a reference-free provider-native profile', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -114,7 +135,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(),
|
||||
@@ -126,11 +147,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)
|
||||
|
||||
@@ -139,22 +160,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: '设置' })
|
||||
@@ -177,7 +231,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(),
|
||||
@@ -189,7 +243,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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -331,7 +331,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||||
// squeeze the output pane below its content width and the line must keep
|
||||
// its single row and overflow sideways instead of folding. Soft-wrapping
|
||||
// here is what shredded the column alignment this card exists to hold.
|
||||
// here shreds the column alignment this card exists to hold.
|
||||
const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
|
||||
const pane = node as HTMLElement
|
||||
const row = pane.querySelector<HTMLElement>('[class*="_line_"]')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Loader overlay for the W5 real-host smoke (`dsh web --patch`): pin the
|
||||
# Loader overlay for the real-host smoke (`dsh web --patch`): pin the
|
||||
# in-browser directory picker. The shipped row is `-auto`, which resolves to
|
||||
# the native OS chooser on a loopback bind with a local display — an
|
||||
# interaction a Playwright page cannot drive, so the resolved backend would
|
||||
|
||||
162
apps/web/tests/produced-file-mentions.e2e.ts
Normal file
162
apps/web/tests/produced-file-mentions.e2e.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds
|
||||
// a built write turn (zero model calls) whose closing message names the written
|
||||
// file three ways: by unique basename (links), ambiguously (stays inert), and
|
||||
// as a file the turn never touched (stays inert). Package tests cover the
|
||||
// resolver in isolation; only the assembled application shows a real write's
|
||||
// locations reaching the prose as an opener. 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 (the produced-files restraint).
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'produced-file-mentions-web-e2e'
|
||||
const DONE = 'FILE_MENTION_DONE'
|
||||
|
||||
/** One-part text content for a built message. */
|
||||
function text(value: string): { type: 'text'; text: string }[] {
|
||||
return [{ type: 'text', text: value }]
|
||||
}
|
||||
|
||||
/** The files the built turn writes; `notes.md` is named in prose but never written. */
|
||||
const WRITES = ['site/report.html', 'a/style.css', 'b/style.css']
|
||||
|
||||
/** Build a settled write turn whose closing prose mentions files in inline code. */
|
||||
function mentionFixture(): string {
|
||||
const session = Session.create(SessionId('produced-file-mentions-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'Produced file mentions',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const calls = WRITES.map((path, index) => ({
|
||||
path,
|
||||
callId: CallId(`file-mention-${String(index)}`),
|
||||
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
|
||||
}))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: calls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
id: call.callId,
|
||||
name: 'write',
|
||||
arguments: call.args,
|
||||
})),
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
for (const call of calls) {
|
||||
const source = session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: call.callId,
|
||||
name: 'write',
|
||||
arguments: call.args,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: call.callId,
|
||||
content: text(`Created ${call.path}`),
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
|
||||
}
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createAssistantMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
|
||||
'',
|
||||
DONE,
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: inline-code mentions of produced files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, mentionFixture(), 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')('links the unique mention and leaves ambiguous and unknown code inert', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions'))
|
||||
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()
|
||||
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
// Exactly one prose mention links: `report.html` resolves to the written
|
||||
// path; the shared `style.css` basename and unwritten `notes.md` stay code.
|
||||
const mentions = page.locator('[class*="markdown"] code button')
|
||||
await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await mentions.first().innerText()).toBe('report.html')
|
||||
expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
|
||||
expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
|
||||
// The turn still ends with its produced-files row (all three writes).
|
||||
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
76
apps/web/tests/produced-files.e2e.ts
Normal file
76
apps/web/tests/produced-files.e2e.ts
Normal 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)
|
||||
})
|
||||
27
apps/web/tests/pwa-manifest.e2e.ts
Normal file
27
apps/web/tests/pwa-manifest.e2e.ts
Normal 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',
|
||||
}],
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
|
||||
// api-proxy recomputes presentation views from logged args/result content)
|
||||
// must render as a bash-shaped terminal card with the parsed exit-status
|
||||
// pill — not the generic console-fenced card the pwsh presenter used to
|
||||
// emit. The seed is authored, not recorded: its header line carries no `cwd`
|
||||
// pill — not a generic console-fenced card. The seed is authored, not
|
||||
// recorded: its header line carries no `cwd`
|
||||
// field (seedSession writes the session cwd itself, and a Windows temp path
|
||||
// substituted into the header would not round-trip through its JSON parse),
|
||||
// and no event references the workspace, so the lane replays on any host
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenario: fresh round trip. A real chromium types a prompt into the
|
||||
// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo
|
||||
// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless)
|
||||
// in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless)
|
||||
// or the live adapter (record). Drive steps run in every mode and wait only
|
||||
// on generic completion (whenTurnSettled — never model-content selectors, so
|
||||
// record cannot hang on a live model answering differently); assertion steps
|
||||
|
||||
@@ -32,7 +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 { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import {
|
||||
addHarnessSourceSection,
|
||||
assertEntriesLoaded,
|
||||
@@ -45,6 +45,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, {
|
||||
@@ -93,6 +97,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 => ({
|
||||
@@ -107,7 +151,7 @@ export interface WebScaffold {
|
||||
mode: WebSnapshotMode
|
||||
/** Browser-facing origin for the bound test server. */
|
||||
baseUrl: string
|
||||
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
|
||||
/** Settled root context (the in-process readiness barrier; headless event subscription is its sanctioned use). */
|
||||
ctx: Context
|
||||
/** Temp project directory sessions run in (bash/fs tool cwd). */
|
||||
workspaceCwd: string
|
||||
@@ -390,6 +434,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 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)
|
||||
@@ -474,11 +528,14 @@ function rawSessionLog(session: Session): string {
|
||||
export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise<void> {
|
||||
const agent = scaffold.ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
|
||||
const tokenized = scrubRequestHeaders(rawSessionLog(agent.session))
|
||||
const fresh = scrubRequestHeaders(rawSessionLog(agent.session))
|
||||
.split(sessionId).join('{{sessionId}}')
|
||||
.split(scaffold.workspaceCwd).join('{{cwd}}')
|
||||
.replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
|
||||
await writeFile(fixturePath, tokenized)
|
||||
const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : ''
|
||||
const stable = stabilizeFixtureMessageIds([fresh], [existing])[0]
|
||||
if (stable === undefined) throw new Error('record harvest: no stabilized fixture')
|
||||
await writeFile(fixturePath, stable)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,8 +620,9 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and
|
||||
* decode-throughput volatility collapse to stable tokens.
|
||||
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration,
|
||||
* decode-throughput, and path-sensitive compaction estimates collapse to
|
||||
* stable tokens.
|
||||
*
|
||||
* Throughput needs a token for the same reason durations do, and no fixture
|
||||
* can supply one: the figure divides a replayed step's output tokens by the
|
||||
@@ -591,6 +649,9 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
duration => duration.startsWith('约') ? duration : '{{duration}}',
|
||||
)
|
||||
.replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}')
|
||||
// Seeded compaction prices realized file paths, whose length differs
|
||||
// between local worktrees and CI scratch directories.
|
||||
.replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2')
|
||||
// Message IconActions clocks widen by calendar day/year; collapse every
|
||||
// shape so goldens stay stable across midnight and year boundaries.
|
||||
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @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
|
||||
// session, and pins the search card the `grep` turn (fixture turn 67) renders in
|
||||
// the assembled application. The built-boot smoke proves the graph boots but
|
||||
// carries no behavior assertions by contract; this is the assembled-output check
|
||||
// that a broken SearchRow registration or a dropped card would fail — the
|
||||
@@ -51,7 +51,7 @@ describe('assembled search card', () => {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||
// Wait for chat content to reach the fixture's later turns (the bash sample
|
||||
// is turn 65, the grep card turn 66).
|
||||
// is turn 66, the grep card turn 67).
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// history RPC, history-page tool views, and the client's log-ordered transcript
|
||||
// events — with ZERO model calls in replay (no replay fixture; a stray stream
|
||||
// fails loud on the open llm seam). The cold session also carries the one
|
||||
// keyless command-row surface: an Access-chip pick runs `/permission` on the
|
||||
// host, so the settled row's copy has a golden here. The seed is a recorded
|
||||
// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds
|
||||
// into its checkpoint, while an Access-chip pick later runs `/permission` on
|
||||
// the host. The seed is a recorded
|
||||
// fixture under the
|
||||
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
|
||||
// live through the composer (real read tool against seeded workspace files)
|
||||
@@ -39,18 +40,18 @@ const SEED_ID = 'seeded-history-web-e2e'
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
|
||||
/**
|
||||
* Append a complete, valid compaction transaction over the recorded turn's own
|
||||
* surface. The recording stays model-authentic and reusable; replay adds this
|
||||
* deterministic condition before seeding it cold, so the scenario pins the bug
|
||||
* this change fixes — a landed compaction must not erase history the reader
|
||||
* already saw — through the real host and the real browser.
|
||||
* Append a complete manual `/compact` lifecycle and valid compaction transaction
|
||||
* over the recorded turn's own surface. The recording stays model-authentic and
|
||||
* reusable; replay adds this deterministic condition before seeding it cold, so
|
||||
* the scenario pins both the log-preserving marker and its single-card command
|
||||
* presentation through the real host and browser.
|
||||
* @param raw - the seed fixture text, already realized (placeholder-free) so
|
||||
* the shadow price below is computed from the exact strings the host folds.
|
||||
* @param meter - the composed token meter; the appended `compact/summary`'s
|
||||
* shadow price must be the exact heuristic price of the shadowed nodes, the
|
||||
* way compact-basic derives it, because the token-meter projections subtract
|
||||
* it verbatim.
|
||||
* @returns the fixture with a compacted turn appended.
|
||||
* @returns the fixture with a manual compaction lifecycle appended.
|
||||
*/
|
||||
function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
const lines = raw.trimEnd().split('\n')
|
||||
@@ -73,28 +74,32 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
if (first === undefined || last === undefined || tail === undefined) {
|
||||
throw new Error('seeded-history compaction requires a non-empty closed surface')
|
||||
}
|
||||
// The transaction opens the turn after the recording's last closed one; read
|
||||
// it from the fixture so a re-recording with a different turn count stays
|
||||
// valid instead of appending a duplicate turn number.
|
||||
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
|
||||
if (typeof lastTurn !== 'number') {
|
||||
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
|
||||
}
|
||||
const turn = lastTurn + 1
|
||||
let seq = tail.seq + 1
|
||||
let time = tail.time + 1
|
||||
/**
|
||||
* Append one event at the next seq/time.
|
||||
* @param event - the event body, without seq/time.
|
||||
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
|
||||
* @returns the assigned seq, so later `sourceEventSeqs` cite the pushed event directly.
|
||||
*/
|
||||
const at = (event: Record<string, unknown>): number => {
|
||||
const taken = seq++
|
||||
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
|
||||
return taken
|
||||
}
|
||||
at({ type: 'turn/start', data: { turn } })
|
||||
const startSeq = at({ type: 'compact/start', data: { turn } })
|
||||
const commandId = 'cmd-seeded-manual-compact'
|
||||
const compactionId = 'compact-seeded-manual-compact'
|
||||
at({
|
||||
type: 'command/run',
|
||||
data: { commandId, name: 'compact', args: '', source: { kind: 'user' } },
|
||||
})
|
||||
const startSeq = at({
|
||||
type: 'compact/start',
|
||||
data: { compactionId, sourceCommandId: commandId, turn: null },
|
||||
})
|
||||
// Load-bearing exactness: the projections subtract this count verbatim, so
|
||||
// it must equal what the host's fold prices for these nodes. The estimator
|
||||
// prices message CONTENT only, so a minimal wrapper per storage shape is
|
||||
@@ -123,6 +128,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
const summarySeq = at({
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
compactionId,
|
||||
sourceCommandId: commandId,
|
||||
summary: [{
|
||||
type: 'text',
|
||||
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
|
||||
@@ -141,13 +148,31 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
type: 'text',
|
||||
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
source: {
|
||||
kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId,
|
||||
},
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: first, end: last },
|
||||
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
|
||||
})
|
||||
at({ type: 'compact/end', data: { turn } })
|
||||
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
at({
|
||||
type: 'compact/end',
|
||||
data: { compactionId, sourceCommandId: commandId, turn: null },
|
||||
})
|
||||
at({
|
||||
type: 'command/done',
|
||||
data: {
|
||||
commandId,
|
||||
kind: 'success',
|
||||
text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`,
|
||||
sourceEventSeq: summarySeq,
|
||||
},
|
||||
})
|
||||
// The persistence seed helper requires a terminal turn/end. Keep the manual
|
||||
// command standalone, then add a closed zero-step fixture boundary after it.
|
||||
const closureTurn = lastTurn + 1
|
||||
at({ type: 'turn/start', data: { turn: closureTurn } })
|
||||
at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } })
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
@@ -239,14 +264,18 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await sessionRow.click()
|
||||
// Settled barrier for history: the recorded final assistant text renders.
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
|
||||
timeout: 10_000,
|
||||
}).toBe(1)
|
||||
expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
|
||||
// Tool cards render from logged tool/call + tool/result alone (views are
|
||||
// host-recomputed per page; the generic card is the documented default).
|
||||
const toolRows = page.locator('[data-variant], [data-sample]')
|
||||
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
|
||||
// The bug this fixes: the compaction shadowed the whole recorded surface on
|
||||
// the model side, and the prompt and full tool output are still on screen.
|
||||
// The pinned hazard: compaction shadows the surface on the model side
|
||||
// only — the prompt and full tool output must stay on screen.
|
||||
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
|
||||
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
@@ -280,10 +309,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}}')
|
||||
@@ -363,7 +392,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
|
||||
const marker = page.getByRole('button', { name: /Context compacted/ })
|
||||
const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
|
||||
await marker.waitFor({ timeout: 10_000 })
|
||||
expect(await marker.getAttribute('aria-expanded')).toBe('false')
|
||||
await marker.click()
|
||||
|
||||
@@ -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)
|
||||
@@ -244,7 +270,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await selector.click()
|
||||
await page.getByRole('menuitem', { name: 'English' }).click()
|
||||
// The settings-owned copy re-registers localized: dialog title, nav,
|
||||
// Appearance labels. (Only the settings namespaces are localized today —
|
||||
// Appearance labels. (Only the settings namespaces are localized —
|
||||
// the rest of the app's copy is intentionally out of this row's scope.)
|
||||
const enDialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await enDialog.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
|
||||
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-commands'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
/**
|
||||
@@ -28,6 +30,7 @@ const EXPECTED_TOOLS = [
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'interrupt_agent',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
@@ -79,4 +82,19 @@ it('assembles the shipped Web catalog with the confined access default', async (
|
||||
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
|
||||
expect(scaffold.ctx.approval.config.policy).toBe('ask')
|
||||
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
|
||||
|
||||
const handle = 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({
|
||||
name: 'feedback',
|
||||
description: 'record feedback about this session',
|
||||
input: { hint: '<text>' },
|
||||
})
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Web e2e scenario: the sidebar session list's scrollbar as the browser
|
||||
// actually lays it out — the observable half of the themed-scrollbar change
|
||||
// actually lays it out — the observable half of the themed scrollbars
|
||||
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
|
||||
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
|
||||
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
|
||||
@@ -17,8 +17,8 @@
|
||||
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
|
||||
// content, consuming no layout width unless something reserves space. That is
|
||||
// the mode in which the reported symptom exists at all, so this environment
|
||||
// reproduces it rather than merely approximating it — measured against clean
|
||||
// master, where the list's band is 0 and the bar covers 7px of the relative
|
||||
// reproduces it rather than merely approximating it — without either
|
||||
// declaration the list's band is 0 and the bar covers 7px of the relative
|
||||
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
|
||||
// the bar and nothing can be covered; a headed run under xvfb behaves that way
|
||||
// and cannot show the symptom.)
|
||||
@@ -40,9 +40,8 @@
|
||||
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
|
||||
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
|
||||
// so it abuts the timestamp without covering it; `band` catches that case.
|
||||
// Removing both — the actual master state — is what produces the reported
|
||||
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
|
||||
// the other assertions in its test silenced.
|
||||
// Removing both is what produces the reported overlap, and `timeCoveredBy`
|
||||
// measures it at 7.
|
||||
//
|
||||
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
|
||||
// to `transparent` while the pointer is outside the column), so every
|
||||
@@ -135,7 +134,8 @@ interface ListMetrics {
|
||||
/**
|
||||
* Measure the sidebar list in the page.
|
||||
* @param page - the page under test.
|
||||
* @returns the list's resolved scrollbar style and the geometry the fix changes.
|
||||
* @returns the list's resolved scrollbar style and the geometry the
|
||||
* scrollbar-gutter/thin-scrollbar declarations shape.
|
||||
*/
|
||||
function measureList(page: Page): Promise<ListMetrics> {
|
||||
return page.evaluate(() => {
|
||||
@@ -201,8 +201,8 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
// The bar is drawn in the rightmost `barWidth` of the border box, whether
|
||||
// or not that space was reserved. Its width comes from the sheet where the
|
||||
// sheet applies, and from the UA's own overlay bar otherwise — 15px is
|
||||
// what this chromium paints, measured against master where the rule is
|
||||
// absent. Taking the UA width as the fallback is what keeps the assertion
|
||||
// what this chromium paints, measured with the rule absent. Taking the
|
||||
// UA width as the fallback is what keeps the assertion
|
||||
// honest: assuming 0 there would report no occlusion precisely in the
|
||||
// state that has it.
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
|
||||
@@ -261,13 +261,14 @@ async function measurePalette(page: Page): Promise<PaletteMetrics> {
|
||||
|
||||
/**
|
||||
* Render the golden body: the resolved scrollbar style of the list in each
|
||||
* palette, plus the geometric relations the fix establishes.
|
||||
* palette, plus the geometric relations the scrollbar-gutter/thin-scrollbar
|
||||
* declarations establish.
|
||||
*
|
||||
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
|
||||
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
|
||||
* committing them would make the golden fail on a machine whose fonts measure
|
||||
* differently — a fixture that has to be re-recorded per platform documents the
|
||||
* platform, not the change. What is recorded instead is the band, the overlap,
|
||||
* platform, not the behavior. What is recorded instead is the band, the overlap,
|
||||
* and the two orderings, each of which is a difference or a comparison and so
|
||||
* survives any layout that keeps the reservation.
|
||||
* @param light - metrics measured under the light palette.
|
||||
@@ -417,12 +418,13 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(metrics.scrollbarEdgeOffset).toBe(2)
|
||||
expect(metrics.rowEdgeInset).toBe(12)
|
||||
// The reported symptom, stated directly: no part of the row's relative time
|
||||
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
|
||||
// covered part. Unlike the client-edge comparison below it does not go
|
||||
// vacuous under an overlay scrollbar, because it measures against the bar's
|
||||
// own width rather than against a content edge the overlay bar does not
|
||||
// move. It is not a replacement for the band assertion above; see the file
|
||||
// header for which regression each one catches.
|
||||
// lies under the bar. Without either declaration it measures 7 — the `h`
|
||||
// of `1h` is the covered part. Unlike the client-edge comparison below it
|
||||
// does not go vacuous under an overlay scrollbar, because it measures
|
||||
// against the bar's own width rather than against a content edge the
|
||||
// overlay bar does not move. It is not a replacement for the band
|
||||
// assertion above; see the file header for which regression each one
|
||||
// catches.
|
||||
expect(metrics.timeCoveredBy).toBe(0)
|
||||
// Corollaries of the reservation, kept because they pin where the band sits
|
||||
// rather than only that it exists: the time ends inside the content area,
|
||||
@@ -451,7 +453,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(quiet.band).toBeGreaterThan(0)
|
||||
expect(quiet.timeCoveredBy).toBe(0)
|
||||
// Scrolling without a pointer — what a keyboard or a touch drag does —
|
||||
// leaves the column quiet. This is the change's one deliberate loss, and
|
||||
// leaves the column quiet. This is the one deliberate loss, and
|
||||
// it is pinned here rather than only described, so making a scroll
|
||||
// re-reveal the bar has to be a decision rather than a side effect.
|
||||
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
|
||||
|
||||
156
apps/web/tests/sidebar-subagent-activity.e2e.ts
Normal file
156
apps/web/tests/sidebar-subagent-activity.e2e.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { 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 type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionId as SessionIdValue } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import type {} from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-subagent-activity', import.meta.url))
|
||||
const RUNNING_OWNER_EXPECTED = join(SNAPSHOT_DIR, 'owner-running.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const HOLD_PROVIDER = 'web-test-hold'
|
||||
const HOLD_MODEL = 'hold'
|
||||
|
||||
/** Model stub that completes the owner turn, then holds its delegated child open. */
|
||||
class StagedAdapter extends LlmAdapter {
|
||||
activeCalls = 0
|
||||
private calls = 0
|
||||
|
||||
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (this.calls === 0) {
|
||||
this.calls += 1
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
return
|
||||
}
|
||||
this.calls += 1
|
||||
const signal = options.signal
|
||||
if (signal === undefined) throw new Error('staged Web adapter requires a turn signal')
|
||||
this.activeCalls += 1
|
||||
try {
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error('holding Web adapter aborted'))
|
||||
}
|
||||
if (signal.aborted) abort()
|
||||
else signal.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
} finally {
|
||||
this.activeCalls -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRunningChild(
|
||||
scaffold: WebScaffold,
|
||||
adapter: StagedAdapter,
|
||||
childId: SessionIdValue,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (adapter.activeCalls !== 1 || scaffold.ctx.agents.get(childId)?.status !== 'running') {
|
||||
if (Date.now() >= deadline) throw new Error('held child did not enter its running model call')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: sidebar subagent activity', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let parentHandle: AgentHandle
|
||||
let childId: SessionIdValue
|
||||
let adapter: StagedAdapter
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
adapter = new StagedAdapter()
|
||||
scaffold.ctx.effect(
|
||||
() => scaffold.ctx.llm.registerAdapter([HOLD_PROVIDER], adapter),
|
||||
'sidebar subagent activity staged adapter',
|
||||
)
|
||||
const cwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
await mkdir(cwd)
|
||||
parentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('sidebar-activity-owner'),
|
||||
meta: { cwd },
|
||||
agentOptions: { provider: HOLD_PROVIDER, model: HOLD_MODEL },
|
||||
})
|
||||
parentHandle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Delegate a background task.' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await parentHandle.agent.whenIdle()
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'sidebar activity child',
|
||||
signal: new AbortController().signal,
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: 'Hold this delegated task open.' }],
|
||||
parent: parentHandle.agent,
|
||||
},
|
||||
})
|
||||
childId = started.childId
|
||||
await waitForRunningChild(scaffold, adapter, childId)
|
||||
|
||||
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)
|
||||
const workspace = await scaffold.ctx.workspace.resolveByPath(cwd)
|
||||
if (workspace === undefined) throw new Error('connected Web workspace was not registered')
|
||||
await workspace.attachSession(parentHandle.agent.session.id)
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
const child = childId === undefined ? undefined : scaffold?.ctx.agents.get(childId)
|
||||
if (child !== undefined) {
|
||||
child.cancel({ kind: 'user' })
|
||||
await child.whenIdle().catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await parentHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'sidebar subagent activity teardown failed')
|
||||
})
|
||||
|
||||
it('pins a running descendant on its visible idle owner row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-subagent-activity'))
|
||||
const sidebar = page.getByRole('tree', { name: 'Sessions' })
|
||||
const ownerRow = sidebar.getByRole('treeitem', { name: /1 subagent running Delegate a background task/ })
|
||||
await ownerRow.waitFor({ timeout: 10_000 })
|
||||
expect(parentHandle.agent.status).toBe('idle')
|
||||
await compareOrRefreshGolden(
|
||||
RUNNING_OWNER_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
expect(await ownerRow.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await ownerRow.click()
|
||||
const runningTrigger = page.getByRole('button', { name: '1 subagent running' })
|
||||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
// Web e2e scenario: the real host filters skill.list to the model-and-user
|
||||
// intersection before the browser slash source renders candidates. A real
|
||||
// Web e2e scenario: the real host serves every user-invocable skill to the
|
||||
// browser slash source — user-only (disable-model-invocation) entries appear
|
||||
// with their marker while user-disabled quadrants stay hidden. A real
|
||||
// chromium connects a fresh workspace seeded with all four policy quadrants;
|
||||
// no model call is issued, so a stray stream fails loud on the open LLM seam.
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
@@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders only the model-and-user intersection in slash candidates', async () => {
|
||||
it('renders every user-invocable skill and marks the user-only entry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/policy')
|
||||
@@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => {
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
// The user-only quadrant is invocable here — its only entry point — and
|
||||
// wears the user-only marker; both user-disabled quadrants stay hidden.
|
||||
expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1)
|
||||
expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
|
||||
80
apps/web/tests/skill-tool-row.e2e.ts
Normal file
80
apps/web/tests/skill-tool-row.e2e.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Web e2e scenario: the real skill-load recording, seeded cold through the
|
||||
// persistence seam, renders through ui-skill's keyed toolview without a model
|
||||
// call. The disclosure proves replay-stable naming and exact durable output.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
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 {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/skill-load/session.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'skill-tool-row-web-e2e'
|
||||
const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, 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 })
|
||||
|
||||
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()
|
||||
await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('expands the loaded skill to its exact recorded instructions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row'))
|
||||
const call = page.locator('[data-tool="skill"]')
|
||||
const row = call.getByRole('button', { name: 'Skill snapshot-skill' })
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1)
|
||||
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
|
||||
await call.getByText('Instructions', { exact: true }).waitFor()
|
||||
const output = call.locator('pre')
|
||||
await output.waitFor()
|
||||
expect(await output.textContent()).toContain('<skill_content name="snapshot-skill">')
|
||||
expect(await output.textContent()).toContain('Follow these snapshot-only instructions.')
|
||||
expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px')
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
150
apps/web/tests/skill-user-invoke.e2e.ts
Normal file
150
apps/web/tests/skill-user-invoke.e2e.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Web e2e scenario: a user invokes a disable-model-invocation skill through
|
||||
// the composer (issue #1470). The entered `/name args` line claims into
|
||||
// skill.invoke: the real host forwards the gesture as an ordinary user
|
||||
// prompt, injects the rendered body as instructions context named after the
|
||||
// skill, and starts a turn answered by the replay adapter. The transcript shows
|
||||
// the gesture bubble, the collapsed context-injection row, and the reply.
|
||||
import { mkdir, mkdtemp, 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 { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const SKILL_NAME = 'user-invoke-demo'
|
||||
const ARGS_TEXT = 'and confirm the fixture wiring'
|
||||
const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
|
||||
|
||||
async function seedUserOnlySkill(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 user-explicit invocation of a model-hidden skill',
|
||||
'disable-model-invocation: true',
|
||||
'---',
|
||||
'',
|
||||
'Reply with the fixture acknowledgement line.',
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
const REPLAY: ReplayOverrideDoc = [{
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: REPLY },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: REPLY } },
|
||||
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}]
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(REPLAY))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
// Paced replay keeps the timing-derived chrome (TTFT / tok/s) present
|
||||
// deterministically; instant playback races it in and out of the golden.
|
||||
paceMs: 10,
|
||||
})
|
||||
await seedUserOnlySkill(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 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed')
|
||||
})
|
||||
|
||||
it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
|
||||
const composer = page.locator('textarea:enabled').last()
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
|
||||
// The menu lists the user-only skill (its only entry point) before enter.
|
||||
await composer.fill(`/${SKILL_NAME}`)
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await expect.poll(
|
||||
() => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
|
||||
await composer.press('Enter')
|
||||
|
||||
// The gesture stays an ordinary user bubble (decorated /name token plus
|
||||
// the trailing text), ahead of the injected context.
|
||||
const bubble = page.locator('[data-ref-chip="skill"]').first()
|
||||
await bubble.waitFor({ timeout: 15_000 })
|
||||
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
|
||||
|
||||
// The rendered body arrives as a context-injection row named after the
|
||||
// skill; expanding it reveals the canonical <skill_content> block, and
|
||||
// the user's text is NOT folded into it.
|
||||
const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
|
||||
await injectionRow.waitFor({ timeout: 15_000 })
|
||||
await injectionRow.click()
|
||||
const injectionBody = page
|
||||
.locator('[data-context-injection-body]')
|
||||
.filter({ hasText: `<skill_content name="${SKILL_NAME}">` })
|
||||
await injectionBody.waitFor({ timeout: 10_000 })
|
||||
const injected = await injectionBody.textContent()
|
||||
expect(injected).toContain('Reply with the fixture acknowledgement line.')
|
||||
expect(injected).not.toContain(ARGS_TEXT)
|
||||
await injectionRow.click()
|
||||
|
||||
// The injection started a turn; the replay adapter answers it.
|
||||
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
|
||||
await settled
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
|
||||
// Real-host smoke: spawn `dsh web` with a real key, walk the full flow
|
||||
// list in a real chromium, screenshot every screen into .artifacts/ for the
|
||||
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
|
||||
// convention); vitest.web.config.ts loads the repo-root .env before this file
|
||||
@@ -11,8 +11,9 @@
|
||||
// (frame/handle) rides local names that survive hashing as suffixes; prefer
|
||||
// data-* for anything new.
|
||||
//
|
||||
// Flow order matters: chat rounds first (5 depends on 3's session), geometry
|
||||
// and theme after, reload recovery last. Tests run sequentially in-file.
|
||||
// Flow order matters: chat rounds first (the bash round reuses the first
|
||||
// send's session), geometry and theme after, reload recovery last. Tests run
|
||||
// sequentially in-file.
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@@ -119,7 +120,7 @@ async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
|
||||
/** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */
|
||||
async function screen(page: Page, name: string): Promise<void> {
|
||||
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
|
||||
}
|
||||
@@ -465,7 +466,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key)', () => {
|
||||
let child: ChildProcess
|
||||
let sessionsDir: string
|
||||
let baseUrl: string
|
||||
@@ -518,7 +519,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('1 cold start: loading page settles into the three-column frame', async () => {
|
||||
it('cold start: loading page settles into the three-column frame', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
|
||||
@@ -527,7 +528,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '01-cold-start')
|
||||
})
|
||||
|
||||
it('2+3 empty-state first send completes a real model round', async () => {
|
||||
it('empty-state first send completes a real model round', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
|
||||
// This scenario spawns its own server against a fresh $DSH_HOME, so the
|
||||
// first-run welcome notice is unacknowledged and its overlay owns pointer
|
||||
@@ -590,7 +591,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '07-back-to-chat')
|
||||
})
|
||||
|
||||
it('5 bash differential rendering: tool row click leaves the default details column closed', async () => {
|
||||
it('bash differential rendering: tool row click leaves the default details column closed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
|
||||
@@ -604,12 +605,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await toolRow.click()
|
||||
// Tool rows no longer drive layout.openDetails; the default column stays closed.
|
||||
// Tool rows do not drive layout.openDetails; the default column stays closed.
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await screen(page, '09-details-closed')
|
||||
}, 150_000)
|
||||
|
||||
it('6 sidebar drag widens the column and resets across reload', async () => {
|
||||
it('sidebar drag widens the column and resets across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
|
||||
const before = await firstTrack(page)
|
||||
const handle = page.locator('[class*="handle"]').first()
|
||||
@@ -627,10 +628,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
expect(await firstTrack(page)).toBe(before)
|
||||
})
|
||||
|
||||
it('7 dark mode: the body attribute cascades the token sheets', async () => {
|
||||
it('dark mode: the body attribute cascades the token sheets', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-dark'))
|
||||
// theme.apply === toggling this attribute (v3 §8); no switcher UI owns it
|
||||
// in P-I, so the acceptance drives the documented mechanism directly.
|
||||
// The body attribute is the documented cascade mechanism; the Settings
|
||||
// gesture is owned by settings-chrome.e2e.ts — drive the attribute
|
||||
// directly here.
|
||||
const dark = await page.evaluate(() => {
|
||||
document.body.setAttribute('data-ds-dark-theme', '')
|
||||
return getComputedStyle(document.body).backgroundColor
|
||||
@@ -643,7 +645,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
expect(dark).not.toBe(light)
|
||||
})
|
||||
|
||||
it('8 reload recovery: history replays after a fresh boot', async () => {
|
||||
it('reload recovery: history replays after a fresh boot', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-reload'))
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 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
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
- menu "模型与推理等级":
|
||||
- menuitemradio "Default" [checked]:
|
||||
- text: Default
|
||||
- img
|
||||
- menuitemradio "Off"
|
||||
- menuitemradio "High"
|
||||
- menuitemradio "Max"
|
||||
@@ -1,6 +1,7 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Commands
|
||||
- option "compact Compact older conversation history" [selected]
|
||||
- option "feedback record feedback about this session"
|
||||
- option "goal set or view the goal for a long-running task"
|
||||
- option "permission Switch the permission preset (sandbox mode + approval policy)"
|
||||
- option "plan Enter or leave plan mode"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -30,8 +30,8 @@
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: Details
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 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
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 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
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 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
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 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
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 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
|
||||
|
||||
@@ -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: 添加自定义提供方
|
||||
@@ -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 "恢复默认模型"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
kind=matches
|
||||
summary=显示 9 / 共 42 处匹配 · 3 个文件
|
||||
file=packages/client/ui-primitives/src/SearchBlock.tsx3
|
||||
file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4
|
||||
file=packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx4
|
||||
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
line=138: export function SearchBlock(props: SearchBlockProps) {
|
||||
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
line=35: const search = searchCardModel(block)
|
||||
line=52: search={search}
|
||||
line=78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
line=36: const search = searchCardModel(block)
|
||||
line=56: search={search}
|
||||
line=78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
expand=… 其余 4 行
|
||||
recovery=Found 9 of 42 matches
|
||||
|
||||
@@ -15,13 +15,13 @@ packages/client/ui-primitives/src/SearchBlock.tsx
|
||||
Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
Line 138: export function SearchBlock(props: SearchBlockProps) {
|
||||
Line 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
packages/client/ui-conversation/src/client/contract/search-card-model.ts
|
||||
Line 24: export const CHAT_SEARCH_MAX_LINES = 8
|
||||
Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
packages/client/ui-conversation/src/client/toolviews/search-row.tsx
|
||||
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
Line 35: const search = searchCardModel(block)
|
||||
Line 52: search={search}
|
||||
Line 78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
packages/client/ui-tool/src/client/tool/models/search-card-model.ts
|
||||
Line 45: export const CHAT_SEARCH_MAX_LINES = 8
|
||||
Line 130: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx
|
||||
Line 34: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
Line 36: const search = searchCardModel(block)
|
||||
Line 56: search={search}
|
||||
Line 78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
|
||||
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)
|
||||
@@ -31,9 +31,7 @@
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- button "Context compacted View compaction summary":
|
||||
- img
|
||||
- text: Context compacted View compaction summary
|
||||
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
|
||||
- button "Context injection AGENTS.md":
|
||||
- img
|
||||
- img
|
||||
@@ -44,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Read Only"': Read Only
|
||||
- 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
|
||||
|
||||
@@ -31,9 +31,7 @@
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- button "Context compacted View compaction summary":
|
||||
- img
|
||||
- text: Context compacted View compaction summary
|
||||
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
|
||||
- button "Context injection AGENTS.md":
|
||||
- img
|
||||
- img
|
||||
@@ -42,8 +40,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 2 sessions" [expanded]:
|
||||
- img
|
||||
- text: workspace 2 sessions
|
||||
- treeitem "1 subagent running Delegate a background task. now"
|
||||
- treeitem "New Session" [selected]
|
||||
@@ -1,3 +1,4 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Skills
|
||||
- option "policy-shared Available to both model and user invocation" [selected]
|
||||
- option "policy-user-only user-only · Available only to user invocation"
|
||||
|
||||
45
apps/web/tests/snapshots/skill-tool-row/ui.expected.md
Normal file
45
apps/web/tests/snapshots/skill-tool-row/ui.expected.md
Normal file
@@ -0,0 +1,45 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Load the snapshot-skill skill with" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Context injection skill-catalog":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection skill-catalog
|
||||
- button "Think Load the requested skill.":
|
||||
- img
|
||||
- img
|
||||
- text: Think Load the requested skill.
|
||||
- button "Skill snapshot-skill" [expanded]:
|
||||
- img
|
||||
- text: Skill snapshot-skill
|
||||
- region "Instructions": "Instructions <skill_content name=\"snapshot-skill\"> <skill_resources> Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. </skill_resources> <skill_instructions> Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. </skill_instructions> </skill_content>"
|
||||
- button "Inspect"
|
||||
- button "Think The skill is loaded.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The skill is loaded.
|
||||
- paragraph: DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{date}} {{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 "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
|
||||
33
apps/web/tests/snapshots/skill-user-invoke/ui.expected.md
Normal file
33
apps/web/tests/snapshots/skill-user-invoke/ui.expected.md
Normal file
@@ -0,0 +1,33 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "/user-invoke-demo and confirm the fixtur" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Context injection user-invoke-demo":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection user-invoke-demo
|
||||
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
|
||||
- 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 · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok
|
||||
@@ -0,0 +1,23 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Explain event sourcing in one sentence. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled]
|
||||
- button "Commands" [disabled]:
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write
|
||||
- button "Stop generating"
|
||||
- button "Send message" [disabled]
|
||||
@@ -3,10 +3,10 @@
|
||||
// A page load with a workspace already registered runs
|
||||
// `WorkspacesService.startInitialSelection`: it connects the most recent
|
||||
// workspace and opens its blank session. `openState` flips to `loading` the
|
||||
// moment `open()` lands, which used to drive `data-phase=settling` on the
|
||||
// conversation root — `visibility:hidden` over the composer seat and the
|
||||
// header for the whole `session.history` round-trip, so the center column went
|
||||
// blank and repainted, reading as a full-page refresh on every launch.
|
||||
// moment `open()` lands; driving `data-phase=settling` on the conversation
|
||||
// root from that flip would hide the composer seat and the header
|
||||
// (`visibility:hidden`) for the whole `session.history` round-trip — the
|
||||
// center column blanks and repaints like a full-page refresh on every launch.
|
||||
//
|
||||
// The unit spec pins the phase condition over hand-built stores. What only the
|
||||
// assembled application can show is that the path a user actually takes
|
||||
@@ -19,8 +19,9 @@
|
||||
// The round-trip against a loopback host is far too fast to observe, so this
|
||||
// scenario HOLDS the `session.history` response open at the browser's network
|
||||
// boundary and asserts the visible frame while it is in flight. That gate is
|
||||
// what makes the assertions non-vacuous: with the exemption reverted the held
|
||||
// window is exactly when `settling` is painted and the composer is hidden.
|
||||
// what makes the assertions non-vacuous: without the phase exemption, the
|
||||
// held window is exactly when `settling` would be painted and the composer
|
||||
// hidden.
|
||||
//
|
||||
// Zero model calls: registering a workspace and opening its blank session are
|
||||
// host RPCs with no model involvement. A stray stream would fail loud with
|
||||
@@ -145,7 +146,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()
|
||||
|
||||
@@ -365,22 +365,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
() => scaffold.ctx.agents.get(childId)?.status,
|
||||
{ timeout: 10_000 },
|
||||
).toBe('running')
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button').first().click()
|
||||
const runningTrigger = page.getByRole('button', { name: '3 subagents running' })
|
||||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await runningTrigger.click()
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*running`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
await ended
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*not running`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByRole('button', { name: '3 subagents' })
|
||||
.locator('[data-state="ongoing"]').count()).toBe(0)
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
|
||||
|
||||
319
apps/web/tests/subagent-interrupt-ui.e2e.ts
Normal file
319
apps/web/tests/subagent-interrupt-ui.e2e.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
// Web e2e scenario: the composer's independent Stop interrupts a running
|
||||
// continuable child. The child holds its model turn open through a replay
|
||||
// hang entry; the browser proves Send and Stop coexist, the parent-offline
|
||||
// disabled-Send-with-Stop composer, the subagent.interrupt
|
||||
// (never session.cancel) transport, the parked follow-up, and the FIFO resume
|
||||
// on a waking send.
|
||||
//
|
||||
// Replay-binding note: only the PRIMARY script can hang, and scripts bind by
|
||||
// first-call order, so the child issues the composition's first model call
|
||||
// (claiming the overridden primary) and the parent's one UI prompt — needed
|
||||
// so the non-blank parent renders its header catalog — binds to a derived
|
||||
// child fixture afterwards.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
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 type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-interrupt', import.meta.url))
|
||||
const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const LABEL = 'event-sourcing researcher'
|
||||
const INITIAL = 'Explain event sourcing in one sentence.'
|
||||
const REARM = 'Keep working until I stop you again.'
|
||||
const REARM_WAKE = 'Start that queued work now.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const WAKING = 'And add one concrete example.'
|
||||
const REARMED_ANSWER = 're-armed setup answer'
|
||||
const PARKED_ANSWER = 'parked follow-up answer'
|
||||
const WAKING_ANSWER = 'waking answer'
|
||||
|
||||
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
|
||||
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve on one exact child's next aborted turn end. */
|
||||
function waitForAbortedTurn(scaffold: WebScaffold, childId: SessionId): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error('interrupt did not reach an aborted turn/end'))
|
||||
}, 30_000)
|
||||
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
if (session.id !== childId || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
if (event.data.reason.kind === 'aborted') resolve()
|
||||
else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
|
||||
function textCompletion(text: string): object {
|
||||
return {
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running continuable child', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let sidecarRoot: string
|
||||
let rearmedReadyFile: string
|
||||
let parent: Agent
|
||||
let childId: SessionId
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const apiCalls: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-'))
|
||||
const readyFile = join(sidecarRoot, 'hang-ready')
|
||||
rearmedReadyFile = join(sidecarRoot, 'hang-rearmed-ready')
|
||||
// The child claims this whole-script replacement: the offline and online
|
||||
// interrupt paths each hold one turn, then the parked and waking turns settle.
|
||||
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
|
||||
{ kind: 'hang', readyFile },
|
||||
{ kind: 'hang', readyFile: rearmedReadyFile },
|
||||
textCompletion(REARMED_ANSWER),
|
||||
textCompletion(PARKED_ANSWER),
|
||||
textCompletion(WAKING_ANSWER),
|
||||
]))
|
||||
await writeFile(
|
||||
join(sidecarRoot, 'session.jsonl'),
|
||||
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
|
||||
)
|
||||
// The parent's one prompted turn replays this recorded single text-only
|
||||
// call (binding is positional, not lineage-aware).
|
||||
const parentTurnPath = join(sidecarRoot, 'parent-turn.jsonl')
|
||||
const base = await readFile(BASE_FIXTURE, 'utf8')
|
||||
const [header, ...events] = base.trimEnd().split('\n')
|
||||
if (header === undefined) throw new Error('base replay fixture has no header')
|
||||
await writeFile(parentTurnPath, [
|
||||
header
|
||||
.replace('"id":"{{sessionId}}"', '"id":"recorded-parent-turn"')
|
||||
.replace(/"createdAt":\d+/, '"createdAt":1784998084442'),
|
||||
...events,
|
||||
'',
|
||||
].join('\n'))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(sidecarRoot, 'session.jsonl'),
|
||||
replayOverride: join(sidecarRoot, 'replay.override.json'),
|
||||
replayChildFixtures: [parentTurnPath],
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const root = scaffold.ctx.agents.roots()[0]
|
||||
if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent')
|
||||
parent = root
|
||||
// The child's first model call claims the primary override and holds.
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: LABEL,
|
||||
signal: new AbortController().signal,
|
||||
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
|
||||
})
|
||||
childId = started.childId
|
||||
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
|
||||
|
||||
// One prompted parent turn makes the parent non-blank so the session
|
||||
// header (and its subagent catalog action) renders.
|
||||
const parentSettled = scaffold.whenTurnSettled()
|
||||
const parentInput = page.locator('textarea:enabled').first()
|
||||
await parentInput.fill('Ask a research subagent to explain event sourcing.')
|
||||
await parentInput.press('Enter')
|
||||
expect(await parentSettled).toBe(parent.id)
|
||||
|
||||
// Reload onto the restart baseline (the proven route to a freshly
|
||||
// discovered catalog), with the child still live and running host-side.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.getByRole('button', { name: /1 subagent/ }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarRoot !== undefined) {
|
||||
await rm(sidecarRoot, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed')
|
||||
})
|
||||
|
||||
it('interrupts the live child through the parent-offline composer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline'))
|
||||
// Simulate a parent that went offline: the catalog delivers
|
||||
// parentAvailable: false while the child Activation stays live (the
|
||||
// interrupt RPC itself needs no live parent — covered host-side by
|
||||
// subagent-interrupt.e2e.ts).
|
||||
const pattern = '**/api/subagent.list'
|
||||
await page.route(pattern, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const body = await response.json() as {
|
||||
result: { ok: true; value: { parentAvailable: boolean } } | { ok: false }
|
||||
}
|
||||
if (body.result.ok) body.result.value.parentAvailable = false
|
||||
await route.fulfill({ response, json: body })
|
||||
})
|
||||
try {
|
||||
await page.getByRole('button', { name: /1 subagent/ }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.getByRole('textbox', {
|
||||
name: 'Parent session offline; sending is unavailable but you can still stop the run',
|
||||
})
|
||||
await input.waitFor({ timeout: 15_000 })
|
||||
expect(await input.isDisabled()).toBe(true)
|
||||
const stop = page.getByRole('button', { name: 'Stop generating' })
|
||||
expect(await stop.count()).toBe(1)
|
||||
expect(await stop.isEnabled()).toBe(true)
|
||||
const send = page.getByRole('button', { name: 'Send message' })
|
||||
expect(await send.count()).toBe(1)
|
||||
expect(await send.isDisabled()).toBe(true)
|
||||
await compareOrRefreshGolden(
|
||||
OFFLINE_COMPOSER_EXPECTED,
|
||||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
|
||||
// Keep the continuable Activation resident after this first abort. The
|
||||
// direct setup queue does not change the parent-offline UI contract: its
|
||||
// input and Send remain disabled throughout the exercised browser path.
|
||||
await scaffold.ctx.subagents.followup(
|
||||
parent,
|
||||
childId,
|
||||
[{ type: 'text', text: REARM }],
|
||||
{ source: { kind: 'user' }, signal: new AbortController().signal },
|
||||
)
|
||||
const aborted = waitForAbortedTurn(scaffold, childId)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
}).result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
|
||||
await aborted
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
|
||||
|
||||
// Wake the parked setup message only after cancellation converges. A
|
||||
// second hang keeps the parent-available case independent from this stop.
|
||||
await scaffold.ctx.subagents.followup(
|
||||
parent,
|
||||
childId,
|
||||
[{ type: 'text', text: REARM_WAKE }],
|
||||
{ source: { kind: 'user' }, signal: new AbortController().signal },
|
||||
)
|
||||
await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
|
||||
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
|
||||
} finally {
|
||||
await page.unroute(pattern)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
|
||||
// Reselect the child with the truthful catalog: parent available again.
|
||||
await page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
.getByRole('button').first().click()
|
||||
await page.getByRole('button', { name: /1 subagent/ }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.getByRole('textbox', { name: 'Message the agent' })
|
||||
await input.waitFor({ timeout: 15_000 })
|
||||
expect(await input.isDisabled()).toBe(false)
|
||||
|
||||
// Queue a follow-up through Send while independent Stop remains available.
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
await input.fill(FOLLOWUP)
|
||||
await page.getByRole('button', { name: 'Send message' }).click()
|
||||
expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
|
||||
.toMatchObject({ ok: true })
|
||||
|
||||
const aborted = waitForAbortedTurn(scaffold, childId)
|
||||
const stop = page.getByRole('button', { name: 'Stop generating' })
|
||||
expect(await stop.count()).toBe(1)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
}).result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
// The addressed child stops through its own RPC, never the generic one.
|
||||
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
|
||||
await aborted
|
||||
|
||||
// Parked: the Activation stays resident and idle with the retained
|
||||
// follow-up; the primary returns to Send without a new turn starting.
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
|
||||
const child = scaffold.ctx.agents.get(childId)
|
||||
expect(child).toBeDefined()
|
||||
expect(child!.inbox.nextTurn).toHaveLength(2)
|
||||
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
|
||||
|
||||
// Only the waking send resumes the parked queue, FIFO, to settlement.
|
||||
await input.fill(WAKING)
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => page.getByText(REARMED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
|
||||
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
expect(userTexts).toEqual([INITIAL, REARM, REARM_WAKE, FOLLOWUP, WAKING])
|
||||
const turnEndKinds = loaded.events
|
||||
.filter(event => event.type === 'turn/end')
|
||||
.map(event => event.data.reason.kind)
|
||||
expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md'])
|
||||
})
|
||||
})
|
||||
176
apps/web/tests/subagent-interrupt.e2e.ts
Normal file
176
apps/web/tests/subagent-interrupt.e2e.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Web e2e scenario (browserless): the subagent.interrupt RPC against the real
|
||||
// composition. A live continuable child holds its model turn open through a
|
||||
// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
|
||||
// proves from the real session state that the turn aborted, the follow-up
|
||||
// parked without auto-starting a new turn, and a later waking send resumed the
|
||||
// preserved FIFO order. No browser: the RPC surface is the product surface
|
||||
// under test, and subagent-interrupt-ui.e2e.ts owns the composer interaction.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const INITIAL = 'Explain event sourcing in one sentence.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const WAKING = 'And add one concrete example.'
|
||||
|
||||
type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** POST one unary RPC through the real HTTP carrier and unwrap its result. */
|
||||
async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
|
||||
const response = await fetch(`${baseUrl}/api/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`,
|
||||
method,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
|
||||
return (await response.json() as { result: RpcResult<T> }).result
|
||||
}
|
||||
|
||||
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
|
||||
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
|
||||
function textCompletion(text: string): object {
|
||||
return {
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
|
||||
let scaffold: WebScaffold
|
||||
let sidecarRoot: string
|
||||
let readyFile: string
|
||||
let parentId: SessionId
|
||||
let childId: SessionId
|
||||
|
||||
beforeAll(async () => {
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-'))
|
||||
readyFile = join(sidecarRoot, 'hang-ready')
|
||||
// Whole-script replacement: the child's three model calls are the hang
|
||||
// (turn 1, interrupted), the parked follow-up's turn, and the waking turn.
|
||||
// The parent never runs a turn, so the child claims this primary script.
|
||||
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
|
||||
{ kind: 'hang', readyFile },
|
||||
textCompletion('resumed response one'),
|
||||
textCompletion('resumed response two'),
|
||||
]))
|
||||
// Header-only primary fixture: the bare-array override replaces the
|
||||
// derived script entirely; the path only anchors replay installation.
|
||||
await writeFile(
|
||||
join(sidecarRoot, 'session.jsonl'),
|
||||
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
|
||||
)
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(sidecarRoot, 'session.jsonl'),
|
||||
replayOverride: join(sidecarRoot, 'replay.override.json'),
|
||||
})
|
||||
|
||||
// A live parent Agent through the real API; no workspace or browser.
|
||||
const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', {
|
||||
cwd: scaffold.workspaceCwd,
|
||||
})
|
||||
if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`)
|
||||
parentId = sessionId(created.value.sessionId)
|
||||
const parent = scaffold.ctx.agents.get(parentId)
|
||||
if (parent === undefined) throw new Error('created parent session did not publish a live Agent')
|
||||
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'event-sourcing researcher',
|
||||
signal: new AbortController().signal,
|
||||
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
|
||||
})
|
||||
childId = started.childId
|
||||
// The hang entry writes readyFile after its prefix chunks, immediately
|
||||
// before waiting for cancellation: the deterministic "turn is open" gate.
|
||||
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
await rm(sidecarRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt teardown failed')
|
||||
})
|
||||
|
||||
it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
|
||||
// Queue the follow-up while the turn is still open, then interrupt.
|
||||
const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: FOLLOWUP }],
|
||||
})
|
||||
expect(queued).toMatchObject({ ok: true })
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
})
|
||||
expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
// accepted acknowledges the admitted cancel, not quiescence: wait for the
|
||||
// aborted turn/end (the composition's first turn/end) before asserting.
|
||||
expect(await settled).toBe(childId)
|
||||
|
||||
// Parked, not resumed: the Activation stays resident with an idle driver,
|
||||
// the follow-up is retained, and no second turn opened.
|
||||
const child = scaffold.ctx.agents.get(childId)
|
||||
expect(child).toBeDefined()
|
||||
expect(child!.status).toBe('idle')
|
||||
expect(child!.inbox.nextTurn).toHaveLength(1)
|
||||
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1)
|
||||
expect((lastEnd)?.data.reason.kind).toBe('aborted')
|
||||
|
||||
// Only an explicit waking send resumes the parked queue, FIFO, then the
|
||||
// child runs both turns to completion and settles.
|
||||
const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: WAKING }],
|
||||
})
|
||||
expect(waking).toMatchObject({ ok: true })
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
|
||||
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
// Human-origin messages only: the real composition also injects
|
||||
// runtime-context snapshots as non-user-source messages.
|
||||
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING])
|
||||
const turnEndKinds = loaded.events
|
||||
.filter(event => event.type === 'turn/end')
|
||||
.map(event => (event).data.reason.kind)
|
||||
expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed'])
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -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 71, two items `in_progress`)
|
||||
// two surfaces the fixture's parallel plan (turn 72, 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenario: assistant IconActions belong to the settled answer, so
|
||||
// they arrive with `turn/end` and not before. The recorded turn narrates in
|
||||
// plain text before its tool call, which is the shape that used to hand the
|
||||
// plain text before its tool call, which is the shape that would hand the
|
||||
// footer to mid-turn narration for the seconds a tool runs and then move it
|
||||
// down. A `hang` sidecar on the SECOND model call parks the turn after the
|
||||
// narration and the tool result are durable, so the running state is stable by
|
||||
|
||||
@@ -91,8 +91,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
).not.toBeUndefined()
|
||||
// First adoption births a blank Session+Agent whose workspace attach must
|
||||
// settle before a test may delete the registration; re-registration after
|
||||
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
|
||||
// path is gone), so callers opt in only where a fresh attach is possible.
|
||||
// a delete mints a fresh blank Session+Agent too (no cwd-based reuse
|
||||
// exists), so callers opt in only where a fresh attach is possible.
|
||||
if (options.waitForAgent === true) {
|
||||
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(agentsBefore)
|
||||
@@ -254,7 +254,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
// a supported reversible flow. It creates a fresh Workspace id and does
|
||||
// NOT re-adopt the retained (non-blank) Session; the New Session flow
|
||||
// mints a fresh blank session and attaches it to the new registration
|
||||
// (the old cwd-only blank reuse is gone, so the account is never empty).
|
||||
// (no cwd-based blank reuse exists, so the account is never empty).
|
||||
await adoptDirectory(scaffold.workspaceCwd)
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
|
||||
@@ -482,7 +482,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
|
||||
// The card is REACHABLE: it sits 8px off the row, so getting to it means
|
||||
// crossing ground that belongs to neither. Hovering it must not dismiss
|
||||
// it — the regression this scenario guards.
|
||||
// it — the hazard this scenario pins.
|
||||
const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
|
||||
await card.hover()
|
||||
await page.waitForTimeout(POINTER_HOLD_MS)
|
||||
@@ -515,10 +515,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
const item = page.getByRole('menuitem', { name: 'Rename' })
|
||||
await item.waitFor({ timeout: 5_000 })
|
||||
// Into the list, then back up to the trigger across the 4px gap below it:
|
||||
// that return trip used to fire the list's pointerleave and close the
|
||||
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
|
||||
// the pointer ON the trigger, so entering the list has to come first for
|
||||
// the return to be a real departure.
|
||||
// without the gap-crossing grace, that return trip fires the list's
|
||||
// pointerleave and closes the menu — a hesitating pointer loses it.
|
||||
// Order matters — clicking leaves the pointer ON the trigger, so entering
|
||||
// the list has to come first for the return to be a real departure.
|
||||
await item.hover()
|
||||
await page.waitForTimeout(POINTER_TRANSIT_MS)
|
||||
await trigger.hover()
|
||||
|
||||
Reference in New Issue
Block a user