"""Regression tests for issue #3820 chat activity display mode.""" import json import shutil import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") NODE = shutil.which("node") _EXTRACT_FUNC_JS = """ function extractFunc(name){ const start = src.indexOf('function ' + name); if(start === -1) throw new Error(name + ' not found'); const params = src.indexOf('(', start); let depth = 0, close = -1; for(let i=params; i true; eval(extractFunc('chatActivityMode')); eval(extractFunc('isTransparentStream')); eval(extractFunc('isFinalAnswerOnlyMode')); eval(extractFunc('isCompactWorklogMode')); const resolverResults = [ chatActivityMode(), isTransparentStream(), isFinalAnswerOnlyMode(), isCompactWorklogMode(), ]; window._chatActivityDisplayMode = 'transparent_stream'; window._transparentStream = false; resolverResults.push( chatActivityMode(), isTransparentStream(), isFinalAnswerOnlyMode(), isCompactWorklogMode(), ); window._chatActivityDisplayMode = 'bogus'; window._transparentStream = true; resolverResults.push( chatActivityMode(), isTransparentStream(), isFinalAnswerOnlyMode(), isCompactWorklogMode(), ); process.stdout.write(JSON.stringify({{resolverResults}})); """ result = _run_node_script(script) assert result["resolverResults"] == [ "hide_all_activity", False, True, False, "transparent_stream", True, False, False, "transparent_stream", True, False, False, ] def test_chat_activity_display_mode_explicit_modes_are_preserved_by_render_helpers(): script = f""" const fs = require('fs'); const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); {_EXTRACT_FUNC_JS} let captured = []; function _projectLiveAnchorActivitySceneForStream(streamId, mode){{ return {{version:'activity_scene_v1', mode, activity_rows:[]}}; }} function renderLiveAnchorActivityScene(streamId, scene, opts){{ captured.push({{streamId, sceneMode: scene.mode, optMode: opts.mode, sessionId: opts.sessionId}}); return false; }} let activeMode = 'compact_worklog'; global.chatActivityMode = () => activeMode; eval(extractFunc('_renderLiveAnchorActivitySceneForStream')); eval(extractFunc('_renderLiveAnchorActivitySceneSnapshotForStream')); const helperResult = _renderLiveAnchorActivitySceneForStream('stream-1', 'sid-1', {{mode:'hide_all_activity'}}); const snapshotResult = _renderLiveAnchorActivitySceneSnapshotForStream( 'stream-2', {{version:'activity_scene_v1', activity_rows:[]}}, 'sid-2', {{mode:'transparent_stream'}}, ); activeMode = 'hide_all_activity'; const compactOverrideResult = _renderLiveAnchorActivitySceneForStream( 'stream-3', 'sid-3', {{mode:'compact_worklog'}}, ); process.stdout.write(JSON.stringify({{helperResult, snapshotResult, compactOverrideResult, captured}})); """ result = _run_node_script(script) assert result["helperResult"] is False assert result["snapshotResult"] is False assert result["compactOverrideResult"] is False assert result["captured"] == [ {"streamId": "stream-1", "sceneMode": "hide_all_activity", "optMode": "hide_all_activity", "sessionId": "sid-1"}, {"streamId": "stream-2", "optMode": "transparent_stream", "sessionId": "sid-2"}, {"streamId": "stream-3", "sceneMode": "hide_all_activity", "optMode": "compact_worklog", "sessionId": "sid-3"}, ] def test_chat_activity_display_mode_live_renderer_early_outs_in_hide_all_mode(): script = f""" const fs = require('fs'); const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); {_EXTRACT_FUNC_JS} global.window = {{ _chatActivityDisplayMode: 'hide_all_activity', _transparentStream: false, }}; global.S = {{ session: {{ session_id: 'sid-1' }}, activeStreamId: 'stream-1' }}; global.isSimplifiedToolCalling = () => true; global.$ = () => {{ throw new Error('unexpected DOM access'); }}; eval(extractFunc('chatActivityMode')); eval(extractFunc('isTransparentStream')); eval(extractFunc('isFinalAnswerOnlyMode')); eval(extractFunc('isCompactWorklogMode')); eval(extractFunc('renderLiveAnchorActivityScene')); const result = renderLiveAnchorActivityScene( 'stream-1', {{version:'activity_scene_v1', activity_rows:[{{role:'tool'}}]}}, {{sessionId:'sid-1', mode:'hide_all_activity'}}, ); process.stdout.write(JSON.stringify({{result, mode: chatActivityMode(), finalOnly: isFinalAnswerOnlyMode(), compact: isCompactWorklogMode()}})); """ result = _run_node_script(script) assert result == {"result": False, "mode": "hide_all_activity", "finalOnly": True, "compact": False} def test_chat_activity_display_mode_anchor_scene_preserves_hide_all_mode(): script = f""" const fs = require('fs'); const vm = require('vm'); const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "assistant_turn_anchors.js"))}, 'utf8'); const sandbox = {{window:{{}}}}; vm.createContext(sandbox); vm.runInContext(src, sandbox, {{filename:'assistant_turn_anchors.js'}}); const api = sandbox.window.HermesAssistantTurnAnchors; const empty = api.projectAssistantTurnAnchorActivityScene(null, {{mode:'hide_all_activity'}}); const registry = api.createAssistantTurnAnchorRegistry({{session_id:'sid-1', turn_id:'turn-1'}}); api.applyAssistantTurnAnchorSourceEvents(registry, [ {{event:'tool', payload:{{tool_call_id:'tool-1', name:'terminal'}}, event_id:'run-1:1', seq:1}}, {{source_type:'settled_message', payload:{{role:'assistant', id:'message-final', content:'final answer'}}}}, ], {{run_id:'run-1', stream_id:'stream-1'}}); const scene = api.projectAssistantTurnAnchorActivityScene(registry, {{mode:'hide_all_activity'}}); const snapshot = api.createAssistantTurnAnchorRendererSnapshot({{mode:'hide_all_activity', rows:[]}}); process.stdout.write(JSON.stringify({{emptyMode:empty.mode, sceneMode:scene.mode, snapshotMode:snapshot.mode}})); """ result = _run_node_script(script) assert result == { "emptyMode": "hide_all_activity", "sceneMode": "hide_all_activity", "snapshotMode": "hide_all_activity", } def test_chat_activity_display_mode_legacy_live_fallbacks_do_not_render_activity(): script = f""" const fs = require('fs'); const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); {_EXTRACT_FUNC_JS} global.S = {{ session: {{ session_id: 'sid-1' }}, activeStreamId: 'stream-1' }}; global.isFinalAnswerOnlyMode = () => true; global.$ = () => {{ throw new Error('unexpected DOM access'); }}; eval(extractFunc('appendLiveToolCard')); eval(extractFunc('ensureLiveWorklogShell')); const appendResult = appendLiveToolCard({{tid:'tool-1'}}, {{sessionId:'sid-1', streamId:'stream-1'}}); const shellResult = ensureLiveWorklogShell(); process.stdout.write(JSON.stringify({{appendResult: appendResult === undefined, shellResult}})); """ result = _run_node_script(script) assert result == {"appendResult": True, "shellResult": None} def test_chat_activity_display_mode_legacy_thinking_fallback_does_not_render_activity(): script = f""" const fs = require('fs'); const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); {_EXTRACT_FUNC_JS} global.isFinalAnswerOnlyMode = () => true; global.$ = () => {{ throw new Error('unexpected DOM access'); }}; eval(extractFunc('appendThinking')); eval(extractFunc('updateThinking')); const appendResult = appendThinking('reasoning text', {{pending:true}}); const updateResult = updateThinking('reasoning text', {{pending:true}}); process.stdout.write(JSON.stringify({{appendResult: appendResult === undefined, updateResult: updateResult === undefined}})); """ result = _run_node_script(script) assert result == {"appendResult": True, "updateResult": True} def test_chat_activity_display_mode_settled_hide_all_scene_persists_without_worklog(): start = MESSAGES_JS.index("function _attachProjectedAnchorSceneToLastAssistant(") end = MESSAGES_JS.index("function _upsertAnchorProcessProse", start) block = MESSAGES_JS[start:end] persist_index = block.index("_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);") return_index = block.rfind("return hasWorklogRows;") assert "const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene);" in block assert "const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene);" in block assert ( "const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity'||hasOwnedOutcomes;" in block ) assert persist_index < return_index assert "return true;" not in block def test_chat_activity_display_mode_switch_to_final_only_clears_existing_live_activity(): script = f""" const fs = require('fs'); const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "panels.js"))}, 'utf8'); {_EXTRACT_FUNC_JS} let cleanupCalls = 0; const select = {{value:''}}; const buttons = [ {{mode:'compact_worklog'}}, {{mode:'transparent_stream'}}, {{mode:'hide_all_activity'}}, ].map(({{mode}}) => ({{ getAttribute: (name) => name === 'data-chat-activity-mode' ? mode : null, classList: {{toggle(){{}}}}, setAttribute(){{}}, }})); global.window = {{_hideLiveActivityForFinalAnswerOnly(){{ cleanupCalls += 1; }}}}; global.document = {{querySelectorAll: () => buttons}}; global.$ = (id) => id === 'settingsChatActivityDisplayMode' ? select : null; eval(extractFunc('_syncChatActivityDisplayModeControl')); _syncChatActivityDisplayModeControl('transparent_stream'); const afterTransparent = {{mode: window._chatActivityDisplayMode, transparent: window._transparentStream, cleanupCalls}}; _syncChatActivityDisplayModeControl('hide_all_activity'); const afterHide = {{mode: window._chatActivityDisplayMode, transparent: window._transparentStream, cleanupCalls, selectValue: select.value}}; process.stdout.write(JSON.stringify({{afterTransparent, afterHide}})); """ result = _run_node_script(script) assert result == { "afterTransparent": {"mode": "transparent_stream", "transparent": True, "cleanupCalls": 0}, "afterHide": {"mode": "hide_all_activity", "transparent": False, "cleanupCalls": 1, "selectValue": "hide_all_activity"}, } def test_chat_activity_display_mode_live_cleanup_removes_existing_activity_rows(): start = UI_JS.index("function _hideLiveActivityForFinalAnswerOnly(){") end = UI_JS.index("function _removeEmptyLiveWorklogShells", start) block = UI_JS[start:end] assert "clearLiveToolCards();" in block assert "removeThinking" in block assert ".transparent-event-row" in block assert "#liveRunStatus" in block assert "window._hideLiveActivityForFinalAnswerOnly=_hideLiveActivityForFinalAnswerOnly" in UI_JS def test_transparent_stream_live_branch_uses_direct_rows(): decorator_start = UI_JS.index("function _decorateTransparentEventRow(row, opts){") decorator_end = UI_JS.index("function _setTransparentRowsExpanded", decorator_start) decorator_block = UI_JS[decorator_start:decorator_end] assert "data-transparent-event-row" in decorator_block assert "transparent-event-status" in decorator_block # Title carries the simple tool name (short form for MCP); status text # is only shown for non-default states. assert "_toolShortName(name)" in decorator_block assert "if(status==='Completed'){" in decorator_block assert "function _syncTransparentEventControls" in UI_JS assert "function _transparentEventCountLabel" in UI_JS assert "data-transparent-tool-count" in UI_JS assert "data-tool-count" in UI_JS # Transparent mode labels the event controls as a trace, not a Worklog # Activity summary. assert "return toolCount?`Trace: ${toolCount} ${toolCount===1?'tool':'tools'}`:'Trace';" in UI_JS assert "return `Activity: ${toolCount} ${toolCount===1?'tool':'tools'}`" not in UI_JS assert "Activity: ${parts.join(' · ')}" not in UI_JS # The summary must not list thinking count (DESIGN.md:75 forbids a second # trailing count badge, and thinking rows render as their own rows). assert "thinkingCount" not in _transparentEventCountLabelBlock(UI_JS) assert "function _setTransparentRowsExpanded" in UI_JS assert "function _setTransparentCardOpen" in UI_JS assert "function _wireTransparentHeaderToggle" in UI_JS assert "function _transparentToolDetailHtml" in UI_JS assert "function _setTransparentDetailMode" in UI_JS assert "transparent-detail-modes" in UI_JS # Spec R4: key on own line, value on next line, no inline pair. assert 'class="tool-arg-pair"' in UI_JS assert 'class="tool-arg-key"' in UI_JS assert 'class="tool-arg-val"' in UI_JS # Spec R5: controls are span[role=button], not