From e93b0c352ab391136935c60a87a9c82001f65d35 Mon Sep 17 00:00:00 2001 From: b3nw Date: Thu, 23 Jul 2026 15:52:51 -0500 Subject: [PATCH] feat: render process wakeups as collapsed summary cards (#6350) Render structured background-process completions and watch matches as accessible collapsed cards while preserving raw fallback, verbatim output, and full watch patterns. Co-authored-by: b3nw --- .gitignore | 3 + api/models.py | 4 +- api/process_event_utils.py | 100 ++++++++ api/routes.py | 5 +- api/streaming.py | 13 +- static/i18n.js | 15 ++ static/style.css | 27 +++ static/ui.js | 100 +++++++- tests/test_process_wakeup_card_rendering.py | 255 ++++++++++++++++++++ tests/test_wakeup_card_responsive.py | 123 ++++++++++ tests/test_wakeup_display_meta.py | 154 ++++++++++++ tests/test_wakeup_meta_recovery_paths.py | 174 +++++++++++++ 12 files changed, 958 insertions(+), 15 deletions(-) create mode 100644 tests/test_process_wakeup_card_rendering.py create mode 100644 tests/test_wakeup_card_responsive.py create mode 100644 tests/test_wakeup_display_meta.py create mode 100644 tests/test_wakeup_meta_recovery_paths.py diff --git a/.gitignore b/.gitignore index 6e91264d..0caf0aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ graphify-out/ node_modules/ package-lock.json docs/ui-ux/review-shots/ + +# Python build metadata (never commit generated egg-info) +*.egg-info/ diff --git a/api/models.py b/api/models.py index 3c0f660b..ede9790c 100644 --- a/api/models.py +++ b/api/models.py @@ -42,6 +42,7 @@ from api.agent_sessions import ( read_importable_agent_session_rows, read_session_lineage_metadata, ) +from api.process_event_utils import stamp_message_source logger = logging.getLogger(__name__) CLI_VISIBLE_SESSION_LIMIT = 20 @@ -919,8 +920,7 @@ def _append_recovered_pending_turn(session, *, timestamp: int | None = None) -> '_recovered': True, } pending_source = getattr(session, 'pending_user_source', None) - if pending_source and pending_source != 'webui': - recovered['_source'] = pending_source + stamp_message_source(recovered, pending_source) if session.pending_attachments: recovered['attachments'] = list(session.pending_attachments) session.messages.append(recovered) diff --git a/api/process_event_utils.py b/api/process_event_utils.py index 85cdc768..ccb8fcb0 100644 --- a/api/process_event_utils.py +++ b/api/process_event_utils.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import OrderedDict from dataclasses import dataclass import logging +import re import threading import time from typing import Any @@ -53,6 +54,105 @@ def completion_delivery_id(evt: Any) -> str: return str(evt.get("session_id") or "").strip() +# ── process-wakeup display metadata (#6345) ──────────────────────────────── +# Inverse of the two structured ``format_wakeup_prompt`` shapes (completion, +# watch_match). Those shapes are pinned by +# tests/test_background_process_wakeup_format.py; the other event kinds +# (watch_overflow/watch_disabled free-text, async_delegation agent-side +# formatter) intentionally return None so the UI keeps its raw fallback. +_WAKEUP_COMPLETION_RE = re.compile( + r"\A\[IMPORTANT: Background process (?P[^\n]*?) completed " + r"\(exit_code=(?P[^)\n]*)\)\.\n" + r"Command: (?P[^\n]*)\n" + r"Output:\n" +) +_WAKEUP_WATCH_MATCH_RE = re.compile( + r"\A\[IMPORTANT: Background process (?P[^\n]*?) matched watch pattern " + r"\"(?P.*)\"\.\n" + r"Command: (?P[^\n]*)\n" + r"Matched output:\n" +) + + +def wakeup_display_meta(text: Any) -> dict | None: + """Parse a ``format_wakeup_prompt`` body into display-only metadata. + + Returns ``{type, task_id, command, exit_code}`` for completion events and + ``{type, task_id, command, pattern}`` for watch matches, or None when the + text is not one of those pinned shapes. Header fields only — the output + section stays in the message body (the UI extracts it there), so the + metadata never duplicates multi-KB process output in the store. + + Header fields are anchored to the pinned single-line grammar (``sid``, + ``exit_code``, ``command``, ``pattern`` never contain newlines). The + optional watch suppression note is deliberately NOT parsed out: it lives in + the free-form output tail, where process output can contain the exact same + "(N earlier matches were suppressed…)" text, so inferring it from the body + would misclassify legitimate output and drop it. The note stays part of the + rendered output verbatim (#6350 review finding 2). + """ + body = str(text or "") + m = _WAKEUP_COMPLETION_RE.match(body) + if m: + exit_code: Any = m.group("exit_code") + try: + exit_code = int(exit_code) + except ValueError: + pass + return { + "type": "completion", + "task_id": m.group("sid"), + "command": m.group("cmd"), + "exit_code": exit_code, + } + m = _WAKEUP_WATCH_MATCH_RE.match(body) + if m: + return { + "type": "watch_match", + "task_id": m.group("sid"), + "command": m.group("cmd"), + "pattern": m.group("pattern"), + } + return None + + +def attach_wakeup_display_meta(msg: Any, source: Any) -> None: + """Stamp ``_wakeup_meta`` on a process-wakeup user message, best-effort. + + Companion to the ``_source`` stamp: display-only (``_wakeup_meta`` is not + in ``_API_SAFE_MSG_KEYS``, so it never reaches a provider) and never + raises — an unparseable body simply leaves the message unstamped and the + UI falls back to parsing/raw rendering. + """ + if source != "process_wakeup" or not isinstance(msg, dict): + return + if msg.get("_wakeup_meta"): + return + try: + meta = wakeup_display_meta(msg.get("content")) + except Exception: + logger.debug("wakeup display-meta derivation failed", exc_info=True) + return + if meta: + msg["_wakeup_meta"] = meta + + +def stamp_message_source(msg: Any, source: Any) -> None: + """Stamp ``_source`` and any display metadata on a materialized user turn. + + Single choke point for every path that persists a non-``webui`` user turn + (result merges, eager checkpoint, and the pending-turn recovery paths) so a + future source-bearing recovery site cannot silently skip the ``_wakeup_meta`` + stamp — the gap #6350 review flagged in ``_append_recovered_pending_turn`` + and the cancel outer-finally recovery. ``webui`` turns are left untouched to + preserve the existing "``_source`` omitted for the default source" contract. + """ + if not isinstance(msg, dict) or not source or source == "webui": + return + msg["_source"] = source + attach_wakeup_display_meta(msg, source) + + def _claim_bounded_local(delegation_id: str) -> bool: with _LEGACY_ASYNC_DELIVERY_LOCK: if delegation_id in _LEGACY_ASYNC_DELIVERY_IDS: diff --git a/api/routes.py b/api/routes.py index 420ffa16..d97aa979 100644 --- a/api/routes.py +++ b/api/routes.py @@ -20887,8 +20887,9 @@ def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, st if latest_text == msg_text: return user_msg = {"role": "user", "content": msg} - if source and source != "webui": - user_msg["_source"] = source + from api.process_event_utils import stamp_message_source + + stamp_message_source(user_msg, source) if isinstance(started_at, (int, float)) and started_at > 0: user_msg["timestamp"] = int(started_at) if attachments: diff --git a/api/streaming.py b/api/streaming.py index 6b891280..27432500 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -71,6 +71,7 @@ from api.process_event_utils import ( release_async_delegation_delivery, requeue_async_delegation_event, schedule_async_delegation_claim_retry, + stamp_message_source, ) @@ -5896,8 +5897,7 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex # exchange and then clear the pending prompt. Materialize the current # turn at the transcript boundary before the assistant/tool response. current_user_msg = {'role': 'user', 'content': msg_text} - if source and source != 'webui': - current_user_msg['_source'] = source + stamp_message_source(current_user_msg, source) insert_at = 0 while insert_at < len(candidates) and _is_context_compression_marker(candidates[insert_at]): insert_at += 1 @@ -5958,8 +5958,7 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex ): display_msg = copy.deepcopy(msg) display_msg['content'] = msg_text - if source and source != 'webui': - display_msg['_source'] = source + stamp_message_source(display_msg, source) merged.append(copy.deepcopy(display_msg)) if key is not None: seen.add(key) @@ -6475,8 +6474,7 @@ def _materialize_pending_user_turn_before_error(session) -> bool: 'timestamp': recovered_ts, '_recovered': True, } - if pending_source != 'webui': - recovered['_source'] = pending_source + stamp_message_source(recovered, pending_source) if pending_attachments: recovered['attachments'] = pending_attachments session.messages.append(recovered) @@ -11433,8 +11431,7 @@ def cancel_stream(stream_id: str) -> bool: 'content': _pending_user, 'timestamp': _recovered_ts, } - if _pending_source and _pending_source != 'webui': - _user_turn['_source'] = _pending_source + stamp_message_source(_user_turn, _pending_source) if _pending_atts: _user_turn['attachments'] = _pending_atts _msgs_for_recovery.append(_user_turn) diff --git a/static/i18n.js b/static/i18n.js index b35423f8..978bdd8d 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -365,6 +365,7 @@ const LOCALES = { bg_no_answer:'(no answer)', bg_failed:'Background task failed: ', process_wakeup_label:'Background wakeup', + process_wakeup_matched:'Watch pattern matched', undo_exchange:'Undo last exchange', cmd_status:'Show session info', cmd_voice:'Toggle microphone input', @@ -2126,6 +2127,7 @@ const LOCALES = { bg_no_answer:'(nessuna risposta)', bg_failed:'Task in background fallito: ', process_wakeup_label:'Riattivazione in background', + process_wakeup_matched:'Pattern di osservazione rilevato', undo_exchange:'Annulla ultimo scambio', cmd_status:'Mostra info sessione', cmd_voice:'Attiva/disattiva input microfono', @@ -3869,6 +3871,7 @@ const LOCALES = { bg_no_answer:'(回答なし)', bg_failed:'バックグラウンドタスク失敗: ', process_wakeup_label:'バックグラウンド起動', + process_wakeup_matched:'監視パターンに一致', undo_exchange:'最後のやり取りを取り消す', cmd_status:'セッション情報を表示', cmd_voice:'マイク入力を切り替え', @@ -5541,6 +5544,7 @@ const LOCALES = { cmd_background: 'Запустить промпт в фоне', cmd_background_usage: '/background <промпт> — запустить параллельно, не блокируя чат', process_wakeup_label: 'Фоновое пробуждение', + process_wakeup_matched: 'Совпадение с шаблоном наблюдения', no_personalities: 'Личности не найдены (добавьте их в ~/.hermes/personalities/)', clarify_heading: 'Требуется уточнение', @@ -7300,6 +7304,7 @@ const LOCALES = { composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', process_wakeup_label: 'Activación en segundo plano', + process_wakeup_matched: 'Patrón de observación coincidente', no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)', available_personalities: 'Personalidades disponibles:', personality_switch_hint: '\n\nUsa `/personality ` para cambiar, o `/personality none` para limpiar.', @@ -8958,6 +8963,7 @@ const LOCALES = { composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', process_wakeup_label: 'Hintergrund-Aktivierung', + process_wakeup_matched: 'Überwachungsmuster erkannt', no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)', available_personalities: 'Verfügbare Persönlichkeiten:', personality_switch_hint: '\n\nNutze `/personality ` zum Wechseln, oder `/personality none` zum Löschen.', @@ -10671,6 +10677,7 @@ const LOCALES = { composer_placeholder_busy_interrupt: 'Enter = 中断 | /queue | /background | /steer', composer_placeholder_busy_steer: 'Enter = 引导 | /queue | /background | /interrupt', process_wakeup_label: '后台唤醒', + process_wakeup_matched: '监视模式已匹配', settings_plugins_title: '插件', plugins_enable_toggle: '启用', settings_plugins_meta: '查看已安装的 Hermes 插件及其注册的生命周期挂钩。此面板为只读。', @@ -12388,6 +12395,7 @@ const LOCALES = { bg_no_answer: '(沒有回答)', bg_failed: '背景任務失敗:', process_wakeup_label: '背景喚醒', + process_wakeup_matched: '監視模式已匹配', undo_exchange: '復原上一組問答', cmd_status: '顯示對話資訊', cmd_voice: '切換麥克風輸入', @@ -14057,6 +14065,7 @@ const LOCALES = { bg_no_answer: '(sem resposta)', bg_failed: 'Tarefa de background falhou: ', process_wakeup_label: 'Reativação em background', + process_wakeup_matched: 'Padrão de observação correspondido', undo_exchange: 'Desfazer última troca', cmd_status: 'Mostrar info da sessão', cmd_voice: 'Alternar input de microfone', @@ -15677,6 +15686,7 @@ const LOCALES = { bg_no_answer: '(답변 없음)', bg_failed: '백그라운드 작업 실패: ', process_wakeup_label: '백그라운드 깨우기', + process_wakeup_matched: '감시 패턴 일치', undo_exchange: '마지막 교환 실행 취소', cmd_status: '세션 정보 표시', cmd_voice: '마이크 입력 전환', @@ -17421,6 +17431,7 @@ const LOCALES = { bg_no_answer: '(aucune réponse)', bg_failed: 'Échec de la tâche en arrière-plan : ', process_wakeup_label: 'Réveil en arrière-plan', + process_wakeup_matched: 'Motif de surveillance détecté', undo_exchange: 'Annuler le dernier échange', cmd_status: 'Afficher les informations de la session', cmd_voice: 'Activer/désactiver la saisie microphone', @@ -20000,6 +20011,7 @@ const LOCALES = { bg_complete: 'Úkol na pozadí dokončen', bg_failed: 'Úkol na pozadí se nezdařil:', process_wakeup_label: 'Probuzení na pozadí', + process_wakeup_matched: 'Sledovaný vzor nalezen', bg_label: 'Výsledek na pozadí:', bg_no_answer: 'Žádná odpověď', bg_running: 'Běží na pozadí...', @@ -20849,6 +20861,7 @@ const LOCALES = { bg_no_answer: '(cevap yok)', bg_failed: 'Arka plan görevi başarısız oldu:', process_wakeup_label: 'Arka plan uyandırması', + process_wakeup_matched: 'İzleme deseni eşleşti', undo_exchange: 'Son değişimi geri al', cmd_status: 'Oturum bilgilerini göster', cmd_voice: 'Mikrofon girişini değiştir', @@ -22595,6 +22608,7 @@ const LOCALES = { bg_no_answer: '(brak odpowiedzi)', bg_failed: 'Zadanie w tle nie powiodło się: ', process_wakeup_label: 'Wybudzenie w tle', + process_wakeup_matched: 'Dopasowano wzorzec obserwacji', undo_exchange: 'Cofnij ostatnią wymianę', cmd_status: 'Pokaż informacje o sesji', cmd_voice: 'Przełącz wejście mikrofonu', @@ -24299,6 +24313,7 @@ const LOCALES = { bg_no_answer:'(không có câu trả lời)', bg_failed:'Tác vụ nền thất bại: ', process_wakeup_label:'Đánh thức nền', + process_wakeup_matched:'Đã khớp mẫu theo dõi', undo_exchange:'Hoàn tác lượt trao đổi cuối', cmd_status:'Hiển thị thông tin phiên', cmd_voice:'Bật/tắt nhập microphone', diff --git a/static/style.css b/static/style.css index 3787bbc4..0f06df06 100644 --- a/static/style.css +++ b/static/style.css @@ -2334,6 +2334,33 @@ .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;}} + /* #6345: collapsed summary-card variant for parseable wakeups. */ + .process-wakeup-notice.process-wakeup-fail{border-left-color:var(--error,#f85149);} + .process-wakeup-card{margin:0;} + /* Wrap rather than clip when the row can't fit one line: keeps the label, + chip, and timestamp inside the card at narrow widths (#6350 review finding 3). */ + .process-wakeup-card>summary{list-style:none;cursor:pointer;display:flex;align-items:center;flex-wrap:wrap;gap:6px 8px;min-width:0;} + .process-wakeup-card>summary::-webkit-details-marker{display:none;} + .process-wakeup-toggle{display:inline-flex;flex:0 0 auto;color:var(--muted);transition:transform .15s ease;} + .process-wakeup-card[open]>summary .process-wakeup-toggle{transform:rotate(90deg);} + .process-wakeup-summary .process-wakeup-label{margin-bottom:0;flex:0 0 auto;} + .process-wakeup-summary .msg-time{margin-left:auto;flex:0 0 auto;} + .process-wakeup-cmd{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:11.5px;color:var(--text);background:transparent;padding:0;} + .process-wakeup-chip{display:inline-flex;align-items:center;gap:4px;padding:1px 7px;border-radius:999px;font-size:10.5px;font-weight:600;border:1px solid var(--border);color:var(--muted);flex:0 0 auto;white-space:nowrap;} + .process-wakeup-chip svg{width:11px;height:11px;flex:0 0 auto;} + .process-wakeup-chip.ok{color:var(--success,#3fb950);border-color:currentColor;} + .process-wakeup-chip.fail{color:var(--error,#f85149);border-color:currentColor;} + /* Watch chip must shrink so a long pattern truncates instead of pushing the + row past the card edge on narrow viewports (#6350 review finding 3). */ + .process-wakeup-chip.watch{flex:0 1 auto;min-width:0;} + .process-wakeup-chip.watch code{flex:0 1 auto;min-width:0;font-family:var(--font-mono);font-size:10.5px;background:transparent;padding:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .process-wakeup-detail{margin-top:8px;} + .process-wakeup-cmd-row,.process-wakeup-pattern-row{margin-bottom:6px;display:flex;gap:6px;align-items:baseline;flex-wrap:wrap;} + .process-wakeup-detail-key{font-size:10.5px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;color:var(--muted);flex:0 0 auto;} + .process-wakeup-cmd-row code,.process-wakeup-pattern-row code{font-family:var(--font-mono);font-size:11.5px;color:var(--text);background:transparent;padding:0;overflow-wrap:anywhere;word-break:break-word;min-width:0;} + .process-wakeup-detail pre.process-wakeup-text{max-height:320px;overflow:auto;} + /* Guarantee a >=44px disclosure touch target under the mobile breakpoint (#6350 review finding 3). */ + @media(max-width:700px){.process-wakeup-card>summary{min-height:44px;}} /* 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 bcd551b8..71de6a9a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15608,6 +15608,71 @@ function _maybeRecoverVirtualizedBlankViewport(options, preserveScroll, virtualW return true; } +// #6345: parse the synthetic wakeup body back into display fields. Mirrors the +// two structured api/background_process.format_wakeup_prompt shapes (pinned by +// tests/test_background_process_wakeup_format.py); other event kinds return +// null and keep the raw-notice fallback. +function _parseProcessWakeupBody(text){ + const s=String(text||''); + // Header groups are single-line by grammar; the output group captures the + // rest verbatim (leading indentation / trailing blank lines preserved). The + // watch suppression note is intentionally NOT split out of the output — real + // process output can contain the identical text, so stripping it would drop + // legitimate content (#6350 review finding 2). It rides along in `output`. + let m=s.match(/^\[IMPORTANT: Background process ([^\n]*?) completed \(exit_code=([^)\n]*)\)\.\nCommand: ([^\n]*)\nOutput:\n([\s\S]*)\]$/); + if(m) return {type:'completion',taskId:m[1],exitCode:m[2],command:m[3],output:m[4],pattern:null}; + m=s.match(/^\[IMPORTANT: Background process ([^\n]*?) matched watch pattern "(.*)"\.\nCommand: ([^\n]*)\nMatched output:\n([\s\S]*)\]$/); + if(m) return {type:'watch_match',taskId:m[1],pattern:m[2],command:m[3],output:m[4],exitCode:null}; + return null; +} +// Server-stamped _wakeup_meta (authoritative when present) merged over the +// client parse; the output section only ever comes from the parse because the +// meta deliberately carries header fields only. +function _processWakeupInfo(m, text){ + const parsed=_parseProcessWakeupBody(text); + const meta=(m&&m._wakeup_meta&&typeof m._wakeup_meta==='object')?m._wakeup_meta:null; + if(!parsed&&!meta) return null; + const pick=(metaKey,parsedKey)=>{ + if(meta&&meta[metaKey]!=null) return meta[metaKey]; + return parsed?parsed[parsedKey]:null; + }; + return { + type:String(pick('type','type')||''), + taskId:String(pick('task_id','taskId')||''), + command:String(pick('command','command')||''), + exitCode:pick('exit_code','exitCode'), + pattern:pick('pattern','pattern'), + output:parsed?parsed.output:null, + }; +} +function _processWakeupCardHtml(info, rawText, extras){ + const isWatch=info.type==='watch_match'; + const exitStr=info.exitCode==null?'':String(info.exitCode); + // Signal-killed processes report negative exit codes (subprocess returncode). + const exitKnown=/^-?\d+$/.test(exitStr); + const exitOk=exitStr==='0'; + let chip; + if(isWatch){ + chip=`${li('eye',11)}${esc(String(info.pattern||''))}`; + }else{ + const cls=exitOk?'ok':(exitKnown?'fail':'neutral'); + const icon=exitOk?li('check',11):(exitKnown?li('x',11):''); + chip=`${icon}exit ${esc(exitStr||'?')}`; + } + const cmdHtml=info.command?`${esc(info.command)}`:''; + // Preserve output byte-for-byte for the
; trim ONLY for the
+  // empty/non-empty decision so leading indentation and trailing blank lines
+  // survive (#6350 review finding 1).
+  const outRaw=info.output!=null?String(info.output):String(rawText||'');
+  const outHtml=outRaw.trim()?`
${esc(outRaw)}
`:''; + const cmdRow=info.command?`
${esc(info.command)}
`:''; + // The collapsed watch chip truncates the pattern; surface the full, + // wrapping value in the expanded detail so touch/keyboard users can read it + // without relying on a hover tooltip (#6350 review finding 4). + const patternRow=(isWatch&&info.pattern)?`
${esc(t('process_wakeup_matched'))}${esc(String(info.pattern))}
`:''; + return `
${li('chevron-right',12)}${li('terminal',13)}${esc(t('process_wakeup_label'))}${cmdHtml}${chip}${extras.timeHtml||''}
${extras.filesHtml||''}${patternRow}${cmdRow}
${outHtml}
${extras.footHtml||''}
`; +} + function renderMessages(options){ _lastMessageRenderAt=performance.now(); const preserveScroll=!!(options&&options.preserveScroll); @@ -16114,8 +16179,22 @@ 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 processTextHtml=processText?`
${esc(processText)}
`:''; - const nextRowHtml=`
${li('terminal',13)}${esc(t('process_wakeup_label'))}
${filesHtml}
${processTextHtml}
${processFootHtml}
`; + // #6345: structured completions/watch-matches render as a collapsed + // summary card; anything unparseable keeps the raw notice below so the + // fallback is never worse than the old full-text dump. + const wakeupInfo=_processWakeupInfo(m, processText); + let noticeClass='process-wakeup-notice'; + let noticeInnerHtml; + if(wakeupInfo){ + noticeClass+=' process-wakeup-notice-card'; + const exitStr=wakeupInfo.exitCode==null?'':String(wakeupInfo.exitCode); + if(wakeupInfo.type==='completion'&&/^-?\d+$/.test(exitStr)&&exitStr!=='0') noticeClass+=' process-wakeup-fail'; + noticeInnerHtml=_processWakeupCardHtml(wakeupInfo, processText, {timeHtml, filesHtml, footHtml:`
${copyBtn}
`}); + }else{ + const processTextHtml=processText?`
${esc(processText)}
`:''; + noticeInnerHtml=`
${li('terminal',13)}${esc(t('process_wakeup_label'))}
${filesHtml}
${processTextHtml}
${processFootHtml}`; + } + const nextRowHtml=`
${noticeInnerHtml}
`; if(row){ row.className='msg-row process-wakeup-row'; row.id=_userMessageDomId(rawIdx); @@ -16124,9 +16203,23 @@ function renderMessages(options){ row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m); row.dataset.role='process_wakeup'; delete row.dataset.editing; - if(row.dataset.rawText!==processText||row.innerHTML!==nextRowHtml){ + // Compare against the HTML we last SET (expando), not live innerHTML: + // a user-expanded
serializes an open attribute into + // innerHTML, which would force a rebuild-and-collapse on every + // streaming rerender. The expando comparison is + // serialization-independent while still rebuilding when the markup + // genuinely changes (locale/timestamp format); open state is + // user-driven, so it is captured and restored across rebuilds. + if(row.dataset.rawText!==processText||row._wakeupRenderedHtml!==nextRowHtml){ + const _priorCard=row.querySelector&&row.querySelector('details.process-wakeup-card'); + const _wasOpen=!!(_priorCard&&_priorCard.open); row.dataset.rawText=processText; + row._wakeupRenderedHtml=nextRowHtml; row.innerHTML=nextRowHtml; + if(_wasOpen){ + const _card=row.querySelector('details.process-wakeup-card'); + if(_card) _card.open=true; + } } }else{ row=document.createElement('div'); @@ -16137,6 +16230,7 @@ function renderMessages(options){ row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m); row.dataset.role='process_wakeup'; row.dataset.rawText=processText; + row._wakeupRenderedHtml=nextRowHtml; row.innerHTML=nextRowHtml; } inner.appendChild(row); diff --git a/tests/test_process_wakeup_card_rendering.py b/tests/test_process_wakeup_card_rendering.py new file mode 100644 index 00000000..1cd5f3c4 --- /dev/null +++ b/tests/test_process_wakeup_card_rendering.py @@ -0,0 +1,255 @@ +"""#6345 — collapsed summary-card rendering for process-wakeup messages. + +Behavioral coverage for the three ui.js helpers behind the card: +``_parseProcessWakeupBody`` (client inverse of ``format_wakeup_prompt``), +``_processWakeupInfo`` (server ``_wakeup_meta`` merged over the client parse), +and ``_processWakeupCardHtml`` (the ``
`` card markup). Structural +integration with the render loop is pinned by +tests/test_process_wakeup_rendering.py. +""" + +import json +import re +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/g,'>').replace(/"/g,'"'); +} +function li(name, size){ return ''; } +function t(key, ...args){ + let out = key; + if(args.length) out += ':' + args.join(','); + return out; +} + +eval(extractFunc('_parseProcessWakeupBody')); +eval(extractFunc('_processWakeupInfo')); +eval(extractFunc('_processWakeupCardHtml')); + +const okBody = '[IMPORTANT: Background process proc_1 completed (exit_code=0).\nCommand: npm run build\nOutput:\nall good]'; +const failBody = '[IMPORTANT: Background process proc_2 completed (exit_code=3).\nCommand: pytest -q\nOutput:\n1 failed]'; +const signalBody = '[IMPORTANT: Background process proc_3 completed (exit_code=-9).\nCommand: sleep 999\nOutput:\n]'; +const watchBody = '[IMPORTANT: Background process w1 matched watch pattern "ERROR.*timeout".\nCommand: tail -f app.log\nMatched output:\nERROR request timeout\n(3 earlier matches were suppressed by rate limit)]'; +const htmlBody = '[IMPORTANT: Background process p completed (exit_code=0).\nCommand: echo ""\nOutput:\nbold]'; +// Finding 1: leading indentation + trailing blank lines must survive. +const wsBody = '[IMPORTANT: Background process p completed (exit_code=0).\nCommand: build\nOutput:\n indented line\n\n]'; +// Finding 2: output that legitimately ends with the suppression phrasing must +// be preserved intact (not lifted into a suppression field and dropped). +const supLikeBody = '[IMPORTANT: Background process w9 matched watch pattern "ERR".\nCommand: tail\nMatched output:\nreal log\n(3 earlier matches were suppressed by rate limit)]'; + +const okInfo = _processWakeupInfo({}, okBody); +const failInfo = _processWakeupInfo({}, failBody); +const signalInfo = _processWakeupInfo({}, signalBody); +const watchInfo = _processWakeupInfo({}, watchBody); +const htmlInfo = _processWakeupInfo({}, htmlBody); +const wsInfo = _processWakeupInfo({}, wsBody); +const supLikeInfo = _processWakeupInfo({}, supLikeBody); +const metaOnlyInfo = _processWakeupInfo( + {_wakeup_meta: {type: 'completion', task_id: 'srv_1', command: 'cargo test', exit_code: 1}}, + 'some future format the client parser does not know' +); +const metaOverParse = _processWakeupInfo( + {_wakeup_meta: {type: 'completion', task_id: 'authoritative', command: 'npm run build', exit_code: 0}}, + okBody +); + +const extras = {timeHtml: '14:32', filesHtml: '', footHtml: '
'}; + +process.stdout.write(JSON.stringify({ + okInfo, failInfo, watchInfo, metaOnlyInfo, + metaOverParseTaskId: metaOverParse.taskId, + unparseableIsNull: _processWakeupInfo({}, 'plain text') === null, + emptyIsNull: _processWakeupInfo({content: ''}, '') === null, + wsInfoOutput: wsInfo.output, + supLikeInfo, + okCard: _processWakeupCardHtml(okInfo, okBody, extras), + failCard: _processWakeupCardHtml(failInfo, failBody, extras), + signalCard: _processWakeupCardHtml(signalInfo, signalBody, extras), + watchCard: _processWakeupCardHtml(watchInfo, watchBody, extras), + htmlCard: _processWakeupCardHtml(htmlInfo, htmlBody, extras), + wsCard: _processWakeupCardHtml(wsInfo, wsBody, extras), + supLikeCard: _processWakeupCardHtml(supLikeInfo, supLikeBody, extras), + metaOnlyCard: _processWakeupCardHtml(metaOnlyInfo, 'some future format the client parser does not know', extras), +})); +""" + + +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_client_parser_mirrors_the_two_structured_wakeup_shapes(): + result = _run_driver() + + ok = result["okInfo"] + assert ok["type"] == "completion" + assert ok["taskId"] == "proc_1" + assert ok["command"] == "npm run build" + assert ok["exitCode"] == "0" + assert ok["output"] == "all good" + + watch = result["watchInfo"] + assert watch["type"] == "watch_match" + assert watch["pattern"] == "ERROR.*timeout" + assert watch["command"] == "tail -f app.log" + # The suppression note is part of the output tail, never a separate field. + assert watch["output"] == "ERROR request timeout\n(3 earlier matches were suppressed by rate limit)" + assert "suppressed" not in watch + + assert result["unparseableIsNull"] is True + assert result["emptyIsNull"] is True + + +def test_output_whitespace_is_preserved_byte_for_byte(): + # #6350 finding 1: leading indentation and trailing blank lines must not be + # trimmed away in the rendered
.
+    result = _run_driver()
+    assert result["wsInfoOutput"] == "    indented line\n\n"
+    ws_card = result["wsCard"]
+    assert "
    indented line\n\n
" in ws_card + + +def test_output_that_looks_like_suppression_metadata_is_kept_in_full(): + # #6350 finding 2: adversarial output ending with the suppression phrasing + # is preserved verbatim, with no suppression field inferred/deleted. + result = _run_driver() + sup = result["supLikeInfo"] + assert sup["output"] == "real log\n(3 earlier matches were suppressed by rate limit)" + assert "suppressed" not in sup + assert "(3 earlier matches were suppressed by rate limit)" in result["supLikeCard"] + + +def test_server_meta_is_authoritative_and_covers_unparseable_bodies(): + result = _run_driver() + + meta_only = result["metaOnlyInfo"] + assert meta_only["taskId"] == "srv_1" + assert meta_only["command"] == "cargo test" + assert meta_only["exitCode"] == 1 + assert meta_only["output"] is None + + assert result["metaOverParseTaskId"] == "authoritative" + + # With no parsable output section the detail falls back to the raw body. + assert "some future format" in result["metaOnlyCard"] + + +def test_card_markup_collapsed_by_default_with_exit_chip(): + result = _run_driver() + + ok_card = result["okCard"] + assert ok_card.startswith('
') + assert "open" not in ok_card.split(">", 1)[0] + assert 'class="process-wakeup-chip ok"' in ok_card + assert "exit 0" in ok_card + assert "npm run build" in ok_card + assert "[IMPORTANT" not in ok_card + assert 'data-icon="chevron-right"' in ok_card + assert 'data-icon="terminal"' in ok_card + assert "process_wakeup_label" in ok_card + assert '14:32' in ok_card + + fail_card = result["failCard"] + assert 'class="process-wakeup-chip fail"' in fail_card + assert "exit 3" in fail_card + + # Signal-killed processes report negative returncodes; still a failure. + signal_card = result["signalCard"] + assert 'class="process-wakeup-chip fail"' in signal_card + assert "exit -9" in signal_card + + watch_card = result["watchCard"] + assert 'class="process-wakeup-chip watch"' in watch_card + assert "ERROR.*timeout" in watch_card + # No separate suppression line anymore; the note rides in the output pre. + assert "process_wakeup_suppressed" not in watch_card + # Finding 4: the full pattern is rendered in the expanded detail (a + # dedicated pattern row), not only inside the collapsed chip's hover title. + assert "process-wakeup-pattern-row" in watch_card + # Pattern appears twice: once in the collapsed chip, once in the detail row. + assert watch_card.count("ERROR.*timeout") >= 2 + + +def test_card_escapes_command_and_output(): + result = _run_driver() + + html_card = result["htmlCard"] + assert "