Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	packages/client/runtime/tests/session.spec.ts
This commit is contained in:
_Kerman
2026-08-04 14:51:07 +08:00
20 changed files with 662 additions and 43 deletions

View File

@@ -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: 70c200c7ade6ef995c68b68ddc21e4c85edf0da8
2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: aa1d29bffd3dcba54925b43eab13ef4d9a4f7cff

View File

@@ -0,0 +1,43 @@
# Agent Note: Frame-coalesced reasoning-chunk publication and browser stress validation
Status: implemented
English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md)
## Problem
Long reasoning streams continuously produce large numbers of `assistant/chunk` events. Each raw event must be ordered, logged, and folded into `PartialAccumulator` to preserve replay fidelity and the completeness of the final content; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; it cannot hide the problem by slowing the producer or discarding raw events.
## Decision
`Session.acceptLiveEvent()` appends every raw event immediately and synchronously updates the transcript, `PartialAccumulator`, and other session-derived state. Visible `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, and `block-end` chunks publish through `Notifier.markFrameDirty()`: the first change schedules one `requestAnimationFrame`, later chunks only continue updating the accumulator, and the frame callback rebuilds one accumulated snapshot from the latest state and notifies subscribers once. `usage`, `finish`, and unknown invisible chunks remain in the event window but trigger no redundant React notifications. Session and history checks share the same visible-chunk classification.
`Notifier` tracks pending publication work with a scheduling kind and generation marker. Ordinary structural events continue to publish in a microtask through `markDirty()`; if a finalized message, tool event, or error arrives while a frame publication is pending, the microtask supersedes it and the old frame callback is invalidated by its generation mismatch. `notifyNow()` likewise invalidates the old schedule to preserve synchronous echo for controlled inputs. Environments without `requestAnimationFrame` fall back to microtask batching. A finalization event may skip one intermediate partial that has not yet appeared, while the published final content and raw event sequence remain complete.
Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`.
`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. The deterministic `?fixture` session emits 100,000 `reasoning-delta` events at a cadence independent of painting, and a terminal marker proves that the events cross production session reduction and reach the live Think row; a 50-millisecond heartbeat and a pre-scheduled DOM event measure main-thread stalls and interaction latency, respectively, with a 250-millisecond budget for identifying clear regressions. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests.
Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. Small fixture unit tests continue to pin input validation, external arrival pacing, concurrency rejection, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites.
## Alternatives considered
**React transitions, deferred values, or component throttling applied to snapshots.** Rejected: the session source would still notify `useSyncExternalStore` for every chunk, the React render has already occurred before a component decides to defer display, and multiple components consuming the same snapshot would each need to implement the strategy. Visual tail-following throttling for the Think summary occurs after snapshot publication and only reduces the frequency of synchronous layout; it does not implement the data-publication policy.
**Dropping, sampling, or concatenating raw chunks at the ingestion or logging layer.** Rejected: raw `assistant/chunk` events are replayable session facts; changing them would reduce diagnostic and UI fidelity and mix display-frequency policy into the authoritative data layer.
**Microtask batching alone.** Rejected: consecutive asynchronous `yield` operations can drain the microtask queue between adjacent chunks, making microtask batching approximate one notification per chunk.
**Pacing the test producer by animation frames.** Rejected: the producer would slow whenever rendering slowed, giving the page implicit backpressure absent from a real network stream and masking main-thread starvation.
**A live model or recorded HTTP byte stream.** Rejected: live models are nondeterministic, and an HTTP/SSE recording would not improve the target assertion. The in-memory fixture preserves individual asynchronous session events, production client reduction, and the React rendering path while controlling the workload and arrival cadence.
## Consequences
The publication rate of streaming `ConversationSnapshot` objects is bounded by the browser's paint rate, so React handles at most one accumulated partial containing all received text per frame; structural events can still publish sooner. Ingestion, ordering, logging, string concatenation, and accumulator updates still run for every raw chunk, so this decision reduces snapshot rebuilding and React work without pretending to solve raw-stream parsing cost.
Horizontal layout reads and writes for the collapsed Think summary run at most once every three frames, and each update moves the summary directly to the latest position; React still commits accumulated snapshots normally, and the summary returns to the first line at finalization. This local visual policy does not change the immediacy of body scrolling or user interactions.
The browser stress lane continues to provide a responsiveness signal from the real assembled application and an entry point for visible profiling, but hardware and scheduling differences make it suitable only as explicit performance evidence. Deterministic focused tests guard publication counts, accumulated content, and preemption order, while the default test lanes remain fast.

View File

@@ -0,0 +1,43 @@
# Agent Note: 推理分片的逐帧累计发布与浏览器压力验证
Status: implemented
[English](2026-08-03-opt-in-reasoning-chunk-browser-stress.md) | 中文
## 问题
长推理流会连续产生大量 `assistant/chunk`。这些原始事件必须逐个完成排序、日志记录和 `PartialAccumulator` 折叠,以保持重放保真度和最终内容完整;但 React 只需要看到当前累计结果,不需要观察同一浏览器帧内的每个中间态。
异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间,不能通过减慢生产方或丢弃原始事件来掩盖问题。
## 决策
`Session.acceptLiveEvent()` 立即追加每个原始事件,并同步更新 transcript、`PartialAccumulator` 及其他会话派生状态。可见的 `block-start``text-delta``reasoning-delta``tool-call-delta``block-end` 分片通过 `Notifier.markFrameDirty()` 发布:第一项变化调度一次 `requestAnimationFrame`,后续分片只继续更新累积器;帧回调从最新状态重建一个累计快照并通知订阅者一次。`usage``finish` 及未知的不可见分片保留在事件窗口中,但不触发无效的 React 通知。会话与历史检查共用同一可见分片分类。
`Notifier` 用调度种类和代际标记管理待发布工作。普通结构事件继续通过 `markDirty()` 在微任务发布;如果定稿消息、工具事件或错误到达时仍有待执行的帧发布,微任务会取代它,旧帧回调因代际不匹配而失效。`notifyNow()` 同样使旧调度失效,以保留受控输入的同步回响。没有 `requestAnimationFrame` 的环境退回微任务合批。定稿事件可以跳过一次尚未显示的中间 partial但发布的定稿内容和原始事件序列保持完整。
实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth``clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`
`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。确定性的 `?fixture` 会话以独立于绘制的节奏发出 100,000 个 `reasoning-delta`,结尾标记证明事件经过生产会话归并并到达实时 Think 行50 毫秒心跳和预先调度的 DOM 事件分别测量主线程停顿与交互延迟250 毫秒预算用于识别明显回归。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。
聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。fixture 的小型单元测试继续固定输入校验、外部到达节奏、并发拒绝、精确事件数和结尾标记交付,无需把 100,000 分片工作负载带入默认测试套件。
## 曾考虑的替代方案
**在 React 内对快照使用 transition、deferred value 或组件节流。** 不予采纳:会话源仍会逐分片通知 `useSyncExternalStore`React render 在组件决定延后展示之前已经发生且多个消费同一快照的组件需要重复实现策略。Think 摘要的视觉跟尾节流位于快照发布之后,只减少同步布局频率,不承担数据发布策略。
**在接收或日志层丢弃、抽样或拼接原始分片。** 不予采纳:原始 `assistant/chunk` 是可重放的会话事实,改变它会损失诊断与 UI 保真度,并把展示频率策略混入数据权威层。
**只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使一个微任务调度近似退化为一次分片一次通知。
**按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿。
**真实模型或录制的 HTTP 字节流。** 不予采纳实时模型不具确定性HTTP/SSEServer-Sent Events录制也不会改进目标断言。内存 fixture 保留逐个异步会话事件、生产客户端归并和 React 渲染路径,同时控制工作负载与到达节奏。
## 后果
流式 `ConversationSnapshot` 的发布频率受浏览器绘制频率约束React 每帧至多处理一个包含全部已接收文本的累计 partial结构事件仍可更快发布。接收、排序、日志记录、字符串拼接和累积器更新仍按原始分片执行因此该决策降低的是快照重建与 React 工作,不把原始流解析成本伪装成已解决。
折叠 Think 摘要的横向布局读写最多每三帧执行一次并直接追上该时刻的最新位置React 仍按累计快照正常提交,定稿时摘要恢复到首行。该局部视觉策略不会改变正文滚动和用户交互的即时性。
浏览器压力车道继续提供真实组装应用上的响应性信号和可见 profiling 入口,但硬件与调度差异使其只适合作为显式性能证据。确定性的 focused tests 负责守住发布次数、累计内容与抢占顺序,默认测试车道保持快速。

View File

@@ -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)

View File

@@ -33,6 +33,7 @@ const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
const MODE = webSnapshotMode()
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
const REPLAY_PACE_MS = 100
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
let scaffold: WebScaffold
@@ -42,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)

View File

@@ -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",

View File

@@ -51,7 +51,7 @@ Non-negotiables across the layers:
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Directory regime (plugin packages)

View File

@@ -1168,6 +1168,16 @@ interface StreamConn<F> {
push(envelope: RpcRequest<F>): 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. */
@@ -1444,6 +1454,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<SessionId, { turn: number; stepStarted: boolean }>()
/** 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
@@ -1467,6 +1479,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)

View File

@@ -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)', () => {

View File

@@ -8,7 +8,7 @@ import type {
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -431,11 +431,3 @@ export class SessionHistorySource implements SessionHistoryFace {
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}

View File

@@ -1,5 +1,6 @@
// Notifier: subscription + microtask-batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
// Notifier: subscription + batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush, while
// N markFrameDirty calls collapse into one animation-frame flush;
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
@@ -9,12 +10,13 @@
// swallow the notification — push subscribers (object-layer watchers) would
// otherwise starve whenever any reader pulls first.
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
/** Subscription + batched notification primitive (shared by Session and SessionManager). */
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private notifyPending = false
private scheduled = false
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
private scheduleGeneration = 0
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
constructor(private readonly rebuild: () => void) {}
@@ -35,19 +37,16 @@ export class Notifier {
markDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled) return
this.scheduled = true
queueMicrotask(() => {
this.scheduled = false
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
})
if (this.scheduled === 'microtask') return
this.schedule('microtask')
}
/** Stream-change entry: mark dirty and publish the cumulative state at most once per frame. */
markFrameDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled !== 'none') return
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
}
/**
@@ -57,11 +56,8 @@ export class Notifier {
notifyNow(): void {
this.dirty = true
this.notifyPending = true
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
this.notifyPending = false
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
this.invalidateSchedule()
this.flush()
}
/**
@@ -73,4 +69,35 @@ export class Notifier {
this.dirty = false
this.rebuild()
}
private schedule(kind: 'microtask' | 'frame'): void {
const generation = ++this.scheduleGeneration
this.scheduled = kind
const publish = () => {
if (generation !== this.scheduleGeneration) return
this.scheduled = 'none'
this.flush()
}
if (kind === 'frame') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
private invalidateSchedule(): void {
this.scheduleGeneration++
this.scheduled = 'none'
}
private flush(): void {
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
}
}

View File

@@ -6,6 +6,19 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
import { toAssistantBlock } from './conversation.ts'
/**
* Whether a stream chunk changes the partial assistant projection shown by the UI.
* @param type - Stream chunk discriminant.
* @returns Whether publishing the accumulated partial can change the visible snapshot.
*/
export function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.

View File

@@ -21,7 +21,7 @@ import { PendingWait } from './pending.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
@@ -690,6 +690,10 @@ export class Session implements SessionFace {
return
}
this.appendLive(event, view)
if (event.type === 'assistant/chunk') {
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
return
}
this.notifier.markDirty()
}

View File

@@ -1,13 +1,17 @@
/**
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
* laziness, synchronous notifyNow, and unsubscribe.
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
* no-listener laziness, synchronous notifyNow, and unsubscribe.
*/
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Notifier } from '../src/client/sessions/notifier.ts'
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Notifier', () => {
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
const order: string[] = []
@@ -60,6 +64,57 @@ describe('Notifier', () => {
expect(rebuilds).toBe(1)
})
it('collapses frame-dirty changes into one cumulative frame publication', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.markFrameDirty()
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(order).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(order).toEqual(['rebuild', 'notify'])
})
it('lets a structural microtask publication supersede a pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markDirty()
await microtask()
expect(notifications).toBe(1)
frames.shift()!(0)
expect(notifications).toBe(1)
})
it('falls back to microtask batching when animation frames are unavailable', async () => {
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(notifications).toBe(0)
await microtask()
expect(notifications).toBe(1)
})
it('unsubscribed listeners stop receiving notifications', async () => {
let calls = 0
const notifier = new Notifier(() => undefined)

View File

@@ -6,7 +6,7 @@
* enough.
*/
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -20,6 +20,10 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api) }
}
@@ -163,6 +167,40 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const { session } = await opened()
const published: Array<string | null> = []
session.subscribe(() => {
const block = session.getSnapshot().partial?.blocks[0]
published.push(block?.kind === 'text' ? block.text : null)
})
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.chunkStart(6, 1))
feed(ev.chunkText(7, 1, '累'))
feed(ev.chunkText(8, 1, '计'))
expect(published).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(published).toEqual(['累计'])
feed(ev.chunkText(9, 1, '完成'))
feed(ev.assistant(10, 1, '累计完成'))
await Promise.resolve()
expect(published).toEqual(['累计', null])
frames.shift()!(0)
expect(published).toEqual(['累计', null])
})
it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

View File

@@ -20,7 +20,7 @@
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -33,6 +33,7 @@ import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
import css from './ToolRow.module.css'
export interface ToolRowProps {
@@ -176,13 +177,17 @@ export function ToolRow({
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const isThink = variant === 'think'
const followSummaryEnd = isThink && state === 'running' && !open
useLayoutEffect(() => {
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
const summaryElement = summaryRef.current
if (summaryElement === null) return
summaryElement.scrollLeft = followSummaryEnd
? summaryElement.scrollWidth - summaryElement.clientWidth
: 0
}, [followSummaryEnd, summaryText])
})
useEffect(() => {
if (!isThink) return
scheduleSummaryScroll()
}, [followSummaryEnd, isThink, scheduleSummaryScroll, summaryText])
const toggleExpand = () => {
setExpanded(v => !v)
}

View File

@@ -0,0 +1,42 @@
/** Frame-throttled scheduling for non-essential visual alignment. */
import { useCallback, useLayoutEffect, useRef } from 'react'
const DEFAULT_INTERVAL_FRAMES = 3
/**
* Return a stable scheduler that coalesces visual updates over a frame interval.
* Repeated calls retain the latest callback, and unmount cancels pending work.
* @param update - DOM alignment to run after the throttle interval.
* @param intervalFrames - Frames to wait before applying the latest alignment.
* @returns a stable function that schedules the latest update.
*/
export function useThrottledVisualUpdate(
update: () => void,
intervalFrames = DEFAULT_INTERVAL_FRAMES,
): () => void {
const updateRef = useRef(update)
updateRef.current = update
const pendingFrameRef = useRef<number | null>(null)
useLayoutEffect(() => () => {
if (pendingFrameRef.current === null) return
cancelAnimationFrame(pendingFrameRef.current)
pendingFrameRef.current = null
}, [])
return useCallback(() => {
if (pendingFrameRef.current !== null) return
let remainingFrames = intervalFrames
const advance = (): void => {
remainingFrames -= 1
if (remainingFrames > 0) {
pendingFrameRef.current = requestAnimationFrame(advance)
return
}
pendingFrameRef.current = null
updateRef.current()
}
pendingFrameRef.current = requestAnimationFrame(advance)
}, [intervalFrames])
}

View File

@@ -1,8 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -12,6 +11,36 @@ import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(index)
}
}
beforeEach(() => {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
@@ -341,6 +370,10 @@ describe('ThinkRow', () => {
streaming
/>,
)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(2)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(1)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
@@ -351,6 +384,7 @@ describe('ThinkRow', () => {
streaming={false}
/>,
)
flushAnimationFrames(3)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)

View File

@@ -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",

View File

@@ -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,
},
})