mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
The slowest user-felt path is typing into the composer while the assistant is streaming. Profile (scripts/profile-under-stream.mjs): FadeText measureOverflow self time: 35.8 ms → 18.1 ms (-50%) total active CPU during 7s window: ~150 ms → ~50 ms Two changes in src/components/ui/fade-text.tsx: 1. Drop the `useEffect([children])` that re-ran `measureOverflow` (reads scrollWidth + clientWidth — forced layout) on every parent re-render. `useResizeObserver` already fires the same callback on mount and whenever the host span's box size changes; that covers the only case where overflow state can legitimately change. The previous explicit useEffect was a forced-layout flush on every parent render, which during streaming meant every token tick. 2. Wrap the component in `memo` with a custom comparator that short-circuits the entire render when scalar string `children` and the className/fadeWidth/style props are unchanged. The hot path was tool-fallback's title chips being re-rendered by parent streaming updates even though their text was stable; memo+ comparator skips that. Also adds two harness scripts under apps/desktop/scripts/: - latency-under-stream.mjs (key→paint latency while a turn streams) - profile-under-stream.mjs (CPU profile while a turn streams) Updates profile-typing-lag.md with the streaming numbers and confirms the Enter→paint submit path is already fast (≤320ms on the populated session; the 2s "stall after Enter" the user noticed once was a one-time cold-start, not reproducible at the UI layer). I'd guess the felt jank in real use is fast-burst typing during a long-form streaming reply (code blocks + markdown lists multiply the per-token render cost). The CPU savings here scale linearly with token volume.
240 lines
8.4 KiB
JavaScript
240 lines
8.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Measure submit (Enter) latency in the composer.
|
|
//
|
|
// For each round:
|
|
// 1. Focus composer, type N chars of stub text
|
|
// 2. Mark a timestamp, fire Enter via Input.dispatchKeyEvent
|
|
// 3. Observe: time until the composer becomes empty (submit accepted),
|
|
// time until the user message renders in the thread viewport,
|
|
// time until the optional "running…" indicator appears,
|
|
// time until the next frame is painted after the message renders.
|
|
//
|
|
// Pre-condition: a session is loaded (load via click-session.mjs first).
|
|
// Note: this DOES talk to the real gateway/agent, so each round triggers
|
|
// a real prompt submission. Don't run this on a live conversation
|
|
// you care about — use a throwaway session.
|
|
|
|
import { writeFileSync } from 'node:fs'
|
|
|
|
const args = Object.fromEntries(
|
|
process.argv.slice(2).flatMap(s => {
|
|
const m = s.match(/^--([^=]+)(?:=(.*))?$/)
|
|
return m ? [[m[1], m[2] ?? true]] : []
|
|
})
|
|
)
|
|
const PORT = Number(args.port ?? 9222)
|
|
const ROUNDS = Number(args.rounds ?? 3)
|
|
|
|
async function pickRenderer() {
|
|
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
|
|
return list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
|
}
|
|
|
|
function connect(url) {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(url)
|
|
let id = 0
|
|
const pending = new Map()
|
|
ws.addEventListener('open', () =>
|
|
resolve({
|
|
send(method, params = {}) {
|
|
const myId = ++id
|
|
ws.send(JSON.stringify({ id: myId, method, params }))
|
|
return new Promise((res, rej) => pending.set(myId, { res, rej }))
|
|
},
|
|
close: () => ws.close()
|
|
})
|
|
)
|
|
ws.addEventListener('error', reject)
|
|
ws.addEventListener('message', ev => {
|
|
const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8'))
|
|
if (m.id != null) {
|
|
const p = pending.get(m.id)
|
|
if (!p) return
|
|
pending.delete(m.id)
|
|
m.error ? p.rej(new Error(m.error.message)) : p.res(m.result)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
async function evalP(cdp, expr) {
|
|
const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
|
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text)
|
|
return r.result.value
|
|
}
|
|
|
|
async function focusAndType(cdp, text) {
|
|
await evalP(cdp, `
|
|
(() => {
|
|
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
|
if (!el) return
|
|
el.focus()
|
|
const range = document.createRange()
|
|
range.selectNodeContents(el)
|
|
range.collapse(false)
|
|
const sel = window.getSelection()
|
|
sel.removeAllRanges()
|
|
sel.addRange(range)
|
|
})()
|
|
`)
|
|
for (const c of text) {
|
|
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
|
|
await new Promise(r => setTimeout(r, 8))
|
|
}
|
|
}
|
|
|
|
async function submitAndMeasure(cdp, timeoutMs = 5000) {
|
|
// Install observers, await milestones. Returns a promise that we'll resolve
|
|
// after the page-side Promise resolves OR a CDP-side timeout (belt + braces).
|
|
const setup = await evalP(
|
|
cdp,
|
|
`
|
|
(() => {
|
|
const composer = document.querySelector('[data-slot="composer-rich-input"]')
|
|
const threadRoot = document.querySelector('[data-slot="aui_thread-content"]') ||
|
|
document.querySelector('[data-slot="aui_thread-viewport"]')
|
|
const startMessageCount = threadRoot ? threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length : 0
|
|
|
|
window.__submitMilestones = { start: performance.now(), startMessageCount }
|
|
window.__submitDone = false
|
|
window.__submitResolve = null
|
|
|
|
const composerObs = new MutationObserver(() => {
|
|
const m = window.__submitMilestones
|
|
if (!m) return
|
|
if (!m.composerClearedMs && composer && composer.innerText.length === 0) {
|
|
m.composerClearedMs = performance.now() - m.start
|
|
}
|
|
})
|
|
composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true })
|
|
|
|
const threadObs = threadRoot ? new MutationObserver(() => {
|
|
const m = window.__submitMilestones
|
|
if (!m || m.userMessageRenderedMs) return
|
|
const c = threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length
|
|
if (c > m.startMessageCount) {
|
|
m.userMessageRenderedMs = performance.now() - m.start
|
|
requestAnimationFrame(() => {
|
|
m.userMessagePaintMs = performance.now() - m.start
|
|
})
|
|
}
|
|
}) : null
|
|
threadObs && threadObs.observe(threadRoot, { childList: true, subtree: true })
|
|
|
|
window.__submitObservers = { composerObs, threadObs }
|
|
return true
|
|
})()
|
|
`
|
|
)
|
|
if (!setup) throw new Error('observer setup failed')
|
|
|
|
// Send Enter via real keystroke channel (rawKeyDown + char + keyUp).
|
|
// React synthetic onKeyDown receives this exactly like a hardware Enter.
|
|
await cdp.send('Input.dispatchKeyEvent', {
|
|
type: 'rawKeyDown',
|
|
windowsVirtualKeyCode: 13,
|
|
nativeVirtualKeyCode: 13,
|
|
key: 'Enter',
|
|
code: 'Enter',
|
|
text: '\r',
|
|
unmodifiedText: '\r'
|
|
})
|
|
await cdp.send('Input.dispatchKeyEvent', {
|
|
type: 'keyUp',
|
|
windowsVirtualKeyCode: 13,
|
|
nativeVirtualKeyCode: 13,
|
|
key: 'Enter',
|
|
code: 'Enter'
|
|
})
|
|
|
|
// Poll for the milestones from outside; cap at timeoutMs.
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
const m = await evalP(cdp, 'JSON.stringify(window.__submitMilestones)')
|
|
const parsed = JSON.parse(m || '{}')
|
|
if (parsed.userMessagePaintMs != null) {
|
|
parsed.reason = 'paint'
|
|
await evalP(cdp, `(() => {
|
|
window.__submitObservers?.composerObs.disconnect()
|
|
window.__submitObservers?.threadObs?.disconnect()
|
|
})()`)
|
|
return parsed
|
|
}
|
|
await new Promise(r => setTimeout(r, 50))
|
|
}
|
|
// Timed out
|
|
const m = await evalP(cdp, 'JSON.stringify(window.__submitMilestones)')
|
|
const parsed = JSON.parse(m || '{}')
|
|
parsed.reason = 'timeout-overall'
|
|
await evalP(cdp, `(() => {
|
|
window.__submitObservers?.composerObs.disconnect()
|
|
window.__submitObservers?.threadObs?.disconnect()
|
|
})()`)
|
|
return parsed
|
|
}
|
|
|
|
async function tryCancel(cdp) {
|
|
// Find a Stop / Cancel button and click it. After submit, the composer
|
|
// turns into "cancel" mode; clicking it interrupts the agent turn so we
|
|
// don't burn tokens on these probes.
|
|
await evalP(
|
|
cdp,
|
|
`
|
|
(() => {
|
|
// Common selectors: aria-label="Stop response", data-slot="composer-cancel",
|
|
// role=button with text "Stop"
|
|
const candidates = [
|
|
'[aria-label="Stop response"]',
|
|
'[aria-label*="Stop"]',
|
|
'[aria-label*="Cancel"]',
|
|
'[data-slot*="cancel"]',
|
|
'[data-slot*="stop"]'
|
|
]
|
|
for (const sel of candidates) {
|
|
const el = document.querySelector(sel)
|
|
if (el) { el.click(); return { clicked: sel } }
|
|
}
|
|
// Fallback: any button whose textContent includes "Stop"
|
|
for (const b of document.querySelectorAll('button')) {
|
|
if ((b.textContent || '').toLowerCase().includes('stop')) { b.click(); return { clicked: 'btn-text-Stop' } }
|
|
}
|
|
return { clicked: null }
|
|
})()
|
|
`
|
|
)
|
|
}
|
|
|
|
async function main() {
|
|
const tgt = await pickRenderer()
|
|
console.log('target', tgt.url)
|
|
const cdp = await connect(tgt.webSocketDebuggerUrl)
|
|
await cdp.send('Runtime.enable')
|
|
|
|
const samples = []
|
|
for (let i = 1; i <= ROUNDS; i++) {
|
|
await focusAndType(cdp, `latency test ${i} ${'x'.repeat(40)}`)
|
|
await new Promise(r => setTimeout(r, 300))
|
|
const result = await submitAndMeasure(cdp, 4000)
|
|
samples.push({ round: i, ...result })
|
|
console.log(
|
|
`r${i}: clear=${(result.composerClearedMs ?? -1).toFixed?.(0) ?? '?'}ms ` +
|
|
`userMsg=${(result.userMessageRenderedMs ?? -1).toFixed?.(0) ?? '?'}ms ` +
|
|
`paint=${(result.userMessagePaintMs ?? -1).toFixed?.(0) ?? '?'}ms ` +
|
|
`reason=${result.reason}`
|
|
)
|
|
// Interrupt the running agent turn so we don't burn tokens on these probes.
|
|
await new Promise(r => setTimeout(r, 200))
|
|
await tryCancel(cdp)
|
|
// Wait long enough for the cancel to settle before next round.
|
|
await new Promise(r => setTimeout(r, 1500))
|
|
}
|
|
writeFileSync('/tmp/hermes-submit-latency.json', JSON.stringify(samples, null, 2))
|
|
console.log('\nwrote /tmp/hermes-submit-latency.json')
|
|
cdp.close()
|
|
}
|
|
|
|
main().catch(e => {
|
|
console.error('fatal:', e.stack ?? e.message)
|
|
process.exit(1)
|
|
})
|