Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector
# Conflicts: # .agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml # .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml # apps/cli/src/web.ts # apps/web/tests/session-title.snapshot.ts # apps/web/tests/smoke-fixture.e2e.ts # packages/client/connection/src/client/api.ts # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/fake-api.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.module.css # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/skeleton-branches.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/host/apiproxy/README.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.schema.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/host/runtime/README.md # packages/llm/llm-deepseek/README.md # scripts/translation-pairing.manifest.json # tsconfig.client.json
This commit is contained in:
@@ -24,7 +24,6 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* loader holding, module-table seeding, AppRoot gate, plugin assembly — lives
|
||||
* in @deepseek-ai/dsh-client-web; this file only finds the mount point.
|
||||
*/
|
||||
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const el = document.getElementById('root')
|
||||
if (el === null) throw new Error('web app: missing #root')
|
||||
bootWebShell(el)
|
||||
void new AppWebEntry(el).run()
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* Browser stand-in for `node:module`, mapped by the vite alias in
|
||||
* vite.config.ts (design §2.4). The vendored Loader's internal.ts imports
|
||||
* `createRequire` at module scope but only calls it inside
|
||||
* `ModuleLoader.fromInternal()`, whose version probe is compiled to the
|
||||
* `"0.0.0"` define in the browser build — so this throw is a fail-loud
|
||||
* tripwire for any path that would genuinely need Node's module machinery.
|
||||
* Browser stand-in for `node:module`. `createRequire` is unreachable in the
|
||||
* configured loader path and fails loud if that assumption changes.
|
||||
*/
|
||||
|
||||
/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */
|
||||
|
||||
132
apps/web/tests/replay-round-trip.e2e.ts
Normal file
132
apps/web/tests/replay-round-trip.e2e.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// 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)
|
||||
// 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
|
||||
// run in replay/refresh only. Settled states only — streaming incrementality
|
||||
// is asserted from the persisted assistant/chunk events, not transient DOM.
|
||||
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
|
||||
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
|
||||
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The scenario's one drive prompt. Record sends it; replay asserts the
|
||||
// committed fixture recorded exactly it, so drive script and fixture cannot
|
||||
// drift apart.
|
||||
const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.'
|
||||
|
||||
describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
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('drives the recorded prompt to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip'))
|
||||
if (MODE !== 'record') {
|
||||
// Drift guard: the committed fixture must carry exactly the drive prompt.
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
// Arm the host-side settled barrier BEFORE the send click.
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled'))
|
||||
// Browser settled-poll after host completion (host strictly precedes render).
|
||||
await page.locator('[data-streaming="true"]').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {
|
||||
// Chunks may coalesce into one commit; a never-mounted streaming node is
|
||||
// legal — the chunk-event assertions below carry incrementality.
|
||||
})
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// World state, not self-report: the real bash executor returned the exact
|
||||
// command output, and the turn closed cleanly.
|
||||
const bashCall = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'bash')
|
||||
if (bashCall?.type !== 'tool/call') throw new Error('the replayed turn did not call the bash tool')
|
||||
const bashResult = sessionEvents.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === bashCall.data.callId)
|
||||
if (bashResult?.type !== 'tool/result') throw new Error('the bash tool call produced no durable result')
|
||||
expect(bashResult.data.isError).toBe(false)
|
||||
expect(bashResult.data.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toBe('WEB_E2E_OK\n')
|
||||
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
|
||||
expect(turnEnds.length).toBe(1)
|
||||
expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
|
||||
// The persisted chunk events are the authoritative incrementality proof.
|
||||
expect(sessionEvents.filter(e => e.type === 'assistant/chunk').length).toBeGreaterThan(10)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-aria'))
|
||||
// Anchor assertions survive a semantics-preserving component rewrite even
|
||||
// while the whole-region golden churns.
|
||||
await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true)
|
||||
expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1)
|
||||
await page.getByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash',
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
|
||||
// Interaction over the REAL wire-delivered transcript (the fixture-client
|
||||
// tier pins the same gesture against FixtureApiClient; this one runs on
|
||||
// mux-frame-fed state). Runs after the golden capture so the committed
|
||||
// aria surface stays the untouched settled state.
|
||||
const think = page.getByRole('button', { name: /^Think/ }).first()
|
||||
expect(await think.getAttribute('aria-expanded')).toBe('false')
|
||||
await think.click()
|
||||
await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
||||
await think.click()
|
||||
await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
444
apps/web/tests/scaffold.ts
Normal file
444
apps/web/tests/scaffold.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
// Shared scaffold for the keyless browser e2e lane (Agent Note:
|
||||
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||
// Boots the REAL web composition — the shipped apps/cli/cordis.yml through
|
||||
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
|
||||
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
|
||||
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
|
||||
// replay (default, keyless: llm-deepseek row disabled, dsh-llm-replay row
|
||||
// inserted in providers mode), record (real adapter + key, harvests fixtures
|
||||
// from live session memory), refresh (keyless replay that rewrites goldens).
|
||||
//
|
||||
// Composition divergences from `dsh web`, all deliberate, all via include
|
||||
// patches over the SAME tree (never a second yml): temp persistenceRoot;
|
||||
// workspace-context disabled (recorded fixtures must not embed this repo's
|
||||
// AGENTS.md); session-title-llm disabled (its fire-and-forget title call
|
||||
// would race the loop for the session's replay cursor); webserver pinned to
|
||||
// port 0 with the built dist; keyless modes disable llm-deepseek and fill
|
||||
// the open llm seam post-boot with installLlmReplay on the settled root ctx
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Page } from 'playwright'
|
||||
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 { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
|
||||
|
||||
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
|
||||
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
|
||||
|
||||
/**
|
||||
* Resolve and validate the lane's snapshot mode.
|
||||
* @returns the active mode; unset/empty selects replay.
|
||||
*/
|
||||
export function webSnapshotMode(): WebSnapshotMode {
|
||||
const value = process.env.DSH_SNAPSHOT
|
||||
if (value === undefined || value === '' || value === 'replay') return 'replay'
|
||||
if (value === 'record' || value === 'refresh') return value
|
||||
throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
/** The shipped composition under test: apps/cli's config tree. */
|
||||
const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
|
||||
|
||||
// Replay publishes the provider catalog the gateway routes to (providers
|
||||
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
|
||||
// catch-all would leave resolveModelContext unroutable and compact-basic's
|
||||
// post-step pressure check would warn every step). The published
|
||||
// contextWindow keeps that pressure path provably inert for small fixtures.
|
||||
const REPLAY_PROVIDERS = [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
|
||||
}]
|
||||
|
||||
/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */
|
||||
function loadRootEnv(): void {
|
||||
const envPath = join(REPO_ROOT, '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
|
||||
if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
|
||||
}
|
||||
}
|
||||
|
||||
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
|
||||
export interface WebScaffold {
|
||||
/** The active snapshot mode this scaffold booted under. */
|
||||
mode: WebSnapshotMode
|
||||
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
|
||||
baseUrl: string
|
||||
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
|
||||
ctx: Context
|
||||
/** Temp project directory sessions run in (bash/fs tool cwd). */
|
||||
workspaceCwd: string
|
||||
/** Temp persistence root (seeded sessions land here through the real API). */
|
||||
persistenceRoot: string
|
||||
/** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
|
||||
whenTurnSettled(timeoutMs?: number): Promise<SessionId>
|
||||
/** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/** Options for {@link launchWebScaffold}. */
|
||||
export interface LaunchOptions {
|
||||
/**
|
||||
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
|
||||
* in replay/refresh modes; ignored in record mode (the real adapter
|
||||
* answers). Omit for scenarios issuing no model calls — a stray stream then
|
||||
* fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row
|
||||
* mounts).
|
||||
*/
|
||||
replayFixture?: string
|
||||
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
|
||||
paceMs?: number
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise<unknown[]> {
|
||||
const failures: unknown[] = []
|
||||
await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error))
|
||||
await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
return failures
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the real web composition under the current snapshot mode.
|
||||
* @param options - replay fixture selection and pacing.
|
||||
* @returns the running scaffold.
|
||||
*/
|
||||
export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
|
||||
requireDist()
|
||||
const mode = webSnapshotMode()
|
||||
if (mode === 'record') {
|
||||
loadRootEnv()
|
||||
if (process.env.DEEPSEEK_API_KEY === undefined || process.env.DEEPSEEK_API_KEY.length === 0) {
|
||||
throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
|
||||
}
|
||||
}
|
||||
const workspaceCwd = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-'))
|
||||
let persistenceRoot: string
|
||||
try {
|
||||
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
|
||||
} catch (error) {
|
||||
const failures: unknown[] = [error]
|
||||
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
|
||||
throw error
|
||||
}
|
||||
|
||||
// The include patch set — the same mechanism AppCLIEntry and the ACP
|
||||
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
|
||||
// stops matching a row fails the boot sweep loudly instead of drifting).
|
||||
const patches: PatchOptions[] = [
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
// storage-json's './.storages' yml default is cwd-relative and resolves
|
||||
// per write; the scaffold restores the original cwd after boot, so the
|
||||
// row gets an absolute temp root (removed with the workspace at close).
|
||||
{ id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
|
||||
// fs/bash cwd default to process.cwd(); the gateway injects the same
|
||||
// value into session.cwd — chdir below anchors all three to the temp
|
||||
// workspace, keeping the composition untouched.
|
||||
{ id: 'workspace-context', disabled: true },
|
||||
{ id: 'session-title-llm', disabled: true },
|
||||
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
|
||||
...mode === 'record' ? [] : [{ id: 'llm-deepseek', disabled: true }],
|
||||
]
|
||||
|
||||
// Sessions inherit the gateway's process.cwd() default; run the boot from
|
||||
// the temp workspace so tool cwd, session cwd, and fixtures agree.
|
||||
const originalCwd = process.cwd()
|
||||
const ctx = new Context()
|
||||
let port = 0
|
||||
let replayHandle: ReplayHandle | undefined
|
||||
try {
|
||||
process.chdir(workspaceCwd)
|
||||
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
||||
const boundPort = ctx.get('httpServer')?.port
|
||||
if (boundPort === undefined) {
|
||||
throw new Error('web e2e scaffold: httpServer service missing after settled boot')
|
||||
}
|
||||
port = boundPort
|
||||
|
||||
// Fill the open llm seam on the settled root ctx (llm-deepseek is disabled
|
||||
// in keyless modes; a scenario with no fixture leaves the seam empty so a
|
||||
// stray stream fails loud with NO_ADAPTER). The direct install, unlike the
|
||||
// plugin row, returns the ReplayHandle for the teardown consumption check.
|
||||
if (mode !== 'record' && options.replayFixture !== undefined) {
|
||||
replayHandle = installLlmReplay(ctx, {
|
||||
file: options.replayFixture,
|
||||
providers: REPLAY_PROVIDERS,
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
ctx,
|
||||
workspaceCwd,
|
||||
persistenceRoot,
|
||||
// Barrier stack: the in-process turn/end identifies the session, then
|
||||
// agent.whenIdle() covers the persistence flush (the idle flip follows
|
||||
// the flush), and the caller's browser settled-poll comes last because
|
||||
// host completion strictly precedes render.
|
||||
whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise<SessionId> {
|
||||
return new Promise<SessionId>((resolveSettled, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error(`no turn/end within ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
if (event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
const agent = ctx.agents.get(session.id)
|
||||
if (agent === undefined) {
|
||||
reject(new Error(`turn/end for ${session.id} but no live agent`))
|
||||
return
|
||||
}
|
||||
agent.whenIdle().then(() => { resolveSettled(session.id) }, reject)
|
||||
})
|
||||
})
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
// Fixture-consumption check first, while the run's binding state is
|
||||
// still authoritative — a scenario that drove fewer model calls than
|
||||
// recorded fails here instead of drifting green.
|
||||
try {
|
||||
replayHandle?.assertConsumed()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a live session back to raw session-JSONL (header + events) — the
|
||||
* in-memory record-mode harvest, so the on-disk zstd default never matters.
|
||||
* Mirrors the TUI suite's rawSessionLog.
|
||||
*/
|
||||
function rawSessionLog(session: Session): string {
|
||||
return [
|
||||
JSON.stringify({ type: 'session', ...session.header }),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Record-mode fixture write-back: harvest the live session, scrub request
|
||||
* headers to {{system}}/{{tools}} (TODO(web-header-pin): the web lane pins no
|
||||
* header class — a deliberate deviation logged in the Agent Note's deferred
|
||||
* work), tokenize the run-local session id, cwd, and browser RPC id
|
||||
* ({{sessionId}}/{{cwd}}/{{rpcId}}, the committed fixture convention —
|
||||
* re-records then diff only on real content), and write the fixture.
|
||||
* @param scaffold - the record-mode scaffold.
|
||||
* @param sessionId - the driven session.
|
||||
* @param fixturePath - the committed session.jsonl / seed.jsonl target.
|
||||
*/
|
||||
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))
|
||||
.split(sessionId).join('{{sessionId}}')
|
||||
.split(scaffold.workspaceCwd).join('{{cwd}}')
|
||||
.replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
|
||||
await writeFile(fixturePath, tokenized)
|
||||
}
|
||||
|
||||
/**
|
||||
* The user prompts recorded in a fixture, in order — the single source tying
|
||||
* spec drive steps to recorded reality so script and fixture cannot drift.
|
||||
* @param fixtureText - raw session.jsonl contents.
|
||||
* @returns the recorded user prompt texts.
|
||||
*/
|
||||
export function fixtureUserPrompts(fixtureText: string): string[] {
|
||||
return parseSessionLog(fixtureText).flatMap((event) => {
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'user') return []
|
||||
const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
return text.length > 0 ? [text] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a recorded session fixture into the scaffold's persistence root
|
||||
* through the REAL backend API (throwaway Context + SessionStore + JSONL
|
||||
* plugin — the semantic-checkpoint precedent), never raw file writes: no
|
||||
* knowledge of bucket hashing, filename encoding, or compression, and
|
||||
* malformed shapes fail loud at seed time. The fixture's tokenized identity
|
||||
* ({{sessionId}}/{{cwd}}) is realized for this world before parsing.
|
||||
* @param scaffold - the target scaffold.
|
||||
* @param fixtureText - raw recorded session.jsonl contents.
|
||||
* @param id - the seeded session id (stable for deterministic goldens).
|
||||
* @returns the seeded id.
|
||||
*/
|
||||
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
|
||||
const realized = fixtureText
|
||||
.split('{{sessionId}}').join(id)
|
||||
.split('{{cwd}}').join(scaffold.workspaceCwd)
|
||||
const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd
|
||||
const rewritten = fixtureCwd === undefined
|
||||
? realized
|
||||
: realized.split(fixtureCwd).join(scaffold.workspaceCwd)
|
||||
const events = parseSessionLog(rewritten)
|
||||
if (events.length === 0) throw new Error('seed fixture has no events')
|
||||
const last = events[events.length - 1]!
|
||||
// An open final turn would be mutated by resume's crash repair on first
|
||||
// open; a committed seed must be a closed recording.
|
||||
if (last.type !== 'turn/end') throw new Error(`seed fixture must end in turn/end, got ${last.type}`)
|
||||
const meta: SessionHeader = {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId(id),
|
||||
createdAt: Date.now() - 60_000,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
delegationDepth: 0,
|
||||
}
|
||||
const seeder = new Context()
|
||||
try {
|
||||
await seeder.plugin(SessionStore)
|
||||
// Same root as the booted tree with the plugin's own default compression,
|
||||
// so the host's directory-scan list() sees one consistent encoding.
|
||||
await seeder.plugin(SessionPersistenceJsonl, { root: scaffold.persistenceRoot })
|
||||
await seeder.sessionPersistence.create(meta)
|
||||
await seeder.sessionPersistence.append(meta.id, events)
|
||||
// Deterministic sidebar order: cold summaries take updatedAt from mtime.
|
||||
const located = seeder.sessionPersistence.locate(meta)
|
||||
if (located !== undefined) {
|
||||
const backdated = new Date(meta.createdAt)
|
||||
await utimes(located.path, backdated, backdated)
|
||||
}
|
||||
} finally {
|
||||
await seeder.fiber.dispose()
|
||||
}
|
||||
return meta.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration
|
||||
* volatility collapse to stable tokens.
|
||||
*/
|
||||
function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
// The header breadcrumb renders the workspace's basename, not the full
|
||||
// path, so both spellings must collapse to the token.
|
||||
const base = workspaceCwd.split('/').pop()!
|
||||
return snapshot
|
||||
.split(workspaceCwd).join('{{cwd}}')
|
||||
.split(base).join('{{workspace}}')
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
|
||||
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the region's aria snapshot at a settled milestone: poll until two
|
||||
* consecutive normalized captures are equal — a single-shot capture races the
|
||||
* last React commits.
|
||||
* @param page - the page under test.
|
||||
* @param selector - the region locator selector.
|
||||
* @param workspaceCwd - normalization input.
|
||||
* @returns the stable normalized snapshot.
|
||||
*/
|
||||
export async function captureStableAria(page: Page, selector: string, workspaceCwd: string): Promise<string> {
|
||||
const region = page.locator(selector).first()
|
||||
let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd)
|
||||
await expect.poll(async () => {
|
||||
const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd)
|
||||
const stable = current === previous
|
||||
previous = current
|
||||
return stable
|
||||
}, { timeout: 5_000, message: 'aria snapshot did not stabilize' }).toBe(true)
|
||||
return previous
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a normalized golden, or rewrite it under refresh. Refresh is the
|
||||
* ONLY writer: a missing golden in replay mode fails with the healing command
|
||||
* instead of silently self-bootstrapping.
|
||||
* @param goldenPath - the committed ui.expected.md path.
|
||||
* @param actual - the stable normalized snapshot.
|
||||
* @param mode - the active snapshot mode.
|
||||
*/
|
||||
export async function compareOrRefreshGolden(goldenPath: string, actual: string, mode: WebSnapshotMode): Promise<void> {
|
||||
const payload = `${actual}\n`
|
||||
if (mode === 'refresh') {
|
||||
await writeFile(goldenPath, payload)
|
||||
return
|
||||
}
|
||||
if (!existsSync(goldenPath)) {
|
||||
throw new Error(`missing golden ${goldenPath} — run DSH_SNAPSHOT=refresh pnpm run test:web to generate it`)
|
||||
}
|
||||
expect(payload).toBe(await readFile(goldenPath, 'utf8'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture-inventory guard (the TUI afterAll shape): the scenario directory
|
||||
* holds exactly the expected files and every committed JSONL is a scrub
|
||||
* fixed-point without a run-local browser RPC id.
|
||||
* @param dir - the scenario snapshot directory.
|
||||
* @param expected - the exact expected file inventory.
|
||||
*/
|
||||
export async function assertFixtureInventory(dir: string, expected: string[]): Promise<void> {
|
||||
const entries = (await readdir(dir)).sort()
|
||||
expect(entries).toEqual([...expected].sort())
|
||||
for (const entry of entries.filter(name => name.endsWith('.jsonl'))) {
|
||||
const content = await readFile(join(dir, entry), 'utf8')
|
||||
expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content)
|
||||
expect(content, `${dir}/${entry} carries a run-local rpcId`)
|
||||
.not.toMatch(/"rpcId":"(?!\{\{rpcId\}\})[^"]+"/)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console tripwires: reconnect/gap-repair self-healing or a pageerror must
|
||||
* fail the scenario, not mask a dead wire behind eventual consistency.
|
||||
* @param page - the page under test.
|
||||
* @returns live warning/pageerror collectors to assert empty at scenario end.
|
||||
*/
|
||||
export function watchConsole(page: Page): { warnings: string[]; pageErrors: string[] } {
|
||||
const warnings: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
const text = message.text()
|
||||
if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text)
|
||||
})
|
||||
page.on('pageerror', (error) => { pageErrors.push(String(error)) })
|
||||
return { warnings, pageErrors }
|
||||
}
|
||||
130
apps/web/tests/seeded-history.e2e.ts
Normal file
130
apps/web/tests/seeded-history.e2e.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// Web e2e scenario: seeded history. A recorded session seeded cold through
|
||||
// the REAL persistence API renders purely from the log — the surface nothing
|
||||
// else covers: sidebar cold listing, the implicit resume/attach inside the
|
||||
// history RPC, history-page tool views, and the client fold of historical
|
||||
// events — with ZERO model calls in replay (no replay fixture; a stray stream
|
||||
// fails loud on the open llm seam). 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)
|
||||
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
|
||||
import { readFile, writeFile, mkdir } 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 { join } from 'node:path'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url))
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
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.'
|
||||
|
||||
describe('web e2e: seeded history renders through cold resume', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace
|
||||
// (the composer's default draft name); the read-tool targets must live in
|
||||
// that session cwd. Pre-creating the directory is safe: create-by-name
|
||||
// adopts an existing directory.
|
||||
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
await mkdir(sessionCwd, { recursive: true })
|
||||
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
|
||||
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
|
||||
if (MODE !== 'record') {
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
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')('records the seed turn live through the composer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
await recordFixture(scaffold, sessionId, SEED)
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-history'))
|
||||
// The sidebar tree collapses workspace groups by default: click the group
|
||||
// row (treeitem 0) to expand, then the revealed session row.
|
||||
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()
|
||||
// Settled barrier for history: the recorded final assistant text renders.
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
// 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)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
|
||||
await page.getByRole('button', {
|
||||
// This scenario deliberately leaves the LLM seam open to prove zero
|
||||
// model calls. History still restores the selected id, but no catalog
|
||||
// adapter exists to provide its presentation name.
|
||||
name: '选择模型,当前 deepseek-v4-flash',
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
|
||||
// Interaction over cold-resumed history: read rows are expand-in-place
|
||||
// rows (rowExpands routes the click to toggleExpand, not openDetails), so
|
||||
// the gesture under test is the inline fold over log-rebuilt content.
|
||||
// Runs after the golden capture; still zero model calls.
|
||||
const row = page.locator('[data-variant] [data-clickable][role="button"]').first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
expect(await row.getAttribute('aria-expanded')).toBe('false')
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
||||
// The expanded body renders the recorded tool result (a.txt's contents).
|
||||
await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
// No replay fixture was installed and the llm seam is open — any stray
|
||||
// stream would have failed the turn loudly. Cleanliness pins the wire.
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules'
|
||||
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
@@ -15,6 +15,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ 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'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-model-selector', dir: 'ui-model-selector', url: '/plugins/ui-model-selector.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@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'] },
|
||||
]
|
||||
|
||||
@@ -73,26 +74,29 @@ afterEach(() => {
|
||||
function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const sidebar = within(tree).getByText(label).textContent ?? ''
|
||||
const breadcrumb = within(screen.getByRole('navigation', { name: '会话层级' }))
|
||||
const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
|
||||
.getByRole('button', { name: label }).textContent ?? ''
|
||||
return { sidebar, breadcrumb, documentTitle: document.title }
|
||||
}
|
||||
|
||||
it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
|
||||
it('projects titles and routes the next turn through the selected model in the built fixture app', async () => {
|
||||
const root = document.querySelector<HTMLElement>('#root')
|
||||
if (root === null) throw new Error('snapshot root missing')
|
||||
act(() => {
|
||||
unmount = bootWebShell(root, {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
|
||||
const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 })
|
||||
const projectRow = projectLabel.closest<HTMLElement>('[role="treeitem"]')
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const projectCount = await within(tree).findByText('4 sessions')
|
||||
const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (projectRow === null) throw new Error('fixture project row missing')
|
||||
fireEvent.click(projectRow)
|
||||
|
||||
@@ -110,6 +114,22 @@ it('projects initial and revised durable titles through the built nine-plugin fi
|
||||
await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
|
||||
const revised = titleSurfaces(revisedLabel)
|
||||
|
||||
const modelTrigger = await screen.findByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash',
|
||||
})
|
||||
fireEvent.click(modelTrigger)
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
|
||||
await waitFor(() => { expect(modelTrigger.textContent).toBe('GPT-5') })
|
||||
|
||||
// fx-alpha starts in the running state. Selecting above is intentionally
|
||||
// allowed for the next turn; stop the fixture's resident run before sending
|
||||
// the route-report prompt.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop generating' }))
|
||||
const composer = await screen.findByPlaceholderText('Message the agent')
|
||||
fireEvent.change(composer, { target: { value: 'report model' } })
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await screen.findByText('当前模型:openai/gpt-5', {}, { timeout: 10_000 })
|
||||
|
||||
await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
|
||||
.toMatchFileSnapshot('./snapshots/session-title.json')
|
||||
})
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
|
||||
// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
|
||||
// chromium. First describe: graph injection + the fail-loud half. Second
|
||||
// describe: the settled success pass — all ten REAL tsdown bundles load
|
||||
// through the module system + vendored Loader chain in ?fixture mode (the
|
||||
// infrastructure four ride the immediately prefetch tier, the UI rows fetch
|
||||
// on demand), the three-column frame appears in one flip, and the resident
|
||||
// question completes through the real UI stack. The full model round lands
|
||||
// in smoke-real under the W5 real-host standard.
|
||||
import { existsSync } from 'node:fs'
|
||||
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 { startWebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
const bundlePath = (dir: string): string =>
|
||||
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
|
||||
|
||||
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar'
|
||||
|
||||
/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
|
||||
const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true },
|
||||
{ id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-model-selector', dir: 'ui-model-selector', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
|
||||
|
||||
const row = (id: string, extra?: Partial<WebBootEntry>): WebBootEntry =>
|
||||
({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra })
|
||||
|
||||
const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, {
|
||||
...(p.inject !== undefined ? { inject: p.inject } : {}),
|
||||
...(p.immediately === true ? { immediately: true } : {}),
|
||||
}))
|
||||
|
||||
/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */
|
||||
const FAIL_GRAPH: WebBootGraph = {
|
||||
rev: 'e2e-fail',
|
||||
entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')],
|
||||
}
|
||||
|
||||
/** Graph for the success pass: the complete assembly. */
|
||||
const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows }
|
||||
|
||||
/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */
|
||||
function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap<string, string>) {
|
||||
return {
|
||||
graph: () => graph,
|
||||
clientPath: (id: string) => byId.get(id),
|
||||
onRebuilt: () => () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('web boot chain (keyless, real carrier)', () => {
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
const pageErrors: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
requireDist()
|
||||
const port = await probeFreePort()
|
||||
const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) }
|
||||
server = await startWebServer({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
distIndex: DIST_INDEX,
|
||||
apiHandler,
|
||||
webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
|
||||
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage()
|
||||
page.on('pageerror', e => pageErrors.push(String(e)))
|
||||
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('GET / injects the entry graph verbatim', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
|
||||
const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
|
||||
expect(boot).toEqual(FAIL_GRAPH)
|
||||
})
|
||||
|
||||
it('serves a real bundle through the plugins endpoint', async () => {
|
||||
const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`)
|
||||
expect(res.status()).toBe(200)
|
||||
expect(await res.text()).toContain('window.__ModuleLoader__.load')
|
||||
})
|
||||
|
||||
it('boots to the loading page and fail-louds the absent entry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
|
||||
await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
|
||||
await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
|
||||
await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
|
||||
// The real UI must not have flipped in: the gate opens only on settled.
|
||||
expect(await page.locator('[class*="frame"]').count()).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the token sheets before any plugin CSS', async () => {
|
||||
const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
|
||||
expect(family.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('web boot chain success pass (keyless, ten real bundles, ?fixture)', () => {
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
const pageErrors: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
requireDist()
|
||||
const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
|
||||
if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
|
||||
const port = await probeFreePort()
|
||||
// ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
|
||||
const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
|
||||
server = await startWebServer({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
distIndex: DIST_INDEX,
|
||||
apiHandler,
|
||||
webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
|
||||
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage()
|
||||
page.on('pageerror', e => pageErrors.push(String(e)))
|
||||
await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('settles and flips to the three-column frame in one pass', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled'))
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
||||
// Loading page is gone; the grid carries the three tracks.
|
||||
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
|
||||
const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
|
||||
expect(template.split(' ').length).toBe(3)
|
||||
})
|
||||
|
||||
it('every plugin CSS landed with its ownership tag', async () => {
|
||||
const owners = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
|
||||
expect(owners).toContain(LAYOUT_ID)
|
||||
expect(owners).toContain(SIDEBAR_ID)
|
||||
})
|
||||
|
||||
it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
|
||||
const frame = page.locator('[class*="frame"]')
|
||||
const firstTrack = async (): Promise<string> => (await frame.evaluate(
|
||||
el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
|
||||
// The tracks transition on the deepsuite curve; assert the animated
|
||||
// settle rather than an instant jump.
|
||||
const settledTrack = async (px: string): Promise<void> => {
|
||||
await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
|
||||
}
|
||||
// The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome.
|
||||
const brand = () => page.locator('[class*="brand"]').count()
|
||||
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
||||
// Mid-collapse the wide chrome is still mounted, fading — not swapped out.
|
||||
expect(await brand()).toBe(1)
|
||||
await settledTrack('56px')
|
||||
await expect.poll(brand, { timeout: 2000 }).toBe(0)
|
||||
for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
|
||||
await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
|
||||
}
|
||||
await page.getByRole('button', { name: 'Open sidebar' }).click()
|
||||
await settledTrack('280px')
|
||||
await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
|
||||
// Rail search: collapse again, the search control expands and lands in the box.
|
||||
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
||||
await settledTrack('56px')
|
||||
await page.getByRole('button', { name: 'Search sessions' }).click()
|
||||
await settledTrack('280px')
|
||||
// Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it.
|
||||
await expect.poll(() => page.evaluate(() =>
|
||||
(document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search')
|
||||
})
|
||||
|
||||
it('renders file tool rows and expands fixture reasoning from either click target', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure'))
|
||||
await page.locator('[role="treeitem"]').first().click()
|
||||
await page.locator('[role="treeitem"][aria-selected]').first().click()
|
||||
|
||||
const thinkRoot = page.locator('[data-variant="think"]').first()
|
||||
const think = thinkRoot.getByRole('button')
|
||||
await think.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
expect(await think.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click()
|
||||
expect(await think.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await thinkRoot.locator(':scope > div').count()).toBe(2)
|
||||
|
||||
await think.getByText('Think', { exact: true }).click()
|
||||
expect(await think.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
const editRoot = page.locator('[data-variant="edit"]').first()
|
||||
await editRoot.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1)
|
||||
expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1)
|
||||
|
||||
const writeRoot = page.locator('[data-variant="write"]').first()
|
||||
await writeRoot.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1)
|
||||
expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream'))
|
||||
await page.getByRole('button', { name: 'New session', exact: true }).click()
|
||||
const input = page.locator('textarea[placeholder]')
|
||||
await input.waitFor({ timeout: 15_000 })
|
||||
await input.fill('render markdown')
|
||||
await page.getByRole('button', { name: '发送' }).click()
|
||||
|
||||
const streaming = page.locator('[data-streaming="true"]')
|
||||
await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 })
|
||||
await streaming.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' })
|
||||
expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1')
|
||||
expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1)
|
||||
const external = page.getByRole('link', { name: 'DeepSeek' })
|
||||
expect(await external.getAttribute('target')).toBe('_blank')
|
||||
expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('selects a model from a provider group and uses it for the next fixture request', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-model-selector'))
|
||||
await page.getByRole('button', { name: 'New session', exact: true }).click()
|
||||
const emptyInput = page.locator('textarea[placeholder]')
|
||||
await emptyInput.fill('selector seed')
|
||||
await page.getByRole('button', { name: '发送' }).click()
|
||||
|
||||
const selector = page.getByRole('button', { name: '选择模型,当前 DeepSeek-V4-Flash' })
|
||||
await selector.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
expect(await selector.innerText()).toBe('DeepSeek-V4-Flash')
|
||||
await selector.click()
|
||||
const menu = page.getByRole('menu', { name: '模型' })
|
||||
await menu.waitFor()
|
||||
const triggerBox = await selector.boundingBox()
|
||||
const menuBox = await menu.boundingBox()
|
||||
expect(triggerBox).not.toBeNull()
|
||||
expect(menuBox).not.toBeNull()
|
||||
expect((menuBox?.y ?? 0) + (menuBox?.height ?? 0)).toBeLessThanOrEqual((triggerBox?.y ?? 0) + 1)
|
||||
expect(await page.getByRole('group', { name: 'DeepSeek' }).getByRole('menuitemradio').allTextContents())
|
||||
.toEqual(expect.arrayContaining(['DeepSeek-V4-Flash快速响应', 'DeepSeek-V4-Pro复杂任务']))
|
||||
await page.getByRole('group', { name: 'OpenAI' }).getByRole('menuitemradio', { name: 'GPT-5' }).click()
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
expect(await page.getByRole('button', { name: '选择模型,当前 GPT-5' }).innerText()).toBe('GPT-5')
|
||||
|
||||
const residentInput = page.locator('textarea[placeholder]')
|
||||
await expect.poll(() => residentInput.isEnabled(), { timeout: 15_000 }).toBe(true)
|
||||
await residentInput.fill('report model')
|
||||
await page.getByRole('button', { name: '发送' }).click()
|
||||
await page.getByText('当前模型:openai/gpt-5', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
it('renders and completes the resident question through the composer slot', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
|
||||
const sessionTree = page.getByRole('tree', { name: 'Sessions' })
|
||||
const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' })
|
||||
if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
|
||||
await sessionTree.getByText('Fixture 历史会话', { exact: true }).click()
|
||||
const composer = page.locator('[data-question-key]')
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
expect({
|
||||
question: await composer.getByRole('heading').innerText(),
|
||||
progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
|
||||
options: await composer.getByRole('radio').allTextContents(),
|
||||
custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"custom": "其他,请填写自定义答案",
|
||||
"options": [
|
||||
"1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
|
||||
"2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
|
||||
"3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
|
||||
],
|
||||
"progress": "1 / 3",
|
||||
"question": "你现在更想招哪类 Agent/Harness 候选人?",
|
||||
}
|
||||
`)
|
||||
|
||||
await composer.getByRole('radio', { name: '工程落地型' }).click()
|
||||
await composer.getByText('2 / 3', { exact: true }).waitFor()
|
||||
await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
|
||||
await composer.getByRole('checkbox', { name: '系统设计' }).click()
|
||||
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
|
||||
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
|
||||
|
||||
await composer.waitFor({ state: 'detached' })
|
||||
const restoredInput = page.locator('textarea[placeholder]')
|
||||
await restoredInput.waitFor()
|
||||
expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
|
||||
})
|
||||
|
||||
it('stayed clean: no page errors across the whole load chain', () => {
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -336,10 +336,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.`
|
||||
await input.fill(prompt)
|
||||
await input.press('Enter')
|
||||
// startSession chain: session mounts, composer moves to the bottom.
|
||||
// Regression pin (P0, 585671106): this send used to white-screen the tree
|
||||
// (scope tag lost to a duplicate inlined runtime instance) — body going
|
||||
// near-empty here means that class of bug is back.
|
||||
// The first send must keep the session tree mounted; a near-empty body
|
||||
// reveals a duplicate runtime bundle with incompatible scope tags.
|
||||
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
|
||||
expect(pageErrors).toEqual([])
|
||||
await page.waitForFunction(
|
||||
|
||||
95
apps/web/tests/snapshots/fresh-round-trip/session.jsonl
Normal file
95
apps/web/tests/snapshots/fresh-round-trip/session.jsonl
Normal file
@@ -0,0 +1,95 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1784973850888,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1784973850889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1784973851088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1784973851107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}}
|
||||
{"type":"assistant/chunk","seq":14,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}}
|
||||
{"type":"assistant/chunk","seq":15,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":1784973851135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":1784973851135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":24,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":""}}}
|
||||
{"type":"assistant/chunk","seq":25,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"{"}}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"command"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1784973851245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":": "}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"echo"}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" WEB"}}}
|
||||
{"type":"assistant/chunk","seq":33,"time":1784973851272,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"_E"}}}
|
||||
{"type":"assistant/chunk","seq":34,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"2"}}}
|
||||
{"type":"assistant/chunk","seq":35,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"E"}}}
|
||||
{"type":"assistant/chunk","seq":36,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"_OK"}}}
|
||||
{"type":"assistant/chunk","seq":37,"time":1784973851300,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":38,"time":1784973851326,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":", "}}}
|
||||
{"type":"assistant/chunk","seq":39,"time":1784973851326,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":40,"time":1784973851352,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"description"}}}
|
||||
{"type":"assistant/chunk","seq":41,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":": "}}}
|
||||
{"type":"assistant/chunk","seq":43,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":44,"time":1784973851379,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"E"}}}
|
||||
{"type":"assistant/chunk","seq":45,"time":1784973851406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"cho"}}}
|
||||
{"type":"assistant/chunk","seq":46,"time":1784973851406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" the"}}}
|
||||
{"type":"assistant/chunk","seq":47,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" test"}}}
|
||||
{"type":"assistant/chunk","seq":48,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" string"}}}
|
||||
{"type":"assistant/chunk","seq":49,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":1784973851461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"}"}}}
|
||||
{"type":"assistant/chunk","seq":51,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."}}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}},"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],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}
|
||||
{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":61,"time":1784973852195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
|
||||
{"type":"assistant/chunk","seq":62,"time":1784973852309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":1784973852338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" executed"}}}
|
||||
{"type":"assistant/chunk","seq":64,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}}
|
||||
{"type":"assistant/chunk","seq":65,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
|
||||
{"type":"assistant/chunk","seq":66,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}}
|
||||
{"type":"assistant/chunk","seq":67,"time":1784973852370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":68,"time":1784973852370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WEB"}}}
|
||||
{"type":"assistant/chunk","seq":69,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_E"}}}
|
||||
{"type":"assistant/chunk","seq":70,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}}
|
||||
{"type":"assistant/chunk","seq":71,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}}
|
||||
{"type":"assistant/chunk","seq":72,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}}
|
||||
{"type":"assistant/chunk","seq":73,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
|
||||
{"type":"assistant/chunk","seq":74,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
|
||||
{"type":"assistant/chunk","seq":75,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}}
|
||||
{"type":"assistant/chunk","seq":76,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
|
||||
{"type":"assistant/chunk","seq":77,"time":1784973852428,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
|
||||
{"type":"assistant/chunk","seq":78,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":79,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":80,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":81,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
|
||||
{"type":"assistant/chunk","seq":82,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":83,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
|
||||
{"type":"assistant/chunk","seq":84,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":85,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
|
||||
{"type":"assistant/chunk","seq":86,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":87,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."}}}}
|
||||
{"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}}
|
||||
{"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
31
apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
Normal file
31
apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
Normal file
@@ -0,0 +1,31 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the bash tool to" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
|
||||
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
|
||||
- img
|
||||
- text: Think The user wants me to run a simple bash command and reply with "DONE".
|
||||
- text: Echo the test string
|
||||
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
|
||||
- img
|
||||
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
|
||||
- paragraph: DONE
|
||||
- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
112
apps/web/tests/snapshots/seeded-history/seed.jsonl
Normal file
112
apps/web/tests/snapshots/seeded-history/seed.jsonl
Normal file
@@ -0,0 +1,112 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1784974101296,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1784974101297,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1784974101422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1784974101452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1784974101452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":1784974101483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
|
||||
{"type":"assistant/chunk","seq":14,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
|
||||
{"type":"assistant/chunk","seq":15,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":1784974101514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":24,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
|
||||
{"type":"assistant/chunk","seq":25,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1784974101546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1784974101576,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parallel"}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
|
||||
{"type":"assistant/chunk","seq":33,"time":1784974101666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":34,"time":1784974101667,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":""}}}
|
||||
{"type":"assistant/chunk","seq":35,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"{"}}}
|
||||
{"type":"assistant/chunk","seq":36,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":37,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"file"}}}
|
||||
{"type":"assistant/chunk","seq":38,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"_path"}}}
|
||||
{"type":"assistant/chunk","seq":39,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":40,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":": "}}}
|
||||
{"type":"assistant/chunk","seq":41,"time":1784974101726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":1784974101727,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"a"}}}
|
||||
{"type":"assistant/chunk","seq":43,"time":1784974101727,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":".txt"}}}
|
||||
{"type":"assistant/chunk","seq":44,"time":1784974101756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":45,"time":1784974101757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"}"}}}
|
||||
{"type":"assistant/chunk","seq":46,"time":1784974101821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":47,"time":1784974101822,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":""}}}
|
||||
{"type":"assistant/chunk","seq":48,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"{"}}}
|
||||
{"type":"assistant/chunk","seq":49,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"file"}}}
|
||||
{"type":"assistant/chunk","seq":51,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"_path"}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":1784974101850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1784974101881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":": "}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":1784974101881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":55,"time":1784974101882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"b"}}}
|
||||
{"type":"assistant/chunk","seq":56,"time":1784974101882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":".txt"}}}
|
||||
{"type":"assistant/chunk","seq":57,"time":1784974101908,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":58,"time":1784974101909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"}"}}}
|
||||
{"type":"assistant/chunk","seq":59,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}}}
|
||||
{"type":"assistant/chunk","seq":60,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}},"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],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}
|
||||
{"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}
|
||||
{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"<path>{{cwd}}/workspace/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"<path>{{cwd}}/workspace/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[66],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":72,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}}
|
||||
{"type":"assistant/chunk","seq":73,"time":1784974102505,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}}
|
||||
{"type":"assistant/chunk","seq":74,"time":1784974102534,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}}
|
||||
{"type":"assistant/chunk","seq":75,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}}
|
||||
{"type":"assistant/chunk","seq":76,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
|
||||
{"type":"assistant/chunk","seq":77,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
|
||||
{"type":"assistant/chunk","seq":78,"time":1784974102565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
|
||||
{"type":"assistant/chunk","seq":79,"time":1784974102595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
|
||||
{"type":"assistant/chunk","seq":80,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
|
||||
{"type":"assistant/chunk","seq":81,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":82,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"alpha"}}}
|
||||
{"type":"assistant/chunk","seq":83,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
|
||||
{"type":"assistant/chunk","seq":84,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
|
||||
{"type":"assistant/chunk","seq":85,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}}
|
||||
{"type":"assistant/chunk","seq":86,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
|
||||
{"type":"assistant/chunk","seq":87,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
|
||||
{"type":"assistant/chunk","seq":88,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":89,"time":1784974102626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"beta"}}}
|
||||
{"type":"assistant/chunk","seq":90,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
|
||||
{"type":"assistant/chunk","seq":91,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
|
||||
{"type":"assistant/chunk","seq":92,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}}
|
||||
{"type":"assistant/chunk","seq":93,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}}
|
||||
{"type":"assistant/chunk","seq":94,"time":1784974102689,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":95,"time":1784974102690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":96,"time":1784974102690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}}
|
||||
{"type":"assistant/chunk","seq":97,"time":1784974102716,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":98,"time":1784974102717,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}}
|
||||
{"type":"assistant/chunk","seq":99,"time":1784974102748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}}
|
||||
{"type":"assistant/chunk","seq":100,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
|
||||
{"type":"assistant/chunk","seq":101,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":102,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
|
||||
{"type":"assistant/chunk","seq":103,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":104,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."}}}}
|
||||
{"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}}
|
||||
{"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
36
apps/web/tests/snapshots/seeded-history/ui.expected.md
Normal file
36
apps/web/tests/snapshots/seeded-history/ui.expected.md
Normal file
@@ -0,0 +1,36 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the read tool twice" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."
|
||||
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- button:
|
||||
- img
|
||||
- text: Read a.txt
|
||||
- button:
|
||||
- img
|
||||
- text: Read b.txt
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
|
||||
- paragraph: DONE
|
||||
- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
@@ -16,10 +16,7 @@ export function requireDist(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OS-assigned free port, released before use. startWebServer echoes
|
||||
* options.port instead of the bound one, so passing 0 directly is unusable.
|
||||
*/
|
||||
/** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */
|
||||
export function probeFreePort(): Promise<number> {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const probe = createServer()
|
||||
|
||||
323
apps/web/tests/workspace-flow.snapshot.ts
Normal file
323
apps/web/tests/workspace-flow.snapshot.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
// @vitest-environment jsdom
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
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-i18n', dir: 'i18n', url: '/plugins/i18n.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'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@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'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against one keyless fixture branch. */
|
||||
function boot(search: string): void {
|
||||
history.replaceState(null, '', `/${search}`)
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Recreate the built client graph while preserving browser-persistent state. */
|
||||
function refresh(search: string): void {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
boot(search)
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Identify the interactive Workspace chip by its menu contract. */
|
||||
function workspaceChip(): HTMLElement {
|
||||
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
|
||||
.find(element => element.getAttribute('aria-haspopup') === 'menu')
|
||||
if (chip === undefined) throw new Error('Workspace chip missing')
|
||||
return chip
|
||||
}
|
||||
|
||||
/** Wait for the runtime-owned controlled input to echo a browser edit. */
|
||||
async function setComposerText(composer: HTMLElement, value: string): Promise<void> {
|
||||
fireEvent.change(composer, { target: { value } })
|
||||
await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) })
|
||||
}
|
||||
|
||||
it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await setComposerText(composer, 'keep this local')
|
||||
|
||||
expect({
|
||||
headline: visibleText(screen.getByText("Let's start building")),
|
||||
workspaceDraft: visibleText(workspaceChip()),
|
||||
sidebar: visibleText(tree),
|
||||
composerDisabled: (composer as HTMLTextAreaElement).disabled,
|
||||
prompt: (composer as HTMLTextAreaElement).value,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"composerDisabled": false,
|
||||
"headline": "Let's start building",
|
||||
"prompt": "keep this local",
|
||||
"sidebar": "No sessions yet",
|
||||
"workspaceDraft": "workspace",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const workspaceSection = screen.getByText('Workspaces').parentElement
|
||||
if (workspaceSection === null) throw new Error('Workspace section missing')
|
||||
fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
||||
target: { value: 'nova' },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
const draft = within(tree).getByText('New session').closest('[role="treeitem"]')
|
||||
if (group === null || draft === null) throw new Error('created Workspace projection missing')
|
||||
|
||||
expect({
|
||||
workspace: visibleText(group),
|
||||
draft: visibleText(draft),
|
||||
draftSelected: draft.getAttribute('aria-selected'),
|
||||
composerWorkspace: visibleText(workspaceChip()),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"composerWorkspace": "nova",
|
||||
"draft": "New session",
|
||||
"draftSelected": "true",
|
||||
"workspace": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
|
||||
boot('?fixture')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await setComposerText(composer, 'discard this page-local draft')
|
||||
const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
|
||||
if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh')
|
||||
|
||||
const before = {
|
||||
workspace: visibleText(beforeGroup),
|
||||
draft: visibleText(within(tree).getByText('New session')),
|
||||
prompt: (composer as HTMLTextAreaElement).value,
|
||||
}
|
||||
|
||||
refresh('?fixture')
|
||||
|
||||
const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const refreshedTree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]')
|
||||
if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh')
|
||||
|
||||
expect({
|
||||
before,
|
||||
after: {
|
||||
workspace: visibleText(afterGroup),
|
||||
replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
|
||||
prompt: (refreshedComposer as HTMLTextAreaElement).value,
|
||||
},
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"after": {
|
||||
"prompt": "",
|
||||
"replacementDraft": "New session",
|
||||
"workspace": "fixture4 sessions",
|
||||
},
|
||||
"before": {
|
||||
"draft": "New session",
|
||||
"prompt": "discard this page-local draft",
|
||||
"workspace": "fixture4 sessions",
|
||||
},
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
|
||||
boot('?fixture&fixtureAttach=fail')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
await setComposerText(composer, 'keep this cwd-only session')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
|
||||
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 })
|
||||
const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
|
||||
const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
const ungroupedSection = ungroupedGroup?.parentElement
|
||||
if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) {
|
||||
throw new Error('Workspace or Ungrouped projection missing')
|
||||
}
|
||||
const session = within(ungroupedSection).getByRole('treeitem', { selected: true })
|
||||
const retained = screen.getByDisplayValue('keep this cwd-only session')
|
||||
|
||||
expect({
|
||||
workspace: visibleText(workspaceGroup),
|
||||
ungrouped: visibleText(ungroupedGroup),
|
||||
session: within(session).getByText('fixture', { exact: true }).textContent,
|
||||
sessionSelected: session.getAttribute('aria-selected'),
|
||||
prompt: (retained as HTMLTextAreaElement).value,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"prompt": "keep this cwd-only session",
|
||||
"session": "fixture",
|
||||
"sessionSelected": "true",
|
||||
"ungrouped": "Ungrouped1 session",
|
||||
"workspace": "fixture3 sessions",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('materializes the automatic Workspace and Session on the first successful send', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
await setComposerText(composer, 'build a lighthouse')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
|
||||
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
const session = within(tree).getByRole('treeitem', { selected: true })
|
||||
if (group === null) throw new Error('materialized Workspace projection missing')
|
||||
|
||||
expect({
|
||||
workspace: visibleText(group),
|
||||
session: within(session).getByText('workspace', { exact: true }).textContent,
|
||||
sessionSelected: session.getAttribute('aria-selected'),
|
||||
promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"promptVisible": "build a lighthouse",
|
||||
"session": "workspace",
|
||||
"sessionSelected": "true",
|
||||
"workspace": "workspace1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => {
|
||||
boot('?fixture=empty&fixturePrompt=reject')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
await setComposerText(composer, 'do not lose this')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
|
||||
|
||||
const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
|
||||
const retained = screen.getByDisplayValue('do not lose this')
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
const session = within(tree).getByRole('treeitem', { selected: true })
|
||||
if (group === null) throw new Error('rejected-send Workspace projection missing')
|
||||
|
||||
expect({
|
||||
workspace: visibleText(group),
|
||||
session: within(session).getByText('workspace', { exact: true }).textContent,
|
||||
error: visibleText(alert),
|
||||
prompt: (retained as HTMLTextAreaElement).value,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
|
||||
"prompt": "do not lose this",
|
||||
"session": "workspace",
|
||||
"workspace": "workspace1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -17,13 +17,19 @@
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
// The web e2e lane (scaffold + replay specs) boots the host spine and reads
|
||||
// its Context merges — host-plane programs, checked in tsconfig.host.json;
|
||||
// this client-registered project must not also hold them (one program
|
||||
// cannot see both sides of the cordis Context merges).
|
||||
"exclude": [
|
||||
"tests/scaffold.ts",
|
||||
"tests/replay-round-trip.e2e.ts",
|
||||
"tests/seeded-history.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/client/web"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/modules"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
|
||||
],
|
||||
},
|
||||
define: {
|
||||
|
||||
Reference in New Issue
Block a user