perf(sessions): skip redundant tool replay after scene restore
This commit is contained in:
1 parent
e168b67e42
commit
6f85d6e780
6 files changed
+297
-6
No files matched your search
@@ -13,6 +13,7 @@ on:
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'tests/browser_reconnect_scene_redraw.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
@@ -24,6 +25,7 @@ on:
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'tests/browser_reconnect_scene_redraw.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
@@ -45,6 +47,9 @@ jobs:
|
||||
scenario: terminal-error
|
||||
- name: historical-transcript-hydration
|
||||
script: tests/browser_historical_transcript_hydration.py
|
||||
- name: reconnect-scene-redraw
|
||||
script: tests/browser_reconnect_scene_redraw.py
|
||||
browsers: chromium
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -69,6 +74,7 @@ jobs:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
|
||||
HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts
|
||||
LIFECYCLE_SCENARIO: ${{ matrix.scenario }}
|
||||
BROWSERS: ${{ matrix.browsers }}
|
||||
run: python ${{ matrix.script }}
|
||||
|
||||
- name: Run settlement frame proof
|
||||
|
||||
+25
-4
@@ -137,11 +137,32 @@ HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \
|
||||
```
|
||||
|
||||
The dedicated `Conversation lifecycle (informational)` workflow runs the current
|
||||
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) and
|
||||
stays non-blocking while the public
|
||||
proof rows (`normal`, `terminal-error`, `historical-transcript-hydration`, and the
|
||||
Chromium `reconnect-scene-redraw` matrix) and stays non-blocking while the public
|
||||
matrix expands to additional behavior rows. The maintainer's private QA harness
|
||||
remains broader; later public slices will add session switching, reconnect/replay,
|
||||
cancellation, compression, and recovery.
|
||||
remains broader; later public slices will add cancellation, compression, and
|
||||
recovery coverage.
|
||||
|
||||
### Active-session reconnect redraw gate
|
||||
|
||||
`tests/browser_reconnect_scene_redraw.py` is an opt-in deterministic browser
|
||||
gate for reconnecting to a running session with a large Anchor activity scene.
|
||||
It uses an isolated temporary server/home and fixture SSE events—no agent,
|
||||
provider credentials, or production state. By default it checks Chromium and
|
||||
WebKit, desktop and 390px viewports, and all three activity display modes:
|
||||
|
||||
```bash
|
||||
pip install playwright
|
||||
python -m playwright install chromium webkit
|
||||
python tests/browser_reconnect_scene_redraw.py
|
||||
```
|
||||
|
||||
`TOOL_COUNT`, `BROWSERS`, and `MODES` narrow the matrix. `MEASURE_BASELINE=1`
|
||||
records an unoptimized baseline without enforcing the redraw ceiling;
|
||||
`METRICS_FILE` saves JSON metrics and `SCREENSHOT_DIR` saves generated-fixture
|
||||
screenshots. This gate tests page reconstruction, journal-cursor resume, and
|
||||
subsequent fixture SSE updates. It does not test a real provider/network or PWA
|
||||
service-worker cache behavior.
|
||||
|
||||
### Streaming reader intent
|
||||
|
||||
|
||||
@@ -2165,6 +2165,12 @@ async function loadSession(sid){
|
||||
S.activeStreamId=activeStreamId;
|
||||
const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
|
||||
const replayPersistedLiveToolCards=(opts)=>{
|
||||
// The journal-backed Anchor scene is authoritative through its resume
|
||||
// cursor; newer rows arrive through the reattached SSE stream. Replaying
|
||||
// the older INFLIGHT tool cache after a successful scene restore would
|
||||
// redraw all N rows N times. Keep the #3707 replay only for legacy HTML
|
||||
// restoration or a failed/unavailable scene render.
|
||||
if(restoredAnchorScene) return;
|
||||
const liveToolCalls=Array.isArray(S.toolCalls)
|
||||
? S.toolCalls
|
||||
: (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real page/loadSession/renderers with deterministic session and SSE fixtures.
|
||||
|
||||
No agent, provider, production state, or credentials. Tests DOM reconstruction,
|
||||
not a real provider/network reconnect.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from browser_conversation_lifecycle import _start_webui_server, _terminate_process
|
||||
|
||||
ROOT = Path(os.environ.get('WEBUI_TEST_ROOT', Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
def fixture(count):
|
||||
tools = [dict(name='terminal', tid=f'tool-{i}', args={'command': f'printf result-{i}'},
|
||||
preview=f'result-{i}', snippet=f'result-{i}', done=True) for i in range(count)]
|
||||
rows = [dict(row_id=f'tool:{t["tid"]}', local_id=t['tid'], role='tool', kind='tool_call',
|
||||
source_event_type='tool_complete', status='completed', order_index=i,
|
||||
tool=t, payload=t) for i, t in enumerate(tools)]
|
||||
return dict(stream_id='run-fixture', last_seq=1000, last_event_id='run-fixture:1000',
|
||||
messages=[], tool_calls=tools, last_assistant_text='', last_reasoning_text='',
|
||||
anchor_activity_scene=dict(version='activity_scene_v1',
|
||||
identity=dict(session_id='fixture', stream_id='run-fixture', run_id='run-fixture'),
|
||||
activity_rows=rows))
|
||||
|
||||
|
||||
INIT = """
|
||||
// A controlling service worker bypasses Playwright's page route fixtures in
|
||||
// WebKit. This test targets reconnect rendering, not PWA cache behavior.
|
||||
if(window===window.top && 'serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register=()=>Promise.reject(new Error('Disabled in reconnect harness'));
|
||||
}
|
||||
window.fixtureSources=[];
|
||||
class FixtureEventSource {
|
||||
static OPEN=1; static CONNECTING=0; static CLOSED=2;
|
||||
constructor(url){this.url=String(url);this.readyState=1;this.listeners={};window.fixtureSources.push(this);}
|
||||
addEventListener(name,fn){(this.listeners[name]||=[]).push(fn);}
|
||||
removeEventListener(){}
|
||||
close(){this.readyState=2;}
|
||||
emit(name,data,id){for(const fn of this.listeners[name]||[])fn({data:JSON.stringify(data),lastEventId:id||''});}
|
||||
}
|
||||
window.EventSource=FixtureEventSource;
|
||||
"""
|
||||
|
||||
|
||||
def session_route(session, sid, workspace):
|
||||
def handle(route):
|
||||
query = parse_qs(urlsplit(route.request.url).query)
|
||||
requested = query.get('session_id', [''])[0]
|
||||
data = session if requested == sid else dict(
|
||||
session_id='idle-fixture', messages=[], message_count=0,
|
||||
tool_calls=[], workspace=workspace)
|
||||
route.fulfill(json={'session': data})
|
||||
return handle
|
||||
|
||||
|
||||
def main():
|
||||
tool_count = int(os.environ.get('TOOL_COUNT', '470'))
|
||||
snapshot = fixture(tool_count)
|
||||
stream_id = snapshot['stream_id']
|
||||
sid = 'fixture'
|
||||
with tempfile.TemporaryDirectory(prefix='webui-reconnect-') as temp:
|
||||
state = Path(temp)
|
||||
env = {k:os.environ[k] for k in ('PATH','SYSTEMROOT','TMPDIR') if k in os.environ}
|
||||
env.update(HOME=temp, HERMES_HOME=temp, HERMES_BASE_HOME=temp,
|
||||
HERMES_WEBUI_STATE_DIR=str(state/'webui'), HERMES_CONFIG_PATH=str(state/'config.yaml'),
|
||||
HERMES_WEBUI_HOST='127.0.0.1', HERMES_WEBUI_SKIP_ONBOARDING='1',
|
||||
HERMES_WEBUI_AGENT_DIR=str(state/'no-agent'))
|
||||
proc, log, _, base = _start_webui_server(ROOT, env, state)
|
||||
results = []
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
for engine in os.environ.get('BROWSERS','chromium,webkit').split(','):
|
||||
browser = getattr(pw,engine).launch(headless=True)
|
||||
try:
|
||||
for mode in os.environ.get('MODES','compact_worklog,transparent_stream,hide_all_activity').split(','):
|
||||
for width in [1280,390]:
|
||||
context = browser.new_context(viewport={'width':width,'height':844},bypass_csp=True)
|
||||
context.add_init_script(INIT)
|
||||
page = context.new_page()
|
||||
|
||||
errors=[]
|
||||
page.on('pageerror',lambda e, errors=errors:errors.append(str(e)))
|
||||
session = dict(session_id=sid,title='Reconnect performance fixture',model='',
|
||||
workspace=temp,messages=[],message_count=0,tool_calls=[],
|
||||
active_stream_id=stream_id,pending_user_message='Inspect the fixture',
|
||||
pending_started_at=1,runtime_journal_snapshot=snapshot)
|
||||
page.route('**/api/session?*',session_route(session,sid,temp))
|
||||
page.route('**/api/chat/stream/status?*',lambda r:r.fulfill(json={'active':True}))
|
||||
page.goto(base,wait_until='load')
|
||||
# WebKit's wait_for_function uses eval, blocked by
|
||||
# the app CSP even with bypass_csp. Inspector
|
||||
# evaluation still works; wait on actual boot state.
|
||||
deadline=time.monotonic()+30
|
||||
while not page.evaluate("typeof loadSession==='function' && S._bootReady === true"):
|
||||
assert time.monotonic()<deadline, ('boot timeout', errors)
|
||||
page.wait_for_timeout(50)
|
||||
page.evaluate("""mode=>{
|
||||
window._chatActivityDisplayMode=mode;window._showThinking=true;
|
||||
window._simplifiedToolCalling=true;
|
||||
window.redrawCount=0;
|
||||
const render=window._renderLiveAnchorActivitySceneForStream;
|
||||
window._renderLiveAnchorActivitySceneForStream=function(...args){window.redrawCount++;return render(...args);};
|
||||
}""",mode)
|
||||
measured=page.evaluate("""async sid=>{
|
||||
const t=performance.now();await loadSession(sid);
|
||||
const loadMs=performance.now()-t;
|
||||
const deadline=performance.now()+5000;
|
||||
while(!fixtureSources.some(s=>s.url.includes('api/chat/stream?'))&&performance.now()<deadline){
|
||||
await new Promise(r=>setTimeout(r,10));
|
||||
}
|
||||
return {ms:loadMs,redraws:window.redrawCount,
|
||||
tools:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
busy:S.busy,
|
||||
sources:fixtureSources.filter(s=>s.url.includes('api/chat/stream?')).map(s=>s.url)};
|
||||
}""",sid)
|
||||
result=dict(engine=engine,mode=mode,width=width,
|
||||
**{k:v for k,v in measured.items() if k!='sources'})
|
||||
print(json.dumps(result),flush=True)
|
||||
results.append(result)
|
||||
if not os.environ.get('MEASURE_BASELINE'):
|
||||
assert measured['redraws']<=2,result
|
||||
expected=0 if mode=='hide_all_activity' else tool_count
|
||||
assert measured['tools']==expected,result
|
||||
assert measured['busy'],result
|
||||
assert measured['sources'], (result, errors)
|
||||
cursor=parse_qs(urlsplit(measured['sources'][-1]).query)
|
||||
assert cursor['after_seq']==[str(snapshot['last_seq'])], (result,errors)
|
||||
if mode!='hide_all_activity':
|
||||
# Subsequent real SSE handler updates still paint the existing owner.
|
||||
updated=page.evaluate("""({sid,stream})=>{
|
||||
const source=fixtureSources.findLast(s=>s.url.includes('api/chat/stream?')&&s.readyState===1);
|
||||
if(!source)throw new Error('missing chat stream');
|
||||
source.emit('tool',{name:'terminal',tid:'after-reconnect',args:{command:'printf later'},preview:'later'},stream+':1001');
|
||||
source.emit('tool_complete',{name:'terminal',tid:'after-reconnect',preview:'LATER RESULT',duration:1},stream+':1002');
|
||||
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
|
||||
}""",{'sid':sid,'stream':stream_id})
|
||||
assert updated=={'rows':expected+1,'result':True},updated
|
||||
switched=page.evaluate("""async sid=>{
|
||||
await loadSession('idle-fixture');
|
||||
await loadSession(sid);
|
||||
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
|
||||
}""",sid)
|
||||
assert switched==updated,switched
|
||||
artifact=os.environ.get('SCREENSHOT_DIR')
|
||||
if artifact:
|
||||
Path(artifact).mkdir(parents=True,exist_ok=True)
|
||||
page.screenshot(path=str(Path(artifact)/f'{engine}-{mode}-{width}.png'))
|
||||
assert not errors,errors
|
||||
context.close()
|
||||
finally:
|
||||
browser.close()
|
||||
output=os.environ.get('METRICS_FILE')
|
||||
if output:
|
||||
Path(output).write_text(json.dumps(results,indent=2))
|
||||
finally:
|
||||
_terminate_process(proc)
|
||||
log.close()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Execute the production reconnect replay seam, not a source-string assertion.
|
||||
|
||||
Browser coverage in browser_reconnect_scene_redraw.py exercises loadSession and
|
||||
real DOM rendering; these cheap Node checks pin fallback and redraw counts.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NODE = shutil.which('node')
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason='Node.js is required for the reconnect replay probe')
|
||||
|
||||
|
||||
def source_slice(text, start_marker, end_marker, path):
|
||||
start = text.find(start_marker)
|
||||
assert start >= 0, f'{path}: missing start marker {start_marker!r}'
|
||||
end = text.find(end_marker, start)
|
||||
assert end >= 0, f'{path}: missing end marker {end_marker!r}'
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def replay_probe(restored_scene, count, skip_unkeyed=True, has_rows=True):
|
||||
sessions = (ROOT / 'static/sessions.js').read_text()
|
||||
ui = (ROOT / 'static/ui.js').read_text()
|
||||
replay = source_slice(
|
||||
sessions, 'const liveToolReplayId=(tc)=>', ' let didReconnect=false;',
|
||||
'static/sessions.js',
|
||||
)
|
||||
append = source_slice(
|
||||
ui, 'function appendLiveToolCard(', '\nfunction _findLatestLiveAssistantByBurst',
|
||||
'static/ui.js',
|
||||
)
|
||||
script = r"""
|
||||
const vm=require('node:vm');
|
||||
const opts=JSON.parse(process.argv[1]);
|
||||
const tools=Array.from({length:opts.count},(_,i)=>({name:'terminal',tid:'tool-'+i,done:true}));
|
||||
tools.push({name:'read_file',done:true});
|
||||
let redraws=0, appended=[];
|
||||
const sandbox={
|
||||
S:{session:{session_id:'session'},activeStreamId:'run',toolCalls:tools},
|
||||
sid:'session',activeStreamId:'run',INFLIGHT:{},restoredAnchorScene:opts.restored_scene,
|
||||
document:{getElementById:()=>({querySelector:()=>opts.has_rows?{}:null})},
|
||||
isFinalAnswerOnlyMode:()=>false,
|
||||
isLiveAnchorActivitySceneOwner:()=>true,
|
||||
_renderLiveAnchorActivitySceneForStream:()=>{redraws++;return true;},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(APPEND,sandbox);
|
||||
// The actual append function proves the amplification when a scene owns DOM.
|
||||
// For legacy fallback, observe arguments at the append boundary instead.
|
||||
if(!opts.restored_scene) sandbox.appendLiveToolCard=(tc)=>appended.push(tc.tid||'unkeyed');
|
||||
vm.runInContext(REPLAY+'\nreplayPersistedLiveToolCards('+JSON.stringify({skipUnkeyedRestoredDuplicates:opts.skip_unkeyed})+');',sandbox);
|
||||
console.log(JSON.stringify({redraws,appended}));
|
||||
""".replace('APPEND', json.dumps(append)).replace('REPLAY', json.dumps(replay))
|
||||
result = subprocess.run(
|
||||
[NODE or 'node', '-e', script, json.dumps({
|
||||
'restored_scene': restored_scene, 'count': count,
|
||||
'skip_unkeyed': skip_unkeyed, 'has_rows': has_rows,
|
||||
})], capture_output=True, text=True, check=True,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('count', [0, 1, 470])
|
||||
def test_restored_complete_scene_is_not_redrawn_per_saved_tool(count):
|
||||
result = replay_probe(True, count)
|
||||
assert result['redraws'] == 0, result
|
||||
|
||||
|
||||
def test_non_anchor_restore_still_replays_keyed_tools():
|
||||
result = replay_probe(False, 3)
|
||||
assert result['appended'] == ['tool-0', 'tool-1', 'tool-2']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('skip_unkeyed,has_rows', [(False, True), (True, False)])
|
||||
def test_failed_scene_or_empty_html_keeps_unkeyed_fallback(skip_unkeyed, has_rows):
|
||||
result = replay_probe(False, 3, skip_unkeyed, has_rows)
|
||||
assert result['appended'] == ['tool-0', 'tool-1', 'tool-2', 'unkeyed']
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -7,7 +8,7 @@ from urllib.parse import quote
|
||||
|
||||
import api.config as api_config
|
||||
import api.routes as routes
|
||||
from api.updates import WEBUI_VERSION
|
||||
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -81,8 +82,12 @@ def test_service_worker_and_favicon_follow_selected_static_root(tmp_path, monkey
|
||||
monkeypatch.setattr(api_config, "get_static_root", lambda: static_root)
|
||||
|
||||
sw_handler = _get("/sw.js")
|
||||
# The route resolves api.updates dynamically on each request. Other update
|
||||
# tests deliberately evict and re-import that module, so a collection-time
|
||||
# alias can become stale even though the checkout and runtime module agree.
|
||||
webui_version = importlib.import_module("api.updates").WEBUI_VERSION
|
||||
expected = sw_path.read_text(encoding="utf-8").replace(
|
||||
"__WEBUI_VERSION__", quote(WEBUI_VERSION, safe="")
|
||||
"__WEBUI_VERSION__", quote(webui_version, safe="")
|
||||
).encode("utf-8")
|
||||
assert sw_handler.status == 200
|
||||
assert sw_handler.header("Service-Worker-Allowed") == "/"
|
||||
|
||||
Reference in new issue
Block a user