test(web): keyless browser e2e lane — replayed round trip + seeded cold resume

apps/web/tests/harness.ts boots the real web assembly in-process
(startHost llm:false -> installLlmReplay providers-mode -> mountWebPlugins
-> startWebServer) under DSH_SNAPSHOT replay/record/refresh. Barrier
stack: in-process turn/end -> agent.whenIdle (covers the persistence
flush) -> browser settled-poll. Seeding goes through the real persistence
API (semantic-checkpoint precedent); record harvests fixtures from live
session memory and tokenizes {{sessionId}}/{{cwd}}; refresh is the sole
golden writer. Console tripwires fail scenarios on reconnect/gap-repair
self-healing; harness close asserts full replay-fixture consumption.

Scenarios, each with fixtures recorded against THIS assembly via a live
model run: replay-round-trip (real composer -> real bash echo -> settled
markdown + aria golden + world-state event asserts) and seeded-history
(cold sidebar list -> implicit resume on open -> history tool cards from
the log, zero model calls). apps/web/tests are host-plane programs:
excluded from the client-registered apps/web project, included in
tsconfig.host.json (one program cannot hold both Context merge sides).
This commit is contained in:
Tianyi Cui
2026-07-24 19:49:02 +08:00
parent 9ef0193dd5
commit 46b9a91e55
9 changed files with 926 additions and 0 deletions

423
apps/web/tests/harness.ts Normal file
View File

@@ -0,0 +1,423 @@
// Shared harness 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 assembly in-process from the exported production
// functions — startHost (bootHost spine) + mountWebPlugins + registry +
// startWebServer — so a real chromium exercises the real HTTP/SSE wire,
// apiproxy, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// replay (default, keyless: `llm: false` + dsh-llm-replay in providers mode),
// record (real DeepSeek adapter + key, harvests fixtures from live session
// memory), refresh (keyless replay that rewrites the committed goldens).
//
// Assembly divergence from `dsh web` (apps/cli/src/web.ts), deliberate: the
// shipped shell opts into sessionTitleLlm, whose fire-and-forget title call
// shares the session's replay cursor — nondeterministic ordering against the
// loop's own calls — so this lane keeps bootHost's disabled default and
// sidebar titles come from the deterministic fallback service.
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Page } from 'playwright'
import { expect } from 'vitest'
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { startHost, mountWebPlugins } from '@deepseek-ai/dsh-host-runtime'
import type { RunningHost } from '@deepseek-ai/dsh-host-runtime'
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
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'
import { Context } from 'cordis'
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)}`)
}
// Replay must run in providers mode (never catch-all): with `llm: false` 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 PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
// The shipped client roster (apps/cli/src/web.ts CLIENT_PACKAGES, sans the
// --dev HMR row). apps/web depends on every entry, so its URL anchors the
// Loader's bare-specifier resolution.
const CLIENT_PACKAGES = [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-question',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
/** 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 harness: real assembly, mode-selected model backend, temp world. */
export interface WebHarness {
/** The active snapshot mode this harness booted under. */
mode: WebSnapshotMode
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
baseUrl: string
/** The running host (ctx is the documented in-process barrier seam). */
host: RunningHost
/** 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
/** Errors the web server reported asynchronously; assert empty at scenario end. */
serverErrors: 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 launchWebHarness}. */
export interface LaunchOptions {
/**
* Replay fixture (session.jsonl) served by dsh-llm-replay 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 on the open seam.
*/
replayFixture?: string
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
paceMs?: number
}
/**
* Boot the real web assembly under the current snapshot mode.
* @param options - replay fixture selection and pacing.
* @returns the running harness.
*/
export async function launchWebHarness(options: LaunchOptions = {}): Promise<WebHarness> {
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-'))
const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
const serverErrors: string[] = []
let host: RunningHost | undefined
let server: Awaited<ReturnType<typeof startWebServer>> | undefined
let replay: ReplayHandle | undefined
try {
host = await startHost({
boot: {
persistenceRoot,
// Keep the request header free of ambient AGENTS.md content so
// recorded fixtures do not embed this repo's instructions.
workspaceContext: false,
cwd: workspaceCwd,
// Replay/refresh boot keyless with the llm seam open; record mounts
// the real adapter and performs real provider I/O.
...(mode === 'record' ? {} : { llm: false as const }),
},
})
if (mode !== 'record' && options.replayFixture !== undefined) {
replay = installLlmReplay(host.ctx, {
file: options.replayFixture,
providers: PROVIDERS,
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
}
// Anchor at apps/cli exactly as `dsh web` does: that package declares
// every roster entry as a dependency, so the Loader's bare-specifier
// resolution and the registry's package.json resolver both work.
const anchor = pathToFileURL(join(REPO_ROOT, 'apps/cli/src/web.ts')).href
const mounted = await mountWebPlugins(host.ctx, CLIENT_PACKAGES, anchor)
const webPlugins = createHostWebPluginRegistry({
ctx: host.ctx,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err: Error) => { serverErrors.push(String(err)) },
})
server = await startWebServer(
{ host: '127.0.0.1', port: 0, distIndex: DIST_INDEX, apiHandler: host.handler, webPlugins },
(err: Error) => { serverErrors.push(String(err)) },
)
} catch (error) {
await server?.close().catch(() => undefined)
await host?.dispose().catch(() => undefined)
await rm(workspaceCwd, { recursive: true, force: true }).catch(() => undefined)
await rm(persistenceRoot, { recursive: true, force: true }).catch(() => undefined)
throw error
}
const runningHost = host
const runningServer = server
const replayHandle = replay
return {
mode,
baseUrl: `http://127.0.0.1:${server.port}`,
host,
workspaceCwd,
persistenceRoot,
serverErrors,
// 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>((resolve, reject) => {
const timer = setTimeout(() => {
off()
reject(new Error(`no turn/end within ${timeoutMs}ms`))
}, timeoutMs)
const off = runningHost.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
if (event.type !== 'turn/end') return
clearTimeout(timer)
off()
const agent = runningHost.ctx.agents.get(session.id)
if (agent === undefined) {
reject(new Error(`turn/end for ${session.id} but no live agent`))
return
}
agent.whenIdle().then(() => { resolve(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)
}
await runningServer.close().catch((e: unknown) => failures.push(e))
await runningHost.dispose().catch((e: unknown) => failures.push(e))
await rm(workspaceCwd, { recursive: true, force: true }).catch((e: unknown) => failures.push(e))
await rm(persistenceRoot, { recursive: true, force: true }).catch((e: unknown) => failures.push(e))
if (failures.length > 0) throw new AggregateError(failures, 'web harness 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.
* @param session - the live session to serialize.
* @returns raw JSONL text ending in one newline.
*/
export 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}} (the web lane pins no header class — a
* deliberate deviation logged in the Agent Note's deferred work), tokenize
* the run-local session id and cwd ({{sessionId}}/{{cwd}}, the committed ACP
* fixture convention — re-records then diff only on real content), and write
* the committed fixture.
* @param harness - the record-mode harness.
* @param sessionId - the driven session.
* @param fixturePath - the committed session.jsonl / seed.jsonl target.
*/
export async function recordFixture(harness: WebHarness, sessionId: SessionId, fixturePath: string): Promise<void> {
const agent = harness.host.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(harness.workspaceCwd).join('{{cwd}}')
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 harness'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 recorded cwd is rewritten to the
* harness workspace so header/path identity and event payload paths agree.
* @param harness - the target harness.
* @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(harness: WebHarness, fixtureText: string, id: string): Promise<SessionId> {
// Committed fixtures tokenize run-local identity ({{sessionId}}/{{cwd}},
// written by recordFixture); realize both for this world before parsing.
const realized = fixtureText
.split('{{sessionId}}').join(id)
.split('{{cwd}}').join(harness.workspaceCwd)
const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd
const rewritten = fixtureCwd === undefined
? realized
: realized.split(fixtureCwd).join(harness.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: harness.workspaceCwd,
delegationDepth: 0,
}
const ctx = new Context()
try {
await ctx.plugin(SessionStore)
// Same root as the host with the plugin's own default compression, so the
// host's directory-scan list() sees one consistent encoding.
await ctx.plugin(SessionPersistenceJsonl, { root: harness.persistenceRoot })
await ctx.sessionPersistence.create(meta)
await ctx.sessionPersistence.append(meta.id, events)
// Deterministic sidebar order: cold summaries take updatedAt from mtime.
const located = ctx.sessionPersistence.locate(meta)
if (located !== undefined) {
const backdated = new Date(meta.createdAt)
await utimes(located.path, backdated, backdated)
}
} finally {
await ctx.fiber.dispose()
}
return meta.id
}
/**
* Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration
* volatility collapse to stable tokens.
* @param snapshot - raw ariaSnapshot text.
* @param workspaceCwd - the harness workspace (basename doubles as the header breadcrumb).
* @returns tokenized snapshot text.
*/
export 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 (no request-header bulk escaped the record write-back).
* @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)
}
}
/**
* 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 }
}

View File

@@ -0,0 +1,109 @@
// 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,
launchWebHarness, recordFixture, watchConsole, webSnapshotMode, type WebHarness,
} from './harness.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 harness: WebHarness
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
harness = await launchWebHarness({
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
})
harness.host.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(harness.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await harness?.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 = harness.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(harness, 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: bash really ran and the turn closed clean.
const toolCalls = sessionEvents.filter(e => e.type === 'tool/call')
expect(toolCalls.map(e => (e as SessionEvent & { data: { name: string } }).data.name)).toContain('bash')
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)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', harness.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
expect(harness.serverErrors).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
})
})

View File

@@ -0,0 +1,105 @@
// 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 } 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,
launchWebHarness, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebHarness,
} from './harness.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 harness: WebHarness
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
harness = await launchWebHarness({})
// The read-tool targets exist in both modes: record needs them for the
// live turn; replay's seeded log carries their recorded contents but the
// workspace stays consistent for any user poking the harness.
await writeFile(join(harness.workspaceCwd, 'a.txt'), 'alpha\n')
await writeFile(join(harness.workspaceCwd, '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(harness, raw, SEED_ID)
}
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
await page.goto(harness.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await harness?.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 = harness.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
await recordFixture(harness, 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'))
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', harness.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
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([])
expect(harness.serverErrors).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md'])
})
})

View File

@@ -0,0 +1,97 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784893539564,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":1784893539588,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"4962b8d4-0422-4f81-8187-642e3e6bab78"}}}}
{"type":"user/message","seq":1,"time":1784893539589,"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":"4962b8d4-0422-4f81-8187-642e3e6bab78"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784893539592,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784893539657,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784893539658,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784893540271,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":6,"time":1784893540271,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":7,"time":1784893540366,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
{"type":"assistant/chunk","seq":8,"time":1784893540396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
{"type":"assistant/chunk","seq":9,"time":1784893540396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":10,"time":1784893540396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":11,"time":1784893540396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}}
{"type":"assistant/chunk","seq":12,"time":1784893540397,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
{"type":"assistant/chunk","seq":13,"time":1784893540421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}}
{"type":"assistant/chunk","seq":14,"time":1784893540447,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}}
{"type":"assistant/chunk","seq":15,"time":1784893540447,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}
{"type":"assistant/chunk","seq":16,"time":1784893540448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":17,"time":1784893540448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":18,"time":1784893540475,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":19,"time":1784893540476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":20,"time":1784893540476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":21,"time":1784893540476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":22,"time":1784893540476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":23,"time":1784893540565,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":24,"time":1784893540566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":25,"time":1784893540591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":26,"time":1784893540592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":27,"time":1784893540592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"command"}}}
{"type":"assistant/chunk","seq":28,"time":1784893540592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":29,"time":1784893540592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":30,"time":1784893540621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":31,"time":1784893540621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"echo"}}}
{"type":"assistant/chunk","seq":32,"time":1784893540621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":" WEB"}}}
{"type":"assistant/chunk","seq":33,"time":1784893540621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"_E"}}}
{"type":"assistant/chunk","seq":34,"time":1784893540651,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"2"}}}
{"type":"assistant/chunk","seq":35,"time":1784893540651,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"E"}}}
{"type":"assistant/chunk","seq":36,"time":1784893540652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"_OK"}}}
{"type":"assistant/chunk","seq":37,"time":1784893540652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":38,"time":1784893540680,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":", "}}}
{"type":"assistant/chunk","seq":39,"time":1784893540680,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":40,"time":1784893540709,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"description"}}}
{"type":"assistant/chunk","seq":41,"time":1784893540710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":42,"time":1784893540710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":43,"time":1784893540710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":44,"time":1784893540738,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"Print"}}}
{"type":"assistant/chunk","seq":45,"time":1784893540738,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":" WEB"}}}
{"type":"assistant/chunk","seq":46,"time":1784893540768,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"_E"}}}
{"type":"assistant/chunk","seq":47,"time":1784893540768,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"2"}}}
{"type":"assistant/chunk","seq":48,"time":1784893540768,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"E"}}}
{"type":"assistant/chunk","seq":49,"time":1784893540768,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"_OK"}}}
{"type":"assistant/chunk","seq":50,"time":1784893540769,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":" to"}}}
{"type":"assistant/chunk","seq":51,"time":1784893540797,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":" stdout"}}}
{"type":"assistant/chunk","seq":52,"time":1784893540801,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":53,"time":1784893540826,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":54,"time":1784893540859,"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":55,"time":1784893540859,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Print WEB_E2E_OK to stdout\"}"}}}}
{"type":"assistant/chunk","seq":56,"time":1784893540859,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":7802,"outputTokens":88,"cacheReadTokens":0,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":57,"time":1784893540860,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":58,"time":1784893540863,"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_yxp0l3itFAMPCLKhz7EU1205","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Print WEB_E2E_OK to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":7802,"outputTokens":88,"cacheReadTokens":0,"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,55,56,57],"surfaceOp":"append"}
{"type":"tool/call","seq":59,"time":1784893540864,"data":{"turn":1,"step":1,"callId":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Print WEB_E2E_OK to stdout\"}"}}
{"type":"tool/result","seq":60,"time":1784893540878,"data":{"turn":1,"step":1,"callId":"call_00_yxp0l3itFAMPCLKhz7EU1205","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[59],"surfaceOp":"append"}
{"type":"step/end","seq":61,"time":1784893540881,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":62,"time":1784893540881,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":63,"time":1784893541457,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":64,"time":1784893541457,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":65,"time":1784893541545,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}
{"type":"assistant/chunk","seq":66,"time":1784893541574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" executed"}}}
{"type":"assistant/chunk","seq":67,"time":1784893541574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}}
{"type":"assistant/chunk","seq":68,"time":1784893541574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":69,"time":1784893541574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" printed"}}}
{"type":"assistant/chunk","seq":70,"time":1784893541603,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":71,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WEB"}}}
{"type":"assistant/chunk","seq":72,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_E"}}}
{"type":"assistant/chunk","seq":73,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}}
{"type":"assistant/chunk","seq":74,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}}
{"type":"assistant/chunk","seq":75,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}}
{"type":"assistant/chunk","seq":76,"time":1784893541633,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":77,"time":1784893541633,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
{"type":"assistant/chunk","seq":78,"time":1784893541675,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}}
{"type":"assistant/chunk","seq":79,"time":1784893541676,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}}
{"type":"assistant/chunk","seq":80,"time":1784893541676,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":81,"time":1784893541676,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":82,"time":1784893541694,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":83,"time":1784893541694,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":84,"time":1784893541694,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":85,"time":1784893541695,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":86,"time":1784893541718,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":87,"time":1784893541718,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":88,"time":1784893541718,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":89,"time":1784893541719,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"WEB_E2E_OK\". I should now reply with \"DONE\"."}}}}
{"type":"assistant/chunk","seq":90,"time":1784893541719,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":91,"time":1784893541719,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":228,"outputTokens":25,"cacheReadTokens":7680,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":92,"time":1784893541719,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":93,"time":1784893541720,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and printed \"WEB_E2E_OK\". I should now reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":228,"outputTokens":25,"cacheReadTokens":7680,"reasoningTokens":22}},"sourceEventSeqs":[63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"}
{"type":"step/end","seq":94,"time":1784893541720,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":95,"time":1784893541721,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,31 @@
- banner:
- navigation "会话层级":
- 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: Print WEB_E2E_OK to stdout
- button "Think The command executed successfully and printed \"WEB_E2E_OK\". I should now reply with \"DONE\".":
- img
- text: Think The command executed successfully and printed "WEB_E2E_OK". I should now reply with "DONE".
- paragraph: DONE
- text: cache hit 49% · 15,823 tokens · 1 turns · 2 steps
- textbox "输入消息Enter 发送Shift+Enter 换行"
- button "添加":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "发送" [disabled]

View File

@@ -0,0 +1,112 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784893580342,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":1784893580362,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"ab867856-cd1a-4270-8cee-c240076c58e9"}}}}
{"type":"user/message","seq":1,"time":1784893580363,"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":"ab867856-cd1a-4270-8cee-c240076c58e9"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784893580365,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784893580420,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784893580421,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784893581003,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":6,"time":1784893581003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":7,"time":1784893581092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
{"type":"assistant/chunk","seq":8,"time":1784893581107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
{"type":"assistant/chunk","seq":9,"time":1784893581108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":10,"time":1784893581108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":11,"time":1784893581108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":12,"time":1784893581135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
{"type":"assistant/chunk","seq":13,"time":1784893581135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":14,"time":1784893581136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":15,"time":1784893581161,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}}
{"type":"assistant/chunk","seq":16,"time":1784893581162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":17,"time":1784893581162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
{"type":"assistant/chunk","seq":18,"time":1784893581189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
{"type":"assistant/chunk","seq":19,"time":1784893581190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":20,"time":1784893581190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":21,"time":1784893581190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}}
{"type":"assistant/chunk","seq":22,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":23,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":24,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}}
{"type":"assistant/chunk","seq":25,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":26,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":27,"time":1784893581258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}}
{"type":"assistant/chunk","seq":28,"time":1784893581259,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}}
{"type":"assistant/chunk","seq":29,"time":1784893581259,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":30,"time":1784893581324,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":31,"time":1784893581325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":32,"time":1784893581325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":33,"time":1784893581325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":34,"time":1784893581351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":35,"time":1784893581351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":36,"time":1784893581351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":37,"time":1784893581351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":38,"time":1784893581378,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":39,"time":1784893581378,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"a"}}}
{"type":"assistant/chunk","seq":40,"time":1784893581378,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":41,"time":1784893581378,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":42,"time":1784893581404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":43,"time":1784893581462,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":44,"time":1784893581462,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":45,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":46,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":47,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":48,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":49,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":50,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":51,"time":1784893581513,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":52,"time":1784893581513,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"b"}}}
{"type":"assistant/chunk","seq":53,"time":1784893581514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":54,"time":1784893581514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":55,"time":1784893581539,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":56,"time":1784893581597,"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 read both files."}}}}
{"type":"assistant/chunk","seq":57,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}}
{"type":"assistant/chunk","seq":58,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}}
{"type":"assistant/chunk","seq":59,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":100,"cacheReadTokens":7680,"reasoningTokens":24}}}}
{"type":"assistant/chunk","seq":60,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":61,"time":1784893581601,"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 read both files."},{"type":"tool-call","id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":100,"cacheReadTokens":7680,"reasoningTokens":24}},"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],"surfaceOp":"append"}
{"type":"tool/call","seq":62,"time":1784893581602,"data":{"turn":1,"step":1,"callId":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}
{"type":"tool/call","seq":63,"time":1784893581604,"data":{"turn":1,"step":1,"callId":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}
{"type":"tool/result","seq":64,"time":1784893581608,"data":{"turn":1,"step":1,"callId":"call_00_8bQPkq98ZAzRTEJA6XM38538","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"}
{"type":"tool/result","seq":65,"time":1784893581609,"data":{"turn":1,"step":1,"callId":"call_01_5FMIBJA9HHyktFY8KSlk9459","content":[{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[63],"surfaceOp":"append"}
{"type":"step/end","seq":66,"time":1784893581611,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":67,"time":1784893581611,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":68,"time":1784893582137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":69,"time":1784893582137,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}}
{"type":"assistant/chunk","seq":70,"time":1784893582257,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}}
{"type":"assistant/chunk","seq":71,"time":1784893582259,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}}
{"type":"assistant/chunk","seq":72,"time":1784893582260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}}
{"type":"assistant/chunk","seq":73,"time":1784893582260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":74,"time":1784893582260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":75,"time":1784893582277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
{"type":"assistant/chunk","seq":76,"time":1784893582303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":77,"time":1784893582303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
{"type":"assistant/chunk","seq":78,"time":1784893582304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":79,"time":1784893582304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"alpha"}}}
{"type":"assistant/chunk","seq":80,"time":1784893582304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
{"type":"assistant/chunk","seq":81,"time":1784893582330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":82,"time":1784893582330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}}
{"type":"assistant/chunk","seq":83,"time":1784893582330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":84,"time":1784893582330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
{"type":"assistant/chunk","seq":85,"time":1784893582331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":86,"time":1784893582331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"beta"}}}
{"type":"assistant/chunk","seq":87,"time":1784893582356,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":88,"time":1784893582357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}}
{"type":"assistant/chunk","seq":89,"time":1784893582384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
{"type":"assistant/chunk","seq":90,"time":1784893582384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}}
{"type":"assistant/chunk","seq":91,"time":1784893582384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
{"type":"assistant/chunk","seq":92,"time":1784893582384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":93,"time":1784893582385,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":94,"time":1784893582411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":95,"time":1784893582411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":96,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}}
{"type":"assistant/chunk","seq":97,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}}
{"type":"assistant/chunk","seq":98,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}}
{"type":"assistant/chunk","seq":99,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":100,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":101,"time":1784893582467,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":102,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":103,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":104,"time":1784893582468,"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\". Now I just need to reply with the single word DONE."}}}}
{"type":"assistant/chunk","seq":105,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":106,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":340,"outputTokens":35,"cacheReadTokens":7680,"reasoningTokens":32}}}}
{"type":"assistant/chunk","seq":107,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":108,"time":1784893582469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". Now I just need to reply with the single word DONE."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":340,"outputTokens":35,"cacheReadTokens":7680,"reasoningTokens":32}},"sourceEventSeqs":[68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"}
{"type":"step/end","seq":109,"time":1784893582470,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":110,"time":1784893582470,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,36 @@
- banner:
- navigation "会话层级":
- 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 read both files.":
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with DONE. Let me read both files.
- 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\". Now I just need to reply with the single word DONE.":
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". Now I just need to reply with the single word DONE.
- paragraph: DONE
- text: cache hit 97% · 15,959 tokens · 1 turns · 2 steps
- textbox "输入消息Enter 发送Shift+Enter 换行"
- button "添加":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "发送" [disabled]

View File

@@ -17,6 +17,15 @@
"src",
"tests"
],
// The web e2e lane (harness + 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/harness.ts",
"tests/replay-round-trip.e2e.ts",
"tests/seeded-history.e2e.ts"
],
"references": [
{
"path": "../../packages/client/web"

View File

@@ -8,6 +8,10 @@
"rewriteRelativeImportExtensions": false
},
"include": [
"apps/web/tests/harness.ts",
"apps/web/tests/support.ts",
"apps/web/tests/replay-round-trip.e2e.ts",
"apps/web/tests/seeded-history.e2e.ts",
"examples/*/src/**/*.ts",
"examples/*/start.ts",
"examples/*/tests/**/*.ts",