дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
perf: add a scenario for switching between and scrolling long agent conversations (#2259)
Adds the agentTabs perf scenario: two 2,100-entry agent conversations switched with Ctrl+Tab and scrolled, with budgets on DOM size, rows skipping rendering off screen, switch latency, scroll jumps and frame pacing.
Цей коміт міститься в:
1 джерело
8c21a1e088
коміт
36fae164a8
5 files changed
+240
No files matched your search
@@ -73,6 +73,16 @@
|
||||
"tabs.openSmallMs": 100,
|
||||
"tabs.switchP95Ms": 33,
|
||||
|
||||
"agentTabs.conversationNodes": 20000,
|
||||
"agentTabs.rowsAlwaysRendered": 0,
|
||||
"agentTabs.switchP95Ms": 66,
|
||||
"agentTabs.switchMainThreadMs": 50,
|
||||
"agentTabs.switchTbtMs": 50,
|
||||
"agentTabs.scrollJumps": 0,
|
||||
"agentTabs.scrollFrameP95Ms": 17.5,
|
||||
"agentTabs.scrollDroppedFramesPct": 2,
|
||||
"agentTabs.scrollTbtMs": 50,
|
||||
|
||||
"popups.findActionOpenMs": 50,
|
||||
"popups.findActionQueryMs": 120,
|
||||
"popups.findActionKeyP95Ms": 33,
|
||||
|
||||
@@ -459,6 +459,110 @@ const tabs: Scenario = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Two agent tasks with long conversations (150 turns, 2,100 entries) in editor tabs:
|
||||
* switching between them with the Switcher (Ctrl+Tab) shows a kept-alive conversation
|
||||
* again, which must not restyle its whole history; then scrolling up through one.
|
||||
* Chromium anchors scrolling on its own (WebKit doesn't), so anchoring is turned off
|
||||
* here: rows that take their real height only once scrolled into view would jump.
|
||||
*/
|
||||
const agentTabs: Scenario = {
|
||||
id: "agentTabs",
|
||||
title: "Switch between and scroll long agent conversations",
|
||||
async run(h) {
|
||||
await h.page.addStyleTag({ content: "* { overflow-anchor: none !important; }" });
|
||||
await h.page.evaluate(() =>
|
||||
(
|
||||
window as never as { __openideaPerf: { runAction(id: string, arg?: unknown): Promise<boolean> } }
|
||||
).__openideaPerf.runAction("agents.openSampleConversations", { tasks: 2, turns: 150 }),
|
||||
);
|
||||
const shown = (n: number) => `[data-editor-focus] span[title="Sample task ${n}"]`;
|
||||
// Each conversation shown until its earlier entries, painted in background batches, are all in.
|
||||
for (const n of [1, 2]) {
|
||||
await h.page.getByRole("tab", { name: `Sample task ${n}` }).click();
|
||||
await h.page.waitForFunction(
|
||||
(selector) =>
|
||||
document.querySelector(selector)?.closest("[data-editor-focus]")?.textContent?.includes("Turn 0:"),
|
||||
shown(n),
|
||||
{ timeout: 30_000, polling: 200 },
|
||||
);
|
||||
}
|
||||
await h.page.waitForTimeout(500);
|
||||
// WebKit restyles a hidden conversation in full when it is shown again (Chromium keeps its
|
||||
// style): what bounds that there is the history's DOM, and rows skipping rendering off screen.
|
||||
const { conversationNodes, rowsAlwaysRendered } = await h.page.evaluate((selector) => {
|
||||
const conversation = document.querySelector(selector)!.closest("[data-editor-focus]")!;
|
||||
const rows = [...conversation.querySelector("[data-message]")!.parentElement!.children];
|
||||
return {
|
||||
conversationNodes: conversation.querySelectorAll("*").length,
|
||||
rowsAlwaysRendered: rows.filter((r) => getComputedStyle(r).contentVisibility !== "auto").length,
|
||||
};
|
||||
}, shown(2));
|
||||
|
||||
const t0 = await h.now();
|
||||
const before = await h.counters();
|
||||
const switches: number[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
switches.push(await h.measure(() => h.pressAction("navigate.switcher"), shown(i % 2 ? 2 : 1)));
|
||||
await h.page.waitForTimeout(150);
|
||||
}
|
||||
const work = Harness.delta(before, await h.counters());
|
||||
const switchTbtMs = totalBlockingTime(await h.longTasksSince(t0));
|
||||
|
||||
// Scrolling up from the end, a step at a time: how far the text under the pointer moves
|
||||
// beyond the scroll itself.
|
||||
const scrollJumps = await h.page.evaluate(async (selector) => {
|
||||
const stream = document
|
||||
.querySelector(selector)!
|
||||
.closest("[data-editor-focus]")!
|
||||
.querySelector("[data-message]")!.parentElement!;
|
||||
const frame = () => new Promise(requestAnimationFrame);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
await frame();
|
||||
await frame();
|
||||
const top = stream.getBoundingClientRect().top;
|
||||
let jumps = 0;
|
||||
for (let i = 0; i < 100 && stream.scrollTop > 0; i++) {
|
||||
const row = [...stream.children].find((r) => r.getBoundingClientRect().bottom > top + 200)!;
|
||||
const from = row.getBoundingClientRect().top;
|
||||
const scrolled = stream.scrollTop;
|
||||
stream.scrollTop -= 120;
|
||||
const by = scrolled - stream.scrollTop;
|
||||
await frame();
|
||||
await frame();
|
||||
if (Math.abs(row.getBoundingClientRect().top - from - by) > 2) jumps++;
|
||||
}
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
return jumps;
|
||||
}, shown(2));
|
||||
|
||||
const refresh = await h.refreshMs();
|
||||
const box = (await h.page.locator("[data-editor-focus]").boundingBox())!;
|
||||
await h.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await h.page.waitForTimeout(200);
|
||||
const t1 = await h.now();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await h.page.mouse.wheel(0, -120);
|
||||
await h.page.waitForTimeout(16);
|
||||
}
|
||||
await h.page.waitForTimeout(200);
|
||||
const scroll = frameSummary(await h.inPage((p) => p.stopFrames()), refresh);
|
||||
return {
|
||||
conversationNodes,
|
||||
rowsAlwaysRendered,
|
||||
...latencySummary(switches, "switch"),
|
||||
switchMainThreadMs: work.taskMs / switches.length,
|
||||
switchStyleLayoutMs: work.styleLayoutMs / switches.length,
|
||||
switchTbtMs,
|
||||
scrollJumps,
|
||||
scrollFrameP95Ms: scroll.frameP95Ms,
|
||||
scrollDroppedFramesPct: scroll.droppedFramesPct,
|
||||
scrollTbtMs: totalBlockingTime(await h.longTasksSince(t1)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Find Action and Search Everywhere: open, then query → first result painted. */
|
||||
const popups: Scenario = {
|
||||
id: "popups",
|
||||
@@ -606,6 +710,7 @@ export const SCENARIOS: Scenario[] = [
|
||||
keyRepeat,
|
||||
scroll,
|
||||
tabs,
|
||||
agentTabs,
|
||||
popups,
|
||||
toolWindows,
|
||||
projectTree,
|
||||
|
||||
@@ -45,6 +45,22 @@ export function activate(): () => void {
|
||||
sync(useProject.getState().project?.root);
|
||||
const disposers = [
|
||||
activateSettingsPages(),
|
||||
// The performance harness (`perf/`): long conversations to switch between and scroll.
|
||||
import.meta.env.DEV || import.meta.env.MODE === "perf"
|
||||
? registerActions([
|
||||
{
|
||||
id: "agents.openSampleConversations",
|
||||
title: "Open Sample Conversations",
|
||||
category: "Agents",
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
const { tasks = 2, turns = 150 } = (arg ?? {}) as { tasks?: number; turns?: number };
|
||||
const { openSampleConversations } = await import("./sampleConversation");
|
||||
openSampleConversations(tasks, turns);
|
||||
},
|
||||
},
|
||||
])
|
||||
: () => {},
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) sync(s.project?.root);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Agent tasks with long generated conversations, opened as editor tabs: what the
|
||||
* performance harness (`perf/`) switches between and scrolls. Dev and perf builds only.
|
||||
*/
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { openTask, useAgents } from "./store";
|
||||
|
||||
function task(id: string, title: string, cwd: string): AgentTask {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
provider: "claude",
|
||||
model: null,
|
||||
status: "ready",
|
||||
isolation: "worktree",
|
||||
cwd,
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: null,
|
||||
updatedMs: null,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A turn as agents work: a request, reasoning, a run of tool calls, then a reply with code. */
|
||||
function turn(cwd: string, i: number): AgentEntry[] {
|
||||
const file = (k: number) => `${cwd}/src/orders/Order${(i + k) % 40}.kt`;
|
||||
const tools: AgentEntry[] = Array.from({ length: 10 }, (_, k) => {
|
||||
// Tool titles as agents report them (commands and paths, not UI text).
|
||||
const command = k % 3 === 2 ? `Edit ${file(k)}` : k % 3 === 1 ? `Read ${file(k)}` : `grep -rn "order${i}" src`;
|
||||
return {
|
||||
type: "tool",
|
||||
id: `tool-${i}-${k}`,
|
||||
title: command,
|
||||
kind: k % 3 === 2 ? "edit" : k % 3 === 1 ? "read" : "execute",
|
||||
status: "completed",
|
||||
locations: [],
|
||||
output: Array.from(
|
||||
{ length: 12 },
|
||||
(_, l) => `${file(l)}:${l * 7 + 3}: val order${i} = repository.find(id${l})`,
|
||||
).join("\n"),
|
||||
diffs: k % 3 === 2 ? [{ path: file(k), added: 4 + (k % 5), removed: 2 }] : [],
|
||||
};
|
||||
});
|
||||
// What a user and an agent wrote (conversation content, not UI text).
|
||||
const request = `Turn ${i}: make the order service retry failed payments and cover it with tests`;
|
||||
const reasoning = `The payment client throws on timeouts; retrying belongs in the service (turn ${i}).`;
|
||||
return [
|
||||
{
|
||||
type: "user",
|
||||
text: request,
|
||||
attachments: [],
|
||||
sentMs: null,
|
||||
doneMs: null,
|
||||
},
|
||||
{
|
||||
type: "thought",
|
||||
text: reasoning,
|
||||
},
|
||||
...tools,
|
||||
{
|
||||
type: "agent",
|
||||
text: [
|
||||
`## Turn ${i}`,
|
||||
"",
|
||||
`Payments now retry **three times** with backoff before the order fails. Changed \`OrderService.kt\` and added tests:`,
|
||||
"",
|
||||
"- `retries` on timeouts, not on declined cards",
|
||||
"- waits 200 ms, then 400 ms, then 800 ms",
|
||||
"- logs each attempt with the order id",
|
||||
"",
|
||||
"```kotlin",
|
||||
`suspend fun pay${i}(order: Order): Receipt =`,
|
||||
" retry(times = 3, initialDelay = 200.milliseconds) {",
|
||||
" payments.charge(order.id, order.total)",
|
||||
" }",
|
||||
"```",
|
||||
"",
|
||||
"| Case | Before | After |",
|
||||
"|---|---|---|",
|
||||
"| Timeout | failed | retried |",
|
||||
"| Declined | failed | failed |",
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Opens `count` tasks of `turns` turns each (14 entries a turn), the last one shown. */
|
||||
export function openSampleConversations(count: number, turns: number) {
|
||||
const cwd = useProject.getState().project?.root ?? "/";
|
||||
const entries = Array.from({ length: turns }, (_, i) => turn(cwd, i)).flat();
|
||||
const s = useAgents.getState();
|
||||
const tasks = new Map(s.tasks);
|
||||
const all = new Map(s.entries);
|
||||
const ids = Array.from({ length: count }, (_, n) => `sample-${n + 1}`);
|
||||
ids.forEach((id, n) => {
|
||||
tasks.set(id, task(id, `Sample task ${n + 1}`, cwd));
|
||||
all.set(id, entries);
|
||||
});
|
||||
useAgents.setState({ tasks, entries: all });
|
||||
for (const id of ids) openTask(id);
|
||||
}
|
||||
@@ -2440,6 +2440,7 @@
|
||||
"Stop to Edit": "停止以编辑",
|
||||
"Stopped after {count} matches": "匹配 {count} 处后停止",
|
||||
"Stream Sample Output": "流式输出示例日志",
|
||||
"Open Sample Conversations": "打开示例会话",
|
||||
"Strikeout": "删除线",
|
||||
"Struct": "结构体",
|
||||
"Struct type": "结构体类型",
|
||||
|
||||
Reference in new issue
Заблокувати користувача