From fa04044e7cda93a702287470142736094b83d607 Mon Sep 17 00:00:00 2001 From: Isla-Liu Date: Wed, 8 Jul 2026 09:38:58 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:[WebUI]=20=E4=BF=AE=E6=AD=A3=20backgrou?= =?UTF-8?q?nd=20wakeup=20=E8=A8=8A=E6=81=AF=E6=B8=B2=E6=9F=93=E9=82=8A?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/style.css | 7 + static/ui.js | 42 +++++- tests/test_process_wakeup_rendering.py | 175 +++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 tests/test_process_wakeup_rendering.py diff --git a/static/style.css b/static/style.css index a178d697..63cb9f2f 100644 --- a/static/style.css +++ b/static/style.css @@ -2318,6 +2318,13 @@ .provider-error-details{margin:12px 0 0;border:1px solid var(--border);border-radius:10px;background:var(--surface);overflow:hidden;} .provider-error-details>summary{cursor:pointer;color:var(--muted);font-size:12px;font-weight:600;padding:8px 12px;} .provider-error-details>pre{margin:0;border:0;border-top:1px solid var(--border);border-radius:0;max-height:220px;} + .process-wakeup-row{padding:6px 0;} + .process-wakeup-notice{margin-left:30px;max-width:680px;padding:8px 10px;border:1px solid var(--border);border-left:2px solid var(--accent-bg-strong);border-radius:9px;background:var(--surface-subtle,var(--hover-bg));color:var(--muted);} + .process-wakeup-label{display:flex;align-items:center;gap:6px;margin-bottom:5px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--accent-text);} + .process-wakeup-label svg{width:13px;height:13px;flex:0 0 auto;} + .process-wakeup-row .msg-body.process-wakeup-body{padding-left:0;max-width:none;color:var(--muted);} + .process-wakeup-row .msg-body pre.process-wakeup-text{margin:0;padding:0;border:0;border-radius:0;background:transparent;color:var(--muted);font-family:var(--font-mono);font-size:12px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;} + .process-wakeup-row .msg-foot{padding-left:0;margin-top:5px;} /* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */ .msg-body pre[class*="language-"],.msg-body pre code[class*="language-"]{background:var(--code-bg) !important;} /* Fix #1463: Prism YAML grammar collapses newlines inside token spans — force pre */ diff --git a/static/ui.js b/static/ui.js index b804fc3e..35c69fb1 100644 --- a/static/ui.js +++ b/static/ui.js @@ -526,6 +526,7 @@ const MESSAGE_VIRTUAL_THRESHOLD_ROWS=80; const MESSAGE_VIRTUAL_BUFFER_PX=900; const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={ user:120, + process_wakeup:96, assistant:160, tool_call:400, default:140, @@ -616,7 +617,7 @@ function _cancelMessageVirtualizedRender(){ } function _messageIsRenderable(m){ if(!m||!m.role||m.role==='tool') return false; - if(m._source === 'process_wakeup') return false; + if(m._source === 'process_wakeup') return !!(msgContent(m)||m.attachments?.length); if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) return false; if(_isRecoveryControlMessage(m)) return false; const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; @@ -811,6 +812,7 @@ function _syncMessageVirtualHeightCache(visWithIdx){ function _messageVirtualRoleForEntry(entry){ const m=entry&&entry.m; if(!m) return 'default'; + if(m._source === 'process_wakeup') return 'process_wakeup'; if(m.role==='user') return 'user'; if(m.role==='assistant'){ if((Array.isArray(m.tool_calls)&&m.tool_calls.length>0)|| @@ -14419,11 +14421,13 @@ function renderMessages(options){ } } } + const isProcessWakeup=m&&m._source==='process_wakeup'; const isUser=m.role==='user'; if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m)){ content='**Error:** No response received after context compression. Please retry.'; } const displayContent=isUser?_stripAttachedFilesMarkerForDisplay(_stripWorkspaceDisplayPrefix(content)):content; + const rowDisplayContent=isProcessWakeup?content:displayContent; if(!isUser&&_isAssistantEmptyPlaceholderContent(m, displayContent)){ content=''; } @@ -14493,6 +14497,41 @@ function renderMessages(options){ continue; } + if(isProcessWakeup){ + currentAssistantTurn=null; + let row=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null; + if(row&&(!row.classList.contains('msg-row')||row.classList.contains('assistant-turn'))) row=null; + const processText=String(rowDisplayContent||'').trim(); + const processFootHtml=`
${timeHtml}${copyBtn}
`; + const nextRowHtml=`
${li('terminal',13)}Background wakeup
${esc(processText)}
${processFootHtml}
`; + if(row){ + row.className='msg-row process-wakeup-row'; + row.id=_userMessageDomId(rawIdx); + row.dataset.msgIdx=rawIdx; + row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx); + row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m); + row.dataset.role='process_wakeup'; + delete row.dataset.editing; + if(row.dataset.rawText!==processText||row.innerHTML!==nextRowHtml){ + row.dataset.rawText=processText; + row.innerHTML=nextRowHtml; + } + }else{ + row=document.createElement('div'); + row.className='msg-row process-wakeup-row'; + row.id=_userMessageDomId(rawIdx); + row.dataset.msgIdx=rawIdx; + row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx); + row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m); + row.dataset.role='process_wakeup'; + row.dataset.rawText=processText; + row.innerHTML=nextRowHtml; + } + inner.appendChild(row); + userRows.set(rawIdx, row); + continue; + } + if(isUser){ currentAssistantTurn=null; let row=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null; @@ -14500,6 +14539,7 @@ function renderMessages(options){ const newRawText=String(displayContent).trim(); const nextRowHtml=`${filesHtml}
${bodyHtml}
${footHtml}`; if(row){ + row.className='msg-row'; row.id=_userMessageDomId(rawIdx); row.dataset.msgIdx=rawIdx; row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx); diff --git a/tests/test_process_wakeup_rendering.py b/tests/test_process_wakeup_rendering.py new file mode 100644 index 00000000..d7df1bcf --- /dev/null +++ b/tests/test_process_wakeup_rendering.py @@ -0,0 +1,175 @@ +"""Regression coverage for process-wakeup transcript rendering. + +A background-process wakeup is stored as a synthetic user turn +(`_source: "process_wakeup"`). It must be visible by default, but it must not +look like a human-authored chat bubble. Keeping it in the visible message list +also preserves the user-turn boundary between the assistant response that +preceded the notification and the assistant response produced by the wakeup. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS_PATH = ROOT / "static" / "ui.js" +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +_DRIVER = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[1], 'utf8'); +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 (p && (p.text || p.content)) || '').join('\n'); + return String(m.content || ''); +} +function _isContextCompactionMessage(){ return false; } +function _isPreservedCompressionTaskListMessage(){ return false; } +function _isRecoveryControlMessage(){ return false; } +function _messageHasReasoningPayload(){ return false; } +function _assistantMessageHasVisibleContent(m){ return !!String(msgContent(m)).trim(); } + +global.window = {}; +global.S = {messages: []}; +let _visWithIdxCache = null; +let _visWithIdxCacheLen = 0; +let _visWithIdxCacheSrc = null; + +const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT'); +const heightEnd = src.indexOf('const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS', heightStart); +if(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd)); +if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage')); +eval(extractFunc('_messageIsRenderable')); +eval(extractFunc('_getVisibleMessagesWithIdx')); +eval(extractFunc('_messageVirtualRoleForEntry')); + +const wakeup = { + role: 'user', + content: '[IMPORTANT: Background process proc_123 completed (exit_code=0).\nCommand: sleep 1\nOutput:\ndone]', + _source: 'process_wakeup', + timestamp: 1783405253.72, +}; +S.messages = [ + {role: 'assistant', content: 'previous assistant report', timestamp: 1783405252.05}, + wakeup, + {role: 'assistant', content: 'assistant response to wakeup', timestamp: 1783405254.10}, +]; + +const visible = _getVisibleMessagesWithIdx(); +const turns = []; +let current = []; +for(const entry of visible){ + const source = entry.m._source || ''; + if(entry.m.role === 'user'){ + if(current.length) turns.push(current); + turns.push(['user:' + source + ':' + String(entry.m.content).slice(0, 35)]); + current = []; + }else if(entry.m.role === 'assistant'){ + current.push('assistant:' + entry.m.content); + } +} +if(current.length) turns.push(current); +const virtualRole = _messageVirtualRoleForEntry({m: wakeup}); +const virtualHeight = typeof _messageVirtualDefaultHeightForRole === 'function' + ? _messageVirtualDefaultHeightForRole(virtualRole) + : null; + +process.stdout.write(JSON.stringify({ + visible: visible.map(e => ({rawIdx: e.rawIdx, role: e.m.role, source: e.m._source || '', text: String(e.m.content).slice(0, 32)})), + turns, + virtualRole, + virtualHeight, +})); +""" + + +def _run_driver(): + assert NODE is not None + proc = subprocess.run( + [NODE, "-e", _DRIVER, str(UI_JS_PATH)], + text=True, + capture_output=True, + timeout=30, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_process_wakeup_message_stays_visible_and_preserves_turn_boundary(): + result = _run_driver() + + assert result["visible"] == [ + {"rawIdx": 0, "role": "assistant", "source": "", "text": "previous assistant report"}, + {"rawIdx": 1, "role": "user", "source": "process_wakeup", "text": "[IMPORTANT: Background process p"}, + {"rawIdx": 2, "role": "assistant", "source": "", "text": "assistant response to wakeup"}, + ] + assert result["turns"] == [ + ["assistant:previous assistant report"], + ["user:process_wakeup:[IMPORTANT: Background process proc"], + ["assistant:assistant response to wakeup"], + ] + + +def test_process_wakeup_has_its_own_virtual_height_role(): + result = _run_driver() + + assert result["virtualRole"] == "process_wakeup" + assert isinstance(result["virtualHeight"], int) + assert 1 <= result["virtualHeight"] <= 120 + + +def test_process_wakeup_uses_compact_status_row_not_normal_user_bubble(): + ui = UI_JS_PATH.read_text(encoding="utf-8") + marker = "const isProcessWakeup=" + marker_idx = ui.find(marker) + assert marker_idx != -1, "render loop must classify process-wakeup messages" + process_branch_idx = ui.find("if(isProcessWakeup)", marker_idx) + user_branch_idx = ui.find("if(isUser)", marker_idx) + + assert process_branch_idx != -1, "process-wakeup render branch missing" + assert user_branch_idx != -1, "normal user render branch missing" + assert process_branch_idx < user_branch_idx, ( + "process-wakeup rows must render through the compact status branch " + "before the normal user-bubble branch" + ) + process_branch = ui[process_branch_idx:user_branch_idx] + assert "process-wakeup-row" in process_branch + assert "process-wakeup-notice" in process_branch + assert "data-role='process_wakeup'" in process_branch or "dataset.role='process_wakeup'" in process_branch + + assert ".process-wakeup-row" in STYLE_CSS + assert ".process-wakeup-notice" in STYLE_CSS + assert ".process-wakeup-text" in STYLE_CSS From d1345e6ed03acc942c32b6a5af7e66a172d4f196 Mon Sep 17 00:00:00 2001 From: Isla-Liu Date: Wed, 8 Jul 2026 09:52:23 +0800 Subject: [PATCH 2/4] fix: handle process wakeup attachments --- static/ui.js | 5 +++-- tests/test_process_wakeup_rendering.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/static/ui.js b/static/ui.js index 35c69fb1..6f21f9a8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14427,7 +14427,7 @@ function renderMessages(options){ content='**Error:** No response received after context compression. Please retry.'; } const displayContent=isUser?_stripAttachedFilesMarkerForDisplay(_stripWorkspaceDisplayPrefix(content)):content; - const rowDisplayContent=isProcessWakeup?content:displayContent; + const rowDisplayContent=displayContent; if(!isUser&&_isAssistantEmptyPlaceholderContent(m, displayContent)){ content=''; } @@ -14503,7 +14503,8 @@ function renderMessages(options){ if(row&&(!row.classList.contains('msg-row')||row.classList.contains('assistant-turn'))) row=null; const processText=String(rowDisplayContent||'').trim(); const processFootHtml=`
${timeHtml}${copyBtn}
`; - const nextRowHtml=`
${li('terminal',13)}Background wakeup
${esc(processText)}
${processFootHtml}
`; + const processTextHtml=processText?`
${esc(processText)}
`:''; + const nextRowHtml=`
${li('terminal',13)}Background wakeup
${filesHtml}
${processTextHtml}
${processFootHtml}
`; if(row){ row.className='msg-row process-wakeup-row'; row.id=_userMessageDomId(rawIdx); diff --git a/tests/test_process_wakeup_rendering.py b/tests/test_process_wakeup_rendering.py index d7df1bcf..403c4b76 100644 --- a/tests/test_process_wakeup_rendering.py +++ b/tests/test_process_wakeup_rendering.py @@ -71,6 +71,8 @@ const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT'); const heightEnd = src.indexOf('const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS', heightStart); if(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd)); if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage')); +eval(extractFunc('_stripWorkspaceDisplayPrefix')); +eval(extractFunc('_stripAttachedFilesMarkerForDisplay')); eval(extractFunc('_messageIsRenderable')); eval(extractFunc('_getVisibleMessagesWithIdx')); eval(extractFunc('_messageVirtualRoleForEntry')); @@ -105,12 +107,26 @@ const virtualRole = _messageVirtualRoleForEntry({m: wakeup}); const virtualHeight = typeof _messageVirtualDefaultHeightForRole === 'function' ? _messageVirtualDefaultHeightForRole(virtualRole) : null; +const attachmentOnlyWakeup = { + role: 'user', + content: '', + _source: 'process_wakeup', + attachments: [{name: 'result.txt'}], +}; +const markerWakeupContent = [ + '[Workspace::v1: /tmp/hermes]', + 'Visible wakeup text', + '', + '[Attached files: result.txt]', +].join(String.fromCharCode(10)); process.stdout.write(JSON.stringify({ visible: visible.map(e => ({rawIdx: e.rawIdx, role: e.m.role, source: e.m._source || '', text: String(e.m.content).slice(0, 32)})), turns, virtualRole, virtualHeight, + attachmentOnlyRenderable: _messageIsRenderable(attachmentOnlyWakeup), + strippedWakeupDisplay: _stripAttachedFilesMarkerForDisplay(_stripWorkspaceDisplayPrefix(markerWakeupContent)), })); """ @@ -151,6 +167,13 @@ def test_process_wakeup_has_its_own_virtual_height_role(): assert 1 <= result["virtualHeight"] <= 120 +def test_attachment_only_process_wakeup_is_visible_and_display_markers_are_stripped(): + result = _run_driver() + + assert result["attachmentOnlyRenderable"] is True + assert result["strippedWakeupDisplay"] == "Visible wakeup text" + + def test_process_wakeup_uses_compact_status_row_not_normal_user_bubble(): ui = UI_JS_PATH.read_text(encoding="utf-8") marker = "const isProcessWakeup=" @@ -169,6 +192,9 @@ def test_process_wakeup_uses_compact_status_row_not_normal_user_bubble(): assert "process-wakeup-row" in process_branch assert "process-wakeup-notice" in process_branch assert "data-role='process_wakeup'" in process_branch or "dataset.role='process_wakeup'" in process_branch + assert "${filesHtml}" in process_branch + assert "const rowDisplayContent=displayContent;" in ui + assert "const rowDisplayContent=isProcessWakeup?content:displayContent;" not in ui assert ".process-wakeup-row" in STYLE_CSS assert ".process-wakeup-notice" in STYLE_CSS From bef8fbd3b67259a733dfa67e407f389612b671ff Mon Sep 17 00:00:00 2001 From: Isla-Liu Date: Wed, 8 Jul 2026 11:34:50 +0800 Subject: [PATCH 3/4] fix: align wakeup notice with message rail --- static/style.css | 3 ++- tests/test_process_wakeup_rendering.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/static/style.css b/static/style.css index 63cb9f2f..16031711 100644 --- a/static/style.css +++ b/static/style.css @@ -2319,12 +2319,13 @@ .provider-error-details>summary{cursor:pointer;color:var(--muted);font-size:12px;font-weight:600;padding:8px 12px;} .provider-error-details>pre{margin:0;border:0;border-top:1px solid var(--border);border-radius:0;max-height:220px;} .process-wakeup-row{padding:6px 0;} - .process-wakeup-notice{margin-left:30px;max-width:680px;padding:8px 10px;border:1px solid var(--border);border-left:2px solid var(--accent-bg-strong);border-radius:9px;background:var(--surface-subtle,var(--hover-bg));color:var(--muted);} + .process-wakeup-notice{margin:8px 0 8px var(--msg-rail);max-width:min(var(--msg-max),760px);padding:8px 10px;border:1px solid var(--border);border-left:2px solid var(--accent-bg-strong);border-radius:9px;background:var(--surface-subtle,var(--hover-bg));color:var(--muted);} .process-wakeup-label{display:flex;align-items:center;gap:6px;margin-bottom:5px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--accent-text);} .process-wakeup-label svg{width:13px;height:13px;flex:0 0 auto;} .process-wakeup-row .msg-body.process-wakeup-body{padding-left:0;max-width:none;color:var(--muted);} .process-wakeup-row .msg-body pre.process-wakeup-text{margin:0;padding:0;border:0;border-radius:0;background:transparent;color:var(--muted);font-family:var(--font-mono);font-size:12px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;} .process-wakeup-row .msg-foot{padding-left:0;margin-top:5px;} + @media(max-width:700px){.process-wakeup-notice{margin-left:0;}} /* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */ .msg-body pre[class*="language-"],.msg-body pre code[class*="language-"]{background:var(--code-bg) !important;} /* Fix #1463: Prism YAML grammar collapses newlines inside token spans — force pre */ diff --git a/tests/test_process_wakeup_rendering.py b/tests/test_process_wakeup_rendering.py index 403c4b76..051a3845 100644 --- a/tests/test_process_wakeup_rendering.py +++ b/tests/test_process_wakeup_rendering.py @@ -199,3 +199,11 @@ def test_process_wakeup_uses_compact_status_row_not_normal_user_bubble(): assert ".process-wakeup-row" in STYLE_CSS assert ".process-wakeup-notice" in STYLE_CSS assert ".process-wakeup-text" in STYLE_CSS + notice_rule = STYLE_CSS[ + STYLE_CSS.index(".process-wakeup-notice{") : STYLE_CSS.index(".process-wakeup-label{") + ] + assert "margin:8px 0 8px var(--msg-rail)" in notice_rule + assert "max-width:min(var(--msg-max),760px)" in notice_rule + assert "margin-left:30px" not in notice_rule + assert "max-width:680px" not in notice_rule + assert "@media(max-width:700px){.process-wakeup-notice{margin-left:0;}}" in STYLE_CSS From 8c96d42be3b94b85af7246699e40a30bc2a226e9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 9 Jul 2026 03:22:27 +0000 Subject: [PATCH 4/4] docs(changelog): #5766 background wakeup status rows --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d66e2f6..fcd2f1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **A background wakeup no longer eats the assistant reply that preceded it.** When a background-process wakeup prompt arrived after an assistant response, the response could disappear from the interactive transcript (it remained in the DB and export). Background wakeups now render as their own distinct "Background wakeup" status row aligned to the message rail — with attachment support — leaving the prior reply intact. Thanks @Isla-Liu. (#5766) + - **Service-tier (fast-mode) support is now derived from the agent's model metadata instead of a hardcoded list.** The service-tier control appears for a model only when the agent reports it supports the fast tier, so newly-supported models light up automatically and unsupported ones don't show a dead control. Falls back safely (no control) when the metadata is absent. Thanks @starship-s. (#5762, #4536) - **Live reasoning no longer briefly renders twice while a reply streams.** The streaming path updated the reasoning display through two renderers at once (the anchored reasoning block and the live thinking card), which could momentarily show the reasoning twice. It now routes through a single renderer — the live thinking card only updates when the anchored path didn't handle it. Thanks @rodboev. (#5773, #5720)