Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
512 lines
18 KiB
JavaScript
512 lines
18 KiB
JavaScript
import {
|
|
requiredScenarioData,
|
|
scenarioSummary as summary,
|
|
sqlString,
|
|
startupWorkspaceID,
|
|
updateAgentGUISnapshot
|
|
} from "./agent-gui-performance-snapshot-helpers.mjs";
|
|
import {
|
|
evaluate,
|
|
finishRendererScenario,
|
|
markRenderer,
|
|
startRendererScenario,
|
|
waitForEvaluation
|
|
} from "./agent-gui-performance-helpers.mjs";
|
|
|
|
const editorSelector = '[data-testid="agent-gui-composer-editor"]';
|
|
const paletteSelector = '[data-testid="agent-gui-mention-palette-surface"]';
|
|
const ordinaryText =
|
|
"Measure AgentGUI composer input one character at a time. ";
|
|
const multilineText = "2\n3\n4\n5";
|
|
const imeCandidates = ["x", "xing", "性能"];
|
|
const imeCommittedText = "性能";
|
|
|
|
const composerInputMarkers = {
|
|
start: "tutti-perf:composer-input:start",
|
|
multilineExpanded: "tutti-perf:composer-input:multiline-expanded-observed",
|
|
multilineShrunk: "tutti-perf:composer-input:multiline-shrunk-observed",
|
|
textEntered: "tutti-perf:composer-input:text-entered-observed",
|
|
imeComposing: "tutti-perf:composer-input:ime-composing-observed",
|
|
imeCommitted: "tutti-perf:composer-input:ime-committed-observed",
|
|
mentionOpened: "tutti-perf:composer-input:mention-opened-observed",
|
|
mentionNavigated: "tutti-perf:composer-input:mention-navigated-observed",
|
|
mentionClosed: "tutti-perf:composer-input:mention-closed-observed",
|
|
end: "tutti-perf:composer-input:end"
|
|
};
|
|
|
|
export const composerInputScenario = {
|
|
id: "composer-input",
|
|
markers: composerInputMarkers,
|
|
milestones: [
|
|
{
|
|
key: "multilineExpanded",
|
|
label: "four-line composer expanded",
|
|
marker: composerInputMarkers.multilineExpanded
|
|
},
|
|
{
|
|
key: "multilineShrunk",
|
|
label: "composer shrunk to one line",
|
|
marker: composerInputMarkers.multilineShrunk
|
|
},
|
|
{
|
|
key: "textEntered",
|
|
label: "per-character text entered",
|
|
marker: composerInputMarkers.textEntered
|
|
},
|
|
{
|
|
key: "imeComposing",
|
|
label: "IME composition updated",
|
|
marker: composerInputMarkers.imeComposing
|
|
},
|
|
{
|
|
key: "imeCommitted",
|
|
label: "IME composition committed",
|
|
marker: composerInputMarkers.imeCommitted
|
|
},
|
|
{
|
|
key: "mentionOpened",
|
|
label: "@ mention panel opened",
|
|
marker: composerInputMarkers.mentionOpened
|
|
},
|
|
{
|
|
key: "mentionNavigated",
|
|
label: "mention highlight and category changed",
|
|
marker: composerInputMarkers.mentionNavigated
|
|
},
|
|
{
|
|
key: "mentionClosed",
|
|
label: "mention panel closed",
|
|
marker: composerInputMarkers.mentionClosed
|
|
}
|
|
],
|
|
prepareSnapshot: prepareComposerInputSnapshot,
|
|
prepare: prepareComposerInput,
|
|
execute: executeComposerInput,
|
|
describe(prepared) {
|
|
return `${prepared.sessionID}; ${Array.from(multilineText).length} multiline inserts + shrink + ${Array.from(ordinaryText).length} text inserts + ${imeCandidates.length} IME updates + @ keyboard navigation`;
|
|
},
|
|
summarize(prepared, result) {
|
|
return summary(
|
|
[
|
|
{ name: "dock composer active", passed: prepared.dockComposer },
|
|
{
|
|
name: "four-line composer expanded",
|
|
passed:
|
|
result.expandedGeometry.height > result.collapsedGeometry.height + 1
|
|
},
|
|
{
|
|
name: "composer shrank to one line",
|
|
passed:
|
|
Math.abs(
|
|
result.shrunkGeometry.height - result.collapsedGeometry.height
|
|
) <= 1
|
|
},
|
|
{
|
|
name: "action button stayed bottom-aligned",
|
|
passed:
|
|
Math.abs(
|
|
result.expandedGeometry.buttonBottomOffset -
|
|
result.collapsedGeometry.buttonBottomOffset
|
|
) <= 1 &&
|
|
Math.abs(
|
|
result.shrunkGeometry.buttonBottomOffset -
|
|
result.collapsedGeometry.buttonBottomOffset
|
|
) <= 1
|
|
},
|
|
{
|
|
name: "per-character text input observed",
|
|
passed: result.inputEvents >= Array.from(ordinaryText).length
|
|
},
|
|
{
|
|
name: "IME composition lifecycle observed",
|
|
passed:
|
|
result.compositionStarts > 0 &&
|
|
result.compositionUpdates >= imeCandidates.length &&
|
|
result.compositionEnds > 0
|
|
},
|
|
{ name: "IME text committed once", passed: result.imeCommitted },
|
|
{ name: "@ mention panel opened", passed: result.mentionOpened },
|
|
{
|
|
name: "mention selection moved",
|
|
passed: result.highlightChanged
|
|
},
|
|
{ name: "mention category cycled", passed: result.categoryChanged },
|
|
{
|
|
name: "mention keyboard events observed",
|
|
passed: result.mentionKeys.join(",") === "ArrowDown,Tab,Escape"
|
|
},
|
|
{ name: "mention panel closed", passed: result.mentionClosed }
|
|
],
|
|
[
|
|
{ label: "Session", value: prepared.sessionID },
|
|
{
|
|
label: "Composer heights",
|
|
value: `${result.collapsedGeometry.height.toFixed(1)} -> ${result.expandedGeometry.height.toFixed(1)} -> ${result.shrunkGeometry.height.toFixed(1)} px`
|
|
},
|
|
{
|
|
label: "Text inserts",
|
|
value: String(
|
|
Array.from(multilineText).length + Array.from(ordinaryText).length
|
|
)
|
|
},
|
|
{ label: "Input events", value: String(result.inputEvents) },
|
|
{
|
|
label: "IME events",
|
|
value: `${result.compositionStarts} start / ${result.compositionUpdates} update / ${result.compositionEnds} end`
|
|
},
|
|
{
|
|
label: "Mention keys",
|
|
value: result.mentionKeys.join(" -> ")
|
|
}
|
|
],
|
|
"CDP expands the composer through explicit newline transactions, deletes back to one line, injects ordinary text character by character, drives one real IME composition lifecycle, then verifies @ keyboard navigation"
|
|
);
|
|
}
|
|
};
|
|
|
|
async function prepareComposerInputSnapshot(context) {
|
|
const workspaceID = await startupWorkspaceID(context);
|
|
const candidates = await context.sqliteJSON(
|
|
context.databasePath,
|
|
`
|
|
SELECT s.agent_session_id AS sessionID,
|
|
s.agent_target_id AS targetID
|
|
FROM workspace_agent_sessions s
|
|
JOIN agent_targets t ON t.id = s.agent_target_id
|
|
WHERE s.workspace_id = '${sqlString(workspaceID)}'
|
|
AND s.deleted_at_unix_ms = 0
|
|
AND s.session_kind = 'root'
|
|
AND s.active_turn_id IS NULL
|
|
AND t.enabled = 1
|
|
ORDER BY s.updated_at_unix_ms DESC, s.agent_session_id ASC
|
|
LIMIT 1;
|
|
`
|
|
);
|
|
const candidate = candidates[0];
|
|
if (!candidate?.sessionID || !candidate.targetID) {
|
|
throw new Error(
|
|
"composer-input requires one settled root session for an enabled Agent target"
|
|
);
|
|
}
|
|
await updateAgentGUISnapshot(context, (state) => ({
|
|
...state,
|
|
agentTargetId: candidate.targetID,
|
|
conversationRailCollapsed: false,
|
|
lastActiveAgentSessionId: candidate.sessionID,
|
|
lastActiveAgentSessionIdByAgentTargetId: {
|
|
...(state.lastActiveAgentSessionIdByAgentTargetId ?? {}),
|
|
[candidate.targetID]: candidate.sessionID
|
|
}
|
|
}));
|
|
return {
|
|
data: {
|
|
sessionID: candidate.sessionID,
|
|
targetID: candidate.targetID,
|
|
workspaceID
|
|
}
|
|
};
|
|
}
|
|
|
|
async function prepareComposerInput(context, options) {
|
|
const fixture = requiredScenarioData(context, "composer-input");
|
|
const ready = await waitForEvaluation(
|
|
context.pageClient,
|
|
`(() => {
|
|
const editor = document.querySelector(${JSON.stringify(editorSelector)});
|
|
const activeSession = document.querySelector(${JSON.stringify(`[data-testid="agent-gui-conversation-item-${fixture.sessionID}"]`)});
|
|
const dockComposer = Boolean(editor?.closest('[data-testid="agent-gui-composer-input-shell"]'));
|
|
return {
|
|
ready: editor instanceof HTMLElement && editor.getAttribute('contenteditable') === 'true' && activeSession?.dataset.active === 'true' && dockComposer,
|
|
dockComposer,
|
|
editorReady: editor instanceof HTMLElement
|
|
};
|
|
})()`,
|
|
options.timeoutMs,
|
|
"enabled AgentGUI composer editor"
|
|
);
|
|
await evaluate(
|
|
context.pageClient,
|
|
`(() => {
|
|
const editor = document.querySelector(${JSON.stringify(editorSelector)});
|
|
if (!(editor instanceof HTMLElement)) throw new Error('composer editor is unavailable');
|
|
editor.focus();
|
|
document.execCommand('selectAll', false);
|
|
document.execCommand('delete', false);
|
|
return true;
|
|
})()`
|
|
);
|
|
await waitForEditorText(context.pageClient, "", options.timeoutMs);
|
|
return {
|
|
dockComposer: ready.dockComposer,
|
|
editorReady: ready.editorReady,
|
|
sessionID: fixture.sessionID,
|
|
targetID: fixture.targetID
|
|
};
|
|
}
|
|
|
|
async function executeComposerInput(context, _prepared, options) {
|
|
const { pageClient } = context;
|
|
await installComposerInputCounters(pageClient);
|
|
await startRendererScenario(pageClient, composerInputMarkers.start);
|
|
|
|
const collapsedGeometry = await readComposerGeometry(pageClient);
|
|
for (const character of Array.from(multilineText)) {
|
|
await pageClient.send("Input.insertText", { text: character });
|
|
}
|
|
await waitForEditorText(pageClient, multilineText, options.timeoutMs);
|
|
const expandedGeometry = await waitForComposerGeometry(
|
|
pageClient,
|
|
`height > ${JSON.stringify(collapsedGeometry.height + 1)} && Math.abs(height - targetHeight) <= 1`,
|
|
options.timeoutMs,
|
|
"four-line composer expansion"
|
|
);
|
|
await markRenderer(pageClient, composerInputMarkers.multilineExpanded);
|
|
|
|
await clearComposerEditor(pageClient);
|
|
await waitForEditorText(pageClient, "", options.timeoutMs);
|
|
const shrunkGeometry = await waitForComposerGeometry(
|
|
pageClient,
|
|
`Math.abs(height - ${JSON.stringify(collapsedGeometry.height)}) <= 1`,
|
|
options.timeoutMs,
|
|
"composer shrink to one line"
|
|
);
|
|
await markRenderer(pageClient, composerInputMarkers.multilineShrunk);
|
|
|
|
for (const character of Array.from(ordinaryText)) {
|
|
await pageClient.send("Input.insertText", { text: character });
|
|
}
|
|
await waitForEditorText(pageClient, ordinaryText, options.timeoutMs);
|
|
await markRenderer(pageClient, composerInputMarkers.textEntered);
|
|
|
|
for (const candidate of imeCandidates) {
|
|
await pageClient.send("Input.imeSetComposition", {
|
|
text: candidate,
|
|
selectionStart: candidate.length,
|
|
selectionEnd: candidate.length
|
|
});
|
|
}
|
|
await waitForEditorText(
|
|
pageClient,
|
|
`${ordinaryText}${imeCommittedText}`,
|
|
options.timeoutMs
|
|
);
|
|
await markRenderer(pageClient, composerInputMarkers.imeComposing);
|
|
await pageClient.send("Input.insertText", { text: imeCommittedText });
|
|
const committed = await waitForEditorText(
|
|
pageClient,
|
|
`${ordinaryText}${imeCommittedText}`,
|
|
options.timeoutMs
|
|
);
|
|
await markRenderer(pageClient, composerInputMarkers.imeCommitted);
|
|
|
|
await pageClient.send("Input.insertText", { text: " @" });
|
|
const opened = await waitForMentionState(
|
|
pageClient,
|
|
"Boolean(palette)",
|
|
options.timeoutMs,
|
|
"open @ mention panel"
|
|
);
|
|
await markRenderer(pageClient, composerInputMarkers.mentionOpened);
|
|
|
|
const initialHighlight = opened.highlightedIndex;
|
|
const initialCategory = opened.activeCategoryIndex;
|
|
await dispatchKey(pageClient, "ArrowDown", "ArrowDown", 40);
|
|
const highlighted = await waitForMentionState(
|
|
pageClient,
|
|
`Boolean(palette) && highlightedIndex !== ${JSON.stringify(initialHighlight)}`,
|
|
options.timeoutMs,
|
|
"changed mention highlight"
|
|
);
|
|
await dispatchKey(pageClient, "Tab", "Tab", 9);
|
|
const navigated = await waitForMentionState(
|
|
pageClient,
|
|
`Boolean(palette) && activeCategoryIndex !== ${JSON.stringify(initialCategory)}`,
|
|
options.timeoutMs,
|
|
"changed mention category"
|
|
);
|
|
await markRenderer(pageClient, composerInputMarkers.mentionNavigated);
|
|
|
|
await dispatchKey(pageClient, "Escape", "Escape", 27);
|
|
const closed = await waitForMentionState(
|
|
pageClient,
|
|
"!palette",
|
|
options.timeoutMs,
|
|
"closed mention panel"
|
|
);
|
|
await markRenderer(pageClient, composerInputMarkers.mentionClosed);
|
|
const counters = await readAndRemoveComposerInputCounters(pageClient);
|
|
await finishRendererScenario(pageClient, composerInputMarkers.end);
|
|
|
|
return {
|
|
...counters,
|
|
collapsedGeometry,
|
|
expandedGeometry,
|
|
shrunkGeometry,
|
|
categoryChanged: navigated.activeCategoryIndex !== initialCategory,
|
|
highlightChanged: highlighted.highlightedIndex !== initialHighlight,
|
|
imeCommitted:
|
|
committed.text === `${ordinaryText}${imeCommittedText}` &&
|
|
countOccurrences(committed.text, imeCommittedText) === 1,
|
|
mentionClosed: closed.palettePresent === false,
|
|
mentionOpened: opened.palettePresent === true
|
|
};
|
|
}
|
|
|
|
async function clearComposerEditor(client) {
|
|
await evaluate(
|
|
client,
|
|
`(() => {
|
|
const editor = document.querySelector(${JSON.stringify(editorSelector)});
|
|
if (!(editor instanceof HTMLElement)) throw new Error('composer editor is unavailable');
|
|
editor.focus();
|
|
document.execCommand('selectAll', false);
|
|
document.execCommand('delete', false);
|
|
return true;
|
|
})()`
|
|
);
|
|
}
|
|
|
|
async function readComposerGeometry(client) {
|
|
return evaluate(client, composerGeometryExpression("true"));
|
|
}
|
|
|
|
async function waitForComposerGeometry(client, predicate, timeoutMs, label) {
|
|
return waitForEvaluation(
|
|
client,
|
|
composerGeometryExpression(predicate),
|
|
timeoutMs,
|
|
label,
|
|
25
|
|
);
|
|
}
|
|
|
|
function composerGeometryExpression(predicate) {
|
|
return `(() => {
|
|
const editor = document.querySelector(${JSON.stringify(editorSelector)});
|
|
const inputArea = editor?.closest('[data-testid="agent-gui-composer-input-shell"]');
|
|
const actionButton = document.querySelector('[data-testid="agent-gui-composer-send"], [data-testid="agent-gui-composer-stop-active-turn"]');
|
|
if (!(inputArea instanceof HTMLElement) || !(actionButton instanceof HTMLElement)) {
|
|
return { ready: false, buttonBottomOffset: -1, height: -1, targetHeight: -1 };
|
|
}
|
|
const inputRect = inputArea.getBoundingClientRect();
|
|
const buttonRect = actionButton.getBoundingClientRect();
|
|
const height = inputRect.height;
|
|
const targetHeight = Number.parseFloat(
|
|
inputArea.style.getPropertyValue('--agent-gui-composer-input-height')
|
|
);
|
|
const buttonBottomOffset = inputRect.bottom - buttonRect.bottom;
|
|
return {
|
|
ready: ${predicate},
|
|
buttonBottomOffset,
|
|
height,
|
|
targetHeight
|
|
};
|
|
})()`;
|
|
}
|
|
|
|
async function installComposerInputCounters(client) {
|
|
await evaluate(
|
|
client,
|
|
`(() => {
|
|
const editor = document.querySelector(${JSON.stringify(editorSelector)});
|
|
if (!(editor instanceof HTMLElement)) throw new Error('composer editor is unavailable');
|
|
const state = {
|
|
compositionStarts: 0,
|
|
compositionUpdates: 0,
|
|
compositionEnds: 0,
|
|
inputEvents: 0,
|
|
mentionKeys: []
|
|
};
|
|
const handlers = {
|
|
compositionstart: () => { state.compositionStarts += 1; },
|
|
compositionupdate: () => { state.compositionUpdates += 1; },
|
|
compositionend: () => { state.compositionEnds += 1; },
|
|
input: () => { state.inputEvents += 1; }
|
|
};
|
|
for (const [type, handler] of Object.entries(handlers)) editor.addEventListener(type, handler, true);
|
|
const keydown = (event) => {
|
|
if (event.target === editor && ['ArrowDown', 'Tab', 'Escape'].includes(event.key)) state.mentionKeys.push(event.key);
|
|
};
|
|
document.addEventListener('keydown', keydown, true);
|
|
window.__tuttiPerfComposerInput = { editor, handlers, keydown, state };
|
|
editor.focus();
|
|
return true;
|
|
})()`
|
|
);
|
|
}
|
|
|
|
async function readAndRemoveComposerInputCounters(client) {
|
|
return evaluate(
|
|
client,
|
|
`(() => {
|
|
const owner = window.__tuttiPerfComposerInput;
|
|
if (!owner) throw new Error('composer input counters are unavailable');
|
|
for (const [type, handler] of Object.entries(owner.handlers)) owner.editor.removeEventListener(type, handler, true);
|
|
document.removeEventListener('keydown', owner.keydown, true);
|
|
delete window.__tuttiPerfComposerInput;
|
|
return owner.state;
|
|
})()`
|
|
);
|
|
}
|
|
|
|
async function waitForEditorText(client, expected, timeoutMs) {
|
|
return waitForEvaluation(
|
|
client,
|
|
`(() => {
|
|
const editor = document.querySelector(${JSON.stringify(editorSelector)});
|
|
const text = editor
|
|
? [...editor.children].map((block) => block.textContent ?? '').join('\\n')
|
|
: '';
|
|
return { ready: text === ${JSON.stringify(expected)}, text };
|
|
})()`,
|
|
timeoutMs,
|
|
`composer text ${JSON.stringify(expected)}`,
|
|
25
|
|
);
|
|
}
|
|
|
|
async function waitForMentionState(client, predicate, timeoutMs, label) {
|
|
return waitForEvaluation(
|
|
client,
|
|
`(() => {
|
|
const palette = document.querySelector(${JSON.stringify(paletteSelector)});
|
|
const highlighted = palette?.querySelector('[data-highlighted]') ?? null;
|
|
const highlightable = palette ? [...palette.querySelectorAll('[role="option"], button[data-highlighted]')] : [];
|
|
const activeCategory = palette?.querySelector('[role="tab"][aria-selected="true"]') ?? null;
|
|
const categories = palette ? [...palette.querySelectorAll('[role="tab"]')] : [];
|
|
const activeCategoryIndex = activeCategory ? categories.indexOf(activeCategory) : -1;
|
|
const highlightedIndex = highlighted ? highlightable.indexOf(highlighted) : -1;
|
|
return {
|
|
ready: ${predicate},
|
|
activeCategoryIndex,
|
|
highlightedIndex,
|
|
palettePresent: Boolean(palette)
|
|
};
|
|
})()`,
|
|
timeoutMs,
|
|
label,
|
|
25
|
|
);
|
|
}
|
|
|
|
async function dispatchKey(client, key, code, virtualKeyCode) {
|
|
await client.send("Input.dispatchKeyEvent", {
|
|
type: "rawKeyDown",
|
|
key,
|
|
code,
|
|
windowsVirtualKeyCode: virtualKeyCode,
|
|
nativeVirtualKeyCode: virtualKeyCode
|
|
});
|
|
await client.send("Input.dispatchKeyEvent", {
|
|
type: "keyUp",
|
|
key,
|
|
code,
|
|
windowsVirtualKeyCode: virtualKeyCode,
|
|
nativeVirtualKeyCode: virtualKeyCode
|
|
});
|
|
}
|
|
|
|
function countOccurrences(value, needle) {
|
|
return value.split(needle).length - 1;
|
|
}
|