diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml new file mode 100644 index 0000000000..7749f7d602 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md +2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 8eaa039caa8af8f2c30424b007edab5e9a2a91ad +2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: e09371502a96f5770057d14d59bddeb0a36c3ddf diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md new file mode 100644 index 0000000000..8eaa039caa --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md @@ -0,0 +1,33 @@ +# Agent Note: Opt-in reasoning chunk browser stress lane + +Status: implemented + +English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md) + +## Problem + +The browser freeze caused by a long reasoning stream emerges across the fixture async stream, client session reduction, React reconciliation, and the live Think row. Small unit tests prove event semantics but do not expose renderer starvation, while putting a 100,000-chunk scenario in the required [web browser lane](2026-07-24-web-gui-browser-e2e-lane.md) would add a slow, intentionally red reproduction to every pull request. A producer paced by `requestAnimationFrame` also gives the renderer implicit backpressure: when the page stalls, production stalls with it and hides the network-arrival condition that triggers the regression. + +## Decision + +`pnpm run test:web:stress` is the explicit entry point for browser performance reproductions. Its dedicated `vitest.web-stress.config.ts` includes only `apps/web/stress-tests/**/*.stress.ts`; the default unit and web configurations do not collect that suffix. The command builds first because Chromium consumes emitted client artifacts, and `tsconfig.host.json` owns the stress spec because the spec boots the host scaffold. + +The reasoning scenario selects the deterministic `?fixture` session and asks an opt-in fixture timing hook to emit exactly 100,000 individual `reasoning-delta` events. The producer has a nominal external cadence of 128 events every 16 milliseconds and repays elapsed-interval debt after a main-thread stall. This approximates bytes continuing to arrive outside the renderer without synchronously preloading a giant fixture queue. A terminal marker proves that the events crossed session reduction and reached the live Think row. + +The browser installs a 50-millisecond heartbeat and schedules a DOM event before starting the stream. The final report includes emitted chunks, maximum heartbeat delay, scheduled-interaction delay, and heartbeat samples. Both delay measurements have a 250-millisecond budget. While the regression remains, the opt-in command prints the measurements and exits nonzero; it becomes the acceptance check for the renderer fix without making an already-known performance failure a default CI gate. `DSH_WEB_STRESS_HEADFUL=1 pnpm run test:web:stress` exposes the same scenario in a visible browser. + +The default fixture unit suite exercises the timing hook with three chunks and fake timers. That small contract pins input validation, interval pacing, concurrency rejection, exact event count, and terminal-marker delivery without carrying the 100,000-chunk workload into `pnpm test`, `pnpm run test:gui`, or `pnpm run test:web`. + +## Alternatives considered + +**Required web browser scenario.** Rejected: the reproduction currently takes tens of seconds and is expected to fail its responsiveness budget, so making it required would block unrelated work before the production fix exists. + +**Animation-frame pacing.** Rejected after measurement: tying production to paint kept the maximum observed delay below the budget because the producer slowed whenever rendering slowed. That tests a backpressured source rather than the reported continuous-stream workload. + +**One synchronous 100,000-event enqueue.** Rejected: it would block the page in the producer itself and would load `FxInbox` with a huge array drained by `shift()`, confounding renderer cost with fixture queue mechanics. + +**A live model or recorded HTTP byte stream.** Rejected for this reproduction: a live stream is nondeterministic, and HTTP-level recording adds fixture cost without improving the target assertion. The in-memory fixture omits HTTP/SSE byte framing but preserves individual asynchronous session events and the production client reduction and React rendering path where the freeze is observed. + +## Consequences + +Developers now have a keyless, repeatable command that reproduces the long-reasoning freeze with an exact workload and emits machine-readable responsiveness evidence. The default suites remain fast and green, but the stress lane must be invoked explicitly during diagnosis and before accepting a renderer fix. Its timing threshold is a browser responsiveness guard rather than a throughput target; hardware changes can alter total duration, while a multi-second heartbeat or interaction delay remains an unambiguous failure. diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md new file mode 100644 index 0000000000..e09371502a --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 需显式启用的推理(reasoning)分片浏览器压力测试车道 + +Status: implemented + +[English](2026-08-03-opt-in-reasoning-chunk-browser-stress.md) | 中文 + +## 问题 + +长推理流引发的浏览器卡死,需要贯穿 fixture(测试前置数据)异步流、客户端会话归并、React 协调过程和实时 Think 行的完整场景才能显现。小型单元测试可以证明事件语义,却无法暴露渲染器饥饿问题;如果把一个包含 100,000 个分片的场景放入必需的 [Web 浏览器测试车道](2026-07-24-web-gui-browser-e2e-lane.md),则每个 PR(Pull Request)都会增加一项缓慢且有意保持失败状态的复现场景。如果生产方按 `requestAnimationFrame` 的节奏发出事件,渲染器还会对生产方施加隐式背压:页面一旦停滞,生产也随之停滞,从而掩盖触发该回归的实际条件,即网络数据仍会持续到达。 + +## 决策 + +`pnpm run test:web:stress` 是浏览器性能复现场景的显式入口。其专用 `vitest.web-stress.config.ts` 只纳入 `apps/web/stress-tests/**/*.stress.ts`;默认单元测试与 Web 测试配置不会收集该后缀。该命令会先执行构建,因为 Chromium 消费编译生成的客户端产物;压力测试文件会启动宿主脚手架,因此由 `tsconfig.host.json` 纳管。 + +推理场景选择确定性的 `?fixture` 会话,并让一个需显式启用的 fixture 计时钩子精确发出 100,000 个相互独立的 `reasoning-delta` 事件。生产方设定的外部到达节奏为每 16 毫秒发出 128 个事件,并在主线程停顿后补发停顿期间本应发出的事件。这样便能近似模拟独立于渲染器而持续到达的字节流,同时无需同步预装载一个巨大的 fixture 队列。一枚结尾标记证明这些事件经过会话归并,并抵达实时 Think 行。 + +浏览器端启动一个间隔 50 毫秒的心跳,并在启动该流之前调度一个 DOM 事件。最终报告包含已发出的分片数、最大心跳延迟、已调度交互的延迟及心跳样本。两项延迟指标的预算均为 250 毫秒。在回归仍然存在期间,此显式启用命令会打印测量值并以非零状态退出;它由此成为渲染器修复的验收检查,同时不会把已知性能故障设为默认 CI 门禁。`DSH_WEB_STRESS_HEADFUL=1 pnpm run test:web:stress` 会在可见浏览器中展示同一场景。 + +默认 fixture 单元测试套件使用三个分片和假定时器来演练该计时钩子。这个小型契约固定输入校验、间隔节奏、并发拒绝、精确事件数及结尾标记交付,而不会把包含 100,000 个分片的工作负载带入 `pnpm test`、`pnpm run test:gui` 或 `pnpm run test:web`。 + +## 曾考虑的替代方案 + +**必需的 Web 浏览器场景。** 不予采纳:该复现场景目前耗时数十秒,且预期无法满足响应性预算;因此,在生产修复交付之前把它设为必需项会阻塞无关工作。 + +**按动画帧控制节奏。** 测量后不予采纳:生产节奏一旦与绘制绑定,生产方就会在渲染变慢时同步减速,使观测到的最大延迟保持在预算以内。这测试的是受背压约束的数据源,而不是问题报告中的连续流工作负载。 + +**同步入队 100,000 个事件。** 不予采纳:它会让生产方自身阻塞页面,并让 `FxInbox` 装入一个通过 `shift()` 排空的巨大数组,从而把渲染器成本与 fixture 队列机制混为一谈。 + +**真实模型或录制的 HTTP 字节流。** 本复现场景不予采纳:实时数据流不具确定性,而 HTTP 层录制会增加 fixture 成本,却无法改进目标断言。内存 fixture 省略 HTTP/SSE(Server-Sent Events)字节分帧,但保留逐个异步会话事件、生产客户端的会话归并过程,以及观察到卡死的 React 渲染路径。 + +## 后果 + +开发者现在拥有一条无密钥且可重复执行的命令,它以精确工作负载复现长推理卡死,并输出机器可读的响应性证据。默认测试套件仍然快速且保持通过,但在诊断期间以及接受渲染器修复之前,都必须显式运行该压力测试车道。其计时阈值是浏览器响应性防线,而非吞吐量目标;硬件差异可能改变总时长,但长达数秒的心跳或交互延迟仍明确表示失败。 diff --git a/apps/web/stress-tests/reasoning-chunks.stress.ts b/apps/web/stress-tests/reasoning-chunks.stress.ts new file mode 100644 index 0000000000..2eb1aa29ef --- /dev/null +++ b/apps/web/stress-tests/reasoning-chunks.stress.ts @@ -0,0 +1,153 @@ +/** + * Opt-in browser stress reproduction for reasoning-stream renderer stalls. + * The fixture emits 100,000 individual chunks through the normal async + * carrier; the test measures event-loop and scheduled-interaction delay while + * the assembled React surface keeps a collapsed Think row live. + */ +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts' +import { newEnglishPage, saveFailureShot } from '../tests/support.ts' + +const CHUNK_COUNT = 100_000 +const CHUNKS_PER_INTERVAL = 128 +const CHUNK_INTERVAL_MS = 16 +const MAIN_THREAD_DELAY_BUDGET_MS = 250 + +interface ReasoningChunkStormState { + sessionId: string + chunkCount: number + chunksPerInterval: number + intervalMs: number + emitted: number + marker: string + emitting: boolean +} + +interface StressProbe { + intervalId: number + intervalMs: number + lastTickAt: number + maxDelayMs: number + samples: number + interactionDueAt: number + interactionHandledAt: number | null +} + +interface StressWindow extends Window { + __fxTiming?: { + startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string + reasoningChunkStormState(): ReasoningChunkStormState | null + } + __reasoningStressProbe?: StressProbe +} + +it('keeps the browser responsive while rendering 100,000 reasoning chunks', async () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page | undefined + try { + scaffold = await launchWebScaffold() + browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' }) + page = await newEnglishPage(browser) + const activePage = page + await activePage.addInitScript(() => { + localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' })) + }) + const tripwire = watchConsole(activePage) + onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks')) + await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' }) + await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // Fixture settings deliberately reject writes, so its welcome notice + // cannot acknowledge. Hide only that test overlay; the assembled chat + // tree beneath it remains mounted and exercises the production renderer. + await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' }) + await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 }) + + await activePage.evaluate(() => { + const intervalMs = 50 + const now = performance.now() + const probe: StressProbe = { + intervalId: 0, + intervalMs, + lastTickAt: now, + maxDelayMs: 0, + samples: 0, + interactionDueAt: now + 1_000, + interactionHandledAt: null, + } + probe.intervalId = window.setInterval(() => { + const tickAt = performance.now() + probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs) + probe.lastTickAt = tickAt + probe.samples++ + }, intervalMs) + document.body.addEventListener('reasoning-stress-interaction', () => { + probe.interactionHandledAt = performance.now() + }, { once: true }) + window.setTimeout(() => { + document.body.dispatchEvent(new CustomEvent('reasoning-stress-interaction')) + }, 1_000) + ;(window as StressWindow).__reasoningStressProbe = probe + }) + + const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => { + const hooks = (window as StressWindow).__fxTiming + if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable') + return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs) + }, { + chunkCount: CHUNK_COUNT, + chunksPerInterval: CHUNKS_PER_INTERVAL, + intervalMs: CHUNK_INTERVAL_MS, + }) + + const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last() + await liveThink.waitFor({ timeout: 60_000 }) + await expect.poll(async () => await activePage.evaluate(() => { + const hooks = (window as StressWindow).__fxTiming + return hooks?.reasoningChunkStormState()?.emitted ?? 0 + }), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT) + await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker) + + const report = await activePage.evaluate(() => { + const win = window as StressWindow + const probe = win.__reasoningStressProbe + const state = win.__fxTiming?.reasoningChunkStormState() + if (probe === undefined || state === undefined || state === null) { + throw new Error('reasoning stress metrics unavailable') + } + window.clearInterval(probe.intervalId) + const interactionDelayMs = probe.interactionHandledAt === null + ? null + : probe.interactionHandledAt - probe.interactionDueAt + return { + chunkCount: state.chunkCount, + chunksPerInterval: state.chunksPerInterval, + intervalMs: state.intervalMs, + emitted: state.emitted, + maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs), + interactionDelayMs, + heartbeatSamples: probe.samples, + } + }) + process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`) + + expect(report).toMatchObject({ + chunkCount: CHUNK_COUNT, + chunksPerInterval: CHUNKS_PER_INTERVAL, + intervalMs: CHUNK_INTERVAL_MS, + emitted: CHUNK_COUNT, + }) + expect(report.heartbeatSamples).toBeGreaterThan(0) + const interactionDelayMs = report.interactionDelayMs + if (interactionDelayMs === null) throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`) + expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS) + expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser?.close() + await scaffold?.close() + } +}, 600_000) diff --git a/package.json b/package.json index 038a9f7316..fef0a1eb53 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:web:built": "vitest run --config vitest.web.config.ts", "test:web:perf": "npm run build && npm run test:web:perf:built", "test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts", + "test:web:stress": "npm run build && vitest run --config vitest.web-stress.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0dbb9be2c7..dff0069b87 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1179,6 +1179,16 @@ interface StreamConn { push(envelope: RpcRequest): void } +interface ReasoningChunkStormState { + sessionId: string + chunkCount: number + chunksPerInterval: number + intervalMs: number + emitted: number + marker: string + emitting: boolean +} + /** Deterministic fixture branches used by keyless Web assembly tests. */ export interface FixtureOptions { /** Start with no real Workspace or Session. */ @@ -1461,6 +1471,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const streamBreakers = new Set<() => void>() /** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */ const retryScenarios = new Map() + /** The single opt-in browser stress producer; normal fixture journeys never start it. */ + let activeReasoningChunkStorm: ReasoningChunkStormState | null = null // Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which // is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let @@ -1484,6 +1496,86 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq) append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } }) }, + /** Start an externally paced reasoning stream for the opt-in browser stress lane. */ + startReasoningChunkStorm( + id: string, + chunkCount: number, + chunksPerInterval: number, + intervalMs: number, + ): string { + if (!Number.isSafeInteger(chunkCount) || chunkCount < 1) { + throw new Error('fixture: reasoning chunk count must be a positive safe integer') + } + if (!Number.isSafeInteger(chunksPerInterval) || chunksPerInterval < 1) { + throw new Error('fixture: reasoning chunks per interval must be a positive safe integer') + } + if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) { + throw new Error('fixture: reasoning interval must be a positive safe integer') + } + if (activeReasoningChunkStorm?.emitting === true) { + throw new Error('fixture: reasoning chunk storm already running') + } + + const sessionId = sid(id) + const log = logOf(sessionId) + let turn = nextTurn.get(sessionId) ?? 0 + for (const event of log) { + const candidate = (event as unknown as { data?: { turn?: unknown } }).data?.turn + if (typeof candidate === 'number') turn = Math.max(turn, candidate + 1) + } + nextTurn.set(sessionId, turn + 1) + const marker = `REASONING_STRESS_COMPLETE:${String(turn)}:${String(chunkCount)}` + const state: ReasoningChunkStormState = { + sessionId: id, + chunkCount, + chunksPerInterval, + intervalMs, + emitted: 0, + marker, + emitting: true, + } + activeReasoningChunkStorm = state + + setRunning(sessionId, true) + append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + append(sessionId, { + type: 'user/message', surfaceOp: 'append', + data: userMessage(text(`Reasoning chunk stress: ${String(chunkCount)} chunks.`)), + }) + append(sessionId, { type: 'step/start', data: { turn, step: 0 } }) + append(sessionId, { + type: 'assistant/chunk', + data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }, + }) + + const startedAt = Date.now() + const pump = (): void => { + const elapsedIntervals = Math.floor((Date.now() - startedAt) / intervalMs) + 1 + const due = Math.max(state.emitted + chunksPerInterval, elapsedIntervals * chunksPerInterval) + const end = Math.min(due, chunkCount) + for (let index = state.emitted; index < end; index++) { + const chunkText = index === chunkCount - 1 + ? `\n${marker}` + : index % 64 === 63 ? '推理\n' : '推理' + append(sessionId, { + type: 'assistant/chunk', + data: { turn, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: chunkText } }, + }) + } + state.emitted = end + if (end < chunkCount) { + setTimeout(pump, intervalMs) + } else { + state.emitting = false + } + } + setTimeout(pump, 0) + return marker + }, + /** Return a copy so browser probes cannot mutate the active producer. */ + reasoningChunkStormState(): ReasoningChunkStormState | null { + return activeReasoningChunkStorm === null ? null : { ...activeReasoningChunkStorm } + }, /** Open one failed model step whose partial remains visible until llm/retry arrives. */ beginModelRetry(id: string): void { const sessionId = sid(id) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 4892d947fb..4fc48d1ea4 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -19,6 +19,16 @@ interface TimingHooks { failNextHistory(): void appendUser(id: string, msg: string): void appendTitle(id: string, title: string): void + startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string + reasoningChunkStormState(): { + sessionId: string + chunkCount: number + chunksPerInterval: number + intervalMs: number + emitted: number + marker: string + emitting: boolean + } | null beginModelRetry(id: string): void scheduleModelRetry(id: string, retry?: number, delayMs?: number): void cancelModelRetryDuringBackoff(id: string, delayMs?: number): void @@ -873,6 +883,50 @@ describe('createFixtureApi', () => { expect(abort.signal.aborted).toBe(false) expect(habort.signal.aborted).toBe(false) }) + + it('paces the opt-in reasoning stress hook from an external interval', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const api = createFixtureApi() + const hooks = timing() + expect(hooks.reasoningChunkStormState()).toBeNull() + expect(() => hooks.startReasoningChunkStorm('fx-alpha', 0, 1, 16)).toThrow(/chunk count/) + expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 0, 16)).toThrow(/chunks per interval/) + expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/) + const abort = new AbortController() + try { + const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => ( + frame.type === 'session/event' + && frame.event.type === 'assistant/chunk' + && frame.event.data.chunk.type === 'reasoning-delta' + && frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE') + ))) + const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16) + expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 16)).toThrow(/already running/) + expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 0, emitting: true, marker }) + + await vi.advanceTimersByTimeAsync(0) + expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 2, emitting: true }) + await vi.advanceTimersByTimeAsync(16) + expect(hooks.reasoningChunkStormState()).toEqual({ + sessionId: 'fx-alpha', chunkCount: 3, chunksPerInterval: 2, intervalMs: 16, + emitted: 3, marker, emitting: false, + }) + + const frames = await streamed + const deltas = frames.flatMap(frame => ( + frame.type === 'session/event' + && frame.event.type === 'assistant/chunk' + && frame.event.data.chunk.type === 'reasoning-delta' + ? [frame.event.data.chunk.text] + : [] + )) + expect(deltas).toEqual(['推理', '推理', `\n${marker}`]) + } finally { + abort.abort() + vi.useRealTimers() + } + }) }) describe('FixtureApiClient (protocol-level fake carrier)', () => { diff --git a/tsconfig.host.json b/tsconfig.host.json index 2634ed58a4..bfc4f898e8 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", "apps/web/tests/complex-history.perf.ts", + "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", diff --git a/vitest.web-stress.config.ts b/vitest.web-stress.config.ts new file mode 100644 index 0000000000..94bda7e117 --- /dev/null +++ b/vitest.web-stress.config.ts @@ -0,0 +1,15 @@ +import tsconfigPaths from 'vite-tsconfig-paths' +import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' + +/** Opt-in browser performance lane; no default Vitest config includes *.stress.ts. */ +export default defineConfig({ + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + test: { + execArgv: vitestExecArgv, + include: ['apps/web/stress-tests/**/*.stress.ts'], + testTimeout: 600_000, + hookTimeout: 120_000, + fileParallelism: false, + }, +})