|
| 1 | +/** |
| 2 | + * Perf gate for CL-6791 P5-J3: non-markdown rows must update in place or at |
| 3 | + * frame cadence — N updates to a row within one frame apply at most once, and |
| 4 | + * no update destroys and rebuilds the row's paint subtree. |
| 5 | + */ |
| 6 | +import { describe, expect, test } from "bun:test"; |
| 7 | +import { |
| 8 | + attachSessionBridge, |
| 9 | + createRecordingPort, |
| 10 | + type TaskProgressSession, |
| 11 | +} from "./runtime-bridge"; |
| 12 | +import { createAppShell } from "./shell/index"; |
| 13 | +import { transcriptRowChildren, streamRowCount, streamRowAt } from "./shell/transcript"; |
| 14 | +import { toolResultRow } from "./mcp-view"; |
| 15 | +import { withTestRenderer } from "./harness"; |
| 16 | +import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; |
| 17 | +import type { AppShell } from "./shell/internals.js"; |
| 18 | +import type { StreamRow } from "./stream.js"; |
| 19 | + |
| 20 | +type ChromeModule = typeof import("./shell/chrome.js"); |
| 21 | +type TeardownModule = typeof import("./teardown.js"); |
| 22 | + |
| 23 | +interface WorkCounts { |
| 24 | + destroys: number; |
| 25 | + builds: number; |
| 26 | + replaces: number; |
| 27 | +} |
| 28 | + |
| 29 | +/** |
| 30 | + * Count real work, not calls: subtree destroys (teardown), node rebuilds |
| 31 | + * (createStreamRowRenderable) and row retexts (replaceStreamRowAt) — the |
| 32 | + * seams a destroy-rebuild would have to pass through. |
| 33 | + */ |
| 34 | +async function withCountedWork<R>(run: (work: WorkCounts) => Promise<R>): Promise<R> { |
| 35 | + const work: WorkCounts = { destroys: 0, builds: 0, replaces: 0 }; |
| 36 | + return withMockedModuleDuring<TeardownModule, R>( |
| 37 | + import.meta.resolve("./teardown.js"), |
| 38 | + (real) => ({ |
| 39 | + ...real, |
| 40 | + destroySubtree: (node: unknown) => { |
| 41 | + work.destroys++; |
| 42 | + real.destroySubtree(node); |
| 43 | + }, |
| 44 | + }), |
| 45 | + () => |
| 46 | + withMockedModuleDuring<ChromeModule, R>( |
| 47 | + import.meta.resolve("./shell/chrome.js"), |
| 48 | + (real) => ({ |
| 49 | + ...real, |
| 50 | + replaceStreamRowAt: (shell: AppShell, index: number, row: StreamRow) => { |
| 51 | + work.replaces++; |
| 52 | + real.replaceStreamRowAt(shell, index, row); |
| 53 | + }, |
| 54 | + createStreamRowRenderable: ( |
| 55 | + ...args: Parameters<typeof real.createStreamRowRenderable> |
| 56 | + ) => { |
| 57 | + work.builds++; |
| 58 | + return real.createStreamRowRenderable(...args); |
| 59 | + }, |
| 60 | + }), |
| 61 | + () => run(work), |
| 62 | + ), |
| 63 | + ); |
| 64 | +} |
| 65 | + |
| 66 | +const SHELL_OPTS = { |
| 67 | + terminal: { columns: 100, rows: 24 }, |
| 68 | + wireKeys: false, |
| 69 | + run: "idle", |
| 70 | +} as const; |
| 71 | + |
| 72 | +function taskSession(over: Partial<TaskProgressSession>): TaskProgressSession { |
| 73 | + return { |
| 74 | + id: "task-1", |
| 75 | + status: "running", |
| 76 | + currentToolName: "grep", |
| 77 | + currentToolPreview: null, |
| 78 | + currentToolStartedAt: null, |
| 79 | + startedAt: 0, |
| 80 | + lastActivityAt: 0, |
| 81 | + ...over, |
| 82 | + }; |
| 83 | +} |
| 84 | + |
| 85 | +describe("row update perf gates (J3)", () => { |
| 86 | + test("N diff-row updates within one frame apply once, with no destroy or rebuild", async () => { |
| 87 | + await withCountedWork(async (work) => { |
| 88 | + await withTestRenderer( |
| 89 | + async (h) => { |
| 90 | + const shell = createAppShell(h.renderer, SHELL_OPTS); |
| 91 | + const bridge = attachSessionBridge(shell, createRecordingPort()); |
| 92 | + try { |
| 93 | + bridge.handle({ type: "inference.start", data: {} }); |
| 94 | + const arguments_ = JSON.stringify({ |
| 95 | + path: "src/a.ts", |
| 96 | + oldText: "x", |
| 97 | + newText: "y", |
| 98 | + }); |
| 99 | + for (let i = 0; i < 5; i++) { |
| 100 | + bridge.handle({ |
| 101 | + type: "inference.tool_call.end", |
| 102 | + data: { name: "edit_file", callId: `c${i}`, arguments: arguments_ }, |
| 103 | + }); |
| 104 | + } |
| 105 | + // The first call appends; the four repeats only fold into the |
| 106 | + // pending snapshot — nothing has repainted yet. |
| 107 | + expect(streamRowCount(shell)).toBe(1); |
| 108 | + expect(work.replaces).toBe(0); |
| 109 | + const destroysBeforeFrame = work.destroys; |
| 110 | + await h.renderOnce(); |
| 111 | + expect(work.replaces).toBe(1); |
| 112 | + // The coalesced repeat changes the row's shape (a run header, no |
| 113 | + // diff body), so the single frame-time application may rebuild — |
| 114 | + // but only once, never once per repeat. |
| 115 | + expect(work.destroys - destroysBeforeFrame).toBeLessThanOrEqual(1); |
| 116 | + const row = streamRowAt(shell, 0); |
| 117 | + expect(row?.coalesced).toBe(true); |
| 118 | + expect(row?.outstanding).toBe(5); |
| 119 | + // An idle frame applies nothing further. |
| 120 | + await h.renderOnce(); |
| 121 | + expect(work.replaces).toBe(1); |
| 122 | + } finally { |
| 123 | + bridge.dispose(); |
| 124 | + shell.dispose(); |
| 125 | + } |
| 126 | + }, |
| 127 | + { width: 100, height: 24 }, |
| 128 | + ); |
| 129 | + }); |
| 130 | + }); |
| 131 | + |
| 132 | + test("tool elapsed ticks: unchanged clock applies nothing, changed clock once per frame", async () => { |
| 133 | + await withCountedWork(async (work) => { |
| 134 | + await withTestRenderer( |
| 135 | + async (h) => { |
| 136 | + const shell = createAppShell(h.renderer, SHELL_OPTS); |
| 137 | + let nowMs = 1_000; |
| 138 | + let tick: (() => void) | undefined; |
| 139 | + const bridge = attachSessionBridge(shell, createRecordingPort(), { |
| 140 | + now: () => nowMs, |
| 141 | + schedule: (fn: () => void) => { |
| 142 | + tick = fn; |
| 143 | + return () => {}; |
| 144 | + }, |
| 145 | + }); |
| 146 | + try { |
| 147 | + bridge.handle({ type: "inference.start", data: {} }); |
| 148 | + bridge.handle({ |
| 149 | + type: "inference.tool_call.end", |
| 150 | + data: { name: "bash", callId: "b1", arguments: { command: "sleep 5" } }, |
| 151 | + }); |
| 152 | + await h.renderOnce(); |
| 153 | + const baseline = work.replaces; |
| 154 | + const destroys = work.destroys; |
| 155 | + |
| 156 | + tick?.(); |
| 157 | + await h.renderOnce(); |
| 158 | + // The first tick changes the row (no stat yet -> "0:00"). |
| 159 | + expect(work.replaces).toBe(baseline + 1); |
| 160 | + expect(work.destroys).toBe(destroys); |
| 161 | + |
| 162 | + // Ticks within the same clock second leave the stat unchanged: |
| 163 | + // zero updates across any number of them. |
| 164 | + for (let i = 0; i < 4; i++) tick?.(); |
| 165 | + await h.renderOnce(); |
| 166 | + expect(work.replaces).toBe(baseline + 1); |
| 167 | + |
| 168 | + nowMs += 5_000; |
| 169 | + for (let i = 0; i < 3; i++) tick?.(); |
| 170 | + await h.renderOnce(); |
| 171 | + expect(work.replaces).toBe(baseline + 2); |
| 172 | + expect(work.destroys).toBe(destroys); |
| 173 | + expect(streamRowAt(shell, 0)?.stat).toBe("0:05"); |
| 174 | + } finally { |
| 175 | + bridge.dispose(); |
| 176 | + shell.dispose(); |
| 177 | + } |
| 178 | + }, |
| 179 | + { width: 100, height: 24 }, |
| 180 | + ); |
| 181 | + }); |
| 182 | + }); |
| 183 | + |
| 184 | + test("N sentence-row progress updates within one frame apply once, with no destroy or rebuild", async () => { |
| 185 | + await withCountedWork(async (work) => { |
| 186 | + await withTestRenderer( |
| 187 | + async (h) => { |
| 188 | + const shell = createAppShell(h.renderer, SHELL_OPTS); |
| 189 | + const nowMs = 42_000; |
| 190 | + const bridge = attachSessionBridge(shell, createRecordingPort(), { |
| 191 | + now: () => nowMs, |
| 192 | + }); |
| 193 | + try { |
| 194 | + bridge.handle({ type: "inference.start", data: {} }); |
| 195 | + bridge.handle({ |
| 196 | + type: "inference.tool_call.end", |
| 197 | + data: { |
| 198 | + name: "spawn_agent", |
| 199 | + callId: "task-1", |
| 200 | + arguments: { description: "Review permission gate" }, |
| 201 | + }, |
| 202 | + }); |
| 203 | + await h.renderOnce(); |
| 204 | + const baseline = work.replaces; |
| 205 | + const destroys = work.destroys; |
| 206 | + |
| 207 | + for (let i = 0; i < 4; i++) { |
| 208 | + bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]); |
| 209 | + } |
| 210 | + await h.renderOnce(); |
| 211 | + expect(work.replaces).toBe(baseline + 1); |
| 212 | + expect(work.destroys).toBe(destroys); |
| 213 | + const row = streamRowAt(shell, 0); |
| 214 | + expect(row?.pending).toBe(true); |
| 215 | + expect(row?.stat).toContain("grep"); |
| 216 | + |
| 217 | + // Unchanged progress applies nothing further. |
| 218 | + bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]); |
| 219 | + await h.renderOnce(); |
| 220 | + expect(work.replaces).toBe(baseline + 1); |
| 221 | + } finally { |
| 222 | + bridge.dispose(); |
| 223 | + shell.dispose(); |
| 224 | + } |
| 225 | + }, |
| 226 | + { width: 100, height: 24 }, |
| 227 | + ); |
| 228 | + }); |
| 229 | + }); |
| 230 | + |
| 231 | + test("diff rows retext their lines in place when the shape is unchanged", async () => { |
| 232 | + await withCountedWork(async (work) => { |
| 233 | + await withTestRenderer( |
| 234 | + async (h) => { |
| 235 | + const shell = createAppShell(h.renderer, SHELL_OPTS); |
| 236 | + const { toolCallRow } = await import("./diff.js"); |
| 237 | + const { appendStreamRow, replaceStreamRowAt } = await import("./shell/chrome.js"); |
| 238 | + try { |
| 239 | + const diffRow = (newText: string): StreamRow => ({ |
| 240 | + ...toolCallRow({ |
| 241 | + name: "edit_file", |
| 242 | + arguments: JSON.stringify({ path: "src/a.ts", oldText: "x", newText }), |
| 243 | + }), |
| 244 | + expanded: true, |
| 245 | + }); |
| 246 | + appendStreamRow(shell, diffRow("y")); |
| 247 | + await h.renderOnce(); |
| 248 | + const node = transcriptRowChildren(shell)[0]; |
| 249 | + const builds = work.builds; |
| 250 | + const destroys = work.destroys; |
| 251 | + |
| 252 | + for (let i = 0; i < 4; i++) { |
| 253 | + replaceStreamRowAt(shell, 0, diffRow(`z${i}`)); |
| 254 | + } |
| 255 | + expect(work.destroys).toBe(destroys); |
| 256 | + expect(work.builds).toBe(builds); |
| 257 | + expect(transcriptRowChildren(shell)[0]).toBe(node); |
| 258 | + await h.renderOnce(); |
| 259 | + expect(h.captureCharFrame()).toContain("z3"); |
| 260 | + } finally { |
| 261 | + shell.dispose(); |
| 262 | + } |
| 263 | + }, |
| 264 | + { width: 100, height: 24 }, |
| 265 | + ); |
| 266 | + }); |
| 267 | + }); |
| 268 | + |
| 269 | + test("structured rows retext their table in place instead of rebuilding", async () => { |
| 270 | + await withCountedWork(async (work) => { |
| 271 | + await withTestRenderer( |
| 272 | + async (h) => { |
| 273 | + const shell = createAppShell(h.renderer, SHELL_OPTS); |
| 274 | + const LIST_1 = JSON.stringify({ |
| 275 | + projects: [ |
| 276 | + { name: "Alpha", status: "In Progress", priority: "urgent" }, |
| 277 | + { name: "Beta", status: { name: "Done" }, priority: "low" }, |
| 278 | + ], |
| 279 | + }); |
| 280 | + try { |
| 281 | + const { appendStreamRow, replaceStreamRowAt } = await import("./shell/chrome.js"); |
| 282 | + const expandedRow = (content: string): StreamRow => ({ |
| 283 | + ...toolResultRow({ name: "mcp__linear__list_projects", content }), |
| 284 | + expanded: true, |
| 285 | + }); |
| 286 | + appendStreamRow(shell, expandedRow(LIST_1)); |
| 287 | + await h.renderOnce(); |
| 288 | + const node = transcriptRowChildren(shell)[0]; |
| 289 | + const builds = work.builds; |
| 290 | + const destroys = work.destroys; |
| 291 | + |
| 292 | + for (let i = 0; i < 5; i++) { |
| 293 | + replaceStreamRowAt(shell, 0, expandedRow(LIST_1.replace("Alpha", `Alpha ${i}`))); |
| 294 | + } |
| 295 | + // Five updates, zero destroy-rebuilds: the same paint node |
| 296 | + // carries the new content, and only its cells changed. |
| 297 | + expect(work.destroys).toBe(destroys); |
| 298 | + expect(work.builds).toBe(builds); |
| 299 | + expect(transcriptRowChildren(shell)[0]).toBe(node); |
| 300 | + await h.renderOnce(); |
| 301 | + expect(h.captureCharFrame()).toContain("Alpha 4"); |
| 302 | + } finally { |
| 303 | + shell.dispose(); |
| 304 | + } |
| 305 | + }, |
| 306 | + { width: 100, height: 24 }, |
| 307 | + ); |
| 308 | + }); |
| 309 | + }); |
| 310 | +}); |
0 commit comments