main
py 545 lines 20.5 KB
Raw
1 import base64
2 from pathlib import Path
3 import shutil
4 import subprocess
5
6 import pytest
7
8
9 PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 MESSAGE_WINDOW_JS = PROJECT_ROOT / "webui" / "js" / "message-window.js"
11 SCROLLER_JS = PROJECT_ROOT / "webui" / "js" / "scroller.js"
12 PROCESS_GROUP_DOM_JS = (
13 PROJECT_ROOT
14 / "webui"
15 / "components"
16 / "messages"
17 / "process-group"
18 / "process-group-dom.js"
19 )
20 MESSAGE_COLLAPSE_JS = PROJECT_ROOT / "webui" / "js" / "message-collapse.js"
21
22
23 def test_message_window_keeps_tail_and_pages_bidirectionally():
24 if not shutil.which("node"):
25 pytest.skip("Node.js is required to execute the message-window regression.")
26
27 source = MESSAGE_WINDOW_JS.read_bytes()
28 module_url = "data:text/javascript;base64," + base64.b64encode(source).decode("ascii")
29 script = f"""
30 import {{ MessageWindow, classifyMessageRenderUnits }} from {module_url!r};
31
32 function assert(condition, message) {{
33 if (!condition) throw new Error(message);
34 }}
35
36 const pluginBackedGroup = [
37 {{ no: 1, id: "step-1", type: "info" }},
38 {{ no: 2, id: "step-2", type: "agent" }},
39 {{ no: 3, id: "step-3", type: "code_exe" }},
40 {{ no: 4, id: "step-4", type: "agent" }},
41 {{ no: 5, id: "step-5", type: "code_exe" }},
42 {{ no: 6, id: "response-1", type: "response", agentno: 0 }},
43 ];
44 const pluginBackedUnits = classifyMessageRenderUnits(pluginBackedGroup);
45 assert(
46 pluginBackedUnits.every((unit) => unit.key === pluginBackedUnits[0].key),
47 "code execution records must remain inside their surrounding process group",
48 );
49 assert(
50 pluginBackedUnits.filter((unit) => unit.isStep).length === 5,
51 "the root response must close the group without becoming a process step",
52 );
53
54 const prefixedUtilityGroup = [
55 {{ no: 20, type: "util" }},
56 {{ no: 21, type: "util" }},
57 {{ no: 22, type: "agent", id: "agent-with-prefix" }},
58 {{ no: 23, type: "response", id: "agent-with-prefix", agentno: 0 }},
59 ];
60 const prefixedUtilityUnits = classifyMessageRenderUnits(prefixedUtilityGroup);
61 assert(
62 prefixedUtilityUnits.every((unit) => unit.key === prefixedUtilityUnits[0].key),
63 "utilities immediately before a real process step must stay in that group",
64 );
65
66 const utilityOnlyResponse = [
67 {{ no: 30, type: "util" }},
68 {{ no: 31, type: "util" }},
69 {{ no: 32, type: "response", id: "response-without-step", agentno: 0 }},
70 {{ no: 33, type: "util" }},
71 {{ no: 34, type: "user", id: "next-user" }},
72 ];
73 const utilityOnlyUnits = classifyMessageRenderUnits(utilityOnlyResponse);
74 assert(
75 utilityOnlyUnits.every((unit) => unit.group === null && !unit.isStep),
76 "orphan utilities must not create or reopen a process group around a response",
77 );
78
79 const sharedIdGroup = new MessageWindow({{ initialLimit: 60 }});
80 const sharedIdKeys = sharedIdGroup.merge([
81 {{ no: 1, id: "shared-run-id", type: "agent", content: "final generation" }},
82 {{ no: 2, id: "shared-run-id", type: "response", content: "final response" }},
83 ]);
84 assert(
85 sharedIdKeys.has("id:shared-run-id:type:response"),
86 "merge must report a newly added step",
87 );
88 const updatedSharedIdKeys = sharedIdGroup.merge([
89 {{ no: 2, id: "shared-run-id", type: "response", content: "updated response" }},
90 ]);
91 assert(
92 updatedSharedIdKeys.size === 0,
93 "merge must not report an existing record update as newly added",
94 );
95 assert(sharedIdGroup.size === 2, "a shared id must not merge GEN and response records");
96 assert(
97 sharedIdGroup.visibleMessages().map((entry) => entry.type).join(",") ===
98 "agent,response",
99 "replay must retain the final GEN immediately before its response",
100 );
101 sharedIdGroup.merge([
102 {{ no: 2, id: "shared-run-id", type: "response", content: "updated response" }},
103 ]);
104 assert(sharedIdGroup.size === 2, "updates to one typed record must not duplicate it");
105 assert(
106 sharedIdGroup.visibleMessages().at(-1).content === "updated response",
107 "typed cache keys must still replace updates to the same message",
108 );
109
110 const logs = Array.from({{ length: 1000 }}, (_, no) => ({{
111 no,
112 type: no % 20 === 0 ? "user" : "tool",
113 content: `log-${{no}}`,
114 }}));
115 const windowed = new MessageWindow({{ initialLimit: 60, pageSize: 60, maxWindow: 120 }});
116 windowed.reset(logs);
117
118 assert(windowed.start === 940 && windowed.end === 1000, "initial render must start at the tail");
119 assert(windowed.visibleMessages()[0].no === 940, "tail slice must be ordered");
120 assert(windowed.olderCount === 940 && windowed.newerCount === 0, "tail counts must be accurate");
121
122 const unordered = new MessageWindow({{ initialLimit: 3, pageSize: 2, maxWindow: 4 }});
123 unordered.reset([logs[2], logs[0], logs[1]]);
124 assert(unordered.visibleMessages().map((entry) => entry.no).join(",") === "0,1,2", "out-of-order records must be sorted once");
125
126 const groupedLogs = Array.from({{ length: 300 }}, (_, no) => ({{
127 no,
128 unit: no >= 135 && no < 195 ? "large-process-group" : `entry-${{no}}`,
129 }}));
130 const groupedWindow = new MessageWindow({{
131 initialLimit: 60,
132 pageSize: 60,
133 maxWindow: 120,
134 getUnitKeys: (messages) => messages.map((message) => message.unit),
135 }});
136 groupedWindow.reset(groupedLogs);
137 groupedWindow.shiftOlder();
138 assert(groupedWindow.visibleStart === 135 && groupedWindow.visibleEnd === 300, "a page boundary must expand to the complete process group");
139 assert(groupedWindow.visibleMessages().filter((message) => message.unit === "large-process-group").length === 60, "a process group must never be split across the window");
140 groupedWindow.shiftOlder();
141 assert(groupedWindow.visibleStart === 120 && groupedWindow.visibleEnd === 240, "older paging must retain the whole intersecting group");
142 groupedWindow.shiftNewer();
143 assert(groupedWindow.visibleStart === 135 && groupedWindow.visibleEnd === 300, "newer paging must restore the whole-group tail range");
144
145 windowed.shiftOlder();
146 assert(windowed.start === 880 && windowed.end === 1000, "first older page should retain the tail overlap");
147 windowed.shiftOlder();
148 assert(windowed.start === 820 && windowed.end === 940, "older paging must retain exactly the adjacent page");
149 assert(windowed.hasOlder && windowed.hasNewer, "a historical window must page in both directions");
150
151 windowed.shiftNewer();
152 assert(windowed.start === 880 && windowed.end === 1000, "newer paging must reverse the older-page swap exactly");
153 assert(windowed.renderedCount === 120, "a shifted window must contain exactly two pages");
154
155 windowed.showHead();
156 assert(windowed.start === 0 && windowed.end === 60, "the initial head view must contain one page");
157 windowed.shiftNewer();
158 assert(windowed.start === 0 && windowed.end === 120, "the first forward shift must retain page A and append page B");
159 windowed.shiftNewer();
160 assert(windowed.start === 60 && windowed.end === 180, "the second forward shift must retain B and append C");
161 windowed.shiftNewer();
162 assert(windowed.start === 120 && windowed.end === 240, "the third forward shift must retain C and append D");
163 windowed.shiftOlder();
164 assert(windowed.start === 60 && windowed.end === 180, "a reverse shift must restore B beside C");
165 windowed.shiftOlder();
166 assert(windowed.start === 0 && windowed.end === 120, "a second reverse shift must restore A beside B");
167
168 windowed.showTail();
169 windowed.shiftOlder();
170 assert(windowed.start === 880 && windowed.end === 1000, "tail paging must restore a two-page live window");
171
172 windowed.merge(Array.from({{ length: 20 }}, (_, offset) => ({{
173 no: 1000 + offset,
174 type: "tool",
175 content: `new-${{offset}}`,
176 }})));
177 assert(windowed.end === 1020, "live tail appends must remain visible");
178 assert(windowed.compactTailIfNeeded(), "an oversized live window must compact");
179 assert(windowed.start === 900 && windowed.end === 1020, "compaction must retain two complete tail pages");
180 assert(windowed.renderedCount === 120, "tail compaction must use the same two-page bound");
181
182 windowed.merge([{{ no: 1019, type: "response", content: "updated" }}]);
183 const updated = windowed.visibleMessages().at(-1);
184 assert(updated.type === "response" && updated.content === "updated", "existing log updates must replace cached data");
185
186 windowed.reset(logs);
187 windowed.merge([{{ no: 1000, type: "tool", content: "unread" }}], {{ followTail: false }});
188 assert(windowed.end === 1000, "an unfollowed live append must not move the visible window");
189 assert(windowed.newerCount === 1, "an unfollowed live append must remain available as a newer page");
190 """
191 subprocess.run(
192 ["node", "--input-type=module", "-e", script],
193 check=True,
194 text=True,
195 )
196
197
198 def test_warning_replay_prefers_classified_process_group():
199 messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
200 encoding="utf-8"
201 )
202 warning_handler = messages.split(
203 "export function drawMessageWarning", maxsplit=1
204 )[1].split("export function drawMessageError", maxsplit=1)[0]
205
206 assert "arguments[0][PROCESS_GROUP_RENDER_INFO]" in warning_handler
207 assert "getLastProcessGroup(false)" in warning_handler
208
209
210 def test_collapsed_process_details_are_deferred_and_discarded():
211 messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
212 encoding="utf-8"
213 )
214 process_group_dom = (
215 PROJECT_ROOT
216 / "webui"
217 / "components"
218 / "messages"
219 / "process-group"
220 / "process-group-dom.js"
221 ).read_text(encoding="utf-8")
222
223 assert "detailPending: !shouldRenderDetail" in messages
224 assert "discardProcessStepDetail(step)" in messages
225 assert 'step.__renderDetail !== "function"' in messages
226 assert "estimateKvpTextSize(kvps)" in messages
227 assert "kvps: expanded ? kvps : null" in messages
228 assert "await Promise.allSettled(pending)" in process_group_dom
229 assert "step.__setExpanded(shouldExpandStep)" in process_group_dom
230
231
232 def test_user_messages_share_collapse_behavior_without_clipping_attachments():
233 messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
234 encoding="utf-8"
235 )
236 message_css = (PROJECT_ROOT / "webui" / "css" / "messages.css").read_text(
237 encoding="utf-8"
238 )
239 action_button_css = (
240 PROJECT_ROOT
241 / "webui"
242 / "components"
243 / "messages"
244 / "action-buttons"
245 / "simple-action-buttons.css"
246 ).read_text(encoding="utf-8")
247
248 assert 'contentSelector = ":scope > .message-body"' in messages
249 assert 'collapseContent?.classList.add("message-collapse-content")' in messages
250 assert 'refreshCollapsibleMessageOverflow(history)' in messages
251 assert 'refreshCollapsibleMessageOverflow(entry.target)' in messages
252 assert 'measureMessageCollapseOverflow(collapseContent' in messages
253 assert '":scope > .message-text",\n );' in messages
254 assert "attachmentsContainer.classList.add(\"attachments-container\")" in messages
255 assert ".message.message-collapsible .message-collapse-content" in message_css
256 assert ".message.message-agent-response.message-collapsible" in message_css
257 assert ".attachments-container.message-collapse-content" not in message_css
258 assert ".message-user .step-action-buttons .expand-btn" in action_button_css
259 assert "order: 1" in action_button_css
260
261
262 def test_message_collapse_ignores_hidden_replay_geometry_and_real_short_text():
263 if not shutil.which("node"):
264 pytest.skip("Node.js is required to execute the collapse regression.")
265
266 source = MESSAGE_COLLAPSE_JS.read_bytes()
267 module_url = "data:text/javascript;base64," + base64.b64encode(source).decode(
268 "ascii"
269 )
270 script = f"""
271 import {{ measureMessageCollapseOverflow }} from {module_url!r};
272
273 function assert(condition, message) {{
274 if (!condition) throw new Error(message);
275 }}
276
277 let historyWidth = 0;
278 const history = {{
279 get clientWidth() {{ return historyWidth || 24; }},
280 }};
281 const content = {{
282 isConnected: true,
283 clientWidth: 210,
284 clientHeight: 34,
285 scrollHeight: 280,
286 getBoundingClientRect: () => ({{ width: 210 }}),
287 closest: (selector) => selector === "#chat-history" ? history : null,
288 }};
289 globalThis.getComputedStyle = (element) => ({{
290 fontSize: "16px",
291 paddingLeft: element === history ? "12px" : "0px",
292 paddingRight: element === history ? "12px" : "0px",
293 }});
294
295 assert(
296 measureMessageCollapseOverflow(content) === null,
297 "zero-width replay staging must not mark short text as overflowing",
298 );
299
300 historyWidth = 800;
301 content.scrollHeight = 34;
302 assert(
303 measureMessageCollapseOverflow(content) === false,
304 "a laid-out one-line message must not expose Show More",
305 );
306
307 content.clientHeight = 240;
308 content.scrollHeight = 420;
309 assert(
310 measureMessageCollapseOverflow(content) === true,
311 "a body taller than the collapsed preview must expose Show More",
312 );
313 assert(
314 measureMessageCollapseOverflow(content, {{ expanded: true }}) === true,
315 "expanded long bodies must retain their Show Less control",
316 );
317 """
318 subprocess.run(
319 ["node", "--input-type=module", "-e", script],
320 check=True,
321 text=True,
322 )
323
324
325 def test_process_groups_are_atomic_and_page_steps_in_fifties():
326 messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
327 encoding="utf-8"
328 )
329 group_css = (
330 PROJECT_ROOT
331 / "webui"
332 / "components"
333 / "messages"
334 / "process-group"
335 / "process-group.css"
336 ).read_text(encoding="utf-8")
337
338 assert "const PROCESS_GROUP_STEP_PAGE_SIZE = 50" in messages
339 assert 'classifyMessageRenderUnits(messages)' in messages
340 assert '"code_exe",' in MESSAGE_WINDOW_JS.read_text(encoding="utf-8")
341 assert "getUnitKeys: getMessageRenderUnitKeys" in messages
342 assert "getProcessGroupRenderMessages(windowMessages)" in messages
343 assert "const addedMessageKeys = _messageWindow.merge" in messages
344 assert "addedMessageKeys.has(getMessageCacheKey(message))" in messages
345 assert 'button.className = "process-group-show-more"' in messages
346 assert "current + PROCESS_GROUP_STEP_PAGE_SIZE" in messages
347 assert "group.dataset.fullStartTimestamp" in messages
348 assert 'else if (log.type === "util")' in messages
349 assert 'group?.classList.contains("utility-only")' in messages
350 assert "allowCompletedGroup: false" in messages
351 assert ".process-group.utility-only {" in group_css
352 assert ".show-utility-messages .process-group.utility-only" in group_css
353 assert ".process-group.utility-only[hidden]" not in group_css
354 assert ".process-group-show-more" in group_css
355 show_more_css = group_css.split(".process-group-show-more {", 1)[1].split(
356 "}", 1
357 )[0]
358 assert "text-decoration: none" in show_more_css
359 assert "opacity: 0.7" in show_more_css
360 assert "text-decoration: underline" not in show_more_css
361
362
363 def test_detail_preferences_await_materialization_and_select_current_step():
364 if not shutil.which("node"):
365 pytest.skip("Node.js is required to execute the detail-mode regression.")
366
367 source = PROCESS_GROUP_DOM_JS.read_text(encoding="utf-8").replace(
368 'import { store as preferencesStore } from '
369 '"/components/sidebar/bottom/preferences/preferences-store.js";\n',
370 'const preferencesStore = { detailMode: "current", showUtils: false };\n',
371 )
372 module_url = "data:text/javascript;base64," + base64.b64encode(
373 source.encode("utf-8")
374 ).decode("ascii")
375 script = f"""
376 import {{ applyModeSteps }} from {module_url!r};
377
378 function assert(condition, message) {{
379 if (!condition) throw new Error(message);
380 }}
381
382 function makeClasses(initial = []) {{
383 const values = new Set(initial);
384 return {{
385 contains: (name) => values.has(name),
386 toggle(name, force) {{
387 if (force) values.add(name);
388 else values.delete(name);
389 }},
390 }};
391 }}
392
393 function makeStep(name, util = false) {{
394 const step = {{
395 name,
396 classList: makeClasses(util ? ["message-util"] : []),
397 detailReady: false,
398 }};
399 step.__setExpanded = (expanded) => new Promise((resolve) => {{
400 queueMicrotask(() => {{
401 step.classList.toggle("expanded", expanded);
402 step.detailReady = expanded;
403 resolve();
404 }});
405 }});
406 return step;
407 }}
408
409 function makeGroup(steps, complete = false) {{
410 const group = {{
411 steps,
412 complete,
413 classList: makeClasses(),
414 hasAttribute: (name) => complete && name === "data-group-complete",
415 querySelector: (selector) =>
416 complete && selector === ".process-group-response" ? {{}} : null,
417 querySelectorAll: (selector) => selector === ".process-step" ? steps : [],
418 }};
419 group.__setExpanded = async (expanded) => {{
420 group.classList.toggle("expanded", expanded);
421 }};
422 return group;
423 }}
424
425 const completedSteps = [makeStep("old")];
426 const currentSteps = [makeStep("first"), makeStep("current"), makeStep("util", true)];
427 const groups = [makeGroup(completedSteps, true), makeGroup(currentSteps)];
428 const history = {{
429 dataset: {{ messageWindowEnd: "100", messageWindowTotal: "100" }},
430 querySelectorAll: (selector) => selector === ".process-group" ? groups : [],
431 }};
432 globalThis.document = {{
433 getElementById: (id) => id === "chat-history" ? history : null,
434 }};
435
436 await applyModeSteps("expanded", false);
437 assert(
438 [...completedSteps, ...currentSteps].every((step) => step.detailReady),
439 "ALL must await every visible step detail",
440 );
441
442 await applyModeSteps("collapsed", false);
443 assert(
444 [...completedSteps, ...currentSteps].every((step) => !step.detailReady),
445 "NO must collapse every visible step",
446 );
447
448 await applyModeSteps("current", false);
449 assert(!completedSteps[0].detailReady, "STEP must not open completed history");
450 assert(!currentSteps[0].detailReady, "STEP must collapse older active steps");
451 assert(currentSteps[1].detailReady, "STEP must materialize the current visible step");
452 assert(!currentSteps[2].detailReady, "hidden utility steps must not become current");
453
454 history.dataset.messageWindowEnd = "50";
455 await applyModeSteps("current", false);
456 assert(
457 currentSteps.every((step) => !step.detailReady),
458 "STEP must not treat a historical window boundary as the live current step",
459 );
460 """
461 subprocess.run(
462 ["node", "--input-type=module", "-e", script],
463 check=True,
464 text=True,
465 )
466
467
468 def test_virtual_rebuild_cancels_stale_scroller_effects():
469 if not shutil.which("node"):
470 pytest.skip("Node.js is required to execute the scroller regression.")
471
472 source = SCROLLER_JS.read_bytes()
473 module_url = "data:text/javascript;base64," + base64.b64encode(source).decode(
474 "ascii"
475 )
476 script = f"""
477 import {{ cancelPendingScroll }} from {module_url!r};
478
479 function assert(condition, message) {{
480 if (!condition) throw new Error(message);
481 }}
482
483 let delayedScrollRan = false;
484 const timeoutId = setTimeout(() => {{ delayedScrollRan = true; }}, 30);
485 const element = {{
486 dataset: {{
487 scrollerTimeout: String(Number(timeoutId)),
488 scrollerReapplySnapshot: "200",
489 scrollingTo: "900",
490 }},
491 scrollTop: 240,
492 scrollCalls: [],
493 scrollTo(options) {{ this.scrollCalls.push(options); }},
494 }};
495
496 cancelPendingScroll(element);
497 await new Promise((resolve) => setTimeout(resolve, 60));
498
499 assert(!delayedScrollRan, "a stale delayed auto-scroll must be canceled");
500 assert(element.scrollCalls.length === 1, "an in-flight smooth scroll must be stopped");
501 assert(element.scrollCalls[0].top === 240, "canceling must retain the current offset");
502 assert(!("scrollerTimeout" in element.dataset), "timeout state must be cleared");
503 assert(!("scrollingTo" in element.dataset), "smooth-scroll state must be cleared");
504 """
505 subprocess.run(
506 ["node", "--input-type=module", "-e", script],
507 check=True,
508 text=True,
509 )
510
511
512 def test_virtual_window_preserves_live_and_navigation_contracts():
513 messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
514 encoding="utf-8"
515 )
516 message_css = (PROJECT_ROOT / "webui" / "css" / "messages.css").read_text(
517 encoding="utf-8"
518 )
519 navigation = (
520 PROJECT_ROOT
521 / "webui"
522 / "components"
523 / "chat"
524 / "navigation"
525 / "chat-navigation-store.js"
526 ).read_text(encoding="utf-8")
527
528 assert "_messageRenderGeneration" in messages
529 assert "result: { element: null, virtualized: true, dontScroll: true }" in messages
530 assert 'scrollMessageWindowToEdge("start")' in navigation
531 assert 'scrollMessageWindowToEdge("end")' in navigation
532 assert 'loadAdjacentMessageWindow("older")' in navigation
533 assert 'loadAdjacentMessageWindow("newer")' in navigation
534 assert "_messageWindowFollowTail" in messages
535 assert "hasUserScrollIntent" in messages
536 assert "cancelPendingScroll(history)" in messages
537 assert "createMessageWindowStagingHistory(history)" in messages
538 assert "history.replaceChildren(...stagedChildren)" in messages
539 assert 'element.classList.add("message-window-restored")' in messages
540 assert "createMessageWindowIndicator" in messages
541 assert 'document.createElement("div")' in messages
542 assert "Loading ${label} messages" in messages
543 assert "Load ${Math.min" not in messages
544 assert "overflow-anchor: none" in message_css
545 assert ".message-container.message-window-restored" in message_css