diff --git a/apps/web/tests/harness.ts b/apps/web/tests/harness.ts new file mode 100644 index 0000000000..6f885da0c8 --- /dev/null +++ b/apps/web/tests/harness.ts @@ -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:). */ + 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 + /** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */ + close(): Promise +} + +/** 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 { + 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> | 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 { + return new Promise((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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 } +} diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts new file mode 100644 index 0000000000..faf1a1f6a8 --- /dev/null +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -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 + 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']) + }) +}) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts new file mode 100644 index 0000000000..734c0848c4 --- /dev/null +++ b/apps/web/tests/seeded-history.e2e.ts @@ -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 + + 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']) + }) +}) diff --git a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl new file mode 100644 index 0000000000..d9cc109c60 --- /dev/null +++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl @@ -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"}}} diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md new file mode 100644 index 0000000000..4464843ce6 --- /dev/null +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl new file mode 100644 index 0000000000..a27f182e32 --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl @@ -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":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"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":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"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"}}} diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md new file mode 100644 index 0000000000..07f5b15b2d --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -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] diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 998996304e..514cbe4d57 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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" diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..a5f48ed22d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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",