Merge pull request #5815 from nesquena/release/stage-5766
Release: render background wakeups as status rows (#5766)
This commit is contained in:
commit
a61d7bd3a6
4 changed files with 261 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2318,6 +2318,14 @@
|
|||
.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: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 */
|
||||
|
|
|
|||
43
static/ui.js
43
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=displayContent;
|
||||
if(!isUser&&_isAssistantEmptyPlaceholderContent(m, displayContent)){
|
||||
content='';
|
||||
}
|
||||
|
|
@ -14493,6 +14497,42 @@ 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=`<div class="msg-foot">${timeHtml}<span class="msg-actions">${copyBtn}</span></div>`;
|
||||
const processTextHtml=processText?`<pre class="process-wakeup-text">${esc(processText)}</pre>`:'';
|
||||
const nextRowHtml=`<div class="process-wakeup-notice"><div class="process-wakeup-label">${li('terminal',13)}<span>Background wakeup</span></div>${filesHtml}<div class="msg-body process-wakeup-body">${processTextHtml}</div>${processFootHtml}</div>`;
|
||||
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 +14540,7 @@ function renderMessages(options){
|
|||
const newRawText=String(displayContent).trim();
|
||||
const nextRowHtml=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||
if(row){
|
||||
row.className='msg-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
|
|
|
|||
209
tests/test_process_wakeup_rendering.py
Normal file
209
tests/test_process_wakeup_rendering.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""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<src.length; i++){
|
||||
if(src[i] === '(') depth++;
|
||||
else if(src[i] === ')'){
|
||||
depth--;
|
||||
if(depth === 0){ close = i; break; }
|
||||
}
|
||||
}
|
||||
const brace = src.indexOf('{', close);
|
||||
depth = 0;
|
||||
for(let i=brace; i<src.length; i++){
|
||||
if(src[i] === '{') depth++;
|
||||
else if(src[i] === '}'){
|
||||
depth--;
|
||||
if(depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(name + ' body did not close');
|
||||
}
|
||||
function msgContent(m){
|
||||
if(!m) return '';
|
||||
if(typeof m.content === 'string') return m.content;
|
||||
if(Array.isArray(m.content)) return m.content.map(p => (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('_stripWorkspaceDisplayPrefix'));
|
||||
eval(extractFunc('_stripAttachedFilesMarkerForDisplay'));
|
||||
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;
|
||||
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)),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
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_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="
|
||||
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 "${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
|
||||
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
|
||||
Loading…
Reference in a new issue