| 1 | // UI performance scenarios: the workloads the streaming pipeline must stay |
| 2 | // smooth under — not just "a normal chat answer". Deterministic generators let |
| 3 | // the simulated harness (ui-perf-scenarios.test.ts) assert count budgets in |
| 4 | // CI, and the same specs are the contract for the future real-browser |
| 5 | // (Playwright) driver, which owns the wall-clock/fps side. |
| 6 | |
| 7 | export type ScenarioDriver = "simulated" | "browser"; |
| 8 | |
| 9 | export interface UIPerfScenario { |
| 10 | id: string; |
| 11 | title: string; |
| 12 | chunksPerSec: number; |
| 13 | reasoningChars: number; |
| 14 | reasoningVisible: boolean; |
| 15 | textChars: number; |
| 16 | paragraphs: number; |
| 17 | codeFences: number; |
| 18 | // browser-driver scenarios need a real input pipeline (typing, wheel, |
| 19 | // selection, tool-card interaction) or a real DOM/session to be meaningful. |
| 20 | driver: ScenarioDriver; |
| 21 | notes?: string; |
| 22 | } |
| 23 | |
| 24 | export const UI_PERF_SCENARIOS: UIPerfScenario[] = [ |
| 25 | { |
| 26 | id: "UI-PERF-01", |
| 27 | title: "normal answer: 2KB markdown prose", |
| 28 | chunksPerSec: 80, |
| 29 | reasoningChars: 0, |
| 30 | reasoningVisible: false, |
| 31 | textChars: 2_000, |
| 32 | paragraphs: 8, |
| 33 | codeFences: 0, |
| 34 | driver: "simulated", |
| 35 | }, |
| 36 | { |
| 37 | id: "UI-PERF-02", |
| 38 | title: "DeepSeek reasoning: 16KB reasoning + 8KB answer", |
| 39 | chunksPerSec: 150, |
| 40 | reasoningChars: 16_000, |
| 41 | reasoningVisible: true, |
| 42 | textChars: 8_000, |
| 43 | paragraphs: 16, |
| 44 | codeFences: 0, |
| 45 | driver: "simulated", |
| 46 | }, |
| 47 | { |
| 48 | id: "UI-PERF-03", |
| 49 | title: "code heavy: 10 fences in 20KB markdown", |
| 50 | chunksPerSec: 120, |
| 51 | reasoningChars: 0, |
| 52 | reasoningVisible: false, |
| 53 | textChars: 20_000, |
| 54 | paragraphs: 10, |
| 55 | codeFences: 10, |
| 56 | driver: "simulated", |
| 57 | }, |
| 58 | { |
| 59 | id: "UI-PERF-04", |
| 60 | title: "interaction under streaming: typing, wheel, selection, tool cards", |
| 61 | chunksPerSec: 150, |
| 62 | reasoningChars: 8_000, |
| 63 | reasoningVisible: true, |
| 64 | textChars: 8_000, |
| 65 | paragraphs: 12, |
| 66 | codeFences: 2, |
| 67 | driver: "browser", |
| 68 | notes: "The point is input latency while streaming, not stream fps; needs the real input pipeline.", |
| 69 | }, |
| 70 | { |
| 71 | id: "UI-PERF-05", |
| 72 | title: "long session: 100 turns, 30 tool calls, diffs and code", |
| 73 | chunksPerSec: 100, |
| 74 | reasoningChars: 0, |
| 75 | reasoningVisible: false, |
| 76 | textChars: 4_000, |
| 77 | paragraphs: 10, |
| 78 | codeFences: 2, |
| 79 | driver: "simulated", |
| 80 | notes: "Per-delta cost must not scale with transcript length; DOM/heap steady state belongs to the browser driver.", |
| 81 | }, |
| 82 | { |
| 83 | id: "UI-PERF-06", |
| 84 | title: "background agent: tab A streams while tab B is active", |
| 85 | chunksPerSec: 150, |
| 86 | reasoningChars: 4_000, |
| 87 | reasoningVisible: false, |
| 88 | textChars: 4_000, |
| 89 | paragraphs: 8, |
| 90 | codeFences: 0, |
| 91 | driver: "simulated", |
| 92 | }, |
| 93 | ]; |
| 94 | |
| 95 | export interface ScenarioChunk { |
| 96 | kind: "text" | "reasoning"; |
| 97 | delta: string; |
| 98 | } |
| 99 | |
| 100 | function fill(prefix: string, target: number, sentence: string): string { |
| 101 | let out = prefix; |
| 102 | while (out.length < target) out += sentence; |
| 103 | return out.slice(0, target); |
| 104 | } |
| 105 | |
| 106 | // generateScenarioChunks produces the scenario's full stream as fixed-size |
| 107 | // deltas: reasoning first, then answer text with the requested paragraph and |
| 108 | // fence structure. Content is deterministic filler — budgets are about shape |
| 109 | // (blocks, fences, rates), never about wording. |
| 110 | export function generateScenarioChunks(spec: UIPerfScenario, chunkChars = 12): ScenarioChunk[] { |
| 111 | const chunks: ScenarioChunk[] = []; |
| 112 | const emit = (kind: "text" | "reasoning", content: string) => { |
| 113 | for (let i = 0; i < content.length; i += chunkChars) { |
| 114 | chunks.push({ kind, delta: content.slice(i, i + chunkChars) }); |
| 115 | } |
| 116 | }; |
| 117 | if (spec.reasoningChars > 0) { |
| 118 | emit("reasoning", fill("thinking: ", spec.reasoningChars, "considering the next step carefully. ")); |
| 119 | } |
| 120 | const blocks: string[] = []; |
| 121 | const proseTarget = Math.max(1, Math.floor((spec.textChars * (spec.codeFences > 0 ? 0.5 : 1)) / spec.paragraphs)); |
| 122 | for (let p = 0; p < spec.paragraphs; p += 1) { |
| 123 | blocks.push(fill(`Paragraph ${p} with **bold** and \`code\`. `, proseTarget, "More explanatory prose follows here. ")); |
| 124 | } |
| 125 | if (spec.codeFences > 0) { |
| 126 | const codeTarget = Math.max(24, Math.floor((spec.textChars * 0.5) / spec.codeFences)); |
| 127 | for (let f = 0; f < spec.codeFences; f += 1) { |
| 128 | const body = fill(`function block${f}() {\n`, codeTarget, ` compute(${f});\n`); |
| 129 | blocks.push(`\`\`\`js\n${body}\n\`\`\``); |
| 130 | } |
| 131 | } |
| 132 | emit("text", blocks.join("\n\n") + "\n\n"); |
| 133 | return chunks; |
| 134 | } |
| 135 |