diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 8cbe83c2f7..31c799468d 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml @@ -29,6 +29,9 @@ /agent-note-frontend-large-session-performance-budgets/calibration/history-selector-invalidation: en: 2d2f78f4cf6aad8a zh: aec6af5f7c66de16 +/agent-note-frontend-large-session-performance-budgets/calibration/streaming-assistant-visibility: + en: 5dc3c84b80be0e8e + zh: 4bbd16e1f455ef85 /agent-note-frontend-large-session-performance-budgets/alternatives-considered: en: 4d2a4a64ccd14507 zh: 8a4aaf4d4abea13d diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 16277f4f37..50cc863788 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -102,6 +102,23 @@ These local runs both failed the independent open budget: their open medians wer Post-GC browser heap was 80.70/80.71 MiB. GNU time reported maximum RSS of 535.78/561.80 MiB for each benchmark command and its children; this is not input-phase memory or an aggregate browser-memory measurement. Moving the composer's selection reveal into an animation frame was rejected: profiling showed the style flush moved to another geometry read, without improving typing. The product fix removes the invalidation source instead. +### Streaming Assistant visibility + +Chat retains visibility per Assistant block. Same-kind text and reasoning appends inspect only the incoming delta until the block becomes visible, then reuse that result. This avoids repeated `trim()` calls on accumulated strings without changing whitespace, first-token timing, or visible anchors. Replacements recalculate visibility, retry resets it, and final messages rebuild it. Copying the block array remains proportional to its length; this does not make every Assembler operation constant-time. + +The [conversation-fold worker](../../../../benchmarks/conversation-fold/conversation-fold.worker.client.ts) measures a single 256 KiB block split into 4,096 or 16,384 deltas, using ordinary text or reasoning whose first half is whitespace. Five fresh compiled Node children per case reuse the existing benchmark registrations, production Assembler and Chat builder. Timing includes every append, one final flush, and reading every output character. Fixture preparation and GC are excluded; retained heap is the post-GC increment with the Assembler, live entries and published state still reachable. + +Local macOS arm64 measurements on Node 26.8.2 give these elapsed medians. The time limits use reference allowances of 12/32 ms and the existing shared scale and headroom; they are not new hosted-runner calibration. + +| Workload | Deltas | Repeated accumulated trim | Cached visibility | Time limit | +|---|---:|---:|---:|---:| +| Ordinary text | 4,096 | 42.25 ms | 7.32 ms | 30 ms | +| Half-leading-whitespace reasoning | 4,096 | 252.75 ms | 7.96 ms | 30 ms | +| Ordinary text | 16,384 | 206.71 ms | 23.06 ms | 80 ms | +| Half-leading-whitespace reasoning | 16,384 | 1114.18 ms | 23.08 ms | 80 ms | + +All four cases reject accumulated trimming and pass with cached visibility. Retained heap remains approximately 2.8/9.9 MiB for the two fragment counts, below the 20 MiB limit derived from a 16 MiB allowance. Maximum worker RSS across these samples falls from 185.70 to 89.36 MiB; it is a whole-process diagnostic, not aggregate application memory. This endpoint measures local assembly with one final publication, excluding network, browser rendering and Trajectory. Separate browser regressions cover settlement, cancellation, retry, reload, reopening and blank reasoning without asserting a browser speedup. + ## Alternatives considered **Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim. diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index 159c31f935..1c469a7768 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -102,6 +102,23 @@ Trajectory 激活在动画帧上采样可见搜索框与 `[data-trajectory-scrol 强制 GC 后的浏览器 heap 为 80.70/80.71 MiB。GNU time 为两次基准命令及其子进程报告的最大 RSS 为 535.78/561.80 MiB;这不是输入阶段内存,也不是浏览器总内存测量。曾尝试将输入框的选区显示推迟到动画帧,但分析显示样式刷新转移到另一处几何读取,没有改善键入,因此未采用。产品修复直接移除样式失效的来源。 +### 流式 Assistant 可见性 + +Chat 保存每个 Assistant 块的可见状态。同类型文本和推理追加只检查新增片段,直到该块可见,之后复用结果。这避免对累计字符串重复调用 `trim()`,同时保留空白、首 token 时间及可见锚点。块替换重新计算可见性,重试清空状态,最终消息重建状态。块数组复制仍与其长度成正比,因此并非每次 Assembler 操作都是常数时间。 + +[conversation-fold worker](../../../../benchmarks/conversation-fold/conversation-fold.worker.client.ts) 测量单个 256 KiB 块,拆成 4,096 或 16,384 个片段,内容为普通文本或前半段为空白的推理。每个用例使用五个全新的已编译 Node 子进程,复用现有基准注册、生产 Assembler 和 Chat builder。计时包含每次追加、一次最终 flush 及逐字符读取输出。夹具准备和 GC 不计时;保留堆内存为 GC 后的增量,此时 Assembler、实时条目和已发布状态仍可达。 + +macOS arm64、Node 26.8.2 上的本地测量得到以下耗时中位数。时间上限使用 12/32 ms 参考额度及现有共享缩放与余量,不代表新的托管运行器校准。 + +| 工作负载 | 片段数 | 累计文本重复 trim | 缓存可见性 | 时间上限 | +|---|---:|---:|---:|---:| +| 普通文本 | 4,096 | 42.25 ms | 7.32 ms | 30 ms | +| 前半段为空白的推理 | 4,096 | 252.75 ms | 7.96 ms | 30 ms | +| 普通文本 | 16,384 | 206.71 ms | 23.06 ms | 80 ms | +| 前半段为空白的推理 | 16,384 | 1114.18 ms | 23.08 ms | 80 ms | + +四个用例都拒绝累计文本重复 trim,并在缓存可见性后通过。两个片段规模的保留堆内存仍约为 2.8/9.9 MiB,低于 16 MiB 额度经余量计算后的 20 MiB 上限。这些样本中的 worker 最大 RSS 从 185.70 降至 89.36 MiB;它是整个进程的诊断值,不是应用总内存。该终点只测量一次最终发布的本地组装,不包含网络、浏览器渲染和 Trajectory。独立浏览器回归覆盖结算、取消、重试、刷新、重开及空白推理,不据此声称浏览器提速。 + ## 考虑过的替代方案 **用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。 diff --git a/benchmarks/conversation-fold/conversation-fold.bench.client.ts b/benchmarks/conversation-fold/conversation-fold.bench.client.ts index add5a34629..3ab528a616 100644 --- a/benchmarks/conversation-fold/conversation-fold.bench.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.bench.client.ts @@ -1,4 +1,4 @@ -/** Required performance budgets for compiled Client history folding and tool preparation. */ +/** Required performance budgets for compiled Client history folding and live Assistant updates. */ import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -10,8 +10,8 @@ import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM, } from '../support/calibration.ts' -import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts' -import type { ConversationFoldWorkerReport, PreparingToolWorkerReport } from './conversation-fold.worker.client.ts' +import { recordPeakMemory, recordTimings, type BenchmarkCase } from '../support/scaling-report.ts' +import type { ConversationFoldWorkerReport, PreparingToolWorkerReport, StreamingAssistantWorkerReport } from './conversation-fold.worker.client.ts' /** Replies in the folded window; each carries one reasoning block and one text block. */ const TURNS = 200 @@ -54,6 +54,20 @@ const PREPARING_CASES = [ { tool: 'bash', characters: 128 * 1024, expectedMs: 150, expectedMb: 8 }, ] as const +const STREAMING_CHARACTERS = 256 * 1024 +const STREAMING_SAMPLES = 5 +/** Reference-machine expectations; shared CI scaling applies only to elapsed time. */ +const STREAMING_WORKLOADS = [ + { fragments: 4_096, expectedMs: 12 }, + { fragments: 16_384, expectedMs: 32 }, +] as const +const STREAMING_CASES = STREAMING_WORKLOADS.flatMap(workload => [ + { ...workload, blockType: 'text', pattern: 'ordinary' }, + { ...workload, blockType: 'reasoning', pattern: 'leading-whitespace' }, +] as const) +/** Incremental heap while the Assembler, live entries, and published Chat state remain reachable. */ +const EXPECTED_STREAMING_RETAINED_MB = 16 + const WORKER = join( import.meta.dirname, '..', @@ -127,3 +141,39 @@ describe('preparing tool arguments', () => { expect(retainedMb).toBeLessThanOrEqual(budgetMb) }) }) + +describe('live Assistant text publication', () => { + it.each(STREAMING_CASES)('publishes $fragments $blockType deltas with $pattern content within bounded CPU and heap costs', async (workload) => { + const samples: StreamingAssistantWorkerReport[] = [] + for (let index = 0; index < STREAMING_SAMPLES; index++) { + const run = await runBuiltBenchmarkWorker({ + worker: WORKER, + args: ['streaming', workload.blockType, workload.pattern, String(STREAMING_CHARACTERS), String(workload.fragments)], + exposeGc: true, timeoutMs: WORKER_TIMEOUT_MS, + }) + expect(run.timedOut, run.stderr).toBe(false) + expect(run.signal, run.stderr).toBeNull() + expect(run.exitCode, run.stderr).toBe(0) + const report = requireReport(run) + expect(report.characters).toBe(STREAMING_CHARACTERS) + expect(report.fragments).toBe(workload.fragments) + samples.push(report) + } + const median = samples.toSorted((a, b) => a.elapsedMs - b.elapsedMs)[Math.floor(STREAMING_SAMPLES / 2)]! + const retainedMb = samples.map(sample => sample.retainedMb).toSorted((a, b) => a - b)[Math.floor(STREAMING_SAMPLES / 2)]! + const budgetMs = ciTimeBudget(workload.expectedMs) + const budgetMb = EXPECTED_STREAMING_RETAINED_MB * PERFORMANCE_BUDGET_HEADROOM + const benchmarkCase: BenchmarkCase = { + id: `conversation-fold/streaming-${workload.blockType}-${workload.pattern}-${String(workload.fragments)}`, + measures: `Client append, final Chat publication, and complete text read for ${String(STREAMING_CHARACTERS / 1024)} KiB in ${String(workload.fragments)} ${workload.blockType} deltas with ${workload.pattern} content.`, + affects: 'Local processing of a long active model response; excludes model pacing, network transport, and browser rendering.', + } + console.log(JSON.stringify({ benchmark: benchmarkCase.id, samples, medianMs: median.elapsedMs, retainedMb, budgetMs, budgetMb })) + recordTimings(benchmarkCase, { + medianMs: { ms: median.elapsedMs, cpuMs: median.cpuUserMs + median.cpuSystemMs }, + }, { medianMs: budgetMs }) + recordPeakMemory(benchmarkCase, { peakRssMb: Math.max(...samples.map(sample => sample.maxRssMb)) }) + expect(median.elapsedMs).toBeLessThanOrEqual(budgetMs) + expect(retainedMb).toBeLessThanOrEqual(budgetMb) + }) +}) diff --git a/benchmarks/conversation-fold/conversation-fold.worker.client.ts b/benchmarks/conversation-fold/conversation-fold.worker.client.ts index 77297a131e..ee42c361a5 100644 --- a/benchmarks/conversation-fold/conversation-fold.worker.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.worker.client.ts @@ -1,4 +1,4 @@ -/** Compiled worker for Client history folding and live tool preparation. */ +/** Compiled worker for Client history folding, live Assistant text, and tool preparation. */ import { performance } from 'node:perf_hooks' import { Context } from '@deepseek-ai/cordis' @@ -57,6 +57,20 @@ export interface PreparingToolWorkerReport { readonly detail: string | undefined } +/** One live Assistant block folded and read through the published Chat snapshot. */ +export interface StreamingAssistantWorkerReport { + readonly blockType: 'text' | 'reasoning' + readonly pattern: 'ordinary' | 'leading-whitespace' + readonly characters: number + readonly fragments: number + readonly checksum: number + readonly elapsedMs: number + readonly cpuUserMs: number + readonly cpuSystemMs: number + readonly retainedMb: number + readonly maxRssMb: number +} + function registerEventDefinitions(ctx: Context): ConversationEventRegistry { const events = new ConversationEventRegistry(ctx) // Only the service holder is an adapter; registrations and routing use production code. @@ -257,6 +271,74 @@ function preparingTool(tool: 'write' | 'bash', characters: number, definitions: } } +function streamingAssistant( + blockType: StreamingAssistantWorkerReport['blockType'], + pattern: StreamingAssistantWorkerReport['pattern'], + characters: number, + fragments: number, + definitions: ConversationEventRegistry, +): StreamingAssistantWorkerReport { + if (globalThis.gc === undefined) throw new Error('streaming benchmark requires --expose-gc') + if (characters % fragments !== 0) throw new Error('streaming characters must divide evenly into fragments') + const chunkSize = characters / fragments + const payload = pattern === 'ordinary' + ? 'abcdefghijklmno '.repeat(Math.ceil(characters / 16)).slice(0, characters) + : ' '.repeat(characters / 2) + 'abcdefghijklmno '.repeat(Math.ceil(characters / 32)).slice(0, characters / 2) + const attemptId = LlmAttemptId('streaming-benchmark') + const live = (index: number, chunk: StreamChunk): SessionEventLikeEntry => ({ + type: 'transient', + event: { + type: 'assistant/live-chunk', seq: 2 + index / (fragments + 1), time: TIME_ZERO + index, + data: { turn: 1, step: 1, attemptId, chunk }, + }, + }) + const chunks: SessionEventLikeEntry[] = [] + for (let index = 0; index < fragments; index++) { + chunks.push(live(index + 1, { + type: blockType === 'text' ? 'text-delta' : 'reasoning-delta', index: 0, + text: payload.slice(index * chunkSize, (index + 1) * chunkSize), + })) + } + const assembler = new ConversationNodeAssembler(definitions, new BenchViewDefinitions()) + assembler.replaceWindow([ + entry(0, 'turn/start', { turn: 1 }), + entry(1, 'step/start', { turn: 1, step: 1 }), + live(0, { type: 'block-start', index: 0, blockType }), + ], false) + assembler.activateTarget('chat') + globalThis.gc() + const before = process.memoryUsage().heapUsed + const start = performance.now() + const cpuStart = process.cpuUsage() + for (const chunk of chunks) assembler.append(chunk) + assembler.flush() + const text = streamingText(assembler, blockType) + // Consuming every character includes any deferred string materialization in the endpoint. + let checksum = 0 + for (let index = 0; index < text.length; index++) checksum += text.charCodeAt(index) + const cpu = process.cpuUsage(cpuStart) + const elapsedMs = performance.now() - start + if (text !== payload) throw new Error('streaming benchmark did not publish the complete Assistant text') + globalThis.gc() + const retainedMb = (process.memoryUsage().heapUsed - before) / 1048576 + // This read keeps the complete owner graph reachable across endpoint collection. + if (streamingText(assembler, blockType) !== text) throw new Error('streaming benchmark lost its published Assistant text') + return { + blockType, pattern, characters: text.length, fragments, checksum, + elapsedMs, cpuUserMs: cpu.user / 1000, cpuSystemMs: cpu.system / 1000, + retainedMb, maxRssMb: process.resourceUsage().maxRSS / 1024, + } +} + +function streamingText(assembler: ConversationNodeAssembler, blockType: StreamingAssistantWorkerReport['blockType']): string { + const node = assembler.get('chat')?.nodes.values().find((candidate): candidate is ChatNode<'assistant-step'> => candidate.kind === 'assistant-step') + const block = node?.data.blocks[0] + if (block?.kind !== blockType || (block.kind !== 'text' && block.kind !== 'reasoning')) { + throw new Error('streaming benchmark did not create its Assistant block') + } + return block.text +} + assertBuiltBenchmarkRuntime(import.meta.url, { '@deepseek-ai/dsh-brand': import.meta.resolve('@deepseek-ai/dsh-brand'), '@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'), @@ -273,6 +355,16 @@ try { if (tool !== 'write' && tool !== 'bash') throw new Error('preparing benchmark tool must be write or bash') const report = preparingTool(tool, positiveInteger(process.argv[4], 'characters'), definitions) process.stdout.write(`${JSON.stringify(report)}\n`) + } else if (process.argv[2] === 'streaming') { + const blockType = process.argv[3] + const pattern = process.argv[4] + if (blockType !== 'text' && blockType !== 'reasoning') throw new Error('streaming block type must be text or reasoning') + if (pattern !== 'ordinary' && pattern !== 'leading-whitespace') throw new Error('streaming pattern must be ordinary or leading-whitespace') + const report = streamingAssistant( + blockType, pattern, positiveInteger(process.argv[5], 'characters'), + positiveInteger(process.argv[6], 'fragments'), definitions, + ) + process.stdout.write(`${JSON.stringify(report)}\n`) } else { const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2) const turns = positiveInteger(turnsValue, 'turns') diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index ea9a48c09a..5b652c5425 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -18,8 +18,8 @@ en: 9386791a9e5efa00 zh: 7cb9f3bbc42319d6 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: - en: cbf1f0a2205046a5 - zh: 2e59d0815351349f + en: b0c4e6711d69d2f8 + zh: 09add0ea1a243922 /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: en: be90c24392fb021b zh: 992b8745b385f9ab diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 99b01c5566..56b30d271b 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -58,6 +58,8 @@ Chat omits system-prompt, ordinary Context injection, and `permission` command r When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start. +Text and reasoning contribute to Assistant visibility only when they contain a non-whitespace character. Streaming appends reuse each block's visibility; block replacement and finalized messages recalculate it while preserving the original text. + ## Command and failure rows diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 35231f8e5e..1cc14f917a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -58,6 +58,8 @@ Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 +只有包含非空白字符的文本和推理块才计入 Assistant 的可见内容。流式追加复用每个块的可见状态;块替换和最终消息重新计算可见性,同时保留原始文本。 + ## 指令与失败行 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index b7658a1bf5..6afb0efde8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -30,6 +30,8 @@ interface AssistantState { readonly turn: number readonly step: number readonly blocks: readonly (AssistantBlock | undefined)[] + /** Missing indices are invisible; same-kind appends preserve already-visible blocks. */ + readonly visibleByIndex: readonly boolean[] readonly visibleBlocks: number readonly firstVisibleSeq: number | undefined readonly firstVisibleTime: number | undefined @@ -43,6 +45,7 @@ function initialState(turn: number, step: number): AssistantState { turn, step, blocks: [], + visibleByIndex: [], visibleBlocks: 0, firstVisibleSeq: undefined, firstVisibleTime: undefined, @@ -62,12 +65,6 @@ function blockIsVisible(block: AssistantBlock | undefined): boolean { return true } -function countVisibleBlocks(blocks: readonly AssistantBlock[]): number { - let count = 0 - for (const block of blocks) if (blockIsVisible(block)) count++ - return count -} - function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean { return blocks.some(blockIsVisible) } @@ -101,30 +98,34 @@ function updateChunk( const blocks = [...state.blocks] let changedIndex = -1 let previousVisible = false + let nextVisible = false switch (chunk.type) { case 'block-start': changedIndex = chunk.index - previousVisible = blockIsVisible(blocks[chunk.index]) + previousVisible = state.visibleByIndex[chunk.index] === true blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) + nextVisible = blockIsVisible(blocks[chunk.index]) break case 'text-delta': { const previous = blocks[chunk.index] changedIndex = chunk.index - previousVisible = blockIsVisible(previous) + previousVisible = state.visibleByIndex[chunk.index] === true blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text } + nextVisible = (previous?.kind === 'text' && previousVisible) || chunk.text.trim() !== '' break } case 'reasoning-delta': { const previous = blocks[chunk.index] changedIndex = chunk.index - previousVisible = blockIsVisible(previous) + previousVisible = state.visibleByIndex[chunk.index] === true blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text } + nextVisible = (previous?.kind === 'reasoning' && previousVisible) || chunk.text.trim() !== '' break } case 'tool-call-delta': { const previous = blocks[chunk.index] changedIndex = chunk.index - previousVisible = blockIsVisible(previous) + previousVisible = state.visibleByIndex[chunk.index] === true // Tool Nodes own streamed arguments; Assistant protocol blocks retain identity until full settlement. blocks[chunk.index] = { kind: 'tool-call', @@ -136,8 +137,9 @@ function updateChunk( } case 'block-end': changedIndex = chunk.index - previousVisible = blockIsVisible(blocks[chunk.index]) + previousVisible = state.visibleByIndex[chunk.index] === true blocks[chunk.index] = toAssistantBlock(chunk.block) + nextVisible = blockIsVisible(blocks[chunk.index]) break case 'usage': return { ...state, usage: chunk.usage } @@ -146,11 +148,18 @@ function updateChunk( } const visibleBlocks = state.visibleBlocks - Number(previousVisible) - + Number(blockIsVisible(blocks[changedIndex])) + + Number(nextVisible) + let visibleByIndex = state.visibleByIndex + if (previousVisible !== nextVisible) { + const next = [...visibleByIndex] + next[changedIndex] = nextVisible + visibleByIndex = next + } const firstToken = isTokenDelta(chunk) return { ...state, blocks, + visibleByIndex, visibleBlocks, ...visibleBlocks > 0 && state.firstVisibleSeq === undefined ? { firstVisibleSeq: seq, firstVisibleTime: time } @@ -167,10 +176,12 @@ function settleMessage( event: SessionEvent<'assistant/message'>, ): AssistantState { const blocks = toAssistantBlocks(event.data.message.content) + const visibleByIndex = blocks.map(blockIsVisible) return { ...state, blocks, - visibleBlocks: countVisibleBlocks(blocks), + visibleByIndex, + visibleBlocks: visibleByIndex.reduce((count, visible) => count + Number(visible), 0), final: match, usage: event.data.usage, } diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index e8bf15bddc..6a2871d0fb 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -13,7 +13,7 @@ import { import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { inspectSystemPrompt } from '../../ui-conversation/src/client/contract/system-prompt.ts' import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' -import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand' +import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' @@ -169,6 +169,17 @@ function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | und return value.nodes.values().find(candidate => candidate.kind === kind) } +function appendAssistantChunk(value: ConversationNodeAssembler, seq: number, chunk: StreamChunk): void { + value.append({ + type: 'transient', + event: { + type: 'assistant/live-chunk', seq, time: 1_700_000_000_000 + seq, + data: { attemptId: LlmAttemptId('assistant-visibility'), turn: 1, step: 1, chunk }, + }, + }) + value.flush() +} + function promptNodes(value: ChatSnapshot): ChatConversationViewNode[] { const prompts = value.nodes.values().filter(candidate => candidate.kind === 'system-prompt') for (const prompt of prompts) expect(value.order).not.toContain(prompt.key) @@ -1128,6 +1139,94 @@ describe('built-in conversation node Definitions', () => { .toMatchObject({ answerAnchorSeq: null, answerStep: null }) }) + it.each(['text', 'reasoning'] as const)('retains %s whitespace and anchors the first visible delta', (kind) => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + ]) + const whitespace = '\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007' + + '\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff' + appendAssistantChunk(value, 3, { type: 'block-start', index: 0, blockType: kind }) + appendAssistantChunk(value, 4, { type: `${kind}-delta`, index: 0, text: whitespace }) + expect(node(snapshot(value), 'assistant-step')).toBeUndefined() + + appendAssistantChunk(value, 5, { type: `${kind}-delta`, index: 0, text: '\u200b' }) + const visible = node(snapshot(value), 'assistant-step') + expect(visible).toMatchObject({ anchorSeq: 5, visibility: 'visible', data: { time: 1_700_000_000_005 } }) + appendAssistantChunk(value, 6, { type: `${kind}-delta`, index: 0, text: `${whitespace}answer` }) + const content = `${whitespace}\u200b${whitespace}answer` + expect(node(snapshot(value), 'assistant-step')).toMatchObject({ + key: visible?.key, anchorSeq: 5, visibility: 'visible', + data: { blocks: [{ kind, text: content }] }, + }) + + value.append(at(7, 'assistant/message', { + turn: 1, step: 1, + message: { ...assistantMessage('whitespace-answer', ''), content: [{ type: kind, text: content }] }, + }, { surfaceOp: 'append' })) + value.flush() + const settled = node(snapshot(value), 'assistant-step')?.data as AssistantChatData + expect(settled.finalNode?.timing?.firstTokenTime).toBe(1_700_000_000_004) + expect(settled.blocks).toEqual([{ kind, text: content }]) + }) + + it.each(['text', 'reasoning'] as const)('updates visibility when a sparse Assistant block becomes %s', (kind) => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + ]) + appendAssistantChunk(value, 3, { type: 'text-delta', index: 0, text: 'answer' }) + const first = node(snapshot(value), 'assistant-step') + const previousKind = kind === 'text' ? 'reasoning' : 'text' + appendAssistantChunk(value, 4, { type: `${previousKind}-delta`, index: 2, text: 'thinking' }) + appendAssistantChunk(value, 5, { type: 'block-start', index: 0, blockType: 'text' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 6, { type: `${previousKind}-delta`, index: 2, text: ' ' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 7, { type: `${kind}-delta`, index: 2, text: ' ' }) + expect(node(snapshot(value), 'assistant-step')).toMatchObject({ + key: first?.key, anchorSeq: 3, visibility: 'hidden', + data: { blocks: [{ kind: 'text', text: '' }, { kind, text: ' ' }] }, + }) + appendAssistantChunk(value, 8, { type: 'block-end', index: 0, block: { type: 'text', text: 'replacement' } }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 9, { type: 'block-end', index: 0, block: { type: 'text', text: ' ' } }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden') + appendAssistantChunk(value, 10, { type: 'block-start', index: 0, blockType: 'image' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 11, { + type: 'tool-call-delta', index: 0, id: ToolCallId('visibility-tool'), name: 'read', argumentsDelta: '', + }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden') + appendAssistantChunk(value, 12, { type: 'block-start', index: 0, blockType: 'image' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 13, { type: 'block-start', index: 0, blockType: 'tool-call' }) + expect(node(snapshot(value), 'assistant-step')).toMatchObject({ + key: first?.key, anchorSeq: 3, visibility: 'hidden', + }) + }) + + it('waits for visible content again after an Assistant retry', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + ]) + appendAssistantChunk(value, 3, { type: 'text-delta', index: 0, text: 'first attempt' }) + const first = node(snapshot(value), 'assistant-step') + value.append(at(4, 'llm/retry', { + retryId: 'visibility-retry', turn: 1, step: 1, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + })) + appendAssistantChunk(value, 5, { type: 'reasoning-delta', index: 0, text: '\u00a0 ' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden') + appendAssistantChunk(value, 6, { type: 'reasoning-delta', index: 0, text: 'replacement' }) + expect(node(snapshot(value), 'assistant-step')).toMatchObject({ + key: first?.key, anchorSeq: 6, visibility: 'visible', + data: { time: 1_700_000_000_006, blocks: [{ kind: 'reasoning', text: '\u00a0 replacement' }] }, + }) + }) + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }),